diff options
Diffstat (limited to 'internal/game/hardening_fixes_batch2_test.go')
| -rw-r--r-- | internal/game/hardening_fixes_batch2_test.go | 203 |
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") + } +} |
