diff options
Diffstat (limited to 'internal')
45 files changed, 685 insertions, 138 deletions
diff --git a/internal/admin/server.go b/internal/admin/server.go index c13fe96..98400c2 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -740,6 +740,11 @@ func (s *AdminServer) accountFromCookie(r *http.Request) string { } func loadOrGenerateCert(cfg *config.Config) (tls.Certificate, error) { + // A half-configured cert pair must fail loudly instead of silently serving + // a self-signed certificate (see the game-server equivalent). + if (cfg.TLS.CertFile == "") != (cfg.TLS.KeyFile == "") { + return tls.Certificate{}, fmt.Errorf("admin_https: tls.cert_file and tls.key_file must be set together") + } if cfg.TLS.CertFile != "" && cfg.TLS.KeyFile != "" { return tls.LoadX509KeyPair(cfg.TLS.CertFile, cfg.TLS.KeyFile) } diff --git a/internal/behavior/store.go b/internal/behavior/store.go index cd7a6b7..a558f87 100644 --- a/internal/behavior/store.go +++ b/internal/behavior/store.go @@ -95,23 +95,27 @@ func SuccessChance(cfg SuccessFormula, level int, requiredLevel int) float64 { } var ( - dropIndex map[string]string + dropIndexes = map[string]map[string]string{} dropIndexMu sync.Mutex ) func loadDropIndex(dataDir string) map[string]string { dropIndexMu.Lock() defer dropIndexMu.Unlock() - if dropIndex == nil { - dropIndex = BuildPathIndex(filepath.Join(dataDir, "drops")) - } - return dropIndex + // Keyed by dataDir: a single process can host more than one data dir + // (tests), and a shared index would resolve ids against the first one. + if idx, ok := dropIndexes[dataDir]; ok { + return idx + } + idx := BuildPathIndex(filepath.Join(dataDir, "drops")) + dropIndexes[dataDir] = idx + return idx } func ClearDropIndex() { dropIndexMu.Lock() defer dropIndexMu.Unlock() - dropIndex = nil + dropIndexes = map[string]map[string]string{} } func LoadDropTable(dataDir, id string) (*DropTableDef, error) { @@ -161,6 +165,11 @@ func pickWeighted(drops []DropEntry) *DropEntry { // cycles at startup. const maxDropTableDepth = 32 +// maxDropRolls bounds a table-reference entry's `quantity` (how many times the +// referenced table is rolled). An accidental large value would run that many +// weighted picks on the owner tick and stall the whole world. +const maxDropRolls = 64 + func ResolveDrop(dataDir string, drops []DropEntry) *DropEntry { return resolveDrop(dataDir, drops, 0) } @@ -227,6 +236,12 @@ func resolveDropList(dataDir string, drops []DropEntry, depth int) []DropEntry { if rolls <= 0 { rolls = 1 } + // Bound authored quantities: an accidental `quantity: 1000000000` on a + // table reference would otherwise run a billion weighted picks on the + // owner tick and stall the whole world. + if rolls > maxDropRolls { + rolls = maxDropRolls + } var out []DropEntry for i := 0; i < rolls; i++ { if resolved := resolveDrop(dataDir, sub.Drops, depth+1); resolved != nil && resolved.ItemID != "" { diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go index 231a011..c9a50a1 100644 --- a/internal/casino/baccarat.go +++ b/internal/casino/baccarat.go @@ -369,6 +369,16 @@ func settleBaccaratBet(spot string, bet int, playerTotal, bankerTotal int, rules case bankerTotal > playerTotal: if spot == baccaratSpotBanker { bankerWin, _ := MulPayout(bet, rules.BankerPayout) + // The banker commission rounds UP, not down: when the wager is + // too small for a whole credit of commission, a winning banker + // bet returns only the stake. MulPayout's positive-stake floor + // would otherwise round the commission to zero and make the + // banker bet +EV (it wins more often than it loses, and ties + // push), which the "never bet less than one commission unit" + // rule exists to prevent. + if float64(bet)*rules.BankerPayout < 1 { + return "push", bet + } return "win", bet + bankerWin } default: diff --git a/internal/casino/baccarat_test.go b/internal/casino/baccarat_test.go index c5565b1..39941eb 100644 --- a/internal/casino/baccarat_test.go +++ b/internal/casino/baccarat_test.go @@ -108,8 +108,8 @@ func TestBaccaratSettlement(t *testing.T) { if result, payout := settleBaccaratBet(baccaratSpotBanker, 100, 4, 6, rules); result != "win" || payout != 195 { t.Fatalf("banker win: result=%s payout=%d, want win/195 (5%% commission)", result, payout) } - if result, payout := settleBaccaratBet(baccaratSpotBanker, 1, 4, 6, rules); result != "win" || payout != 2 { - t.Fatalf("banker win on bet 1: result=%s payout=%d, want win/2 (a winning stake never pays zero)", result, payout) + if result, payout := settleBaccaratBet(baccaratSpotBanker, 1, 4, 6, rules); result != "push" || payout != 1 { + t.Fatalf("banker win on bet 1: result=%s payout=%d, want push/1 (a sub-credit commission rounds up, returning only the stake)", result, payout) } if result, payout := settleBaccaratBet(baccaratSpotTie, 100, 7, 7, rules); result != "win" || payout != 900 { t.Fatalf("tie win: result=%s payout=%d, want win/900 (8:1)", result, payout) diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go index 16a480c..fd0572b 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -618,7 +618,16 @@ func (t *blackjackTable) settle() []Event { } payoutFailed := false for _, hand := range p.hands { - result, payout := settleHand(hand, dealerTotal, dealerBlackjack, t.Rules) + result, payout, settleErr := settleHand(hand, dealerTotal, dealerBlackjack, t.Rules) + if settleErr != nil { + // A payout multiplier that overflows the credit cap is a + // configuration defect, not a payable win. Treat it like a + // failed settlement so the stake stays open for recovery to + // refund rather than paying a clamped/absurd amount. + payoutFailed = true + events = append(events, t.playerAction(p.Name, "Settlement unavailable.", payoutFailedMessage())) + continue + } if payout > 0 && p.Wallet != nil { if err := p.Wallet.PayStakeCover(payout, hand.stakes); err != nil { // The payout could not be made durable. The journal @@ -658,10 +667,17 @@ func (t *blackjackTable) settle() []Event { } if p.insurance > 0 { insurancePayout := 0 + var insuranceErr error if dealerBlackjack { - insurancePayout, _ = MulPayout(p.insurance, 1+t.Rules.InsurancePayout) + insurancePayout, insuranceErr = MulPayout(p.insurance, 1+t.Rules.InsurancePayout) } - if insurancePayout > 0 { + switch { + case insuranceErr != nil: + // Overflow is a config defect: leave the insurance stake open + // for recovery to refund instead of paying a clamped amount. + payoutFailed = true + events = append(events, t.private(p.Name, payoutFailedMessage())) + case insurancePayout > 0: if err := p.Wallet.PayStakeCover(insurancePayout, []string{p.insuranceStakeID}); err != nil { payoutFailed = true events = append(events, t.private(p.Name, payoutFailedMessage())) @@ -669,7 +685,7 @@ func (t *blackjackTable) settle() []Event { p.Wallet.ResolveStake(p.insuranceStakeID) events = append(events, t.private(p.Name, fmt.Sprintf("Insurance pays %d credits.", insurancePayout))) } - } else { + default: // Insurance lost: its stake was consumed by the rules. p.Wallet.ResolveStake(p.insuranceStakeID) events = append(events, t.private(p.Name, "Insurance pays 0 credits.")) @@ -690,36 +706,36 @@ func (t *blackjackTable) settle() []Event { return events } -func settleHand(hand *blackjackHand, dealerTotal int, dealerBlackjack bool, rules BlackjackConfig) (string, int) { +func settleHand(hand *blackjackHand, dealerTotal int, dealerBlackjack bool, rules BlackjackConfig) (string, int, error) { total, _ := hand.total() if hand.surrendered { - return "surrender", hand.bet / 2 + return "surrender", hand.bet / 2, nil } if hand.bust { - return "bust", 0 + return "bust", 0, nil } playerBlackjack := hand.blackjack(rules) if playerBlackjack && dealerBlackjack { - return "push", hand.bet + return "push", hand.bet, nil } if dealerBlackjack { - return "dealer blackjack", 0 + return "dealer blackjack", 0, nil } if playerBlackjack { // Winnings round down to whole credits; never bet less than two. - blackjackWin, _ := MulPayout(hand.bet, rules.BlackjackPayout) - return "blackjack", hand.bet + blackjackWin + blackjackWin, err := MulPayout(hand.bet, rules.BlackjackPayout) + if err != nil { + return "blackjack", 0, err + } + return "blackjack", hand.bet + blackjackWin, nil } if total > 21 || dealerTotal > total && dealerTotal <= 21 { - return "loss", 0 + return "loss", 0, nil } if dealerTotal == total { - return "push", hand.bet - } - if dealerTotal > 21 { - return "win", hand.bet * 2 + return "push", hand.bet, nil } - return "win", hand.bet * 2 + return "win", hand.bet * 2, nil } func (t *blackjackTable) resetRound() { diff --git a/internal/casino/blackjack_test.go b/internal/casino/blackjack_test.go index 0f7527c..2e2e985 100644 --- a/internal/casino/blackjack_test.go +++ b/internal/casino/blackjack_test.go @@ -34,22 +34,22 @@ func TestBlackjackHandScoreShowsBlackjackAs21(t *testing.T) { func TestBlackjackSettlementRules(t *testing.T) { rules := (BlackjackConfig{}).Normalize() natural := &blackjackHand{bet: 10, cards: []card{{rank: "A"}, {rank: "K"}}} - if result, payout := settleHand(natural, 20, false, rules); result != "blackjack" || payout != 25 { - t.Fatalf("natural result=%s payout=%d", result, payout) + if result, payout, err := settleHand(natural, 20, false, rules); err != nil || result != "blackjack" || payout != 25 { + t.Fatalf("natural result=%s payout=%d err=%v", result, payout, err) } smallNatural := &blackjackHand{bet: 1, cards: []card{{rank: "A"}, {rank: "K"}}} - if result, payout := settleHand(smallNatural, 20, false, rules); result != "blackjack" || payout != 2 { - t.Fatalf("small natural result=%s payout=%d, want blackjack/2 (winnings round down)", result, payout) + if result, payout, err := settleHand(smallNatural, 20, false, rules); err != nil || result != "blackjack" || payout != 2 { + t.Fatalf("small natural result=%s payout=%d err=%v, want blackjack/2 (winnings round down)", result, payout, err) } dealerNatural := &blackjackHand{bet: 10, cards: []card{{rank: "A"}, {rank: "Q"}}} - if result, payout := settleHand(dealerNatural, 21, true, rules); result != "push" || payout != 10 { - t.Fatalf("dealer natural result=%s payout=%d", result, payout) + if result, payout, err := settleHand(dealerNatural, 21, true, rules); err != nil || result != "push" || payout != 10 { + t.Fatalf("dealer natural result=%s payout=%d err=%v", result, payout, err) } - if result, payout := settleHand(&blackjackHand{bet: 10, cards: []card{{rank: "10"}, {rank: "8"}}}, 21, true, rules); result != "dealer blackjack" || payout != 0 { - t.Fatalf("dealer blackjack result=%s payout=%d", result, payout) + if result, payout, err := settleHand(&blackjackHand{bet: 10, cards: []card{{rank: "10"}, {rank: "8"}}}, 21, true, rules); err != nil || result != "dealer blackjack" || payout != 0 { + t.Fatalf("dealer blackjack result=%s payout=%d err=%v", result, payout, err) } - if result, payout := settleHand(&blackjackHand{bet: 10, cards: []card{{rank: "10"}, {rank: "8"}}}, 22, false, rules); result != "win" || payout != 20 { - t.Fatalf("dealer bust result=%s payout=%d", result, payout) + if result, payout, err := settleHand(&blackjackHand{bet: 10, cards: []card{{rank: "10"}, {rank: "8"}}}, 22, false, rules); err != nil || result != "win" || payout != 20 { + t.Fatalf("dealer bust result=%s payout=%d err=%v", result, payout, err) } } diff --git a/internal/casino/config_validate.go b/internal/casino/config_validate.go index 38d426d..a03cfbf 100644 --- a/internal/casino/config_validate.go +++ b/internal/casino/config_validate.go @@ -50,6 +50,12 @@ func (c TableConfig) Validate() error { if !finiteNonnegative(b.BlackjackPayout) || !finiteNonnegative(b.InsurancePayout) { return fmt.Errorf("blackjack_payout and insurance_payout must be finite and nonnegative") } + if b.BlackjackPayout > maxBlackjackPayout { + return fmt.Errorf("blackjack_payout %v exceeds the %d:1 cap", b.BlackjackPayout, maxBlackjackPayout) + } + if b.InsurancePayout > maxInsurancePayout { + return fmt.Errorf("insurance_payout %v exceeds the %d:1 cap", b.InsurancePayout, maxInsurancePayout) + } rules := b.Normalize() if rules.AllowsSurrender() && c.Normalize().MinBet < minimumSurrenderBet { return fmt.Errorf("surrender needs min_bet >= %d: a surrendered hand returns half the bet, which rounds to zero below that", minimumSurrenderBet) @@ -70,6 +76,12 @@ func (c TableConfig) Validate() error { if !finiteNonnegative(b.TiePayout) || !finiteNonnegative(b.BankerPayout) { return fmt.Errorf("tie_payout and banker_payout must be finite and nonnegative") } + if b.TiePayout > maxTiePayout { + return fmt.Errorf("tie_payout %v exceeds the %d:1 cap", b.TiePayout, maxTiePayout) + } + if b.BankerPayout > maxBankerPayout { + return fmt.Errorf("banker_payout %v exceeds the %v:1 cap", b.BankerPayout, maxBankerPayout) + } // Non-blocking for the same reason as blackjack above. return nil default: @@ -114,6 +126,22 @@ func validateShoe(decks int, shuffleAt float64) error { // the whole stake. const minimumSurrenderBet = 2 +// Payout-multiplier caps. Validation bounds authored multipliers so a typo +// (e.g. blackjack_payout: 15) cannot silently create an absurd table and so +// MulPayout's overflow backstop is not the only thing standing between a +// configuration error and a MaxCredits payout. +const ( + maxBlackjackPayout = 5 + maxInsurancePayout = 5 + maxTiePayout = 10 + maxBankerPayout = 0.99 +) + +// maxSlotSymbolLen is the fixed width of one reel cell in the renderer +// (" " + symbol + padding + " "); a longer id would make the renderer's +// width padding negative and panic. +const maxSlotSymbolLen = 7 + // blackjackRoundCards is the heuristic per-round card budget the shoe uses // to decide whether to reshuffle before a round: an estimated eleven cards // per hand times the most hands the table can have in play (the dealer's @@ -141,6 +169,31 @@ func (p SlotProfile) Validate() error { return fmt.Errorf("unsupported slot geometry %dx%d: the runtime evaluator supports %dx%d only", profile.Rows, profile.Reels, slotGridRows, slotGridReels) } + // Symbol IDs are looked up by exact string and rendered in fixed-width + // reel cells, so a duplicate ID silently pays that win twice, an over-long + // ID panics the renderer, and a zero total weight makes weightedSymbol + // unable to pick anything. + seenSymbols := make(map[string]bool, len(profile.Symbols)) + totalWeight := 0 + for _, symbol := range profile.Symbols { + if symbol.ID == "" { + return fmt.Errorf("slot symbol with an empty id") + } + if len(symbol.ID) > maxSlotSymbolLen { + return fmt.Errorf("slot symbol id %q exceeds the %d-character reel width", symbol.ID, maxSlotSymbolLen) + } + if seenSymbols[symbol.ID] { + return fmt.Errorf("duplicate slot symbol id %q", symbol.ID) + } + seenSymbols[symbol.ID] = true + if symbol.Weight < 0 { + return fmt.Errorf("slot symbol %q has negative reel weight", symbol.ID) + } + totalWeight += symbol.Weight + } + if totalWeight <= 0 { + return fmt.Errorf("slot symbols have no total reel weight, so no symbol can ever appear") + } // A named trigger must exist, or the feature silently never fires while // the RTP model also computes a zero trigger probability and reports green. if profile.FreeSpinTrigger != "" { diff --git a/internal/casino/correct_casino_refund_bank_test.go b/internal/casino/correct_casino_refund_bank_test.go index fd90ae1..4262997 100644 --- a/internal/casino/correct_casino_refund_bank_test.go +++ b/internal/casino/correct_casino_refund_bank_test.go @@ -99,3 +99,35 @@ func TestMachineRefundAllFailedRefundKeepsStakeOpenForRecovery(t *testing.T) { t.Fatalf("the failed refund was not reported: %+v", events) } } + +// TestMachineDisconnectSettlesBankedFeatureWinnings: already-landed free-spin +// winnings are not part of the forfeited in-flight wager, so a disconnect must +// settle them through the wallet rather than silently zeroing featureBank. +func TestMachineDisconnectSettlesBankedFeatureWinnings(t *testing.T) { + m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, newTestRand()) + wallet := &testWallet{credits: 100} + m.join("alice") + m.beginSpinLocked(10, wallet, "", "") + m.spin = &SlotSpin{Payout: 25, ScatterCount: 3} + m.finishSpin() // triggers the feature: 25 banked, the stake stays open + if m.featureBank != 25 || !m.inFreeSpins { + t.Fatalf("setup: featureBank=%v inFreeSpins=%v, want 25/true", m.featureBank, m.inFreeSpins) + } + + events := m.markDisconnected("alice") + if wallet.paid != 25 { + t.Fatalf("disconnect paid %d, want 25 banked free-spin credits", wallet.paid) + } + if got := wallet.credits + wallet.paid; got != 115 { + t.Fatalf("banked winnings were destroyed on disconnect: balance %d, want 115 (100 - 10 forfeited stake + 25 banked)", got) + } + if !hasMessage(events, "25") { + t.Fatalf("missing banked-winnings settlement message: %+v", events) + } + if len(wallet.open) != 0 { + t.Fatalf("stake still outstanding after disconnect: %+v", wallet.open) + } + if m.owns("alice") { + t.Fatal("machine still owns the disconnected player") + } +} diff --git a/internal/casino/machine.go b/internal/casino/machine.go index cf0ccb9..83e018f 100644 --- a/internal/casino/machine.go +++ b/internal/casino/machine.go @@ -380,15 +380,47 @@ func (m *Machine) resetSeat() { // markDisconnected removes the player from the machine, forfeiting any // in-flight wager, so the seat is immediately free for someone else. +// +// Already-landed free-spin winnings (featureBank) are NOT part of the +// in-flight wager: they are settled after resetSeat resolves the triggering +// stake, so the unlinked settlement cannot be read at recovery as covering +// that stake. If the round's payout could not be persisted (stakeFailed), the +// stake is left open for recovery and the bank is reported as forfeited +// rather than silently destroyed. func (m *Machine) markDisconnected(name string) []Event { m.mu.Lock() defer m.mu.Unlock() if !m.owns(name) { return nil } + bank := int(m.featureBank) + wallet := m.player.Wallet + stakeFailed := m.stakeFailed m.resetSeat() - return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventLeft, Player: name, + events := []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventLeft, Player: name, Message: fmt.Sprintf("%s stands up from the slots machine.", name), Color: "casino_action", Public: true}} + if bank <= 0 || wallet == nil { + return events + } + switch { + case stakeFailed: + events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, + PrivateTo: name, Player: name, + PrivateMessage: fmt.Sprintf("Your %d credits of banked free-spin winnings were forfeited because the round could not be persisted.", bank), + Color: "casino_action", Public: false}) + case wallet.PayCredits(bank) != nil: + events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, + PrivateTo: name, Player: name, + PrivateMessage: fmt.Sprintf("Your %d credits of free-spin winnings could not be persisted; they are recorded for recovery.", bank), + Color: "casino_action", Public: false}) + default: + events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, + PrivateTo: name, Player: name, + Message: fmt.Sprintf("%s's %d banked free-spin credits pay out on disconnect.", name, bank), + PrivateMessage: fmt.Sprintf("The house settles your %d banked free-spin winnings.", bank), + Color: "casino_action", Public: true}) + } + return events } func (m *Machine) hasParticipant(name string) bool { diff --git a/internal/casino/p2_hardening_test.go b/internal/casino/p2_hardening_test.go new file mode 100644 index 0000000..435e102 --- /dev/null +++ b/internal/casino/p2_hardening_test.go @@ -0,0 +1,60 @@ +package casino + +import "testing" + +// TestTableConfigRejectsAbsurdMultipliers: authored payout multipliers are +// bounded so a typo cannot silently create an absurd table (and so the +// MulPayout overflow backstop is not the only line of defence). +func TestTableConfigRejectsAbsurdMultipliers(t *testing.T) { + for name, c := range map[string]TableConfig{ + "blackjack payout": {ID: "t", Game: GameBlackjack, Blackjack: BlackjackConfig{BlackjackPayout: 15}}, + "insurance payout": {ID: "t", Game: GameBlackjack, Blackjack: BlackjackConfig{InsurancePayout: 15}}, + "tie payout": {ID: "t", Game: GameBaccarat, Baccarat: BaccaratConfig{TiePayout: 100}}, + "banker payout": {ID: "t", Game: GameBaccarat, Baccarat: BaccaratConfig{BankerPayout: 1.5}}, + } { + t.Run(name, func(t *testing.T) { + if c.Validate() == nil { + t.Fatalf("accepted an absurd payout multiplier: %+v", c) + } + }) + } + // The shipped/default values remain valid. + if err := (TableConfig{ID: "t", Game: GameBaccarat}).Validate(); err != nil { + t.Fatalf("default baccarat table rejected: %v", err) + } + if err := (TableConfig{ID: "t", Game: GameBlackjack}).Validate(); err != nil { + t.Fatalf("default blackjack table rejected: %v", err) + } +} + +// TestSlotProfileRejectsDuplicateSymbol: a duplicated id would pay that win +// twice because evaluateSlot matches symbols by id. +func TestSlotProfileRejectsDuplicateSymbol(t *testing.T) { + p := DefaultHuffAndPuffProfile() + p.Symbols = append(p.Symbols, p.Symbols[0]) + if err := p.Validate(); err == nil { + t.Fatal("accepted a duplicate slot symbol id") + } +} + +// TestSlotProfileRejectsZeroWeight: with no total reel weight weightedSymbol +// can never pick a symbol. +func TestSlotProfileRejectsZeroWeight(t *testing.T) { + p := DefaultHuffAndPuffProfile() + for i := range p.Symbols { + p.Symbols[i].Weight = 0 + } + if err := p.Validate(); err == nil { + t.Fatal("accepted a profile with no reel weight") + } +} + +// TestSlotProfileRejectsOverlongSymbol: the renderer pads to a fixed 7-cell +// width, so a longer id makes the padding negative and panics. +func TestSlotProfileRejectsOverlongSymbol(t *testing.T) { + p := DefaultHuffAndPuffProfile() + p.Symbols[0].ID = "way-too-long" + if err := p.Validate(); err == nil { + t.Fatal("accepted a symbol id longer than the reel width") + } +} diff --git a/internal/casino/types.go b/internal/casino/types.go index 71da01c..578aee1 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -44,10 +44,20 @@ func ruleBool(value *bool, defaultValue bool) bool { func (c BlackjackConfig) DealerHitsSoft17() bool { return ruleBool(c.DealerHitSoft17, false) } func (c BlackjackConfig) AllowsResplitAces() bool { return ruleBool(c.ResplitAces, false) } func (c BlackjackConfig) AllowsDoubleAfterSplit() bool { return ruleBool(c.DoubleAfterSplit, true) } + +// CountsDoubleSplitBlackjack reports whether a 3+ card hand that reaches 21 +// after a split and double is paid as a blackjack (3:2). When true (the +// default) this raises the table RTP above the nominal blackjack payout; +// standard play pays 1:1 for any 21 on a split hand, doubled or not. func (c BlackjackConfig) CountsDoubleSplitBlackjack() bool { return ruleBool(c.DoubleSplitBlackjack, true) } -func (c BlackjackConfig) AllowsSurrender() bool { return ruleBool(c.Surrender, false) } + +func (c BlackjackConfig) AllowsSurrender() bool { return ruleBool(c.Surrender, false) } + +// AllowsUnlikeTenSplit reports whether any two 10-valued cards may split +// (K+Q, 10+J). Standard casinos split only identical ranks, so a true value +// (the default) is player-favourable. func (c BlackjackConfig) AllowsUnlikeTenSplit() bool { return ruleBool(c.SplitUnlikeTenValues, true) } func (c BlackjackConfig) Normalize() BlackjackConfig { @@ -159,14 +169,14 @@ func (c TableConfig) Normalize() TableConfig { if c.MaxBet <= 0 { c.MaxBet = DefaultMaxBet } - if c.MaxBet < c.MinBet { - c.MaxBet = c.MinBet - } // Normalize the nested game rule blocks too: a new game that reads // t.Config.Blackjack/Baccarat directly (rather than only t.Rules) would // otherwise see zero values and build an empty shoe. c.Blackjack = c.Blackjack.Normalize() c.Baccarat = c.Baccarat.Normalize() + if c.MaxBet < c.MinBet { + c.MaxBet = c.MinBet + } return c } diff --git a/internal/game/act_agility.go b/internal/game/act_agility.go index fd16102..9842365 100644 --- a/internal/game/act_agility.go +++ b/internal/game/act_agility.go @@ -159,6 +159,10 @@ func (g *Game) obstacleFail(sess *net.Session, p *player.Player, d *behavior.Obs if p.HP < 1 { p.HP = 1 } + // Match the combat/hazard damage paths: without StartRegen the first + // RegenTick sees a zero timer and heals on the very next tick instead of + // waiting the full regen interval. + p.StartRegen() sess.WriteLine(g.colorize(sess, "damage_taken", fmt.Sprintf("You take %s damage. HP: %d/%d", g.dmgDisplay(sess, damage), p.HP, p.MaxHP()))) diff --git a/internal/game/act_effects.go b/internal/game/act_effects.go index a107320..e51deab 100644 --- a/internal/game/act_effects.go +++ b/internal/game/act_effects.go @@ -173,10 +173,16 @@ func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Ste } // 10. credits (signed). A negative cost was preflighted for affordability, - // so the debit is unconditional here. + // so the debit is unconditional here. Clamp defensively anyway: a + // multi-step batch that shares one tick must never drive the balance + // negative, an invariant every wallet/economy path relies on. if step.Credits != 0 { if step.Credits < 0 { - p.Credits += step.Credits + debit := step.Credits + if p.Credits+debit < 0 { + debit = -p.Credits + } + p.Credits += debit g.saveCharacter(p) } else { g.grantCredits(p, step.Credits) diff --git a/internal/game/act_talk.go b/internal/game/act_talk.go index 9ae9959..239e6f5 100644 --- a/internal/game/act_talk.go +++ b/internal/game/act_talk.go @@ -192,7 +192,16 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) { } td, ok := p.Action.Data.(*behavior.TalkData) - if !ok || td.Cfg == nil { + if !ok { + g.cancelAction(p) + sess.SetState(net.StateGame) + g.writePrompt(sess) + return + } + // The estate-directory prompt is a talk action with no dialogue tree + // (Cfg == nil), so it must be allowed through to its own handler below + // instead of being cancelled as a malformed talk node. + if td.Cfg == nil && !td.EstateDirectory { g.cancelAction(p) sess.SetState(net.StateGame) g.writePrompt(sess) diff --git a/internal/game/casino.go b/internal/game/casino.go index 4dfced6..dcf4273 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -932,7 +932,14 @@ func renderSlotSnapshot(snapshot *casino.SlotSnapshot, mode string, unicode bool if reel < snapshot.StoppedReels { value = snapshot.Grid[row][reel] } - line += " " + colorSlotSymbol(mode, value) + strings.Repeat(" ", 7-len(value)) + " " + frameVertical + // Pad to the fixed 7-cell reel width; a malformed profile could + // otherwise make this negative and panic. Validation caps the id + // length, so this is a defence-in-depth backstop. + pad := 7 - len(value) + if pad < 0 { + pad = 0 + } + line += " " + colorSlotSymbol(mode, value) + strings.Repeat(" ", pad) + " " + frameVertical } lines = append(lines, line) } diff --git a/internal/game/cmd_close.go b/internal/game/cmd_close.go index 217ae9a..20c49ba 100644 --- a/internal/game/cmd_close.go +++ b/internal/game/cmd_close.go @@ -2,7 +2,6 @@ package game import ( "fmt" - "os" "gopkg.in/yaml.v3" "thehouseoficarus/internal/net" @@ -60,7 +59,7 @@ func (g *Game) executeClose(sess *net.Session, args []string, rawInput string) { sess.WriteLine("Error updating current room YAML.") return } - if err := os.WriteFile(curPath, curData, 0644); err != nil { + if err := writeRoomFile(curPath, curData); err != nil { sess.WriteLine("Error writing current room file.") return } @@ -74,7 +73,7 @@ func (g *Game) executeClose(sess *net.Session, args []string, rawInput string) { if tok2 { targetData, terr := yaml.Marshal(targetRoom) if terr == nil { - if werr := os.WriteFile(targetPath, targetData, 0644); werr == nil { + if werr := writeRoomFile(targetPath, targetData); werr == nil { reciprocalRemoved = true } } diff --git a/internal/game/cmd_dig.go b/internal/game/cmd_dig.go index 1497c34..c58ba63 100644 --- a/internal/game/cmd_dig.go +++ b/internal/game/cmd_dig.go @@ -63,7 +63,7 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) { sess.WriteLine("Error updating current room YAML.") return } - if err := os.WriteFile(curPath, curData, 0644); err != nil { + if err := writeRoomFile(curPath, curData); err != nil { sess.WriteLine("Error writing current room file.") return } @@ -75,7 +75,9 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) { if ok { conflictData, cerr := yaml.Marshal(conflictRoom) if cerr == nil { - os.WriteFile(conflictPath, conflictData, 0644) + if werr := writeRoomFile(conflictPath, conflictData); werr != nil { + sess.WriteLine(fmt.Sprintf("Warning: could not link the opposite exit in room %d: %v", conflictID, werr)) + } } } } @@ -161,7 +163,7 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) { sess.WriteLine("Error creating directory.") return } - if err := os.WriteFile(newPath, data, 0644); err != nil { + if err := writeRoomFile(newPath, data); err != nil { sess.WriteLine("Error writing room file.") return } @@ -174,7 +176,7 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) { sess.WriteLine("Error updating current room YAML.") return } - if err := os.WriteFile(curPath, curData, 0644); err != nil { + if err := writeRoomFile(curPath, curData); err != nil { sess.WriteLine("Error writing current room file.") return } diff --git a/internal/game/cmd_remove.go b/internal/game/cmd_remove.go index 5bc02a7..53dd313 100644 --- a/internal/game/cmd_remove.go +++ b/internal/game/cmd_remove.go @@ -151,10 +151,6 @@ func (g *Game) doRemoveAll(sess *net.Session) { } needed := len(p.Equipment) - if _, hasAmmo := p.Equipment[item.SlotAmmo]; hasAmmo { - needed-- - needed++ - } if needed > p.FreeSlots() { sess.WriteLine("You don't have room to remove everything!") return diff --git a/internal/game/cmd_room.go b/internal/game/cmd_room.go index 3210ef5..b381cf3 100644 --- a/internal/game/cmd_room.go +++ b/internal/game/cmd_room.go @@ -2,7 +2,6 @@ package game import ( "fmt" - "os" "strings" "gopkg.in/yaml.v3" @@ -190,7 +189,7 @@ func (g *Game) writeRoom(sess *net.Session, fn func(*world.Room)) { sess.WriteLine("Error marshaling room YAML.") return } - if err := os.WriteFile(path, data, 0644); err != nil { + if err := writeRoomFile(path, data); err != nil { sess.WriteLine("Error writing room file.") return } diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go index 92ef38b..8e01326 100644 --- a/internal/game/cmd_room_insert.go +++ b/internal/game/cmd_room_insert.go @@ -127,7 +127,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) { sess.WriteLine("Error creating directory.") return } - if err := os.WriteFile(newPath, data, 0644); err != nil { + if err := writeRoomFile(newPath, data); err != nil { sess.WriteLine("Error writing room file.") return } @@ -147,7 +147,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) { sess.WriteLine("Error updating current room YAML.") return } - if err := os.WriteFile(curPath, curData, 0644); err != nil { + if err := writeRoomFile(curPath, curData); err != nil { sess.WriteLine("Error writing current room file.") return } @@ -166,7 +166,9 @@ func (g *Game) roomInsert(sess *net.Session, args []string) { if tok2 { targetData, terr := yaml.Marshal(targetRoom) if terr == nil { - os.WriteFile(targetPath, targetData, 0644) + if werr := writeRoomFile(targetPath, targetData); werr != nil { + sess.WriteLine(fmt.Sprintf("Warning: could not link the opposite exit in room %d: %v", targetID, werr)) + } } } diff --git a/internal/game/cmd_room_mob.go b/internal/game/cmd_room_mob.go index ced170f..ce729d4 100644 --- a/internal/game/cmd_room_mob.go +++ b/internal/game/cmd_room_mob.go @@ -2,7 +2,6 @@ package game import ( "fmt" - "os" "gopkg.in/yaml.v3" "thehouseoficarus/internal/net" @@ -44,7 +43,10 @@ func (g *Game) roomAddMob(sess *net.Session, args []string) { path, _ := g.World.GetRoomPath(p.RoomID) data, _ := yaml.Marshal(room) - os.WriteFile(path, data, 0644) + if err := writeRoomFile(path, data); err != nil { + sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + return + } sess.WriteLine(fmt.Sprintf("Added mob '%s' to room.", mobID)) } @@ -77,6 +79,9 @@ func (g *Game) roomRemMob(sess *net.Session, args []string) { path, _ := g.World.GetRoomPath(p.RoomID) data, _ := yaml.Marshal(room) - os.WriteFile(path, data, 0644) + if err := writeRoomFile(path, data); err != nil { + sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + return + } sess.WriteLine(fmt.Sprintf("Removed mob '%s' from room.", mobID)) } diff --git a/internal/game/cmd_room_object.go b/internal/game/cmd_room_object.go index 713a8f3..38b828a 100644 --- a/internal/game/cmd_room_object.go +++ b/internal/game/cmd_room_object.go @@ -2,7 +2,6 @@ package game import ( "fmt" - "os" "strings" "gopkg.in/yaml.v3" @@ -118,7 +117,10 @@ func (g *Game) roomAddLocalObj(sess *net.Session, name string, aliases []string, path, _ := g.World.GetRoomPath(p.RoomID) data, _ := yaml.Marshal(room) - os.WriteFile(path, data, 0644) + if err := writeRoomFile(path, data); err != nil { + sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + return + } sess.WriteLine(fmt.Sprintf("Added local object '%s' to room.", name)) } @@ -153,7 +155,10 @@ func (g *Game) roomRemLocalObj(sess *net.Session, name string) { path, _ := g.World.GetRoomPath(p.RoomID) data, _ := yaml.Marshal(room) - os.WriteFile(path, data, 0644) + if err := writeRoomFile(path, data); err != nil { + sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + return + } sess.WriteLine(fmt.Sprintf("Removed local object '%s' from room.", name)) } @@ -217,7 +222,10 @@ func (g *Game) roomToggleHidden(sess *net.Session, input string, hidden bool) { path, _ := g.World.GetRoomPath(p.RoomID) data, _ := yaml.Marshal(room) - os.WriteFile(path, data, 0644) + if err := writeRoomFile(path, data); err != nil { + sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + return + } action := "Hidden" if !hidden { diff --git a/internal/game/cmd_room_remove.go b/internal/game/cmd_room_remove.go index af745fd..77a09b3 100644 --- a/internal/game/cmd_room_remove.go +++ b/internal/game/cmd_room_remove.go @@ -180,7 +180,7 @@ func (g *Game) roomRemove(sess *net.Session, args []string) { sess.WriteLine("Error updating current room YAML.") return } - if err := os.WriteFile(aPath, aData, 0644); err != nil { + if err := writeRoomFile(aPath, aData); err != nil { sess.WriteLine("Error writing current room file.") return } @@ -198,7 +198,9 @@ func (g *Game) roomRemove(sess *net.Session, args []string) { if cPathOK { cData, cerr := yaml.Marshal(cRoom) if cerr == nil { - os.WriteFile(cPath, cData, 0644) + if werr := writeRoomFile(cPath, cData); werr != nil { + sess.WriteLine(fmt.Sprintf("Warning: could not link the opposite exit in room %d: %v", cID, werr)) + } } } diff --git a/internal/game/cmd_room_spawn.go b/internal/game/cmd_room_spawn.go index d4353e4..c626acb 100644 --- a/internal/game/cmd_room_spawn.go +++ b/internal/game/cmd_room_spawn.go @@ -2,7 +2,6 @@ package game import ( "fmt" - "os" "strconv" "gopkg.in/yaml.v3" @@ -63,7 +62,10 @@ func (g *Game) roomAddSpawn(sess *net.Session, args []string) { path, _ := g.World.GetRoomPath(p.RoomID) data, _ := yaml.Marshal(room) - os.WriteFile(path, data, 0644) + if err := writeRoomFile(path, data); err != nil { + sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + return + } sess.WriteLine(fmt.Sprintf("Added item spawn '%s' x%d (respawn: %.0f ticks) to room.", itemID, quantity, respawnTicks)) } @@ -96,6 +98,9 @@ func (g *Game) roomRemSpawn(sess *net.Session, args []string) { path, _ := g.World.GetRoomPath(p.RoomID) data, _ := yaml.Marshal(room) - os.WriteFile(path, data, 0644) + if err := writeRoomFile(path, data); err != nil { + sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + return + } sess.WriteLine(fmt.Sprintf("Removed item spawn '%s' from room.", itemID)) } diff --git a/internal/game/cmd_shop.go b/internal/game/cmd_shop.go index f3211e0..e2209fc 100644 --- a/internal/game/cmd_shop.go +++ b/internal/game/cmd_shop.go @@ -345,10 +345,25 @@ func (g *Game) doShopSell(sess *net.Session, mob *world.MobInstance, input strin } stock := g.MobStore.ShopStockOf(mob.InstanceID, match.ID) + // Only sell as many as the balance headroom can pay for: grantCredits + // clamps at the economy cap, so selling past it would consume the items + // and destroy the excess credits (the pickup paths take only the headroom). + headroom := creditHeadroom(p) total := 0 + sellQty := 0 for k := 0; k < actualQty; k++ { - total += mob.Shop.SellPrice(value, stock+k) + price := mob.Shop.SellPrice(value, stock+k) + if total+price > headroom { + break + } + total += price + sellQty++ + } + if sellQty <= 0 { + sess.WriteLine("You can't carry any more credits.") + return } + actualQty = sellQty p.RemoveItem(match.ID, actualQty) g.grantCredits(p, total) diff --git a/internal/game/cmd_swapid.go b/internal/game/cmd_swapid.go index 9ee832a..00b6568 100644 --- a/internal/game/cmd_swapid.go +++ b/internal/game/cmd_swapid.go @@ -135,7 +135,7 @@ func (g *Game) executeSwapID(sess *net.Session, args []string, rawInput string) if merr != nil { continue } - if werr := os.WriteFile(path, data, 0644); werr != nil { + if werr := writeRoomFile(path, data); werr != nil { continue } updated++ diff --git a/internal/game/cmd_trigger_utility.go b/internal/game/cmd_trigger_utility.go index f9e3692..75512d1 100644 --- a/internal/game/cmd_trigger_utility.go +++ b/internal/game/cmd_trigger_utility.go @@ -59,6 +59,14 @@ func (g *Game) triggerProcessing(sess *net.Session, p *player.Player, mod *ModDe } } + // Refuse before consuming the item when the balance is at the cap: + // grantCredits clamps, so processing would otherwise destroy the item's + // value entirely. + if creditValue > creditHeadroom(p) { + sess.WriteLine("You can't carry any more credits.") + return + } + if inv.Quantity > 1 { inv.Quantity-- } else { diff --git a/internal/game/cmd_undig.go b/internal/game/cmd_undig.go index 8a47db9..a0fd1a0 100644 --- a/internal/game/cmd_undig.go +++ b/internal/game/cmd_undig.go @@ -204,7 +204,7 @@ func (g *Game) performUndig(sess *net.Session, p *player.Player, targetID int) { if merr != nil { return nil } - if werr := os.WriteFile(path, newData, 0644); werr != nil { + if werr := writeRoomFile(path, newData); werr != nil { return werr } cleaned++ diff --git a/internal/game/economy.go b/internal/game/economy.go index 9ce4404..0b9e92f 100644 --- a/internal/game/economy.go +++ b/internal/game/economy.go @@ -863,6 +863,24 @@ func pruneOpenPayouts(j *economyJournal, p *player.Player) bool { return changed } +// reanchorOwedPayout rewrites an open payout record's checkpoints to the +// current durable balance so a later recovery reads it as still owed (the +// balance sits at the record's before-checkpoint). It is used when a recovery +// run could not pay every sibling in an identical-signature group: the paid +// siblings moved the durable balance, so leaving the unpaid remainder at its +// original checkpoints would let a later run mistake the balance for its own +// durability and settle the liability without paying it. The post-checkpoint +// respects the economy cap: when the credit cannot fit, before == after, so +// the record keeps reading as owed at this balance instead of claiming an +// impossible post-state. +func reanchorOwedPayout(rec *economyRecord, credits, credit int) { + rec.CreditsBefore = credits + rec.CreditsAfter = credits + if after, err := addCreditsChecked(credits, credit); err == nil { + rec.CreditsAfter = after + } +} + // recoverEconomy compensates open records from the last crash. It is called // once from New, before any session connects. // @@ -885,24 +903,6 @@ func pruneOpenPayouts(j *economyJournal, p *player.Player) bool { // written later proves the round resolved (a win/push/refund already // returned the stake inside that payout) → settled with no refund. // Anything else stays open for operator inspection. -// reanchorOwedPayout rewrites an open payout record's checkpoints to the -// current durable balance so a later recovery reads it as still owed (the -// balance sits at the record's before-checkpoint). It is used when a recovery -// run could not pay every sibling in an identical-signature group: the paid -// siblings moved the durable balance, so leaving the unpaid remainder at its -// original checkpoints would let a later run mistake the balance for its own -// durability and settle the liability without paying it. The post-checkpoint -// respects the economy cap: when the credit cannot fit, before == after, so -// the record keeps reading as owed at this balance instead of claiming an -// impossible post-state. -func reanchorOwedPayout(rec *economyRecord, credits, credit int) { - rec.CreditsBefore = credits - rec.CreditsAfter = credits - if after, err := addCreditsChecked(credits, credit); err == nil { - rec.CreditsAfter = after - } -} - func (g *Game) recoverEconomy() { if g.Economy == nil { return diff --git a/internal/game/game.go b/internal/game/game.go index 3ffc72e..5ad1033 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -79,6 +79,13 @@ type Game struct { sequences map[string]*sequence globalSeqs []*sequence + // disconnectDeferrals counts, per session, how many ticks its pending + // disconnect has been deferred for combat. Per-Game (not package-level) + // so a second Game in one process cannot inherit stale counters, and + // pruned each tick so a session hard-removed elsewhere cannot pin its + // Player in memory for the life of the process. + disconnectDeferrals map[*net.Session]int + shutdownMu sync.Mutex // shutdownOnce guards RequestShutdown; transportStop is the registered @@ -134,18 +141,19 @@ func New(dataDir string, colorConfig *config.ColorsConfig, constantColorConfig * // Entropy-seeded, not clock-seeded: a wall-clock seed makes every // shoe and reel stream reconstructible. casino.NewManager(seed) stays // the deterministic test seam. - Casino: casino.NewManagerEntropy(), - flagIndex: newFlagTriggerIndex(), - queue: NewCommandQueue(), - safespot: NewSafespotManager(), - ValidationConfig: valConfig, - StartingRoom: startingRoom, - loggedInChars: make(map[string]*net.Session), - restTimers: make(map[string]uint64), - guardWatchTimers: make(map[string]int), - hackingStates: make(map[string]*hacking.Session), - sequences: make(map[string]*sequence), - shutdownDone: make(chan struct{}), + Casino: casino.NewManagerEntropy(), + flagIndex: newFlagTriggerIndex(), + queue: NewCommandQueue(), + safespot: NewSafespotManager(), + ValidationConfig: valConfig, + StartingRoom: startingRoom, + loggedInChars: make(map[string]*net.Session), + restTimers: make(map[string]uint64), + guardWatchTimers: make(map[string]int), + hackingStates: make(map[string]*hacking.Session), + sequences: make(map[string]*sequence), + disconnectDeferrals: make(map[*net.Session]int), + shutdownDone: make(chan struct{}), } g.owner = newOwner() // Runtime assignment (not initializer-level) so the command registry, @@ -700,6 +708,12 @@ func (g *Game) ProcessQueuedCommands() { }) for _, qc := range actives { + // A queued command can outlive its session (logout, disconnect, or a + // re-login on a new connection): never dereference a departed one on + // the owner goroutine. Mirrors the free-queue guard above. + if qc.Session == nil || qc.Session.Player == nil { + continue + } raw := qc.Command if qc.Args != "" { raw += " " + qc.Args diff --git a/internal/game/production_engine.go b/internal/game/production_engine.go index ddada6b..89d6f55 100644 --- a/internal/game/production_engine.go +++ b/internal/game/production_engine.go @@ -128,7 +128,15 @@ func (g *Game) startProductionFromItem(sess *net.Session, p *player.Player, item return } - if craft.Type == "combine" { + // Normalize the empty craft type to "combine" here as well as in the + // craft index: an omitted `type:` is combine, but the index's normalized + // key was never written back to the recipe, so the literal comparison + // below would fall through to the production path with an empty key. + craftType := craft.Type + if craftType == "" { + craftType = "combine" + } + if craftType == "combine" { g.startCombine(sess, p, item, craft, count) return } @@ -140,7 +148,7 @@ func (g *Game) startProductionFromItem(sess *net.Session, p *player.Player, item cKey := craft.Subtype if cKey == "" { - cKey = craft.Type + cKey = craftType } info, ok := productionTypes[cKey] diff --git a/internal/game/room_write.go b/internal/game/room_write.go new file mode 100644 index 0000000..5ba24c1 --- /dev/null +++ b/internal/game/room_write.go @@ -0,0 +1,13 @@ +package game + +import ( + "thehouseoficarus/internal/persistence" +) + +// writeRoomFile durably writes an authored world/content file: temp file + +// rename + directory fsync. The in-game admin room commands previously used a +// bare os.WriteFile, which can truncate world data if the process dies +// mid-write. +func writeRoomFile(path string, data []byte) error { + return persistence.WriteFile(path, data, 0644) +} diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go index 312fe50..8bba630 100644 --- a/internal/game/sys_triggers.go +++ b/internal/game/sys_triggers.go @@ -151,6 +151,7 @@ func (g *Game) advanceSequence(seq *sequence) { if len(seq.steps) == 0 { g.completePlayerSequence(seq, p) } + g.syncPendingSequence(seq, p) return } @@ -172,6 +173,7 @@ func (g *Game) advanceSequence(seq *sequence) { if len(seq.steps) == 0 { g.completePlayerSequence(seq, p) } + g.syncPendingSequence(seq, p) return } @@ -220,6 +222,22 @@ func (g *Game) completePlayerSequence(seq *sequence, p *player.Player) { } } +// syncPendingSequence keeps a locked sequence's persisted snapshot in step +// with the live sequence as it advances, so a crash before the next clean +// disconnect/shutdown cannot replay already-fired steps (and re-grant their +// rewards) on reconnect. It is a no-op for unlocked/completed sequences. +func (g *Game) syncPendingSequence(seq *sequence, p *player.Player) { + if p == nil || !seq.locked || p.PendingSequence == nil || p.PendingSequence.Key != seq.key { + return + } + if len(seq.steps) == 0 { + return + } + p.PendingSequence.Steps = append([]behavior.Step(nil), seq.steps...) + p.PendingSequence.Wait = seq.wait + g.saveCharacter(p) +} + func (g *Game) completeGlobalSequence(seq *sequence) { g.removeSequence(seq) } diff --git a/internal/game/tick.go b/internal/game/tick.go index 4181622..ef8089a 100644 --- a/internal/game/tick.go +++ b/internal/game/tick.go @@ -16,19 +16,18 @@ import ( // lockout) forever. const disconnectCombatGraceTicks = 5 -// disconnectCombatDeferrals counts, per session, how many ticks its pending -// disconnect has already been deferred for combat. DisconnectTick is its only -// reader/writer and every tick system runs on the owner goroutine, so the map -// needs no lock. An entry is dropped when this path completes the hard remove; -// a session hard-removed elsewhere (a second Hub.Remove, shutdown) leaves at -// most one stale int behind. -var disconnectCombatDeferrals = map[*net.Session]int{} - func (g *Game) DisconnectTick() { if g.Hub == nil { return } - for _, sess := range g.Hub.AllSessions() { + // Lazy init: tests construct Game literals directly rather than via New. + if g.disconnectDeferrals == nil { + g.disconnectDeferrals = make(map[*net.Session]int) + } + sessions := g.Hub.AllSessions() + seen := make(map[*net.Session]bool, len(sessions)) + for _, sess := range sessions { + seen[sess] = true if !sess.IsDisconnecting() { continue } @@ -44,18 +43,26 @@ func (g *Game) DisconnectTick() { // until a restart. Defer while the fight can still resolve, but only // for a bounded grace period, then let the countdown proceed. if g.Combat.Get(p.Name) != nil { - if deferred := disconnectCombatDeferrals[sess]; deferred < disconnectCombatGraceTicks { - disconnectCombatDeferrals[sess] = deferred + 1 + if deferred := g.disconnectDeferrals[sess]; deferred < disconnectCombatGraceTicks { + g.disconnectDeferrals[sess] = deferred + 1 continue } } if sess.TickDisconnectCountdown() { - delete(disconnectCombatDeferrals, sess) + delete(g.disconnectDeferrals, sess) g.restoreGodPlayer(p) g.saveCharacter(p) g.Hub.HardRemove(sess) } } + // Drop counters for sessions hard-removed elsewhere (a second Hub.Remove, + // shutdown) so a departed session (and its Player) cannot be retained for + // the life of the process. + for sess := range g.disconnectDeferrals { + if !seen[sess] { + delete(g.disconnectDeferrals, sess) + } + } } func (g *Game) RegenTick() { diff --git a/internal/net/p2_hardening_test.go b/internal/net/p2_hardening_test.go new file mode 100644 index 0000000..089dcb2 --- /dev/null +++ b/internal/net/p2_hardening_test.go @@ -0,0 +1,31 @@ +package net + +import ( + "testing" + + "thehouseoficarus/internal/config" +) + +// TestPartialTLSConfigIsFatal: an operator who sets only one of +// tls.cert_file / tls.key_file must get a startup error instead of a silent +// self-signed fallback that makes clients fail verification confusingly. +func TestPartialTLSConfigIsFatal(t *testing.T) { + cfg := config.Default() + cfg.TLS.CertFile = "/tmp/only-cert.pem" + cfg.TLS.KeyFile = "" + if _, err := loadOrGenerateCert(cfg, "test"); err == nil { + t.Fatal("accepted a half-configured TLS cert pair") + } +} + +// TestPreAuthStatesUseShortTimeout: the new-account prompts are pre-auth +// states and must use the short login window, not the 30-minute idle window. +func TestPreAuthStatesUseShortTimeout(t *testing.T) { + for _, st := range []SessionState{StateAccountName, StatePassword, StateNewAccountPass, StateNewAccount} { + sess := &Session{} + sess.SetState(st) + if got := sessionIdleTimeout(sess); got != loginReadTimeout { + t.Fatalf("state %v timeout = %v, want %v", st, got, loginReadTimeout) + } + } +} diff --git a/internal/net/server.go b/internal/net/server.go index 05a98d1..f56aebb 100644 --- a/internal/net/server.go +++ b/internal/net/server.go @@ -233,7 +233,7 @@ func (s *Server) sessionIdleTimeout(sess *Session) time.Duration { // anonymous flood targets), everything else gets a generous idle timeout. func sessionIdleTimeout(sess *Session) time.Duration { switch sess.State() { - case StateAccountName, StatePassword: + case StateAccountName, StatePassword, StateNewAccountPass, StateNewAccount: return loginReadTimeout default: return idleReadTimeout @@ -555,6 +555,11 @@ func NewServer(cfg *config.Config) (*Server, error) { } func loadOrGenerateCert(cfg *config.Config, service string) (tls.Certificate, error) { + // A half-configured cert pair must fail loudly: silently falling back to a + // self-signed cert makes clients fail verification with a confusing error. + if (cfg.TLS.CertFile == "") != (cfg.TLS.KeyFile == "") { + return tls.Certificate{}, fmt.Errorf("%s: tls.cert_file and tls.key_file must be set together", service) + } if cfg.TLS.CertFile != "" && cfg.TLS.KeyFile != "" { return tls.LoadX509KeyPair(cfg.TLS.CertFile, cfg.TLS.KeyFile) } diff --git a/internal/object/store.go b/internal/object/store.go index 997c324..ee154cf 100644 --- a/internal/object/store.go +++ b/internal/object/store.go @@ -2,6 +2,7 @@ package object import ( "fmt" + "log" "os" "path/filepath" "sync" @@ -43,6 +44,10 @@ func (s *ObjectStore) Load(id string) (*ObjectDef, error) { if err := yaml.Unmarshal(data, &def); err != nil { return nil, fmt.Errorf("parse object %s: %w", id, err) } + if def.ID != "" && def.ID != id { + log.Printf("object %s: file %s declares id %q but is loaded as %q; the filename is authoritative and the declared id is ignored", + id, path, def.ID, id) + } def.ID = id s.cache[id] = &def return &def, nil diff --git a/internal/persistence/txn.go b/internal/persistence/txn.go index d09feaa..8ba21fa 100644 --- a/internal/persistence/txn.go +++ b/internal/persistence/txn.go @@ -238,6 +238,15 @@ func (t *Transaction) Commit() error { } journalPath := filepath.Join(t.journalDir, "txn-"+hex.EncodeToString(id[:])+".json") if err := WriteFile(journalPath, journal, 0600); err != nil { + // WriteFile can fail AFTER the rename (e.g. the directory fsync), + // leaving a complete, valid journal whose operations were never + // applied. Remove it before returning so a later Recover cannot replay + // its before-images over newer writes; if the removal also fails, + // surface that so the caller can stop admitting mutations instead of + // serving on with an unrecoverable journal on disk. + if rErr := t.removeJournalDurable(journalPath); rErr != nil { + return fmt.Errorf("write journal: %v; journal cleanup failed (journal left on disk): %v", err, rErr) + } return fmt.Errorf("write journal: %w", err) } diff --git a/internal/validate/checks.go b/internal/validate/checks.go index a6eeee8..bfa8342 100644 --- a/internal/validate/checks.go +++ b/internal/validate/checks.go @@ -24,6 +24,7 @@ func validateRooms(s Source) []Issue { mobIDs := s.Mobs.AllDefIDs() objIDs := s.Objects.IDSet() hazardIDs := s.World.HazardIndex() + dropIDs := dropTableIDSet(s.DataDir) for id := range roomIndex { room, err := s.World.LoadRoom(id) @@ -69,7 +70,14 @@ func validateRooms(s Source) []Issue { }) continue } - if exit.Room > 0 { + if exit.Room <= 0 && !exit.AlwaysBlocked { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Room %d: exit %q has room %d; a non-positive target is a permanently blocked exit (set always_blocked: true to express that intent)", + id, dir, exit.Room), + }) + } else if exit.Room > 0 { if _, ok := roomIndex[exit.Room]; !ok { issues = append(issues, Issue{ Level: "ERROR", @@ -81,7 +89,7 @@ func validateRooms(s Source) []Issue { } issues = append(issues, validateTriggerBlock( fmt.Sprintf("Room %d: exit %q on_traverse", id, dir), - exit.OnTraverse, itemIDs, roomIndex, mobIDs, nil)...) + exit.OnTraverse, itemIDs, roomIndex, mobIDs, dropIDs)...) } for _, rm := range room.Mobs { @@ -128,7 +136,7 @@ func validateRooms(s Source) []Issue { displayName: world.NormalizeObjectName(robj.Local.Name), effDefID: robj.ID, }) - issues = append(issues, validateLocalObject(id, robj, itemIDs, roomIndex, mobIDs)...) + issues = append(issues, validateLocalObject(id, robj, itemIDs, roomIndex, mobIDs, dropIDs)...) } else if robj.ID != "" && !objIDs[robj.ID] { issues = append(issues, Issue{ Level: "ERROR", @@ -144,6 +152,15 @@ func validateRooms(s Source) []Issue { effDefID: robj.ID, }) } + } else { + // Neither a local definition nor a file reference: the entry + // silently disappears at runtime, so surface it as an error. + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Room %d: object entry %d resolves to nothing (no local definition and no id)", + id, idx), + }) } for _, wr := range robj.WanderRooms { if _, ok := roomIndex[wr]; !ok { @@ -176,7 +193,7 @@ func validateRooms(s Source) []Issue { // are restricted to the passive subset (name/aliases/color/hidden/ // inroom_description/description/on_look); interactable or stateful behavior // must be defined as a standalone object file instead. -func validateLocalObject(roomID int, robj world.RoomObject, itemIDs map[string]bool, roomIndex map[int]bool, mobIDs map[string]bool) []Issue { +func validateLocalObject(roomID int, robj world.RoomObject, itemIDs map[string]bool, roomIndex map[int]bool, mobIDs map[string]bool, dropIDs map[string]bool) []Issue { var issues []Issue def := robj.Local prefix := fmt.Sprintf("Room %d: local object %q", roomID, robj.ID) @@ -233,7 +250,7 @@ func validateLocalObject(roomID int, robj world.RoomObject, itemIDs map[string]b } if len(def.OnLook) > 0 { - issues = append(issues, validateTriggerBlock(prefix+": on_look", def.OnLook, itemIDs, roomIndex, mobIDs, nil)...) + issues = append(issues, validateTriggerBlock(prefix+": on_look", def.OnLook, itemIDs, roomIndex, mobIDs, dropIDs)...) } return issues @@ -678,7 +695,7 @@ func validateObjects(s Source) []Issue { } if obj.Talk != nil { - issues = append(issues, validateTalkConfig(fmt.Sprintf("Object %q: talk", id), obj.Talk, itemIDs, roomIndex)...) + issues = append(issues, validateTalkConfig(fmt.Sprintf("Object %q: talk", id), obj.Talk, itemIDs, roomIndex, mobIDs, dropIDs)...) } if len(obj.OnLook) > 0 { @@ -1112,6 +1129,7 @@ func validateRoomTriggers(s Source) []Issue { roomIndex := s.World.RoomIndex() itemIDs := s.Items.IDSet() mobIDs := s.Mobs.AllDefIDs() + dropIDs := dropTableIDSet(s.DataDir) for id := range roomIndex { room, err := s.World.LoadRoom(id) @@ -1120,11 +1138,11 @@ func validateRoomTriggers(s Source) []Issue { } if len(room.OnEnter) > 0 { issues = append(issues, validateTriggerBlock( - fmt.Sprintf("Room %d: on_enter", id), room.OnEnter, itemIDs, roomIndex, mobIDs, nil)...) + fmt.Sprintf("Room %d: on_enter", id), room.OnEnter, itemIDs, roomIndex, mobIDs, dropIDs)...) } if len(room.OnExit) > 0 { issues = append(issues, validateTriggerBlock( - fmt.Sprintf("Room %d: on_exit", id), room.OnExit, itemIDs, roomIndex, mobIDs, nil)...) + fmt.Sprintf("Room %d: on_exit", id), room.OnExit, itemIDs, roomIndex, mobIDs, dropIDs)...) } } @@ -1486,7 +1504,7 @@ func toolInList(list []string, target string) bool { return false } -func validateTalkConfig(prefix string, cfg *behavior.TalkConfig, itemIDs map[string]bool, roomIndex map[int]bool) []Issue { +func validateTalkConfig(prefix string, cfg *behavior.TalkConfig, itemIDs map[string]bool, roomIndex map[int]bool, mobIDs map[string]bool, dropIDs map[string]bool) []Issue { var issues []Issue if cfg == nil { return issues @@ -1494,7 +1512,7 @@ func validateTalkConfig(prefix string, cfg *behavior.TalkConfig, itemIDs map[str for nodeID, node := range cfg.Nodes { nodePrefix := fmt.Sprintf("%s: node %q", prefix, nodeID) if node.Action != nil { - issues = append(issues, validateTalkStep(nodePrefix, node.Action, itemIDs, roomIndex)...) + issues = append(issues, validateTalkStep(nodePrefix, node.Action, itemIDs, roomIndex, mobIDs, dropIDs)...) } if node.Goto != "" { if _, ok := cfg.Nodes[node.Goto]; !ok { @@ -1508,7 +1526,7 @@ func validateTalkConfig(prefix string, cfg *behavior.TalkConfig, itemIDs map[str for i, opt := range node.Options { optPrefix := fmt.Sprintf("%s: option %d", nodePrefix, i+1) if opt.Action != nil { - issues = append(issues, validateTalkStep(optPrefix, opt.Action, itemIDs, roomIndex)...) + issues = append(issues, validateTalkStep(optPrefix, opt.Action, itemIDs, roomIndex, mobIDs, dropIDs)...) } } } @@ -1750,9 +1768,9 @@ func validateTriggerBlock(prefix string, block []behavior.Trigger, itemIDs map[s // validateTalkStep validates a talk node/option Action (a single Step, no mob // spawn/despawn expected — mobIDs nil so those are skipped). -func validateTalkStep(prefix string, step *behavior.Step, itemIDs map[string]bool, roomIndex map[int]bool) []Issue { +func validateTalkStep(prefix string, step *behavior.Step, itemIDs map[string]bool, roomIndex map[int]bool, mobIDs map[string]bool, dropIDs map[string]bool) []Issue { if step == nil { return nil } - return validateStep(prefix, step, itemIDs, roomIndex, nil, nil) + return validateStep(prefix, step, itemIDs, roomIndex, mobIDs, dropIDs) } diff --git a/internal/validate/local_test.go b/internal/validate/local_test.go index f0bc0a1..f696b7b 100644 --- a/internal/validate/local_test.go +++ b/internal/validate/local_test.go @@ -17,7 +17,7 @@ func TestValidateLocalObjectPassiveOK(t *testing.T) { Hidden: true, Description: behavior.DescList{{Text: "A small window."}}, }} - issues := validateLocalObject(1001, robj, nil, nil, nil) + issues := validateLocalObject(1001, robj, nil, nil, nil, nil) if len(issues) != 0 { t.Errorf("expected no issues for passive local object, got: %+v", issues) } @@ -28,7 +28,7 @@ func TestValidateLocalObjectRejectsInteractable(t *testing.T) { Name: "rock", Gather: &behavior.GatherConfig{}, }} - issues := validateLocalObject(1001, robj, nil, nil, nil) + issues := validateLocalObject(1001, robj, nil, nil, nil, nil) if !containsMsg(issues, "interactable behavior") { t.Errorf("expected interactable-behavior error, got: %+v", issues) } @@ -38,7 +38,7 @@ func TestValidateLocalObjectRequiresName(t *testing.T) { robj := world.RoomObject{ID: "x", Local: &object.ObjectDef{ Description: behavior.DescList{{Text: "no name"}}, }} - issues := validateLocalObject(1001, robj, nil, nil, nil) + issues := validateLocalObject(1001, robj, nil, nil, nil, nil) if !containsMsg(issues, "has no name") { t.Errorf("expected has-no-name error, got: %+v", issues) } @@ -46,7 +46,7 @@ func TestValidateLocalObjectRequiresName(t *testing.T) { func TestValidateLocalObjectStrayIDWarns(t *testing.T) { robj := world.RoomObject{ID: "anvil", Local: &object.ObjectDef{Name: "anvil", ID: "anvil"}} - issues := validateLocalObject(1001, robj, nil, nil, nil) + issues := validateLocalObject(1001, robj, nil, nil, nil, nil) if !containsMsg(issues, "ignored on local objects") { t.Errorf("expected stray-id warning, got: %+v", issues) } diff --git a/internal/validate/p2_hardening_test.go b/internal/validate/p2_hardening_test.go new file mode 100644 index 0000000..68ae48f --- /dev/null +++ b/internal/validate/p2_hardening_test.go @@ -0,0 +1,50 @@ +package validate + +import ( + "path/filepath" + "testing" +) + +// TestValidateRejectsNonPositiveExit: an exit with room <= 0 renders as a +// blocked direction but passes every other check, so it must be reported +// unless always_blocked: true expresses the intent. +func TestValidateRejectsNonPositiveExit(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "rooms", "1.yaml"), "name: Blocked\nexits:\n north:\n room: 0\n") + issues := validateRooms(newSource(dir)) + if !containsMsg(issues, "non-positive") { + t.Fatalf("non-positive exit not reported: %+v", issues) + } +} + +// TestValidateRejectsEmptyObjectEntry: an objects: entry that is neither a +// local definition nor a file reference disappears at runtime, so it must be +// an error instead of a silent no-op. +func TestValidateRejectsEmptyObjectEntry(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "rooms", "1.yaml"), + "name: Empty\nobjects:\n - aliases: []\n description: []\n hidden: false\n name: \"\"\n") + issues := validateRooms(newSource(dir)) + if !containsMsg(issues, "resolves to nothing") { + t.Fatalf("empty object entry not reported: %+v", issues) + } +} + +// TestValidateRejectsDropTableInRoomTrigger: room on_enter/on_exit and +// on_traverse steps consume drop_table like every other trigger block, so a +// typo'd table id must be reported rather than silently awarding nothing. +func TestValidateRejectsDropTableInRoomTrigger(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "rooms", "1.yaml"), + "name: Loot\nexits:\n north:\n room: 1\n on_traverse:\n - steps:\n - drop_table:\n - table: no_such_table\n weight: 100\n") + if issues := validateRooms(newSource(dir)); !containsMsg(issues, "no_such_table") { + t.Fatalf("unknown drop table in an exit trigger not reported: %+v", issues) + } + + // on_enter is validated by validateRoomTriggers. + writeFile(t, filepath.Join(dir, "rooms", "2.yaml"), + "name: Loot2\non_enter:\n - steps:\n - drop_table:\n - table: no_such_table\n weight: 100\n") + if issues := validateRoomTriggers(newSource(dir)); !containsMsg(issues, "no_such_table") { + t.Fatalf("unknown drop table in on_enter not reported: %+v", issues) + } +} diff --git a/internal/world/mob.go b/internal/world/mob.go index b004643..631e3a3 100644 --- a/internal/world/mob.go +++ b/internal/world/mob.go @@ -2,6 +2,7 @@ package world import ( "fmt" + "log" "math/rand" "os" "path/filepath" @@ -441,6 +442,10 @@ func (s *MobStore) LoadDef(id string) (*MobDef, error) { if err := yaml.Unmarshal(data, &def); err != nil { return nil, fmt.Errorf("parse mob %s: %w", id, err) } + if def.ID != "" && def.ID != id { + log.Printf("mob %s: file %s declares id %q but is loaded as %q; the filename is authoritative and the declared id is ignored", + id, path, def.ID, id) + } def.ID = id s.mu.Lock() diff --git a/internal/world/p2_hardening_test.go b/internal/world/p2_hardening_test.go new file mode 100644 index 0000000..01f8a04 --- /dev/null +++ b/internal/world/p2_hardening_test.go @@ -0,0 +1,33 @@ +package world + +import ( + "testing" + + "gopkg.in/yaml.v3" +) + +// TestRoomObjectRejectsUnknownField: RoomObject.UnmarshalYAML uses +// Node.Decode, which does not inherit KnownFields(true), so without an +// explicit check a misspelled field inside a room's objects: entry is +// silently discarded even though the top-level room file is strict. +func TestRoomObjectRejectsUnknownField(t *testing.T) { + var ro RoomObject + if err := yaml.Unmarshal([]byte("name: anvil\nin_room_description: typo\n"), &ro); err == nil { + t.Fatal("accepted an unknown room object field") + } + // A legitimate local object still parses. + if err := yaml.Unmarshal([]byte("name: anvil\ninroom_description: A heavy anvil.\n"), &ro); err != nil { + t.Fatalf("rejected a valid local object: %v", err) + } +} + +// TestSeedGroundItemsDoesNotMarkUnloadedRoom: a room that fails to load must +// not be marked seeded, or its item_spawns stay disabled for the rest of the +// process even after the room file is fixed/created. +func TestSeedGroundItemsDoesNotMarkUnloadedRoom(t *testing.T) { + w := New(t.TempDir()) + w.SeedGroundItems(999) + if w.seeded[999] { + t.Fatal("a room that failed to load was marked seeded; its spawns would never appear") + } +} diff --git a/internal/world/room.go b/internal/world/room.go index f4390da..3783a73 100644 --- a/internal/world/room.go +++ b/internal/world/room.go @@ -1,6 +1,7 @@ package world import ( + "fmt" "strings" "gopkg.in/yaml.v3" @@ -122,6 +123,28 @@ type RoomObject struct { } func (ro *RoomObject) UnmarshalYAML(value *yaml.Node) error { + // yaml.v3's Node.Decode builds a fresh decoder that does NOT inherit + // KnownFields(true), so a misspelled key inside a room's objects: entry + // (inroom_description, onlook, ...) would be silently discarded even + // though the top-level room file is decoded strictly. Reject unknown + // keys here so the startup validator's "unknown field is an ERROR" + // guarantee extends to the region authors edit most. + if value.Kind != yaml.MappingNode { + return fmt.Errorf("room object must be a mapping") + } + allowed := map[string]bool{ + "id": true, "wander_rooms": true, "wander_interval": true, "hidden": true, + "name": true, "aliases": true, "color": true, "inroom_description": true, + "removal_item": true, "description": true, "on_use": true, "steal": true, + "gather": true, "talk": true, "safespot": true, "on_look": true, + } + for i := 0; i+1 < len(value.Content); i += 2 { + key := value.Content[i].Value + if !allowed[key] { + return fmt.Errorf("unknown room object field %q", key) + } + } + type rawRef struct { ID string `yaml:"id"` WanderRooms []int `yaml:"wander_rooms"` diff --git a/internal/world/world.go b/internal/world/world.go index 7475ce0..a084f63 100644 --- a/internal/world/world.go +++ b/internal/world/world.go @@ -91,16 +91,24 @@ func (w *World) SeedGroundItems(roomID int) { w.mu.Unlock() return } - w.seeded[roomID] = true w.mu.Unlock() room, err := w.LoadRoom(roomID) if err != nil { + // Do NOT mark a room seeded before it loads: a dangling exit (or a + // not-yet-created room) would otherwise permanently disable its item + // spawns even after the room is fixed/created. return } w.mu.Lock() defer w.mu.Unlock() + // Re-check under the lock: a concurrent seed may have won the race while + // this call was loading. + if w.seeded[roomID] { + return + } + w.seeded[roomID] = true for _, s := range room.ItemSpawns { merged := false |
