aboutsummaryrefslogtreecommitdiff
path: root/internal/game/hardening_fixes_batch2_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/hardening_fixes_batch2_test.go')
-rw-r--r--internal/game/hardening_fixes_batch2_test.go203
1 files changed, 203 insertions, 0 deletions
diff --git a/internal/game/hardening_fixes_batch2_test.go b/internal/game/hardening_fixes_batch2_test.go
new file mode 100644
index 0000000..e81dd9c
--- /dev/null
+++ b/internal/game/hardening_fixes_batch2_test.go
@@ -0,0 +1,203 @@
+package game
+
+// Regression tests for the second hardening pass: transactional trigger
+// effects, ammo preservation on death, safespot clearing on teleport, and
+// economy-journal torn-tail repair.
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/player"
+)
+
+func seedBatchItems(t *testing.T, dir string) {
+ t.Helper()
+ writeSeedFile(t, filepath.Join(dir, "items", "cowhide.yaml"),
+ []byte("id: cowhide\nname: cowhide\nvalue: 1\n"))
+ writeSeedFile(t, filepath.Join(dir, "items", "leather.yaml"),
+ []byte("id: leather\nname: leather\nvalue: 1\nstackable: true\n"))
+ writeSeedFile(t, filepath.Join(dir, "items", "rock.yaml"),
+ []byte("id: rock\nname: rock\nvalue: 1\n"))
+ writeSeedFile(t, filepath.Join(dir, "items", "arrow.yaml"),
+ []byte("id: arrow\nname: arrow\nvalue: 1\nstackable: true\n"))
+ writeSeedFile(t, filepath.Join(dir, "items", "helm.yaml"),
+ []byte("id: helm\nname: helm\nvalue: 100\n"))
+ writeSeedFile(t, filepath.Join(dir, "items", "body.yaml"),
+ []byte("id: body\nname: body\nvalue: 100\n"))
+ writeSeedFile(t, filepath.Join(dir, "items", "sword.yaml"),
+ []byte("id: sword\nname: sword\nvalue: 100\n"))
+}
+
+// TestApplyStepUnaffordablePurchaseIsAtomic: a step that both sets a flag and
+// charges credits must apply nothing when the player cannot afford it.
+func TestApplyStepUnaffordablePurchaseIsAtomic(t *testing.T) {
+ h := newHarness(t)
+ seedBatchItems(t, h.dir)
+ h.g.ItemStore.Reload(h.dir)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+ sess, _ := h.connect("alice")
+ p := sess.Player
+ p.Credits = 0
+ p.Flags = map[string]any{}
+
+ step := &behavior.Step{Credits: -10, SetPlayerFlags: map[string]any{"bought": true}}
+ if h.g.applyStep(sess, p, step, p.RoomID, scopePlayer, nil) {
+ t.Fatal("unaffordable purchase reported success")
+ }
+ if _, ok := p.Flags["bought"]; ok {
+ t.Fatal("flag granted on a failed purchase")
+ }
+ if p.Credits != 0 {
+ t.Fatalf("credits changed on a failed purchase: %d", p.Credits)
+ }
+}
+
+// TestApplyStepFullInventoryTradeIsAtomic: a take+give+charge step with no
+// room for the reward must consume nothing and charge nothing.
+func TestApplyStepFullInventoryTradeIsAtomic(t *testing.T) {
+ h := newHarness(t)
+ seedBatchItems(t, h.dir)
+ h.g.ItemStore.Reload(h.dir)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+ sess, _ := h.connect("alice")
+ p := sess.Player
+ p.Inventory = make(map[int]*player.InventorySlot)
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: "cowhide", Quantity: 1})
+ for i := 1; i < 28; i++ {
+ p.SetInvSlot(i, &player.InventorySlot{ItemID: "rock", Quantity: 1})
+ }
+ p.Credits = 100
+
+ step := &behavior.Step{TakeItem: "cowhide", GiveItem: "leather", Credits: -10}
+ if h.g.applyStep(sess, p, step, p.RoomID, scopePlayer, nil) {
+ t.Fatal("full-inventory trade reported success")
+ }
+ if !p.HasItem("cowhide") {
+ t.Fatal("cowhide consumed on a failed trade")
+ }
+ if p.HasItem("leather") {
+ t.Fatal("leather delivered on a failed trade")
+ }
+ if p.Credits != 100 {
+ t.Fatalf("credits charged on a failed trade: %d", p.Credits)
+ }
+}
+
+// TestApplyStepStackableRewardMergesWhenFull: a stackable reward that fits an
+// existing stack must still be delivered when every slot is occupied.
+func TestApplyStepStackableRewardMergesWhenFull(t *testing.T) {
+ h := newHarness(t)
+ seedBatchItems(t, h.dir)
+ h.g.ItemStore.Reload(h.dir)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+ sess, _ := h.connect("alice")
+ p := sess.Player
+ p.Inventory = make(map[int]*player.InventorySlot)
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: "leather", Quantity: 1})
+ for i := 1; i < 28; i++ {
+ p.SetInvSlot(i, &player.InventorySlot{ItemID: "rock", Quantity: 1})
+ }
+
+ if !h.g.applyStep(sess, p, &behavior.Step{GiveItem: "leather"}, p.RoomID, scopePlayer, nil) {
+ t.Fatal("stackable reward should merge into a full inventory")
+ }
+ if slot := p.InvSlot(0); slot == nil || slot.Quantity != 2 {
+ t.Fatalf("reward did not merge into the existing stack: %+v", slot)
+ }
+}
+
+// TestDropItemsOnDeathPreservesAmmoStack: an equipped ammo stack must be
+// dropped in full (and AmmoQty cleared), not silently reduced to one unit.
+func TestDropItemsOnDeathPreservesAmmoStack(t *testing.T) {
+ h := newHarness(t)
+ seedBatchItems(t, h.dir)
+ h.g.ItemStore.Reload(h.dir)
+
+ p := player.New("alice")
+ p.RoomID = 1
+ p.Credits = 0
+ p.Equipment = map[item.EquipSlot]string{
+ item.SlotAmmo: "arrow",
+ item.SlotHead: "helm",
+ item.SlotTorso: "body",
+ item.SlotMainHand: "sword",
+ }
+ p.AmmoQty = 50
+
+ h.g.RunOnOwner(func() {
+ h.g.dropItemsOnDeath(p)
+ })
+
+ if p.AmmoQty != 0 {
+ t.Fatalf("AmmoQty not cleared after the stack dropped: %d", p.AmmoQty)
+ }
+ ground := h.g.World.GroundItems(p.RoomID)
+ if ground["arrow"] != 50 {
+ t.Fatalf("ammo stack not preserved on the ground: %+v", ground)
+ }
+}
+
+// TestTeleportPlayerClearsSafespot: cover immunity must not follow a teleport
+// into a room with no cover.
+func TestTeleportPlayerClearsSafespot(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+ sess, _ := h.connect("alice")
+ p := sess.Player
+ h.g.safespot.Set(p.Name, SafespotState{Active: true, RoomID: p.RoomID, ObjectDefID: "rock"})
+
+ h.g.RunOnOwner(func() {
+ h.g.teleportPlayer(sess, p, 2)
+ })
+
+ if h.g.safespot.Has(p.Name) {
+ t.Fatal("safespot survived a teleport")
+ }
+ if p.RoomID != 2 {
+ t.Fatalf("room after teleport = %d, want 2", p.RoomID)
+ }
+}
+
+// TestEconomyJournalRepairsTornTail: a torn final line must be truncated
+// before the next append, so the journal does not become permanently
+// unreadable (which would disable recovery and compaction).
+func TestEconomyJournalRepairsTornTail(t *testing.T) {
+ dir := t.TempDir()
+ j := newEconomyJournal(dir)
+ if err := os.MkdirAll(filepath.Join(dir, "economy"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(j.path, []byte(`{"id":"torn","kind":"payout","status":"open"`), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := j.append(economyRecord{ID: "good", Player: "alice", Kind: "payout", Status: "open", Credit: 5}); err != nil {
+ t.Fatalf("append after torn tail: %v", err)
+ }
+ open, err := j.loadOpen()
+ if err != nil {
+ t.Fatalf("loadOpen after torn-tail repair: %v", err)
+ }
+ for _, rec := range open {
+ if rec.ID == "torn" {
+ t.Fatal("torn record survived repair")
+ }
+ }
+ found := false
+ for _, rec := range open {
+ if rec.ID == "good" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatal("valid record missing after torn-tail repair")
+ }
+}