aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-19 01:55:34 -0400
committerhistoria <[not public]>2026-09-19 01:55:34 -0400
commitcc33072eced3d5460512c09c42ec4531c807d7af (patch)
treeb1b451fa87fa389cb8343a3645372f3b43f5523e
parent2c38386484cdb507c4687caceb37e0b963e33594 (diff)
downloadthehouseoficarus-cc33072eced3d5460512c09c42ec4531c807d7af.tar.gz
fix: casino stake preservation, world state reconciliation, casino game registration
-rw-r--r--building_guide/rooms.md16
-rw-r--r--cmd/thoi/main.go2
-rw-r--r--internal/admin/api_map.go6
-rw-r--r--internal/admin/api_rooms.go5
-rw-r--r--internal/admin/api_rooms_bulk.go5
-rw-r--r--internal/admin/server.go8
-rw-r--r--internal/casino/baccarat.go8
-rw-r--r--internal/casino/blackjack.go26
-rw-r--r--internal/casino/config_validate.go137
-rw-r--r--internal/casino/core.go25
-rw-r--r--internal/casino/fix_regressions_test.go121
-rw-r--r--internal/casino/machine.go15
-rw-r--r--internal/casino/registry.go59
-rw-r--r--internal/casino/table.go13
-rw-r--r--internal/casino/types.go10
-rw-r--r--internal/game/act_burn.go2
-rw-r--r--internal/game/casino.go37
-rw-r--r--internal/game/combat_mob.go16
-rw-r--r--internal/game/room_reconcile.go21
-rw-r--r--internal/game/snapshot_render_guard_test.go40
-rw-r--r--internal/game/sys_safespot.go5
-rw-r--r--internal/game/sys_triggers.go6
-rw-r--r--internal/world/fix_regressions_test.go153
-rw-r--r--internal/world/objects.go182
-rw-r--r--internal/world/world.go22
25 files changed, 814 insertions, 126 deletions
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