aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/account_txn.go7
-rw-r--r--internal/game/act_talk.go4
-rw-r--r--internal/game/casino_house_edge.go16
-rw-r--r--internal/game/correct_audit_fixes_test.go94
-rw-r--r--internal/game/exit_migrate.go13
-rw-r--r--internal/game/harness_test.go33
6 files changed, 142 insertions, 25 deletions
diff --git a/internal/game/account_txn.go b/internal/game/account_txn.go
index a1a5cf0..77a4219 100644
--- a/internal/game/account_txn.go
+++ b/internal/game/account_txn.go
@@ -482,8 +482,11 @@ func equalsFold(a, b string) bool {
return true
}
-// recoverJournals rolls back leftover lifecycle journals at startup and
-// surfaces unrecoverable ones.
+// recoverJournals rolls back leftover lifecycle journals and surfaces
+// unrecoverable ones. It scans every journal in the directory, so it must run
+// only where no other transaction can be in flight: at startup, or on the game
+// owner goroutine (which serializes every transaction). The mid-game caller in
+// video_poker_banks.go is on the owner.
func (g *Game) recoverJournals() {
if err := persistence.Recover(g.journalDir()); err != nil {
fmt.Fprintf(os.Stderr, "lifecycle journal recovery failed: %v\n", err)
diff --git a/internal/game/act_talk.go b/internal/game/act_talk.go
index fa93435..8f47bf5 100644
--- a/internal/game/act_talk.go
+++ b/internal/game/act_talk.go
@@ -139,6 +139,10 @@ func (g *Game) showTalkNode(sess *net.Session, node behavior.TalkNode) {
}
}
+ // A terminal node (no options, no goto) ends the conversation; clear the
+ // action or it lingers as a stale talk action (status shows "talking",
+ // isBusy suppresses the prompt, and advanceTalk no-ops every tick).
+ g.cancelAction(p)
sess.SetState(net.StateGame)
g.writePrompt(sess)
}
diff --git a/internal/game/casino_house_edge.go b/internal/game/casino_house_edge.go
index de0036b..ff3ce59 100644
--- a/internal/game/casino_house_edge.go
+++ b/internal/game/casino_house_edge.go
@@ -158,19 +158,9 @@ func (g *Game) showCasinoListTable(sess *net.Session, betHeader string, rows []c
}
}
budget := max(len(headers), width-3*len(headers)-1)
- for {
- sum, widest := 0, 0
- for i, w := range widths {
- sum += w
- if w > widths[widest] {
- widest = i
- }
- }
- if sum <= budget || widths[widest] <= 1 {
- break
- }
- widths[widest]--
- }
+ // Size the pre-wrapped cells with the same shrink algorithm Table.Render
+ // uses, instead of a second copy that can disagree on which column yields.
+ ui.FitWidths(widths, budget)
table := ui.Table{}
// Headers wrap too, so narrow terminals never lose a column label.
all := append([][]string{headers}, cells...)
diff --git a/internal/game/correct_audit_fixes_test.go b/internal/game/correct_audit_fixes_test.go
index c32b1e5..03facc0 100644
--- a/internal/game/correct_audit_fixes_test.go
+++ b/internal/game/correct_audit_fixes_test.go
@@ -166,6 +166,100 @@ func TestGatherYieldsDrop(t *testing.T) {
})
}
+// End-to-end burn: the missing coverage the audit called out. Start a fire
+// with a tinderbox and tick until it catches.
+func TestBurnCreatesFire(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ writeSeedFile(t, filepath.Join(h.dir, "items", "logs.yaml"),
+ []byte("id: logs\nname: logs\nburn_ticks: 100\nfire_level: 1\nfire_xp: 10\n"))
+ writeSeedFile(t, filepath.Join(h.dir, "items", "tinderbox.yaml"),
+ []byte("id: tinderbox\nname: tinderbox\ntool:\n type: fire\n speed: 1\n"))
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: "logs", Quantity: 5})
+ p.SetInvSlot(1, &player.InventorySlot{ItemID: "tinderbox", Quantity: 1})
+ h.g.startBurn(sess, p, "logs", false)
+ if p.Action == nil || p.Action.Type != behavior.TypeBurn {
+ t.Fatalf("burn action did not start: %s", sess.Conn.(*chanConn).Bytes())
+ }
+ // Each attempt is two calls (phase 1 then the roll); 200 calls is ~100
+ // attempts at >=5% success, so failure is effectively impossible.
+ for i := 0; i < 200 && p.Action != nil; i++ {
+ h.g.advanceBurn(sess, p)
+ }
+ if p.Action != nil {
+ t.Fatal("burn never completed")
+ }
+ if !h.g.hasFireObject(1) {
+ t.Fatalf("no fire created (output: %s)", sess.Conn.(*chanConn).Bytes())
+ }
+ })
+}
+
+// Regression: a terminal talk node (messages, no options, no goto) must clear
+// the talk action, or it lingers as a stale action that suppresses the prompt
+// and no-ops every tick.
+func TestTerminalTalkNodeClearsAction(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ writeSeedFile(t, filepath.Join(h.dir, "objects", "statue.yaml"),
+ []byte("id: statue\nname: statue\ntalk:\n nodes:\n start:\n messages: [The statue says nothing more.]\n"))
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
+ h.g.RunOnOwner(func() {
+ h.g.ObjectStore.Reload(h.dir)
+ obj, err := h.g.ObjectStore.Load("statue")
+ if err != nil {
+ t.Fatal(err)
+ }
+ h.g.startTalk(sess, p, obj)
+ if p.Action != nil {
+ t.Fatalf("terminal talk node left a stale action: %+v", p.Action)
+ }
+ if sess.State() != net.StateGame {
+ t.Fatalf("state = %v, want StateGame", sess.State())
+ }
+ })
+}
+
+// End-to-end object steal: the missing coverage the audit called out.
+func TestObjectStealYieldsLoot(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ writeSeedFile(t, filepath.Join(h.dir, "objects", "market_stall.yaml"),
+ []byte("id: market_stall\nname: market stall\nsteal:\n level: 1\n xp: 10\n speed: 1\n drops:\n - item_id: stolen_coins\n weight: 100\n"))
+ writeSeedFile(t, filepath.Join(h.dir, "items", "stolen_coins.yaml"),
+ []byte("id: stolen_coins\nname: stolen coins\nstackable: true\n"))
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ h.g.ObjectStore.Reload(h.dir)
+ h.g.World.EnsureObjectStates(1, []string{"market_stall"})
+ obj, err := h.g.ObjectStore.Load("market_stall")
+ if err != nil {
+ t.Fatal(err)
+ }
+ h.g.startSteal(sess, p, "object", nil, obj)
+ if p.Action == nil || p.Action.Type != behavior.TypeSteal {
+ t.Fatalf("steal action did not start: %s", sess.Conn.(*chanConn).Bytes())
+ }
+ // ~50% success per attempt; 200 attempts makes failure negligible.
+ for i := 0; i < 200 && p.CountItem("stolen_coins") == 0; i++ {
+ h.g.advanceSteal(sess, p)
+ }
+ if got := p.CountItem("stolen_coins"); got == 0 {
+ t.Fatalf("steal yielded no loot (output: %s)", sess.Conn.(*chanConn).Bytes())
+ }
+ })
+}
+
func TestPlayerDataFileNameCanonicalizesPrefixes(t *testing.T) {
for _, in := range []string{
"players/characters/alice.yaml",
diff --git a/internal/game/exit_migrate.go b/internal/game/exit_migrate.go
index f6df05d..4edab95 100644
--- a/internal/game/exit_migrate.go
+++ b/internal/game/exit_migrate.go
@@ -6,6 +6,7 @@ import (
"strings"
"thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
)
// rewritePlayerRoomIDs remaps room-ID-embedded state in a single player per
@@ -21,10 +22,10 @@ func rewritePlayerRoomIDs(p *player.Player, idMap map[int]int) bool {
return false
}
changed := false
- if remapScalar(idMap, &p.RoomID) {
+ if world.RemapInt(idMap, &p.RoomID) {
changed = true
}
- if p.PendingSequence != nil && p.PendingSequence.RoomID != 0 && remapScalar(idMap, &p.PendingSequence.RoomID) {
+ if p.PendingSequence != nil && p.PendingSequence.RoomID != 0 && world.RemapInt(idMap, &p.PendingSequence.RoomID) {
changed = true
}
if len(p.Flags) > 0 && rewriteDiscoveredExitFlags(p.Flags, idMap) {
@@ -41,14 +42,6 @@ func rewritePlayerRoomIDs(p *player.Player, idMap map[int]int) bool {
return changed
}
-func remapScalar(idMap map[int]int, n *int) bool {
- if newID, ok := idMap[*n]; ok && newID != *n {
- *n = newID
- return true
- }
- return false
-}
-
// rewriteDiscoveredExitFlags rewrites hidden_exit_<roomID>_<dir> flag keys,
// remapping the embedded roomID per idMap. Flag values are preserved.
func rewriteDiscoveredExitFlags(flags map[string]any, idMap map[int]int) bool {
diff --git a/internal/game/harness_test.go b/internal/game/harness_test.go
index 3bdce0f..c5377b4 100644
--- a/internal/game/harness_test.go
+++ b/internal/game/harness_test.go
@@ -131,6 +131,39 @@ func newHarness(t *testing.T) *harness {
return h
}
+// newHarnessWithTicks is newHarness with the production master tick
+// subscription registered, so tick-driven systems (disconnect countdown,
+// action advance, casino tick) run exactly as they do in production. Most
+// harness tests drive the systems directly and do not need this.
+func newHarnessWithTicks(t *testing.T) *harness {
+ t.Helper()
+ h := newHarness(t)
+ h.g.SubscribeTicks(1, func() bool {
+ h.g.RunTickSystems()
+ return true
+ })
+ return h
+}
+
+// Regression: the harness must be able to run the real master tick sequence,
+// which is the only way the disconnect countdown (Hub.Remove -> DisconnectTick
+// -> onRemove) is exercised end-to-end.
+func TestHarnessWithTicksCompletesDisconnect(t *testing.T) {
+ h := newHarnessWithTicks(t)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+ sess, _ := h.connect("alice")
+ h.hub.Remove(sess)
+ h.barrier()
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) && len(h.hub.AllSessions()) > 0 {
+ time.Sleep(5 * time.Millisecond)
+ }
+ if got := len(h.hub.AllSessions()); got != 0 {
+ t.Fatalf("session not hard-removed after the countdown: %d", got)
+ }
+}
+
// seedWorld writes the minimal on-disk content a Game needs: three rooms.
func seedWorld(t *testing.T, dir string) {
t.Helper()