From 0e60567a7a0addb9421769ee7be7853bfa8788cb Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sat, 19 Sep 2026 03:15:59 -0400 Subject: refactor: simplifcation, change how rooms are stored in player yaml, remove legacy code --- building_guide/behaviors.md | 12 +- building_guide/rooms.md | 53 ++-- building_guide/triggers.md | 308 +++++++++++---------- data/help/agility.yaml | 4 +- data/help/hacking.yaml | 4 +- data/help/jack.yaml | 4 +- internal/admin/server.go | 14 +- internal/admin/static/map.js | 21 +- internal/behavior/desc.go | 69 +++++ internal/behavior/desc_test.go | 86 ++++++ internal/behavior/store.go | 34 +++ internal/casino/baccarat.go | 10 +- internal/casino/blackjack.go | 8 +- internal/casino/config_validate.go | 77 +++++- internal/casino/config_validate_test.go | 50 +++- internal/casino/core.go | 16 +- internal/casino/hardening_casino_validate_test.go | 14 +- internal/casino/manager_test.go | 8 + internal/casino/registry.go | 18 +- internal/casino/slots.go | 6 +- internal/casino/types.go | 11 +- internal/game/act.go | 17 +- internal/game/casino.go | 39 ++- internal/game/casino_machine_address_test.go | 31 +++ internal/game/cmd_quit.go | 19 +- internal/game/cmd_registry.go | 4 + internal/game/cmd_room.go | 48 +++- internal/game/cmd_room_insert.go | 18 +- internal/game/cmd_room_mob.go | 11 +- internal/game/cmd_room_object.go | 24 +- .../game/cmd_room_reconcile_regression_test.go | 47 ++++ internal/game/cmd_room_remove.go | 19 +- internal/game/cmd_room_spawn.go | 11 +- internal/game/cmd_sneak.go | 15 + internal/game/cmd_undig.go | 1 + internal/game/core_login_account.go | 33 +-- internal/game/exit_migrate.go | 2 +- internal/game/game.go | 20 +- internal/game/lifecycle.go | 31 +++ internal/game/login_offowner_regression_test.go | 43 +-- internal/game/look_entities.go | 17 +- internal/game/quit_safespot_regression_test.go | 47 ++++ internal/game/render_help.go | 44 +-- internal/game/render_help_test.go | 31 +++ internal/game/room_reconcile.go | 3 + internal/net/server.go | 4 - internal/player/hardening_password_params_test.go | 24 +- internal/player/password.go | 77 ++---- internal/player/password_test.go | 34 +-- internal/player/roomset.go | 142 +++++++--- internal/player/roomset_test.go | 58 ++++ internal/player/skill_abbr_test.go | 19 ++ internal/validate/checks.go | 64 ++++- internal/validate/validate.go | 11 +- internal/world/world.go | 38 ++- 55 files changed, 1246 insertions(+), 627 deletions(-) create mode 100644 internal/behavior/desc_test.go create mode 100644 internal/game/casino_machine_address_test.go create mode 100644 internal/game/cmd_room_reconcile_regression_test.go create mode 100644 internal/game/lifecycle.go create mode 100644 internal/game/quit_safespot_regression_test.go create mode 100644 internal/player/skill_abbr_test.go diff --git a/building_guide/behaviors.md b/building_guide/behaviors.md index 3063677..26c12ec 100644 --- a/building_guide/behaviors.md +++ b/building_guide/behaviors.md @@ -479,10 +479,10 @@ talk: ### Interaction Reference The unified **Trigger** shape is the shared wrapper used by every event block: `on_use`, -`on_look`, `on_kill`, `on_traverse`, `on_enter`, `on_exit`, `on_flag_change`, -`on_global_flag_change`, and global trigger files (`data/triggers/*.yaml`). When a block's -list of Triggers is evaluated, the first whose filters pass fires and its `steps` run as a -scripted sequence (first-match-wins). +`on_look`, `on_kill`, `on_traverse`, `on_enter`, `on_exit`, and flag-trigger files under +`data/triggers/player_flag/` or `data/triggers/global_flag/` (`on_player_flag` / +`on_global_flag`). When a block's list of Triggers is evaluated, the first whose filters +pass fires and its `steps` run as a scripted sequence (first-match-wins). | Field | Scope | Description | |-------|-------|-------------| @@ -490,8 +490,8 @@ scripted sequence (first-match-wins). | `item_id` | on_use/on_look/on_kill | `on_use`: required item (`use on `); empty = bare `use `. `on_look`: only fires if carrying this item. `on_kill`: only fires if wielding this weapon. | | `condition` | all | Optional Trigger-level gate (Condition struct). | | `steps` | all | Ordered list of Step entries run as a scripted sequence. | -| `on_player_flag` | on_flag_change only | Player flag this trigger watches. | -| `on_global_flag` | on_global_flag_change only | Global flag this trigger watches. | +| `on_player_flag` | flag-trigger files only | Player flag this trigger watches. | +| `on_global_flag` | flag-trigger files only | Global flag this trigger watches. | | `value` | flag triggers | Optional value-match filter. | Talk node/option actions still use the NodeAction shape (`action:` with the effect fields diff --git a/building_guide/rooms.md b/building_guide/rooms.md index 719c09e..25c6f5f 100644 --- a/building_guide/rooms.md +++ b/building_guide/rooms.md @@ -355,14 +355,15 @@ validation reports invalid definitions, and game lookup refuses invalid room casino configurations. Raw file editing is not a validated save endpoint. - IDs must be nonempty, have no surrounding whitespace, and be unique across - the room's tables and machines (including legacy slots table entries). -- Supported table games are `blackjack` and `baccarat`; machines use `slots` + the room's tables and machines. +- Supported table games are `blackjack` and `baccarat`; slots are machines only + and must be declared under `casino_machines`. A `slots` entry under + `casino_tables` is rejected. Machines use `slots` with the optional variant `huff_and_even_more_puff`. A machine variant is registered in Go (`slotVariants` in `internal/casino/registry.go`); naming an unregistered variant is a validation error. - A multiplayer table is addressed by its game name (`play blackjack`), so a - room may host only one table per game; a legacy slots table still collides - with a slots machine. + room may host only one table per game. - A machine is addressed by its `variant` when one is declared, so a room may host several variants of one game, each reached by its own variant name. A bare game name addresses a machine only when it is the sole machine of that @@ -607,31 +608,33 @@ mobs: --- -### Room flag triggers +### Flag triggers -A room that needs to react when a flag changes uses the `on_flag_change:` / `on_global_flag_change:` -blocks (these replace the old `triggers:` block). Each entry watches one flag, runs a `steps` -sequence when that flag changes, and resolves broadcasts/spawns against the room. See +A trigger that reacts to a flag change is a **file-backed trigger**, not a room field. Put it +under `data/triggers/player_flag/` (watch a player flag with `on_player_flag:`) or +`data/triggers/global_flag/` (watch a global flag with `on_global_flag:`). Each file holds one +trigger, the filename stem is its id, and the trigger must set exactly one of those two keys — +a file with neither is a startup ERROR because it is loaded but can never fire. See `triggers.md` for the full reference. ```yaml -on_flag_change: - - on_player_flag: lever_pulled - condition: - room: 1001 # opt-in: only fires if the player is in this room - steps: - - wait: 5 - messages: ["The cabin shakes as the small craft touches down"] - - wait: 5 - messages: ["The pistons hiss as the rear staircase opens"] - - set_player_flags: - 1001_touchdown: true -``` - -Flag triggers listen **globally** — they fire regardless of where the flag is set. The old -room `triggers:` block was implicitly room-scoped; migration injects `condition: { room: - }` to preserve that. Delete the `room` condition to make the trigger fire wherever the -player is. For server-wide events use global trigger files in `data/triggers/` instead. +# data/triggers/player_flag/1001_touchdown.yaml +on_player_flag: lever_pulled +condition: + room: 1001 # opt-in: only fires if the player is in this room +steps: + - wait: 5 + messages: ["The cabin shakes as the small craft touches down"] + - wait: 5 + messages: ["The pistons hiss as the rear staircase opens"] + - set_player_flags: + 1001_touchdown: true +``` + +Flag triggers listen **globally** — they fire regardless of where the flag is set. Add +`condition: { room: }` to scope one to the room it belongs to; delete that condition to +let it fire wherever the player is. Room files support only `on_enter`, `on_exit`, and exit +`on_traverse` trigger blocks; there is no room-level `on_flag_change:` field. --- diff --git a/building_guide/triggers.md b/building_guide/triggers.md index c32830d..ba44c40 100644 --- a/building_guide/triggers.md +++ b/building_guide/triggers.md @@ -181,8 +181,8 @@ entity: | Object | `on_use`, `on_look` | | Mob | `on_kill` | | Exit (room YAML) | `on_traverse` | -| Room | `on_enter`, `on_exit` (NEW), `on_flag_change`, `on_global_flag_change` | -| Global file (`data/triggers/*.yaml`) | one Trigger per file (flag triggers) | +| Room | `on_enter`, `on_exit` (NEW) | +| Flag trigger file (`data/triggers/player_flag/`, `data/triggers/global_flag/`) | one Trigger per file (`on_player_flag` / `on_global_flag`) | - **on_use / on_look** — see `objects.md`. `item_id` filters which item triggers the entry. - **on_kill** — see `mobs.md`. `item_id` filters by wielded weapon. @@ -193,8 +193,7 @@ entity: - **on_exit** (NEW) — fires when a player leaves the room, **before** `RoomID` is updated to the destination. Broadcasts/spawn resolve against the old (departed) room. Mirror of on_enter, useful for parting messages, closing spawns, or recording departures. -- **on_flag_change** / **on_global_flag_change** — flag-watch triggers, replacing the old - room `triggers:` block. See Flag triggers below. +- **on_flag_change** / **on_global_flag_change** — flag-watch triggers are **file-backed**, one per file under `data/triggers/player_flag/` or `data/triggers/global_flag/`. They are not room fields. See Flag triggers below. ### Quick Start: Landing Sequence @@ -259,91 +258,99 @@ triggers watching that flag. ### Flag triggers -`on_flag_change` and `on_global_flag_change` replace the old room `triggers:` block. Each -entry carries the flag it watches (`on_player_flag` or `on_global_flag`), an optional -`condition` (use `player_flag`/`global_flag` + `value` to match a specific flag value), and -a `steps` list. +A flag trigger is a **standalone trigger file**, one per flag, under +`data/triggers/player_flag/` (set `on_player_flag:`) or `data/triggers/global_flag/` (set +`on_global_flag:`). The filename stem is the trigger id. Each file carries the flag it +watches, an optional `condition` (use `player_flag`/`global_flag` + `value` to match a +specific flag value), and a `steps` list. The block sits at the top level of the file — +there is no `on_flag_change:` wrapper and no room-file form. Key behaviors: - **Listen globally.** A flag-change trigger fires regardless of where the flag is set. - The event block lives in a room (for broadcasts/spawns to use that room) or in a global - file (see Global trigger files). + Add `condition: { room: }` when a broadcast/spawn should resolve against a specific + room, or use a global trigger file for server-wide events (see Global trigger files). - **Room scoping is opt-in.** Use `condition: { room: }` to restrict the trigger to - players currently in that room. Migration injects `{ room: }` into migrated - room triggers to preserve the old room-scoped behavior; authors can delete it to make the - trigger fire globally. + players currently in that room. - **Fires on the non-existent → existent transition.** Setting a flag that didn't exist before counts as a change and fires the trigger. Setting a flag to the same value it already has does **not** re-fire. - **Triggers activate on becoming truthy.** Setting `true → false` does **not** fire. ```yaml -# A room watches a player flag and plays a sequence for that player -on_flag_change: - - on_player_flag: lever_pulled - steps: - - broadcast: "The lever snaps back into place with a loud clunk." - - set_player_flags: - lever_pulled: false # reset so next pull re-fires +# data/triggers/player_flag/lever_pulled.yaml +on_player_flag: lever_pulled +steps: + - broadcast: "The lever snaps back into place with a loud clunk." + - set_player_flags: + lever_pulled: false # reset so next pull re-fires ``` -To migrate the old room `triggers:` block, replace `triggers:` with `on_flag_change:` (for -`on_player_flag`) or `on_global_flag_change:` (for `on_global_flag`) and add -`condition: { room: }` if you want the old room-scoped behavior. +A trigger file that sets neither `on_player_flag` nor `on_global_flag` is a startup ERROR: +it is loaded but indexed nowhere and can never fire. ### Value-matching flag triggers By default a flag trigger fires when the watched flag becomes truthy. Use a `condition` with a `player_flag`/`global_flag` + `value` to require a specific value — enabling multi-stage -quests off one numeric flag: +quests off one numeric flag. Because each file watches exactly one flag, a multi-stage flag +uses one file per stage: ```yaml -on_flag_change: - - on_player_flag: quest_stage - condition: - player_flag: quest_stage - value: 1 - steps: - - messages: ["Quest started — find the crystal shard."] - - on_player_flag: quest_stage - condition: - player_flag: quest_stage - value: 2 - steps: - - messages: ["You found the shard — return to the elder."] - - on_player_flag: quest_stage - condition: - player_flag: quest_stage - value: 3 - steps: - - messages: ["The ritual begins..."] - - wait: 10 - broadcast: "The temple hums with ancient power!" - - spawn_mob: crystal_guardian +# data/triggers/player_flag/quest_stage_1.yaml +on_player_flag: quest_stage +condition: + player_flag: quest_stage + value: 1 +steps: + - messages: ["Quest started — find the crystal shard."] +``` + +```yaml +# data/triggers/player_flag/quest_stage_2.yaml +on_player_flag: quest_stage +condition: + player_flag: quest_stage + value: 2 +steps: + - messages: ["You found the shard — return to the elder."] +``` + +```yaml +# data/triggers/global_flag/ritual.yaml +on_global_flag: ritual_stage +condition: + global_flag: ritual_stage + value: 3 +steps: + - messages: ["The ritual begins..."] + - wait: 10 + broadcast: "The temple hums with ancient power!" + - spawn_mob: crystal_guardian ``` ### Global flag trigger -A room can watch a global flag and fire once globally (not per-player). Everyone in the -room sees the broadcast: +A global-flag trigger file fires once globally (not per-player), and its broadcast is seen +by every connected player. (`broadcast` inside a player-flag trigger is seen by that player.) ```yaml -on_global_flag_change: - - on_global_flag: floodgate_open - steps: - - broadcast: "Ancient gears grind as the floodgate slowly opens..." - - wait: 15 - broadcast: "Water thunders through the opening!" - - set_global_flags: - valley_flooded: true +# data/triggers/global_flag/floodgate.yaml +on_global_flag: floodgate_open +steps: + - broadcast: "Ancient gears grind as the floodgate slowly opens..." + - wait: 15 + broadcast: "Water thunders through the opening!" + - set_global_flags: + valley_flooded: true ``` ### Global trigger files -Global triggers live one-per-file in `data/triggers/`. They fire regardless of where the -flag-setting player is. Use them for server-wide events. The filename stem is the trigger -ID (must be globally unique). One Trigger per file — the block fields sit at the top level: +Global triggers live one-per-file in `data/triggers/player_flag/` or +`data/triggers/global_flag/`. They fire regardless of where the flag-setting player is. Use +them for server-wide events. The filename stem is the trigger ID (must be globally unique). +One Trigger per file — the block fields sit at the top level: ```yaml # data/triggers/announce_99.yaml @@ -374,24 +381,23 @@ steps: ### Boss arena — puzzle unlocks a boss -Player activates an altar (on a separate object) setting a player flag, and this room's -`on_flag_change` triggers the boss spawn: +Player activates an altar (on a separate object) setting a player flag, and a flag trigger +file spawns the boss: ```yaml -# data/rooms/dungeon/9001.yaml -on_flag_change: - - on_player_flag: activated_altar - steps: - - broadcast: "The altar glows with an eerie light..." - - wait: 10 - broadcast: "The ground trembles beneath your feet." - - wait: 20 - broadcast: "A massive guardian emerges from the shadows!" - - spawn_mob: - id: altar_guardian - owner_only: true - despawn_on_leave: true - despawn_rooms: [450, 451, 452] +# data/triggers/player_flag/activated_altar.yaml +on_player_flag: activated_altar +steps: + - broadcast: "The altar glows with an eerie light..." + - wait: 10 + broadcast: "The ground trembles beneath your feet." + - wait: 20 + broadcast: "A massive guardian emerges from the shadows!" + - spawn_mob: + id: altar_guardian + owner_only: true + despawn_on_leave: true + despawn_rooms: [450, 451, 452] ``` The boss is `owner_only` — only the player who triggered it can interact with it. It @@ -400,23 +406,23 @@ When the owner leaves those rooms, the boss begins despawning. ### Story beat — timed cutscene after NPC conversation -An NPC conversation node sets `quest_ritual: started`. A room trigger plays a dramatic -sequence for that player: +An NPC conversation node sets `quest_ritual: started`. A flag trigger file plays a +dramatic sequence for that player: ```yaml -on_flag_change: - - on_player_flag: quest_ritual - steps: - - wait: 8 - messages: ["The elder begins to chant in a language you don't recognize."] - - wait: 6 - messages: ["Wisps of light swirl around the altar."] - - wait: 4 - messages: ["The ground beneath you shudders as the ritual reaches its peak."] - - wait: 6 - broadcast: "A blinding flash fills the chamber!" - - teleport: 601 - messages: ["You open your eyes. You're somewhere else entirely."] +# data/triggers/player_flag/quest_ritual.yaml +on_player_flag: quest_ritual +steps: + - wait: 8 + messages: ["The elder begins to chant in a language you don't recognize."] + - wait: 6 + messages: ["Wisps of light swirl around the altar."] + - wait: 4 + messages: ["The ground beneath you shudders as the ritual reaches its peak."] + - wait: 6 + broadcast: "A blinding flash fills the chamber!" + - teleport: 601 + messages: ["You open your eyes. You're somewhere else entirely."] ``` The final step uses `teleport` and `messages` (the triggering player is moved and sees a @@ -430,19 +436,22 @@ When a step sets a flag, any triggers watching that flag fire immediately (on th tick). This lets you chain sequences: ```yaml -on_flag_change: - - on_player_flag: phase_1_done - steps: - - broadcast: "The first seal cracks." - - set_player_flags: - phase_2_started: true # triggers the next trigger +# data/triggers/player_flag/phase_1_done.yaml +on_player_flag: phase_1_done +steps: + - broadcast: "The first seal cracks." + - set_player_flags: + phase_2_started: true # triggers the next trigger +``` - - on_player_flag: phase_2_started - steps: - - wait: 10 - broadcast: "The second seal glows brighter..." - - wait: 10 - spawn_mob: phase_2_adds +```yaml +# data/triggers/player_flag/phase_2_started.yaml +on_player_flag: phase_2_started +steps: + - wait: 10 + broadcast: "The second seal glows brighter..." + - wait: 10 + spawn_mob: phase_2_adds ``` Both sequences run concurrently — phase 2's wait countdown starts on the same tick phase @@ -506,13 +515,13 @@ spawn_mob: Use `despawn_mob` in a step to remove previously spawned mobs: ```yaml -on_flag_change: - - on_player_flag: puzzle_solved - steps: - - despawn_mob: puzzle_guardian # remove the puzzle mob - - wait: 10 - broadcast: "The guardian dissolves into mist." - - spawn_mob: boss_guardian # spawn the real boss +# data/triggers/player_flag/puzzle_solved.yaml +on_player_flag: puzzle_solved +steps: + - despawn_mob: puzzle_guardian # remove the puzzle mob + - wait: 10 + broadcast: "The guardian dissolves into mist." + - spawn_mob: boss_guardian # spawn the real boss ``` `despawn_mob` with an owner only removes mobs spawned by that player. Without an owner (in @@ -613,59 +622,56 @@ on_look: **Step 2: Blood trail in room 5002** — flag trigger fires on value 1 ```yaml -# data/rooms/city/5002.yaml -on_flag_change: - - on_player_flag: investigation - condition: - all_of: - - player_flag: investigation - value: 1 - - room: 5002 - steps: - - wait: 3 - messages: ["You notice a trail of blood leading east..."] - - set_player_flags: - investigation: 2 +# data/triggers/player_flag/investigation_1.yaml +on_player_flag: investigation +condition: + all_of: + - player_flag: investigation + value: 1 + - room: 5002 +steps: + - wait: 3 + messages: ["You notice a trail of blood leading east..."] + - set_player_flags: + investigation: 2 ``` **Step 3: Broken dagger in room 5003** — flag trigger fires on value 2 ```yaml -# data/rooms/city/5003.yaml -on_flag_change: - - on_player_flag: investigation - condition: - all_of: - - player_flag: investigation - value: 2 - - room: 5003 - steps: - - wait: 3 - messages: ["A glint of metal catches your eye under a crate."] +# data/triggers/player_flag/investigation_2.yaml +on_player_flag: investigation +condition: + all_of: + - player_flag: investigation + value: 2 + - room: 5003 +steps: + - wait: 3 + messages: ["A glint of metal catches your eye under a crate."] ``` **Step 4: Confrontation** — flag trigger fires on value 3 (set by a separate talk node), spawns boss ```yaml -# data/rooms/city/5004.yaml -on_flag_change: - - on_player_flag: investigation - condition: - all_of: - - player_flag: investigation - value: 3 - - room: 5004 - steps: - - wait: 5 - broadcast: "A shadow detaches itself from the warehouse wall..." - - wait: 5 - broadcast: "The assassin steps into the light, blade drawn." - - spawn_mob: - id: assassin_boss - owner_only: true - despawn_on_leave: true - despawn_rooms: [5004] +# data/triggers/player_flag/investigation_3.yaml +on_player_flag: investigation +condition: + all_of: + - player_flag: investigation + value: 3 + - room: 5004 +steps: + - wait: 5 + broadcast: "A shadow detaches itself from the warehouse wall..." + - wait: 5 + broadcast: "The assassin steps into the light, blade drawn." + - spawn_mob: + id: assassin_boss + owner_only: true + despawn_on_leave: true + despawn_rooms: [5004] ``` The player must stay in room 5004 to fight the boss. If they flee, the boss despawns and diff --git a/data/help/agility.yaml b/data/help/agility.yaml index f28c3c0..56aba22 100644 --- a/data/help/agility.yaml +++ b/data/help/agility.yaml @@ -1,5 +1,5 @@ -title: "Agility" -body: | +name: "Agility" +description: | Agility is trained by completing obstacle courses. Each course is a sequence of rooms with obstacles that you traverse using special commands. diff --git a/data/help/hacking.yaml b/data/help/hacking.yaml index 67803cc..56290fb 100644 --- a/data/help/hacking.yaml +++ b/data/help/hacking.yaml @@ -1,5 +1,5 @@ -title: "Hacking" -body: | +name: "Hacking" +description: | Hacking is trained by jacking into terminals and completing their minigames. Different terminals offer different challenges at different difficulty levels. diff --git a/data/help/jack.yaml b/data/help/jack.yaml index 11e9f75..8ffba07 100644 --- a/data/help/jack.yaml +++ b/data/help/jack.yaml @@ -1,5 +1,5 @@ -title: "Jack" -body: | +name: "Jack" +description: | Jack into a terminal to start a hacking minigame. Usage: jack [terminal] 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 ") 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__), 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: