From cc33072eced3d5460512c09c42ec4531c807d7af Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sat, 19 Sep 2026 01:55:34 -0400 Subject: fix: casino stake preservation, world state reconciliation, casino game registration --- building_guide/rooms.md | 16 ++- cmd/thoi/main.go | 2 + internal/admin/api_map.go | 6 +- internal/admin/api_rooms.go | 5 + internal/admin/api_rooms_bulk.go | 5 + internal/admin/server.go | 8 ++ internal/casino/baccarat.go | 8 +- internal/casino/blackjack.go | 26 ++-- internal/casino/config_validate.go | 137 +++++++++++++-------- internal/casino/core.go | 25 +++- internal/casino/fix_regressions_test.go | 121 ++++++++++++++++++ internal/casino/machine.go | 15 ++- internal/casino/registry.go | 59 +++++++++ internal/casino/table.go | 13 +- internal/casino/types.go | 10 +- internal/game/act_burn.go | 2 +- internal/game/casino.go | 37 +++++- internal/game/combat_mob.go | 16 ++- internal/game/room_reconcile.go | 21 ++++ internal/game/snapshot_render_guard_test.go | 40 ++++++ internal/game/sys_safespot.go | 5 +- internal/game/sys_triggers.go | 6 +- internal/world/fix_regressions_test.go | 153 +++++++++++++++++++++++ internal/world/objects.go | 182 +++++++++++++++++++++++----- internal/world/world.go | 22 +++- 25 files changed, 814 insertions(+), 126 deletions(-) create mode 100644 internal/casino/fix_regressions_test.go create mode 100644 internal/casino/registry.go create mode 100644 internal/game/room_reconcile.go create mode 100644 internal/game/snapshot_render_guard_test.go create mode 100644 internal/world/fix_regressions_test.go diff --git a/building_guide/rooms.md b/building_guide/rooms.md index c70b379..719c09e 100644 --- a/building_guide/rooms.md +++ b/building_guide/rooms.md @@ -357,11 +357,17 @@ casino configurations. Raw file editing is not a validated save endpoint. - IDs must be nonempty, have no surrounding whitespace, and be unique across the room's tables and machines (including legacy slots table entries). - Supported table games are `blackjack` and `baccarat`; machines use `slots` - with the optional variant `huff_and_even_more_puff`. -- Only one table/machine per game is addressable: a room is addressed by game - name (`play blackjack`), so a second entry for the same game, or the same - machine variant (including a legacy slots table alongside a slots machine), - is rejected as unreachable content. + with the optional variant `huff_and_even_more_puff`. A machine variant is + registered in Go (`slotVariants` in `internal/casino/registry.go`); naming an + unregistered variant is a validation error. +- A multiplayer table is addressed by its game name (`play blackjack`), so a + room may host only one table per game; a legacy slots table still collides + with a slots machine. +- A machine is addressed by its `variant` when one is declared, so a room may + host several variants of one game, each reached by its own variant name. A + bare game name addresses a machine only when it is the sole machine of that + game (or declares no variant). Two variant-less machines of one game, or two + machines sharing a variant, are rejected as unreachable duplicates. - Omitted/zero numeric values keep their existing defaults. Negative bets, timers, seat counts, split counts, decks, and payouts are rejected. - An explicit `max_bet` must be at least `min_bet`. An omitted or zero diff --git a/cmd/thoi/main.go b/cmd/thoi/main.go index 915d7ca..142b227 100644 --- a/cmd/thoi/main.go +++ b/cmd/thoi/main.go @@ -92,6 +92,7 @@ func main() { ReloadCourses: adminReloadCourses, RewriteRoomIDs: adminRewriteRoomIDs, EvictRoom: adminEvictRoom, + ReconcileRoom: g.ReconcileRoom, RebuildCraftIndex: adminRebuildCraftIndex, ConfigPath: *configPath, RunOnOwner: g.RunAdminOnOwner, @@ -124,6 +125,7 @@ func main() { ReloadCourses: adminReloadCourses, RewriteRoomIDs: adminRewriteRoomIDs, EvictRoom: adminEvictRoom, + ReconcileRoom: g.ReconcileRoom, RebuildCraftIndex: adminRebuildCraftIndex, ConfigPath: *configPath, RunOnOwner: g.RunAdminOnOwner, diff --git a/internal/admin/api_map.go b/internal/admin/api_map.go index d0372df..231f52b 100644 --- a/internal/admin/api_map.go +++ b/internal/admin/api_map.go @@ -246,6 +246,10 @@ func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) { var upLinks []UDLink var downLinks []UDLink + // Hoist the room index once: RoomIndex builds a fresh map copy under the + // world lock, and calling it per exit made this loop O(rooms x exits x + // rooms) with a mutex acquisition each time. + roomIndex := s.world.RoomIndex() seenLinks := make(map[string]bool) for rid, room := range roomData { c := g.Coord[rid] @@ -255,7 +259,7 @@ func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) { continue } targetCoord, hasCoord := g.Coord[target] - targetExists := s.world.RoomIndex()[target] + _, targetExists := roomIndex[target] if !targetExists || !hasCoord { if dir != world.Up && dir != world.Down { diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go index 863af47..ca7ad06 100644 --- a/internal/admin/api_rooms.go +++ b/internal/admin/api_rooms.go @@ -311,6 +311,11 @@ func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) { failJSON(w, fmt.Sprintf("write room: %v", err)) return } + // Refresh live runtime state (objects, spawns, mobs) so the edit takes + // effect without a restart; player-dropped items are preserved. + if s.reconcileRoom != nil { + s.reconcileRoom(id) + } s.undoStack.Push(ChangeDesc{ Description: fmt.Sprintf("update room %d", id), diff --git a/internal/admin/api_rooms_bulk.go b/internal/admin/api_rooms_bulk.go index b9cbb4a..3296375 100644 --- a/internal/admin/api_rooms_bulk.go +++ b/internal/admin/api_rooms_bulk.go @@ -162,6 +162,11 @@ func (s *AdminServer) handleBulkEdit(w http.ResponseWriter, r *http.Request) { } s.world.RebuildRoomIndex(s.dataDir) + if s.reconcileRoom != nil { + for _, wr := range writes { + s.reconcileRoom(wr.id) + } + } var desc string if patch.Dir != nil && *patch.Dir != "" { diff --git a/internal/admin/server.go b/internal/admin/server.go index 4cceadd..9c62249 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -73,6 +73,12 @@ type AdminServer struct { // wired game callback before the cache entries drop. evictRoom func(roomID int) + // reconcileRoom refreshes a room's live runtime state (object instances, + // item-spawn entries, mob instances) from its edited definition, so a + // content edit takes effect without a restart. Wired from the Game, which + // owns MobStore; optional (nil in tests). + reconcileRoom func(roomID int) + // rebuildCraftIndex rebuilds the in-game craft index after item files // change (create/update/delete/rename/move/undo/redo). Wired from the // Game so the web editor's item edits stay consistent with gameplay. @@ -107,6 +113,7 @@ type ServerDeps struct { ReloadCourses func() RewriteRoomIDs func(map[int]int) EvictRoom func(roomID int) + ReconcileRoom func(roomID int) RebuildCraftIndex func() ConfigPath string RunOnOwner func(func()) bool @@ -161,6 +168,7 @@ func NewServer(deps ServerDeps) (*AdminServer, error) { reloadCourses: deps.ReloadCourses, rewriteRoomIDs: deps.RewriteRoomIDs, evictRoom: deps.EvictRoom, + reconcileRoom: deps.ReconcileRoom, rebuildCraftIndex: deps.RebuildCraftIndex, configPath: deps.ConfigPath, runOnOwner: deps.RunOnOwner, diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go index 2b2b060..62a83a5 100644 --- a/internal/casino/baccarat.go +++ b/internal/casino/baccarat.go @@ -185,10 +185,10 @@ func (t *baccaratTable) deal() []Event { } } events = append(events, settlement) - if p.Wallet != nil && !payoutFailed { - // The round resolved; the stakes are no longer outstanding. - p.Wallet.ResolveStakes() - } + // settleStakeOutcome already consumed this stake by handle (a payout + // removed it, a push/loss resolved it). Deliberately do NOT call the + // player-wide ResolveStakes here: it would also consume a stake left + // pending by an earlier failed payout, which must stay refundable. } t.resetRound() for _, p := range t.players.ordered() { diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go index 3b08ba5..cb71034 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -592,14 +592,12 @@ func (t *blackjackTable) settle() []Event { if !p.HasBet { continue } - payoutFailed := false for _, hand := range p.hands { result, payout, settleErr := settleHand(hand, dealerTotal, dealerBlackjack, t.Rules) // One shared settlement path for every game: pay before // announcing, and leave the stakes pending on any failure so a // refund can never be paid on top of a payout that was not durable. if settleStakeOutcome(p.Wallet, payout, hand.stakes, settleErr) { - payoutFailed = true publicResult, _ := blackjackResultMessages(p.Name, result, payout) settlement := t.playerAction(p.Name, publicResult, payoutFailedMessage()) colorizeBlackjackSettlement(&settlement, result) @@ -618,7 +616,6 @@ func (t *blackjackTable) settle() []Event { insurancePayout, insuranceErr = MulPayout(p.insurance, 1+t.Rules.InsurancePayout) } if settleStakeOutcome(p.Wallet, insurancePayout, []string{p.insuranceStakeID}, insuranceErr) { - payoutFailed = true events = append(events, t.private(p.Name, payoutFailedMessage())) } else if insurancePayout > 0 { events = append(events, t.private(p.Name, fmt.Sprintf("Insurance pays %d credits.", insurancePayout))) @@ -626,11 +623,12 @@ func (t *blackjackTable) settle() []Event { events = append(events, t.private(p.Name, "Insurance pays 0 credits.")) } } - if p.Wallet != nil && !payoutFailed { - // The round resolved; the stakes are no longer pending and must - // never be refunded. - p.Wallet.ResolveStakes() - } + // Each hand's stakes were already consumed per-handle by + // settleStakeOutcome (a payout removes them in PayCredits; a loss + // resolves them), and the insurance stake likewise. Deliberately do + // NOT call the player-wide ResolveStakes here: it would also consume + // a stake left pending by an earlier failed payout, which must stay + // refundable (see the Wallet contract). } t.resetRound() for _, p := range t.players.ordered() { @@ -781,9 +779,15 @@ func (t *blackjackTable) markDisconnectedLocked(name string) []Event { } if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != nil { if t.phase == blackjackPlaying || t.phase == blackjackInsurance { - // A disconnected player forfeits the round in flight: consume the - // stakes now so a later refund cannot return them. - p.Wallet.ResolveStakes() + // A disconnected player forfeits the round in flight: consume + // ONLY this round's stakes, by handle, so a stake left pending by + // an earlier failed payout stays refundable. + var ids []string + for _, hand := range p.hands { + ids = append(ids, hand.stakes...) + } + ids = append(ids, p.insuranceStakeID) + resolveStakeIDs(p.Wallet, ids) } else { // No card has been dealt for this stake yet, so refund it: nothing // the bettor paid for has been revealed or played. diff --git a/internal/casino/config_validate.go b/internal/casino/config_validate.go index 9b9759c..6181883 100644 --- a/internal/casino/config_validate.go +++ b/internal/casino/config_validate.go @@ -16,8 +16,10 @@ func (c MachineConfig) Validate() error { if c.Game != GameSlots { return fmt.Errorf("unsupported machine game %q", c.Game) } - if c.Variant != "" && c.Variant != "huff_and_even_more_puff" { - return fmt.Errorf("unsupported slots variant %q", c.Variant) + if c.Variant != "" { + if _, ok := slotVariants[c.Variant]; !ok { + return fmt.Errorf("unsupported slots variant %q", c.Variant) + } } if c.SpinTicks < 0 { return fmt.Errorf("spin_ticks must not be negative") @@ -38,55 +40,74 @@ func (c TableConfig) Validate() error { if c.MaxPlayers < 0 || c.BettingTicks < 0 { return fmt.Errorf("max_players and betting_ticks must not be negative") } - switch c.Game { - case GameBlackjack: - b := c.Blackjack - if err := validateShoe(b.Decks, b.ShuffleAt); err != nil { - return err - } - if b.MaxSplitHands < 0 || b.ActionTicks < 0 { - return fmt.Errorf("max_split_hands and action_ticks must not be negative") - } - if !finiteNonnegative(b.BlackjackPayout) || !finiteNonnegative(b.InsurancePayout) { - return fmt.Errorf("blackjack_payout and insurance_payout must be finite and nonnegative") - } - if b.BlackjackPayout > maxBlackjackPayout { - return fmt.Errorf("blackjack_payout %v exceeds the %d:1 cap", b.BlackjackPayout, maxBlackjackPayout) - } - if b.InsurancePayout > maxInsurancePayout { - return fmt.Errorf("insurance_payout %v exceeds the %d:1 cap", b.InsurancePayout, maxInsurancePayout) - } - rules := b.Normalize() - if rules.AllowsSurrender() && c.Normalize().MinBet < minimumSurrenderBet { - return fmt.Errorf("surrender needs min_bet >= %d: a surrendered hand returns half the bet, which rounds to zero below that", minimumSurrenderBet) - } - // Shoe coverage is NOT a configuration floor. blackjackRoundCards is - // only a boundary heuristic (an all-ace hand can hold more than - // eleven cards), so a hard floor would reject tables that play fine - // and, because a room is refused whole on any error, would darken - // every casino game in it. shoe.ensureForRound reshuffles at the - // round boundary and draw()'s exhausted-shoe rebuild is a deliberate - // shuffle-through instead. - return nil - case GameBaccarat: - b := c.Baccarat - if err := validateShoe(b.Decks, b.ShuffleAt); err != nil { - return err - } - if !finiteNonnegative(b.TiePayout) || !finiteNonnegative(b.BankerPayout) { - return fmt.Errorf("tie_payout and banker_payout must be finite and nonnegative") - } - if b.TiePayout > maxTiePayout { - return fmt.Errorf("tie_payout %v exceeds the %d:1 cap", b.TiePayout, maxTiePayout) - } - if b.BankerPayout > maxBankerPayout { - return fmt.Errorf("banker_payout %v exceeds the %v:1 cap", b.BankerPayout, maxBankerPayout) - } - // Non-blocking for the same reason as blackjack above. - return nil - default: + def, ok := tableGameDefFor(c.Game) + if !ok { return fmt.Errorf("unsupported table game %q", c.Game) } + return def.validate(c) +} + +// normalizeBlackjackTable applies the blackjack rule defaults. It is the +// game's Normalize entry in tableGames. +func normalizeBlackjackTable(c TableConfig) TableConfig { + c.Blackjack = c.Blackjack.Normalize() + return c +} + +// normalizeBaccaratTable applies the baccarat rule defaults. It is the game's +// Normalize entry in tableGames. +func normalizeBaccaratTable(c TableConfig) TableConfig { + c.Baccarat = c.Baccarat.Normalize() + return c +} + +func validateBlackjackTable(c TableConfig) error { + b := c.Blackjack + if err := validateShoe(b.Decks, b.ShuffleAt); err != nil { + return err + } + if b.MaxSplitHands < 0 || b.ActionTicks < 0 { + return fmt.Errorf("max_split_hands and action_ticks must not be negative") + } + if !finiteNonnegative(b.BlackjackPayout) || !finiteNonnegative(b.InsurancePayout) { + return fmt.Errorf("blackjack_payout and insurance_payout must be finite and nonnegative") + } + if b.BlackjackPayout > maxBlackjackPayout { + return fmt.Errorf("blackjack_payout %v exceeds the %d:1 cap", b.BlackjackPayout, maxBlackjackPayout) + } + if b.InsurancePayout > maxInsurancePayout { + return fmt.Errorf("insurance_payout %v exceeds the %d:1 cap", b.InsurancePayout, maxInsurancePayout) + } + rules := b.Normalize() + if rules.AllowsSurrender() && c.Normalize().MinBet < minimumSurrenderBet { + return fmt.Errorf("surrender needs min_bet >= %d: a surrendered hand returns half the bet, which rounds to zero below that", minimumSurrenderBet) + } + // Shoe coverage is NOT a configuration floor. blackjackRoundCards is + // only a boundary heuristic (an all-ace hand can hold more than + // eleven cards), so a hard floor would reject tables that play fine + // and, because a room is refused whole on any error, would darken + // every casino game in it. shoe.ensureForRound reshuffles at the + // round boundary and draw()'s exhausted-shoe rebuild is a deliberate + // shuffle-through instead. + return nil +} + +func validateBaccaratTable(c TableConfig) error { + b := c.Baccarat + if err := validateShoe(b.Decks, b.ShuffleAt); err != nil { + return err + } + if !finiteNonnegative(b.TiePayout) || !finiteNonnegative(b.BankerPayout) { + return fmt.Errorf("tie_payout and banker_payout must be finite and nonnegative") + } + if b.TiePayout > maxTiePayout { + return fmt.Errorf("tie_payout %v exceeds the %d:1 cap", b.TiePayout, maxTiePayout) + } + if b.BankerPayout > maxBankerPayout { + return fmt.Errorf("banker_payout %v exceeds the %v:1 cap", b.BankerPayout, maxBankerPayout) + } + // Non-blocking for the same reason as blackjack above. + return nil } func validateIdentityAndBets(id string, minBet, maxBet int) error { @@ -234,6 +255,9 @@ func (p SlotProfile) Validate() error { // validation and for newMachine. func machineSlotProfile(c MachineConfig) SlotProfile { profile := DefaultHuffAndPuffProfile() + if factory, ok := slotVariants[c.Variant]; ok { + profile = factory() + } if c.Payout > 0 { profile = ScaleToRTP(profile, c.Payout) } @@ -277,12 +301,25 @@ func ValidateRoomConfigs(tables []TableConfig, machines []MachineConfig) error { check("table", c.ID, c.Validate()) checkAddressable(strings.ToLower(string(c.Game)), c.ID) } + // A machine addresses by its variant when one is declared; a bare game + // name is addressable only when exactly one machine in the room declares + // it, so a room may host several variants of one game (and the validator + // no longer rejects the second variant as a duplicate game). + machineGameCount := make(map[string]int, len(machines)) + for _, c := range machines { + machineGameCount[strings.ToLower(string(c.Game))]++ + } for _, c := range machines { check("machine", c.ID, c.Validate()) - checkAddressable(strings.ToLower(string(c.Game)), c.ID) if c.Variant != "" { checkAddressable(strings.ToLower(c.Variant), c.ID) } + // A variant-less machine is addressed by its game name, so two of them + // collide; a variant-ful machine only claims the bare game name when it + // is the sole machine that declares it. + if c.Variant == "" || machineGameCount[strings.ToLower(string(c.Game))] == 1 { + checkAddressable(strings.ToLower(string(c.Game)), c.ID) + } } return errors.Join(errs...) } diff --git a/internal/casino/core.go b/internal/casino/core.go index 7ac728f..0b9d0af 100644 --- a/internal/casino/core.go +++ b/internal/casino/core.go @@ -105,10 +105,17 @@ func (c *tableCore[P]) advanceBettingLocked(resolve func() []Event, reset func() // refundAllLocked refunds every committed wager through its wallet exactly // once and returns the events. The caller resets its own round state // afterwards, so a refunded round can never settle and pay again. +// +// It deliberately does NOT gate on p.HasBet: a stake whose payout could not +// be persisted stays pending after resetRound clears HasBet, and a shutdown +// or room eviction must still return it. RefundOutstanding is the player-wide +// ledger and a player can only participate in one casino game at a time, so +// refunding every seated wallet is safe and returns exactly that player's +// outstanding stakes. func (c *tableCore[P]) refundAllLocked() []Event { var events []Event for _, p := range c.players.participants() { - if !p.HasBet || p.Wallet == nil { + if p.Wallet == nil { continue } name := p.Name @@ -127,6 +134,22 @@ func (c *tableCore[P]) refundAllLocked() []Event { return events } +// resolveStakeIDs consumes each listed pending stake by wallet handle. It is +// the per-handle counterpart to ResolveStakes: use it whenever another stake +// may still be pending for the same player (for example an earlier payout +// that failed to persist), so consuming one round's stakes cannot destroy an +// unrelated refundable stake. +func resolveStakeIDs(w Wallet, ids []string) { + if w == nil { + return + } + for _, id := range ids { + if id != "" { + w.ResolveStake(id) + } + } +} + // settleStakeOutcome applies one wager's outcome through the wallet and // reports whether the settlement could NOT be made durable. It carries the // money-critical ordering shared by every table game (documented in diff --git a/internal/casino/fix_regressions_test.go b/internal/casino/fix_regressions_test.go new file mode 100644 index 0000000..dc615ec --- /dev/null +++ b/internal/casino/fix_regressions_test.go @@ -0,0 +1,121 @@ +package casino + +// Regressions for the money-safety fixes: table settlement must consume only +// its own round's stakes (never the player-wide ledger), a shutdown/eviction +// refund must still return a stake left pending after the round reset, and a +// disconnect during the slot payout window must settle a revealed win. + +import "testing" + +// TestBlackjackSecondRoundDoesNotResolveFailedPayoutStake: round 1's payout +// cannot be persisted, leaving its stake open for recovery. Round 2 then +// loses; the table must resolve only round 2's stake, not call the +// player-wide ResolveStakes and erase round 1's refund evidence. +func TestBlackjackSecondRoundDoesNotResolveFailedPayoutStake(t *testing.T) { + table := newBlackjackTable(1, TableConfig{ID: "bj", Game: GameBlackjack}, newTestRand()) + // Draw order: round 1 dealer 10,7 (17) vs alice 10,8 (18, win); + // round 2 dealer 10,10 (20) vs alice 10,8 (18, loss). + table.shoe = tableauShoe( + card{rank: "10"}, card{rank: "7"}, card{rank: "10"}, card{rank: "8"}, + card{rank: "10"}, card{rank: "10"}, card{rank: "10"}, card{rank: "8"}, + ) + wallet := &failingPayoutWallet{testWallet{credits: 100}} + table.join("alice") + + table.bet("alice", "", 10, wallet) + table.action("alice", "stand") + if len(wallet.open) != 1 { + t.Fatalf("setup: a failed payout must leave round 1's stake open, open=%+v", wallet.open) + } + firstID := wallet.open[0].ID + + table.bet("alice", "", 10, wallet) + table.action("alice", "stand") + if len(wallet.open) != 1 || wallet.open[0].ID != firstID { + t.Fatalf("round 2 erased the failed-payout stake: open=%+v, want only %q", wallet.open, firstID) + } +} + +// TestBlackjackShutdownRefundsFailedPayoutAfterReset: once the round that +// held the stake has reset (HasBet cleared), a shutdown/eviction refund must +// still return the pending stake rather than skip the seated player. +func TestBlackjackShutdownRefundsFailedPayoutAfterReset(t *testing.T) { + table := newBlackjackTable(1, TableConfig{ID: "bj", Game: GameBlackjack}, newTestRand()) + table.shoe = tableauShoe( + card{rank: "10"}, card{rank: "7"}, card{rank: "10"}, card{rank: "8"}, + ) + wallet := &failingPayoutWallet{testWallet{credits: 100}} + table.join("alice") + table.bet("alice", "", 10, wallet) + table.action("alice", "stand") + if table.phase != blackjackWaiting { + t.Fatalf("setup: the round should have reset (phase=%v)", table.phase) + } + if len(wallet.open) != 1 { + t.Fatalf("setup: a failed payout must leave the stake open, open=%+v", wallet.open) + } + + table.refundAll() + if len(wallet.open) != 0 { + t.Fatalf("shutdown refund skipped the reset round's pending stake: open=%+v", wallet.open) + } + // The failed payout means the stake was never consumed, so the refund + // makes the player whole at 100 (the pre-fix code skipped the seated + // player and left them at 90). + if got := wallet.credits + wallet.paid; got != 100 { + t.Fatalf("refund returned balance %d, want 100 (stake returned once)", got) + } +} + +// TestRoomAllowsDistinctSlotVariants: a room may host several variants of one +// game (each addressed by its variant), while two variant-less machines of the +// same game remain an unreachable duplicate. +func TestRoomAllowsDistinctSlotVariants(t *testing.T) { + slotVariants["test_second_variant"] = func() SlotProfile { return DefaultHuffAndPuffProfile() } + defer delete(slotVariants, "test_second_variant") + + err := ValidateRoomConfigs(nil, []MachineConfig{ + {ID: "slots_a", Game: GameSlots, Variant: "huff_and_even_more_puff"}, + {ID: "slots_b", Game: GameSlots, Variant: "test_second_variant"}, + }) + if err != nil { + t.Fatalf("two distinct slot variants rejected: %v", err) + } + + err = ValidateRoomConfigs(nil, []MachineConfig{ + {ID: "slots_a", Game: GameSlots}, + {ID: "slots_b", Game: GameSlots}, + }) + if err == nil { + t.Fatal("two variant-less machines of one game accepted despite being unaddressable") + } +} + +// TestSlotsDisconnectDuringPayoutWindowSettlesRevealedWin: once every reel has +// stopped the result is visible to the player, so a disconnect during the +// cosmetic payout delay must pay the shown win and consume the stake rather +// than forfeit both. +func TestSlotsDisconnectDuringPayoutWindowSettlesRevealedWin(t *testing.T) { + machine := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 1}, newTestRand()) + wallet := &testWallet{credits: 100} + machine.join("alice") + machine.setBet("alice", 10, wallet) + machine.spin = &SlotSpin{Payout: 20} // deterministic winning spin + for i := 0; i < 20 && machine.phase != machinePayout; i++ { + machine.tick() + } + if machine.phase != machinePayout { + t.Fatalf("setup: expected the payout window (phase=%v)", machine.phase) + } + + machine.markDisconnected("alice") + if wallet.paid != 20 { + t.Fatalf("revealed win was not paid on disconnect: paid=%d, want 20", wallet.paid) + } + if len(wallet.open) != 0 { + t.Fatalf("resolved round left a stake outstanding: open=%+v", wallet.open) + } + if got := wallet.credits + wallet.paid; got != 110 { + t.Fatalf("balance after payout = %d, want 110", got) + } +} diff --git a/internal/casino/machine.go b/internal/casino/machine.go index 97b846d..7821975 100644 --- a/internal/casino/machine.go +++ b/internal/casino/machine.go @@ -402,12 +402,23 @@ func (m *Machine) markDisconnected(name string) []Event { if !m.owns(name) { return nil } + // A spin whose reels have all stopped has already been revealed to the + // player, so its result is final. Settle it through the normal path + // before the seat is reset: otherwise a disconnect during the cosmetic + // payout delay forfeits an already-shown win and consumes the stake for a + // spin the player watched land. finishSpin consumes the stake itself on a + // resolved round (or banks a feature win), so resetSeat below neither + // double-pays nor double-consumes. + var events []Event + if m.phase == machinePayout && m.spin != nil { + events = append(events, m.finishSpin()...) + } bank := int(m.featureBank) wallet := m.player.Wallet stakeFailed := m.stakeFailed m.resetSeat() - events := []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventLeft, Player: name, - Message: fmt.Sprintf("%s stands up from the slots machine.", name), Color: "casino_action", Public: true}} + events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventLeft, Player: name, + Message: fmt.Sprintf("%s stands up from the slots machine.", name), Color: "casino_action", Public: true}) if bank <= 0 || wallet == nil { return events } diff --git a/internal/casino/registry.go b/internal/casino/registry.go new file mode 100644 index 0000000..9a973d4 --- /dev/null +++ b/internal/casino/registry.go @@ -0,0 +1,59 @@ +package casino + +import "math/rand" + +// tableGameDef is the single registration point for a multiplayer table game. +// A game's validation, rule defaults, and constructor live together here, so +// adding a game is one entry instead of edits to three independent switches +// (TableConfig.Validate, TableConfig.Normalize, and newTable) that could +// silently drift apart. The rule structs stay on TableConfig; this does not +// introduce a config union. +type tableGameDef struct { + // validate checks a raw (pre-Normalize) table config. + validate func(TableConfig) error + // normalize applies this game's rule defaults to a table config. + normalize func(TableConfig) TableConfig + // new builds the game. It is called only after validate passes and the + // shared table config has been normalized. + new func(roomID int, cfg TableConfig, rng *rand.Rand) tableGame +} + +// tableGames is populated in init, not in a var initializer: the constructors +// transitively call TableConfig.Normalize, which consults tableGames, and Go +// rejects that as an initialization cycle when the map is a literal. +var tableGames map[GameName]tableGameDef + +func init() { + tableGames = map[GameName]tableGameDef{ + GameBlackjack: { + validate: validateBlackjackTable, + normalize: normalizeBlackjackTable, + new: func(roomID int, cfg TableConfig, rng *rand.Rand) tableGame { + return newBlackjackTable(roomID, cfg, rng) + }, + }, + GameBaccarat: { + validate: validateBaccaratTable, + normalize: normalizeBaccaratTable, + new: func(roomID int, cfg TableConfig, rng *rand.Rand) tableGame { + return newBaccaratTable(roomID, cfg, rng) + }, + }, + } +} + +// tableGameDefFor returns the registration for a game, or false when the game +// has no multiplayer table implementation. +func tableGameDefFor(game GameName) (tableGameDef, bool) { + def, ok := tableGames[game] + return def, ok +} + +// slotVariants maps a machine variant name to the profile that variant plays. +// This is the content seam for a new slot cabinet: a variant is a profile +// lookup, not a new code path through Validate and the machine constructor. +// The map is shared by MachineConfig.Validate and machineSlotProfile so the +// accepted set and the played set cannot disagree. +var slotVariants = map[string]func() SlotProfile{ + "huff_and_even_more_puff": DefaultHuffAndPuffProfile, +} diff --git a/internal/casino/table.go b/internal/casino/table.go index e3104ae..408e5fe 100644 --- a/internal/casino/table.go +++ b/internal/casino/table.go @@ -72,13 +72,12 @@ type Table struct { // multiplayer table implementation. func newTable(roomID int, cfg TableConfig, rng *rand.Rand) *Table { cfg = cfg.Normalize() - var game tableGame - switch cfg.Game { - case GameBlackjack: - game = newBlackjackTable(roomID, cfg, rng) - case GameBaccarat: - game = newBaccaratTable(roomID, cfg, rng) - default: + def, ok := tableGameDefFor(cfg.Game) + if !ok { + return nil + } + game := def.new(roomID, cfg, rng) + if game == nil { return nil } // The Table and the game share the game's base, so RoomID/Config are a diff --git a/internal/casino/types.go b/internal/casino/types.go index 0400df4..e057ba6 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -176,11 +176,11 @@ func (c TableConfig) Normalize() TableConfig { if c.MaxBet <= 0 { c.MaxBet = DefaultMaxBet } - // Normalize the nested game rule blocks too: a new game that reads - // t.Config.Blackjack/Baccarat directly (rather than only t.Rules) would - // otherwise see zero values and build an empty shoe. - c.Blackjack = c.Blackjack.Normalize() - c.Baccarat = c.Baccarat.Normalize() + // Normalize the game's own rule block through its registration, so a new + // game gets its defaults without a third switch to remember. + if def, ok := tableGameDefFor(c.Game); ok { + c = def.normalize(c) + } if c.MaxBet < c.MinBet { c.MaxBet = c.MinBet } diff --git a/internal/game/act_burn.go b/internal/game/act_burn.go index a823b19..877fd78 100644 --- a/internal/game/act_burn.go +++ b/internal/game/act_burn.go @@ -431,7 +431,7 @@ func (g *Game) FireTick() { for _, st := range g.World.AllFireStates() { st.Quality-- if st.Quality <= 0 { - g.World.RemoveObjInstance(st.RoomID, st.DefID) + g.World.RemoveObjInstanceIndex(st.RoomID, st.DefID, st.Index) g.cancelStokers(st.RoomID) if def, err := g.ObjectStore.Load(st.DefID); err == nil && def.RemovalItem != "" { g.World.AddGroundItem(st.RoomID, def.RemovalItem, 1) diff --git a/internal/game/casino.go b/internal/game/casino.go index c52e5eb..58e1e69 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -116,9 +116,14 @@ func (w casinoWallet) ResolveStakes() { if len(w.p.PendingWagers) == 0 { return } + before := append([]player.PendingWager(nil), w.p.PendingWagers...) w.p.PendingWagers = nil if err := w.g.AccountStore.SaveCharacter(w.p); err != nil { - fmt.Fprintf(os.Stderr, "casino resolve stakes: save for %s: %v\n", w.p.Name, err) + // 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. + w.p.PendingWagers = before + fmt.Fprintf(os.Stderr, "casino resolve stakes: save for %s: %v (ledger restored in memory)\n", w.p.Name, err) } } @@ -127,11 +132,18 @@ func (w casinoWallet) ResolveStakes() { // already removed it (PayCredits) makes this a no-op, so no redundant // save is paid. func (w casinoWallet) ResolveStake(stakeID string) { - if stakeID == "" || !removePendingWager(w.p, stakeID) { + if stakeID == "" { + return + } + removed := takePendingWagers(w.p, []string{stakeID}) + if len(removed) == 0 { return } if err := w.g.AccountStore.SaveCharacter(w.p); err != nil { - fmt.Fprintf(os.Stderr, "casino resolve stake: save for %s: %v\n", w.p.Name, err) + // 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...) + fmt.Fprintf(os.Stderr, "casino resolve stake: save for %s: %v (ledger restored in memory)\n", w.p.Name, err) } } @@ -260,8 +272,25 @@ func (g *Game) casinoMachine(roomID int, gameName string) (*casino.Machine, bool if casino.ValidateRoomConfigs(room.CasinoTables, room.CasinoMachines) != nil { return nil, false } + // Prefer an exact variant match first: a room may host several variants of + // one game, where a bare game name is ambiguous. + for _, cfg := range room.CasinoMachines { + if cfg.Variant != "" && strings.EqualFold(cfg.Variant, gameName) { + machine := g.Casino.EnsureMachine(roomID, cfg) + return machine, machine != nil + } + } + // Then a bare game name: prefer a variant-less machine so a machine that + // declares its own variant is reached by that variant, and a sole machine + // of a game is still reachable by the game name. + for _, cfg := range room.CasinoMachines { + if cfg.Variant == "" && strings.EqualFold(string(cfg.Game), gameName) { + machine := g.Casino.EnsureMachine(roomID, cfg) + return machine, machine != nil + } + } for _, cfg := range room.CasinoMachines { - if strings.EqualFold(string(cfg.Game), gameName) || strings.EqualFold(cfg.Variant, gameName) { + if strings.EqualFold(string(cfg.Game), gameName) { machine := g.Casino.EnsureMachine(roomID, cfg) return machine, machine != nil } diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go index 21aaf4c..b16e4e1 100644 --- a/internal/game/combat_mob.go +++ b/internal/game/combat_mob.go @@ -330,10 +330,16 @@ func (g *Game) awardKillDrops(sess *net.Session, p *player.Player, mob *world.Mo // scheduleMobRespawn removes trigger-spawned mobs or schedules a normal respawn. func (g *Game) scheduleMobRespawn(mob *world.MobInstance) { - instanceID := mob.InstanceID + if mob == nil { + return + } if mob.SpawnedByTrigger { g.SubscribeTicks(10, func() bool { - g.MobStore.RemoveInstance(instanceID) + // Read the ID at fire time: a room-ID migration may have rewritten + // it while the timer was pending. + if id := mob.InstanceID; id != "" { + g.MobStore.RemoveInstance(id) + } return false }) return @@ -343,7 +349,11 @@ func (g *Game) scheduleMobRespawn(mob *world.MobInstance) { respawnTicks = engine.ToTicks(30) } g.SubscribeTicks(respawnTicks, func() bool { - g.respawnMob(instanceID) + // Re-read the current ID at fire time instead of capturing the string: + // RekeyRooms rewrites an instance's key when its home room is rekeyed, + // so a captured ID would miss, the mob would stay at HP 0 forever, and + // SeedMobs would skip the (now-correct) key as already present. + g.respawnMob(mob.InstanceID) return false }) } diff --git a/internal/game/room_reconcile.go b/internal/game/room_reconcile.go new file mode 100644 index 0000000..7491776 --- /dev/null +++ b/internal/game/room_reconcile.go @@ -0,0 +1,21 @@ +package game + +// ReconcileRoom refreshes a room's live runtime state from its on-disk +// definition after a content edit, without a restart. Object instances and +// item-spawn ground entries are reconciled by World; mob instances are dropped +// and reseeded here because MobStore is owned by the game layer. Player-dropped +// ground items and live reservations are preserved. +// +// It runs on the owner goroutine (the admin callbacks that call it already do) +// and must not redispatch. +func (g *Game) ReconcileRoom(roomID int) { + room, err := g.World.LoadRoom(roomID) + if err != nil { + return + } + g.World.ReconcileRoomState(roomID, room) + if g.MobStore != nil { + g.MobStore.RemoveMobsInRoom(roomID) + g.MobStore.SeedMobs(roomID, room.Mobs) + } +} diff --git a/internal/game/snapshot_render_guard_test.go b/internal/game/snapshot_render_guard_test.go new file mode 100644 index 0000000..83833d5 --- /dev/null +++ b/internal/game/snapshot_render_guard_test.go @@ -0,0 +1,40 @@ +package game + +// Guard for the last per-game presentation seam: the Snapshot marker interface +// guarantees an event carries a structured view, but renderCasinoSnapshot's +// type switch silently returns "" for a snapshot it does not know. This +// source-level test pairs every `func (*X) snapshot() {}` in the casino package +// with a `case *casino.X:` in the renderer, so adding a game cannot silently +// drop its view (the text message would still appear, hiding the loss). + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +func TestEveryCasinoSnapshotHasARenderer(t *testing.T) { + typesSrc, err := os.ReadFile(filepath.Join("..", "casino", "types.go")) + if err != nil { + t.Fatalf("read casino types: %v", err) + } + renderSrc, err := os.ReadFile("casino.go") + if err != nil { + t.Fatalf("read renderer: %v", err) + } + + re := regexp.MustCompile(`func \(\*(\w+)\) snapshot\(\)`) + matches := re.FindAllStringSubmatch(string(typesSrc), -1) + if len(matches) == 0 { + t.Fatal("no Snapshot implementations found in casino/types.go") + } + for _, m := range matches { + name := m[1] + want := "case *casino." + name + ":" + if !strings.Contains(string(renderSrc), want) { + t.Errorf("snapshot %s has no renderer: add `%s` to renderCasinoSnapshot", name, want) + } + } +} diff --git a/internal/game/sys_safespot.go b/internal/game/sys_safespot.go index 2529471..7876b38 100644 --- a/internal/game/sys_safespot.go +++ b/internal/game/sys_safespot.go @@ -204,7 +204,7 @@ func (g *Game) degradeSafespot(st *world.ObjState, objDef *object.ObjectDef, cfg if cfg.RespawnTicks > 0 { g.scheduleSafespotRespawn(st, objDef, cfg) } else { - g.World.RemoveObjInstance(st.RoomID, objDef.ID) + g.World.RemoveObjInstanceIndex(st.RoomID, objDef.ID, st.Index) } return } @@ -227,8 +227,9 @@ func (g *Game) scheduleSafespotRespawn(st *world.ObjState, objDef *object.Object } roomID := st.RoomID defID := objDef.ID + index := st.Index g.SubscribeTicks(engine.ToTicks(cfg.RespawnTicks), func() bool { - respawnSt := g.World.GetObjState(roomID, defID, 0) + respawnSt := g.World.GetObjState(roomID, defID, index) if respawnSt == nil { return false } diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go index 61eb325..7a6466a 100644 --- a/internal/game/sys_triggers.go +++ b/internal/game/sys_triggers.go @@ -418,7 +418,11 @@ func (g *Game) TransientMobTick() { } } if inAllowed { - inst.DespawnCounter = inst.DespawnTickCount() + // The owner is back inside the allowed rooms: reset the grace + // period. (The old code assigned DespawnTickCount() back to the + // same field, a no-op that made the countdown cumulative across + // brief absences.) + inst.ResetDespawnCounter(engine.ToTicks(inst.DespawnTicks)) continue } inst.DecrementDespawnCounter() diff --git a/internal/world/fix_regressions_test.go b/internal/world/fix_regressions_test.go new file mode 100644 index 0000000..f2ef795 --- /dev/null +++ b/internal/world/fix_regressions_test.go @@ -0,0 +1,153 @@ +package world + +import "testing" + +// TestEnsureObjectStatesDoesNotDuplicateWanderer: a wandering object leaves its +// home room's key behind. Re-entering the home room must recognize the state by +// HomeRoomID and not create a second, independent instance (which would make the +// object visible/gatherable in two rooms and eventually silently discard one +// state when they collide). +func TestEnsureObjectStatesDoesNotDuplicateWanderer(t *testing.T) { + w := New(t.TempDir()) + w.AddRoomPath(1, "room-1") + w.AddRoomPath(2, "room-2") + w.EnsureObjectStates(1, []string{"fishing_spot"}) + st := w.GetObjState(1, "fishing_spot", 0) + if st == nil { + t.Fatal("setup: object state was not created") + } + st.WanderRooms = []int{2} + st.WanderInterval = 1 + w.TickObjWander() + + if w.GetObjState(2, "fishing_spot", 0) == nil { + t.Fatal("setup: the object did not wander to room 2") + } + + // Re-enter the home room: it must not spawn a duplicate. + w.EnsureObjectStates(1, []string{"fishing_spot"}) + if got := len(w.AllObjInstances(1)); got != 0 { + t.Fatalf("home room shows %d instances while the object wanders; want 0", got) + } + if got := len(w.AllObjInstances(2)); got != 1 { + t.Fatalf("wandered room shows %d instances; want 1", got) + } +} + +// TestRemoveObjInstanceIndexRemovesOnlyNamed: a room may declare the same object +// twice. Destroying one instance must remove exactly that index, not every +// state for the defID (which erased an untouched sibling). +func TestRemoveObjInstanceIndexRemovesOnlyNamed(t *testing.T) { + w := New(t.TempDir()) + w.EnsureObjectStates(1, []string{"safespot", "safespot"}) + if got := len(w.AllObjInstances(1)); got != 2 { + t.Fatalf("setup: expected two instances, got %d", got) + } + + w.RemoveObjInstanceIndex(1, "safespot", 1) + got := w.AllObjInstances(1) + if len(got) != 1 { + t.Fatalf("removing index 1 left %d instances; want 1", len(got)) + } + if got[0].Index != 0 { + t.Fatalf("removed the wrong instance: surviving index=%d, want 0", got[0].Index) + } +} + +// TestReconcileRoomStateAddsPrunesAndPreserves: a live content edit must add +// newly declared objects/spawns and drop removed ones, while preserving +// dynamic instances (fires) and player-dropped ground items, and must not +// double a spawn on a second reconcile. +func TestReconcileRoomStateAddsPrunesAndPreserves(t *testing.T) { + w := New(t.TempDir()) + w.EnsureObjectStates(1, []string{"anvil", "anvil"}) + w.AddObjInstance(1, "fire", 5) // dynamic instance, not in the room file + w.AddGroundItem(1, "loot", 1) // player-dropped, not a spawn + w.mu.Lock() + w.seeded[1] = true + w.mu.Unlock() + + room := &Room{ + ID: 1, + Objects: []RoomObject{{ID: "anvil"}}, + ItemSpawns: []SpawnDef{{ID: "ore", Quantity: 3, RespawnTicks: 5}}, + } + w.ReconcileRoomState(1, room) + + anvils, fires := 0, 0 + for _, st := range w.AllObjInstances(1) { + switch st.DefID { + case "anvil": + anvils++ + case "fire": + fires++ + } + } + if anvils != 1 { + t.Fatalf("prune left %d anvils; want 1", anvils) + } + if fires != 1 { + t.Fatalf("reconcile pruned the dynamic fire instance (fires=%d)", fires) + } + ground := w.AccessibleGround(1, "alice") + if ground["ore"] != 3 { + t.Fatalf("new item spawn quantity = %d, want 3", ground["ore"]) + } + if ground["loot"] != 1 { + t.Fatalf("reconcile destroyed a player-dropped item (loot=%d)", ground["loot"]) + } + + // A second reconcile must be idempotent (no doubled spawn). + w.ReconcileRoomState(1, room) + if got := w.AccessibleGround(1, "alice")["ore"]; got != 3 { + t.Fatalf("second reconcile doubled the spawn: ore=%d, want 3", got) + } + + // Removing the spawn from the definition drops its ground entry. + w.ReconcileRoomState(1, &Room{ID: 1, Objects: []RoomObject{{ID: "anvil"}}}) + if got := w.AccessibleGround(1, "alice")["ore"]; got != 0 { + t.Fatalf("removed item spawn still on the ground: ore=%d", got) + } +} + +// TestReconcileRoomStateLeavesVisitingWanderer: reconciling a room must not +// prune an object that merely wandered in from a different home room. +func TestReconcileRoomStateLeavesVisitingWanderer(t *testing.T) { + w := New(t.TempDir()) + w.AddRoomPath(1, "room-1") + w.AddRoomPath(2, "room-2") + w.EnsureObjectStates(1, []string{"fishing_spot"}) + st := w.GetObjState(1, "fishing_spot", 0) + st.WanderRooms = []int{2} + st.WanderInterval = 1 + w.TickObjWander() + if w.GetObjState(2, "fishing_spot", 0) == nil { + t.Fatal("setup: the object did not wander to room 2") + } + + // Room 2 declares nothing; its reconcile must leave the visitor alone. + w.ReconcileRoomState(2, &Room{ID: 2}) + if w.GetObjState(2, "fishing_spot", 0) == nil { + t.Fatal("reconcile pruned a wanderer visiting from another home room") + } +} + +// TestRekeyRoomsRemapsHomeRoomID: identity is anchored to HomeRoomID, so a room +// rekey must remap it alongside RoomID or a wanderer can be duplicated after +// the rename. +func TestRekeyRoomsRemapsHomeRoomID(t *testing.T) { + w := New(t.TempDir()) + w.EnsureObjectStates(1, []string{"fishing_spot"}) + st := w.GetObjState(1, "fishing_spot", 0) + if st.HomeRoomID != 1 { + t.Fatalf("setup: HomeRoomID=%d, want 1", st.HomeRoomID) + } + w.RekeyRooms(map[int]int{1: 3}) + moved := w.GetObjState(3, "fishing_spot", 0) + if moved == nil { + t.Fatal("object state lost after rekey") + } + if moved.HomeRoomID != 3 { + t.Fatalf("HomeRoomID after rekey = %d, want 3", moved.HomeRoomID) + } +} diff --git a/internal/world/objects.go b/internal/world/objects.go index fa17f08..3c004a5 100644 --- a/internal/world/objects.go +++ b/internal/world/objects.go @@ -10,13 +10,23 @@ import ( ) type ObjState struct { - Depleted bool - DepleteTimer float64 - DefID string - Name string - Aliases []string - Index int - RoomID int + Depleted bool + DepleteTimer float64 + DefID string + Name string + Aliases []string + Index int + // RoomID is where the instance currently is; HomeRoomID is the room that + // declares it and never changes as it wanders. Identity (and re-seeding) + // is anchored to HomeRoomID so a wandering object cannot be duplicated by + // re-entering its home room. + RoomID int + HomeRoomID int + // Dynamic marks an instance created at runtime (AddObjInstance) rather + // than declared by the room file. ReconcileRoomState must not prune these: + // a live fire is not in the room's objects list and would be extinguished + // by any content edit. + Dynamic bool JustRespawned bool WanderRooms []int WanderInterval float64 @@ -56,17 +66,37 @@ func (w *World) ensureObjStates(roomID int, defIDs []string) { idx := counts[defID] counts[defID]++ key := w.ObjStateKey(roomID, defID, idx) - if _, exists := w.objStates[key]; !exists { - w.objStates[key] = &ObjState{ - DefID: defID, - Name: defID, - Index: idx, - RoomID: roomID, - } + if _, exists := w.objStates[key]; exists { + continue + } + // The object may simply be away from its home room (wandering): its + // state lives under its current room's key, so re-seeding the home + // room must not create a second, independent instance. + if w.homeObjStateLocked(roomID, defID, idx) != nil { + continue + } + w.objStates[key] = &ObjState{ + DefID: defID, + Name: defID, + Index: idx, + RoomID: roomID, + HomeRoomID: roomID, } } } +// homeObjStateLocked returns the instance whose home room is roomID and whose +// identity is (defID, index), wherever it has currently wandered. It must be +// called with w.mu held. +func (w *World) homeObjStateLocked(roomID int, defID string, index int) *ObjState { + for _, st := range w.objStates { + if st.HomeRoomID == roomID && st.DefID == defID && st.Index == index { + return st + } + } + return nil +} + func (w *World) AddObjInstance(roomID int, defID string, quality float64) { w.mu.Lock() defer w.mu.Unlock() @@ -76,13 +106,18 @@ func (w *World) AddObjInstance(roomID int, defID string, quality float64) { idx := 0 for { key := w.ObjStateKey(roomID, defID, idx) - if _, exists := w.objStates[key]; !exists { + // An index is free only if no instance currently occupies it AND no + // instance from this room has wandered away holding that index; reusing + // a wandered index would collide when the wanderer returns. + if _, exists := w.objStates[key]; !exists && w.homeObjStateLocked(roomID, defID, idx) == nil { w.objStates[key] = &ObjState{ - DefID: defID, - Name: defID, - Index: idx, - RoomID: roomID, - Quality: quality, + DefID: defID, + Name: defID, + Index: idx, + RoomID: roomID, + HomeRoomID: roomID, + Dynamic: true, + Quality: quality, } return } @@ -90,17 +125,108 @@ func (w *World) AddObjInstance(roomID int, defID string, quality float64) { } } -func (w *World) RemoveObjInstance(roomID int, defID string) { +// ReconcileRoomState brings a room's object instances and item-spawn ground +// entries in line with a (possibly just-edited) room definition, without a +// restart. Player-dropped ground items, dynamic object instances (fires), and +// live reservations are preserved. Mob instances live in MobStore and are +// reconciled by the game layer, which owns that store. +func (w *World) ReconcileRoomState(roomID int, room *Room) { w.mu.Lock() defer w.mu.Unlock() - idx := 0 - for { - key := w.ObjStateKey(roomID, defID, idx) - if _, exists := w.objStates[key]; !exists { - return + if w.objStates == nil { + w.objStates = make(map[string]*ObjState) + } + + // Prune room-declared instances the definition no longer declares. Prune + // by home room so a wandered instance is reconciled where it belongs, and + // never prune a dynamic instance. + want := make(map[string]int, len(room.Objects)) + for _, ro := range room.Objects { + want[ro.ID]++ + } + for key, st := range w.objStates { + if st.Dynamic || st.HomeRoomID != roomID { + continue + } + if st.Index >= want[st.DefID] { + delete(w.objStates, key) } + } + + // Re-create declared instances that are missing (including ones that had + // wandered away; ensureObjStates recognises them by HomeRoomID). + defIDs := make([]string, 0, len(room.Objects)) + for _, ro := range room.Objects { + defIDs = append(defIDs, ro.ID) + } + w.ensureObjStates(roomID, defIDs) + + // Ground spawns are only reconciled after the room has been seeded: before + // that, the first entry seeds from the definition anyway, and adding + // entries here would make SeedGroundItems double the quantity. + if w.seeded[roomID] { + w.reconcileGroundSpawnsLocked(roomID, room.ItemSpawns) + } +} + +// reconcileGroundSpawnsLocked adds entries for newly declared item spawns and +// drops entries for spawns the room no longer declares, leaving the live +// quantity of existing spawns (and every dropped item) untouched. It must be +// called with w.mu held. +func (w *World) reconcileGroundSpawnsLocked(roomID int, spawns []SpawnDef) { + declared := make(map[string]bool, len(spawns)) + for _, s := range spawns { + declared[strings.ToLower(s.ID)] = true + } + kept := w.groundItems[roomID][:0] + for _, e := range w.groundItems[roomID] { + if e.isSpawn && !declared[strings.ToLower(e.itemID)] { + continue + } + kept = append(kept, e) + } + w.groundItems[roomID] = kept + for _, s := range spawns { + found := false + for _, e := range w.groundItems[roomID] { + if e.isSpawn && strings.EqualFold(e.itemID, s.ID) { + found = true + break + } + } + if !found { + w.groundItems[roomID] = append(w.groundItems[roomID], &groundEntry{ + itemID: s.ID, + quantity: s.Quantity, + isSpawn: true, + respawnDelay: spawnRespawnDelay(s.RespawnTicks), + respawnQty: s.Quantity, + }) + } + } +} + +// RemoveObjInstanceIndex removes exactly one instance by identity +// (defID, index), looking it up by its current room first (roomID) and +// falling back to its home room in case it has wandered away. A room may +// legitimately declare the same object twice, so the index names which one; +// removing every instance of a defID would destroy a sibling. +func (w *World) RemoveObjInstanceIndex(roomID int, defID string, index int) { + w.mu.Lock() + defer w.mu.Unlock() + if w.objStates == nil { + return + } + key := w.ObjStateKey(roomID, defID, index) + if st, ok := w.objStates[key]; ok && st.DefID == defID && st.Index == index { delete(w.objStates, key) - idx++ + return + } + for k, st := range w.objStates { + if st.DefID == defID && st.Index == index && st.HomeRoomID == roomID { + delete(w.objStates, k) + return + } } } @@ -170,6 +296,8 @@ func (w *World) FindObjInstances(roomID int, name string) []ObjState { Aliases: st.Aliases, Index: st.Index, RoomID: st.RoomID, + HomeRoomID: st.HomeRoomID, + Dynamic: st.Dynamic, Depleted: st.Depleted, DepleteTimer: st.DepleteTimer, SharedMax: st.SharedMax, diff --git a/internal/world/world.go b/internal/world/world.go index cb53b77..7a91894 100644 --- a/internal/world/world.go +++ b/internal/world/world.go @@ -3,6 +3,7 @@ package world import ( "fmt" "log" + "math" "os" "path/filepath" "strconv" @@ -120,7 +121,7 @@ func (w *World) SeedGroundItems(roomID int) { e.quantity += s.Quantity e.respawnQty += s.Quantity e.isSpawn = true - e.respawnDelay = int(s.RespawnTicks) + e.respawnDelay = spawnRespawnDelay(s.RespawnTicks) merged = true break } @@ -130,7 +131,7 @@ func (w *World) SeedGroundItems(roomID int) { itemID: s.ID, quantity: s.Quantity, isSpawn: true, - respawnDelay: int(s.RespawnTicks), + respawnDelay: spawnRespawnDelay(s.RespawnTicks), respawnQty: s.Quantity, } w.groundItems[roomID] = append(w.groundItems[roomID], e) @@ -138,6 +139,20 @@ func (w *World) SeedGroundItems(roomID int) { } } +// spawnRespawnDelay converts a fractional respawn_ticks value to whole ticks. +// Truncating (the old int(v)) turned any 0 < v < 1 into a one-shot spawn that +// never returned; round instead and floor a positive value at one tick. +func spawnRespawnDelay(ticks float64) int { + if ticks <= 0 { + return 0 + } + delay := int(math.Round(ticks)) + if delay < 1 { + delay = 1 + } + return delay +} + func (w *World) RoomIndex() map[int]bool { w.mu.Lock() defer w.mu.Unlock() @@ -255,6 +270,9 @@ func (w *World) RekeyRooms(idMap map[int]int) { // from the renamed room (and TickObjWander then creates a second // entry for the same state). Remap wander rooms like MobStore does. m.st.RoomID = m.newID + if mapped, ok := idMap[m.st.HomeRoomID]; ok && mapped != m.st.HomeRoomID { + m.st.HomeRoomID = mapped + } for i, room := range m.st.WanderRooms { if mapped, ok := idMap[room]; ok && mapped != room { m.st.WanderRooms[i] = mapped -- cgit v1.2.3