From 91b466b6923d3eb1df1658199960fe7aa6db36dc Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sun, 20 Sep 2026 15:35:58 -0400 Subject: feat: new placeholder world --- internal/game/casino.go | 59 ++-- internal/game/casino_durability_test.go | 135 ++++++++ internal/game/content_refs.go | 4 +- internal/game/vesta_world_test.go | 379 +++++++++++++++++++++ internal/persistence/txn.go | 60 ++-- .../persistence/txn_directory_durability_test.go | 175 ++++++++++ 6 files changed, 767 insertions(+), 45 deletions(-) create mode 100644 internal/game/casino_durability_test.go create mode 100644 internal/game/vesta_world_test.go create mode 100644 internal/persistence/txn_directory_durability_test.go (limited to 'internal') diff --git a/internal/game/casino.go b/internal/game/casino.go index 23b6c51..698cb78 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -19,14 +19,34 @@ import ( type casinoWallet struct { g *Game p *player.Player + // Optional per-wallet persistence seam for fault-injection tests. + saveCharacter func(*player.Player) error +} + +// save treats a completed replacement as applied, even if its durability is +// uncertain. Reverting memory then would disagree with the visible file and +// could overwrite a paid win or resurrect a resolved stake on the next save. +// Report the storage fault, but do not tell the game to retry/refund an +// already-applied mutation. Only pre-replacement failures require rollback. +func (w casinoWallet) save(operation string) error { + save := w.saveCharacter + if save == nil { + save = w.g.AccountStore.SaveCharacter + } + err := save(w.p) + if errors.Is(err, persistence.ErrDurabilityUncertain) { + fmt.Fprintf(os.Stderr, "casino %s: %s save durability uncertain: %v — keeping applied state\n", operation, w.p.Name, err) + return nil + } + return err } // Wager debits the stake and records it in PendingWagers in the SAME atomic // character save. The entry stays until the round resolves: a settlement // removes the stakes it returns (PayCredits) or consumes them // (ResolveStake/ResolveStakes), and startup refunds anything still listed -// (see recoverPendingWagers). A failed save reverts the in-memory debit and -// ledger entry, so a player is never charged for a stake that is not durable. +// (see recoverPendingWagers). A pre-replacement save failure reverts the +// debit and ledger entry; an uncertain replacement keeps both (see save). func (w casinoWallet) Wager(amount int) (casino.Wager, error) { if amount <= 0 { return casino.Wager{}, casino.ErrInsufficientFunds @@ -46,16 +66,7 @@ func (w casinoWallet) Wager(amount int) (casino.Wager, error) { w.p.Credits -= credits pending := player.PendingWager{ID: newPendingWagerID(), Chips: chips, Credits: credits} w.p.PendingWagers = append(w.p.PendingWagers, pending) - if err := w.g.AccountStore.SaveCharacter(w.p); err != nil { - if errors.Is(err, persistence.ErrDurabilityUncertain) { - // The character file was replaced but its directory entry may not - // be durable. The debit and the pending entry are in the visible - // file, so keep them: a surviving save refunds the stake at - // startup, and a lost one means the player still has the money. - // Either way the stake is never charged twice. - fmt.Fprintf(os.Stderr, "casino wager: %s save durability uncertain: %v — stake kept pending\n", w.p.Name, err) - return casino.Wager{Total: amount, Chips: chips, Credits: credits, ID: pending.ID}, nil - } + if err := w.save("wager"); err != nil { // Revert the in-memory mutation: the play must not proceed on a // stake that could not be made durable. if chips > 0 { @@ -78,15 +89,15 @@ func (w casinoWallet) Wager(amount int) (casino.Wager, error) { // passes the hand's stake IDs so the credit and the stake return are one // atomic step; a slot win passes its triggering stake for the same reason. // Either way a crash can never pay the payout AND refund the same stake. A -// failed save reverts both the credit and the ledger and reports persistence -// failure, so the caller never announces a win that is not durable. +// pre-replacement failure reverts both the credit and the ledger and reports +// failure. An uncertain replacement is applied and logged (see save). func (w casinoWallet) PayCredits(amount int, stakeIDs ...string) error { return w.payCredits(amount, stakeIDs) } // payCredits credits the payout and, when stakeIDs is non-empty, removes -// those stakes from the pending ledger in the same atomic save. On any -// failure the credit and the ledger are reverted together. +// those stakes from the pending ledger in the same atomic save. On a +// pre-replacement failure the credit and the ledger are reverted together. func (w casinoWallet) payCredits(amount int, stakeIDs []string) error { if amount <= 0 && len(stakeIDs) == 0 { return nil @@ -101,7 +112,7 @@ func (w casinoWallet) payCredits(amount int, stakeIDs []string) error { removed = takePendingWagers(w.p, stakeIDs) } w.p.Credits = sum - if err := w.g.AccountStore.SaveCharacter(w.p); err != nil { + if err := w.save("payout"); err != nil { w.p.Credits = creditsBefore w.p.PendingWagers = append(w.p.PendingWagers, removed...) return casino.ErrWalletPersistence @@ -118,7 +129,7 @@ func (w casinoWallet) ResolveStakes() { } before := append([]player.PendingWager(nil), w.p.PendingWagers...) w.p.PendingWagers = nil - if err := w.g.AccountStore.SaveCharacter(w.p); err != nil { + if err := w.save("resolve stakes"); err != nil { // Keep the in-memory ledger consistent with the durable file: the // stakes are still pending on disk, so restore them for a later // retry or startup recovery instead of silently diverging. @@ -139,7 +150,7 @@ func (w casinoWallet) ResolveStake(stakeID string) { if len(removed) == 0 { return } - if err := w.g.AccountStore.SaveCharacter(w.p); err != nil { + if err := w.save("resolve stake"); err != nil { // The durable file still lists the stake, so restore it in memory to // keep the two records consistent for a later retry/recovery. w.p.PendingWagers = append(w.p.PendingWagers, removed...) @@ -159,7 +170,7 @@ func (w casinoWallet) ResolveStakesByIDs(stakeIDs ...string) { if len(removed) == 0 { return } - if err := w.g.AccountStore.SaveCharacter(w.p); err != nil { + if err := w.save("resolve stakes"); err != nil { // The durable file still lists the stakes, so restore them in memory to // keep the two records consistent for a later retry/recovery. w.p.PendingWagers = append(w.p.PendingWagers, removed...) @@ -169,9 +180,9 @@ func (w casinoWallet) ResolveStakesByIDs(stakeIDs ...string) { // RefundOutstanding returns every pending stake to the player — chips in // kind, credits as credits — and clears the ledger in one save. It is used -// by graceful shutdown/eviction and by a pre-deal disconnect. On a failed -// save the in-memory return and ledger are reverted so a later attempt (or -// startup recovery) retries cleanly. +// by graceful shutdown/eviction and by a pre-deal disconnect. A pre-replacement +// failure reverts the return and ledger for retry; an uncertain replacement +// keeps both applied (see save). func (w casinoWallet) RefundOutstanding() (chips, credits int, err error) { before := append([]player.PendingWager(nil), w.p.PendingWagers...) for _, pending := range before { @@ -198,7 +209,7 @@ func (w casinoWallet) RefundOutstanding() (chips, credits int, err error) { return 0, 0, casino.ErrWalletPersistence } w.p.Credits = sum - if err := w.g.AccountStore.SaveCharacter(w.p); err != nil { + if err := w.save("refund"); err != nil { w.p.Credits = creditsBefore if chips > 0 { w.p.RemoveItem("chips", chips) diff --git a/internal/game/casino_durability_test.go b/internal/game/casino_durability_test.go new file mode 100644 index 0000000..227d391 --- /dev/null +++ b/internal/game/casino_durability_test.go @@ -0,0 +1,135 @@ +package game + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "testing" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/persistence" + "thehouseoficarus/internal/player" +) + +// Exercise every wallet mutation with both pre-replacement and post-replacement +// failures. The latter writes the actual character file, then reports an +// uncertain result, matching a failed directory fsync or post-save chmod. +func TestCasinoWalletSaveFailureConsistency(t *testing.T) { + type balance struct { + credits, chips int + pending map[string]player.PendingWager + } + state := func(p *player.Player) balance { + b := balance{credits: p.Credits, chips: countChips(p), pending: make(map[string]player.PendingWager)} + for _, stake := range p.PendingWagers { + b.pending[stake.ID] = stake + } + return b + } + tests := []struct { + name string + run func(casinoWallet) error + reportsError bool + credits, chips, stakes int + }{ + {"wager", func(w casinoWallet) error { + _, err := w.Wager(4) + return err + }, true, 68, 0, 4}, + {"payout", func(w casinoWallet) error { return w.PayCredits(40, "a", "b") }, true, 110, 2, 1}, + {"unlinked payout", func(w casinoWallet) error { return w.PayCredits(20) }, true, 90, 2, 3}, + {"resolve all", func(w casinoWallet) error { w.ResolveStakes(); return nil }, false, 70, 2, 0}, + {"resolve one", func(w casinoWallet) error { w.ResolveStake("a"); return nil }, false, 70, 2, 2}, + {"resolve batch", func(w casinoWallet) error { w.ResolveStakesByIDs("a", "b"); return nil }, false, 70, 2, 1}, + {"refund", func(w casinoWallet) error { + chips, credits, err := w.RefundOutstanding() + if err == nil && (chips != 5 || credits != 30) { + return fmt.Errorf("refund = %d chips, %d credits; want 5, 30", chips, credits) + } + return err + }, true, 100, 7, 0}, + } + for _, tt := range tests { + for _, replaced := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/replaced=%t", tt.name, replaced), func(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "players", "characters"), 0700); err != nil { + t.Fatal(err) + } + store := player.NewAccountStore(dir) + g := &Game{Deps: Deps{AccountStore: store}} + p := player.New("alice") + p.Credits = 70 + p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 2}) + p.PendingWagers = []player.PendingWager{ + {ID: "a", Credits: 10, Chips: 5}, + {ID: "b", Credits: 10}, + {ID: "c", Credits: 10}, + } + if err := store.SaveCharacter(p); err != nil { + t.Fatal(err) + } + before := state(p) + calls := 0 + w := casinoWallet{g: g, p: p, saveCharacter: func(p *player.Player) error { + calls++ + if !replaced { + return errors.New("injected failure before replacement") + } + if err := store.SaveCharacter(p); err != nil { + t.Fatal(err) + } + return fmt.Errorf("injected directory sync failure: %w", persistence.ErrDurabilityUncertain) + }} + err := tt.run(w) + if calls != 1 { + t.Fatalf("save calls = %d, want 1", calls) + } + if !replaced && tt.reportsError { + if !errors.Is(err, casino.ErrWalletPersistence) { + t.Fatalf("error = %v, want ErrWalletPersistence", err) + } + } else if err != nil { + t.Fatal(err) + } + if replaced { + if p.Credits != tt.credits || countChips(p) != tt.chips || len(p.PendingWagers) != tt.stakes { + t.Fatalf("applied state = %+v; want credits=%d chips=%d stakes=%d", state(p), tt.credits, tt.chips, tt.stakes) + } + } else if !reflect.DeepEqual(state(p), before) { + t.Fatalf("failed replacement changed money: got %+v, want %+v", state(p), before) + } + disk, err := store.LoadCharacter(p.Name) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(state(p), state(disk)) { + t.Fatalf("memory = %+v; visible file = %+v", state(p), state(disk)) + } + + // A later ordinary save must not erase the payout or resurrect + // its stakes. Recovery refunds only the remaining ledger, once. + wantCredits, wantChips := disk.Credits, countChips(disk) + for _, stake := range disk.PendingWagers { + wantCredits += stake.Credits + wantChips += stake.Chips + } + if err := store.SaveCharacter(p); err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + g.recoverPendingWagers() + disk, err = store.LoadCharacter(p.Name) + if err != nil { + t.Fatal(err) + } + if disk.Credits != wantCredits || countChips(disk) != wantChips || len(disk.PendingWagers) != 0 { + t.Fatalf("recovery %d = %+v; want credits=%d chips=%d no stakes", i, state(disk), wantCredits, wantChips) + } + } + }) + } + } +} diff --git a/internal/game/content_refs.go b/internal/game/content_refs.go index d3328a1..0608f9e 100644 --- a/internal/game/content_refs.go +++ b/internal/game/content_refs.go @@ -11,8 +11,8 @@ package game const ( // jailRoomID is the destination of the stall-guard's "Take me to jail" // step in act_steal.go. - jailRoomID = 99999162 + jailRoomID = 5133 // estateNeighborhoodID is the local-neighborhood house room used by the // construction/estate directory flow in cmd_estate_directory.go. - estateNeighborhoodID = 99999300 + estateNeighborhoodID = 3131 ) diff --git a/internal/game/vesta_world_test.go b/internal/game/vesta_world_test.go new file mode 100644 index 0000000..50e799c --- /dev/null +++ b/internal/game/vesta_world_test.go @@ -0,0 +1,379 @@ +package game + +import ( + "path/filepath" + "strings" + "testing" + + "thehouseoficarus/internal/item" + "thehouseoficarus/internal/object" + "thehouseoficarus/internal/validate" + "thehouseoficarus/internal/world" +) + +// Load authored content without starting a server, migrating characters or +// recovering the live economy journal. World-building tests must be read-only. +func loadVesta(t *testing.T) (*Game, map[int]*world.Room, map[int]string) { + t.Helper() + dir := filepath.Join("..", "..", "data") + g := &Game{Deps: Deps{ + DataDir: dir, World: world.New(dir), MobStore: world.NewMobStore(dir), + ObjectStore: object.NewObjectStore(dir), ItemStore: item.NewItemStore(dir), + CourseStore: NewCourseStore(dir), + }} + rooms := map[int]*world.Room{} + zones := map[int]string{} + for id := range g.World.RoomIndex() { + r, err := g.World.LoadRoom(id) + if err != nil { + t.Fatalf("room %d: %v", id, err) + } + rooms[id] = r + path, _ := g.World.GetRoomPath(id) + zones[id] = filepath.Base(filepath.Dir(path)) + } + return g, rooms, zones +} + +func vestaCity(zone string) bool { + switch zone { + case "numisia", "brumalia", "tuccia", "domita", "casino": + return true + } + return false +} + +func safeVestaRooms(t *testing.T, g *Game, rooms map[int]*world.Room) map[int]bool { + t.Helper() + safe := map[int]bool{} + for id, r := range rooms { + safe[id] = r.Hazard == "" + for _, spawn := range r.Mobs { + mob, err := g.MobStore.LoadDef(spawn.ID) + if err != nil { + t.Fatal(err) + } + if mob.Combat != nil && mob.Combat.Aggressive { + safe[id] = false + if spawn.WanderInterval != 0 || len(spawn.WanderRooms) != 0 { + t.Errorf("room %d: hostile %s must not wander onto safe routes", id, spawn.ID) + } + } + } + } + return safe +} + +func vestaWalk(rooms map[int]*world.Room, safe map[int]bool, start int) map[int]bool { + seen := map[int]bool{start: true} + queue := []int{start} + for len(queue) > 0 { + id := queue[0] + queue = queue[1:] + for _, exit := range rooms[id].Exits { + if exit.Condition != nil || exit.Hidden || exit.AlwaysBlocked || !safe[exit.Room] || seen[exit.Room] { + continue + } + seen[exit.Room] = true + queue = append(queue, exit.Room) + } + } + return seen +} + +func TestVestaGeographyAndSafeTravel(t *testing.T) { + g, rooms, zones := loadVesta(t) + grid := world.BuildGrid(1001, func(id int) (*world.Room, bool) { + r, ok := rooms[id] + return r, ok + }, nil, nil, func(conflict world.GridConflict) { + t.Errorf("inconsistent world grid: %+v", conflict) + }) + if len(grid.Coord) != len(rooms) { + t.Errorf("only %d/%d rooms connected to the retained introduction", len(grid.Coord), len(rooms)) + } + for _, id := range []int{1001, 1002, 1003} { + if rooms[id] == nil { + t.Fatalf("retained intro room %d missing", id) + } + } + for id, zone := range zones { + if zone == "intro" && (id < 1001 || id > 1003) || id >= 999991 { + t.Errorf("placeholder room %d remains", id) + } + } + // Map Y decreases northward. These relative positions are part of the brief. + num, fields := grid.Coord[1104], grid.Coord[1306] + brum, forest := grid.Coord[2104], grid.Coord[4100] + tuccia, domita := grid.Coord[3104], grid.Coord[5104] + if fields[1] >= num[1] || brum[0] <= num[0] || forest[0] >= num[0] || tuccia[1] <= forest[1] || domita[0] >= forest[0] || domita[1] >= forest[1] { + t.Fatal("region no longer follows the requested city geography") + } + safe := safeVestaRooms(t, g, rooms) + walkable := vestaWalk(rooms, safe, 1104) + counts := map[string]int{} + for id, zone := range zones { + counts[zone]++ + if vestaCity(zone) || zone == "roads" { + if !safe[id] { + t.Errorf("%s room %d is unsafe", zone, id) + } + // The house is the only ownership-gated city interior. + if id != estateNeighborhoodID && rooms[id].Name != "Tuccia — Home Workshop" && !walkable[id] { + t.Errorf("%s room %d requires a gate or dangerous detour", zone, id) + } + } + } + for _, zone := range []string{"numisia", "brumalia", "tuccia", "domita"} { + if counts[zone] < 30 { + t.Errorf("%s needs dozens of distinct rooms, got %d", zone, counts[zone]) + } + } + for _, id := range []int{1306, 4100, 1201, 1207, 1212, 5207} { + if !walkable[id] { + t.Errorf("rural hub/resource destination %d has no safe all-level approach", id) + } + } + for _, course := range g.CourseStore.AllCourses() { + if !walkable[course.StartRoom] { + t.Errorf("course %s has an unsafe or gated approach", course.ID) + } + } +} + +func TestVestaEarlySkillsAndSeparateServices(t *testing.T) { + g, rooms, zones := loadVesta(t) + gathers := map[string]map[int]bool{} + placed := map[string]bool{} + stock := map[string]bool{} + spawns := map[string]bool{} + for id, r := range rooms { + shops := 0 + for _, spawn := range r.Mobs { + spawns[spawn.ID] = true + mob, err := g.MobStore.LoadDef(spawn.ID) + if err != nil { + t.Fatal(err) + } + if mob.Shop != nil { + shops++ + for _, entry := range mob.Shop.Items { + stock[entry.ItemID] = true + } + } + if c := mob.Combat; c != nil { + s := c.Stats + if c.AssassinLevel > 40 || s.Attack > 40 || s.Strength > 40 || s.Defense > 40 || s.Ranged > 40 || s.Science > 40 { + t.Errorf("room %d: %s is beyond this starting region's combat scope", id, spawn.ID) + } + } + if mob.Steal != nil && mob.Steal.Level > 40 { + t.Errorf("room %d: %s is beyond early Thieving progression", id, spawn.ID) + } + } + if shops > 1 { + t.Errorf("room %d crams %d vendors into one shop", id, shops) + } + for _, ref := range r.Objects { + if ref.Local != nil { + continue + } + placed[ref.ID] = true + obj, err := g.ObjectStore.Load(ref.ID) + if err != nil { + t.Fatal(err) + } + if obj.Gather == nil { + continue + } + if vestaCity(zones[id]) { + t.Errorf("resource %s belongs outside city room %d", ref.ID, id) + } + if gathers[obj.Gather.Skill] == nil { + gathers[obj.Gather.Skill] = map[int]bool{} + } + for _, drop := range obj.Gather.Drops { + gathers[obj.Gather.Skill][drop.Level] = true + if drop.Level > 40 { + t.Errorf("room %d: %s yields level %d resources in the starting region", id, ref.ID, drop.Level) + } + } + } + } + for skill, levels := range map[string][]int{ + "woodcutting": {1, 15, 30, 35}, "mining": {1, 15, 20, 30, 40}, + "fishing": {1, 5, 20, 28, 35, 40}, "technology": {1, 10, 20, 30, 40}, + } { + for _, level := range levels { + if !gathers[skill][level] { + t.Errorf("missing %s training tier %d", skill, level) + } + } + } + for _, id := range []string{ + "furnace", "anvil", "workbench", "spinning_wheel", "pottery_wheel", "pottery_oven", "cooking_range", + "allotment_patch", "herb_patch", "flower_patch", "flax_bed", "sheep", "charging_station", + "terminal_basic", "terminal_advanced", "terminal_secure", "scrap_pile", + "solar_altar", "hydro_altar", "cosmic_altar", "eco_altar", "chaos_altar", "estate_directory", + } { + if !placed[id] { + t.Errorf("missing reachable skill station %s", id) + } + } + for _, id := range []string{ + "bronze_axe", "bronze_pickaxe", "hammer", "knife", "matches", "small_fishing_net", "fishing_rod", + "fly_fishing_rod", "feather", "fishing_bait", "lobster_pot", "harpoon", "saw", "shears", + "needle", "thread", "chisel", "rake", "spade", "watering_can", "plant_cure", "vial", "eye_of_newt", + "solar_identifier", "hydro_identifier", "eco_identifier", "cosmic_identifier", "chaos_identifier", + "biojunk", "ecojunk", "lawjunk", "naturejunk", "salt", "acid_vial", "insulated_gloves", + } { + if !stock[id] { + t.Errorf("no vendor supplies required skill input %s", id) + } + } + for _, id := range []string{"apprentice_frame", "irrigation_frame", "harbor_winch", "survey_frame", "numisia_contracts", "tuccia_contracts", "domita_contracts"} { + if !spawns[id] { + t.Errorf("missing combat/Assassin activity %s", id) + } + } + levels := map[int]bool{} + for _, course := range g.CourseStore.AllCourses() { + levels[course.RequiredLevel] = true + if course.RequiredLevel > 40 { + t.Errorf("%s exceeds the starting-region scope", course.ID) + } + for _, obstacle := range course.Obstacles { + foundExit := false + for _, ref := range rooms[obstacle.RoomID].Objects { + if ref.Local != nil { + continue + } + obj, _ := g.ObjectStore.Load(ref.ID) + for _, trigger := range obj.OnUse { + for _, step := range trigger.Steps { + foundExit = foundExit || step.Teleport == course.StartRoom + } + } + } + if !foundExit { + t.Errorf("%s obstacle %d has no level-independent escape", course.ID, obstacle.RoomID) + } + } + } + for _, level := range []int{1, 10, 20, 30, 40} { + if !levels[level] { + t.Errorf("missing Agility tier %d", level) + } + } +} + +func TestVestaCasinoFloorsAndVariants(t *testing.T) { + _, rooms, zones := loadVesta(t) + grid := world.BuildGrid(6102, func(id int) (*world.Room, bool) { + r, ok := rooms[id] + return r, ok + }, nil, nil, nil) + games := map[string]int{} + floors := map[int]int{} + future := 0 + soft17, surrender, singleDeck, huff, classic := false, false, false, false, false + for id, r := range rooms { + if zones[id] != "casino" { + if len(r.CasinoTables)+len(r.CasinoMachines) > 0 { + t.Errorf("unexpected casino game outside Domita's casino: %d", id) + } + continue + } + floor := grid.Coord[id][2] + floors[floor]++ + if strings.Contains(r.Name, "Future ") { + future++ + } + count := len(r.CasinoTables) + len(r.CasinoMachines) + if count > 1 || count > 0 && floor != 0 { + t.Errorf("room %d: games must have individual ground-floor salons", id) + } + for _, table := range r.CasinoTables { + games[string(table.Game)]++ + if table.Game == "blackjack" { + soft17 = soft17 || table.Blackjack.DealerHitSoft17 != nil && *table.Blackjack.DealerHitSoft17 + surrender = surrender || table.Blackjack.Surrender != nil && *table.Blackjack.Surrender + singleDeck = singleDeck || table.Blackjack.Decks == 1 + } + } + for _, machine := range r.CasinoMachines { + games[string(machine.Game)]++ + huff = huff || machine.Variant == "huff_and_even_more_puff" + classic = classic || machine.Variant == "" + } + } + if games["blackjack"] < 5 || games["baccarat"] < 3 || games["slots"] < 5 || !soft17 || !surrender || !singleDeck || !huff || !classic { + t.Errorf("casino variant coverage lost: %v", games) + } + if future < 15 || floors[0] < 30 || floors[1] < 15 || floors[2] < 15 || floors[3] < 15 { + t.Errorf("casino needs a large ground floor and substantial expansion floors: floors=%v future=%d", floors, future) + } +} + +func TestVestaTransportAndContractReferences(t *testing.T) { + g, rooms, _ := loadVesta(t) + // The older shipped-data test does not load modules. Explicitly include + // them here, otherwise deleted transport destinations can silently escape CI. + oldMods, oldByID := AllMods, modByID + oldTechs, oldTechByID := AllTechs, techByID + t.Cleanup(func() { + AllMods, modByID = oldMods, oldByID + AllTechs, techByID = oldTechs, oldTechByID + }) + if err := g.LoadMods(); err != nil { + t.Fatal(err) + } + if err := g.LoadTechs(); err != nil { + t.Fatal(err) + } + g.ValidationConfig.RootRooms = []int{1001} + g.CourseStore.SetWorld(g.World) + for _, issue := range validate.Run(g.validationSource()) { + if issue.Level == "ERROR" { + t.Errorf("%s", issue.Message) + } + } + walkable := vestaWalk(rooms, safeVestaRooms(t, g, rooms), 1104) + for _, mod := range AllMods { + if mod.Category == ModTransport && !walkable[mod.Destination] { + t.Errorf("transport %s points to an unsafe/unreachable destination %d", mod.ID, mod.Destination) + } + } + spawns := map[string]bool{} + for _, r := range rooms { + for _, mob := range r.Mobs { + spawns[mob.ID] = true + } + } + for _, id := range []string{"numisia_contracts", "tuccia_contracts", "domita_contracts"} { + mob, _ := g.MobStore.LoadDef(id) + assignments := 0 + for _, node := range mob.Talk.Nodes { + for _, option := range node.Options { + if option.Action == nil { + continue + } + flags := option.Action.SetPlayerFlags + target, _ := flags["assassin_task_mob"].(string) + if target == "" { + continue + } + assignments++ + if !spawns[target] { + t.Errorf("%s assigns unplaced target %s", id, target) + } + if flags["assassin_task_remaining"] != flags["assassin_task_total"] || flags["assassin_task_total"] == nil { + t.Errorf("%s has an incomplete contract for %s", id, target) + } + } + } + if assignments == 0 { + t.Errorf("%s offers no functioning contracts", id) + } + } +} diff --git a/internal/persistence/txn.go b/internal/persistence/txn.go index 9939204..400d355 100644 --- a/internal/persistence/txn.go +++ b/internal/persistence/txn.go @@ -64,9 +64,9 @@ type Transaction struct { ops map[string]*fileOp order []string journalDir string - // dirSync makes a journal directory entry durable (nil means syncDir). - // It is a field rather than a package-level function so a test can - // assert that the commit point is synced and inject a sync failure. + // dirSync makes directory mutations durable (nil means syncDir). + // Per-transaction so tests can check ordering and inject failures + // without changing a package-global hook. dirSync func(string) error } @@ -82,12 +82,31 @@ func syncDir(dir string) error { return d.Sync() } -// syncJournalDir syncs the transaction's journal directory. -func (t *Transaction) syncJournalDir() error { +func (t *Transaction) syncDirectory(dir string) error { if t.dirSync != nil { - return t.dirSync(t.journalDir) + return t.dirSync(dir) } - return syncDir(t.journalDir) + return syncDir(dir) +} + +// removeFileDurable also syncs an already-absent file's parent: recovery may +// be retrying a deletion whose previous directory sync failed. A missing +// parent needs no sync because this transaction does not create directories. +func (t *Transaction) removeFileDurable(path string) error { + err := os.Remove(path) + if err != nil && !os.IsNotExist(err) { + return err + } + dir := filepath.Dir(path) + if os.IsNotExist(err) { + if _, statErr := os.Stat(dir); os.IsNotExist(statErr) { + return nil + } + } + if err := t.syncDirectory(dir); err != nil { + return fmt.Errorf("sync directory %s after delete: %w", dir, err) + } + return nil } // removeJournalDurable unlinks a journal and syncs its directory, so the @@ -101,7 +120,7 @@ func (t *Transaction) removeJournalDurable(path string) error { for attempt := 0; attempt < journalRemoveAttempts; attempt++ { removeErr = os.Remove(path) if removeErr == nil || os.IsNotExist(removeErr) { - if err := t.syncJournalDir(); err != nil { + if err := t.syncDirectory(t.journalDir); err != nil { return fmt.Errorf("journal %s removed but its directory could not be synced (a crash may resurrect the journal and roll the transaction back): %w", path, err) } return nil @@ -291,7 +310,7 @@ func (t *Transaction) apply() error { return fmt.Errorf("write %s: %w", op.Path, err) } case kindDelete: - if err := os.Remove(op.Path); err != nil && !os.IsNotExist(err) { + if err := t.removeFileDurable(op.Path); err != nil { return fmt.Errorf("delete %s: %w", op.Path, err) } case kindMove: @@ -306,6 +325,17 @@ func (t *Transaction) apply() error { if err := os.Rename(op.Path, op.MoveTo); err != nil { return fmt.Errorf("move %s: %w", op.Path, err) } + // A cross-directory rename changes both directory entries. Make + // both durable before the recovery journal can be retired. + srcDir, dstDir := filepath.Dir(op.Path), filepath.Dir(op.MoveTo) + if err := t.syncDirectory(srcDir); err != nil { + return fmt.Errorf("sync move source directory %s: %w", srcDir, err) + } + if dstDir != srcDir { + if err := t.syncDirectory(dstDir); err != nil { + return fmt.Errorf("sync move destination directory %s: %w", dstDir, err) + } + } case kindMoveTarget: // Content replace after the move delivered the source bytes. if op.NewExists { @@ -333,21 +363,13 @@ func (t *Transaction) rollback() error { if op.OldExists { return WriteFile(path, op.Old, os.FileMode(op.OldMode)) } - return os.Remove(path) + return t.removeFileDurable(path) } switch op.Kind { - case kindMove, kindMoveTarget: - if err := restore(op.Path); err != nil && !os.IsNotExist(err) { - record(fmt.Errorf("restore %s: %w", op.Path, err)) - } - case kindReplace: + case kindMove, kindMoveTarget, kindReplace, kindDelete: if err := restore(op.Path); err != nil { record(fmt.Errorf("restore %s: %w", op.Path, err)) } - case kindDelete: - if err := restore(op.Path); err != nil && !os.IsNotExist(err) { - record(fmt.Errorf("restore %s: %w", op.Path, err)) - } } } return firstErr diff --git a/internal/persistence/txn_directory_durability_test.go b/internal/persistence/txn_directory_durability_test.go new file mode 100644 index 0000000..134580e --- /dev/null +++ b/internal/persistence/txn_directory_durability_test.go @@ -0,0 +1,175 @@ +package persistence + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestTransactionSyncsChangedDirectoriesBeforeCommit(t *testing.T) { + for _, operation := range []string{"delete", "move same directory", "move across directories"} { + t.Run(operation, func(t *testing.T) { + dir := t.TempDir() + journalDir := filepath.Join(dir, ".journal") + srcDir := filepath.Join(dir, "source") + dstDir := srcDir + if operation == "move across directories" { + dstDir = filepath.Join(dir, "destination") + } + src, dst := filepath.Join(srcDir, "a.yaml"), filepath.Join(dstDir, "b.yaml") + writeTestFile(t, src, "source") + writeTestFile(t, dst, "destination") + txn := NewTransaction(journalDir) + var err error + if operation == "delete" { + err = txn.Delete(src) + } else { + err = txn.Move(src, dst) + } + if err != nil { + t.Fatal(err) + } + var synced []string + txn.dirSync = func(d string) error { + entries, err := ListJournalFiles(journalDir) + if err != nil { + t.Fatal(err) + } + if d != journalDir && len(entries) != 1 { + t.Fatal("journal retired before data directory sync") + } + if d == journalDir && len(entries) != 0 { + t.Fatal("commit point did not remove journal") + } + if _, err := os.Stat(src); !os.IsNotExist(err) { + t.Fatalf("sync must follow deletion/rename: %v", err) + } + synced = append(synced, d) + return syncDir(d) + } + if err := txn.Commit(); err != nil { + t.Fatal(err) + } + want := []string{srcDir} + if dstDir != srcDir { + want = append(want, dstDir) + } + want = append(want, journalDir) + if !reflect.DeepEqual(synced, want) { + t.Fatalf("sync order = %v, want %v", synced, want) + } + if operation != "delete" && readTestFile(t, dst) != "source" { + t.Fatal("move did not replace destination") + } + }) + } +} + +func TestTransactionDataSyncFailureRollsBack(t *testing.T) { + for _, failAt := range []string{"delete", "move source", "move destination"} { + t.Run(failAt, func(t *testing.T) { + dir := t.TempDir() + journalDir := filepath.Join(dir, ".journal") + srcDir, dstDir := filepath.Join(dir, "source"), filepath.Join(dir, "destination") + src, dst := filepath.Join(srcDir, "a.yaml"), filepath.Join(dstDir, "b.yaml") + writeTestFile(t, src, "source") + writeTestFile(t, dst, "destination") + txn := NewTransaction(journalDir) + var err error + if failAt == "delete" { + err = txn.Delete(src) + } else { + err = txn.Move(src, dst) + } + if err != nil { + t.Fatal(err) + } + failed := false + fault := errors.New("injected data directory sync failure") + txn.dirSync = func(d string) error { + failDir := srcDir + if failAt == "move destination" { + failDir = dstDir + } + if d == failDir { + failed = true + return fault + } + // Restoring existing before-images uses WriteFile, which + // already syncs their directories before journal removal. + if d == journalDir && (readTestFile(t, src) != "source" || readTestFile(t, dst) != "destination") { + t.Fatal("journal retired before rollback restored files") + } + return syncDir(d) + } + if err := txn.Commit(); !errors.Is(err, fault) || !failed { + t.Fatalf("commit error = %v, fault injected = %t", err, failed) + } + entries, err := ListJournalFiles(journalDir) + if err != nil || len(entries) != 0 { + t.Fatalf("successful rollback should retire journal: %v, %v", entries, err) + } + }) + } +} + +func TestRollbackDeletionSyncFailureRetainsJournalAndRetries(t *testing.T) { + dir := t.TempDir() + journalDir := filepath.Join(dir, ".journal") + createdDir, failDir := filepath.Join(dir, "created"), filepath.Join(dir, "fail") + if err := os.Mkdir(createdDir, 0700); err != nil { + t.Fatal(err) + } + created, deleted := filepath.Join(createdDir, "new.yaml"), filepath.Join(failDir, "old.yaml") + writeTestFile(t, deleted, "before") + txn := NewTransaction(journalDir) + if err := txn.Replace(created, []byte("new"), 0600); err != nil { + t.Fatal(err) + } + if err := txn.Delete(deleted); err != nil { + t.Fatal(err) + } + var synced []string + txn.dirSync = func(d string) error { + synced = append(synced, d) + return errors.New("injected directory sync failure") + } + if err := txn.Commit(); err == nil || !strings.Contains(err.Error(), "rollback failed") { + t.Fatalf("expected apply and rollback sync failures, got %v", err) + } + if !reflect.DeepEqual(synced, []string{failDir, createdDir}) { + t.Fatalf("syncs = %v; missing rollback deletion barrier", synced) + } + if _, err := os.Stat(created); !os.IsNotExist(err) { + t.Fatalf("rollback should have removed created file: %v", err) + } + if entries, err := ListJournalFiles(journalDir); err != nil || len(entries) != 1 { + t.Fatalf("failed rollback must retain journal: %v, %v", entries, err) + } + + // Retry the same rollback with the file already absent: skipping its + // directory sync would incorrectly retire an undurable deletion. + synced = nil + txn.dirSync = func(d string) error { + synced = append(synced, d) + return syncDir(d) + } + if err := txn.rollback(); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(synced, []string{createdDir}) { + t.Fatalf("retry syncs = %v, want %s", synced, createdDir) + } + if err := Recover(journalDir); err != nil { + t.Fatal(err) + } + if readTestFile(t, deleted) != "before" { + t.Fatal("recovery did not restore deleted file") + } + if entries, err := ListJournalFiles(journalDir); err != nil || len(entries) != 0 { + t.Fatalf("recovery did not retire journal: %v, %v", entries, err) + } +} -- cgit v1.2.3