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") } }