aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/casino.go59
-rw-r--r--internal/game/casino_durability_test.go135
-rw-r--r--internal/game/content_refs.go4
-rw-r--r--internal/game/vesta_world_test.go379
4 files changed, 551 insertions, 26 deletions
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)
+ }
+ }
+}