diff options
Diffstat (limited to 'internal/game')
32 files changed, 281 insertions, 113 deletions
diff --git a/internal/game/act_clean.go b/internal/game/act_clean.go index 4efe293..0540cb1 100644 --- a/internal/game/act_clean.go +++ b/internal/game/act_clean.go @@ -68,7 +68,7 @@ func (g *Game) advanceClean(sess *net.Session, p *player.Player) { } consumeID := plan[0].id - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == consumeID { slot.ItemID = matchItem.ID diff --git a/internal/game/act_farm.go b/internal/game/act_farm.go index 6725351..e83beb1 100644 --- a/internal/game/act_farm.go +++ b/internal/game/act_farm.go @@ -257,7 +257,7 @@ func (g *Game) advanceHarvest(sess *net.Session, p *player.Player) { noRoom := false if stackable { quality, maxQuality := stackProps(productDef) - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { s := p.InvSlot(i) // Only a stack with the properties a fresh harvest stack gets // may absorb it; merging into a differently qualified stack @@ -275,7 +275,7 @@ func (g *Game) advanceHarvest(sess *net.Session, p *player.Player) { // One slot per unit: the clamp and the placement loop must count // slots the same way (a nil 0..27 slot). freeSlots := 0 - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { if p.InvSlot(i) == nil { freeSlots++ } diff --git a/internal/game/act_gather.go b/internal/game/act_gather.go index 3bef8a0..df0ae49 100644 --- a/internal/game/act_gather.go +++ b/internal/game/act_gather.go @@ -417,7 +417,7 @@ func (g *Game) checkBirdNest(sess *net.Session, p *player.Player, cfg *behavior. } freeSlot := -1 - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { if i == currentDropSlot { continue } diff --git a/internal/game/act_identify.go b/internal/game/act_identify.go index 6ff4d61..89bef35 100644 --- a/internal/game/act_identify.go +++ b/internal/game/act_identify.go @@ -29,7 +29,7 @@ func (g *Game) advanceIdentify(sess *net.Session, p *player.Player) { totalJunk := scrapCount * multiplier hasExistingStack := false - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == junkID { hasExistingStack = true @@ -42,7 +42,7 @@ func (g *Game) advanceIdentify(sess *net.Session, p *player.Player) { return } - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == "scrap" { p.SetInvSlot(i, nil) @@ -50,7 +50,7 @@ func (g *Game) advanceIdentify(sess *net.Session, p *player.Player) { } placed := false - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == junkID { slot.Quantity += totalJunk diff --git a/internal/game/act_steal.go b/internal/game/act_steal.go index 5a7f93e..7df400b 100644 --- a/internal/game/act_steal.go +++ b/internal/game/act_steal.go @@ -42,7 +42,7 @@ var stallGuardTalk = &behavior.TalkConfig{ }, "jail": { Messages: []string{"Off to the detention cell with you!"}, - Action: &behavior.Step{Teleport: 99999162}, + Action: &behavior.Step{Teleport: jailRoomID}, Options: []behavior.TalkOption{{Text: "(You are dragged away)"}}, }, "fight": { diff --git a/internal/game/casino.go b/internal/game/casino.go index 971952a..736add2 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -489,42 +489,47 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { sess.WriteLine("You're not playing a game.") return } - // Table bet syntax: bet | bet <amount> [on] [spot] | bet min|max [on] [spot]. - // Tokens that parse as an amount (or min/max) set the wager; any other - // token is the bet target and the table game validates it. - amount, spot, hasAmount := 0, "", false + // Table bet syntax: bet | bet <amount> [on] <target> | bet min|max [on] <target>. + // BEFORE an "on" keyword, a token that parses as an amount (or min/max) + // sets the wager and any other token is the bet target; AFTER "on", the + // next token is always the target. That makes numeric targets reachable + // ("bet 5 on 17"), which roulette, craps, and keno need. + amount, spot, hasAmount, sawOn := 0, "", false, false for _, arg := range args { if strings.EqualFold(arg, "on") { + sawOn = true continue } - if strings.EqualFold(arg, "min") { - if hasAmount { - sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.") - return - } - amount, hasAmount = table.Config.MinBet, true - continue - } - if strings.EqualFold(arg, "max") { - if hasAmount { - sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.") - return + if !sawOn { + if strings.EqualFold(arg, "min") { + if hasAmount { + sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.") + return + } + amount, hasAmount = table.Config.MinBet, true + continue } - amount = min(table.Config.MaxBet, casinoAffordable(p)) - if amount < 1 { - sess.WriteLine("You don't have any chips or credits to bet.") - return + if strings.EqualFold(arg, "max") { + if hasAmount { + sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.") + return + } + amount = min(table.Config.MaxBet, casinoAffordable(p)) + if amount < 1 { + sess.WriteLine("You don't have any chips or credits to bet.") + return + } + hasAmount = true + continue } - hasAmount = true - continue - } - if value, err := strconv.Atoi(arg); err == nil && value > 0 { - if hasAmount { - sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.") - return + if value, err := strconv.Atoi(arg); err == nil && value > 0 { + if hasAmount { + sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.") + return + } + amount, hasAmount = value, true + continue } - amount, hasAmount = value, true - continue } if spot != "" { sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.") @@ -579,12 +584,12 @@ func (g *Game) CasinoTick() { func (g *Game) emitCasinoEvents(events []casino.Event) { for _, event := range events { - if event.Message == "" && event.PrivateMessage == "" && event.Menu == nil && event.Blackjack == nil && event.Baccarat == nil && event.Slot == nil { + if event.Message == "" && event.PrivateMessage == "" && event.Menu == nil && event.Snapshot == nil { continue } category := event.Color if category == "" { - category = casinoEventColor(event.Type) + category = "casino_action" } if event.Public && g.Hub != nil { for _, sess := range g.Hub.PlayersInRoom(event.RoomID) { @@ -592,14 +597,8 @@ func (g *Game) emitCasinoEvents(events []casino.Event) { continue } message := event.Message - if event.Slot != nil && sess.Player != nil { - message += "\n" + renderSlotSnapshot(event.Slot, g.colorMode(sess), sess.Player.OptionBool("unicode")) - } - if event.Blackjack != nil && sess.Player != nil { - message += "\n" + renderBlackjackSnapshot(event.Blackjack, g.colorMode(sess), sess.Player.OptionBool("unicode"), sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt) - } - if event.Baccarat != nil && sess.Player != nil { - message += "\n" + renderBaccaratSnapshot(event.Baccarat, g.colorMode(sess), sess.Player.OptionBool("unicode"), sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt) + if rendered := g.renderCasinoSnapshot(sess, event.Snapshot); rendered != "" { + message += "\n" + rendered } sess.WriteLine(g.colorize(sess, category, message)) } @@ -621,11 +620,8 @@ func (g *Game) emitCasinoEvents(events []casino.Event) { continue } privateMessage := g.casinoPrivateMessage(sess, category, event.Menu, text) - if event.Blackjack != nil && sess.Player != nil { - privateMessage += "\n" + renderBlackjackSnapshot(event.Blackjack, g.colorMode(sess), sess.Player.OptionBool("unicode"), sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt) - } - if event.Baccarat != nil && sess.Player != nil { - privateMessage += "\n" + renderBaccaratSnapshot(event.Baccarat, g.colorMode(sess), sess.Player.OptionBool("unicode"), sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt) + if rendered := g.renderCasinoSnapshot(sess, event.Snapshot); rendered != "" { + privateMessage += "\n" + rendered } sess.WriteLine(g.renderCasinoMessage(sess, category, event.Menu, privateMessage)) if category == "casino_menu" { @@ -643,29 +639,42 @@ func (g *Game) renderCasinoMessage(sess *net.Session, category string, menu *cas return g.colorize(sess, category, message) } -func casinoEventColor(eventType casino.EventType) string { - if eventType == casino.EventPayout { - return "casino_win" - } - return "casino_action" -} - func (g *Game) casinoPrivateMessage(sess *net.Session, category string, menu *casino.MenuView, message string) string { if category != "casino_menu" || menu == nil { return message } - chips := g.colorize(sess, "casino_chips", fmt.Sprintf("%d", countChips(sess.Player))) - credits := g.colorize(sess, "casino_credits", fmt.Sprintf("%d", sess.Player.Credits)) - balance := fmt.Sprintf("You have %s chips and %s credits.", chips, credits) - switch menu.Kind { - case casino.MenuBlackjackBet, casino.MenuBaccaratBet: - return g.renderCasinoCommands(sess, balance, message, menu.Commands) - case casino.MenuBlackjackTurn: - return g.renderCasinoCommands(sess, "", menu.Title, menu.Commands) - case casino.MenuSlotCommands: - return g.renderCasinoCommands(sess, balance, message, menu.Commands) - } - return message + balance := "" + if menu.ShowBalance { + chips := g.colorize(sess, "casino_chips", fmt.Sprintf("%d", countChips(sess.Player))) + credits := g.colorize(sess, "casino_credits", fmt.Sprintf("%d", sess.Player.Credits)) + balance = fmt.Sprintf("You have %s chips and %s credits.", chips, credits) + } + heading := menu.Heading + if heading == "" { + heading = message + } + return g.renderCasinoCommands(sess, balance, heading, menu.Commands) +} + +// renderCasinoSnapshot renders a game's structured view for one session. It is +// the only per-game switch in the presentation layer: adding a game means +// adding its snapshot type here, and the Snapshot interface guarantees the +// event carries it rather than being silently dropped by a field filter. +func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot) string { + if snapshot == nil || sess.Player == nil { + return "" + } + mode := g.colorMode(sess) + unicode := sess.Player.OptionBool("unicode") + switch s := snapshot.(type) { + case *casino.SlotSnapshot: + return renderSlotSnapshot(s, mode, unicode) + case *casino.BlackjackSnapshot: + return renderBlackjackSnapshot(s, mode, unicode, sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt) + case *casino.BaccaratSnapshot: + return renderBaccaratSnapshot(s, mode, unicode, sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt) + } + return "" } // renderCasinoCommands lays every casino menu out in the same style: an diff --git a/internal/game/casino_test.go b/internal/game/casino_test.go index 026415f..ffd9958 100644 --- a/internal/game/casino_test.go +++ b/internal/game/casino_test.go @@ -74,6 +74,14 @@ func TestExecuteBetParsesBaccaratSpots(t *testing.T) { if !strings.Contains(string(conn.data), "must be on player, banker, or tie") { t.Fatalf("bet red 100: expected spot rejection, got %q", string(conn.data)) } + // A numeric token AFTER "on" is the target, not a second amount, so + // numeric spots (roulette numbers, craps hard ways) reach the table. + // Baccarat rejects the value, which proves the parser forwarded it. + conn.data = nil + g.executeBet(sess, []string{"5", "on", "17"}, "bet 5 on 17") + if !strings.Contains(string(conn.data), "must be on player, banker, or tie") { + t.Fatalf("bet 5 on 17: expected the table to receive the numeric spot, got %q", string(conn.data)) + } // Two amounts or two spots are usage errors. for _, args := range [][]string{{"10", "20"}, {"player", "banker"}} { conn.data = nil @@ -95,7 +103,7 @@ func TestCasinoMenuRendersAlignedColoredCommands(t *testing.T) { conn := &casinoTestConn{} sess := &net.Session{Player: player.New("alice"), Conn: conn} g.loggedInChars = map[string]*net.Session{"alice": sess} - menu := &casino.MenuView{Kind: casino.MenuSlotCommands, Commands: []casino.MenuCommand{ + menu := &casino.MenuView{Heading: "Your slot machine bet is set to 10.", ShowBalance: true, Commands: []casino.MenuCommand{ {Command: "bet <amount>", Desc: "Set your wager"}, {Command: "spin", Desc: "Spin using your current wager"}, }} diff --git a/internal/game/cmd_bank.go b/internal/game/cmd_bank.go index 0bfc2a3..1ec0b7c 100644 --- a/internal/game/cmd_bank.go +++ b/internal/game/cmd_bank.go @@ -182,7 +182,7 @@ func (g *Game) doBankDepositAll(sess *net.Session) { p := sess.Player var deposited int - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot == nil { continue @@ -299,7 +299,7 @@ func (g *Game) withdrawBankItem(sess *net.Session, p *player.Player, bankSlotIdx // means the exact same item instance: merging two different-quality // stacks would silently destroy the withdrawn stack's properties. if def != nil && def.Stackable { - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { if slot := p.InvSlot(i); slot != nil && slot.ItemID == bs.ItemID && slot.Quality == bs.Quality && slot.MaxQuality == bs.MaxQuality { slot.Quantity += bs.Quantity diff --git a/internal/game/cmd_dig.go b/internal/game/cmd_dig.go index c58ba63..ff33490 100644 --- a/internal/game/cmd_dig.go +++ b/internal/game/cmd_dig.go @@ -127,7 +127,7 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) { return } - newID, err := findNextRoomID(curPath) + newID, err := findNextRoomID(filepath.Dir(curPath), g.World.RoomIndex()) if err != nil { sess.WriteLine("Error scanning room directory.") return @@ -222,14 +222,22 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) { g.checkAggro(sess) } -func findNextRoomID(currentRoomPath string) (int, error) { - dir := filepath.Dir(currentRoomPath) - entries, err := os.ReadDir(dir) +// findNextRoomID returns the first free room ID at or above localDir's +// minimum, checked against every room ID in the world (globallyUsed). The +// admin allocator (admin/id_alloc.go) does the same. A directory-scoped scan +// could hand out an ID that already exists in another area, and +// World.AddRoomPath would then silently shadow that room for the rest of the +// process while leaving a duplicate file on disk. +func findNextRoomID(localDir string, globallyUsed map[int]bool) (int, error) { + entries, err := os.ReadDir(localDir) if err != nil { return 0, err } - used := make(map[int]bool) + used := make(map[int]bool, len(globallyUsed)+len(entries)) + for id := range globallyUsed { + used[id] = true + } minID := 0 first := true for _, entry := range entries { @@ -248,11 +256,11 @@ func findNextRoomID(currentRoomPath string) (int, error) { } } - if first { - return 1, nil + start := 1 + if !first { + start = minID } - - for id := minID; ; id++ { + for id := start; ; id++ { if !used[id] { return id, nil } diff --git a/internal/game/cmd_dig_test.go b/internal/game/cmd_dig_test.go new file mode 100644 index 0000000..1a96c25 --- /dev/null +++ b/internal/game/cmd_dig_test.go @@ -0,0 +1,57 @@ +package game + +import ( + "os" + "path/filepath" + "testing" +) + +// TestFindNextRoomIDSkipsIDsInOtherDirectories: the in-game allocator must +// check every room ID in the world, not just the current room's directory. +// A directory-scoped scan hands out an ID that already exists elsewhere, and +// World.AddRoomPath then silently shadows that room. +func TestFindNextRoomIDSkipsIDsInOtherDirectories(t *testing.T) { + root := t.TempDir() + intro := filepath.Join(root, "intro") + arena := filepath.Join(root, "arena") + for _, dir := range []string{intro, arena} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + // intro owns 1001 and 1002; arena owns 1003. + for _, id := range []string{"1001", "1002"} { + if err := os.WriteFile(filepath.Join(intro, id+".yaml"), []byte("name: intro\n"), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(arena, "1003.yaml"), []byte("name: arena\n"), 0o644); err != nil { + t.Fatal(err) + } + globallyUsed := map[int]bool{1001: true, 1002: true, 1003: true} + + got, err := findNextRoomID(intro, globallyUsed) + if err != nil { + t.Fatalf("findNextRoomID: %v", err) + } + if got != 1004 { + t.Fatalf("findNextRoomID = %d, want 1004 (1003 is used by arena)", got) + } +} + +// TestFindNextRoomIDEmptyDirectoryStartsAtOne: an empty directory still +// allocates the globally lowest free ID rather than blindly returning 1. +func TestFindNextRoomIDEmptyDirectoryStartsAtOne(t *testing.T) { + root := t.TempDir() + empty := filepath.Join(root, "empty") + if err := os.MkdirAll(empty, 0o755); err != nil { + t.Fatal(err) + } + got, err := findNextRoomID(empty, map[int]bool{1: true, 2: true}) + if err != nil { + t.Fatalf("findNextRoomID: %v", err) + } + if got != 3 { + t.Fatalf("findNextRoomID = %d, want 3", got) + } +} diff --git a/internal/game/cmd_drop.go b/internal/game/cmd_drop.go index 278a57d..1c30cce 100644 --- a/internal/game/cmd_drop.go +++ b/internal/game/cmd_drop.go @@ -5,6 +5,7 @@ import ( "strings" "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" ) func (g *Game) doDropAll(sess *net.Session) { @@ -120,7 +121,7 @@ func (g *Game) doDrop(sess *net.Session, input string) { if qty == 0 { if def != nil && def.Stackable { // "drop <item>" means the whole item: sum every matching stack. - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == itemID && slot.Quantity > 0 { qty += slot.Quantity @@ -239,7 +240,7 @@ func (g *Game) handleDropAll(sess *net.Session, input string) { } var dropped []string - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot == nil || slot.Quantity <= 0 { continue diff --git a/internal/game/cmd_estate_directory.go b/internal/game/cmd_estate_directory.go index 9966e52..83c5b80 100644 --- a/internal/game/cmd_estate_directory.go +++ b/internal/game/cmd_estate_directory.go @@ -2,6 +2,7 @@ package game import ( "fmt" + "log" "os" "path/filepath" "sort" @@ -182,12 +183,20 @@ func (g *Game) getOwnedHouseRoom(p *player.Player, flagKey string) int { } houseMap := map[string]int{ - "local_neighborhood": 99999300, + "local_neighborhood": estateNeighborhoodID, } return houseMap[v] } func (g *Game) teleportPlayer(sess *net.Session, p *player.Player, roomID int) { + // Refuse a destination that does not exist: persisting a nonexistent + // RoomID soft-locks the character on login (see the login rescue in + // core_login_char.go). This is the single choke point every trigger, + // module, and estate teleport flows through. + if _, err := g.World.LoadRoom(roomID); err != nil { + log.Printf("teleport %s: destination room %d does not exist; ignoring", p.Name, roomID) + return + } if g.Hub != nil { for _, other := range g.Hub.PlayersInRoom(p.RoomID) { if other != sess { diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go index 33f55de..a806ffa 100644 --- a/internal/game/cmd_get.go +++ b/internal/game/cmd_get.go @@ -76,7 +76,7 @@ func (g *Game) doGet(sess *net.Session, input string) { if def != nil && def.Stackable { quality, maxQuality := g.groundStackProps(p, itemID) - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot == nil || slot.ItemID != itemID || slot.Quality != quality || slot.MaxQuality != maxQuality { continue @@ -198,7 +198,7 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) { if def != nil && def.Stackable { quality, maxQuality := g.groundStackProps(p, itemID) - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot == nil || slot.ItemID != itemID || slot.Quality != quality || slot.MaxQuality != maxQuality { continue @@ -306,7 +306,7 @@ func (g *Game) doGetAll(sess *net.Session) { if def != nil && def.Stackable { stacked := false quality, maxQuality := g.groundStackProps(p, itemID) - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot == nil || slot.ItemID != itemID || slot.Quality != quality || slot.MaxQuality != maxQuality { continue @@ -485,7 +485,7 @@ func (g *Game) findGroundMatches(input string, roomID int) []itemMatch { func (g *Game) findInventoryMatches(input string, p *player.Player) []itemMatch { return g.collectMatches(input, func(yield func(string, int) bool) { - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot == nil { continue diff --git a/internal/game/cmd_inventory.go b/internal/game/cmd_inventory.go index b7b52ed..fbde186 100644 --- a/internal/game/cmd_inventory.go +++ b/internal/game/cmd_inventory.go @@ -4,6 +4,7 @@ import ( "fmt" "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" ) func (g *Game) executeInventory(sess *net.Session, args []string, rawInput string) { @@ -15,7 +16,7 @@ func (g *Game) doInventory(sess *net.Session) { sess.WriteLine(fmt.Sprintf("Inventory (%d free):", p.FreeSlots())) num := 0 - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot == nil { continue diff --git a/internal/game/cmd_remove.go b/internal/game/cmd_remove.go index 53dd313..d03daec 100644 --- a/internal/game/cmd_remove.go +++ b/internal/game/cmd_remove.go @@ -98,7 +98,7 @@ func (g *Game) removeAmmo(sess *net.Session, p *player.Player, qty int) { } quality, maxQuality := g.pickedStackProps(ammoID) - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { s := p.InvSlot(i) // Only a stack with the properties a fresh stack gets may absorb the // equipped stack (see pickedStackProps). @@ -198,7 +198,7 @@ func (g *Game) doRemoveAll(sess *net.Session) { // failure instead of silently destroying the ammo. func (g *Game) removeAllAmmo(sess *net.Session, p *player.Player, itemID string) { quality, maxQuality := g.pickedStackProps(itemID) - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { s := p.InvSlot(i) // Only a stack with the properties a fresh stack gets may absorb the // equipped stack (see pickedStackProps). diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go index 8e01326..bf95282 100644 --- a/internal/game/cmd_room_insert.go +++ b/internal/game/cmd_room_insert.go @@ -58,7 +58,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) { oppositeDir := world.OppositeExit[dir] - newID, err := findNextRoomID(curPath) + newID, err := findNextRoomID(filepath.Dir(curPath), g.World.RoomIndex()) if err != nil { sess.WriteLine("Error scanning room directory.") return diff --git a/internal/game/cmd_shop.go b/internal/game/cmd_shop.go index e2209fc..282d3ca 100644 --- a/internal/game/cmd_shop.go +++ b/internal/game/cmd_shop.go @@ -382,7 +382,7 @@ func (g *Game) doShopSell(sess *net.Session, mob *world.MobInstance, input strin func (g *Game) shopInvCapacity(p *player.Player, itemID string, def *item.ItemDef) int { if def != nil && def.Stackable { quality, maxQuality := stackProps(def) - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { // Capacity must agree with giveShopItems: only a stack with the // properties a fresh stack gets can absorb a purchase (see // stackProps). Counting a differently qualified stack here would @@ -408,7 +408,7 @@ func (g *Game) giveShopItems(p *player.Player, itemID string, qty int, def *item // harvest, ammo return and pickup: a differently qualified stack is // not a merge target. quality, maxQuality := stackProps(def) - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { if slot := p.InvSlot(i); slot != nil && slot.ItemID == itemID && slot.Quality == quality && slot.MaxQuality == maxQuality { slot.Quantity += qty return diff --git a/internal/game/cmd_swapid.go b/internal/game/cmd_swapid.go index 00b6568..8b637fb 100644 --- a/internal/game/cmd_swapid.go +++ b/internal/game/cmd_swapid.go @@ -117,7 +117,12 @@ func (g *Game) executeSwapID(sess *net.Session, args []string, rawInput string) // The two swapped rooms are always re-marshaled to correct their `id:` // field; other rooms are re-marshaled only if a reference actually moved. idMap := map[int]int{id1: id2, id2: id1} - var updated int + // Rewrite every room that references the swapped IDs in ONE journal + // transaction. The old per-file writeRoomFile loop swallowed errors, so + // a crash or a failed write mid-loop left a half-migrated world with no + // recovery record. + rewriteTxn := g.newTxn() + updated := 0 for id := range g.World.RoomIndex() { room, err := g.World.LoadRoom(id) if err != nil { @@ -133,13 +138,19 @@ func (g *Game) executeSwapID(sess *net.Session, args []string, rawInput string) } data, merr := yaml.Marshal(room) if merr != nil { - continue + sess.WriteLine(fmt.Sprintf("Error rewriting room %d: %v", id, merr)) + return } - if werr := writeRoomFile(path, data); werr != nil { - continue + if rerr := rewriteTxn.Replace(path, data, 0644); rerr != nil { + sess.WriteLine(fmt.Sprintf("Error rewriting room %d: %v", id, rerr)) + return } updated++ } + if rerr := rewriteTxn.Commit(); rerr != nil { + sess.WriteLine(fmt.Sprintf("Error rewriting room references: %v", rerr)) + return + } // Full runtime migration: Hub room buckets, casino room keys, world // room-keyed state (ground items, seeded markers, object states), live diff --git a/internal/game/cmd_trigger_combat.go b/internal/game/cmd_trigger_combat.go index 0578c2b..0d53e94 100644 --- a/internal/game/cmd_trigger_combat.go +++ b/internal/game/cmd_trigger_combat.go @@ -105,7 +105,7 @@ func (g *Game) scienceAttack(sess *net.Session, p *player.Player, mob *world.Mob func (g *Game) findInventoryItem(p *player.Player, input string) (int, *player.InventorySlot) { lower := strings.ToLower(strings.TrimSpace(input)) - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot == nil { continue diff --git a/internal/game/cmd_trigger_utility.go b/internal/game/cmd_trigger_utility.go index 481cd40..fa81f23 100644 --- a/internal/game/cmd_trigger_utility.go +++ b/internal/game/cmd_trigger_utility.go @@ -97,7 +97,7 @@ func (g *Game) triggerBonesToNuts(sess *net.Session, p *player.Player, mod *ModD g.cancelAction(p) } - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == "bones" { slot.ItemID = "nutrient_bar" @@ -283,7 +283,7 @@ func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef outputQty = 1 } placed := false - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == match.ID { slot.Quantity += outputQty diff --git a/internal/game/cmd_undig.go b/internal/game/cmd_undig.go index a0fd1a0..c752c86 100644 --- a/internal/game/cmd_undig.go +++ b/internal/game/cmd_undig.go @@ -191,7 +191,7 @@ func (g *Game) performUndig(sess *net.Session, p *player.Player, targetID int) { return nil } - changed := false + changed := room.RemoveRoomRefs(targetID) for ed, exitDef := range room.Exits { if exitDef.Room == targetID { delete(room.Exits, ed) diff --git a/internal/game/cmd_wear.go b/internal/game/cmd_wear.go index e459386..773e3c6 100644 --- a/internal/game/cmd_wear.go +++ b/internal/game/cmd_wear.go @@ -196,7 +196,7 @@ func (g *Game) unequipAmmo(p *player.Player) bool { return true } quality, maxQuality := g.pickedStackProps(ammoID) - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { s := p.InvSlot(i) // Only a stack with the properties a fresh stack gets may absorb the // equipped stack (see pickedStackProps). @@ -227,7 +227,7 @@ func (g *Game) doWearAll(sess *net.Session) { invSlot int }) - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot == nil { continue diff --git a/internal/game/content_refs.go b/internal/game/content_refs.go new file mode 100644 index 0000000..d3328a1 --- /dev/null +++ b/internal/game/content_refs.go @@ -0,0 +1,18 @@ +package game + +// Code-referenced room IDs. +// +// A few rooms are referenced by literal ID in game code rather than by YAML. +// Renaming, moving, or deleting them compiles clean and validates clean (no +// check inspects Go literals), but silently soft-locks the affected flow: the +// thief's "take me to jail" option and the estate directory's "enter" both +// teleport there. TestCodeReferencedRoomsExist pins their existence against +// the shipped data so a move fails loudly instead. +const ( + // jailRoomID is the destination of the stall-guard's "Take me to jail" + // step in act_steal.go. + jailRoomID = 99999162 + // estateNeighborhoodID is the local-neighborhood house room used by the + // construction/estate directory flow in cmd_estate_directory.go. + estateNeighborhoodID = 99999300 +) diff --git a/internal/game/content_refs_test.go b/internal/game/content_refs_test.go new file mode 100644 index 0000000..456a0ee --- /dev/null +++ b/internal/game/content_refs_test.go @@ -0,0 +1,21 @@ +package game + +import ( + "path/filepath" + "testing" + + "thehouseoficarus/internal/world" +) + +// TestCodeReferencedRoomsExist guards the literal room IDs in content_refs.go. +// If one of those rooms is moved or deleted, the thief-jail and +// estate-directory teleports would silently soft-lock players, so the shipped +// data must still contain them. +func TestCodeReferencedRoomsExist(t *testing.T) { + w := world.New(filepath.Join("..", "..", "data")) + for _, id := range []int{jailRoomID, estateNeighborhoodID} { + if _, err := w.LoadRoom(id); err != nil { + t.Errorf("code-referenced room %d is missing from data/rooms: %v", id, err) + } + } +} diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go index e9fa67f..3b35b15 100644 --- a/internal/game/core_login_account.go +++ b/internal/game/core_login_account.go @@ -158,7 +158,10 @@ func (g *Game) handlePasswordOffOwner(sess *net.Session, input string) { return } throttle := g.ensureLoginThrottle() - key := loginThrottleKey(name, sess.RemoteAddr()) + // Key on the host, not RemoteAddr: the latter carries the ephemeral + // source port, so a reconnect would land on a fresh key and reset the + // escalation. RemoteHost strips the port (see net.Session.RemoteHost). + key := loginThrottleKey(name, sess.RemoteHost()) auth := passwordAuth{} acc, err := g.AccountStore.LoadAccount(name) switch { diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go index 3a2e324..41e25b8 100644 --- a/internal/game/core_login_char.go +++ b/internal/game/core_login_char.go @@ -2,6 +2,7 @@ package game import ( "fmt" + "log" "strings" "thehouseoficarus/internal/net" @@ -128,6 +129,18 @@ func (g *Game) connectCharacter(sess *net.Session, name string) { } } + // Rescue a character whose saved room no longer exists (the room was + // deleted, or a code-referenced teleport target was removed). Persisting + // a nonexistent RoomID soft-locks the character on every later login + // because movement and every room lookup fail. + if _, err := g.World.LoadRoom(p.RoomID); err != nil { + log.Printf("character %s: saved room %d no longer exists; relocating to starting room %d", p.Name, p.RoomID, g.StartingRoom) + p.RoomID = g.StartingRoom + if _, err := g.World.LoadRoom(p.RoomID); err != nil { + log.Printf("character %s: starting room %d also missing; leaving room as %d", p.Name, g.StartingRoom, p.RoomID) + } + } + p.Stats.RecordRoomVisit(p.RoomID) g.World.SeedGroundItems(p.RoomID) g.seedRoomMobs(p.RoomID) diff --git a/internal/game/core_production.go b/internal/game/core_production.go index afffa7c..7a4b6e9 100644 --- a/internal/game/core_production.go +++ b/internal/game/core_production.go @@ -16,7 +16,7 @@ func (g *Game) findTool(p *player.Player, toolTypes []string) (toolSpeed float64 } } } - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot == nil { continue diff --git a/internal/game/game.go b/internal/game/game.go index af2ff66..c0c6488 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -764,6 +764,10 @@ func (g *Game) ProcessQueuedCommands() { func (g *Game) buildCraftIndex() { items, err := g.ItemStore.LoadAll() if err != nil { + // A malformed item file must not silently empty every crafting menu; + // surface it so the operator can fix the file (the validator also + // reports it). + log.Printf("craft index: item load failed; craft index left empty: %v", err) return } g.CraftIndex.Build(items) diff --git a/internal/game/look_target.go b/internal/game/look_target.go index 5f3818d..49a2ec7 100644 --- a/internal/game/look_target.go +++ b/internal/game/look_target.go @@ -234,7 +234,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { return } - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot == nil { continue diff --git a/internal/game/production_engine.go b/internal/game/production_engine.go index 89d6f55..9795db0 100644 --- a/internal/game/production_engine.go +++ b/internal/game/production_engine.go @@ -440,7 +440,7 @@ func (g *Game) canContinueProduction(p *player.Player, item *item.ItemDef) bool } outDef, _ := g.ItemStore.Load(item.ID) if outDef != nil && outDef.Stackable { - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == item.ID { return true diff --git a/internal/game/production_index.go b/internal/game/production_index.go index 35466a7..22e944e 100644 --- a/internal/game/production_index.go +++ b/internal/game/production_index.go @@ -293,7 +293,7 @@ func stackProps(def *item.ItemDef) (quality, maxQuality int) { func craftOutputFits(p *player.Player, itemID string, outDef *item.ItemDef) bool { if outDef != nil && outDef.Stackable { quality, maxQuality := stackProps(outDef) - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == itemID && slot.Quality == quality && slot.MaxQuality == maxQuality { return true @@ -311,7 +311,7 @@ func craftOutputFits(p *player.Player, itemID string, outDef *item.ItemDef) bool func placeCraftOutput(p *player.Player, itemID string, outDef *item.ItemDef, outputQty int) bool { quality, maxQuality := stackProps(outDef) if outDef != nil && outDef.Stackable { - for i := 0; i < 28; i++ { + for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == itemID && slot.Quality == quality && slot.MaxQuality == maxQuality { slot.Quantity += outputQty diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go index a6feec2..61eb325 100644 --- a/internal/game/sys_triggers.go +++ b/internal/game/sys_triggers.go @@ -569,6 +569,9 @@ func (g *Game) loadGlobalTriggers(dataDir string) error { } data, err := os.ReadFile(full) if err != nil { + // A permissions/IO error here would otherwise disable a + // file-backed flag trigger with no trace at all. + log.Printf("trigger load %q: %v (skipped)", full, err) continue } var t behavior.Trigger @@ -603,7 +606,9 @@ func copyTrigger(t behavior.Trigger) *behavior.Trigger { func (g *Game) loadAllFlagTriggers() { g.flagIndex.clear() - _ = g.loadGlobalTriggers(g.DataDir) + if err := g.loadGlobalTriggers(g.DataDir); err != nil { + log.Printf("trigger load: %v (some file-backed flag triggers may be missing)", err) + } } // firePlayerFlagTriggers starts sequences for every on_flag_change trigger |
