aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/bank_transport_regression_test.go114
-rw-r--r--internal/game/casino.go10
-rw-r--r--internal/game/cmd_bank.go76
-rw-r--r--internal/game/cmd_quit.go11
-rw-r--r--internal/game/cmd_trigger.go8
-rw-r--r--internal/game/cmd_trigger_transport.go14
-rw-r--r--internal/game/core_login_account.go29
-rw-r--r--internal/game/core_login_char.go23
-rw-r--r--internal/game/game.go10
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