diff options
Diffstat (limited to 'internal')
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) { |
