diff options
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/bank_transport_regression_test.go | 114 | ||||
| -rw-r--r-- | internal/game/casino.go | 10 | ||||
| -rw-r--r-- | internal/game/cmd_bank.go | 76 | ||||
| -rw-r--r-- | internal/game/cmd_quit.go | 11 | ||||
| -rw-r--r-- | internal/game/cmd_trigger.go | 8 | ||||
| -rw-r--r-- | internal/game/cmd_trigger_transport.go | 14 | ||||
| -rw-r--r-- | internal/game/core_login_account.go | 29 | ||||
| -rw-r--r-- | internal/game/core_login_char.go | 23 | ||||
| -rw-r--r-- | internal/game/game.go | 10 |
9 files changed, 262 insertions, 33 deletions
diff --git a/internal/game/bank_transport_regression_test.go b/internal/game/bank_transport_regression_test.go new file mode 100644 index 0000000..529df84 --- /dev/null +++ b/internal/game/bank_transport_regression_test.go @@ -0,0 +1,114 @@ +package game + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/item" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +// regressionGame builds a minimal Game with real stores over a temp data dir. +func regressionGame(t *testing.T) (*Game, *net.Session) { + t.Helper() + dir := t.TempDir() + for _, sub := range []string{"items", "rooms"} { + if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(dir, "items", "chips.yaml"), []byte("name: chips\nstackable: true\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "rooms", "1.yaml"), []byte("name: test\n"), 0o644); err != nil { + t.Fatal(err) + } + g := &Game{ + Deps: Deps{ + World: world.New(dir), + ItemStore: item.NewItemStore(dir), + AccountStore: player.NewAccountStore(dir), + }, + Casino: casino.NewManager(1), + Combat: combat.NewTracker(), + } + sess := &net.Session{Player: player.New("alice"), Conn: &casinoTestConn{}} + sess.Player.RoomID = 1 + return g, sess +} + +// Regression: a stackable withdrawal must succeed via stack merging even +// when the inventory has zero free slots. +func TestBankWithdrawMergesStackIntoFullInventory(t *testing.T) { + g, sess := regressionGame(t) + for i := 0; i < 28; i++ { + sess.Player.SetInvSlot(i, &player.InventorySlot{ItemID: "filler", Quantity: 1}) + } + sess.Player.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 10}) + sess.Player.SetBankSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 5}) + + g.doBankWithdraw(sess, "1") + + inv := sess.Player.InvSlot(0) + if inv == nil || inv.Quantity != 15 { + t.Fatalf("stack withdrawal blocked by a full inventory: inventory=%+v", inv) + } + if sess.Player.BankSlot(0) != nil { + t.Fatal("bank slot was not cleared after the withdrawal") + } +} + +// Regression: stacks of the same item but different quality must not merge, +// which would silently destroy the withdrawn stack's properties. +func TestBankWithdrawKeepsIncompatibleQualitySeparate(t *testing.T) { + g, sess := regressionGame(t) + sess.Player.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 10, Quality: 3, MaxQuality: 10}) + sess.Player.SetBankSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 5, Quality: 8, MaxQuality: 10}) + + g.doBankWithdraw(sess, "1") + + first, second := sess.Player.InvSlot(0), sess.Player.InvSlot(1) + if first == nil || first.Quantity != 10 || first.Quality != 3 { + t.Fatalf("existing stack was disturbed: %+v", first) + } + if second == nil || second.Quantity != 5 || second.Quality != 8 || second.MaxQuality != 10 { + t.Fatalf("incompatible stack was merged or metadata lost: first=%+v second=%+v", first, second) + } +} + +// Regression: sequenced transport modules (like transport_home) bypass +// triggerTransport entirely; the casino gate must also apply when the module +// is started. +func TestSequencedTransportBlockedWhilePlaying(t *testing.T) { + g, sess := regressionGame(t) + + oldMods, oldIndex := AllMods, modByID + t.Cleanup(func() { AllMods, modByID = oldMods, oldIndex }) + mod := &ModDef{ + ID: "transport_home", Name: "transport home", Category: ModTransport, + Level: 1, Destination: 2, + Sequence: []behavior.ModTriggerStep{{Message: "wait", Delay: 1}}, + } + AllMods = []*ModDef{mod} + modByID = map[string]*ModDef{mod.ID: mod} + + g.Casino.JoinMachine(1, casino.MachineConfig{ID: "slots", Game: casino.GameSlots}, sess.Player.Name) + conn := sess.Conn.(*casinoTestConn) + conn.data = nil + + g.doTrigger(sess, "transport_home") + + if sess.Player.Action != nil { + t.Fatalf("transport sequence scheduled despite casino participation: %+v", sess.Player.Action) + } + if !strings.Contains(string(conn.data), "playing a game") { + t.Fatalf("missing casino-gate message: %q", string(conn.data)) + } +} diff --git a/internal/game/casino.go b/internal/game/casino.go index 9f3b524..2e80bf3 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -47,6 +47,16 @@ func (w casinoWallet) PayCredits(amount int) { // then credits. func casinoAffordable(p *player.Player) int { return p.Credits + countChips(p) } +// inCasinoGame reports whether the player is currently seated at any casino +// table or machine. Nil-safe for test scaffolding without a Casino manager. +func (g *Game) inCasinoGame(name string) bool { + if g.Casino == nil { + return false + } + _, playing := g.Casino.Participation(name) + return playing +} + func (g *Game) casinoTable(roomID int, gameName string) (*casino.Table, bool) { room, err := g.World.LoadRoom(roomID) if err != nil { diff --git a/internal/game/cmd_bank.go b/internal/game/cmd_bank.go index 3c4cac3..86057cc 100644 --- a/internal/game/cmd_bank.go +++ b/internal/game/cmd_bank.go @@ -78,6 +78,7 @@ func (g *Game) showBankBrowse(sess *net.Session) { type bankEntry struct { slot int + pos int name string id string qty int @@ -94,7 +95,10 @@ func (g *Game) showBankBrowse(sess *net.Session) { if def != nil { itemName = def.Name } - entries = append(entries, bankEntry{slot: i, name: itemName, id: slot.ItemID, qty: slot.Quantity}) + // pos is the 1-based display position; it is also the number the + // withdraw command accepts, so gaps in bank slot numbering can no + // longer make a displayed number select the wrong item. + entries = append(entries, bankEntry{slot: i, pos: len(entries) + 1, name: itemName, id: slot.ItemID, qty: slot.Quantity}) } if len(entries) == 0 && p.BankedCredits <= 0 { @@ -109,7 +113,7 @@ func (g *Game) showBankBrowse(sess *net.Session) { for _, e := range entries { def, _ := g.ItemStore.Load(e.id) t.Rows = append(t.Rows, []string{ - fmt.Sprintf("%d", e.slot+1), + fmt.Sprintf("%d", e.pos), g.itemColorize(sess, def, e.name), fmt.Sprintf("%d", e.qty), }) @@ -153,9 +157,10 @@ func (g *Game) doBankDeposit(sess *net.Session, input string) { bankIdx := p.NextBankSlot() p.SetBankSlot(bankIdx, &player.InventorySlot{ - ItemID: itemID, - Quantity: qty, - Quality: slot.Quality, + ItemID: itemID, + Quantity: qty, + Quality: slot.Quality, + MaxQuality: slot.MaxQuality, }) g.AccountStore.SaveCharacter(p) @@ -183,9 +188,10 @@ func (g *Game) doBankDepositAll(sess *net.Session) { } bankIdx := p.NextBankSlot() p.SetBankSlot(bankIdx, &player.InventorySlot{ - ItemID: slot.ItemID, - Quantity: slot.Quantity, - Quality: slot.Quality, + ItemID: slot.ItemID, + Quantity: slot.Quantity, + Quality: slot.Quality, + MaxQuality: slot.MaxQuality, }) p.SetInvSlot(i, nil) deposited++ @@ -204,19 +210,18 @@ func (g *Game) doBankDepositAll(sess *net.Session) { func (g *Game) doBankWithdraw(sess *net.Session, input string) { p := sess.Player - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - sess.WriteLine("Your inventory is full.") - return - } + // No upfront capacity check here: withdrawBankItem merges stackable items + // into existing stacks first and only needs a free slot otherwise. - var entries []struct { + type entryRef struct { slot int + pos int name string id string qty int } + var entries []entryRef for _, i := range p.BankSlots() { bs := p.BankSlot(i) if bs == nil { @@ -227,12 +232,7 @@ func (g *Game) doBankWithdraw(sess *net.Session, input string) { if def != nil { itemName = def.Name } - entries = append(entries, struct { - slot int - name string - id string - qty int - }{slot: i, name: itemName, id: bs.ItemID, qty: bs.Quantity}) + entries = append(entries, entryRef{slot: i, pos: len(entries) + 1, name: itemName, id: bs.ItemID, qty: bs.Quantity}) } idx, err := parseChoiceIndex(input) @@ -242,12 +242,7 @@ func (g *Game) doBankWithdraw(sess *net.Session, input string) { return } - var matches []struct { - slot int - name string - id string - qty int - } + var matches []entryRef lower := strings.ToLower(input) for _, e := range entries { if behavior.WordPrefixMatch(e.name, lower) { @@ -293,6 +288,30 @@ func (g *Game) withdrawBankItem(sess *net.Session, p *player.Player, bankSlotIdx return } + def, _ := g.ItemStore.Load(bs.ItemID) + + // Stackable withdrawals merge into a compatible stack first; a free slot + // is only required when the player holds none of the item. Compatibility + // 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++ { + if slot := p.InvSlot(i); slot != nil && slot.ItemID == bs.ItemID && + slot.Quality == bs.Quality && slot.MaxQuality == bs.MaxQuality { + slot.Quantity += bs.Quantity + p.SetBankSlot(bankSlotIdx, nil) + g.AccountStore.SaveCharacter(p) + displayName := bs.ItemID + if def != nil { + displayName = def.Name + } + itemColor := g.itemColorize(sess, def, displayName) + sess.WriteLine(fmt.Sprintf("You withdraw %d %ss.", bs.Quantity, itemColor)) + return + } + } + } + freeSlot := p.FirstFreeSlot() if freeSlot == -1 { sess.WriteLine("Your inventory is full.") @@ -301,12 +320,11 @@ func (g *Game) withdrawBankItem(sess *net.Session, p *player.Player, bankSlotIdx itemID := bs.ItemID qty := bs.Quantity - def, _ := g.ItemStore.Load(itemID) if def != nil && def.Stackable { - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: qty}) + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: qty, Quality: bs.Quality, MaxQuality: bs.MaxQuality}) } else { - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: 1}) + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: 1, Quality: bs.Quality, MaxQuality: bs.MaxQuality}) if qty > 1 { bs.Quantity-- g.AccountStore.SaveCharacter(p) diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go index fa4038b..0eb8fb9 100644 --- a/internal/game/cmd_quit.go +++ b/internal/game/cmd_quit.go @@ -11,16 +11,19 @@ func (g *Game) executeQuit(sess *net.Session, args []string, rawInput string) { func (g *Game) doQuit(sess *net.Session) { p := sess.Player - if _, playing := g.Casino.Participation(p.Name); playing { - sess.WriteLine(g.colorize(sess, "warning", "WARNING: You're in the middle of a game!")) - g.emitCasinoEvents(g.Casino.MarkDisconnected(p.Name)) - } + // Combat blocks quitting outright; check it before forfeiting any casino + // wager so a cancelled quit never costs the player their round. if g.Combat.Get(p.Name) != nil { sess.WriteLine("You can't rest during combat!") return } + if _, playing := g.Casino.Participation(p.Name); playing { + sess.WriteLine(g.colorize(sess, "warning", "WARNING: You're in the middle of a game!")) + g.emitCasinoEvents(g.Casino.MarkDisconnected(p.Name)) + } + g.cancelAction(p) g.cancelRest(p.Name) diff --git a/internal/game/cmd_trigger.go b/internal/game/cmd_trigger.go index f74efec..404b924 100644 --- a/internal/game/cmd_trigger.go +++ b/internal/game/cmd_trigger.go @@ -44,6 +44,14 @@ func (g *Game) doTrigger(sess *net.Session, input string) { sess.WriteLine("You can't imagine transport would pick you up here") return } + // This also covers sequenced transport modules (e.g. transport_home), + // which never pass through triggerTransport. Transport would strand + // the player at a remote room while still seated at a table or + // machine; require them to stop playing first. + if g.inCasinoGame(p.Name) { + sess.WriteLine("You're playing a game. Type \"stop\" between rounds before transporting.") + return + } } if mod.ID == "transport_home" && p.HomeTransportCooldown > 0 { diff --git a/internal/game/cmd_trigger_transport.go b/internal/game/cmd_trigger_transport.go index e2053b0..dbb0d16 100644 --- a/internal/game/cmd_trigger_transport.go +++ b/internal/game/cmd_trigger_transport.go @@ -13,6 +13,13 @@ func (g *Game) triggerTransport(sess *net.Session, p *player.Player, mod *ModDef return } + // Transport would strand the player at a remote room while still seated at + // a table or machine; require them to stop playing first. + if _, playing := g.Casino.Participation(p.Name); playing { + sess.WriteLine("You're playing a game. Type \"stop\" between rounds before transporting.") + return + } + if p.Action != nil { g.cancelAction(p) } @@ -22,6 +29,13 @@ func (g *Game) triggerTransport(sess *net.Session, p *player.Player, mod *ModDef } func (g *Game) executeTransportEffect(sess *net.Session, p *player.Player, mod *ModDef) { + // Safety net: sequenced modules reach the effect from advanceTriggerModule + // without re-entering triggerTransport. Never teleport a seated gambler. + if g.inCasinoGame(p.Name) { + sess.WriteLine("You're playing a game. Type \"stop\" between rounds before transporting.") + return + } + if g.Hub != nil { for _, other := range g.Hub.PlayersInRoom(p.RoomID) { if other != sess { diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go index 186a5c3..77b72d3 100644 --- a/internal/game/core_login_account.go +++ b/internal/game/core_login_account.go @@ -340,6 +340,25 @@ func (g *Game) handleRenameAccount(sess *net.Session, input string) { return } + // Persist the new name inside the account file itself; otherwise the next + // login adopts the old name again and account lookup drifts apart from + // the filename. Roll the rename back if the file cannot be updated so the + // account never ends up in a half-renamed state. + acc, err := g.AccountStore.LoadAccount(newName) + if err != nil { + sess.WriteLine(fmt.Sprintf("Error renaming account: %v", err)) + os.Rename(newPath, oldPath) + g.showMenu(sess) + return + } + acc.Name = newName + if err := g.AccountStore.SaveAccount(acc); err != nil { + sess.WriteLine(fmt.Sprintf("Error renaming account: %v", err)) + os.Rename(newPath, oldPath) + g.showMenu(sess) + return + } + sess.Account.Name = newName sess.WriteLine(fmt.Sprintf("Account renamed to %s.", newName)) g.showMenu(sess) @@ -354,6 +373,16 @@ func (g *Game) handlePurgeAccount(sess *net.Session, input string) { return } + // Refuse to purge while any character is connected: a live session would + // keep playing (and saving) a character whose file is about to vanish. + for _, name := range sess.Account.Characters { + if g.characterOnline(name) { + sess.WriteLine(fmt.Sprintf("%s is logged in. Log all characters out before purging.", name)) + g.showMenu(sess) + return + } + } + for _, name := range sess.Account.Characters { os.Remove(g.AccountStore.CharPath(name)) } diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go index 38499e2..9d7c211 100644 --- a/internal/game/core_login_char.go +++ b/internal/game/core_login_char.go @@ -144,6 +144,16 @@ func (g *Game) handleRenameChar(sess *net.Session, input string) { sess.Write("New name: ") } +// characterOnline reports whether the named character is currently connected +// through a live session. Menu operations that rename or delete character +// files must refuse while the character is online, otherwise the in-memory +// player later overwrites or resurrects the moved/removed file. +func (g *Game) characterOnline(name string) bool { + g.charsMu.Lock() + defer g.charsMu.Unlock() + return g.loggedInChars[name] != nil +} + func (g *Game) handleRenameCharName(sess *net.Session, input string) { newName := strings.TrimSpace(input) oldName := sess.PendingChar @@ -166,6 +176,12 @@ func (g *Game) handleRenameCharName(sess *net.Session, input string) { sess.Write("New name: ") return } + if g.characterOnline(oldName) { + sess.WriteLine("That character is logged in elsewhere. Log them out first.") + sess.PendingChar = "" + g.showMenu(sess) + return + } oldPath := g.AccountStore.CharPath(oldName) newPath := g.AccountStore.CharPath(newName) @@ -240,6 +256,13 @@ func (g *Game) handleDeleteChar(sess *net.Session, input string) { return } + if g.characterOnline(sess.PendingChar) { + sess.WriteLine("That character is logged in elsewhere. Log them out first.") + sess.PendingChar = "" + g.showMenu(sess) + return + } + charPath := g.AccountStore.CharPath(sess.PendingChar) if err := os.Remove(charPath); err != nil { sess.WriteLine(fmt.Sprintf("Error deleting: %v", err)) diff --git a/internal/game/game.go b/internal/game/game.go index 23e0635..822042e 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -237,6 +237,10 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { } parts := strings.Fields(strings.ToLower(input)) + if len(parts) == 0 { + g.reprompt(sess) + return + } cmd := parts[0] if len(parts) == 1 { @@ -369,6 +373,12 @@ func (g *Game) buildCraftIndex() { g.CraftIndex.Build(items) } +// RebuildCraftIndex refreshes the craft index after item definitions change +// on disk (wired into the admin web editor's item mutation paths). +func (g *Game) RebuildCraftIndex() { + g.buildCraftIndex() +} + type pendingDepletion struct { instanceKey string objDefID string |
