From 0f812ccc307ea0550511ccced40b65dfb0cdaa91 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 20:11:29 -0400 Subject: fix: world and casino integrity fixes --- internal/behavior/store.go | 16 ++++++++++++++-- internal/casino/credits.go | 6 +++++- internal/casino/manager.go | 6 +++--- internal/casino/money_regression_test.go | 2 +- internal/game/cmd_swapid.go | 16 +++++++++------- internal/game/cmd_undig.go | 27 ++++++++++++++++----------- internal/game/look_entities.go | 8 +++++++- internal/validate/validate.go | 20 ++++++++++++++++---- 8 files changed, 71 insertions(+), 30 deletions(-) (limited to 'internal') diff --git a/internal/behavior/store.go b/internal/behavior/store.go index a558f87..4051836 100644 --- a/internal/behavior/store.go +++ b/internal/behavior/store.go @@ -47,12 +47,24 @@ func BuildRoomIndex(dir string) map[int]string { func WalkYAMLDir(dir string, fn func(path, id string, data []byte) error) error { return filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { - if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") { + if err != nil { + // A missing root is an empty tree; any other walk error (for + // example an unreadable directory) must surface instead of + // silently validating nothing. + if os.IsNotExist(err) { + return nil + } + return err + } + if d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") { return nil } data, readErr := os.ReadFile(path) if readErr != nil { - return nil + if os.IsNotExist(readErr) { + return nil + } + return readErr } id := strings.TrimSuffix(d.Name(), ".yaml") return fn(path, id, data) diff --git a/internal/casino/credits.go b/internal/casino/credits.go index 631a4bc..19364da 100644 --- a/internal/casino/credits.go +++ b/internal/casino/credits.go @@ -76,6 +76,10 @@ func walletErrorMessage(err error, context ...string) string { // payoutFailedMessage explains a settlement the wallet could not persist: // the journal record for the payout stays open, so startup recovery will // re-apply the owed amount. Games must NOT announce such a payout as paid. +// payoutFailedMessage explains a settlement the wallet could not apply right +// now. This covers both a persistence failure and a credit-cap block (the +// journal record stays open either way), so it must not assert a persistence +// failure as the cause. Recovery re-applies the owed amount. func payoutFailedMessage() string { - return "The house could not process your payout (persistence failure). Your settlement is recorded and will be restored when you return." + return "The house could not process your payout right now. Your settlement is recorded and will be restored when you return." } diff --git a/internal/casino/manager.go b/internal/casino/manager.go index b71e5df..d9b2c3e 100644 --- a/internal/casino/manager.go +++ b/internal/casino/manager.go @@ -75,9 +75,9 @@ func (m *Manager) EnsureMachine(roomID int, cfg MachineConfig) *Machine { m.mu.Lock() defer m.mu.Unlock() if machine := m.machines[key]; machine != nil { - // DeepEqual (not ==) so the *bool rule fields compare by value; - // == would treat every freshly parsed config as different and - // discard an idle machine on every room load. + // DeepEqual (not ==) compares the config by value and stays correct + // as rule structs gain pointer/slice fields; == would panic or, for + // a struct with incomparable fields, fail to compile. if reflect.DeepEqual(machine.Config, cfg) { return machine } diff --git a/internal/casino/money_regression_test.go b/internal/casino/money_regression_test.go index 31cd0e6..bb8263e 100644 --- a/internal/casino/money_regression_test.go +++ b/internal/casino/money_regression_test.go @@ -129,7 +129,7 @@ func TestBlackjackFailedPayoutNotAnnouncedAsPaid(t *testing.T) { if strings.Contains(e.PrivateMessage, "You win! +") { t.Fatalf("announced a successful payout after the wallet rejected it: %q", e.PrivateMessage) } - if strings.Contains(e.PrivateMessage, "persistence failure") { + if strings.Contains(e.PrivateMessage, "could not process your payout") { return // the failure was explained to the player } } diff --git a/internal/game/cmd_swapid.go b/internal/game/cmd_swapid.go index 8b637fb..d1dbd68 100644 --- a/internal/game/cmd_swapid.go +++ b/internal/game/cmd_swapid.go @@ -66,13 +66,15 @@ func (g *Game) executeSwapID(sess *net.Session, args []string, rawInput string) // Refuse while non-admin players are online: swapping room IDs relocates // every character whose state embeds those IDs, which is disruptive to // normal play. Admins may stay online (their own state is migrated too). - for _, other := range g.Hub.AllSessions() { - if other == sess || other.Player == nil { - continue - } - if !g.checkAdmin(other) { - sess.WriteLine("Cannot swap room IDs while non-admin players are online.") - return + if g.Hub != nil { + for _, other := range g.Hub.AllSessions() { + if other == sess || other.Player == nil { + continue + } + if !g.checkAdmin(other) { + sess.WriteLine("Cannot swap room IDs while non-admin players are online.") + return + } } } diff --git a/internal/game/cmd_undig.go b/internal/game/cmd_undig.go index c752c86..88dd661 100644 --- a/internal/game/cmd_undig.go +++ b/internal/game/cmd_undig.go @@ -224,18 +224,23 @@ func (g *Game) performUndig(sess *net.Session, p *player.Player, targetID int) { g.World.RebuildRoomIndex(g.DataDir) - if p.RoomID == targetID { - p.RoomID = 0 - } - - for _, other := range g.Hub.AllSessions() { - if other.Player != nil && other.Player.RoomID == targetID { - other.Player.RoomID = p.RoomID - if g.Hub != nil { - g.Hub.EnterRoom(other, p.RoomID) + // Everyone in the deleted room relocates to the admin's room. If the + // admin is somehow standing in the target room (0 is not a valid room), + // fall back to the starting room rather than teleporting players to a + // nonexistent room and soft-locking them. + destination := p.RoomID + if destination == targetID { + destination = g.StartingRoom + } + + if g.Hub != nil { + for _, other := range g.Hub.AllSessions() { + if other.Player != nil && other.Player.RoomID == targetID { + other.Player.RoomID = destination + g.Hub.EnterRoom(other, destination) + other.WriteLine("The room around you dissolves. You find yourself elsewhere.") + g.doLook(other) } - other.WriteLine("The room around you dissolves. You find yourself elsewhere.") - g.doLook(other) } } diff --git a/internal/game/look_entities.go b/internal/game/look_entities.go index 70077a3..e06d49f 100644 --- a/internal/game/look_entities.go +++ b/internal/game/look_entities.go @@ -230,7 +230,13 @@ func (g *Game) showRoomCasinos(sess *net.Session, room *world.Room) []string { } var lines []string for _, table := range room.CasinoTables { - name := casinoGameLabel(table.Game) + " table" + // Legacy slots declared under casino_tables are instantiated as the + // room's slots machine, so label them as a machine, not a table. + kind := "table" + if table.Game == casino.GameSlots { + kind = "machine" + } + name := casinoGameLabel(table.Game) + " " + kind lines = append(lines, fmt.Sprintf("A %s is here.", g.colorize(sess, "casino_action", name))) } for _, machine := range room.CasinoMachines { diff --git a/internal/validate/validate.go b/internal/validate/validate.go index d77eafa..a7ce1b0 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -126,7 +126,7 @@ func Run(s Source) []Issue { func validateRoomFilenames(dataDir string) []Issue { dir := filepath.Join(dataDir, "rooms") var issues []Issue - _ = behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error { + if err := behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error { if _, err := strconv.Atoi(id); err != nil { rel := strings.TrimPrefix(path, dataDir+"/") issues = append(issues, Issue{ @@ -136,7 +136,13 @@ func validateRoomFilenames(dataDir string) []Issue { }) } return nil - }) + }); err != nil { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "integrity", + Message: fmt.Sprintf("rooms: could not scan room files: %v", err), + }) + } return issues } @@ -159,7 +165,7 @@ func validateKnownFields(dataDir string, extra []KnownFieldTarget) []Issue { var issues []Issue for _, t := range targets { dir := filepath.Join(dataDir, t.SubDir) - _ = behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error { + if err := behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error { dec := yaml.NewDecoder(bytes.NewReader(data)) dec.KnownFields(true) if err := dec.Decode(t.NewDef()); err != nil { @@ -171,7 +177,13 @@ func validateKnownFields(dataDir string, extra []KnownFieldTarget) []Issue { }) } return nil - }) + }); err != nil { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "unknown-field", + Message: fmt.Sprintf("%s: could not scan %s: %v", t.Label, t.SubDir, err), + }) + } } // cards.yaml lives at the data root rather than a subdirectory. if data, err := os.ReadFile(filepath.Join(dataDir, "cards.yaml")); err == nil { -- cgit v1.2.3