aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--building_guide/rooms.md16
-rw-r--r--data/rooms/example_unused/town/999997.yaml4
-rw-r--r--data/rooms/intro/1006.yaml6
-rw-r--r--internal/admin/server.go5
-rw-r--r--internal/behavior/store.go27
-rw-r--r--internal/casino/baccarat.go10
-rw-r--r--internal/casino/baccarat_test.go4
-rw-r--r--internal/casino/blackjack.go50
-rw-r--r--internal/casino/blackjack_test.go20
-rw-r--r--internal/casino/config_validate.go53
-rw-r--r--internal/casino/correct_casino_refund_bank_test.go32
-rw-r--r--internal/casino/machine.go34
-rw-r--r--internal/casino/p2_hardening_test.go60
-rw-r--r--internal/casino/types.go18
-rw-r--r--internal/game/act_agility.go4
-rw-r--r--internal/game/act_effects.go10
-rw-r--r--internal/game/act_talk.go11
-rw-r--r--internal/game/casino.go9
-rw-r--r--internal/game/cmd_close.go5
-rw-r--r--internal/game/cmd_dig.go10
-rw-r--r--internal/game/cmd_remove.go4
-rw-r--r--internal/game/cmd_room.go3
-rw-r--r--internal/game/cmd_room_insert.go8
-rw-r--r--internal/game/cmd_room_mob.go11
-rw-r--r--internal/game/cmd_room_object.go16
-rw-r--r--internal/game/cmd_room_remove.go6
-rw-r--r--internal/game/cmd_room_spawn.go11
-rw-r--r--internal/game/cmd_shop.go17
-rw-r--r--internal/game/cmd_swapid.go2
-rw-r--r--internal/game/cmd_trigger_utility.go8
-rw-r--r--internal/game/cmd_undig.go2
-rw-r--r--internal/game/economy.go36
-rw-r--r--internal/game/game.go38
-rw-r--r--internal/game/production_engine.go12
-rw-r--r--internal/game/room_write.go13
-rw-r--r--internal/game/sys_triggers.go18
-rw-r--r--internal/game/tick.go31
-rw-r--r--internal/net/p2_hardening_test.go31
-rw-r--r--internal/net/server.go7
-rw-r--r--internal/object/store.go5
-rw-r--r--internal/persistence/txn.go9
-rw-r--r--internal/validate/checks.go44
-rw-r--r--internal/validate/local_test.go8
-rw-r--r--internal/validate/p2_hardening_test.go50
-rw-r--r--internal/world/mob.go5
-rw-r--r--internal/world/p2_hardening_test.go33
-rw-r--r--internal/world/room.go23
-rw-r--r--internal/world/world.go10
48 files changed, 705 insertions, 144 deletions
diff --git a/building_guide/rooms.md b/building_guide/rooms.md
index ffe4f7b..568611f 100644
--- a/building_guide/rooms.md
+++ b/building_guide/rooms.md
@@ -268,6 +268,22 @@ its configured profit multiplier; otherwise play begins. Split hands can be
configured independently for re-splitting aces and doubling, and split aces
receive one card each with no further actions beyond a configured re-split.
+Two blackjack knobs are player-favourable and on by default, so set them
+explicitly when authoring a standard-rules table:
+
+- `split_unlike_ten_values: true` lets any two 10-valued cards split
+ (K+Q, 10+J). Standard play splits only identical ranks.
+- `double_split_blackjack: true` pays a 3+ card hand that reaches 21 after a
+ split and double as a blackjack (3:2). Standard play pays 1:1 for any 21 on
+ a split hand, doubled or not. Both raise the table's actual RTP above the
+ nominal blackjack payout.
+
+Baccarat's `banker_payout` (0.95 = a 5% commission) rounds the commission UP:
+a winning banker bet too small to carry a whole credit of commission returns
+only the stake rather than paying a commission-free 1:1. Payout multipliers
+are capped (`blackjack_payout`/`insurance_payout` ≤ 5, `tie_payout` ≤ 10,
+`banker_payout` ≤ 0.99) so a typo cannot silently create an absurd table.
+
At a blackjack table, `bet` uses the configured minimum wager; `bet min`,
`bet max`, and `bet <amount>` select the wager explicitly.
diff --git a/data/rooms/example_unused/town/999997.yaml b/data/rooms/example_unused/town/999997.yaml
index d775332..2c9a091 100644
--- a/data/rooms/example_unused/town/999997.yaml
+++ b/data/rooms/example_unused/town/999997.yaml
@@ -3,7 +3,9 @@ name: Deep Mine
color: ""
description:
- text: A deeper mine shaft. Higher quality ores can be found here. This area is not yet accessible.
-exits: {}
+exits:
+ south:
+ room: 999996
objects: []
item_spawns: []
mobs:
diff --git a/data/rooms/intro/1006.yaml b/data/rooms/intro/1006.yaml
index a6e2575..fc40ea2 100644
--- a/data/rooms/intro/1006.yaml
+++ b/data/rooms/intro/1006.yaml
@@ -9,9 +9,5 @@ id: 1006
item_spawns: []
mobs: []
name: 'Room #1006'
-objects:
- - aliases: []
- description: []
- hidden: false
- name: ""
+objects: []
on_enter: []
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