aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/admin/api_rooms.go8
-rw-r--r--internal/casino/baccarat.go106
-rw-r--r--internal/casino/blackjack.go102
-rw-r--r--internal/casino/config_validate.go13
-rw-r--r--internal/casino/core.go137
-rw-r--r--internal/casino/machine.go30
-rw-r--r--internal/casino/manager.go40
-rw-r--r--internal/casino/manager_test.go34
-rw-r--r--internal/casino/money_regression_test.go37
-rw-r--r--internal/casino/slots.go7
-rw-r--r--internal/casino/table.go62
-rw-r--r--internal/casino/types.go7
-rw-r--r--internal/game/casino.go4
-rw-r--r--internal/game/cmd_queued.go2
-rw-r--r--internal/game/core_command_queue.go39
-rw-r--r--internal/game/exit_migrate.go3
-rw-r--r--internal/game/hardening_queue_cleanup_test.go25
-rw-r--r--internal/game/sys_safespot_state.go16
-rw-r--r--internal/game/sys_safespot_state_test.go19
-rw-r--r--internal/game/tick.go3
-rw-r--r--internal/persistence/txn.go6
-rw-r--r--internal/validate/checks.go101
-rw-r--r--internal/world/grid.go3
-rw-r--r--internal/world/mob.go13
-rw-r--r--internal/world/mob_rekey_test.go30
-rw-r--r--internal/world/room_migrate.go5
-rw-r--r--internal/world/room_migrate_test.go8
27 files changed, 546 insertions, 314 deletions
diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go
index 015c0ee..32a6f1c 100644
--- a/internal/admin/api_rooms.go
+++ b/internal/admin/api_rooms.go
@@ -631,8 +631,7 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) {
}
}
- conflicts := 0
- testGrid := world.BuildGrid(s.cfg.StartingRoom, func(id int) (*world.Room, bool) {
+ conflicts := world.BuildGridConflicts(s.cfg.StartingRoom, func(id int) (*world.Room, bool) {
room, err := s.world.LoadRoom(id)
if err != nil {
log.Printf("link: conflict test BuildGrid failed to load room %d: %v", id, err)
@@ -657,12 +656,9 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) {
roomCopy.Exits[oppDir] = world.ExitDef{Room: body.From}
}
return &roomCopy, true
- }, nil, nil, func(gc world.GridConflict) {
- conflicts++
})
- _ = testGrid
- if conflicts > 0 {
+ if len(conflicts) > 0 {
writeJSONError(w, "link would cause map conflicts (twists or overlaps)", http.StatusConflict)
return
}
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go
index 164d0ff..b762b6f 100644
--- a/internal/casino/baccarat.go
+++ b/internal/casino/baccarat.go
@@ -3,14 +3,13 @@ package casino
import (
"fmt"
"math/rand"
- "sync"
)
-type baccaratPhase string
-
+// Baccarat uses the shared table phases; these aliases keep the call sites
+// readable.
const (
- baccaratWaiting baccaratPhase = "waiting"
- baccaratBetting baccaratPhase = "betting"
+ baccaratWaiting = phaseWaiting
+ baccaratBetting = phaseBetting
)
// baccaratSpots are the outcomes a participant may wager on.
@@ -36,38 +35,27 @@ type baccaratPlayer struct {
func (p *baccaratPlayer) participantPtr() *Participant { return &p.Participant }
type baccaratTable struct {
- mu sync.Mutex
- tableBase
- Rules BaccaratConfig
- players seatSet[*baccaratPlayer]
- phase baccaratPhase
- window bettingWindow
- shoe *shoe
+ tableCore[*baccaratPlayer]
+ Rules BaccaratConfig
+ shoe *shoe
}
func newBaccaratTable(roomID int, cfg TableConfig, rng *rand.Rand) *baccaratTable {
cfg = cfg.Normalize()
rules := cfg.Baccarat.Normalize()
return &baccaratTable{
- tableBase: tableBase{RoomID: roomID, Config: cfg}, Rules: rules,
- players: newSeatSet[*baccaratPlayer](), phase: baccaratWaiting,
- shoe: newShoe(rules.Decks, rules.ShuffleAt, rng),
+ tableCore: newTableCore[*baccaratPlayer](&tableBase{RoomID: roomID, Config: cfg}),
+ Rules: rules,
+ shoe: newShoe(rules.Decks, rules.ShuffleAt, rng),
}
}
func (t *baccaratTable) menuEvent(name string) Event {
- e := t.private(name, t.menu())
- e.Color = "casino_menu"
- e.Menu = &MenuView{Kind: MenuBaccaratBet, Commands: []MenuCommand{
+ return t.betMenuEvent(name, MenuBaccaratBet, []MenuCommand{
{Command: "bet <amount>", Desc: "Place a wager (min/max accepted)"},
{Command: "bet <amount> on player|banker|tie", Desc: "Wager on a specific outcome"},
{Command: "stop", Desc: "Leave the table"},
- }}
- return e
-}
-
-func (t *baccaratTable) menu() string {
- return "New baccarat hand! Place your bet."
+ })
}
func (t *baccaratTable) join(name string) []Event {
@@ -79,8 +67,7 @@ func (t *baccaratTable) join(name string) []Event {
if t.players.count() >= t.Config.MaxPlayers {
return []Event{t.private(name, "That baccarat table is full.")}
}
- t.players.add(name, &baccaratPlayer{Participant: Participant{Name: name, Connected: true}})
- events := []Event{t.playerAction(name, fmt.Sprintf("%s sits down.", name), "You sit down.")}
+ events := []Event{t.addSeatLocked(name, &baccaratPlayer{Participant: Participant{Name: name, Connected: true}})}
events = append(events, t.menuEvent(name))
return events
}
@@ -97,12 +84,9 @@ func (t *baccaratTable) leave(name string) []Event {
if p.HasBet {
return []Event{t.private(name, "You're in the middle of a baccarat round!")}
}
- t.players.remove(name)
+ t.removeSeatLocked(name)
events := []Event{t.playerAction(name, fmt.Sprintf("%s stands up.", name), "You stand up.")}
- if t.players.count() == 0 {
- events = append(events, t.event("The baccarat table closes.", true))
- }
- return events
+ return t.closeIfEmptyLocked(events)
}
func (t *baccaratTable) bet(name, spot string, amount int, wallet Wallet) []Event {
@@ -135,11 +119,7 @@ func (t *baccaratTable) bet(name, spot string, amount int, wallet Wallet) []Even
events := []Event{t.playerAction(name,
fmt.Sprintf("%s bets %d credits on %s.", name, amount, spot),
fmt.Sprintf("You bet %d credits on %s.", amount, spot))}
- if t.players.count() > 1 && t.phase == baccaratWaiting {
- t.phase = baccaratBetting
- t.window.open(t.Config.BettingTicks)
- events = append(events, t.event(fmt.Sprintf("Baccarat betting is open for %d ticks.", t.Config.BettingTicks), true))
- }
+ events = t.openBettingLocked(events)
if allBet(t.players.participants()) {
events = append(events, t.deal()...)
}
@@ -152,20 +132,7 @@ func (t *baccaratTable) tick() []Event {
if t.phase != baccaratBetting {
return nil
}
- sitOuts, resolve, pending := t.window.advance(t.players.participants())
- if pending {
- return nil
- }
- var events []Event
- for _, name := range sitOuts {
- events = append(events, t.event(fmt.Sprintf("%s sits out this baccarat round.", name), true))
- }
- if resolve {
- events = append(events, t.deal()...)
- } else {
- t.resetRound()
- }
- return events
+ return t.advanceBettingLocked(t.deal, t.resetRound)
}
func (t *baccaratTable) action(name, action string) []Event {
@@ -291,18 +258,9 @@ func (t *baccaratTable) markDisconnected(name string) []Event {
if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != nil {
p.Wallet.RefundOutstanding()
}
- t.players.remove(name)
+ t.removeSeatLocked(name)
events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)}
- if t.players.count() == 0 {
- events = append(events, t.event("The baccarat table closes.", true))
- }
- return events
-}
-
-func (t *baccaratTable) hasParticipant(name string) bool {
- t.mu.Lock()
- defer t.mu.Unlock()
- return t.players.has(name)
+ return t.closeIfEmptyLocked(events)
}
// baccaratTotal scores a hand modulo 10.
@@ -431,31 +389,7 @@ func baccaratResultMessages(name, result string, payout int) (string, string) {
func (t *baccaratTable) refundAll() []Event {
t.mu.Lock()
defer t.mu.Unlock()
- var events []Event
- for _, p := range t.players.ordered() {
- if !p.HasBet || p.Wallet == nil {
- continue
- }
- name := p.Name
- chips, credits, err := p.Wallet.RefundOutstanding()
- refund := chips + credits
- if err != nil || refund <= 0 {
- if err != nil {
- events = append(events, t.private(name, "Shutdown refund could not be persisted; the stake is recorded for recovery."))
- }
- continue
- }
- events = append(events, t.playerAction(name,
- fmt.Sprintf("%s is refunded %d as the casino closes.", name, refund),
- fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund)))
- }
+ events := t.refundAllLocked()
t.resetRound()
return events
}
-
-// setRoom updates the event-routing room after a room-ID rekey. Locked.
-func (t *baccaratTable) setRoom(roomID int) {
- t.mu.Lock()
- defer t.mu.Unlock()
- t.tableBase.setRoom(roomID)
-}
diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go
index fd0572b..fe212f7 100644
--- a/internal/casino/blackjack.go
+++ b/internal/casino/blackjack.go
@@ -6,16 +6,15 @@ import (
"fmt"
"math/rand"
"strings"
- "sync"
)
-type blackjackPhase string
-
+// Blackjack uses the shared table phases; these aliases keep the rule
+// vocabulary readable at the call sites.
const (
- blackjackWaiting blackjackPhase = "waiting"
- blackjackBetting blackjackPhase = "betting"
- blackjackInsurance blackjackPhase = "insurance"
- blackjackPlaying blackjackPhase = "playing"
+ blackjackWaiting = phaseWaiting
+ blackjackBetting = phaseBetting
+ blackjackInsurance = phaseInsurance
+ blackjackPlaying = phasePlaying
)
type blackjackHand struct {
@@ -50,7 +49,8 @@ func (h *blackjackHand) total() (int, bool) {
func (h *blackjackHand) blackjack(rules BlackjackConfig) bool {
total, _ := h.total()
- return len(h.cards) == 2 && !h.fromSplit && total == 21 || rules.CountsDoubleSplitBlackjack() && h.fromSplit && h.doubled && total == 21
+ return (len(h.cards) == 2 && !h.fromSplit && total == 21) ||
+ (rules.CountsDoubleSplitBlackjack() && h.fromSplit && h.doubled && total == 21)
}
type blackjackPlayer struct {
@@ -65,12 +65,8 @@ type blackjackPlayer struct {
func (p *blackjackPlayer) participantPtr() *Participant { return &p.Participant }
type blackjackTable struct {
- mu sync.Mutex
- tableBase
+ tableCore[*blackjackPlayer]
Rules BlackjackConfig
- players seatSet[*blackjackPlayer]
- phase blackjackPhase
- window bettingWindow
timer countdown
ceiling countdown
activePlayer int
@@ -98,21 +94,19 @@ func newBlackjackTable(roomID int, cfg TableConfig, rng *rand.Rand) *blackjackTa
cfg = cfg.Normalize()
rules := cfg.Blackjack.Normalize()
return &blackjackTable{
- tableBase: tableBase{RoomID: roomID, Config: cfg}, Rules: rules,
- players: newSeatSet[*blackjackPlayer](), phase: blackjackWaiting,
- shoe: newShoe(rules.Decks, rules.ShuffleAt, rng), turnHand: -1,
+ tableCore: newTableCore[*blackjackPlayer](&tableBase{RoomID: roomID, Config: cfg}),
+ Rules: rules,
+ shoe: newShoe(rules.Decks, rules.ShuffleAt, rng),
+ turnHand: -1,
}
}
func (t *blackjackTable) menuEvent(name string) Event {
- e := t.private(name, t.menu())
- e.Color = "casino_menu"
- e.Menu = &MenuView{Kind: MenuBlackjackBet, Commands: []MenuCommand{
+ return t.betMenuEvent(name, MenuBlackjackBet, []MenuCommand{
{Command: "bet", Desc: "Place a wager (min/max accepted)"},
{Command: "bet <amount>", Desc: "Place a specific wager"},
{Command: "stop", Desc: "Leave the table"},
- }}
- return e
+ })
}
func (t *blackjackTable) join(name string) []Event {
@@ -124,8 +118,7 @@ func (t *blackjackTable) join(name string) []Event {
if t.players.count() >= t.Config.MaxPlayers {
return []Event{t.private(name, "That blackjack table is full.")}
}
- t.players.add(name, &blackjackPlayer{Participant: Participant{Name: name, Connected: true}})
- events := []Event{t.playerAction(name, fmt.Sprintf("%s sits down.", name), "You sit down.")}
+ events := []Event{t.addSeatLocked(name, &blackjackPlayer{Participant: Participant{Name: name, Connected: true}})}
events = append(events, t.menuEvent(name))
return events
}
@@ -175,11 +168,7 @@ func (t *blackjackTable) bet(name, spot string, amount int, wallet Wallet) []Eve
}
p.HasBet, p.Wager, p.Wallet, p.lastBet = true, wager, wallet, amount
events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d credits.", name, amount), fmt.Sprintf("You bet %d credits.", amount))}
- if t.players.count() > 1 && t.phase == blackjackWaiting {
- t.phase = blackjackBetting
- t.window.open(t.Config.BettingTicks)
- events = append(events, t.event(fmt.Sprintf("Blackjack betting is open for %d ticks.", t.Config.BettingTicks), true))
- }
+ events = t.openBettingLocked(events)
if allBet(t.players.participants()) {
events = append(events, t.startRound()...)
}
@@ -191,20 +180,7 @@ func (t *blackjackTable) tick() []Event {
defer t.mu.Unlock()
switch t.phase {
case blackjackBetting:
- sitOuts, resolve, pending := t.window.advance(t.players.participants())
- if pending {
- return nil
- }
- var events []Event
- for _, name := range sitOuts {
- events = append(events, t.event(fmt.Sprintf("%s sits out this blackjack round.", name), true))
- }
- if resolve {
- events = append(events, t.startRound()...)
- } else {
- t.resetRound()
- }
- return events
+ return t.advanceBettingLocked(t.startRound, t.resetRound)
case blackjackInsurance:
if t.insuranceSettled() {
return t.finishInsurance()
@@ -807,10 +783,6 @@ func (t *blackjackTable) snapshot(revealDealer bool) *BlackjackSnapshot {
return snapshot
}
-func (t *blackjackTable) menu() string {
- return "New blackjack hand! Place your bet."
-}
-
func handScore(hand *blackjackHand) string {
total, soft := hand.total()
if !soft || total == 21 {
@@ -859,11 +831,9 @@ func (t *blackjackTable) markDisconnectedLocked(name string) []Event {
p.Wallet.RefundOutstanding()
}
}
- t.players.remove(name)
+ t.removeSeatLocked(name)
events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)}
- if t.players.count() == 0 {
- events = append(events, t.event("The blackjack table closes.", true))
- }
+ events = t.closeIfEmptyLocked(events)
if t.phase != blackjackPlaying {
// Betting and insurance resolve against the remaining seats on the
// next tick; waiting has no round in flight.
@@ -882,12 +852,6 @@ func (t *blackjackTable) markDisconnectedLocked(name string) []Event {
return append(events, t.finishHand()...)
}
-func (t *blackjackTable) hasParticipant(name string) bool {
- t.mu.Lock()
- defer t.mu.Unlock()
- return t.players.has(name)
-}
-
// refundAll refunds every committed wager at this table (graceful
// shutdown/eviction): the wallet returns every outstanding stake — opening
// bet, doubles, splits, and insurance — exactly once. It then RESETS the
@@ -897,31 +861,7 @@ func (t *blackjackTable) hasParticipant(name string) bool {
func (t *blackjackTable) refundAll() []Event {
t.mu.Lock()
defer t.mu.Unlock()
- var events []Event
- for _, p := range t.players.ordered() {
- if !p.HasBet || p.Wallet == nil {
- continue
- }
- name := p.Name
- chips, credits, err := p.Wallet.RefundOutstanding()
- refund := chips + credits
- if err != nil || refund <= 0 {
- if err != nil {
- events = append(events, t.private(name, "Shutdown refund could not be persisted; the stake is recorded for recovery."))
- }
- continue
- }
- events = append(events, t.playerAction(name,
- fmt.Sprintf("%s is refunded %d as the casino closes.", name, refund),
- fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund)))
- }
+ events := t.refundAllLocked()
t.resetRound()
return events
}
-
-// setRoom updates the event-routing room after a room-ID rekey. Locked.
-func (t *blackjackTable) setRoom(roomID int) {
- t.mu.Lock()
- defer t.mu.Unlock()
- t.tableBase.setRoom(roomID)
-}
diff --git a/internal/casino/config_validate.go b/internal/casino/config_validate.go
index a03cfbf..9b9759c 100644
--- a/internal/casino/config_validate.go
+++ b/internal/casino/config_validate.go
@@ -26,8 +26,8 @@ func (c MachineConfig) Validate() error {
return fmt.Errorf("payout must be finite and between 0 and 1 (0 uses the default)")
}
// The machine's effective profile is the built-in one scaled to the
- // configured RTP; ValidateRTP certifies that profile, so it must be a
- // geometry the runtime evaluator can actually play.
+ // configured RTP; it must be a geometry the runtime evaluator can
+ // actually play.
return machineSlotProfile(c).Validate()
}
@@ -160,9 +160,10 @@ const baccaratRoundCards = 6
// Validate checks a slot profile the machine can actually play. The runtime
// evaluator (generateSlotSpinWithProfile, matchingLength, stoppedScatters and
// evaluateSlot) is pinned to the SlotSpin grid of three rows by five reels;
-// CalculateRTP/ValidateRTP are generic over Rows/Reels/Ways, so a profile
-// with another geometry would be certified by the RTP gate and then played as
-// a different game. Rejecting the geometry here keeps that gate meaningful.
+// CalculateRTP also assumes that geometry (fixed 3..5 symbol runs and a
+// five-reel no-match term). A profile with another geometry would be
+// certified by the RTP gate and then played as a different game. Rejecting
+// the geometry here keeps that gate meaningful.
func (p SlotProfile) Validate() error {
profile := p.Normalize()
if profile.Rows != slotGridRows || profile.Reels != slotGridReels {
@@ -268,7 +269,7 @@ func ValidateRoomConfigs(tables []TableConfig, machines []MachineConfig) error {
for _, c := range tables {
if c.Game == GameSlots {
// A slots "table" is instantiated as the room's slots machine.
- legacy := MachineConfig{ID: c.ID, Game: GameSlots, MinBet: c.MinBet, MaxBet: c.MaxBet}
+ legacy := LegacySlotMachine(c)
check("legacy slots", c.ID, legacy.Validate())
checkAddressable(strings.ToLower(string(GameSlots)), c.ID)
continue
diff --git a/internal/casino/core.go b/internal/casino/core.go
new file mode 100644
index 0000000..85a5e28
--- /dev/null
+++ b/internal/casino/core.go
@@ -0,0 +1,137 @@
+package casino
+
+import (
+ "fmt"
+ "strings"
+ "sync"
+)
+
+// tablePhase is the shared lifecycle phase of a multiplayer table game.
+// Blackjack adds insurance and playing; baccarat uses only waiting/betting.
+type tablePhase string
+
+const (
+ phaseWaiting tablePhase = "waiting"
+ phaseBetting tablePhase = "betting"
+ phaseInsurance tablePhase = "insurance"
+ phasePlaying tablePhase = "playing"
+)
+
+// tableCore is the plumbing every multiplayer table game shares: the seat set,
+// the phase/betting window, and the table lock. A game embeds a
+// tableCore[itsPlayerType] and keeps only its own rules and round state.
+//
+// Methods suffixed Locked require the caller to hold c.mu; the others take it.
+// A game's methods take the same promoted lock, so a game must never call a
+// locking core method while already holding c.mu.
+type tableCore[P seatedPlayer] struct {
+ *tableBase
+ mu sync.Mutex
+ players seatSet[P]
+ phase tablePhase
+ window bettingWindow
+}
+
+func newTableCore[P seatedPlayer](base *tableBase) tableCore[P] {
+ return tableCore[P]{tableBase: base, players: newSeatSet[P](), phase: phaseWaiting}
+}
+
+// base exposes the shared table identity to the non-generic Table handle.
+func (c *tableCore[P]) base() *tableBase { return c.tableBase }
+
+// idle reports whether no player is seated (or mid-round).
+func (c *tableCore[P]) idle() bool {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.players.count() == 0
+}
+
+func (c *tableCore[P]) hasParticipant(name string) bool {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.players.has(name)
+}
+
+// addSeatLocked seats a player and returns the shared public "sits down"
+// event. The caller has already checked capacity.
+func (c *tableCore[P]) addSeatLocked(name string, p P) Event {
+ c.players.add(name, p)
+ return c.playerAction(name, fmt.Sprintf("%s sits down.", name), "You sit down.")
+}
+
+// removeSeatLocked frees a player's seat. The caller owns the surrounding
+// fixups (active-seat indices, refunds) and announcements.
+func (c *tableCore[P]) removeSeatLocked(name string) { c.players.remove(name) }
+
+// closeIfEmptyLocked appends the shared "table closes" event once the last
+// seat has been removed.
+func (c *tableCore[P]) closeIfEmptyLocked(events []Event) []Event {
+ if c.players.count() == 0 {
+ events = append(events, c.event(fmt.Sprintf("The %s table closes.", c.Config.Game), true))
+ }
+ return events
+}
+
+// openBettingLocked opens the timed betting window once a second player has a
+// wager down, and announces it.
+func (c *tableCore[P]) openBettingLocked(events []Event) []Event {
+ if c.players.count() > 1 && c.phase == phaseWaiting {
+ c.phase = phaseBetting
+ c.window.open(c.Config.BettingTicks)
+ events = append(events, c.event(fmt.Sprintf("%s betting is open for %d ticks.", gameTitle(c.Config.Game), c.Config.BettingTicks), true))
+ }
+ return events
+}
+
+// advanceBettingLocked moves the betting window one tick. pending leaves the
+// window open; otherwise it announces the players who never bet and calls
+// resolve (wagers are down) or reset (nobody bet).
+func (c *tableCore[P]) advanceBettingLocked(resolve func() []Event, reset func()) []Event {
+ sitOuts, goRound, pending := c.window.advance(c.players.participants())
+ if pending {
+ return nil
+ }
+ var events []Event
+ for _, name := range sitOuts {
+ events = append(events, c.event(fmt.Sprintf("%s sits out this %s round.", name, c.Config.Game), true))
+ }
+ if goRound {
+ return append(events, resolve()...)
+ }
+ reset()
+ return events
+}
+
+// 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.
+func (c *tableCore[P]) refundAllLocked() []Event {
+ var events []Event
+ for _, p := range c.players.participants() {
+ if !p.HasBet || p.Wallet == nil {
+ continue
+ }
+ name := p.Name
+ chips, credits, err := p.Wallet.RefundOutstanding()
+ refund := chips + credits
+ if err != nil || refund <= 0 {
+ if err != nil {
+ events = append(events, c.private(name, "Shutdown refund could not be persisted; the stake is recorded for recovery."))
+ }
+ continue
+ }
+ events = append(events, c.playerAction(name,
+ fmt.Sprintf("%s is refunded %d as the casino closes.", name, refund),
+ fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund)))
+ }
+ return events
+}
+
+// gameTitle renders a configured game name as a message heading.
+func gameTitle(game GameName) string {
+ name := string(game)
+ if name == "" {
+ return "Table"
+ }
+ return strings.ToUpper(name[:1]) + name[1:]
+}
diff --git a/internal/casino/machine.go b/internal/casino/machine.go
index 83e018f..f285518 100644
--- a/internal/casino/machine.go
+++ b/internal/casino/machine.go
@@ -30,6 +30,11 @@ type Machine struct {
inFreeSpins bool
spinBet int
payoutRemainder float64
+ // stakeID is the wallet handle of the wager committed to the current
+ // round (the triggering wager for a free-spin feature). Resolving by
+ // handle — not the player-wide ResolveStakes — keeps an older
+ // failed-payout stake open as recovery evidence when a new round starts.
+ stakeID string
// featureBank holds the winnings of a paid round whose free-spin feature
// is still running. They are paid as ONE settlement when the feature
// ends: see finishSpin for why no payout may be journaled while the
@@ -105,11 +110,13 @@ func (m *Machine) start(name string, wallet Wallet) []Event {
}
func (m *Machine) beginSpinLocked(amount int, wallet Wallet, publicMessage, privateMessage string) []Event {
- if _, err := wallet.Wager(amount); err != nil {
+ wager, err := wallet.Wager(amount)
+ if err != nil {
return m.private(walletErrorMessage(err, "You don't have enough chips and credits for that bet."))
}
m.bet = amount
m.spinBet = amount
+ m.stakeID = wager.ID
m.player.Wallet = wallet
m.stakeFailed = false
m.spin = generateSlotSpinWithProfile(m.rng, m.spinBet, m.profile)
@@ -209,9 +216,11 @@ func (m *Machine) autoBetTick() []Event {
m.autoBet = false
return m.private("Autobet stopped: no wager is set.")
}
- if _, err := m.player.Wallet.Wager(m.bet); err != nil {
+ if wager, err := m.player.Wallet.Wager(m.bet); err != nil {
m.autoBet = false
return m.private(walletErrorMessage(err, "Autobet stopped: you don't have enough chips and credits for that bet."))
+ } else {
+ m.stakeID = wager.ID
}
m.spinBet = m.bet
m.stakeFailed = false
@@ -328,13 +337,15 @@ func (m *Machine) finishSpin() []Event {
m.inFreeSpins = false
if !payFailed && m.player.Wallet != nil {
// The paid round fully resolved (the feature either never
- // started or just completed; free spins wager nothing): the
- // stake is no longer outstanding.
- m.player.Wallet.ResolveStakes()
+ // started or just completed; free spins wager nothing): resolve
+ // ONLY this round's stake, so an older failed-payout stake stays
+ // open as recovery evidence.
+ m.player.Wallet.ResolveStake(m.stakeID)
+ m.stakeID = ""
m.stakeFailed = false
}
if !m.autoBet {
- events = append(events, m.menuEvents("")...)
+ events = append(events, m.privateMenu("")...)
}
}
return events
@@ -362,12 +373,13 @@ func (m *Machine) leave(name string) []Event {
// unresolved for recovery (or the shutdown refund) to complete.
func (m *Machine) resetSeat() {
if m.player != nil && m.player.Wallet != nil && !m.stakeFailed {
- m.player.Wallet.ResolveStakes()
+ m.player.Wallet.ResolveStake(m.stakeID)
}
m.player = nil
m.bet = 0
m.spin = nil
m.spinBet = 0
+ m.stakeID = ""
m.phase = machineReady
m.reel, m.clock, m.suspense = 0, 0, false
m.freeSpins = 0
@@ -496,10 +508,6 @@ func (m *Machine) privateMenu(prefix string) []Event {
return events
}
-func (m *Machine) menuEvents(prefix string) []Event {
- return m.privateMenu(prefix)
-}
-
// refundAll refunds any outstanding stake at graceful shutdown/eviction and
// then DISCARDS the round (the machine closes with the process). The wallet
// resolves every outstanding stake exactly once; a completed spin has no
diff --git a/internal/casino/manager.go b/internal/casino/manager.go
index b1281b2..b71e5df 100644
--- a/internal/casino/manager.go
+++ b/internal/casino/manager.go
@@ -7,6 +7,7 @@ import (
"math/rand"
"reflect"
"sort"
+ "strconv"
"strings"
"sync"
)
@@ -361,26 +362,32 @@ func (m *Manager) RekeyRooms(idMap map[int]int) {
}
m.mu.Lock()
defer m.mu.Unlock()
- rekey := func(key string) (string, bool) {
- var roomID int
- var tableID string
- if _, err := fmt.Sscanf(key, "%d:%s", &roomID, &tableID); err != nil {
- return "", false
+ // rekey parses a room:table key and returns the remapped key and room ID.
+ // strings.Cut/strconv is used instead of fmt.Sscanf because a table or
+ // machine ID may legally contain whitespace (validation rejects only
+ // surrounding whitespace) and %s would stop at the first space, rebuilding
+ // a corrupted key and orphaning the game.
+ rekey := func(key string) (string, int, bool) {
+ roomStr, tableID, found := strings.Cut(key, ":")
+ if !found {
+ return "", 0, false
+ }
+ roomID, err := strconv.Atoi(roomStr)
+ if err != nil {
+ return "", 0, false
}
newID, ok := idMap[roomID]
if !ok || newID == roomID {
- return "", false
+ return "", 0, false
}
- return fmt.Sprintf("%d:%s", newID, tableID), true
+ return fmt.Sprintf("%d:%s", newID, tableID), newID, true
}
movedTables := make(map[string]*Table, len(m.tables))
for key, table := range m.tables {
- if newKey, ok := rekey(key); ok {
- var roomID int
- var tableID string
- fmt.Sscanf(key, "%d:%s", &roomID, &tableID)
- table.RoomID = idMap[roomID]
- table.game.setRoom(table.RoomID)
+ if newKey, newRoomID, ok := rekey(key); ok {
+ // The Table handle and the game share one *tableBase, so this
+ // single assignment updates event routing for both.
+ table.RoomID = newRoomID
movedTables[newKey] = table
delete(m.tables, key)
}
@@ -390,11 +397,8 @@ func (m *Manager) RekeyRooms(idMap map[int]int) {
}
movedMachines := make(map[string]*Machine, len(m.machines))
for key, machine := range m.machines {
- if newKey, ok := rekey(key); ok {
- var roomID int
- var tableID string
- fmt.Sscanf(key, "%d:%s", &roomID, &tableID)
- machine.RoomID = idMap[roomID]
+ if newKey, newRoomID, ok := rekey(key); ok {
+ machine.RoomID = newRoomID
movedMachines[newKey] = machine
delete(m.machines, key)
}
diff --git a/internal/casino/manager_test.go b/internal/casino/manager_test.go
index b037efa..6601b52 100644
--- a/internal/casino/manager_test.go
+++ b/internal/casino/manager_test.go
@@ -127,3 +127,37 @@ func eventText(events []Event) string {
}
return ""
}
+
+// TestRekeyRoomsKeepsTableIdentityAndWhitespaceIDs: a room rekey must move
+// the manager key and the shared table identity together, and must not
+// truncate a table ID that legally contains whitespace.
+func TestRekeyRoomsKeepsTableIdentityAndWhitespaceIDs(t *testing.T) {
+ m := NewManager(1)
+ const id = "high stakes bj"
+ cfg := TableConfig{ID: id, Game: GameBlackjack}
+ m.Join(1, cfg, "alice")
+ table := m.Table(1, id)
+ if table == nil {
+ t.Fatal("table was not created for a valid whitespace-containing ID")
+ }
+ m.RekeyRooms(map[int]int{1: 12})
+ if m.Table(1, id) != nil {
+ t.Fatal("table left under the old room key")
+ }
+ moved := m.Table(12, id)
+ if moved != table {
+ t.Fatalf("rekey lost the table (or truncated its key): Table(12, %q)=%p want %p", id, moved, table)
+ }
+ if moved.RoomID != 12 {
+ t.Fatalf("moved table RoomID = %d, want 12", moved.RoomID)
+ }
+ // The game shares the table's base, so events must route to the new room.
+ events := moved.join("bob")
+ if len(events) == 0 || events[0].RoomID != 12 {
+ t.Fatalf("post-rekey events route to the wrong room: %+v", events)
+ }
+ // The old, truncated key must not have been created.
+ if m.Table(12, "high") != nil {
+ t.Fatal("rekey created a truncated table key from a whitespace ID")
+ }
+}
diff --git a/internal/casino/money_regression_test.go b/internal/casino/money_regression_test.go
index 79f9b46..31cd0e6 100644
--- a/internal/casino/money_regression_test.go
+++ b/internal/casino/money_regression_test.go
@@ -249,3 +249,40 @@ func TestSlotsStakeResolvesOnlyAfterFullRound(t *testing.T) {
t.Errorf("refundAll double-paid a completed round: paid %d -> %d", paidBefore, wallet.paid)
}
}
+
+// TestSlotsSecondSpinDoesNotResolveFailedPayoutStake: a spin whose payout
+// could not be persisted leaves its stake OPEN as recovery evidence. The
+// next round must resolve only its own stake — a player-wide ResolveStakes
+// would silently erase the failed round's evidence and drop the refund.
+func TestSlotsSecondSpinDoesNotResolveFailedPayoutStake(t *testing.T) {
+ machine := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 1}, newTestRand())
+ wallet := &failingPayoutWallet{testWallet{credits: 100}}
+ machine.join("alice")
+ machine.setBet("alice", 10, wallet)
+ machine.spin = &SlotSpin{Payout: 20} // winning spin the wallet refuses to pay
+ for i := 0; i < 20 && machine.phase != machineReady; i++ {
+ machine.tick()
+ }
+ if len(wallet.open) != 1 {
+ t.Fatal("setup: a failed payout must leave the stake open")
+ }
+
+ // A second, losing round must not resolve the first (still-open) stake.
+ machine.setBet("alice", 10, wallet)
+ machine.spin = &SlotSpin{}
+ for i := 0; i < 20 && machine.phase != machineReady; i++ {
+ machine.tick()
+ }
+ if len(wallet.open) != 1 {
+ t.Fatalf("the second spin resolved the failed-payout stake; open=%d, want 1", len(wallet.open))
+ }
+
+ // The preserved stake is still refundable at shutdown.
+ machine.refundAll()
+ if len(wallet.open) != 0 {
+ t.Fatalf("failed-payout stake was not refunded: open=%+v", wallet.open)
+ }
+ if got := wallet.credits + wallet.paid; got != 90 {
+ t.Fatalf("balance %d, want 90 (first stake refunded, second leg lost)", got)
+ }
+}
diff --git a/internal/casino/slots.go b/internal/casino/slots.go
index 6be5004..217e9fb 100644
--- a/internal/casino/slots.go
+++ b/internal/casino/slots.go
@@ -31,9 +31,10 @@ type SlotSpin struct {
// The runtime slot evaluator is pinned to this grid: SlotSpin/SlotSnapshot
// hold it, generateSlotSpinWithProfile fills it, and matchingLength and
-// stoppedScatters evaluate it. CalculateRTP/ValidateRTP are generic over
-// SlotProfile.Rows/Reels/Ways, so SlotProfile.Validate rejects any other
-// geometry (see config_validate.go) to keep the RTP gate honest.
+// stoppedScatters evaluate it. CalculateRTP also assumes this geometry (fixed
+// 3..5 symbol runs and a five-reel no-match term), so SlotProfile.Validate
+// rejects any other geometry (see config_validate.go) to keep the RTP gate
+// honest.
const (
slotGridRows = 3
slotGridReels = 5
diff --git a/internal/casino/table.go b/internal/casino/table.go
index 09c19de..4be60a0 100644
--- a/internal/casino/table.go
+++ b/internal/casino/table.go
@@ -1,6 +1,7 @@
package casino
import (
+ "fmt"
"math/rand"
)
@@ -17,55 +18,71 @@ type tableGame interface {
markDisconnected(name string) []Event
hasParticipant(name string) bool
refundAll() []Event
- setRoom(roomID int)
idle() bool
+ // base returns the table identity shared with the Table handle.
+ base() *tableBase
}
-// tableBase carries the identity every table game needs to build events.
+// tableBase is the identity every table game shares with its Table handle.
+// The Table and the embedded tableGame hold the SAME *tableBase, so the room
+// ID has a single source of truth: a rekey updates it once and every event the
+// game builds follows, with no second field to keep in sync.
type tableBase struct {
RoomID int
Config TableConfig
}
-func (b tableBase) event(message string, public bool) Event {
+func (b *tableBase) event(message string, public bool) Event {
return Event{RoomID: b.RoomID, TableID: b.Config.ID, Type: EventResult, Message: message, Public: public, Color: "casino_action"}
}
-func (b tableBase) private(name, message string) Event {
+func (b *tableBase) private(name, message string) Event {
e := b.event(message, false)
e.PrivateTo = name
return e
}
-func (b tableBase) playerAction(name, publicMessage, privateMessage string) Event {
+func (b *tableBase) playerAction(name, publicMessage, privateMessage string) Event {
e := b.event(publicMessage, true)
e.PrivateTo = name
e.PrivateMessage = privateMessage
return e
}
+// betMenuEvent builds the shared "place your bet" menu heading for a table
+// game; only the menu kind and command list differ per game.
+func (b *tableBase) betMenuEvent(name string, kind MenuKind, commands []MenuCommand) Event {
+ e := b.private(name, fmt.Sprintf("New %s hand! Place your bet.", b.Config.Game))
+ e.Color = "casino_menu"
+ e.Menu = &MenuView{Kind: kind, Commands: commands}
+ return e
+}
+
// Table is the manager-facing handle for a multiplayer table. It only holds
-// routing state; all game logic lives in the tableGame implementation.
+// routing state; all game logic lives in the tableGame implementation. It
+// shares one *tableBase with the game, so RoomID/Config are a single source of
+// truth for both.
type Table struct {
- RoomID int
- Config TableConfig
- game tableGame
+ *tableBase
+ game tableGame
}
// newTable builds the table game for cfg.Game, or nil when the game has no
// multiplayer table implementation.
func newTable(roomID int, cfg TableConfig, rng *rand.Rand) *Table {
cfg = cfg.Normalize()
- t := &Table{RoomID: roomID, Config: cfg}
+ var game tableGame
switch cfg.Game {
case GameBlackjack:
- t.game = newBlackjackTable(roomID, cfg, rng)
+ game = newBlackjackTable(roomID, cfg, rng)
case GameBaccarat:
- t.game = newBaccaratTable(roomID, cfg, rng)
+ game = newBaccaratTable(roomID, cfg, rng)
default:
return nil
}
- return t
+ // The Table and the game share the game's base, so RoomID/Config are a
+ // single source of truth for both.
+ return &Table{tableBase: game.base(), game: game}
}
func (t *Table) join(name string) []Event { return t.game.join(name) }
@@ -86,24 +103,5 @@ func (t *Table) hasParticipant(name string) bool { return t.game.hasPartici
// shutdown): stakes return to their players through the wallet.
func (t *Table) refundAll() []Event { return t.game.refundAll() }
-// setRoom updates the table game's room for event routing after a room-ID
-// rekey. Games keep their state; only their location moves.
-func (b *tableBase) setRoom(roomID int) { b.RoomID = roomID }
-
-// idle reports whether no player is seated (or mid-round): only an idle
-// table may have its live rules replaced by new room configuration.
-func (t *blackjackTable) idle() bool {
- t.mu.Lock()
- defer t.mu.Unlock()
- return t.players.count() == 0
-}
-
-// idle reports whether no player is seated (or mid-round).
-func (t *baccaratTable) idle() bool {
- t.mu.Lock()
- defer t.mu.Unlock()
- return t.players.count() == 0
-}
-
// idle on the Table wrapper delegates to the game.
func (t *Table) idle() bool { return t.game.idle() }
diff --git a/internal/casino/types.go b/internal/casino/types.go
index 578aee1..3d31820 100644
--- a/internal/casino/types.go
+++ b/internal/casino/types.go
@@ -129,6 +129,13 @@ type MachineConfig struct {
Payout float64 `yaml:"payout,omitempty"`
}
+// LegacySlotMachine converts a legacy slots TableConfig (older rooms declared
+// slots under casino_tables) into the MachineConfig the room actually
+// instantiates. Shared by validation and room lookup so the two cannot drift.
+func LegacySlotMachine(c TableConfig) MachineConfig {
+ return MachineConfig{ID: c.ID, Game: GameSlots, MinBet: c.MinBet, MaxBet: c.MaxBet}
+}
+
func (c MachineConfig) Normalize() MachineConfig {
if c.MinBet <= 0 {
c.MinBet = 1
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 3e382e9..971952a 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -414,9 +414,7 @@ func (g *Game) casinoMachine(roomID int, gameName string) (*casino.Machine, bool
if strings.EqualFold(gameName, "slots") {
for _, cfg := range room.CasinoTables {
if strings.EqualFold(string(cfg.Game), "slots") {
- machine := g.Casino.EnsureMachine(roomID, casino.MachineConfig{
- ID: cfg.ID, Game: casino.GameSlots, MinBet: cfg.MinBet, MaxBet: cfg.MaxBet,
- })
+ machine := g.Casino.EnsureMachine(roomID, casino.LegacySlotMachine(cfg))
return machine, machine != nil
}
}
diff --git a/internal/game/cmd_queued.go b/internal/game/cmd_queued.go
index 5f43ad1..00cf994 100644
--- a/internal/game/cmd_queued.go
+++ b/internal/game/cmd_queued.go
@@ -50,7 +50,7 @@ func (g *Game) doQueued(sess *net.Session) {
sess.WriteLine(" All queued actions take effect on the next game tick (600ms).")
sess.WriteLine(" Free actions stack. Only the most recent active action survives.")
- // ActivePositions already returns the names sorted and the caller's
+ // ActivePositions returns the names in resolution order and the caller's
// 1-based position, so there is nothing to recompute here.
names, pos := g.queue.ActivePositions(p.Name)
if pos > 0 && len(names) > 1 {
diff --git a/internal/game/core_command_queue.go b/internal/game/core_command_queue.go
index 28b7a36..8fce71d 100644
--- a/internal/game/core_command_queue.go
+++ b/internal/game/core_command_queue.go
@@ -106,23 +106,32 @@ func (q *CommandQueue) DeleteActive(name string) {
delete(q.activeQueue, name)
}
-// ActivePositions returns the sorted names of every player holding an active
-// command, and the position (1-indexed) of the named player among them, or 0
-// when that player has no active command. Used by the `queued` command to
-// show queue order.
+// ActivePositions returns the names of every player holding an active command
+// in actual resolution order (earliest Timestamp first, matching
+// ProcessQueuedCommands), and the 1-based position of the named player among
+// them, or 0 when that player has no active command. Used by the `queued`
+// command to show queue order.
func (q *CommandQueue) ActivePositions(name string) (names []string, pos int) {
q.mu.Lock()
defer q.mu.Unlock()
- for n := range q.activeQueue {
- names = append(names, n)
+ type entry struct {
+ name string
+ at time.Time
}
- sort.Strings(names)
- for i, n := range names {
- if n == name {
- return names, i + 1
+ entries := make([]entry, 0, len(q.activeQueue))
+ for n, qc := range q.activeQueue {
+ entries = append(entries, entry{name: n, at: qc.Timestamp})
+ }
+ sort.SliceStable(entries, func(i, j int) bool {
+ return entries[i].at.Before(entries[j].at)
+ })
+ for _, e := range entries {
+ names = append(names, e.name)
+ if e.name == name {
+ pos = len(names)
}
}
- return names, 0
+ return names, pos
}
// ClearPlayer drops every queued command for the player: free, active, and
@@ -145,11 +154,3 @@ func (q *CommandQueue) DrainConsume(name string) (*QueuedCommand, bool) {
delete(q.consumeQueue, name)
return qc, ok
}
-
-// ActiveCount returns the number of players with active commands. Used by the
-// `queued` command to show position.
-func (q *CommandQueue) ActiveCount() int {
- q.mu.Lock()
- defer q.mu.Unlock()
- return len(q.activeQueue)
-}
diff --git a/internal/game/exit_migrate.go b/internal/game/exit_migrate.go
index 2c56d27..55289f2 100644
--- a/internal/game/exit_migrate.go
+++ b/internal/game/exit_migrate.go
@@ -183,6 +183,9 @@ func (g *Game) MigrateRoomIDsOnOwner(idMap map[int]int) {
}
g.World.RekeyRooms(idMap)
g.MobStore.RekeyRooms(idMap)
+ if g.safespot != nil {
+ g.safespot.RekeyRooms(idMap)
+ }
// The file paths changed under the world: rebuild its room index so
// lookups agree with the post-migration layout.
g.World.RebuildRoomIndex(g.DataDir)
diff --git a/internal/game/hardening_queue_cleanup_test.go b/internal/game/hardening_queue_cleanup_test.go
index 95b96bf..3285381 100644
--- a/internal/game/hardening_queue_cleanup_test.go
+++ b/internal/game/hardening_queue_cleanup_test.go
@@ -114,24 +114,27 @@ func TestProcessQueuedCommandsSkipsStaleSession(t *testing.T) {
}
}
-// TestActivePositionsReturnsSortedNamesAndOneBasedPosition pins the documented
-// contract of ActivePositions: names sorted, position 1-indexed, 0 when the
-// player holds no active command.
-func TestActivePositionsReturnsSortedNamesAndOneBasedPosition(t *testing.T) {
+// TestActivePositionsReturnsResolutionOrderAndOneBasedPosition pins the
+// contract of ActivePositions: names in actual resolution order (earliest
+// Timestamp first, matching ProcessQueuedCommands), position 1-indexed, 0 when
+// the player holds no active command.
+func TestActivePositionsReturnsResolutionOrderAndOneBasedPosition(t *testing.T) {
q := NewCommandQueue()
- for _, name := range []string{"carol", "alice", "bob"} {
- q.EnqueueActive(name, QueuedCommand{Command: "look", Timestamp: time.Now()})
- }
+ base := time.Now()
+ // Queue deliberately out of alphabetical order: resolution is first-to-queue.
+ q.EnqueueActive("alice", QueuedCommand{Command: "look", Timestamp: base.Add(3 * time.Millisecond)})
+ q.EnqueueActive("bob", QueuedCommand{Command: "look", Timestamp: base.Add(2 * time.Millisecond)})
+ q.EnqueueActive("carol", QueuedCommand{Command: "look", Timestamp: base.Add(1 * time.Millisecond)})
names, pos := q.ActivePositions("bob")
- if want := []string{"alice", "bob", "carol"}; !reflect.DeepEqual(names, want) {
- t.Errorf("names = %v, want %v", names, want)
+ if want := []string{"carol", "bob", "alice"}; !reflect.DeepEqual(names, want) {
+ t.Errorf("names = %v, want %v (timestamp order)", names, want)
}
if pos != 2 {
t.Errorf("position of bob = %d, want 2", pos)
}
- if _, pos := q.ActivePositions("alice"); pos != 1 {
- t.Errorf("position of alice = %d, want 1", pos)
+ if _, pos := q.ActivePositions("carol"); pos != 1 {
+ t.Errorf("position of carol = %d, want 1", pos)
}
if _, pos := q.ActivePositions("nobody"); pos != 0 {
t.Errorf("position of an unqueued player = %d, want 0", pos)
diff --git a/internal/game/sys_safespot_state.go b/internal/game/sys_safespot_state.go
index 1520bc7..32f57ec 100644
--- a/internal/game/sys_safespot_state.go
+++ b/internal/game/sys_safespot_state.go
@@ -67,6 +67,22 @@ func (m *SafespotManager) Delete(name string) bool {
return ok
}
+// RekeyRooms remaps the stored RoomID of every safespot per idMap so a live
+// room rename does not orphan a player's cover (the safespot lookup keys on
+// the room ID and force-ejects on a miss).
+func (m *SafespotManager) RekeyRooms(idMap map[int]int) {
+ if len(idMap) == 0 {
+ return
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ for _, ss := range m.states {
+ if newID, ok := idMap[ss.RoomID]; ok && newID != ss.RoomID {
+ ss.RoomID = newID
+ }
+ }
+}
+
// DecrementHideCountdown decrements the hide countdown for a player who is
// transitioning from hiding to hidden. Returns (newCountdown, ok). A return of
// ok=true with newCountdown > 0 means the player is still counting down;
diff --git a/internal/game/sys_safespot_state_test.go b/internal/game/sys_safespot_state_test.go
new file mode 100644
index 0000000..2976bce
--- /dev/null
+++ b/internal/game/sys_safespot_state_test.go
@@ -0,0 +1,19 @@
+package game
+
+import "testing"
+
+// TestSafespotManagerRekeyRooms: a live room rename must remap the room ID
+// stored on an active safespot, or the safespot lookup misses and the player
+// is force-ejected from cover.
+func TestSafespotManagerRekeyRooms(t *testing.T) {
+ m := NewSafespotManager()
+ m.Set("alice", SafespotState{Active: true, ObjectDefID: "bush", RoomID: 5})
+ m.Set("bob", SafespotState{Active: true, ObjectDefID: "rock", RoomID: 9})
+ m.RekeyRooms(map[int]int{5: 7})
+ if ss, _ := m.Get("alice"); ss.RoomID != 7 {
+ t.Fatalf("alice safespot room = %d, want 7", ss.RoomID)
+ }
+ if ss, _ := m.Get("bob"); ss.RoomID != 9 {
+ t.Fatalf("bob safespot room = %d, want 9 (unchanged)", ss.RoomID)
+ }
+}
diff --git a/internal/game/tick.go b/internal/game/tick.go
index 21489ca..a57ea26 100644
--- a/internal/game/tick.go
+++ b/internal/game/tick.go
@@ -377,8 +377,7 @@ func (g *Game) MoveTick() {
continue
}
p.MoveTicks--
- if p.MoveTicks > 0 {
- } else {
+ if p.MoveTicks == 0 {
g.completeMove(sess, p)
g.writePrompt(sess)
}
diff --git a/internal/persistence/txn.go b/internal/persistence/txn.go
index 8ba21fa..9939204 100644
--- a/internal/persistence/txn.go
+++ b/internal/persistence/txn.go
@@ -201,8 +201,10 @@ func (t *Transaction) Move(oldPath, newPath string) error {
if dst.Kind != kindMoveTarget && dst.Kind != "" {
return fmt.Errorf("conflicting operation on %s", newPath)
}
- // An existing replace/delete on the destination becomes a move-target
- // (the destination's before-image governs rollback).
+ // The destination must not already carry a replace/delete: callers must
+ // issue Move before any Replace on the destination. A destination already
+ // recorded as a move-target (a chained move) keeps its before-image for
+ // rollback.
dst.Kind = kindMoveTarget
src.Kind = kindMove
src.MoveTo = newPath
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index aee46f2..1c87c67 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -56,6 +56,13 @@ func validateRooms(s Source) []Issue {
Message: fmt.Sprintf("Room %d: invalid color %q", id, room.Color),
})
}
+ // A conditional description whose condition references a missing room
+ // or item silently blanks the room text at runtime.
+ for i := range room.Description {
+ if c := room.Description[i].Condition; c != nil {
+ issues = append(issues, validateCondition(fmt.Sprintf("Room %d: description[%d]", id, i), c, roomIndex, itemIDs)...)
+ }
+ }
for dir, exit := range room.Exits {
switch dir {
@@ -249,6 +256,14 @@ func validateLocalObject(roomID int, robj world.RoomObject, itemIDs map[string]b
})
}
+ // A conditional description whose condition is malformed silently hides
+ // the object at runtime; validate it like any other condition.
+ for i := range def.Description {
+ if c := def.Description[i].Condition; c != nil {
+ issues = append(issues, validateCondition(fmt.Sprintf("%s: description[%d]", prefix, i), c, roomIndex, itemIDs)...)
+ }
+ }
+
if len(def.OnLook) > 0 {
issues = append(issues, validateTriggerBlock(prefix+": on_look", def.OnLook, itemIDs, roomIndex, mobIDs, dropIDs)...)
}
@@ -698,6 +713,12 @@ func validateObjects(s Source) []Issue {
issues = append(issues, validateGather(fmt.Sprintf("Object %q: gather", id), obj.Gather, itemIDs, dropIDs)...)
}
+ for i := range obj.Description {
+ if c := obj.Description[i].Condition; c != nil {
+ issues = append(issues, validateCondition(fmt.Sprintf("Object %q: description[%d]", id, i), c, roomIndex, itemIDs)...)
+ }
+ }
+
if obj.Talk != nil {
issues = append(issues, validateTalkConfig(fmt.Sprintf("Object %q: talk", id), obj.Talk, itemIDs, roomIndex, mobIDs, dropIDs)...)
}
@@ -803,6 +824,17 @@ func validateCraftBlocks(s Source) []Issue {
craftLabel = c.Type + "/" + c.Subtype
}
+ // SuccessChance clamps to Cap; an omitted cap makes the recipe
+ // fail every time (see craftFailureReachable, which would then
+ // mask it as an intentional always-fail recipe).
+ if c.Success != nil && c.Success.Cap <= 0 {
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "config",
+ Message: fmt.Sprintf("Item %q (craft: %s): success.cap is %v; every success roll clamps to zero", item.ID, craftLabel, c.Success.Cap),
+ })
+ }
+
for _, e := range c.Ingredients {
if len(e.Items) == 0 {
issues = append(issues, Issue{
@@ -1367,12 +1399,8 @@ func validateRoomGrid(s Source) []Issue {
}
}
- for _, origin := range seeds {
- if placed[origin] {
- continue
- }
-
- grid := world.BuildGrid(origin, load, include, nil, func(c world.GridConflict) {
+ onConf := func(origin int) func(c world.GridConflict) {
+ return func(c world.GridConflict) {
switch c.Kind {
case "twist":
issues = append(issues, Issue{
@@ -1391,33 +1419,18 @@ func validateRoomGrid(s Source) []Issue {
c.Target, c.From, c.Dir, c.Want[0], c.Want[1], c.Want[2], c.Occupier, origin),
})
}
- })
-
- for rid := range grid.Coord {
- placed[rid] = true
}
}
- onConf := func(origin int) func(c world.GridConflict) {
- return func(c world.GridConflict) {
- switch c.Kind {
- case "twist":
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "integrity",
- Message: fmt.Sprintf(
- "Grid twist: room %d (via %d %s) maps to (%d,%d,%d) but was already placed at (%d,%d,%d) [origin %d]",
- c.Target, c.From, c.Dir, c.Want[0], c.Want[1], c.Want[2], c.Existing[0], c.Existing[1], c.Existing[2], origin),
- })
- case "overlap":
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "integrity",
- Message: fmt.Sprintf(
- "Grid overlap: room %d (via %d %s) wants (%d,%d,%d), already used by room %d [origin %d]",
- c.Target, c.From, c.Dir, c.Want[0], c.Want[1], c.Want[2], c.Occupier, origin),
- })
- }
+ for _, origin := range seeds {
+ if placed[origin] {
+ continue
+ }
+
+ grid := world.BuildGrid(origin, load, include, nil, onConf(origin))
+
+ for rid := range grid.Coord {
+ placed[rid] = true
}
}
@@ -1487,6 +1500,16 @@ func validateTechs(s Source) []Issue {
func validateGather(prefix string, cfg *behavior.GatherConfig, itemIDs map[string]bool, dropIDs map[string]bool) []Issue {
var issues []Issue
+ // SuccessChance clamps to Cap, so an omitted (zero) cap makes every roll
+ // fail forever. Warn instead of letting the content silently never pay.
+ if cfg.Success.Cap <= 0 {
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "config",
+ Message: fmt.Sprintf("%s: success.cap is %v; every success roll clamps to zero (set cap to the intended maximum chance)", prefix, cfg.Success.Cap),
+ })
+ }
+
if cfg.Bait != "" && !itemIDs[cfg.Bait] {
issues = append(issues, Issue{
Level: "ERROR",
@@ -1558,6 +1581,9 @@ func validateTalkConfig(prefix string, cfg *behavior.TalkConfig, itemIDs map[str
if node.Action != nil {
issues = append(issues, validateTalkStep(nodePrefix, node.Action, itemIDs, roomIndex, mobIDs, dropIDs)...)
}
+ if node.Condition != nil {
+ issues = append(issues, validateCondition(nodePrefix, node.Condition, roomIndex, itemIDs)...)
+ }
if node.Goto != "" {
if _, ok := cfg.Nodes[node.Goto]; !ok {
issues = append(issues, Issue{
@@ -1572,6 +1598,21 @@ func validateTalkConfig(prefix string, cfg *behavior.TalkConfig, itemIDs map[str
if opt.Action != nil {
issues = append(issues, validateTalkStep(optPrefix, opt.Action, itemIDs, roomIndex, mobIDs, dropIDs)...)
}
+ if opt.Condition != nil {
+ issues = append(issues, validateCondition(optPrefix, opt.Condition, roomIndex, itemIDs)...)
+ }
+ // An unknown option goto silently ends the conversation at
+ // runtime (act_talk.advanceTalk), so it is an error here just
+ // like a node-level goto.
+ if opt.Goto != "" {
+ if _, ok := cfg.Nodes[opt.Goto]; !ok {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: goto points to nonexistent node %q", optPrefix, opt.Goto),
+ })
+ }
+ }
}
}
diff --git a/internal/world/grid.go b/internal/world/grid.go
index 9ee08d1..9773241 100644
--- a/internal/world/grid.go
+++ b/internal/world/grid.go
@@ -64,6 +64,9 @@ func BuildGrid(seed int, load func(int) (*Room, bool), include func(int) bool, e
continue
}
target := exit.Room
+ if edgeInclude != nil && !edgeInclude(rid, dir, target) {
+ continue
+ }
if include != nil && !include(target) {
continue
}
diff --git a/internal/world/mob.go b/internal/world/mob.go
index 631e3a3..afd9024 100644
--- a/internal/world/mob.go
+++ b/internal/world/mob.go
@@ -428,9 +428,9 @@ func (s *MobStore) LoadDef(id string) (*MobDef, error) {
s.mu.Unlock()
return def, nil
}
- s.mu.Unlock()
-
+ // pathIndex is writer-guarded by ReloadDefs; read it under the lock too.
path, ok := s.pathIndex[id]
+ s.mu.Unlock()
if !ok {
return nil, fmt.Errorf("read mob %s: no such mob", id)
}
@@ -455,7 +455,9 @@ func (s *MobStore) LoadDef(id string) (*MobDef, error) {
}
func (s *MobStore) AllDefIDs() map[string]bool {
- ids := make(map[string]bool)
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ ids := make(map[string]bool, len(s.pathIndex))
for id := range s.pathIndex {
ids[id] = true
}
@@ -757,5 +759,10 @@ func (s *MobStore) RekeyRooms(idMap map[int]int) {
inst.WanderRooms[i] = newID
}
}
+ for i, room := range inst.DespawnRooms {
+ if newID, ok := idMap[room]; ok && newID != room {
+ inst.DespawnRooms[i] = newID
+ }
+ }
}
}
diff --git a/internal/world/mob_rekey_test.go b/internal/world/mob_rekey_test.go
new file mode 100644
index 0000000..3a0eb85
--- /dev/null
+++ b/internal/world/mob_rekey_test.go
@@ -0,0 +1,30 @@
+package world
+
+import "testing"
+
+// TestRekeyRoomsUpdatesMobDespawnRooms: DespawnRooms is a live room reference
+// (sys_triggers compares it against the owner's current room). A room rename
+// must remap it alongside RoomID/HomeRoomID/WanderRooms, or a transient mob
+// despawns while its owner never left the allowed room.
+func TestRekeyRoomsUpdatesMobDespawnRooms(t *testing.T) {
+ s := &MobStore{instances: map[string]*MobInstance{
+ "m1": {
+ InstanceID: "m1",
+ RoomID: 1,
+ HomeRoomID: 1,
+ WanderRooms: []int{1, 5},
+ DespawnRooms: []int{1, 5},
+ },
+ }}
+ s.RekeyRooms(map[int]int{1: 3, 5: 7})
+ inst := s.instances["m1"]
+ if inst.RoomID != 3 || inst.HomeRoomID != 3 {
+ t.Fatalf("room/home = %d/%d, want 3/3", inst.RoomID, inst.HomeRoomID)
+ }
+ if got := inst.WanderRooms; len(got) != 2 || got[0] != 3 || got[1] != 7 {
+ t.Fatalf("wander_rooms = %v, want [3 7]", got)
+ }
+ if got := inst.DespawnRooms; len(got) != 2 || got[0] != 3 || got[1] != 7 {
+ t.Fatalf("despawn_rooms = %v, want [3 7]", got)
+ }
+}
diff --git a/internal/world/room_migrate.go b/internal/world/room_migrate.go
index 9bc7e0f..b7840bc 100644
--- a/internal/world/room_migrate.go
+++ b/internal/world/room_migrate.go
@@ -71,7 +71,12 @@ func (r *Room) RewriteRoomIDs(idMap map[int]int) bool {
}
// Per-room local objects: on_use/on_look steps + object desc conditions.
+ // WanderRooms is a room reference on every object entry (reference or
+ // local), so remap it outside the Local branch.
for i := range r.Objects {
+ if remapIntSlice(idMap, r.Objects[i].WanderRooms) {
+ changed = true
+ }
if r.Objects[i].Local != nil {
loc := r.Objects[i].Local
if remapTriggers(idMap, loc.OnUse) {
diff --git a/internal/world/room_migrate_test.go b/internal/world/room_migrate_test.go
index f5bd972..d76436b 100644
--- a/internal/world/room_migrate_test.go
+++ b/internal/world/room_migrate_test.go
@@ -34,11 +34,16 @@ func TestRoomRewriteRoomIDs(t *testing.T) {
Mobs: []RoomMob{
{ID: "guard", WanderRooms: []int{90, 100}},
},
+ Objects: []RoomObject{
+ // A reference object's WanderRooms is a room reference too.
+ {ID: "wandering_anvil", WanderRooms: []int{15, 25}},
+ },
}
idMap := map[int]int{
10: 11, 20: 21, 40: 41, 50: 51, 60: 61,
70: 71, 80: 81, 90: 91, 100: 101, 110: 111,
+ 15: 16, 25: 26,
}
if !r.RewriteRoomIDs(idMap) {
t.Fatal("expected changed=true")
@@ -80,6 +85,9 @@ func TestRoomRewriteRoomIDs(t *testing.T) {
if got := r.Mobs[0].WanderRooms; len(got) != 2 || got[0] != 91 || got[1] != 101 {
t.Errorf("mob wander_rooms = %v, want [91 101]", got)
}
+ if got := r.Objects[0].WanderRooms; len(got) != 2 || got[0] != 16 || got[1] != 26 {
+ t.Errorf("object wander_rooms = %v, want [16 26]", got)
+ }
}
func TestRoomRewriteRoomIDsNoChange(t *testing.T) {