diff options
Diffstat (limited to 'internal')
49 files changed, 1049 insertions, 439 deletions
diff --git a/internal/admin/server.go b/internal/admin/server.go index 9c62249..96b5981 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -754,12 +754,8 @@ func (s *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) { } // Expensive password hashing stays off the owner. Reload the account - // inside the task so an upgrade cannot overwrite a concurrent character - // rename/create or a password/admin change made since authentication. - var upgradedHash string - if player.NeedsRehash(acc.PasswordHash) { - upgradedHash, _ = player.HashPassword(password) - } + // inside the task so a concurrent character rename/create or a + // password/admin change made since authentication is not overwritten. authorized := false if !s.runOnOwner(func() { current, err := s.accountStore.LoadAccount(account) @@ -767,12 +763,6 @@ func (s *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) { return } authorized = true - if upgradedHash != "" { - current.PasswordHash = upgradedHash - if err := s.accountStore.SaveAccount(current); err != nil { - log.Printf("admin login: password rehash save for %s: %v", account, err) - } - } }) { writeJSONError(w, "game is shutting down", http.StatusServiceUnavailable) return diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js index f8cee51..4c0cae4 100644 --- a/internal/admin/static/map.js +++ b/internal/admin/static/map.js @@ -2056,7 +2056,26 @@ async function doSavePanel() { var el = $('#roomName'); if (el) r.name = el.value; el = $('#roomColor'); if (el) r.color = el.value; - el = $('#roomDesc'); if (el && el.value) r.description = [{text: el.value}]; + // Only rewrite description when the textarea actually changed. The + // textarea is populated by joining every variant, so unconditionally + // assigning a single variant here destroyed conditional descriptions on + // any unrelated panel save (rename, block_transport toggle, ...). + el = $('#roomDesc'); + if (el) { + var origDesc = ''; + if (Array.isArray(r.description)) { + origDesc = r.description.map(function(d) { return d.text || ''; }).join('\n\n'); + } else if (typeof r.description === 'string') { + origDesc = r.description; + } + if (el.value !== origDesc) { + var hasConditional = Array.isArray(r.description) && r.description.some(function(d) { return d.condition; }); + if (hasConditional && !confirm('This room has conditional descriptions. Saving the edited text will replace them all with one description. Continue?')) { + return; + } + r.description = el.value ? [{text: el.value}] : []; + } + } el = $('#roomBlockTransport'); if (el) r.block_transport = el.checked; if (currentRoomData && currentRoomData.room) { diff --git a/internal/behavior/desc.go b/internal/behavior/desc.go index f6dbaf3..744b97e 100644 --- a/internal/behavior/desc.go +++ b/internal/behavior/desc.go @@ -1,8 +1,77 @@ package behavior +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + +// DescVariant is one alternative description, optionally gated by a condition. type DescVariant struct { Text string `yaml:"text"` Condition *Condition `yaml:"condition,omitempty"` } +// DescList is an ordered list of description variants. The first variant whose +// condition matches is shown. +// +// Content may author the common single, unconditional description two ways: +// as a bare scalar (`description: "A dark room."`) or as a list of variants +// (`description: [{text: "A dark room."}]`). MarshalYAML collapses a single +// unconditional variant back to the scalar form so machine-written room files +// stay close to what a human would type. type DescList []DescVariant + +func (d *DescList) UnmarshalYAML(value *yaml.Node) error { + switch value.Kind { + case yaml.ScalarNode: + var text string + if err := value.Decode(&text); err != nil { + return err + } + if text == "" { + *d = nil + return nil + } + *d = DescList{{Text: text}} + return nil + case yaml.SequenceNode: + out := make(DescList, 0, len(value.Content)) + for _, item := range value.Content { + // Accept a bare string element as shorthand for a variant. + if item.Kind == yaml.ScalarNode { + var text string + if err := item.Decode(&text); err != nil { + return err + } + out = append(out, DescVariant{Text: text}) + continue + } + var variant DescVariant + if err := item.Decode(&variant); err != nil { + return err + } + out = append(out, variant) + } + *d = out + return nil + case yaml.AliasNode: + return d.UnmarshalYAML(value.Alias) + case 0: + // An omitted/zero node leaves the list empty. + *d = nil + return nil + default: + return fmt.Errorf("description must be a string or a list of variants") + } +} + +func (d DescList) MarshalYAML() (any, error) { + if len(d) == 0 { + return []DescVariant{}, nil + } + if len(d) == 1 && d[0].Condition == nil { + return d[0].Text, nil + } + return []DescVariant(d), nil +} diff --git a/internal/behavior/desc_test.go b/internal/behavior/desc_test.go new file mode 100644 index 0000000..d506530 --- /dev/null +++ b/internal/behavior/desc_test.go @@ -0,0 +1,86 @@ +package behavior + +import ( + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestDescListScalarForm(t *testing.T) { + var wrap struct { + Description DescList `yaml:"description"` + } + if err := yaml.Unmarshal([]byte("description: \"A dark room.\"\n"), &wrap); err != nil { + t.Fatalf("scalar description failed to decode: %v", err) + } + if len(wrap.Description) != 1 || wrap.Description[0].Text != "A dark room." { + t.Fatalf("scalar decode = %#v", wrap.Description) + } +} + +func TestDescListSequenceForm(t *testing.T) { + src := "description:\n - text: \"Default\"\n - text: \"Lit\"\n condition:\n player_flag: lamp_lit\n" + var wrap struct { + Description DescList `yaml:"description"` + } + if err := yaml.Unmarshal([]byte(src), &wrap); err != nil { + t.Fatalf("sequence description failed to decode: %v", err) + } + if len(wrap.Description) != 2 { + t.Fatalf("got %d variants, want 2", len(wrap.Description)) + } + if wrap.Description[1].Condition == nil { + t.Fatal("conditional variant lost its condition") + } +} + +func TestDescListRoundTripPreservesCondition(t *testing.T) { + original := DescList{ + {Text: "Default"}, + {Text: "Lit", Condition: &Condition{PlayerFlag: "lamp_lit"}}, + } + data, err := yaml.Marshal(struct { + Description DescList `yaml:"description"` + }{Description: original}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + // A conditional list must not collapse to the scalar shorthand. + if strings.Contains(string(data), "description: Default") { + t.Fatalf("conditional list collapsed to scalar:\n%s", data) + } + var loaded struct { + Description DescList `yaml:"description"` + } + if err := yaml.Unmarshal(data, &loaded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(loaded.Description) != 2 || loaded.Description[1].Condition == nil { + t.Fatalf("round trip lost structure: %#v", loaded.Description) + } +} + +func TestDescListSingleVariantMarshalsAsScalar(t *testing.T) { + data, err := yaml.Marshal(struct { + Description DescList `yaml:"description"` + }{Description: DescList{{Text: "A dark room."}}}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(data), "description: A dark room.") { + t.Fatalf("single unconditional variant should use the scalar form:\n%s", data) + } +} + +func TestDescListEmptyStillMarshals(t *testing.T) { + data, err := yaml.Marshal(struct { + Description DescList `yaml:"description"` + }{}) + if err != nil { + t.Fatalf("marshal empty: %v", err) + } + if !strings.Contains(string(data), "description:") { + t.Fatalf("empty description should still emit a key:\n%s", data) + } +} diff --git a/internal/behavior/store.go b/internal/behavior/store.go index 4051836..f2e9350 100644 --- a/internal/behavior/store.go +++ b/internal/behavior/store.go @@ -12,12 +12,32 @@ import ( "gopkg.in/yaml.v3" ) +// excludedContentPath reports whether path lies under a directory or file +// whose name starts with "_", relative to root. That is the project's +// convention for work-in-progress content that must not be loaded, validated, +// indexed, or published while a builder is still authoring it. +func excludedContentPath(root, path string) bool { + rel, err := filepath.Rel(root, path) + if err != nil { + return false + } + for _, part := range strings.Split(rel, string(os.PathSeparator)) { + if strings.HasPrefix(part, "_") { + return true + } + } + return false +} + func BuildPathIndex(dir string) map[string]string { index := make(map[string]string) filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") { return nil } + if excludedContentPath(dir, path) { + return nil + } id := strings.TrimSuffix(d.Name(), ".yaml") index[id] = path return nil @@ -31,6 +51,9 @@ func BuildRoomIndex(dir string) map[int]string { if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") { return nil } + if excludedContentPath(dir, path) { + return nil + } id := strings.TrimSuffix(d.Name(), ".yaml") n, convErr := strconv.Atoi(id) if convErr != nil { @@ -39,6 +62,11 @@ func BuildRoomIndex(dir string) map[int]string { // skip it here and let validation flag it. return nil } + // Room IDs are positive; every other code path treats <= 0 as "no + // room". A 0.yaml/-1.yaml must not become a real room. + if n <= 0 { + return nil + } index[n] = path return nil }) @@ -59,6 +87,9 @@ func WalkYAMLDir(dir string, fn func(path, id string, data []byte) error) error if d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") { return nil } + if excludedContentPath(dir, path) { + return nil + } data, readErr := os.ReadFile(path) if readErr != nil { if os.IsNotExist(readErr) { @@ -82,6 +113,9 @@ func CheckDuplicateIDs(dir string) []Duplicate { if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") { return nil } + if excludedContentPath(dir, path) { + return nil + } id := strings.TrimSuffix(d.Name(), ".yaml") seen[id] = append(seen[id], path) return nil diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go index 62a83a5..53ed0a9 100644 --- a/internal/casino/baccarat.go +++ b/internal/casino/baccarat.go @@ -241,7 +241,15 @@ func (t *baccaratTable) markDisconnected(name string) []Event { return nil } if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != nil { - p.Wallet.RefundOutstanding() + if _, _, err := p.Wallet.RefundOutstanding(); err != nil { + // The stake stays pending on disk and startup recovery will return + // it, but tell the departing player instead of silently swallowing + // the failure. + t.removeSeatLocked(name) + events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)} + events = append(events, t.private(name, "Your stake could not be returned just now; it stays pending and will be refunded after the next server restart.")) + return t.closeIfEmptyLocked(events) + } } t.removeSeatLocked(name) events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)} diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go index cb71034..ed56d15 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -777,6 +777,7 @@ func (t *blackjackTable) markDisconnectedLocked(name string) []Event { if index < 0 { return nil } + refundFailed := false if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != nil { if t.phase == blackjackPlaying || t.phase == blackjackInsurance { // A disconnected player forfeits the round in flight: consume @@ -791,12 +792,17 @@ func (t *blackjackTable) markDisconnectedLocked(name string) []Event { } else { // No card has been dealt for this stake yet, so refund it: nothing // the bettor paid for has been revealed or played. - p.Wallet.RefundOutstanding() + if _, _, err := p.Wallet.RefundOutstanding(); err != nil { + refundFailed = true + } } } t.removeSeatLocked(name) events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)} events = t.closeIfEmptyLocked(events) + if refundFailed { + events = append(events, t.private(name, "Your stake could not be returned just now; it stays pending and will be refunded after the next server restart.")) + } if t.phase != blackjackPlaying { // Betting and insurance resolve against the remaining seats on the // next tick; waiting has no round in flight. diff --git a/internal/casino/config_validate.go b/internal/casino/config_validate.go index 6181883..9ad1776 100644 --- a/internal/casino/config_validate.go +++ b/internal/casino/config_validate.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "math" + "sort" "strings" ) @@ -21,8 +22,8 @@ func (c MachineConfig) Validate() error { return fmt.Errorf("unsupported slots variant %q", c.Variant) } } - if c.SpinTicks < 0 { - return fmt.Errorf("spin_ticks must not be negative") + if c.SpinTicks < 0 || c.SpinTicks > maxSpinTicks { + return fmt.Errorf("spin_ticks must be between 0 and %d", maxSpinTicks) } if !finiteNonnegative(c.Payout) || c.Payout > 1 { return fmt.Errorf("payout must be finite and between 0 and 1 (0 uses the default)") @@ -37,16 +38,40 @@ func (c TableConfig) Validate() error { if err := validateIdentityAndBets(c.ID, c.MinBet, c.MaxBet); err != nil { return err } - if c.MaxPlayers < 0 || c.BettingTicks < 0 { - return fmt.Errorf("max_players and betting_ticks must not be negative") + if c.MaxPlayers < 0 || c.MaxPlayers > maxTablePlayers { + return fmt.Errorf("max_players must be between 0 and %d", maxTablePlayers) + } + if c.BettingTicks < 0 || c.BettingTicks > maxBettingTicks { + return fmt.Errorf("betting_ticks must be between 0 and %d", maxBettingTicks) } def, ok := tableGameDefFor(c.Game) if !ok { return fmt.Errorf("unsupported table game %q", c.Game) } + if foreign := foreignRuleBlocks(c); len(foreign) > 0 { + return fmt.Errorf("table game %q has a rule block for a different game (%s); remove it or set game to match", c.Game, strings.Join(foreign, ", ")) + } return def.validate(c) } +// foreignRuleBlocks names every game-specific rule block present on c that +// does not belong to c.Game, in a deterministic order. Each game registers its +// own block in tableGames, so a new game widens this automatically instead of +// needing another hand-maintained switch. +func foreignRuleBlocks(c TableConfig) []string { + var foreign []string + for game, def := range tableGames { + if game == c.Game || def.hasRuleBlock == nil || def.ruleBlockName == "" { + continue + } + if def.hasRuleBlock(c) { + foreign = append(foreign, def.ruleBlockName) + } + } + sort.Strings(foreign) + return foreign +} + // normalizeBlackjackTable applies the blackjack rule defaults. It is the // game's Normalize entry in tableGames. func normalizeBlackjackTable(c TableConfig) TableConfig { @@ -66,8 +91,8 @@ func validateBlackjackTable(c TableConfig) error { if err := validateShoe(b.Decks, b.ShuffleAt); err != nil { return err } - if b.MaxSplitHands < 0 || b.ActionTicks < 0 { - return fmt.Errorf("max_split_hands and action_ticks must not be negative") + if b.MaxSplitHands < 0 || b.ActionTicks < 0 || b.ActionTicks > maxActionTicks { + return fmt.Errorf("max_split_hands must not be negative and action_ticks must be between 0 and %d", maxActionTicks) } if !finiteNonnegative(b.BlackjackPayout) || !finiteNonnegative(b.InsurancePayout) { return fmt.Errorf("blackjack_payout and insurance_payout must be finite and nonnegative") @@ -130,6 +155,22 @@ func finiteNonnegative(n float64) bool { return !math.IsNaN(n) && !math.IsInf(n, 0) && n >= 0 } +// hasBlackjackRuleBlock reports whether c carries any explicit blackjack rule. +// Used by foreignRuleBlocks to reject a block authored for another game. +func hasBlackjackRuleBlock(c TableConfig) bool { + b := c.Blackjack + return b.Decks != 0 || b.ShuffleAt != 0 || b.BlackjackPayout != 0 || + b.DealerHitSoft17 != nil || b.MaxSplitHands != 0 || b.ResplitAces != nil || + b.DoubleAfterSplit != nil || b.DoubleSplitBlackjack != nil || + b.InsurancePayout != 0 || b.Surrender != nil || b.ActionTicks != 0 || + b.SplitUnlikeTenValues != nil +} + +func hasBaccaratRuleBlock(c TableConfig) bool { + b := c.Baccarat + return b.Decks != 0 || b.ShuffleAt != 0 || b.TiePayout != 0 || b.BankerPayout != 0 +} + func validateShoe(decks int, shuffleAt float64) error { // A generous resource limit prevents malformed content from allocating an // unbounded shoe. Zero retains the existing eight-deck default. @@ -158,6 +199,18 @@ const ( maxBankerPayout = 0.99 ) +// Authoring limits for tick-based windows. A typo such as betting_ticks: +// 2000000 would otherwise publish a table that holds its betting window open +// for days; while the window is open a committed stake cannot be withdrawn, so +// the bettor is trapped. These are generous relative to normal play (a tick is +// 600ms). +const ( + maxTablePlayers = 64 + maxBettingTicks = 3600 + maxActionTicks = 3600 + maxSpinTicks = 3600 +) + // 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. @@ -187,6 +240,9 @@ const baccaratRoundCards = 6 // the geometry here keeps that gate meaningful. func (p SlotProfile) Validate() error { profile := p.Normalize() + if len(profile.Symbols) == 0 { + return fmt.Errorf("slot profile has no symbols") + } if profile.Rows != slotGridRows || profile.Reels != slotGridReels { return fmt.Errorf("unsupported slot geometry %dx%d: the runtime evaluator supports %dx%d only", profile.Rows, profile.Reels, slotGridRows, slotGridReels) @@ -265,7 +321,7 @@ func machineSlotProfile(c MachineConfig) SlotProfile { } // ValidateRoomConfigs checks the shared room-local ID namespace as well as -// individual definitions. Legacy slots under casino_tables remain supported. +// individual definitions. // // It also enforces one table/machine per game per room: a room is addressed // by game name ("play blackjack"), so a second entry for the same game (or @@ -291,13 +347,6 @@ func ValidateRoomConfigs(tables []TableConfig, machines []MachineConfig) error { addressable[key] = id } for _, c := range tables { - if c.Game == GameSlots { - // A slots "table" is instantiated as the room's slots machine. - legacy := LegacySlotMachine(c) - check("legacy slots", c.ID, legacy.Validate()) - checkAddressable(strings.ToLower(string(GameSlots)), c.ID) - continue - } check("table", c.ID, c.Validate()) checkAddressable(strings.ToLower(string(c.Game)), c.ID) } diff --git a/internal/casino/config_validate_test.go b/internal/casino/config_validate_test.go index f0f87f5..5e72054 100644 --- a/internal/casino/config_validate_test.go +++ b/internal/casino/config_validate_test.go @@ -75,15 +75,16 @@ func TestTableConfigValidation(t *testing.T) { } } -func TestRoomCasinoIDsAndLegacySlots(t *testing.T) { - tables := []TableConfig{{ID: "slots", Game: GameSlots}} +func TestRoomCasinoIDsRejectDuplicates(t *testing.T) { machines := []MachineConfig{{ID: "slots", Game: GameSlots}} - if err := ValidateRoomConfigs(tables, nil); err != nil { - t.Fatalf("legacy slots rejected: %v", err) - } - if ValidateRoomConfigs(tables, machines) == nil || ValidateRoomConfigs(nil, append(machines, machines...)) == nil { + if ValidateRoomConfigs(nil, append(machines, machines...)) == nil { t.Fatal("duplicate casino IDs accepted") } + + // Slots are machines only: declaring them under casino_tables is invalid. + if err := ValidateRoomConfigs([]TableConfig{{ID: "slots", Game: GameSlots}}, nil); err == nil { + t.Fatal("slots table accepted; slots must be declared under casino_machines") + } } func TestManagerGamesHaveIndependentRandomStreams(t *testing.T) { @@ -122,3 +123,40 @@ func TestScaleToRTPDoesNotChangeSourceProfile(t *testing.T) { t.Fatal("profiles share mutable paytables") } } + +func TestSlotProfileEmptySymbolsRejected(t *testing.T) { + // A new slotVariants entry that forgets its symbol table must not validate + // and silently play the built-in cabinet. + if err := (SlotProfile{}).Validate(); err == nil { + t.Fatal("empty slot profile validated; expected an error") + } +} + +func TestTableConfigRejectsForeignRuleBlock(t *testing.T) { + c := TableConfig{ID: "t", Game: GameBaccarat, Blackjack: BlackjackConfig{BlackjackPayout: 15}} + if err := c.Validate(); err == nil { + t.Fatal("baccarat table with a blackjack rule block validated; expected an error") + } + ok := TableConfig{ID: "t", Game: GameBlackjack} + if err := ok.Validate(); err != nil { + t.Fatalf("valid blackjack table rejected: %v", err) + } +} + +func TestTableConfigTimerCaps(t *testing.T) { + cases := map[string]TableConfig{ + "huge betting window": {ID: "t", Game: GameBaccarat, BettingTicks: maxBettingTicks + 1}, + "huge action window": {ID: "t", Game: GameBlackjack, Blackjack: BlackjackConfig{ActionTicks: maxActionTicks + 1}}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + if err := c.Validate(); err == nil { + t.Fatal("expected an error") + } + }) + } + m := MachineConfig{ID: "m", Game: GameSlots, SpinTicks: maxSpinTicks + 1} + if err := m.Validate(); err == nil { + t.Fatal("expected an error for an oversized spin window") + } +} diff --git a/internal/casino/core.go b/internal/casino/core.go index 0b9d0af..7143841 100644 --- a/internal/casino/core.go +++ b/internal/casino/core.go @@ -143,11 +143,7 @@ func resolveStakeIDs(w Wallet, ids []string) { if w == nil { return } - for _, id := range ids { - if id != "" { - w.ResolveStake(id) - } - } + w.ResolveStakesByIDs(ids...) } // settleStakeOutcome applies one wager's outcome through the wallet and @@ -175,12 +171,10 @@ func settleStakeOutcome(w Wallet, payout int, stakeIDs []string, payoutErr error if payout > 0 { return w.PayCredits(payout, stakeIDs...) != nil } - // A zero payout is a loss: consume the stakes so recovery cannot refund a - // round that resolved. - for _, id := range stakeIDs { - if id != "" { - w.ResolveStake(id) - } + // A zero payout is a loss: consume the stakes in one save so recovery can + // never refund a round that resolved. + if w != nil { + w.ResolveStakesByIDs(stakeIDs...) } return false } diff --git a/internal/casino/hardening_casino_validate_test.go b/internal/casino/hardening_casino_validate_test.go index 1d27d54..dfec79b 100644 --- a/internal/casino/hardening_casino_validate_test.go +++ b/internal/casino/hardening_casino_validate_test.go @@ -162,13 +162,13 @@ func TestRoomRejectsASecondEntryForOneGame(t *testing.T) { if ok != nil { t.Fatalf("one entry per game rejected: %v", ok) } - // A legacy slots table and a slots machine both answer to "slots". - legacy := ValidateRoomConfigs( - []TableConfig{{ID: "legacy_slots", Game: GameSlots}}, - []MachineConfig{{ID: "slots", Game: GameSlots}}, - ) - if legacy == nil || !strings.Contains(legacy.Error(), "duplicate casino game") { - t.Fatalf("legacy slots table plus a slots machine accepted: %v", legacy) + // Slots are machines only: a slots entry under casino_tables is rejected + // outright rather than treated as a machine. + if legacy := ValidateRoomConfigs( + []TableConfig{{ID: "slots_table", Game: GameSlots}}, + nil, + ); legacy == nil { + t.Fatal("slots table accepted; slots must be declared under casino_machines") } // Two machines of the same variant are equally unreachable. twoVariants := ValidateRoomConfigs(nil, []MachineConfig{ diff --git a/internal/casino/manager_test.go b/internal/casino/manager_test.go index 9b5d35a..a832d53 100644 --- a/internal/casino/manager_test.go +++ b/internal/casino/manager_test.go @@ -45,6 +45,14 @@ func (w *testWallet) ResolveStake(stakeID string) { w.open = kept } +// ResolveStakesByIDs consumes the listed stakes in one step, mirroring the +// production wallet. +func (w *testWallet) ResolveStakesByIDs(stakeIDs ...string) { + for _, id := range stakeIDs { + w.ResolveStake(id) + } +} + // ResolveStakes mirrors the production wallet: every outstanding stake is // settled (no longer refundable). func (w *testWallet) ResolveStakes() { w.open = nil } diff --git a/internal/casino/registry.go b/internal/casino/registry.go index 9a973d4..26acd8c 100644 --- a/internal/casino/registry.go +++ b/internal/casino/registry.go @@ -13,6 +13,12 @@ type tableGameDef struct { validate func(TableConfig) error // normalize applies this game's rule defaults to a table config. normalize func(TableConfig) TableConfig + // ruleBlockName is the YAML rule block this game owns (for error messages), + // and hasRuleBlock reports whether that block carries any explicit value. + // Together they let TableConfig.Validate reject a block authored for a + // different game without a hand-maintained switch. + ruleBlockName string + hasRuleBlock func(TableConfig) bool // new builds the game. It is called only after validate passes and the // shared table config has been normalized. new func(roomID int, cfg TableConfig, rng *rand.Rand) tableGame @@ -26,15 +32,19 @@ var tableGames map[GameName]tableGameDef func init() { tableGames = map[GameName]tableGameDef{ GameBlackjack: { - validate: validateBlackjackTable, - normalize: normalizeBlackjackTable, + validate: validateBlackjackTable, + normalize: normalizeBlackjackTable, + ruleBlockName: "blackjack", + hasRuleBlock: hasBlackjackRuleBlock, new: func(roomID int, cfg TableConfig, rng *rand.Rand) tableGame { return newBlackjackTable(roomID, cfg, rng) }, }, GameBaccarat: { - validate: validateBaccaratTable, - normalize: normalizeBaccaratTable, + validate: validateBaccaratTable, + normalize: normalizeBaccaratTable, + ruleBlockName: "baccarat", + hasRuleBlock: hasBaccaratRuleBlock, new: func(roomID int, cfg TableConfig, rng *rand.Rand) tableGame { return newBaccaratTable(roomID, cfg, rng) }, diff --git a/internal/casino/slots.go b/internal/casino/slots.go index 217e9fb..419cfcf 100644 --- a/internal/casino/slots.go +++ b/internal/casino/slots.go @@ -76,9 +76,9 @@ func (p SlotProfile) Normalize() SlotProfile { if p.FreeSpinCount <= 0 { p.FreeSpinCount = 8 } - if len(p.Symbols) == 0 { - return DefaultHuffAndPuffProfile() - } + // An empty symbol table is NOT defaulted: a new variant that forgets its + // symbols must fail validation, not silently play the built-in cabinet. + // machineSlotProfile is the one place that starts from a real profile. return p } diff --git a/internal/casino/types.go b/internal/casino/types.go index e057ba6..2d4ba02 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -129,13 +129,6 @@ type MachineConfig struct { Payout float64 `yaml:"payout,omitempty"` } -// LegacySlotMachine converts a legacy slots TableConfig (older rooms declared -// slots under casino_tables) into the MachineConfig the room actually -// instantiates. Shared by validation and room lookup so the two cannot drift. -func LegacySlotMachine(c TableConfig) MachineConfig { - return MachineConfig{ID: c.ID, Game: GameSlots, MinBet: c.MinBet, MaxBet: c.MaxBet} -} - func (c MachineConfig) Normalize() MachineConfig { if c.MinBet <= 0 { c.MinBet = 1 @@ -216,6 +209,10 @@ type Wallet interface { // ResolveStake consumes ONE pending stake by wallet handle (a single // hand paid or lost while a sibling hand's round is still in flight). ResolveStake(stakeID string) + // ResolveStakesByIDs consumes the listed pending stakes in ONE save. It is + // the batched form of ResolveStake used when a losing round has several + // stakes (split/double hands) so they are not committed one save at a time. + ResolveStakesByIDs(stakeIDs ...string) // RefundOutstanding returns every pending stake to the player (chips as // chips, credits as credits) and clears the ledger. An error means the // refund could not be made durable: nothing was returned and the stakes diff --git a/internal/game/act.go b/internal/game/act.go index 1317df4..449b2da 100644 --- a/internal/game/act.go +++ b/internal/game/act.go @@ -14,18 +14,13 @@ import ( "thehouseoficarus/internal/world" ) +// verbAliases maps a gather-style verb that has no commandRegistry entry to +// the action it starts. Verbs that are registered directly (mine, chop, fish, +// cut, talk/speak/ask, steal/thieve, hide) must not be duplicated here: a +// registered command is looked up first, so an entry would be dead and its +// removal untested. Only shear remains as a pure action alias. var verbAliases = map[string]string{ - "mine": "gather", - "chop": "gather", - "fish": "gather", - "cut": "gather", - "shear": "gather", - "talk": "talk", - "speak": "talk", - "ask": "talk", - "steal": "steal", - "thieve": "steal", - "hide": "safespot", + "shear": "gather", } var verbSkill = map[string]string{ diff --git a/internal/game/casino.go b/internal/game/casino.go index 58e1e69..23b6c51 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -147,6 +147,26 @@ func (w casinoWallet) ResolveStake(stakeID string) { } } +// ResolveStakesByIDs consumes the listed pending stakes in one durable save. +// A losing split/doubled hand used to call ResolveStake once per handle, each +// with its own full character save; batching removes that repeated fsync and +// the crash window between saves. +func (w casinoWallet) ResolveStakesByIDs(stakeIDs ...string) { + if len(stakeIDs) == 0 { + return + } + removed := takePendingWagers(w.p, stakeIDs) + if len(removed) == 0 { + return + } + if err := w.g.AccountStore.SaveCharacter(w.p); err != nil { + // The durable file still lists the stakes, so restore them in memory to + // keep the two records consistent for a later retry/recovery. + w.p.PendingWagers = append(w.p.PendingWagers, removed...) + fmt.Fprintf(os.Stderr, "casino resolve stakes: save for %s: %v (ledger restored in memory)\n", w.p.Name, err) + } +} + // RefundOutstanding returns every pending stake to the player — chips in // kind, credits as credits — and clears the ledger in one save. It is used // by graceful shutdown/eviction and by a pre-deal disconnect. On a failed @@ -272,6 +292,15 @@ func (g *Game) casinoMachine(roomID int, gameName string) (*casino.Machine, bool if casino.ValidateRoomConfigs(room.CasinoTables, room.CasinoMachines) != nil { return nil, false } + // An explicit machine id is the most specific address, so match it first: + // the shipped room's machine `huff_puff_01` must be reachable by name even + // though it also declares a variant. + for _, cfg := range room.CasinoMachines { + if strings.EqualFold(cfg.ID, gameName) { + machine := g.Casino.EnsureMachine(roomID, cfg) + return machine, machine != nil + } + } // Prefer an exact variant match first: a room may host several variants of // one game, where a bare game name is ambiguous. for _, cfg := range room.CasinoMachines { @@ -295,16 +324,6 @@ func (g *Game) casinoMachine(roomID int, gameName string) (*casino.Machine, bool return machine, machine != nil } } - // Migrate older room definitions that declared slots under casino_tables; - // slots are still always instantiated as one-player machines. - if strings.EqualFold(gameName, "slots") { - for _, cfg := range room.CasinoTables { - if strings.EqualFold(string(cfg.Game), "slots") { - machine := g.Casino.EnsureMachine(roomID, casino.LegacySlotMachine(cfg)) - return machine, machine != nil - } - } - } return nil, false } diff --git a/internal/game/casino_machine_address_test.go b/internal/game/casino_machine_address_test.go new file mode 100644 index 0000000..3a0280c --- /dev/null +++ b/internal/game/casino_machine_address_test.go @@ -0,0 +1,31 @@ +package game + +import ( + "os" + "path/filepath" + "testing" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/world" +) + +// TestCasinoMachineAddressableByID: a slot cabinet's authored id must be a +// usable address. The shipped room declares `id: huff_puff_01` with a variant, +// and matching only the variant/game left the cabinet unreachable as +// `play huff_puff_01`. +func TestCasinoMachineAddressableByID(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "rooms"), 0755); err != nil { + t.Fatal(err) + } + room := "name: Casino\ncasino_machines:\n - id: huff_puff_01\n game: slots\n variant: huff_and_even_more_puff\n" + if err := os.WriteFile(filepath.Join(dir, "rooms", "1.yaml"), []byte(room), 0644); err != nil { + t.Fatal(err) + } + g := &Game{Deps: Deps{World: world.New(dir)}, Casino: casino.NewManager(1)} + + machine, ok := g.casinoMachine(1, "huff_puff_01") + if !ok || machine == nil { + t.Fatal("machine id huff_puff_01 is not addressable") + } +} diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go index 0a8b8ca..50f1ec5 100644 --- a/internal/game/cmd_quit.go +++ b/internal/game/cmd_quit.go @@ -92,7 +92,10 @@ func (g *Game) beginQuitCountdown(sess *net.Session) { case 3: sess.WriteLine("You close your eyes...") case 0: - g.cancelRest(p.Name) + // Release combat, cover, actions and hacking state before the + // disconnect. This path used to leave the character occupying a + // safespot cover after logging out. + g.releaseCharacterRuntime(sess, p) g.restoreGodPlayer(p) // Forfeit any committed casino wager and free the seat only // now — immediately before the disconnect. Settling games only @@ -101,16 +104,10 @@ func (g *Game) beginQuitCountdown(sess *net.Session) { g.emitCasinoEvents(g.Casino.MarkDisconnected(p.Name)) } g.saveCharacter(p) - g.charsMu.Lock() - delete(g.loggedInChars, p.Name) - g.charsMu.Unlock() - // The character is leaving the world: drop its queued free/ - // active/consume commands with the registry entry, or they - // retain this session and replay on a later login as the same - // character. - if g.queue != nil { - g.queue.ClearPlayer(p.Name) - } + // Drop the registry entry and any queued commands: leftover queue + // entries retain this session and would replay on a later login as + // the same character. + g.detachSession(p.Name) if g.Hub != nil { g.Hub.LeaveRoom(sess) } diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index 9a42d1e..fb72c99 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -242,6 +242,10 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI } else { g.startAction(sess, "talk", strings.Join(args, " ")) } + default: + // No handler for this alias: never return silently, or a typo'd + // alias becomes an invisible no-op. + sess.WriteLine("Unknown command.") } return } diff --git a/internal/game/cmd_room.go b/internal/game/cmd_room.go index b381cf3..33627eb 100644 --- a/internal/game/cmd_room.go +++ b/internal/game/cmd_room.go @@ -83,7 +83,7 @@ func (g *Game) roomSetName(sess *net.Session, args []string) { return } text := strings.Join(args, " ") - g.writeRoom(sess, func(room *world.Room) { + g.mutateRoom(sess, func(room *world.Room) { room.Name = text }) sess.WriteLine(fmt.Sprintf("Room name set to: %s", text)) @@ -94,10 +94,29 @@ func (g *Game) roomSetDesc(sess *net.Session, args []string) { sess.WriteLine("Usage: room desc <text>") return } + room, _, err := g.loadRoomWrite(sess.Player) + if err != nil { + sess.WriteLine(fmt.Sprintf("Error loading room: %v", err)) + return + } + // Replacing the list wholesale would destroy alternates; require a direct + // YAML edit when the room already has conditional or multiple variants. + if len(room.Description) > 1 { + sess.WriteLine("This room has multiple or conditional descriptions; edit the room YAML directly to keep them.") + return + } + for _, v := range room.Description { + if v.Condition != nil { + sess.WriteLine("This room has conditional descriptions; edit the room YAML directly to keep them.") + return + } + } text := strings.Join(args, " ") - g.writeRoom(sess, func(room *world.Room) { + if !g.mutateRoom(sess, func(room *world.Room) { room.Description = behavior.DescList{{Text: text}} - }) + }) { + return + } sess.WriteLine("Room description updated.") } @@ -121,7 +140,7 @@ func (g *Game) roomSetColor(sess *net.Session, args []string) { return } } - g.writeRoom(sess, func(room *world.Room) { + g.mutateRoom(sess, func(room *world.Room) { room.Color = value }) if value == "" { @@ -176,21 +195,30 @@ func parseLocalObjArgs(rawInput, action string) (name string, aliases []string, return name, nil, desc } -func (g *Game) writeRoom(sess *net.Session, fn func(*world.Room)) { +// mutateRoom loads the player's current room, applies fn, writes it back, and +// reconciles the live runtime state from the new file. Every successful +// in-game room edit must go through it: a plain file write left the live room +// unchanged (a removed mob kept standing, an added spawn never appeared) until +// the next room entry or restart. +// +// It runs on the owner goroutine and returns false after reporting an error. +func (g *Game) mutateRoom(sess *net.Session, fn func(*world.Room)) bool { p := sess.Player room, path, err := g.loadRoomWrite(p) if err != nil { sess.WriteLine(fmt.Sprintf("Error loading room: %v", err)) - return + return false } fn(room) data, err := yaml.Marshal(room) if err != nil { - sess.WriteLine("Error marshaling room YAML.") - return + sess.WriteLine(fmt.Sprintf("Error marshaling room YAML: %v", err)) + return false } if err := writeRoomFile(path, data); err != nil { - sess.WriteLine("Error writing room file.") - return + sess.WriteLine(fmt.Sprintf("Error writing room file: %v", err)) + return false } + g.ReconcileRoom(p.RoomID) + return true } diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go index bf95282..2d7ff58 100644 --- a/internal/game/cmd_room_insert.go +++ b/internal/game/cmd_room_insert.go @@ -134,14 +134,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) { g.World.AddRoomPath(newID, newPath) - curRoom.Exits[dir] = world.ExitDef{ - Room: newID, - Condition: exitDef.Condition, - BlockedMessage: exitDef.BlockedMessage, - OnTraverse: exitDef.OnTraverse, - Hidden: exitDef.Hidden, - AlwaysBlocked: exitDef.AlwaysBlocked, - } + curRoom.Exits[dir] = world.RewirePreserving(exitDef, newID) curData, err := yaml.Marshal(curRoom) if err != nil { sess.WriteLine("Error updating current room YAML.") @@ -153,14 +146,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) { } if targetExit, tok := targetRoom.Exits[oppositeDir]; tok && targetExit.Room == p.RoomID { - targetRoom.Exits[oppositeDir] = world.ExitDef{ - Room: newID, - Condition: targetExit.Condition, - BlockedMessage: targetExit.BlockedMessage, - OnTraverse: targetExit.OnTraverse, - Hidden: targetExit.Hidden, - AlwaysBlocked: targetExit.AlwaysBlocked, - } + targetRoom.Exits[oppositeDir] = world.RewirePreserving(targetExit, newID) } targetPath, tok2 := g.World.GetRoomPath(targetID) if tok2 { diff --git a/internal/game/cmd_room_mob.go b/internal/game/cmd_room_mob.go index ce729d4..5adc4b2 100644 --- a/internal/game/cmd_room_mob.go +++ b/internal/game/cmd_room_mob.go @@ -3,7 +3,6 @@ package game import ( "fmt" - "gopkg.in/yaml.v3" "thehouseoficarus/internal/net" "thehouseoficarus/internal/world" ) @@ -41,10 +40,7 @@ func (g *Game) roomAddMob(sess *net.Session, args []string) { } room.Mobs = append(room.Mobs, world.RoomMob{ID: mobID}) - path, _ := g.World.GetRoomPath(p.RoomID) - data, _ := yaml.Marshal(room) - if err := writeRoomFile(path, data); err != nil { - sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + if !g.mutateRoom(sess, func(r *world.Room) { *r = *room }) { return } sess.WriteLine(fmt.Sprintf("Added mob '%s' to room.", mobID)) @@ -77,10 +73,7 @@ func (g *Game) roomRemMob(sess *net.Session, args []string) { } room.Mobs = filtered - path, _ := g.World.GetRoomPath(p.RoomID) - data, _ := yaml.Marshal(room) - if err := writeRoomFile(path, data); err != nil { - sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + if !g.mutateRoom(sess, func(r *world.Room) { *r = *room }) { 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 38b828a..e11d178 100644 --- a/internal/game/cmd_room_object.go +++ b/internal/game/cmd_room_object.go @@ -4,7 +4,6 @@ import ( "fmt" "strings" - "gopkg.in/yaml.v3" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/net" "thehouseoficarus/internal/object" @@ -47,7 +46,9 @@ func (g *Game) roomAddObj(sess *net.Session, args []string) { } } room.Objects = append(room.Objects, world.RoomObject{ID: objID}) - g.writeRoom(sess, func(r *world.Room) { *r = *room }) + if !g.mutateRoom(sess, func(r *world.Room) { *r = *room }) { + return + } sess.WriteLine(fmt.Sprintf("Added object '%s' to room.", objID)) } @@ -77,7 +78,9 @@ func (g *Game) roomRemObj(sess *net.Session, args []string) { return } room.Objects = filtered - g.writeRoom(sess, func(r *world.Room) { *r = *room }) + if !g.mutateRoom(sess, func(r *world.Room) { *r = *room }) { + return + } sess.WriteLine(fmt.Sprintf("Removed object '%s' from room.", objID)) } @@ -115,10 +118,7 @@ func (g *Game) roomAddLocalObj(sess *net.Session, name string, aliases []string, }, }) - path, _ := g.World.GetRoomPath(p.RoomID) - data, _ := yaml.Marshal(room) - if err := writeRoomFile(path, data); err != nil { - sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + if !g.mutateRoom(sess, func(r *world.Room) { *r = *room }) { return } sess.WriteLine(fmt.Sprintf("Added local object '%s' to room.", name)) @@ -153,10 +153,7 @@ func (g *Game) roomRemLocalObj(sess *net.Session, name string) { } room.Objects = filtered - path, _ := g.World.GetRoomPath(p.RoomID) - data, _ := yaml.Marshal(room) - if err := writeRoomFile(path, data); err != nil { - sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + if !g.mutateRoom(sess, func(r *world.Room) { *r = *room }) { return } sess.WriteLine(fmt.Sprintf("Removed local object '%s' from room.", name)) @@ -220,10 +217,7 @@ func (g *Game) roomToggleHidden(sess *net.Session, input string, hidden bool) { return } - path, _ := g.World.GetRoomPath(p.RoomID) - data, _ := yaml.Marshal(room) - if err := writeRoomFile(path, data); err != nil { - sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + if !g.mutateRoom(sess, func(r *world.Room) { *r = *room }) { return } diff --git a/internal/game/cmd_room_reconcile_regression_test.go b/internal/game/cmd_room_reconcile_regression_test.go new file mode 100644 index 0000000..790cb00 --- /dev/null +++ b/internal/game/cmd_room_reconcile_regression_test.go @@ -0,0 +1,47 @@ +package game + +import ( + "os" + "path/filepath" + "testing" + + "thehouseoficarus/internal/player" +) + +// TestRoomAddMobReconcilesLiveState: the in-game room editor used to write the +// YAML but leave the live room untouched, so an added mob did not appear until +// the room was re-entered or the server restarted. A successful room edit must +// reconcile runtime state like the admin editor does. +func TestRoomAddMobReconcilesLiveState(t *testing.T) { + dir := t.TempDir() + for _, sub := range []string{"mobs", "rooms"} { + if err := os.MkdirAll(filepath.Join(dir, sub), 0755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(dir, "mobs", "rat.yaml"), + []byte("id: rat\nname: giant rat\ndescription: A scruffy rat.\ncombat:\n stats:\n hp: 5\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "rooms", "1.yaml"), + []byte("name: Central\n"), 0644); err != nil { + t.Fatal(err) + } + + g := newRemoveGame(t, dir) + sess := newRemoveSession(player.New("alice")) + sess.Player.RoomID = 1 + + if got := len(g.MobStore.MobsInRoom(1)); got != 0 { + t.Fatalf("room should start with no mobs, got %d", got) + } + g.roomAddMob(sess, []string{"rat"}) + if got := len(g.MobStore.MobsInRoom(1)); got != 1 { + t.Fatalf("room addmob did not reconcile live state: %d mobs in room, want 1", got) + } + for _, m := range g.MobStore.MobsInRoom(1) { + if m.DefID != "rat" { + t.Fatalf("seeded mob DefID = %q, want rat", m.DefID) + } + } +} diff --git a/internal/game/cmd_room_remove.go b/internal/game/cmd_room_remove.go index 77a09b3..361cc74 100644 --- a/internal/game/cmd_room_remove.go +++ b/internal/game/cmd_room_remove.go @@ -167,14 +167,7 @@ func (g *Game) roomRemove(sess *net.Session, args []string) { } // Apply: rewire A's dir exit to C, preserving A's current dir-exit props. - aRoom.Exits[dir] = world.ExitDef{ - Room: cID, - Condition: aExit.Condition, - BlockedMessage: aExit.BlockedMessage, - OnTraverse: aExit.OnTraverse, - Hidden: aExit.Hidden, - AlwaysBlocked: aExit.AlwaysBlocked, - } + aRoom.Exits[dir] = world.RewirePreserving(aExit, cID) aData, err := yaml.Marshal(aRoom) if err != nil { sess.WriteLine("Error updating current room YAML.") @@ -186,14 +179,7 @@ func (g *Game) roomRemove(sess *net.Session, args []string) { } // Rewire C's opposite exit to A, preserving C's current opposite-exit props. - cRoom.Exits[oppositeDir] = world.ExitDef{ - Room: aID, - Condition: cOppExit.Condition, - BlockedMessage: cOppExit.BlockedMessage, - OnTraverse: cOppExit.OnTraverse, - Hidden: cOppExit.Hidden, - AlwaysBlocked: cOppExit.AlwaysBlocked, - } + cRoom.Exits[oppositeDir] = world.RewirePreserving(cOppExit, aID) cPath, cPathOK := g.World.GetRoomPath(cID) if cPathOK { cData, cerr := yaml.Marshal(cRoom) @@ -218,6 +204,7 @@ func (g *Game) roomRemove(sess *net.Session, args []string) { g.World.RebuildRoomIndex(g.DataDir) g.MobStore.RemoveMobsInRoom(bID) g.World.ClearRoomState(bID) + g.clearGuardWatchRoom(bID) // Drop any cached in-room games and refund their outstanding wagers // before the room stops existing (same eviction semantics as the // admin editor's room deletion). diff --git a/internal/game/cmd_room_spawn.go b/internal/game/cmd_room_spawn.go index c626acb..d48664a 100644 --- a/internal/game/cmd_room_spawn.go +++ b/internal/game/cmd_room_spawn.go @@ -4,7 +4,6 @@ import ( "fmt" "strconv" - "gopkg.in/yaml.v3" "thehouseoficarus/internal/net" "thehouseoficarus/internal/world" ) @@ -60,10 +59,7 @@ func (g *Game) roomAddSpawn(sess *net.Session, args []string) { RespawnTicks: respawnTicks, }) - path, _ := g.World.GetRoomPath(p.RoomID) - data, _ := yaml.Marshal(room) - if err := writeRoomFile(path, data); err != nil { - sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + if !g.mutateRoom(sess, func(r *world.Room) { *r = *room }) { return } sess.WriteLine(fmt.Sprintf("Added item spawn '%s' x%d (respawn: %.0f ticks) to room.", itemID, quantity, respawnTicks)) @@ -96,10 +92,7 @@ func (g *Game) roomRemSpawn(sess *net.Session, args []string) { } room.ItemSpawns = filtered - path, _ := g.World.GetRoomPath(p.RoomID) - data, _ := yaml.Marshal(room) - if err := writeRoomFile(path, data); err != nil { - sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + if !g.mutateRoom(sess, func(r *world.Room) { *r = *room }) { return } sess.WriteLine(fmt.Sprintf("Removed item spawn '%s' from room.", itemID)) diff --git a/internal/game/cmd_sneak.go b/internal/game/cmd_sneak.go index 5ae60e7..ce063ce 100644 --- a/internal/game/cmd_sneak.go +++ b/internal/game/cmd_sneak.go @@ -2,10 +2,25 @@ package game import ( "fmt" + "strconv" + "strings" "thehouseoficarus/internal/net" ) +// clearGuardWatchRoom drops the sneak/guard-watch cycle timers for one room. +// Called when the room's content is reconciled or the room is deleted, so the +// map does not retain entries for rooms/objects that no longer exist for the +// life of the process. +func (g *Game) clearGuardWatchRoom(roomID int) { + prefix := strconv.Itoa(roomID) + ":" + for key := range g.guardWatchTimers { + if strings.HasPrefix(key, prefix) { + delete(g.guardWatchTimers, key) + } + } +} + func (g *Game) executeSneak(sess *net.Session, args []string, rawInput string) { g.doSneak(sess) } diff --git a/internal/game/cmd_undig.go b/internal/game/cmd_undig.go index 88dd661..0b42f5c 100644 --- a/internal/game/cmd_undig.go +++ b/internal/game/cmd_undig.go @@ -246,6 +246,7 @@ func (g *Game) performUndig(sess *net.Session, p *player.Player, targetID int) { g.MobStore.RemoveMobsInRoom(targetID) g.World.ClearRoomState(targetID) + g.clearGuardWatchRoom(targetID) // Drop any cached in-room games and refund their outstanding wagers // (same eviction semantics as the other room-removal paths). g.EvictCasinoRoom(targetID) diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go index 3b35b15..0224a06 100644 --- a/internal/game/core_login_account.go +++ b/internal/game/core_login_account.go @@ -2,7 +2,6 @@ package game import ( "fmt" - "log" "strings" "time" @@ -86,23 +85,13 @@ func (g *Game) handlePassword(sess *net.Session, input string) { return } auth := passwordAuth{ok: true, stored: acc.PasswordHash} - // Transparent upgrade: legacy single-pass SHA-256 hashes rehash to - // Argon2id after a successful verification. A failed save keeps the - // legacy hash and retries on the next login. - if player.NeedsRehash(acc.PasswordHash) { - if hash, hashErr := player.HashPassword(input); hashErr == nil { - auth.rehash = hash - } else { - log.Printf("login: password rehash for %s: %v", acc.Name, hashErr) - } - } g.applyPasswordAuth(sess, auth) } // passwordAuth carries the result of the EXPENSIVE password work (Argon2id -// verification and any rehash), which runs on the caller's connection -// goroutine — never on the owner, where a burst of logins would stall -// every command and tick behind password hashing. +// verification), which runs on the caller's connection goroutine — never on +// the owner, where a burst of logins would stall every command and tick +// behind password hashing. type passwordAuth struct { ok bool // loadErr marks an account read failure off the owner: the owner task @@ -112,8 +101,6 @@ type passwordAuth struct { // file against it so a password change, rename, or delete made during // verification cannot authorize a stale login. stored string - // rehash is an upgraded hash to persist after a successful check. - rehash string } // handlePasswordOffOwner verifies the password on the caller's connection @@ -170,16 +157,6 @@ func (g *Game) handlePasswordOffOwner(sess *net.Session, input string) { case player.CheckPassword(input, acc.PasswordHash): auth.ok, auth.stored = true, acc.PasswordHash throttle.clear(key) - // Transparent upgrade: legacy single-pass SHA-256 hashes rehash to - // Argon2id after a successful verification. A failed rehash keeps - // the legacy hash and retries on the next login. - if player.NeedsRehash(acc.PasswordHash) { - if hash, hashErr := player.HashPassword(input); hashErr == nil { - auth.rehash = hash - } else { - log.Printf("login: password rehash for %s: %v", acc.Name, hashErr) - } - } default: // Wrong password: apply an escalating per-(account, IP) delay so a // brute-force attempt costs real wall-clock time. This is the @@ -229,10 +206,6 @@ func (g *Game) applyPasswordAuth(sess *net.Session, auth passwordAuth) { reject("Account changed; sign in again.") return } - if auth.rehash != "" { - acc.PasswordHash = auth.rehash - g.saveAccount(acc) - } sess.Account = &player.Account{ Name: acc.Name, PasswordHash: acc.PasswordHash, diff --git a/internal/game/exit_migrate.go b/internal/game/exit_migrate.go index 55289f2..66fccfe 100644 --- a/internal/game/exit_migrate.go +++ b/internal/game/exit_migrate.go @@ -10,7 +10,7 @@ import ( // rewritePlayerRoomIDs remaps room-ID-embedded state in a single player per // idMap (oldID -> newID): discovered-exit player flag keys // (hidden_exit_<id>_<dir>), current RoomID, PendingSequence.RoomID, MapSymbols -// keys, and Stats.RoomsVisited bitset. Returns whether any field was changed. +// keys, and Stats.RoomsVisited. Returns whether any field was changed. // // idMap is assumed to be a bijection (see Room.RewriteRoomIDs); the flag-key // and int-map rekeys use delete-all-then-set-all so a swap (A<->B) cannot diff --git a/internal/game/game.go b/internal/game/game.go index 5aad784..6066179 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -197,22 +197,18 @@ func (g *Game) SetHub(hub *net.Hub) { return } if p := sess.Player; p != nil { - // The session is leaving the world: end any live fight and - // cancel a running logout countdown first, so neither keeps - // acting on a detached session (ghost combat that saves over - // a reconnected character, or a duplicate logout that drops - // the reconnected session's registry entry). - g.stopCombat(p.Name) - g.cancelRest(p.Name) + // The session is leaving the world: end any live fight, cancel + // a running logout countdown, and step out of any safespot + // cover first, so nothing keeps acting on a detached session + // (ghost combat that saves over a reconnected character, a + // duplicate logout that drops the reconnected session's + // registry entry, or a phantom cover occupant). + g.releaseCharacterRuntime(sess, p) g.restoreGodPlayer(p) g.persistLockedOnDisconnect(p) g.saveCharacter(p) p.DeactivateAllTechs() g.detachSession(p.Name) - delete(g.hackingStates, p.Name) - if ss, ok := g.safespot.Get(p.Name); ok { - g.forceLeaveSafespot(sess, p, &ss, "") - } } }) }) @@ -440,7 +436,7 @@ func (g *Game) HandleSession(sess *net.Session, input string) { g.handleRecipeChoice(sess, input) case net.StateHowMany: g.handleHowMany(sess, input) - case net.StateSmithProduct, net.StateFletchProduct, net.StateCraftProduct, net.StateProductChoice: + case net.StateProductChoice: g.handleProductChoice(sess, input) case net.StateColorChoice: g.handleColorChoice(sess, input) diff --git a/internal/game/lifecycle.go b/internal/game/lifecycle.go new file mode 100644 index 0000000..b39c805 --- /dev/null +++ b/internal/game/lifecycle.go @@ -0,0 +1,31 @@ +package game + +import ( + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +// releaseCharacterRuntime tears down a character's live world presence when it +// leaves the world, whether through the quit countdown final tick or a hard +// disconnect. Keeping it in one place is what stops a per-player system from +// being cleaned up on one exit path and forgotten on another: the quit path +// used to leave a player occupying a safespot cover and holding a stale action. +// +// It deliberately does NOT detach the session registry, touch trigger +// sequences, persist the character, or resolve casino wagers. Callers own +// those because their ordering differs: disconnect must snapshot locked +// sequences (persistLockedOnDisconnect) before sequences are dropped, and +// resolves casino wagers through its deferral/refund path, while quit forfeits +// a mid-round stake immediately before saving. +func (g *Game) releaseCharacterRuntime(sess *net.Session, p *player.Player) { + if p == nil { + return + } + g.stopCombat(p.Name) + g.cancelRest(p.Name) + g.cancelAction(p) + if ss, ok := g.safespot.Get(p.Name); ok { + g.forceLeaveSafespot(sess, p, &ss, "") + } + delete(g.hackingStates, p.Name) +} diff --git a/internal/game/login_offowner_regression_test.go b/internal/game/login_offowner_regression_test.go index fa8324c..4826c4c 100644 --- a/internal/game/login_offowner_regression_test.go +++ b/internal/game/login_offowner_regression_test.go @@ -6,8 +6,6 @@ package game // transition on the owner. The observable login behavior is unchanged. import ( - "crypto/sha256" - "encoding/base64" "strings" "testing" "time" @@ -29,26 +27,13 @@ func waitForOutput(t *testing.T, conn *chanConn, substr string) { } } -// TestLoginThroughServerEntryUsesOffOwnerPathAndUpgradesLegacyHash drives -// the real server entry point (HandleSessionOnOwner) through: legacy-hash -// account creation, a WRONG password attempt, and a correct login that -// rehashes the legacy SHA-256 hash to Argon2id durably. -func TestLoginThroughServerEntryUsesOffOwnerPathAndUpgradesLegacyHash(t *testing.T) { +// TestLoginThroughServerEntryUsesOffOwnerPath drives the real server entry +// point (HandleSessionOnOwner) through a WRONG password attempt and a correct +// login, exercising the off-owner verification path. +func TestLoginThroughServerEntryUsesOffOwnerPath(t *testing.T) { h := newHarness(t) h.seedAccount("alice") h.seedCharacter("alice", "alice") - acc, err := h.g.AccountStore.LoadAccount("alice") - if err != nil { - t.Fatal(err) - } - // Downgrade to a legacy salted SHA-256 hash so the login exercises the - // transparent rehash. - salt := []byte("0123456789abcdef") - sum := sha256.Sum256(append(append([]byte{}, salt...), []byte("pw")...)) - acc.PasswordHash = "sha256:" + base64.RawStdEncoding.EncodeToString(salt) + ":" + strings.ToLower(sha256HashHex(sum)) - if err := h.g.AccountStore.SaveAccount(acc); err != nil { - t.Fatal(err) - } conn := newChanConn() sess := &net.Session{Conn: conn} @@ -67,15 +52,12 @@ func TestLoginThroughServerEntryUsesOffOwnerPathAndUpgradesLegacyHash(t *testing if sess.Account == nil || sess.Account.Name != "alice" || len(sess.Account.Characters) != 1 { t.Fatalf("login did not complete: %+v", sess.Account) } - upgraded, err := h.g.AccountStore.LoadAccount("alice") + loaded, err := h.g.AccountStore.LoadAccount("alice") if err != nil { t.Fatal(err) } - if !strings.HasPrefix(upgraded.PasswordHash, "argon2id:") { - t.Errorf("legacy hash not upgraded on login: %s", upgraded.PasswordHash[:12]) - } - if !player.CheckPassword("pw", upgraded.PasswordHash) { - t.Error("upgraded hash no longer verifies the password") + if !player.CheckPassword("pw", loaded.PasswordHash) { + t.Error("stored hash no longer verifies the password") } } @@ -111,14 +93,3 @@ func TestNewAccountThroughServerEntryHashesOffOwner(t *testing.T) { t.Error("created hash does not verify the password") } } - -// sha256HashHex formats like hex.EncodeToString for the legacy hash check. -func sha256HashHex(sum [32]byte) string { - const hexdigits = "0123456789abcdef" - out := make([]byte, 64) - for i, b := range sum { - out[i*2] = hexdigits[b>>4] - out[i*2+1] = hexdigits[b&0x0f] - } - return string(out) -} diff --git a/internal/game/look_entities.go b/internal/game/look_entities.go index e06d49f..1239f31 100644 --- a/internal/game/look_entities.go +++ b/internal/game/look_entities.go @@ -230,18 +230,17 @@ func (g *Game) showRoomCasinos(sess *net.Session, room *world.Room) []string { } var lines []string for _, table := range room.CasinoTables { - // Legacy slots declared under casino_tables are instantiated as the - // room's slots machine, so label them as a machine, not a table. - kind := "table" - if table.Game == casino.GameSlots { - kind = "machine" - } - name := casinoGameLabel(table.Game) + " " + kind + name := casinoGameLabel(table.Game) + " table" lines = append(lines, fmt.Sprintf("A %s is here.", g.colorize(sess, "casino_action", name))) } for _, machine := range room.CasinoMachines { - name := casinoGameLabel(machine.Game) + " machine" - lines = append(lines, fmt.Sprintf("A %s is here.", g.colorize(sess, "casino_action", name))) + label := casinoGameLabel(machine.Game) + " machine" + if machine.Variant != "" { + // The variant (or the machine id) is the addressable name; show it + // so two variants in one room do not render identical lines. + label = casinoGameLabel(casino.GameName(machine.Variant)) + " " + casinoGameLabel(machine.Game) + " machine" + } + lines = append(lines, fmt.Sprintf("A %s is here.", g.colorize(sess, "casino_action", label))) } return lines } diff --git a/internal/game/quit_safespot_regression_test.go b/internal/game/quit_safespot_regression_test.go new file mode 100644 index 0000000..99e8b4f --- /dev/null +++ b/internal/game/quit_safespot_regression_test.go @@ -0,0 +1,47 @@ +package game + +import ( + "testing" + + "thehouseoficarus/internal/config" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +// TestQuitCountdownClearsSafespot: the safespot predicates are room-agnostic, +// so logging out while covered used to leave the character registered as a +// phantom occupant of the cover (blocking a max_occupants cover for an offline +// player) and resume hazard immunity on the next login. The quit path must +// release cover just like every other room-changing path. +func TestQuitCountdownClearsSafespot(t *testing.T) { + dir := t.TempDir() + seedWorld(t, dir) + colors := &config.ColorsConfig{} + g := New(dir, colors, colors, config.ValidationConfig{}, 1) + g.SetHub(net.NewHub()) + + sess := &net.Session{Player: player.New("alice"), Conn: &casinoTestConn{}} + sess.Player.Credits = 1000 + g.charsMu.Lock() + g.loggedInChars["alice"] = sess + g.charsMu.Unlock() + + g.safespot.Set("alice", SafespotState{Active: true, ObjectDefID: "rock", ObjectIndex: 0, RoomID: 1}) + if !g.isSafespotted("alice") { + t.Fatal("safespot not active before quit") + } + + g.doQuit(sess) + for i := 0; i < 15 && sess.Player != nil; i++ { + g.Ticks.Tick() + } + if sess.Player != nil { + t.Fatal("quit countdown never completed") + } + if g.safespot.Has("alice") { + t.Fatal("safespot state survived logout") + } + if g.isSafespotted("alice") { + t.Fatal("player still counts as safespotted after logout") + } +} diff --git a/internal/game/render_help.go b/internal/game/render_help.go index 7886f48..a11ac74 100644 --- a/internal/game/render_help.go +++ b/internal/game/render_help.go @@ -28,10 +28,13 @@ type cmdEntry struct { var commandList = []cmdEntry{ {"alias", "Instant", "Create command shortcuts"}, + {"aps", "Active", "Auto-travel using Agility"}, {"attack / kill", "Active", "Attack a mob"}, {"autotrigger / auto", "Instant", "Set a module to auto-trigger"}, {"bank", "Active", "Open bank interface"}, + {"browse / list", "Instant", "Browse a shop's stock"}, {"burn", "Active", "Start a fire"}, + {"buy", "Instant", "Buy from a shop"}, {"chop / cut", "Active", "Chop trees (Woodcutting)"}, {"clean", "Free", "Clean grimy herbs (Pharmacy)"}, {"color / colors", "Instant", "Customize display colors"}, @@ -39,19 +42,23 @@ var commandList = []cmdEntry{ {"construct / make", "Active", "Build furniture (Construction)"}, {"cook", "Active", "Cook raw food on a fire or range"}, {"craft", "Active", "Craft jewelry (Crafting)"}, + {"cure", "Active", "Cure diseased crops (Farming)"}, {"description / desc", "Instant", "Set your character description"}, {"drink", "Free", "Drink a potion"}, {"drop", "Active", "Drop items to the ground"}, {"eat", "Free", "Eat food to restore hitpoints"}, {"equipment / eq", "Instant", "Show equipped items"}, {"exits", "Instant", "List available exits"}, - {"farm", "Active", "Farm crops (Farming)"}, {"fish", "Active", "Fish at fishing spots (Fishing)"}, {"fletch", "Free", "Fletch logs into bows (Fletching)"}, {"get / take / pick", "Active", "Pick up items from the ground"}, {"global", "Instant", "Chat with everyone on the server"}, + {"harvest", "Active", "Harvest a grown farming patch"}, {"help", "Instant", "Show help topics"}, + {"hide / safespot", "Active", "Hide in a safespot"}, + {"home / recall", "Instant", "Return to your home room"}, {"id / identify", "Active", "Identify herbs (Pharmacy)"}, + {"inspect", "Instant", "Inspect a mob, object, or item"}, {"inventory / i / inv", "Instant", "Show your inventory"}, {"jack / jackin", "Active", "Jack into a terminal (Hacking)"}, {"look / l", "Instant", "Look around or examine things"}, @@ -67,27 +74,37 @@ var commandList = []cmdEntry{ {"mods / modlist", "Instant", "List available science modules"}, {"north / south / east / west / up / down", "Active", "Move in a direction"}, {"option / options", "Instant", "View or change settings"}, + {"plant", "Active", "Plant seeds in a farming patch"}, {"prompt", "Instant", "Set custom command prompt"}, {"pull / push", "Active", "Interact with objects"}, {"queued", "Instant", "Show pending tick actions"}, {"quit", "Active", "Rest and disconnect (\"quit now\" forfeits a mid-game bet)"}, + {"rake", "Active", "Rake a farming patch"}, {"remove / unwear / unwield", "Free", "Unequip items"}, {"say", "Instant", "Chat with players in your room"}, {"score / sc", "Instant", "View your stats and skills"}, {"search", "Active", "Search items for loot"}, + {"sell", "Instant", "Sell to a shop"}, + {"skills / sk", "Instant", "Show your skill levels"}, {"smelt", "Active", "Smelt ore into bars (Smithing)"}, {"smith", "Active", "Smith bars into items (Smithing)"}, {"sneak", "Instant", "Toggle sneak mode"}, + {"stats", "Instant", "Show your combat stats"}, {"steal", "Active", "Steal from mobs (Thieving)"}, + {"stop", "Instant", "Stop the current action"}, {"stoke", "Active", "Add logs to a fire"}, {"style", "Instant", "Change combat style"}, {"symbol", "Instant", "Set custom map symbol for current room"}, {"talk / speak / ask", "Active", "Talk to mobs"}, + {"task", "Instant", "Show your current task"}, {"tech", "Instant", "Toggle technology abilities"}, {"trigger", "Active", "Trigger a science module"}, {"unalias", "Instant", "Remove command shortcuts"}, + {"unhide / unsafespot", "Active", "Leave a safespot"}, {"use", "Active", "Use an object (crafting)"}, + {"verbs / commands", "Instant", "List every command you can use"}, {"walk", "Active", "Pathfind to a room or multi-step walk"}, + {"water", "Active", "Water crops (Farming)"}, {"wear / wield", "Free", "Equip items"}, {"who", "Instant", "List everyone online and their combat level"}, } @@ -106,43 +123,32 @@ func LoadHelp(dataDir string) ([]HelpDef, error) { return helps, nil } -// helpRaw accepts both the canonical schema (name/description) and the -// legacy variant (title/body) used by a few help files. +// helpRaw mirrors the canonical help schema and is also used by data +// validation to flag unknown fields. type helpRaw struct { Name string `yaml:"name"` - Title string `yaml:"title"` Aliases []string `yaml:"aliases"` Related []string `yaml:"related"` Category string `yaml:"category"` Description string `yaml:"description"` - Body string `yaml:"body"` } -// parseHelpDef unmarshals a help definition, normalizing legacy title/body -// files onto the canonical HelpDef. Returns nil for files that are not help -// topics (no name at all). +// parseHelpDef unmarshals a help definition. Returns nil for files that are +// not help topics (no name at all). func parseHelpDef(data []byte) *HelpDef { var raw helpRaw if err := yaml.Unmarshal(data, &raw); err != nil { return nil } - name := raw.Name - if name == "" { - name = raw.Title - } - if name == "" { + if raw.Name == "" { return nil } - content := raw.Description - if content == "" { - content = raw.Body - } return &HelpDef{ - Name: name, + Name: raw.Name, Aliases: raw.Aliases, Related: raw.Related, Category: raw.Category, - Content: content, + Content: raw.Description, } } diff --git a/internal/game/render_help_test.go b/internal/game/render_help_test.go index b8ad614..dcfa619 100644 --- a/internal/game/render_help_test.go +++ b/internal/game/render_help_test.go @@ -79,3 +79,34 @@ func TestHelpAliasesResolveWithoutCollision(t *testing.T) { } } } + +// Every verb advertised in the help table must actually be a command. "farm" +// was listed for a long time while not existing, so typing it returned +// "Unknown command." A help entry is the first thing a new player trusts. +func TestHelpCommandListMatchesRegistry(t *testing.T) { + for _, entry := range commandList { + for _, verb := range strings.Split(entry.Name, "/") { + verb = strings.TrimSpace(verb) + if verb == "" { + continue + } + if _, ok := commandRegistry[verb]; ok { + continue + } + if _, ok := verbAliases[verb]; ok { + continue + } + t.Errorf("help lists %q (from %q) but it is not a registered command", verb, entry.Name) + } + } +} + +// alwaysAvailable is a second hand-maintained verb list; it must not drift +// from the registry either. +func TestAlwaysAvailableCommandsAreRegistered(t *testing.T) { + for _, verb := range alwaysAvailable { + if _, ok := commandRegistry[verb]; !ok { + t.Errorf("alwaysAvailable lists %q but it is not registered", verb) + } + } +} diff --git a/internal/game/room_reconcile.go b/internal/game/room_reconcile.go index 7491776..31b66f6 100644 --- a/internal/game/room_reconcile.go +++ b/internal/game/room_reconcile.go @@ -14,6 +14,9 @@ func (g *Game) ReconcileRoom(roomID int) { return } g.World.ReconcileRoomState(roomID, room) + // Guard-watch cycle timers are keyed by room:object; an object edit can + // remove the object the timer belonged to, so drop the room's timers. + g.clearGuardWatchRoom(roomID) if g.MobStore != nil { g.MobStore.RemoveMobsInRoom(roomID) g.MobStore.SeedMobs(roomID, room.Mobs) diff --git a/internal/net/server.go b/internal/net/server.go index daf3e47..80710ad 100644 --- a/internal/net/server.go +++ b/internal/net/server.go @@ -31,7 +31,6 @@ const ( StateNewAccount StateMenu StateNewCharName - StateNewCharConfirm StateRenameAccount StateRenameChar StateRenameCharName @@ -44,9 +43,6 @@ const ( StateDropAllConfirm StateRecipeChoice StateHowMany - StateSmithProduct - StateFletchProduct - StateCraftProduct StateProductChoice StateColorChoice StateBank diff --git a/internal/player/hardening_password_params_test.go b/internal/player/hardening_password_params_test.go index 4c171f1..32b399b 100644 --- a/internal/player/hardening_password_params_test.go +++ b/internal/player/hardening_password_params_test.go @@ -5,12 +5,7 @@ package player // (e.g. argon2id:1:4294967295:...) must fail the check, not request a // multi-TiB allocation that kills the process. -import ( - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "testing" -) +import "testing" func TestCheckPasswordRejectsOutOfRangeArgonParams(t *testing.T) { // Enormous memory cost (0xFFFFFFFF KiB) with an otherwise well-formed @@ -33,7 +28,7 @@ func TestCheckPasswordRejectsOutOfRangeArgonParams(t *testing.T) { } } -func TestCheckPasswordAcceptsNormalAndLegacyHashes(t *testing.T) { +func TestCheckPasswordAcceptsNormalHashes(t *testing.T) { hashed, err := HashPassword("s3cret") if err != nil { t.Fatal(err) @@ -44,19 +39,4 @@ func TestCheckPasswordAcceptsNormalAndLegacyHashes(t *testing.T) { if CheckPassword("wrong", hashed) { t.Fatal("wrong password accepted") } - - // Legacy single-pass sha256 hashes must keep verifying so old accounts - // can still log in and be upgraded. - salt := []byte("sixteen-byte-slt") - sum := sha256.Sum256(append(append([]byte{}, salt...), []byte("pw")...)) - legacy := "sha256:" + base64.RawStdEncoding.EncodeToString(salt) + ":" + hex.EncodeToString(sum[:]) - if !CheckPassword("pw", legacy) { - t.Fatal("legacy sha256 hash rejected") - } - if CheckPassword("nope", legacy) { - t.Fatal("wrong password accepted against legacy sha256 hash") - } - if !NeedsRehash(legacy) { - t.Fatal("legacy sha256 hash not flagged for rehash") - } } diff --git a/internal/player/password.go b/internal/player/password.go index de5fe5f..706ef45 100644 --- a/internal/player/password.go +++ b/internal/player/password.go @@ -2,7 +2,6 @@ package player import ( "crypto/rand" - "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" @@ -19,10 +18,8 @@ import ( // // argon2id:<time>:<memoryKiB>:<threads>:<b64salt>:<hexkey> // -// Legacy accounts may still carry the old single-pass salted SHA-256 hash -// ("sha256:<b64salt>:<hexhash>"); CheckPassword verifies both, and the -// login paths rehash to Argon2id on successful verification (see -// NeedsRehash). +// Only Argon2id is accepted: the prototype has no password-hash migration and +// accounts are recreated rather than upgraded. const ( argonTime = 1 argonMemory = 64 * 1024 // KiB @@ -78,57 +75,33 @@ func HashPassword(password string) (string, error) { base64.RawStdEncoding.EncodeToString(salt), hex.EncodeToString(key)), nil } -// CheckPassword verifies a password against a stored hash in either the -// current Argon2id format or the legacy salted SHA-256 format. +// CheckPassword verifies a password against a stored Argon2id hash. func CheckPassword(password, stored string) bool { parts := strings.SplitN(stored, ":", 6) - if len(parts) == 0 { + if len(parts) == 0 || parts[0] != "argon2id" { return false } - switch parts[0] { - case "argon2id": - if len(parts) != 6 { - return false - } - t, errT := strconv.Atoi(parts[1]) - m, errM := strconv.Atoi(parts[2]) - p, errP := strconv.Atoi(parts[3]) - salt, errSalt := base64.RawStdEncoding.DecodeString(parts[4]) - expected, errKey := hex.DecodeString(parts[5]) - if errT != nil || errM != nil || errP != nil || errSalt != nil || errKey != nil { - return false - } - if t < argonMinTime || t > argonMaxTime || - m < argonMinMemory || m > argonMaxMemory || - p < argonMinThreads || p > argonMaxThreads || - len(expected) < argonMinKeyLen || len(expected) > argonMaxKeyLen { - // Out-of-range parameters: fail the check instead of trusting the - // stored values and requesting an enormous allocation. - return false - } - acquireHashSlot() - key := argon2.IDKey([]byte(password), salt, uint32(t), uint32(m), uint8(p), uint32(len(expected))) - releaseHashSlot() - return subtle.ConstantTimeCompare(key, expected) == 1 - case "sha256": - legacy := strings.SplitN(stored, ":", 3) - if len(legacy) != 3 { - return false - } - salt, err := base64.RawStdEncoding.DecodeString(legacy[1]) - if err != nil { - return false - } - hash := sha256.Sum256(append(salt, []byte(password)...)) - return subtle.ConstantTimeCompare([]byte(legacy[2]), []byte(fmt.Sprintf("%x", hash))) == 1 - default: + if len(parts) != 6 { return false } -} - -// NeedsRehash reports whether a stored hash uses the legacy single-pass -// SHA-256 scheme. Callers rehash with HashPassword after a successful -// verification and persist the new hash (transparent upgrade on login). -func NeedsRehash(stored string) bool { - return strings.HasPrefix(stored, "sha256:") + t, errT := strconv.Atoi(parts[1]) + m, errM := strconv.Atoi(parts[2]) + p, errP := strconv.Atoi(parts[3]) + salt, errSalt := base64.RawStdEncoding.DecodeString(parts[4]) + expected, errKey := hex.DecodeString(parts[5]) + if errT != nil || errM != nil || errP != nil || errSalt != nil || errKey != nil { + return false + } + if t < argonMinTime || t > argonMaxTime || + m < argonMinMemory || m > argonMaxMemory || + p < argonMinThreads || p > argonMaxThreads || + len(expected) < argonMinKeyLen || len(expected) > argonMaxKeyLen { + // Out-of-range parameters: fail the check instead of trusting the + // stored values and requesting an enormous allocation. + return false + } + acquireHashSlot() + key := argon2.IDKey([]byte(password), salt, uint32(t), uint32(m), uint8(p), uint32(len(expected))) + releaseHashSlot() + return subtle.ConstantTimeCompare(key, expected) == 1 } diff --git a/internal/player/password_test.go b/internal/player/password_test.go index 82b580b..2a7d596 100644 --- a/internal/player/password_test.go +++ b/internal/player/password_test.go @@ -1,9 +1,6 @@ package player import ( - "crypto/sha256" - "encoding/base64" - "fmt" "strings" "testing" ) @@ -29,30 +26,15 @@ func TestHashPasswordUsesArgon2idAndVerifies(t *testing.T) { } } -func TestCheckPasswordVerifiesLegacySHA256(t *testing.T) { - salt := make([]byte, 16) - for i := range salt { - salt[i] = byte(i) - } - sum := sha256.Sum256(append(salt, []byte("legacy pw")...)) - legacy := fmt.Sprintf("sha256:%s:%x", base64.RawStdEncoding.EncodeToString(salt), sum) - - if !CheckPassword("legacy pw", legacy) { - t.Fatal("correct legacy password rejected") - } - if CheckPassword("wrong", legacy) { - t.Fatal("wrong legacy password accepted") - } - if !NeedsRehash(legacy) { - t.Fatal("legacy hash not flagged for rehash") - } - if NeedsRehash("argon2id:1:65536:2:c2FsdA:x") { - t.Fatal("argon2id hash flagged for rehash") - } -} - func TestCheckPasswordRejectsGarbage(t *testing.T) { - for _, stored := range []string{"", "plain", "argon2id:1:2:3:no-salt:zz", "unknown:v1:salt:deadbeef"} { + for _, stored := range []string{ + "", + "plain", + "argon2id:1:2:3:no-salt:zz", + "unknown:v1:salt:deadbeef", + // Legacy single-pass SHA-256 hashes are no longer accepted. + "sha256:AAAAAAAAAAAAAAAAAAAAAA:0011223344556677889900112233445566778899001122334455667788990011", + } { if CheckPassword("anything", stored) { t.Fatalf("garbage hash accepted: %q", stored) } diff --git a/internal/player/roomset.go b/internal/player/roomset.go index 3565bb6..d7e1b47 100644 --- a/internal/player/roomset.go +++ b/internal/player/roomset.go @@ -2,75 +2,103 @@ package player import ( "encoding/base64" - "math/bits" + "encoding/binary" + "fmt" + "sort" + "strings" "gopkg.in/yaml.v3" ) +// RoomSet is the set of room IDs a character has visited, used for the map +// discovery view. +// +// It is stored sparsely: room IDs are filename-derived and may be large and +// widely spaced (the shipped example rooms run from 1001 to 99999301), so an +// ID-indexed bitset would grow with the *largest* ID rather than the number of +// visited rooms. A sparse map keeps memory proportional to the visited count, +// and the YAML encoding is a sorted, delta-encoded uvarint blob (see +// MarshalYAML) which is a handful of bytes for a typical character regardless +// of how high the IDs run. +// +// The zero value is a valid empty set. Copies share the underlying map, so a +// RoomSet should not be copied after a concurrent mutation (the game layer only +// touches it on the owner goroutine). type RoomSet struct { - bits []byte + ids map[int]struct{} +} + +// roomSetV1Prefix tags the delta-encoded encoding. The colon is safe inside a +// YAML plain scalar: ':' is only a mapping indicator when followed by a space, +// and ':' is not in the base64 alphabet, so the prefix cannot be confused with +// payload bytes. +const roomSetV1Prefix = "v1:" + +// roomSetMaxID bounds decoded room IDs. IDs are filename-derived and fit +// comfortably in an int32; rejecting anything larger keeps a corrupt delta +// blob from synthesizing absurd IDs. +const roomSetMaxID = 1<<31 - 1 + +func (rs *RoomSet) ensure() { + if rs.ids == nil { + rs.ids = make(map[int]struct{}) + } } func (rs *RoomSet) Add(roomID int) { if roomID < 0 { return } - byteIdx := roomID / 8 - if byteIdx >= len(rs.bits) { - nb := make([]byte, byteIdx+1) - copy(nb, rs.bits) - rs.bits = nb - } - rs.bits[byteIdx] |= 1 << (roomID % 8) + rs.ensure() + rs.ids[roomID] = struct{}{} } func (rs *RoomSet) Has(roomID int) bool { - if roomID < 0 { + if roomID < 0 || rs.ids == nil { return false } - byteIdx := roomID / 8 - return byteIdx < len(rs.bits) && rs.bits[byteIdx]&(1<<(roomID%8)) != 0 + _, ok := rs.ids[roomID] + return ok } func (rs *RoomSet) Len() int { - n := 0 - for _, b := range rs.bits { - n += bits.OnesCount8(b) + if rs.ids == nil { + return 0 } - return n + return len(rs.ids) } func (rs RoomSet) IsZero() bool { - return len(rs.bits) == 0 + return len(rs.ids) == 0 } +// Keys returns the visited room IDs in ascending order. func (rs *RoomSet) Keys() []int { - var keys []int - for byteIdx, b := range rs.bits { - if b == 0 { - continue - } - base := byteIdx * 8 - for bitIdx := 0; bitIdx < 8; bitIdx++ { - if b&(1<<bitIdx) != 0 { - keys = append(keys, base+bitIdx) - } - } + if len(rs.ids) == 0 { + return nil } + keys := make([]int, 0, len(rs.ids)) + for id := range rs.ids { + keys = append(keys, id) + } + sort.Ints(keys) return keys } +// Rekey remaps every visited room through idMap, leaving unmapped IDs in place. +// It returns the new set and whether any ID changed. The receiver is not +// modified. func (rs *RoomSet) Rekey(idMap map[int]int) (RoomSet, bool) { var next RoomSet changed := false - for _, k := range rs.Keys() { - newK, ok := idMap[k] + for id := range rs.ids { + newID, ok := idMap[id] if !ok { - newK = k - } else if newK != k { + newID = id + } else if newID != id { changed = true } - next.Add(newK) + next.Add(newID) } return next, changed } @@ -83,13 +111,29 @@ func NewRoomSetFrom(ids ...int) RoomSet { return rs } +// MarshalYAML emits the current encoding: ascending IDs as delta-encoded +// uvarints, base64-encoded behind the "v1:" tag. IDs come from filenames, so a +// small delta usually costs one byte and the total is O(visited). func (rs RoomSet) MarshalYAML() (any, error) { - return base64.StdEncoding.EncodeToString(rs.bits), nil + if len(rs.ids) == 0 { + return "", nil + } + keys := rs.Keys() + buf := make([]byte, 0, len(keys)*2) + prev := 0 + for _, id := range keys { + buf = binary.AppendUvarint(buf, uint64(id-prev)) + prev = id + } + return roomSetV1Prefix + base64.StdEncoding.EncodeToString(buf), nil } +// UnmarshalYAML accepts only the v1 delta encoding (plus null/empty for the +// zero value). There is deliberately no legacy decoding: the game is a +// prototype and character files are recreated rather than migrated. func (rs *RoomSet) UnmarshalYAML(value *yaml.Node) error { - if value.Kind == yaml.ScalarNode && (value.Tag == "!!null" || value.Value == "null") { - rs.bits = nil + rs.ids = nil + if value.Kind == yaml.ScalarNode && (value.Tag == "!!null" || value.Value == "null" || value.Value == "") { return nil } var s string @@ -97,13 +141,31 @@ func (rs *RoomSet) UnmarshalYAML(value *yaml.Node) error { return err } if s == "" { - rs.bits = nil return nil } - b, err := base64.StdEncoding.DecodeString(s) + if !strings.HasPrefix(s, roomSetV1Prefix) { + return fmt.Errorf("rooms_visited: unrecognized encoding, want %q prefix", roomSetV1Prefix) + } + return rs.decodeDelta(s[len(roomSetV1Prefix):]) +} + +func (rs *RoomSet) decodeDelta(encoded string) error { + raw, err := base64.StdEncoding.DecodeString(encoded) if err != nil { - return err + return fmt.Errorf("rooms_visited: %w", err) + } + prev := 0 + for len(raw) > 0 { + delta, n := binary.Uvarint(raw) + if n <= 0 { + return fmt.Errorf("rooms_visited: corrupt delta encoding") + } + raw = raw[n:] + if delta > roomSetMaxID || prev > roomSetMaxID-int(delta) { + return fmt.Errorf("rooms_visited: room ID out of range") + } + prev += int(delta) + rs.Add(prev) } - rs.bits = b return nil } diff --git a/internal/player/roomset_test.go b/internal/player/roomset_test.go index 2b6b9cb..38bd993 100644 --- a/internal/player/roomset_test.go +++ b/internal/player/roomset_test.go @@ -1,6 +1,8 @@ package player import ( + "encoding/base64" + "encoding/binary" "strings" "testing" @@ -85,3 +87,59 @@ func TestRoomSetMarshalEmpty(t *testing.T) { t.Errorf("MarshalYAML empty = %q, want empty string", s) } } + +// Sparse, high room IDs are the reason RoomSet is not a dense bitset: the +// encoding must stay proportional to the visited count, not the largest ID. +func TestRoomSetSparseEncodingIsCompact(t *testing.T) { + var rs RoomSet + for _, id := range []int{1001, 1002, 1003, 2001, 2006, 99999301} { + rs.Add(id) + } + data, err := yaml.Marshal(PlayerStats{RoomsVisited: rs}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if len(data) > 200 { + t.Fatalf("encoding is not compact for sparse IDs: %d bytes\n%s", len(data), data) + } + + var loaded PlayerStats + if err := yaml.Unmarshal(data, &loaded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for _, id := range []int{1001, 1002, 1003, 2001, 2006, 99999301} { + if !loaded.RoomsVisited.Has(id) { + t.Errorf("round trip lost room %d", id) + } + } + if loaded.RoomsVisited.Has(5000) { + t.Error("round trip invented room 5000") + } +} + +// The prototype has no legacy formats: anything without the v1 prefix is a +// hard error rather than a silently-decoded old bitset. +func TestRoomSetRejectsUnknownEncoding(t *testing.T) { + var wrap struct { + Visited RoomSet `yaml:"rooms_visited"` + } + if err := yaml.Unmarshal([]byte("rooms_visited: AQIDBA==\n"), &wrap); err == nil { + t.Fatal("expected error for unprefixed encoding") + } + if err := yaml.Unmarshal([]byte("rooms_visited: [7, 42, 900]\n"), &wrap); err == nil { + t.Fatal("expected error for YAML sequence encoding") + } +} + +func TestRoomSetRejectsOutOfRangeDelta(t *testing.T) { + // A valid uvarint delta above roomSetMaxID must fail rather than wrap or + // synthesize an absurd room ID. + buf := binary.AppendUvarint(nil, uint64(roomSetMaxID)+1) + encoded := roomSetV1Prefix + base64.StdEncoding.EncodeToString(buf) + var wrap struct { + Visited RoomSet `yaml:"rooms_visited"` + } + if err := yaml.Unmarshal([]byte("rooms_visited: "+encoded+"\n"), &wrap); err == nil { + t.Fatal("expected error for out-of-range delta") + } +} diff --git a/internal/player/skill_abbr_test.go b/internal/player/skill_abbr_test.go new file mode 100644 index 0000000..81039f0 --- /dev/null +++ b/internal/player/skill_abbr_test.go @@ -0,0 +1,19 @@ +package player + +import "testing" + +// Every skill must have an abbreviation. A missing entry silently renders as an +// empty XP drop and an empty column in the skills display, with no error, so +// this must fail loudly when a skill is added without its abbreviation. +func TestEverySkillHasAbbreviation(t *testing.T) { + for _, skill := range AllSkills { + if SkillAbbr[skill] == "" { + t.Errorf("skill %q has no abbreviation", skill) + } + } + for skill := range SkillAbbr { + if !ValidSkill(skill) { + t.Errorf("SkillAbbr contains unknown skill %q", skill) + } + } +} diff --git a/internal/validate/checks.go b/internal/validate/checks.go index 1c87c67..6341108 100644 --- a/internal/validate/checks.go +++ b/internal/validate/checks.go @@ -9,6 +9,7 @@ import ( "sort" "strings" + "gopkg.in/yaml.v3" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/casino" "thehouseoficarus/internal/color" @@ -132,6 +133,24 @@ func validateRooms(s Source) []Issue { id, sp.ID), }) } + // A non-positive quantity is silently skipped by every ground-item + // read path, so the spawn is dead content with no feedback. + if sp.Quantity <= 0 { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Room %d: item spawn %q has quantity %d; it must be positive or the spawn can never appear", + id, sp.ID, sp.Quantity), + }) + } + if sp.RespawnTicks < 0 { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Room %d: item spawn %q has negative respawn_ticks %.1f", + id, sp.ID, sp.RespawnTicks), + }) + } } var collEntries []roomObjEntry @@ -1180,8 +1199,51 @@ func validateCourses(s Source) []Issue { return issues } +// validateFlagTriggerFiles validates every file-backed flag trigger under +// data/triggers. A file that sets neither on_player_flag nor on_global_flag is +// loaded but indexed nowhere and can never fire, so it is an ERROR; the steps +// are checked for dangling references like any other trigger block. +func validateFlagTriggerFiles(s Source) []Issue { + var issues []Issue + roomIndex := s.World.RoomIndex() + itemIDs := s.Items.IDSet() + mobIDs := s.Mobs.AllDefIDs() + dropIDs := dropTableIDSet(s.DataDir) + dir := filepath.Join(s.DataDir, "triggers") + if err := behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error { + var t behavior.Trigger + if err := yaml.Unmarshal(data, &t); err != nil { + // validateKnownFields already reports the decode error. + return nil + } + rel := strings.TrimPrefix(filepath.ToSlash(path), filepath.ToSlash(s.DataDir)+"/") + if t.OnPlayerFlag == "" && t.OnGlobalFlag == "" { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "trigger", + Message: fmt.Sprintf("trigger %s: neither on_player_flag nor on_global_flag is set, so it is never indexed and can never fire", rel), + }) + return nil + } + if t.Condition != nil { + issues = append(issues, validateCondition(rel, t.Condition, roomIndex, itemIDs)...) + } + for si := range t.Steps { + issues = append(issues, validateStep(fmt.Sprintf("%s: step[%d]", rel, si), &t.Steps[si], itemIDs, roomIndex, mobIDs, dropIDs)...) + } + return nil + }); err != nil { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "trigger", + Message: fmt.Sprintf("triggers: could not scan trigger files: %v", err), + }) + } + return issues +} + // validateRoomTriggers validates the on_enter and on_exit trigger blocks on -// every room. Flag-change triggers (data/triggers) are not validated here. +// every room. Flag-change triggers (data/triggers) are validated separately. func validateRoomTriggers(s Source) []Issue { var issues []Issue roomIndex := s.World.RoomIndex() diff --git a/internal/validate/validate.go b/internal/validate/validate.go index a7ce1b0..dbcf06f 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -110,6 +110,7 @@ func Run(s Source) []Issue { issues = append(issues, validateDropTables(s)...) issues = append(issues, validateCourses(s)...) issues = append(issues, validateRoomTriggers(s)...) + issues = append(issues, validateFlagTriggerFiles(s)...) issues = append(issues, validateTechs(s)...) issues = append(issues, validateRoomWiring(s)...) issues = append(issues, validateModuleDestinations(s)...) @@ -127,13 +128,21 @@ func validateRoomFilenames(dataDir string) []Issue { dir := filepath.Join(dataDir, "rooms") var issues []Issue if err := behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error { - if _, err := strconv.Atoi(id); err != nil { + n, convErr := strconv.Atoi(id) + if convErr != nil { rel := strings.TrimPrefix(path, dataDir+"/") issues = append(issues, Issue{ Level: "ERROR", Type: "integrity", Message: fmt.Sprintf("room %s: filename is not a numeric room ID and the file is ignored", rel), }) + } else if n <= 0 { + rel := strings.TrimPrefix(path, dataDir+"/") + issues = append(issues, Issue{ + Level: "ERROR", + Type: "integrity", + Message: fmt.Sprintf("room %s: room ID must be positive, got %d", rel, n), + }) } return nil }); err != nil { diff --git a/internal/world/world.go b/internal/world/world.go index 7a91894..10ceb08 100644 --- a/internal/world/world.go +++ b/internal/world/world.go @@ -18,10 +18,14 @@ type World struct { dataDir string mu sync.Mutex roomPathIndex map[int]string - groundItems map[int][]*groundEntry - seeded map[int]bool - objStates map[string]*ObjState - objMoves []ObjMove + // roomIDMismatchLogged dedupes the "declared id ignored" warning, which + // would otherwise fire on every prompt/tick for a room file copied from a + // template with a stale id:. Guarded by mu. + roomIDMismatchLogged map[int]bool + groundItems map[int][]*groundEntry + seeded map[int]bool + objStates map[string]*ObjState + objMoves []ObjMove hazardMu sync.Mutex hazardPathIndex map[string]string @@ -30,12 +34,13 @@ type World struct { func New(dataDir string) *World { return &World{ - dataDir: dataDir, - roomPathIndex: behavior.BuildRoomIndex(filepath.Join(dataDir, "rooms")), - groundItems: make(map[int][]*groundEntry), - seeded: make(map[int]bool), - objStates: make(map[string]*ObjState), - hazardDefs: make(map[string]*HazardDef), + dataDir: dataDir, + roomPathIndex: behavior.BuildRoomIndex(filepath.Join(dataDir, "rooms")), + roomIDMismatchLogged: make(map[int]bool), + groundItems: make(map[int][]*groundEntry), + seeded: make(map[int]bool), + objStates: make(map[string]*ObjState), + hazardDefs: make(map[string]*HazardDef), } } @@ -58,8 +63,17 @@ func (w *World) LoadRoom(id int) (*Room, error) { // declared id: is silently ignored, so surface it rather than leave the // author believing their written id is in use. if room.ID != 0 && room.ID != id { - log.Printf("room %d: file %s declares id %d but is loaded as room %d; the filename is authoritative and the declared id is ignored", - id, path, room.ID, id) + w.mu.Lock() + if w.roomIDMismatchLogged == nil { + w.roomIDMismatchLogged = make(map[int]bool) + } + already := w.roomIDMismatchLogged[id] + w.roomIDMismatchLogged[id] = true + w.mu.Unlock() + if !already { + log.Printf("room %d: file %s declares id %d but is loaded as room %d; the filename is authoritative and the declared id is ignored", + id, path, room.ID, id) + } } room.ID = id if room.Exits == nil { |
