aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore6
-rw-r--r--building_guide/construction.md6
-rw-r--r--building_guide/courses.md12
-rw-r--r--building_guide/quests.md21
-rw-r--r--building_guide/rooms.md37
-rw-r--r--building_guide/triggers.md8
-rwxr-xr-xcardsbin0 -> 11187266 bytes
-rw-r--r--data/cards.yaml936
-rw-r--r--internal/admin/api_colors.go7
-rw-r--r--internal/admin/api_entity_path_test.go21
-rw-r--r--internal/admin/api_files.go9
-rw-r--r--internal/admin/api_modules.go9
-rw-r--r--internal/admin/api_room_courses.go9
-rw-r--r--internal/admin/api_room_insert_remove.go3
-rw-r--r--internal/admin/api_rooms.go2
-rw-r--r--internal/admin/api_rooms_bulk_delete.go5
-rw-r--r--internal/admin/api_techs.go11
-rw-r--r--internal/admin/api_triggers.go3
-rw-r--r--internal/admin/server.go5
-rw-r--r--internal/admin/undo.go134
-rw-r--r--internal/admin/yaml_util.go21
-rw-r--r--internal/casino/baccarat.go45
-rw-r--r--internal/casino/baccarat_test.go8
-rw-r--r--internal/casino/blackjack.go95
-rw-r--r--internal/casino/blackjack_test.go8
-rw-r--r--internal/casino/core.go34
-rw-r--r--internal/casino/machine.go4
-rw-r--r--internal/casino/machine_test.go2
-rw-r--r--internal/casino/table.go9
-rw-r--r--internal/casino/types.go58
-rw-r--r--internal/game/act_clean.go2
-rw-r--r--internal/game/act_farm.go4
-rw-r--r--internal/game/act_gather.go2
-rw-r--r--internal/game/act_identify.go6
-rw-r--r--internal/game/act_steal.go2
-rw-r--r--internal/game/casino.go135
-rw-r--r--internal/game/casino_test.go10
-rw-r--r--internal/game/cmd_bank.go4
-rw-r--r--internal/game/cmd_dig.go26
-rw-r--r--internal/game/cmd_dig_test.go57
-rw-r--r--internal/game/cmd_drop.go5
-rw-r--r--internal/game/cmd_estate_directory.go11
-rw-r--r--internal/game/cmd_get.go8
-rw-r--r--internal/game/cmd_inventory.go3
-rw-r--r--internal/game/cmd_remove.go4
-rw-r--r--internal/game/cmd_room_insert.go2
-rw-r--r--internal/game/cmd_shop.go4
-rw-r--r--internal/game/cmd_swapid.go19
-rw-r--r--internal/game/cmd_trigger_combat.go2
-rw-r--r--internal/game/cmd_trigger_utility.go4
-rw-r--r--internal/game/cmd_undig.go2
-rw-r--r--internal/game/cmd_wear.go4
-rw-r--r--internal/game/content_refs.go18
-rw-r--r--internal/game/content_refs_test.go21
-rw-r--r--internal/game/core_login_account.go5
-rw-r--r--internal/game/core_login_char.go13
-rw-r--r--internal/game/core_production.go2
-rw-r--r--internal/game/game.go4
-rw-r--r--internal/game/look_target.go2
-rw-r--r--internal/game/production_engine.go2
-rw-r--r--internal/game/production_index.go4
-rw-r--r--internal/game/sys_triggers.go7
-rw-r--r--internal/net/remote_host_test.go34
-rw-r--r--internal/net/server.go8
-rw-r--r--internal/validate/grid_test.go2
-rw-r--r--internal/world/grid_test.go2
-rw-r--r--internal/world/mob.go25
-rw-r--r--internal/world/mob_rekey_test.go23
-rw-r--r--internal/world/objects.go7
-rw-r--r--internal/world/room_migrate.go42
70 files changed, 1641 insertions, 424 deletions
diff --git a/.gitignore b/.gitignore
index 0b8ca97..bf7e510 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,8 +7,10 @@
# Admin undo history (password hashes are redacted, but never commit it).
/data/.admin_history.json
/data/.admin_history.json.bak
-thoi
-mud
+# Built binaries live at the repo root. Anchor to the root so the pattern
+# does not also ignore the cmd/thoi/ source directory (and any new file in it).
+/thoi
+/mud
.cache/
.npm/
.local/
diff --git a/building_guide/construction.md b/building_guide/construction.md
index fef4a7b..07b8f7f 100644
--- a/building_guide/construction.md
+++ b/building_guide/construction.md
@@ -199,8 +199,10 @@ House room files follow the same format as world rooms:
name: "Player House: Entrance Hall"
description: "A modest but welcoming entrance hall."
exits:
- south: 170
- north: 201
+ south:
+ room: 170
+ north:
+ room: 201
objects:
- id: workbench
```
diff --git a/building_guide/courses.md b/building_guide/courses.md
index 8a79598..3f571e0 100644
--- a/building_guide/courses.md
+++ b/building_guide/courses.md
@@ -104,10 +104,14 @@ The hub room (e.g. room 200) links to the first obstacle of each course:
```yaml
name: "Agility Training Grounds"
exits:
- south: 17
- north: 201 # Vent Shaft (level 1)
- east: 210 # Rooftop (level 20)
- west: 220 # Reactor (level 50)
+ south:
+ room: 17
+ north:
+ room: 201 # Vent Shaft (level 1)
+ east:
+ room: 210 # Rooftop (level 20)
+ west:
+ room: 220 # Reactor (level 50)
```
### Supported Obstacle Verbs
diff --git a/building_guide/quests.md b/building_guide/quests.md
index 2c0cbac..060a969 100644
--- a/building_guide/quests.md
+++ b/building_guide/quests.md
@@ -70,13 +70,14 @@ drops:
name: "Cellar"
description: "A damp, dark cellar. The floor scuttles with movement."
exits:
- east: 1
+ east:
+ room: 1
mobs:
- - "rat"
- - "rat"
- - "rat"
- - "rat"
- - "rat"
+ - id: rat
+ - id: rat
+ - id: rat
+ - id: rat
+ - id: rat
```
### 4. Room 1 with quest giver (`data/rooms/1.yaml`)
@@ -84,10 +85,12 @@ mobs:
name: "Town Square"
description: "Cobblestone paths lead in all directions. The Elder stands near the fountain."
exits:
- west: 20
- north: 2
+ west:
+ room: 20
+ north:
+ room: 2
mobs:
- - "quest_giver"
+ - id: quest_giver
objects:
- id: lumby_fountain
```
diff --git a/building_guide/rooms.md b/building_guide/rooms.md
index 3b9c120..c70b379 100644
--- a/building_guide/rooms.md
+++ b/building_guide/rooms.md
@@ -10,11 +10,18 @@ Minimal room:
name: "Town Square"
description: "Cobblestone paths lead in all directions. A fountain gurgles peacefully."
exits:
- north: 2
- west: 7
- east: 3
+ north:
+ room: 2
+ west:
+ room: 7
+ east:
+ room: 3
```
+Every exit is a mapping with a `room:` target. The scalar shorthand
+(`north: 2`) is **not** accepted by the loader: it fails strict YAML decoding
+and the room is reported as a load ERROR.
+
### Map color
A room may set a `color` that tints its node on the map and the links connecting it:
@@ -53,9 +60,12 @@ Always passable, any direction:
```yaml
exits:
- north: 2
- ne: 3
- southeast: 4
+ north:
+ room: 2
+ ne:
+ room: 3
+ southeast:
+ room: 4
```
#### Conditional exits
@@ -200,8 +210,8 @@ Simple placement (no wandering):
```yaml
mobs:
- - "newbie_trainer"
- - "man"
+ - id: newbie_trainer
+ - id: man
```
With wander config per-instance:
@@ -574,11 +584,12 @@ the room every few ticks using combat formulas.
name: "Exposed Solar Array"
hazard: solar_radiation # ID of data/hazards/solar_radiation.yaml
exits:
- south: 99999180
+ south:
+ room: 99999180
objects:
- id: rock_outcrop # safespot object shields players from the hazard
mobs:
- - solar_panel_frame # task worksite (see mobs.md)
+ - id: solar_panel_frame # task worksite (see mobs.md)
```
- Players get a `[Y/n]` confirmation when walking from a safe room into a hazardous one
@@ -732,7 +743,8 @@ on_use:
```yaml
name: "Hidden Passage"
exits:
- south: 2
+ south:
+ room: 2
north:
room: 8
condition:
@@ -762,7 +774,8 @@ stackable: false
```yaml
name: "Locked Storage"
exits:
- west: 2
+ west:
+ room: 2
east:
room: 6
condition:
diff --git a/building_guide/triggers.md b/building_guide/triggers.md
index 05fb639..c32830d 100644
--- a/building_guide/triggers.md
+++ b/building_guide/triggers.md
@@ -378,7 +378,7 @@ Player activates an altar (on a separate object) setting a player flag, and this
`on_flag_change` triggers the boss spawn:
```yaml
-# data/rooms/dungeon/boss_chamber.yaml
+# data/rooms/dungeon/9001.yaml
on_flag_change:
- on_player_flag: activated_altar
steps:
@@ -613,7 +613,7 @@ on_look:
**Step 2: Blood trail in room 5002** — flag trigger fires on value 1
```yaml
-# data/rooms/city/5002_alley.yaml
+# data/rooms/city/5002.yaml
on_flag_change:
- on_player_flag: investigation
condition:
@@ -631,7 +631,7 @@ on_flag_change:
**Step 3: Broken dagger in room 5003** — flag trigger fires on value 2
```yaml
-# data/rooms/city/5003_warehouse.yaml
+# data/rooms/city/5003.yaml
on_flag_change:
- on_player_flag: investigation
condition:
@@ -648,7 +648,7 @@ on_flag_change:
spawns boss
```yaml
-# data/rooms/city/5004_docks.yaml
+# data/rooms/city/5004.yaml
on_flag_change:
- on_player_flag: investigation
condition:
diff --git a/cards b/cards
new file mode 100755
index 0000000..22d34e6
--- /dev/null
+++ b/cards
Binary files differ
diff --git a/data/cards.yaml b/data/cards.yaml
index 2b4762a..543d8de 100644
--- a/data/cards.yaml
+++ b/data/cards.yaml
@@ -1,7 +1,9 @@
# Unicode casino card art, shared by every card game (blackjack, baccarat).
-# Each card may define 5, 7, and 9 line art.
-# Keys are rank plus suit: A/K/Q/J/10/2-9 followed by C/D/H/S.
-# Missing art uses the generated Unicode card renderer.
+# Each card defines 7 and 9 line art (5 line cards use the generated renderer:
+# there is no room for a pip layout, so crowded ranks fall back to a single
+# centered suit symbol). Keys are rank plus suit: A/K/Q/J/10/2-9 + C/D/H/S.
+# Aces and the 10 at 7 lines are left to the generated renderer too, which
+# shows the rank corners around one centered suit symbol.
# Example override:
# AH:
# 5: ["┌───┐", "│ A │", "│ ♥ │", "│ A │", "└───┘"]
@@ -10,51 +12,883 @@ cards:
AD: {}
AH: {}
AS: {}
- 2C: {}
- 2D: {}
- 2H: {}
- 2S: {}
- 3C: {}
- 3D: {}
- 3H: {}
- 3S: {}
- 4C: {}
- 4D: {}
- 4H: {}
- 4S: {}
- 5C: {}
- 5D: {}
- 5H: {}
- 5S: {}
- 6C: {}
- 6D: {}
- 6H: {}
- 6S: {}
- 7C: {}
- 7D: {}
- 7H: {}
- 7S: {}
- 8C: {}
- 8D: {}
- 8H: {}
- 8S: {}
- 9C: {}
- 9D: {}
- 9H: {}
- 9S: {}
- 10C: {}
- 10D: {}
- 10H: {}
- 10S: {}
- JC: {}
- JD: {}
- JH: {}
- JS: {}
- QC: {}
- QD: {}
- QH: {}
- QS: {}
- KC: {}
- KD: {}
- KH: {}
- KS: {}
+ 2C:
+ 7:
+ - '┌─────┐'
+ - '│2 │'
+ - '│ ♣ │'
+ - '│ │'
+ - '│ ♣ │'
+ - '│ 2│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│2 │'
+ - '│ ♣ │'
+ - '│ │'
+ - '│ │'
+ - '│ │'
+ - '│ ♣ │'
+ - '│ 2│'
+ - '└───────┘'
+ 2D:
+ 7:
+ - '┌─────┐'
+ - '│2 │'
+ - '│ ♦ │'
+ - '│ │'
+ - '│ ♦ │'
+ - '│ 2│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│2 │'
+ - '│ ♦ │'
+ - '│ │'
+ - '│ │'
+ - '│ │'
+ - '│ ♦ │'
+ - '│ 2│'
+ - '└───────┘'
+ 2H:
+ 7:
+ - '┌─────┐'
+ - '│2 │'
+ - '│ ♥ │'
+ - '│ │'
+ - '│ ♥ │'
+ - '│ 2│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│2 │'
+ - '│ ♥ │'
+ - '│ │'
+ - '│ │'
+ - '│ │'
+ - '│ ♥ │'
+ - '│ 2│'
+ - '└───────┘'
+ 2S:
+ 7:
+ - '┌─────┐'
+ - '│2 │'
+ - '│ ♠ │'
+ - '│ │'
+ - '│ ♠ │'
+ - '│ 2│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│2 │'
+ - '│ ♠ │'
+ - '│ │'
+ - '│ │'
+ - '│ │'
+ - '│ ♠ │'
+ - '│ 2│'
+ - '└───────┘'
+ 3C:
+ 7:
+ - '┌─────┐'
+ - '│3 │'
+ - '│ ♣ │'
+ - '│ ♣ │'
+ - '│ ♣ │'
+ - '│ 3│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│3 │'
+ - '│ ♣ │'
+ - '│ │'
+ - '│ ♣ │'
+ - '│ │'
+ - '│ ♣ │'
+ - '│ 3│'
+ - '└───────┘'
+ 3D:
+ 7:
+ - '┌─────┐'
+ - '│3 │'
+ - '│ ♦ │'
+ - '│ ♦ │'
+ - '│ ♦ │'
+ - '│ 3│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│3 │'
+ - '│ ♦ │'
+ - '│ │'
+ - '│ ♦ │'
+ - '│ │'
+ - '│ ♦ │'
+ - '│ 3│'
+ - '└───────┘'
+ 3H:
+ 7:
+ - '┌─────┐'
+ - '│3 │'
+ - '│ ♥ │'
+ - '│ ♥ │'
+ - '│ ♥ │'
+ - '│ 3│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│3 │'
+ - '│ ♥ │'
+ - '│ │'
+ - '│ ♥ │'
+ - '│ │'
+ - '│ ♥ │'
+ - '│ 3│'
+ - '└───────┘'
+ 3S:
+ 7:
+ - '┌─────┐'
+ - '│3 │'
+ - '│ ♠ │'
+ - '│ ♠ │'
+ - '│ ♠ │'
+ - '│ 3│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│3 │'
+ - '│ ♠ │'
+ - '│ │'
+ - '│ ♠ │'
+ - '│ │'
+ - '│ ♠ │'
+ - '│ 3│'
+ - '└───────┘'
+ 4C:
+ 7:
+ - '┌─────┐'
+ - '│4 │'
+ - '│♣ ♣│'
+ - '│ │'
+ - '│♣ ♣│'
+ - '│ 4│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│4 │'
+ - '│ ♣ ♣ │'
+ - '│ │'
+ - '│ │'
+ - '│ │'
+ - '│ ♣ ♣ │'
+ - '│ 4│'
+ - '└───────┘'
+ 4D:
+ 7:
+ - '┌─────┐'
+ - '│4 │'
+ - '│♦ ♦│'
+ - '│ │'
+ - '│♦ ♦│'
+ - '│ 4│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│4 │'
+ - '│ ♦ ♦ │'
+ - '│ │'
+ - '│ │'
+ - '│ │'
+ - '│ ♦ ♦ │'
+ - '│ 4│'
+ - '└───────┘'
+ 4H:
+ 7:
+ - '┌─────┐'
+ - '│4 │'
+ - '│♥ ♥│'
+ - '│ │'
+ - '│♥ ♥│'
+ - '│ 4│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│4 │'
+ - '│ ♥ ♥ │'
+ - '│ │'
+ - '│ │'
+ - '│ │'
+ - '│ ♥ ♥ │'
+ - '│ 4│'
+ - '└───────┘'
+ 4S:
+ 7:
+ - '┌─────┐'
+ - '│4 │'
+ - '│♠ ♠│'
+ - '│ │'
+ - '│♠ ♠│'
+ - '│ 4│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│4 │'
+ - '│ ♠ ♠ │'
+ - '│ │'
+ - '│ │'
+ - '│ │'
+ - '│ ♠ ♠ │'
+ - '│ 4│'
+ - '└───────┘'
+ 5C:
+ 7:
+ - '┌─────┐'
+ - '│5 │'
+ - '│♣ ♣│'
+ - '│ ♣ │'
+ - '│♣ ♣│'
+ - '│ 5│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│5 │'
+ - '│ ♣ ♣ │'
+ - '│ │'
+ - '│ ♣ │'
+ - '│ │'
+ - '│ ♣ ♣ │'
+ - '│ 5│'
+ - '└───────┘'
+ 5D:
+ 7:
+ - '┌─────┐'
+ - '│5 │'
+ - '│♦ ♦│'
+ - '│ ♦ │'
+ - '│♦ ♦│'
+ - '│ 5│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│5 │'
+ - '│ ♦ ♦ │'
+ - '│ │'
+ - '│ ♦ │'
+ - '│ │'
+ - '│ ♦ ♦ │'
+ - '│ 5│'
+ - '└───────┘'
+ 5H:
+ 7:
+ - '┌─────┐'
+ - '│5 │'
+ - '│♥ ♥│'
+ - '│ ♥ │'
+ - '│♥ ♥│'
+ - '│ 5│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│5 │'
+ - '│ ♥ ♥ │'
+ - '│ │'
+ - '│ ♥ │'
+ - '│ │'
+ - '│ ♥ ♥ │'
+ - '│ 5│'
+ - '└───────┘'
+ 5S:
+ 7:
+ - '┌─────┐'
+ - '│5 │'
+ - '│♠ ♠│'
+ - '│ ♠ │'
+ - '│♠ ♠│'
+ - '│ 5│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│5 │'
+ - '│ ♠ ♠ │'
+ - '│ │'
+ - '│ ♠ │'
+ - '│ │'
+ - '│ ♠ ♠ │'
+ - '│ 5│'
+ - '└───────┘'
+ 6C:
+ 7:
+ - '┌─────┐'
+ - '│6 │'
+ - '│♣ ♣│'
+ - '│♣ ♣│'
+ - '│♣ ♣│'
+ - '│ 6│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│6 │'
+ - '│ ♣ ♣ │'
+ - '│ │'
+ - '│ ♣ ♣ │'
+ - '│ │'
+ - '│ ♣ ♣ │'
+ - '│ 6│'
+ - '└───────┘'
+ 6D:
+ 7:
+ - '┌─────┐'
+ - '│6 │'
+ - '│♦ ♦│'
+ - '│♦ ♦│'
+ - '│♦ ♦│'
+ - '│ 6│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│6 │'
+ - '│ ♦ ♦ │'
+ - '│ │'
+ - '│ ♦ ♦ │'
+ - '│ │'
+ - '│ ♦ ♦ │'
+ - '│ 6│'
+ - '└───────┘'
+ 6H:
+ 7:
+ - '┌─────┐'
+ - '│6 │'
+ - '│♥ ♥│'
+ - '│♥ ♥│'
+ - '│♥ ♥│'
+ - '│ 6│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│6 │'
+ - '│ ♥ ♥ │'
+ - '│ │'
+ - '│ ♥ ♥ │'
+ - '│ │'
+ - '│ ♥ ♥ │'
+ - '│ 6│'
+ - '└───────┘'
+ 6S:
+ 7:
+ - '┌─────┐'
+ - '│6 │'
+ - '│♠ ♠│'
+ - '│♠ ♠│'
+ - '│♠ ♠│'
+ - '│ 6│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│6 │'
+ - '│ ♠ ♠ │'
+ - '│ │'
+ - '│ ♠ ♠ │'
+ - '│ │'
+ - '│ ♠ ♠ │'
+ - '│ 6│'
+ - '└───────┘'
+ 7C:
+ 7:
+ - '┌─────┐'
+ - '│7 │'
+ - '│♣ ♣│'
+ - '│♣ ♣ ♣│'
+ - '│♣ ♣│'
+ - '│ 7│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│7 │'
+ - '│ ♣ ♣ │'
+ - '│ ♣ │'
+ - '│ ♣ ♣ │'
+ - '│ │'
+ - '│ ♣ ♣ │'
+ - '│ 7│'
+ - '└───────┘'
+ 7D:
+ 7:
+ - '┌─────┐'
+ - '│7 │'
+ - '│♦ ♦│'
+ - '│♦ ♦ ♦│'
+ - '│♦ ♦│'
+ - '│ 7│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│7 │'
+ - '│ ♦ ♦ │'
+ - '│ ♦ │'
+ - '│ ♦ ♦ │'
+ - '│ │'
+ - '│ ♦ ♦ │'
+ - '│ 7│'
+ - '└───────┘'
+ 7H:
+ 7:
+ - '┌─────┐'
+ - '│7 │'
+ - '│♥ ♥│'
+ - '│♥ ♥ ♥│'
+ - '│♥ ♥│'
+ - '│ 7│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│7 │'
+ - '│ ♥ ♥ │'
+ - '│ ♥ │'
+ - '│ ♥ ♥ │'
+ - '│ │'
+ - '│ ♥ ♥ │'
+ - '│ 7│'
+ - '└───────┘'
+ 7S:
+ 7:
+ - '┌─────┐'
+ - '│7 │'
+ - '│♠ ♠│'
+ - '│♠ ♠ ♠│'
+ - '│♠ ♠│'
+ - '│ 7│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│7 │'
+ - '│ ♠ ♠ │'
+ - '│ ♠ │'
+ - '│ ♠ ♠ │'
+ - '│ │'
+ - '│ ♠ ♠ │'
+ - '│ 7│'
+ - '└───────┘'
+ 8C:
+ 7:
+ - '┌─────┐'
+ - '│8 │'
+ - '│♣ ♣ ♣│'
+ - '│♣ ♣ ♣│'
+ - '│♣ ♣│'
+ - '│ 8│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│8 │'
+ - '│ ♣ ♣ │'
+ - '│ ♣ │'
+ - '│ ♣ ♣ │'
+ - '│ ♣ │'
+ - '│ ♣ ♣ │'
+ - '│ 8│'
+ - '└───────┘'
+ 8D:
+ 7:
+ - '┌─────┐'
+ - '│8 │'
+ - '│♦ ♦ ♦│'
+ - '│♦ ♦ ♦│'
+ - '│♦ ♦│'
+ - '│ 8│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│8 │'
+ - '│ ♦ ♦ │'
+ - '│ ♦ │'
+ - '│ ♦ ♦ │'
+ - '│ ♦ │'
+ - '│ ♦ ♦ │'
+ - '│ 8│'
+ - '└───────┘'
+ 8H:
+ 7:
+ - '┌─────┐'
+ - '│8 │'
+ - '│♥ ♥ ♥│'
+ - '│♥ ♥ ♥│'
+ - '│♥ ♥│'
+ - '│ 8│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│8 │'
+ - '│ ♥ ♥ │'
+ - '│ ♥ │'
+ - '│ ♥ ♥ │'
+ - '│ ♥ │'
+ - '│ ♥ ♥ │'
+ - '│ 8│'
+ - '└───────┘'
+ 8S:
+ 7:
+ - '┌─────┐'
+ - '│8 │'
+ - '│♠ ♠ ♠│'
+ - '│♠ ♠ ♠│'
+ - '│♠ ♠│'
+ - '│ 8│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│8 │'
+ - '│ ♠ ♠ │'
+ - '│ ♠ │'
+ - '│ ♠ ♠ │'
+ - '│ ♠ │'
+ - '│ ♠ ♠ │'
+ - '│ 8│'
+ - '└───────┘'
+ 9C:
+ 7:
+ - '┌─────┐'
+ - '│9 │'
+ - '│♣ ♣ ♣│'
+ - '│♣ ♣ ♣│'
+ - '│♣ ♣ ♣│'
+ - '│ 9│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│9 │'
+ - '│ ♣ ♣ │'
+ - '│ ♣ ♣ │'
+ - '│ ♣ │'
+ - '│ ♣ ♣ │'
+ - '│ ♣ ♣ │'
+ - '│ 9│'
+ - '└───────┘'
+ 9D:
+ 7:
+ - '┌─────┐'
+ - '│9 │'
+ - '│♦ ♦ ♦│'
+ - '│♦ ♦ ♦│'
+ - '│♦ ♦ ♦│'
+ - '│ 9│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│9 │'
+ - '│ ♦ ♦ │'
+ - '│ ♦ ♦ │'
+ - '│ ♦ │'
+ - '│ ♦ ♦ │'
+ - '│ ♦ ♦ │'
+ - '│ 9│'
+ - '└───────┘'
+ 9H:
+ 7:
+ - '┌─────┐'
+ - '│9 │'
+ - '│♥ ♥ ♥│'
+ - '│♥ ♥ ♥│'
+ - '│♥ ♥ ♥│'
+ - '│ 9│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│9 │'
+ - '│ ♥ ♥ │'
+ - '│ ♥ ♥ │'
+ - '│ ♥ │'
+ - '│ ♥ ♥ │'
+ - '│ ♥ ♥ │'
+ - '│ 9│'
+ - '└───────┘'
+ 9S:
+ 7:
+ - '┌─────┐'
+ - '│9 │'
+ - '│♠ ♠ ♠│'
+ - '│♠ ♠ ♠│'
+ - '│♠ ♠ ♠│'
+ - '│ 9│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│9 │'
+ - '│ ♠ ♠ │'
+ - '│ ♠ ♠ │'
+ - '│ ♠ │'
+ - '│ ♠ ♠ │'
+ - '│ ♠ ♠ │'
+ - '│ 9│'
+ - '└───────┘'
+ 10C:
+ 9:
+ - '┌───────┐'
+ - '│10 │'
+ - '│ ♣ ♣ │'
+ - '│ ♣ ♣ ♣ │'
+ - '│ ♣ │'
+ - '│ ♣ ♣ │'
+ - '│ ♣ ♣ │'
+ - '│ 10│'
+ - '└───────┘'
+ 10D:
+ 9:
+ - '┌───────┐'
+ - '│10 │'
+ - '│ ♦ ♦ │'
+ - '│ ♦ ♦ ♦ │'
+ - '│ ♦ │'
+ - '│ ♦ ♦ │'
+ - '│ ♦ ♦ │'
+ - '│ 10│'
+ - '└───────┘'
+ 10H:
+ 9:
+ - '┌───────┐'
+ - '│10 │'
+ - '│ ♥ ♥ │'
+ - '│ ♥ ♥ ♥ │'
+ - '│ ♥ │'
+ - '│ ♥ ♥ │'
+ - '│ ♥ ♥ │'
+ - '│ 10│'
+ - '└───────┘'
+ 10S:
+ 9:
+ - '┌───────┐'
+ - '│10 │'
+ - '│ ♠ ♠ │'
+ - '│ ♠ ♠ ♠ │'
+ - '│ ♠ │'
+ - '│ ♠ ♠ │'
+ - '│ ♠ ♠ │'
+ - '│ 10│'
+ - '└───────┘'
+ JC:
+ 7:
+ - '┌─────┐'
+ - '│J │'
+ - '│ \^/ │'
+ - '│(o o)│'
+ - '│ |~| │'
+ - '│ J│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│J │'
+ - '│ │'
+ - '│ \^/ │'
+ - '│ (o o) │'
+ - '│ [~] │'
+ - '│ │'
+ - '│ J│'
+ - '└───────┘'
+ JD:
+ 7:
+ - '┌─────┐'
+ - '│J │'
+ - '│ \^/ │'
+ - '│(o o)│'
+ - '│ |~| │'
+ - '│ J│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│J │'
+ - '│ │'
+ - '│ \^/ │'
+ - '│ (o o) │'
+ - '│ [~] │'
+ - '│ │'
+ - '│ J│'
+ - '└───────┘'
+ JH:
+ 7:
+ - '┌─────┐'
+ - '│J │'
+ - '│ \^/ │'
+ - '│(o o)│'
+ - '│ |~| │'
+ - '│ J│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│J │'
+ - '│ │'
+ - '│ \^/ │'
+ - '│ (o o) │'
+ - '│ [~] │'
+ - '│ │'
+ - '│ J│'
+ - '└───────┘'
+ JS:
+ 7:
+ - '┌─────┐'
+ - '│J │'
+ - '│ \^/ │'
+ - '│(o o)│'
+ - '│ |~| │'
+ - '│ J│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│J │'
+ - '│ │'
+ - '│ \^/ │'
+ - '│ (o o) │'
+ - '│ [~] │'
+ - '│ │'
+ - '│ J│'
+ - '└───────┘'
+ QC:
+ 7:
+ - '┌─────┐'
+ - '│Q │'
+ - '│ ^^^ │'
+ - '│(o o)│'
+ - '│ \ / │'
+ - '│ Q│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│Q │'
+ - '│ │'
+ - '│ \^^^/ │'
+ - '│ (o o) │'
+ - '│ \_/ │'
+ - '│ │'
+ - '│ Q│'
+ - '└───────┘'
+ QD:
+ 7:
+ - '┌─────┐'
+ - '│Q │'
+ - '│ ^^^ │'
+ - '│(o o)│'
+ - '│ \ / │'
+ - '│ Q│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│Q │'
+ - '│ │'
+ - '│ \^^^/ │'
+ - '│ (o o) │'
+ - '│ \_/ │'
+ - '│ │'
+ - '│ Q│'
+ - '└───────┘'
+ QH:
+ 7:
+ - '┌─────┐'
+ - '│Q │'
+ - '│ ^^^ │'
+ - '│(o o)│'
+ - '│ \ / │'
+ - '│ Q│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│Q │'
+ - '│ │'
+ - '│ \^^^/ │'
+ - '│ (o o) │'
+ - '│ \_/ │'
+ - '│ │'
+ - '│ Q│'
+ - '└───────┘'
+ QS:
+ 7:
+ - '┌─────┐'
+ - '│Q │'
+ - '│ ^^^ │'
+ - '│(o o)│'
+ - '│ \ / │'
+ - '│ Q│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│Q │'
+ - '│ │'
+ - '│ \^^^/ │'
+ - '│ (o o) │'
+ - '│ \_/ │'
+ - '│ │'
+ - '│ Q│'
+ - '└───────┘'
+ KC:
+ 7:
+ - '┌─────┐'
+ - '│K │'
+ - '│ ^^^ │'
+ - '│(o o)│'
+ - '│ /|\ │'
+ - '│ K│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│K │'
+ - '│ │'
+ - '│ ^^^^^ │'
+ - '│ (o o) │'
+ - '│ //|\\ │'
+ - '│ │'
+ - '│ K│'
+ - '└───────┘'
+ KD:
+ 7:
+ - '┌─────┐'
+ - '│K │'
+ - '│ ^^^ │'
+ - '│(o o)│'
+ - '│ /|\ │'
+ - '│ K│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│K │'
+ - '│ │'
+ - '│ ^^^^^ │'
+ - '│ (o o) │'
+ - '│ //|\\ │'
+ - '│ │'
+ - '│ K│'
+ - '└───────┘'
+ KH:
+ 7:
+ - '┌─────┐'
+ - '│K │'
+ - '│ ^^^ │'
+ - '│(o o)│'
+ - '│ /|\ │'
+ - '│ K│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│K │'
+ - '│ │'
+ - '│ ^^^^^ │'
+ - '│ (o o) │'
+ - '│ //|\\ │'
+ - '│ │'
+ - '│ K│'
+ - '└───────┘'
+ KS:
+ 7:
+ - '┌─────┐'
+ - '│K │'
+ - '│ ^^^ │'
+ - '│(o o)│'
+ - '│ /|\ │'
+ - '│ K│'
+ - '└─────┘'
+ 9:
+ - '┌───────┐'
+ - '│K │'
+ - '│ │'
+ - '│ ^^^^^ │'
+ - '│ (o o) │'
+ - '│ //|\\ │'
+ - '│ │'
+ - '│ K│'
+ - '└───────┘'
diff --git a/internal/admin/api_colors.go b/internal/admin/api_colors.go
index 7989e35..b6285c4 100644
--- a/internal/admin/api_colors.go
+++ b/internal/admin/api_colors.go
@@ -12,6 +12,7 @@ import (
"thehouseoficarus/internal/color"
"thehouseoficarus/internal/config"
"thehouseoficarus/internal/game"
+ "thehouseoficarus/internal/persistence"
)
// colorCategory describes one editable color category: its config key, a
@@ -595,13 +596,13 @@ func (s *AdminServer) writeConfigFile() error {
if merr != nil {
return merr
}
- return os.WriteFile(s.configPath, out, 0644)
+ return persistence.WriteFile(s.configPath, out, 0644)
}
out, err := patchColorMaps(data, s.cfg.DefaultColors, s.cfg.ConstantColors)
if err != nil {
return err
}
- return os.WriteFile(s.configPath, out, 0644)
+ return persistence.WriteFile(s.configPath, out, 0644)
}
// backupConfigFile copies the current config.yaml next to itself before an
@@ -615,7 +616,7 @@ func (s *AdminServer) backupConfigFile() error {
}
return err
}
- return os.WriteFile(s.configPath+".bak", data, 0600)
+ return persistence.WriteFile(s.configPath+".bak", data, 0600)
}
// patchColorMaps updates the default_colors and constant_colors mappings in a
diff --git a/internal/admin/api_entity_path_test.go b/internal/admin/api_entity_path_test.go
index 75fed7a..3a0f87b 100644
--- a/internal/admin/api_entity_path_test.go
+++ b/internal/admin/api_entity_path_test.go
@@ -56,10 +56,31 @@ func TestEntityAPICannotEscapeData(t *testing.T) {
if !strings.Contains(rec.Body.String(), "error") {
t.Fatalf("traversal rename id not rejected: status %d body %s", rec.Code, rec.Body.String())
}
+ // Traversal SOURCE id via the rename and move routes: the source is
+ // joined into a path too, so it must be rejected before it can move a
+ // file outside data/ (e.g. the server's own config.yaml).
+ sentinel := filepath.Join(root, "sentinel.yaml")
+ if err := os.WriteFile(sentinel, []byte("name: Sentinel\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ for _, tc := range []struct{ path, body string }{
+ {"/api/objects/rename", `{"id":"../../sentinel","new_id":"stolen"}`},
+ {"/api/objects/move", `{"id":"../../sentinel","dir":"sub"}`},
+ } {
+ if rec := post(tc.path, tc.body); !strings.Contains(rec.Body.String(), "error") {
+ t.Fatalf("traversal source id not rejected at %s: status %d body %s", tc.path, rec.Code, rec.Body.String())
+ }
+ }
+ if _, err := os.Stat(sentinel); err != nil {
+ t.Fatalf("traversal source id moved a file outside data/: %v", err)
+ }
// Nothing may exist outside the data directory.
if _, err := os.Stat(filepath.Join(root, "outside.yaml")); !os.IsNotExist(err) {
t.Fatal("entity API escaped the data directory")
}
+ if _, err := os.Stat(filepath.Join(root, "stolen.yaml")); !os.IsNotExist(err) {
+ t.Fatal("traversal rename escaped the data directory")
+ }
}
// TestTechModuleCreateRejectsTraversalID: POST /api/techs and /api/modules
diff --git a/internal/admin/api_files.go b/internal/admin/api_files.go
index 2aa0e5f..b9f54c5 100644
--- a/internal/admin/api_files.go
+++ b/internal/admin/api_files.go
@@ -227,9 +227,10 @@ func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) {
// writing, so the raw editor can never disclose or overwrite it.
body = restoreAccountSecret(relPath, fullPath, body)
- var oldContent []byte
- if existing, err := snapshotFile(fullPath); err == nil {
- oldContent = existing
+ oldContent, existed, err := snapshotBefore(fullPath)
+ if err != nil {
+ failJSON(w, "read existing file: "+err.Error())
+ return
}
dir := filepath.Dir(fullPath)
@@ -243,7 +244,7 @@ func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) {
return
}
- isCreate := oldContent == nil
+ isCreate := !existed
desc := "Updated file " + relPath
if isCreate {
desc = "Created file " + relPath
diff --git a/internal/admin/api_modules.go b/internal/admin/api_modules.go
index 8356b2d..ea547c5 100644
--- a/internal/admin/api_modules.go
+++ b/internal/admin/api_modules.go
@@ -96,9 +96,10 @@ func (s *AdminServer) handleModuleByID(w http.ResponseWriter, r *http.Request) {
delete(body, "_raw")
path := filepath.Join(s.dataDir, "modules", id+".yaml")
- var oldContent []byte
- if existing, err := snapshotFile(path); err == nil {
- oldContent = existing
+ oldContent, existed, err := snapshotBefore(path)
+ if err != nil {
+ failJSON(w, "read existing file: "+err.Error())
+ return
}
newContent, err := writeMapAsYAML(path, body)
@@ -107,7 +108,7 @@ func (s *AdminServer) handleModuleByID(w http.ResponseWriter, r *http.Request) {
return
}
- isCreate := oldContent == nil
+ isCreate := !existed
desc := "Updated module " + id
if isCreate {
desc = "Created module " + id
diff --git a/internal/admin/api_room_courses.go b/internal/admin/api_room_courses.go
index 5a33e6a..db74adf 100644
--- a/internal/admin/api_room_courses.go
+++ b/internal/admin/api_room_courses.go
@@ -204,16 +204,17 @@ func (s *AdminServer) handleCourseByID(w http.ResponseWriter, r *http.Request) {
delete(body, "_raw")
delete(body, "_path")
- var oldContent []byte
- if existing, err := snapshotFile(path); err == nil {
- oldContent = existing
+ oldContent, existed, err := snapshotBefore(path)
+ if err != nil {
+ failJSON(w, "read existing file: "+err.Error())
+ return
}
newContent, err := writeMapAsYAML(path, body)
if err != nil {
failJSON(w, "write error: "+err.Error())
return
}
- isCreate := oldContent == nil
+ isCreate := !existed
desc := "Updated course " + id
if isCreate {
desc = "Created course " + id
diff --git a/internal/admin/api_room_insert_remove.go b/internal/admin/api_room_insert_remove.go
index 45537d6..5f03b72 100644
--- a/internal/admin/api_room_insert_remove.go
+++ b/internal/admin/api_room_insert_remove.go
@@ -11,6 +11,7 @@ import (
"gopkg.in/yaml.v3"
"thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/persistence"
"thehouseoficarus/internal/world"
)
@@ -368,7 +369,7 @@ func rollbackWrite(path string, oldContent []byte) {
if oldContent == nil {
return
}
- if err := os.WriteFile(path, oldContent, 0644); err != nil {
+ if err := persistence.WriteFile(path, oldContent, 0644); err != nil {
fmt.Printf("admin: rollback write %s failed: %v\n", path, err)
}
}
diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go
index 32a6f1c..863af47 100644
--- a/internal/admin/api_rooms.go
+++ b/internal/admin/api_rooms.go
@@ -365,7 +365,7 @@ func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) {
if loadErr != nil {
continue
}
- changed := false
+ changed := otherRoom.RemoveRoomRefs(id)
for _, dir := range world.ExitOrder {
exit, exists := otherRoom.Exits[dir]
if !exists || exit.Room != id {
diff --git a/internal/admin/api_rooms_bulk_delete.go b/internal/admin/api_rooms_bulk_delete.go
index bba1879..d15549e 100644
--- a/internal/admin/api_rooms_bulk_delete.go
+++ b/internal/admin/api_rooms_bulk_delete.go
@@ -120,6 +120,11 @@ func (s *AdminServer) handleBulkDelete(w http.ResponseWriter, r *http.Request) {
continue
}
changed := false
+ for deletedID := range deletedSet {
+ if otherRoom.RemoveRoomRefs(deletedID) {
+ changed = true
+ }
+ }
for _, dir := range world.ExitOrder {
exit, exists := otherRoom.Exits[dir]
if !exists || !deletedSet[exit.Room] {
diff --git a/internal/admin/api_techs.go b/internal/admin/api_techs.go
index 9e742e1..d2c5f65 100644
--- a/internal/admin/api_techs.go
+++ b/internal/admin/api_techs.go
@@ -111,9 +111,16 @@ func (s *AdminServer) handleTechByID(w http.ResponseWriter, r *http.Request) {
var oldContent []byte
if !isCreate {
- if existing, err := snapshotFile(path); err == nil {
- oldContent = existing
+ existing, existed, err := snapshotBefore(path)
+ if err != nil {
+ failJSON(w, "read existing file: "+err.Error())
+ return
}
+ if !existed {
+ failJSON(w, "not found: "+id)
+ return
+ }
+ oldContent = existing
}
newContent, err := writeMapAsYAML(path, body)
diff --git a/internal/admin/api_triggers.go b/internal/admin/api_triggers.go
index 299ca10..0dd086c 100644
--- a/internal/admin/api_triggers.go
+++ b/internal/admin/api_triggers.go
@@ -11,6 +11,7 @@ import (
"gopkg.in/yaml.v3"
"thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/persistence"
)
func (s *AdminServer) handleTriggers(w http.ResponseWriter, r *http.Request) {
@@ -138,7 +139,7 @@ func (s *AdminServer) handleTriggerByID(w http.ResponseWriter, r *http.Request)
return
}
- if err := os.WriteFile(newPath, triggerData, 0644); err != nil {
+ if err := persistence.WriteFile(newPath, triggerData, 0644); err != nil {
failJSON(w, "write error: "+err.Error())
return
}
diff --git a/internal/admin/server.go b/internal/admin/server.go
index 05586ef..f35be84 100644
--- a/internal/admin/server.go
+++ b/internal/admin/server.go
@@ -982,8 +982,7 @@ func (s *AdminServer) handleEntityRename(entityType string) http.HandlerFunc {
ID string `json:"id"`
NewID string `json:"new_id"`
}
- if err := readJSON(r, &body); err != nil || body.ID == "" || body.NewID == "" ||
- !validEntityID(body.NewID) {
+ if err := readJSON(r, &body); err != nil || !validEntityID(body.ID) || !validEntityID(body.NewID) {
failJSON(w, "invalid")
return
}
@@ -1015,7 +1014,7 @@ func (s *AdminServer) handleEntityMove(entityType string) http.HandlerFunc {
ID string `json:"id"`
Dir string `json:"dir"`
}
- if err := readJSON(r, &body); err != nil || body.ID == "" {
+ if err := readJSON(r, &body); err != nil || !validEntityID(body.ID) {
failJSON(w, "invalid")
return
}
diff --git a/internal/admin/undo.go b/internal/admin/undo.go
index c2ea61d..3001c8b 100644
--- a/internal/admin/undo.go
+++ b/internal/admin/undo.go
@@ -227,6 +227,17 @@ func (us *UndoStack) Redo() *ChangeDesc {
return &last
}
+// requireSnapshot rejects a nil before/after image on a change that must
+// restore real content. A failed before-image read leaves nil, and writing
+// it would truncate the file to zero bytes (which parses as a zero-value
+// def), so a missing image must fail the undo/redo instead.
+func requireSnapshot(content []byte, path, action string) error {
+ if content == nil {
+ return fmt.Errorf("%s %s: no snapshot recorded", action, path)
+ }
+ return nil
+}
+
// applyUndo reverts a change: the primary file, then every extra file.
// Extra-file failures propagate: a multi-file undo that restored only part
// of a change must be reported as failed, never as success.
@@ -270,6 +281,9 @@ func (us *UndoStack) applyUndo(c ChangeDesc) error {
return fmt.Errorf("remove created %s: %w", c.FilePath, err)
}
} else {
+ if err := requireSnapshot(c.OldContent, c.FilePath, "revert"); err != nil {
+ return err
+ }
if err := us.writeSnapshot(c.FilePath, restored); err != nil {
return fmt.Errorf("revert %s: %w", c.FilePath, err)
}
@@ -332,6 +346,9 @@ func (us *UndoStack) applyRedo(c ChangeDesc) error {
if c.NewFilePath != "" {
target = c.NewFilePath
}
+ if err := requireSnapshot(c.NewContent, target, "reapply"); err != nil {
+ return err
+ }
if err := us.writeSnapshot(target, restored); err != nil {
return fmt.Errorf("reapply %s: %w", target, err)
}
@@ -362,6 +379,9 @@ func (us *UndoStack) applyUndoExtraFile(ef ExtraFile) error {
return fmt.Errorf("undo restore %s: %w", ef.FilePath, err)
}
case ef.NewFilePath != "":
+ if err := requireSnapshot(ef.OldContent, ef.FilePath, "undo restore"); err != nil {
+ return err
+ }
if err := os.Remove(ef.NewFilePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("undo remove %s: %w", ef.NewFilePath, err)
}
@@ -369,6 +389,9 @@ func (us *UndoStack) applyUndoExtraFile(ef ExtraFile) error {
return fmt.Errorf("undo restore %s: %w", ef.FilePath, err)
}
default:
+ if err := requireSnapshot(ef.OldContent, ef.FilePath, "undo revert"); err != nil {
+ return err
+ }
if err := us.writeRestored(ef.FilePath, ef.OldContent); err != nil {
return fmt.Errorf("undo revert %s: %w", ef.FilePath, err)
}
@@ -387,6 +410,9 @@ func (us *UndoStack) applyRedoExtraFile(ef ExtraFile) error {
return fmt.Errorf("redo create %s: %w", ef.FilePath, err)
}
case ef.NewFilePath != "":
+ if err := requireSnapshot(ef.NewContent, ef.NewFilePath, "redo create"); err != nil {
+ return err
+ }
if err := os.Remove(ef.FilePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("redo remove %s: %w", ef.FilePath, err)
}
@@ -394,6 +420,9 @@ func (us *UndoStack) applyRedoExtraFile(ef ExtraFile) error {
return fmt.Errorf("redo create %s: %w", ef.NewFilePath, err)
}
default:
+ if err := requireSnapshot(ef.NewContent, ef.FilePath, "redo reapply"); err != nil {
+ return err
+ }
if err := us.writeRestored(ef.FilePath, ef.NewContent); err != nil {
return fmt.Errorf("redo reapply %s: %w", ef.FilePath, err)
}
@@ -450,7 +479,9 @@ func (us *UndoStack) writeSnapshot(path string, content []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
- if err := os.WriteFile(path, content, us.fileMode(path)); err != nil {
+ // Atomic temp+fsync+rename: a crash mid-undo must never leave a
+ // truncated world file (empty YAML parses as a zero-value def).
+ if err := persistence.WriteFile(path, content, us.fileMode(path)); err != nil {
return err
}
if us.isPlayerDataPath(path) {
@@ -513,39 +544,46 @@ func (us *UndoStack) Info() UndoInfo {
return info
}
+// undoExtraEntry and undoEntry are the persisted undo/redo wire types. They
+// are declared ONCE and shared by save and load: when each function declared
+// its own copy, a field added to one and not the other silently dropped
+// persisted history on the next restart.
+type undoExtraEntry struct {
+ FilePath string `json:"file_path"`
+ NewFilePath string `json:"new_file_path,omitempty"`
+ OldContent string `json:"old_content"`
+ NewContent string `json:"new_content"`
+ IsDelete bool `json:"is_delete,omitempty"`
+ IsCreate bool `json:"is_create,omitempty"`
+ IsDirDelete bool `json:"is_dir_delete,omitempty"`
+}
+
+type undoEntry struct {
+ Time string `json:"time"`
+ Description string `json:"description"`
+ FilePath string `json:"file_path"`
+ NewFilePath string `json:"new_file_path,omitempty"`
+ OldContent string `json:"old_content"`
+ NewContent string `json:"new_content"`
+ IsDelete bool `json:"is_delete"`
+ IsCreate bool `json:"is_create"`
+ IsDirDelete bool `json:"is_dir_delete,omitempty"`
+ ExtraFiles []undoExtraEntry `json:"extra_files,omitempty"`
+ IDMap map[int]int `json:"id_map,omitempty"`
+}
+
+type undoFile struct {
+ History []undoEntry `json:"history"`
+ Redo []undoEntry `json:"redo"`
+}
+
func (us *UndoStack) save() {
- type extraEntry struct {
- FilePath string `json:"file_path"`
- NewFilePath string `json:"new_file_path,omitempty"`
- OldContent string `json:"old_content"`
- NewContent string `json:"new_content"`
- IsDelete bool `json:"is_delete,omitempty"`
- IsCreate bool `json:"is_create,omitempty"`
- IsDirDelete bool `json:"is_dir_delete,omitempty"`
- }
- type entry struct {
- Time string `json:"time"`
- Description string `json:"description"`
- FilePath string `json:"file_path"`
- NewFilePath string `json:"new_file_path,omitempty"`
- OldContent string `json:"old_content"`
- NewContent string `json:"new_content"`
- IsDelete bool `json:"is_delete"`
- IsCreate bool `json:"is_create"`
- IsDirDelete bool `json:"is_dir_delete,omitempty"`
- ExtraFiles []extraEntry `json:"extra_files,omitempty"`
- IDMap map[int]int `json:"id_map,omitempty"`
- }
- type saveData struct {
- History []entry `json:"history"`
- Redo []entry `json:"redo"`
- }
- toEntries := func(changes []ChangeDesc) []entry {
- var entries []entry
+ toEntries := func(changes []ChangeDesc) []undoEntry {
+ var entries []undoEntry
for _, c := range changes {
- var extra []extraEntry
+ var extra []undoExtraEntry
for _, ef := range c.ExtraFiles {
- extra = append(extra, extraEntry{
+ extra = append(extra, undoExtraEntry{
FilePath: ef.FilePath,
NewFilePath: ef.NewFilePath,
OldContent: string(us.redactContent(ef.OldContent, ef.FilePath, ef.NewFilePath)),
@@ -555,7 +593,7 @@ func (us *UndoStack) save() {
IsDirDelete: ef.IsDirDelete,
})
}
- entries = append(entries, entry{
+ entries = append(entries, undoEntry{
Time: c.Time,
Description: c.Description,
FilePath: c.FilePath,
@@ -571,7 +609,7 @@ func (us *UndoStack) save() {
}
return entries
}
- data := saveData{
+ data := undoFile{
History: toEntries(us.history),
Redo: toEntries(us.redo),
}
@@ -708,38 +746,12 @@ func (us *UndoStack) load() {
if err != nil {
return
}
- type extraEntry struct {
- FilePath string `json:"file_path"`
- NewFilePath string `json:"new_file_path,omitempty"`
- OldContent string `json:"old_content"`
- NewContent string `json:"new_content"`
- IsDelete bool `json:"is_delete,omitempty"`
- IsCreate bool `json:"is_create,omitempty"`
- IsDirDelete bool `json:"is_dir_delete,omitempty"`
- }
- type entry struct {
- Time string `json:"time"`
- Description string `json:"description"`
- FilePath string `json:"file_path"`
- NewFilePath string `json:"new_file_path,omitempty"`
- OldContent string `json:"old_content"`
- NewContent string `json:"new_content"`
- IsDelete bool `json:"is_delete"`
- IsCreate bool `json:"is_create"`
- IsDirDelete bool `json:"is_dir_delete,omitempty"`
- ExtraFiles []extraEntry `json:"extra_files,omitempty"`
- IDMap map[int]int `json:"id_map,omitempty"`
- }
- type saveData struct {
- History []entry `json:"history"`
- Redo []entry `json:"redo"`
- }
- var sd saveData
+ var sd undoFile
if err := json.Unmarshal(data, &sd); err != nil {
log.Printf("undo: failed to load history: %v", err)
return
}
- toChanges := func(entries []entry) []ChangeDesc {
+ toChanges := func(entries []undoEntry) []ChangeDesc {
out := make([]ChangeDesc, 0, len(entries))
for _, e := range entries {
c := ChangeDesc{
diff --git a/internal/admin/yaml_util.go b/internal/admin/yaml_util.go
index 8cd3252..e1d4942 100644
--- a/internal/admin/yaml_util.go
+++ b/internal/admin/yaml_util.go
@@ -157,6 +157,21 @@ func snapshotFile(path string) ([]byte, error) {
return os.ReadFile(path)
}
+// snapshotBefore reads a file's content for an undo record and reports
+// whether it existed. A missing file returns (nil, false, nil) — a create. A
+// read failure on an EXISTING file returns an error: treating it as a create
+// would make the undo record delete the real file, so the caller must abort.
+func snapshotBefore(path string) ([]byte, bool, error) {
+ data, err := os.ReadFile(path)
+ if err == nil {
+ return data, true, nil
+ }
+ if os.IsNotExist(err) {
+ return nil, false, nil
+ }
+ return nil, false, err
+}
+
func findEntityPath(dataDir, subdir, id string) (string, error) {
_, path, err := readYAMLFile(dataDir, subdir, id)
if err == nil {
@@ -167,6 +182,9 @@ func findEntityPath(dataDir, subdir, id string) (string, error) {
}
func moveEntityFile(dataDir, subdir, id, targetDir string) (string, string, []byte, error) {
+ if !validEntityID(id) {
+ return "", "", nil, fmt.Errorf("invalid id: %q", id)
+ }
if targetDir != "" && !validEntityID(targetDir) {
return "", "", nil, fmt.Errorf("invalid directory: %q", targetDir)
}
@@ -219,6 +237,9 @@ func createEntityDir(dataDir, subdir, name string) error {
}
func renameEntityFile(dataDir, subdir, oldID, newID string) (string, string, []byte, error) {
+ if !validEntityID(oldID) {
+ return "", "", nil, fmt.Errorf("invalid id: %q", oldID)
+ }
if !validEntityID(newID) {
return "", "", nil, fmt.Errorf("invalid new id: %q", newID)
}
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go
index b762b6f..cf8a289 100644
--- a/internal/casino/baccarat.go
+++ b/internal/casino/baccarat.go
@@ -51,7 +51,7 @@ func newBaccaratTable(roomID int, cfg TableConfig, rng *rand.Rand) *baccaratTabl
}
func (t *baccaratTable) menuEvent(name string) Event {
- return t.betMenuEvent(name, MenuBaccaratBet, []MenuCommand{
+ return t.betMenuEvent(name, []MenuCommand{
{Command: "bet <amount>", Desc: "Place a wager (min/max accepted)"},
{Command: "bet <amount> on player|banker|tie", Desc: "Wager on a specific outcome"},
{Command: "stop", Desc: "Leave the table"},
@@ -161,45 +161,30 @@ func (t *baccaratTable) deal() []Event {
player, banker := playTableau(t.shoe)
playerTotal, bankerTotal := baccaratTotal(player), baccaratTotal(banker)
round := t.event("New baccarat hand!", true)
- round.Baccarat = t.snapshot(player, banker)
+ round.Snapshot = t.snapshot(player, banker)
events := []Event{round, t.event(baccaratOutcomeMessage(playerTotal, bankerTotal), true)}
for _, p := range t.players.ordered() {
if !p.HasBet {
continue
}
result, payout, settleErr := settleBaccaratBet(p.lastSpot, p.Wager.Total, playerTotal, bankerTotal, t.Rules)
- // A payout-overflow error means the win could not be computed; treat
- // it like an unpersisted payout so the win is never announced as paid
- // (blackjack handles the same case identically).
- payoutFailed := settleErr != nil
- if !payoutFailed && payout > 0 && p.Wallet != nil {
- if err := p.Wallet.PayStakeCover(payout, []string{p.stakeID}); err != nil {
- // The payout could not be made durable; recovery will
- // re-apply the owed amount from the open journal record.
- // Do not announce the win as paid and leave the stakes
- // open for recovery to settle.
- payoutFailed = true
- }
- }
+ // The shared settlement path pays before announcing, leaves the
+ // stake open on any failure (recovery re-applies the owed payout),
+ // and resolves it only once durable.
+ payoutFailed := settleStakeOutcome(p.Wallet, payout, []string{p.stakeID}, settleErr)
+ publicResult, privateResult := baccaratResultMessages(p.Name, result, payout)
if payoutFailed {
- publicResult, _ := baccaratResultMessages(p.Name, result, payout)
- settlement := t.playerAction(p.Name, publicResult, payoutFailedMessage())
- if result == "win" {
+ privateResult = payoutFailedMessage()
+ }
+ settlement := t.playerAction(p.Name, publicResult, privateResult)
+ if result == "win" {
+ if p.lastSpot == baccaratSpotTie {
+ settlement.Color = "casino_jackpot_win"
+ } else {
settlement.Color = "casino_big_win"
}
- events = append(events, settlement)
- } else {
- publicResult, privateResult := baccaratResultMessages(p.Name, result, payout)
- settlement := t.playerAction(p.Name, publicResult, privateResult)
- if result == "win" {
- if p.lastSpot == baccaratSpotTie {
- settlement.Color = "casino_jackpot_win"
- } else {
- settlement.Color = "casino_big_win"
- }
- }
- events = append(events, settlement)
}
+ events = append(events, settlement)
if p.Wallet != nil && !payoutFailed {
// The round resolved; the stakes are no longer outstanding.
p.Wallet.ResolveStakes()
diff --git a/internal/casino/baccarat_test.go b/internal/casino/baccarat_test.go
index 41f5acf..49e8fdd 100644
--- a/internal/casino/baccarat_test.go
+++ b/internal/casino/baccarat_test.go
@@ -150,8 +150,8 @@ func TestBaccaratSoloBetDealsImmediately(t *testing.T) {
// The round event carries a snapshot of both hands and the bets.
var snapshot *BaccaratSnapshot
for _, event := range events {
- if event.Baccarat != nil {
- snapshot = event.Baccarat
+ if s, ok := event.Snapshot.(*BaccaratSnapshot); ok {
+ snapshot = s
}
}
if snapshot == nil || len(snapshot.Player) != 2 || len(snapshot.Banker) != 2 ||
@@ -354,8 +354,8 @@ func TestBaccaratMenuAndActions(t *testing.T) {
menu = event.Menu
}
}
- if menu == nil || menu.Kind != MenuBaccaratBet {
- t.Fatalf("join menu=%+v, want MenuBaccaratBet", menu)
+ if menu == nil || !menu.ShowBalance || menu.Heading != "New baccarat hand! Place your bet." {
+ t.Fatalf("join menu=%+v, want a baccarat betting menu", menu)
}
if len(menu.Commands) != 3 || menu.Commands[0].Command != "bet <amount>" || menu.Commands[2].Command != "stop" {
t.Fatalf("baccarat menu commands=%+v", menu.Commands)
diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go
index fe212f7..55ee702 100644
--- a/internal/casino/blackjack.go
+++ b/internal/casino/blackjack.go
@@ -102,7 +102,7 @@ func newBlackjackTable(roomID int, cfg TableConfig, rng *rand.Rand) *blackjackTa
}
func (t *blackjackTable) menuEvent(name string) Event {
- return t.betMenuEvent(name, MenuBlackjackBet, []MenuCommand{
+ return t.betMenuEvent(name, []MenuCommand{
{Command: "bet", Desc: "Place a wager (min/max accepted)"},
{Command: "bet <amount>", Desc: "Place a specific wager"},
{Command: "stop", Desc: "Leave the table"},
@@ -258,7 +258,7 @@ func (t *blackjackTable) action(name, action string) []Event {
hand.bust = true
}
e := t.playerAction(name, fmt.Sprintf("%s hits and receives %s.", name, hand.cards[len(hand.cards)-1]), fmt.Sprintf("You hit and receive %s.", hand.cards[len(hand.cards)-1]))
- e.Blackjack = t.snapshot(false)
+ e.Snapshot = t.snapshot(false)
events := []Event{e}
if total >= 21 {
events = append(events, t.finishHand()...)
@@ -286,7 +286,7 @@ func (t *blackjackTable) action(name, action string) []Event {
hand.bust = true
}
e := t.playerAction(name, fmt.Sprintf("%s doubles down.", name), "You double down.")
- e.Blackjack = t.snapshot(false)
+ e.Snapshot = t.snapshot(false)
return append([]Event{e}, t.finishHand()...)
case "split":
return t.splitHand(name, p, hand)
@@ -376,7 +376,7 @@ func (t *blackjackTable) splitHand(name string, p *blackjackPlayer, hand *blackj
index := t.activeHand
p.hands = append(p.hands[:index], append([]*blackjackHand{left, right}, p.hands[index+1:]...)...)
e := t.playerAction(name, fmt.Sprintf("%s splits the hand.", name), "You split the hand.")
- e.Blackjack = t.snapshot(false)
+ e.Snapshot = t.snapshot(false)
events := []Event{e}
if left.cards[0].rank == "A" {
// Split aces receive one card each; they only stay live when a
@@ -418,7 +418,7 @@ func (t *blackjackTable) startRound() []Event {
}
}
roundEvent := t.event("", true)
- roundEvent.Blackjack = t.snapshot(false)
+ roundEvent.Snapshot = t.snapshot(false)
events := []Event{roundEvent}
if t.dealer.cards[1].rank == "A" {
t.phase = blackjackInsurance
@@ -490,7 +490,7 @@ func (t *blackjackTable) prompt() []Event {
actions := t.availableActions(p, h)
e := t.private(name, fmt.Sprintf("Your turn (%s).\nActions: %s", strings.Join(ranks, ","), strings.Join(actions, ", ")))
e.Color = "casino_menu"
- e.Menu = &MenuView{Kind: MenuBlackjackTurn, Title: fmt.Sprintf("Your turn (%s)", strings.Join(ranks, ",")), Actions: actions, Commands: blackjackActionCommands(actions)}
+ e.Menu = &MenuView{Heading: fmt.Sprintf("Your turn (%s)", strings.Join(ranks, ",")), Commands: blackjackActionCommands(actions)}
return []Event{e}
}
@@ -585,7 +585,7 @@ func (t *blackjackTable) settle() []Event {
}
dealerTotal, _ := t.dealer.total()
dealerEvent := t.event("", true)
- dealerEvent.Blackjack = t.snapshot(true)
+ dealerEvent.Snapshot = t.snapshot(true)
events := []Event{dealerEvent}
dealerBlackjack := t.dealerBlackjack()
for _, p := range t.players.ordered() {
@@ -595,50 +595,20 @@ func (t *blackjackTable) settle() []Event {
payoutFailed := false
for _, hand := range p.hands {
result, payout, settleErr := settleHand(hand, dealerTotal, dealerBlackjack, t.Rules)
- if settleErr != nil {
- // A payout multiplier that overflows the credit cap is a
- // configuration defect, not a payable win. Treat it like a
- // failed settlement so the stake stays open for recovery to
- // refund rather than paying a clamped/absurd amount.
+ // One shared settlement path for every game: pay before
+ // announcing, leave the stakes open on any failure, and resolve
+ // them only once the payout is durable.
+ if settleStakeOutcome(p.Wallet, payout, hand.stakes, settleErr) {
payoutFailed = true
- events = append(events, t.playerAction(p.Name, "Settlement unavailable.", payoutFailedMessage()))
+ publicResult, _ := blackjackResultMessages(p.Name, result, payout)
+ settlement := t.playerAction(p.Name, publicResult, payoutFailedMessage())
+ colorizeBlackjackSettlement(&settlement, result)
+ events = append(events, settlement)
continue
}
- if payout > 0 && p.Wallet != nil {
- if err := p.Wallet.PayStakeCover(payout, hand.stakes); err != nil {
- // The payout could not be made durable. The journal
- // record stays open listing the hand's stakes as
- // covered: recovery re-applies the owed payout (which
- // returns those stakes) and refunds must not pay them
- // again. The win is not announced as paid.
- payoutFailed = true
- publicResult, _ := blackjackResultMessages(p.Name, result, payout)
- settlement := t.playerAction(p.Name, publicResult, payoutFailedMessage())
- if result == "blackjack" {
- settlement.Color = "casino_jackpot_win"
- } else if result == "win" {
- settlement.Color = "casino_big_win"
- }
- events = append(events, settlement)
- continue
- }
- // The hand was paid: its stakes are resolved immediately
- // so a later sibling-hand payout failure cannot cause a
- // refund of a stake this settlement already returned.
- t.resolveHandStakes(p.Wallet, hand)
- } else if payout == 0 && p.Wallet != nil {
- // A lost hand resolves its stakes even when a sibling
- // hand's payout is still in flight: the stake was
- // consumed by the rules of the game.
- t.resolveHandStakes(p.Wallet, hand)
- }
publicResult, privateResult := blackjackResultMessages(p.Name, result, payout)
settlement := t.playerAction(p.Name, publicResult, privateResult)
- if result == "blackjack" {
- settlement.Color = "casino_jackpot_win"
- } else if result == "win" {
- settlement.Color = "casino_big_win"
- }
+ colorizeBlackjackSettlement(&settlement, result)
events = append(events, settlement)
}
if p.insurance > 0 {
@@ -647,23 +617,12 @@ func (t *blackjackTable) settle() []Event {
if dealerBlackjack {
insurancePayout, insuranceErr = MulPayout(p.insurance, 1+t.Rules.InsurancePayout)
}
- switch {
- case insuranceErr != nil:
- // Overflow is a config defect: leave the insurance stake open
- // for recovery to refund instead of paying a clamped amount.
+ if settleStakeOutcome(p.Wallet, insurancePayout, []string{p.insuranceStakeID}, insuranceErr) {
payoutFailed = true
events = append(events, t.private(p.Name, payoutFailedMessage()))
- case insurancePayout > 0:
- if err := p.Wallet.PayStakeCover(insurancePayout, []string{p.insuranceStakeID}); err != nil {
- payoutFailed = true
- events = append(events, t.private(p.Name, payoutFailedMessage()))
- } else {
- p.Wallet.ResolveStake(p.insuranceStakeID)
- events = append(events, t.private(p.Name, fmt.Sprintf("Insurance pays %d credits.", insurancePayout)))
- }
- default:
- // Insurance lost: its stake was consumed by the rules.
- p.Wallet.ResolveStake(p.insuranceStakeID)
+ } else if insurancePayout > 0 {
+ events = append(events, t.private(p.Name, fmt.Sprintf("Insurance pays %d credits.", insurancePayout)))
+ } else {
events = append(events, t.private(p.Name, "Insurance pays 0 credits."))
}
}
@@ -725,13 +684,13 @@ func (t *blackjackTable) resetRound() {
}
}
-// resolveHandStakes settles every stake wager a hand committed. Nil-safe.
-func (t *blackjackTable) resolveHandStakes(wallet Wallet, hand *blackjackHand) {
- if wallet == nil {
- return
- }
- for _, stakeID := range hand.stakes {
- wallet.ResolveStake(stakeID)
+// colorizeBlackjackSettlement paints a winning settlement with the big-win
+// (or jackpot, for a natural) color.
+func colorizeBlackjackSettlement(e *Event, result string) {
+ if result == "blackjack" {
+ e.Color = "casino_jackpot_win"
+ } else if result == "win" {
+ e.Color = "casino_big_win"
}
}
diff --git a/internal/casino/blackjack_test.go b/internal/casino/blackjack_test.go
index 2e2e985..e3ffcaf 100644
--- a/internal/casino/blackjack_test.go
+++ b/internal/casino/blackjack_test.go
@@ -130,8 +130,8 @@ func TestBlackjackPromptNamesActiveHandAndOnlyOffersAvailableActions(t *testing.
if event.Message != "Your turn (K,6).\nActions: hit, stand, double" {
t.Fatalf("prompt=%q", event.Message)
}
- if event.Menu == nil || event.Menu.Kind != MenuBlackjackTurn || event.Menu.Title != "Your turn (K,6)" ||
- len(event.Menu.Actions) != 3 || event.Menu.Actions[0] != "hit" || event.Menu.Actions[1] != "stand" || event.Menu.Actions[2] != "double" {
+ if event.Menu == nil || event.Menu.Heading != "Your turn (K,6)" || event.Menu.ShowBalance ||
+ len(event.Menu.Commands) != 3 || event.Menu.Commands[0].Command != "hit" || event.Menu.Commands[1].Command != "stand" || event.Menu.Commands[2].Command != "double" {
t.Fatalf("prompt menu=%+v", event.Menu)
}
}
@@ -139,7 +139,7 @@ func TestBlackjackPromptNamesActiveHandAndOnlyOffersAvailableActions(t *testing.
func TestBlackjackMenuEventCarriesStructuredMenu(t *testing.T) {
table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand())
event := table.menuEvent("alice")
- if event.Menu == nil || event.Menu.Kind != MenuBlackjackBet {
+ if event.Menu == nil || !event.Menu.ShowBalance {
t.Fatalf("menu event=%+v", event.Menu)
}
if len(event.Menu.Commands) != 3 || event.Menu.Commands[0].Command != "bet" || event.Menu.Commands[2].Command != "stop" {
@@ -355,7 +355,7 @@ func hasMessage(events []Event, fragment string) bool {
func hasSnapshot(events []Event) bool {
for _, event := range events {
- if event.Blackjack != nil {
+ if _, ok := event.Snapshot.(*BlackjackSnapshot); ok {
return true
}
}
diff --git a/internal/casino/core.go b/internal/casino/core.go
index 85a5e28..a0ac1c8 100644
--- a/internal/casino/core.go
+++ b/internal/casino/core.go
@@ -127,6 +127,40 @@ func (c *tableCore[P]) refundAllLocked() []Event {
return events
}
+// settleStakeOutcome applies one wager's outcome through the wallet and
+// reports whether the settlement could NOT be made durable. It carries the
+// money-critical ordering shared by every table game (documented in
+// building_guide/concurrency.md): compute the payout, pay it via
+// PayStakeCover BEFORE announcing the win, and only then resolve the stakes
+// the settlement returned. On a persistence failure the stakes are left open
+// so recovery re-applies the owed payout and a shutdown refund never returns
+// them a second time; the caller must not announce a paid win and must not
+// run the player-wide ResolveStakes.
+//
+// payoutErr is a payout-computation error (for example an overflowed
+// multiplier): it is treated exactly like a persistence failure so a
+// clamped or absurd amount is never paid. A nil wallet (test scaffolding)
+// resolves the stakes in memory only.
+func settleStakeOutcome(w Wallet, payout int, stakeIDs []string, payoutErr error) (failed bool) {
+ if payoutErr != nil {
+ return true
+ }
+ if w == nil {
+ return false
+ }
+ if payout > 0 {
+ if err := w.PayStakeCover(payout, stakeIDs); err != nil {
+ return true
+ }
+ }
+ for _, id := range stakeIDs {
+ if id != "" {
+ w.ResolveStake(id)
+ }
+ }
+ return false
+}
+
// gameTitle renders a configured game name as a message heading.
func gameTitle(game GameName) string {
name := string(game)
diff --git a/internal/casino/machine.go b/internal/casino/machine.go
index f285518..3e1e0af 100644
--- a/internal/casino/machine.go
+++ b/internal/casino/machine.go
@@ -468,7 +468,7 @@ func (m *Machine) playerAction(publicMessage, privateMessage string) []Event {
func (m *Machine) publicSlot(message string, stopped int) []Event {
return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, Player: m.player.Name,
- Message: message, Color: "casino_action", Public: true, Slot: &SlotSnapshot{Grid: m.spin.Grid, StoppedReels: stopped}}}
+ Message: message, Color: "casino_action", Public: true, Snapshot: &SlotSnapshot{Grid: m.spin.Grid, StoppedReels: stopped}}}
}
func stoppedScatters(grid [slotGridRows][slotGridReels]string, reels int, trigger string) int {
@@ -503,7 +503,7 @@ func (m *Machine) privateMenu(prefix string) []Event {
events := m.private(prefix)
events[0].Color = "casino_menu"
if m.phase == machineReady {
- events[0].Menu = &MenuView{Kind: MenuSlotCommands, Commands: slotCommands}
+ events[0].Menu = &MenuView{Heading: prefix, ShowBalance: true, Commands: slotCommands}
}
return events
}
diff --git a/internal/casino/machine_test.go b/internal/casino/machine_test.go
index 73687b2..1e8cdad 100644
--- a/internal/casino/machine_test.go
+++ b/internal/casino/machine_test.go
@@ -45,7 +45,7 @@ func TestMachineMenuEventCarriesStructuredMenu(t *testing.T) {
m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, rand.New(rand.NewSource(7)))
m.player = &Participant{Name: "alice", Connected: true}
events := m.privateMenu("You sit down at the slot machine.")
- if len(events) != 1 || events[0].Menu == nil || events[0].Menu.Kind != MenuSlotCommands || len(events[0].Menu.Commands) == 0 {
+ if len(events) != 1 || events[0].Menu == nil || !events[0].Menu.ShowBalance || len(events[0].Menu.Commands) == 0 {
t.Fatalf("slot menu event=%+v", events)
}
}
diff --git a/internal/casino/table.go b/internal/casino/table.go
index 4be60a0..e3104ae 100644
--- a/internal/casino/table.go
+++ b/internal/casino/table.go
@@ -50,11 +50,12 @@ func (b *tableBase) playerAction(name, publicMessage, privateMessage string) Eve
}
// betMenuEvent builds the shared "place your bet" menu heading for a table
-// game; only the menu kind and command list differ per game.
-func (b *tableBase) betMenuEvent(name string, kind MenuKind, commands []MenuCommand) Event {
- e := b.private(name, fmt.Sprintf("New %s hand! Place your bet.", b.Config.Game))
+// game; only the command list differs per game.
+func (b *tableBase) betMenuEvent(name string, commands []MenuCommand) Event {
+ heading := fmt.Sprintf("New %s hand! Place your bet.", b.Config.Game)
+ e := b.private(name, heading)
e.Color = "casino_menu"
- e.Menu = &MenuView{Kind: kind, Commands: commands}
+ e.Menu = &MenuView{Heading: heading, ShowBalance: true, Commands: commands}
return e
}
diff --git a/internal/casino/types.go b/internal/casino/types.go
index 3d31820..980769c 100644
--- a/internal/casino/types.go
+++ b/internal/casino/types.go
@@ -234,36 +234,24 @@ type Wallet interface {
type EventType string
+// Only the event types the games actually construct are defined. Payout and
+// win styling travels on Event.Color, so there is no separate payout event
+// type to keep in sync.
const (
- EventJoined EventType = "joined"
- EventBetting EventType = "betting"
- EventBetPlaced EventType = "bet_placed"
- EventRoundStarted EventType = "round_started"
- EventResult EventType = "result"
- EventPayout EventType = "payout"
- EventTimedOut EventType = "timed_out"
- EventLeft EventType = "left"
- EventClosed EventType = "closed"
-)
-
-type MenuKind string
-
-const (
- MenuSlotCommands MenuKind = "slot_commands"
- MenuBlackjackBet MenuKind = "blackjack_bet"
- MenuBlackjackTurn MenuKind = "blackjack_turn"
- MenuBaccaratBet MenuKind = "baccarat_bet"
+ EventJoined EventType = "joined"
+ EventResult EventType = "result"
+ EventLeft EventType = "left"
)
// MenuView carries a structured menu alongside its plain-text Message so the
// presentation layer can enrich it (balances, command colors) without parsing
-// message prefixes.
+// message prefixes. It is deliberately game-agnostic: every game supplies a
+// heading and its command list, and the renderer has no per-game switch that
+// could silently drop a new game's commands.
type MenuView struct {
- Kind MenuKind
- Body string // command list (MenuSlotCommands)
- Title string // prompt heading (MenuBlackjackTurn)
- Actions []string // available commands (MenuBlackjackTurn)
- Commands []MenuCommand // unified command list for all casino menus
+ Heading string // prompt heading shown above the command list
+ ShowBalance bool // prepend the player's chips/credits balance
+ Commands []MenuCommand
}
// MenuCommand is one selectable command shown in a casino menu: the typed
@@ -283,17 +271,27 @@ type Event struct {
PrivateTo string
Color string
Public bool
- Slot *SlotSnapshot
- Blackjack *BlackjackSnapshot
- Baccarat *BaccaratSnapshot
- Menu *MenuView
+ // Snapshot is the optional per-game view attached to the event. A game
+ // implements the Snapshot interface for its view type; the presentation
+ // layer type-switches on it. Because the field is typed, a new game's
+ // snapshot can never be assigned without implementing the interface, and
+ // the event filter can test it with a single nil check instead of
+ // enumerating every game's field.
+ Snapshot Snapshot
+ Menu *MenuView
}
+// Snapshot is the marker interface for a game's structured event view. The
+// method is unexported so only types in this package can implement it.
+type Snapshot interface{ snapshot() }
+
type SlotSnapshot struct {
Grid [slotGridRows][slotGridReels]string
StoppedReels int
}
+func (*SlotSnapshot) snapshot() {}
+
// CardView renders one card for any card-based table game.
type CardView struct {
Rank string
@@ -315,6 +313,8 @@ type BlackjackSnapshot struct {
Players []BlackjackPlayerView
}
+func (*BlackjackSnapshot) snapshot() {}
+
// BaccaratBetView lists one participant's wager on a baccarat outcome.
type BaccaratBetView struct {
Name string
@@ -330,6 +330,8 @@ type BaccaratSnapshot struct {
Bets []BaccaratBetView
}
+func (*BaccaratSnapshot) snapshot() {}
+
type Participant struct {
Name string
Connected bool
diff --git a/internal/game/act_clean.go b/internal/game/act_clean.go
index 4efe293..0540cb1 100644
--- a/internal/game/act_clean.go
+++ b/internal/game/act_clean.go
@@ -68,7 +68,7 @@ func (g *Game) advanceClean(sess *net.Session, p *player.Player) {
}
consumeID := plan[0].id
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot != nil && slot.ItemID == consumeID {
slot.ItemID = matchItem.ID
diff --git a/internal/game/act_farm.go b/internal/game/act_farm.go
index 6725351..e83beb1 100644
--- a/internal/game/act_farm.go
+++ b/internal/game/act_farm.go
@@ -257,7 +257,7 @@ func (g *Game) advanceHarvest(sess *net.Session, p *player.Player) {
noRoom := false
if stackable {
quality, maxQuality := stackProps(productDef)
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
s := p.InvSlot(i)
// Only a stack with the properties a fresh harvest stack gets
// may absorb it; merging into a differently qualified stack
@@ -275,7 +275,7 @@ func (g *Game) advanceHarvest(sess *net.Session, p *player.Player) {
// One slot per unit: the clamp and the placement loop must count
// slots the same way (a nil 0..27 slot).
freeSlots := 0
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
if p.InvSlot(i) == nil {
freeSlots++
}
diff --git a/internal/game/act_gather.go b/internal/game/act_gather.go
index 3bef8a0..df0ae49 100644
--- a/internal/game/act_gather.go
+++ b/internal/game/act_gather.go
@@ -417,7 +417,7 @@ func (g *Game) checkBirdNest(sess *net.Session, p *player.Player, cfg *behavior.
}
freeSlot := -1
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
if i == currentDropSlot {
continue
}
diff --git a/internal/game/act_identify.go b/internal/game/act_identify.go
index 6ff4d61..89bef35 100644
--- a/internal/game/act_identify.go
+++ b/internal/game/act_identify.go
@@ -29,7 +29,7 @@ func (g *Game) advanceIdentify(sess *net.Session, p *player.Player) {
totalJunk := scrapCount * multiplier
hasExistingStack := false
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot != nil && slot.ItemID == junkID {
hasExistingStack = true
@@ -42,7 +42,7 @@ func (g *Game) advanceIdentify(sess *net.Session, p *player.Player) {
return
}
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot != nil && slot.ItemID == "scrap" {
p.SetInvSlot(i, nil)
@@ -50,7 +50,7 @@ func (g *Game) advanceIdentify(sess *net.Session, p *player.Player) {
}
placed := false
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot != nil && slot.ItemID == junkID {
slot.Quantity += totalJunk
diff --git a/internal/game/act_steal.go b/internal/game/act_steal.go
index 5a7f93e..7df400b 100644
--- a/internal/game/act_steal.go
+++ b/internal/game/act_steal.go
@@ -42,7 +42,7 @@ var stallGuardTalk = &behavior.TalkConfig{
},
"jail": {
Messages: []string{"Off to the detention cell with you!"},
- Action: &behavior.Step{Teleport: 99999162},
+ Action: &behavior.Step{Teleport: jailRoomID},
Options: []behavior.TalkOption{{Text: "(You are dragged away)"}},
},
"fight": {
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 971952a..736add2 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -489,42 +489,47 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) {
sess.WriteLine("You're not playing a game.")
return
}
- // Table bet syntax: bet | bet <amount> [on] [spot] | bet min|max [on] [spot].
- // Tokens that parse as an amount (or min/max) set the wager; any other
- // token is the bet target and the table game validates it.
- amount, spot, hasAmount := 0, "", false
+ // Table bet syntax: bet | bet <amount> [on] <target> | bet min|max [on] <target>.
+ // BEFORE an "on" keyword, a token that parses as an amount (or min/max)
+ // sets the wager and any other token is the bet target; AFTER "on", the
+ // next token is always the target. That makes numeric targets reachable
+ // ("bet 5 on 17"), which roulette, craps, and keno need.
+ amount, spot, hasAmount, sawOn := 0, "", false, false
for _, arg := range args {
if strings.EqualFold(arg, "on") {
+ sawOn = true
continue
}
- if strings.EqualFold(arg, "min") {
- if hasAmount {
- sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.")
- return
- }
- amount, hasAmount = table.Config.MinBet, true
- continue
- }
- if strings.EqualFold(arg, "max") {
- if hasAmount {
- sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.")
- return
+ if !sawOn {
+ if strings.EqualFold(arg, "min") {
+ if hasAmount {
+ sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.")
+ return
+ }
+ amount, hasAmount = table.Config.MinBet, true
+ continue
}
- amount = min(table.Config.MaxBet, casinoAffordable(p))
- if amount < 1 {
- sess.WriteLine("You don't have any chips or credits to bet.")
- return
+ if strings.EqualFold(arg, "max") {
+ if hasAmount {
+ sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.")
+ return
+ }
+ amount = min(table.Config.MaxBet, casinoAffordable(p))
+ if amount < 1 {
+ sess.WriteLine("You don't have any chips or credits to bet.")
+ return
+ }
+ hasAmount = true
+ continue
}
- hasAmount = true
- continue
- }
- if value, err := strconv.Atoi(arg); err == nil && value > 0 {
- if hasAmount {
- sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.")
- return
+ if value, err := strconv.Atoi(arg); err == nil && value > 0 {
+ if hasAmount {
+ sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.")
+ return
+ }
+ amount, hasAmount = value, true
+ continue
}
- amount, hasAmount = value, true
- continue
}
if spot != "" {
sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.")
@@ -579,12 +584,12 @@ func (g *Game) CasinoTick() {
func (g *Game) emitCasinoEvents(events []casino.Event) {
for _, event := range events {
- if event.Message == "" && event.PrivateMessage == "" && event.Menu == nil && event.Blackjack == nil && event.Baccarat == nil && event.Slot == nil {
+ if event.Message == "" && event.PrivateMessage == "" && event.Menu == nil && event.Snapshot == nil {
continue
}
category := event.Color
if category == "" {
- category = casinoEventColor(event.Type)
+ category = "casino_action"
}
if event.Public && g.Hub != nil {
for _, sess := range g.Hub.PlayersInRoom(event.RoomID) {
@@ -592,14 +597,8 @@ func (g *Game) emitCasinoEvents(events []casino.Event) {
continue
}
message := event.Message
- if event.Slot != nil && sess.Player != nil {
- message += "\n" + renderSlotSnapshot(event.Slot, g.colorMode(sess), sess.Player.OptionBool("unicode"))
- }
- if event.Blackjack != nil && sess.Player != nil {
- message += "\n" + renderBlackjackSnapshot(event.Blackjack, g.colorMode(sess), sess.Player.OptionBool("unicode"), sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt)
- }
- if event.Baccarat != nil && sess.Player != nil {
- message += "\n" + renderBaccaratSnapshot(event.Baccarat, g.colorMode(sess), sess.Player.OptionBool("unicode"), sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt)
+ if rendered := g.renderCasinoSnapshot(sess, event.Snapshot); rendered != "" {
+ message += "\n" + rendered
}
sess.WriteLine(g.colorize(sess, category, message))
}
@@ -621,11 +620,8 @@ func (g *Game) emitCasinoEvents(events []casino.Event) {
continue
}
privateMessage := g.casinoPrivateMessage(sess, category, event.Menu, text)
- if event.Blackjack != nil && sess.Player != nil {
- privateMessage += "\n" + renderBlackjackSnapshot(event.Blackjack, g.colorMode(sess), sess.Player.OptionBool("unicode"), sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt)
- }
- if event.Baccarat != nil && sess.Player != nil {
- privateMessage += "\n" + renderBaccaratSnapshot(event.Baccarat, g.colorMode(sess), sess.Player.OptionBool("unicode"), sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt)
+ if rendered := g.renderCasinoSnapshot(sess, event.Snapshot); rendered != "" {
+ privateMessage += "\n" + rendered
}
sess.WriteLine(g.renderCasinoMessage(sess, category, event.Menu, privateMessage))
if category == "casino_menu" {
@@ -643,29 +639,42 @@ func (g *Game) renderCasinoMessage(sess *net.Session, category string, menu *cas
return g.colorize(sess, category, message)
}
-func casinoEventColor(eventType casino.EventType) string {
- if eventType == casino.EventPayout {
- return "casino_win"
- }
- return "casino_action"
-}
-
func (g *Game) casinoPrivateMessage(sess *net.Session, category string, menu *casino.MenuView, message string) string {
if category != "casino_menu" || menu == nil {
return message
}
- chips := g.colorize(sess, "casino_chips", fmt.Sprintf("%d", countChips(sess.Player)))
- credits := g.colorize(sess, "casino_credits", fmt.Sprintf("%d", sess.Player.Credits))
- balance := fmt.Sprintf("You have %s chips and %s credits.", chips, credits)
- switch menu.Kind {
- case casino.MenuBlackjackBet, casino.MenuBaccaratBet:
- return g.renderCasinoCommands(sess, balance, message, menu.Commands)
- case casino.MenuBlackjackTurn:
- return g.renderCasinoCommands(sess, "", menu.Title, menu.Commands)
- case casino.MenuSlotCommands:
- return g.renderCasinoCommands(sess, balance, message, menu.Commands)
- }
- return message
+ balance := ""
+ if menu.ShowBalance {
+ chips := g.colorize(sess, "casino_chips", fmt.Sprintf("%d", countChips(sess.Player)))
+ credits := g.colorize(sess, "casino_credits", fmt.Sprintf("%d", sess.Player.Credits))
+ balance = fmt.Sprintf("You have %s chips and %s credits.", chips, credits)
+ }
+ heading := menu.Heading
+ if heading == "" {
+ heading = message
+ }
+ return g.renderCasinoCommands(sess, balance, heading, menu.Commands)
+}
+
+// renderCasinoSnapshot renders a game's structured view for one session. It is
+// the only per-game switch in the presentation layer: adding a game means
+// adding its snapshot type here, and the Snapshot interface guarantees the
+// event carries it rather than being silently dropped by a field filter.
+func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot) string {
+ if snapshot == nil || sess.Player == nil {
+ return ""
+ }
+ mode := g.colorMode(sess)
+ unicode := sess.Player.OptionBool("unicode")
+ switch s := snapshot.(type) {
+ case *casino.SlotSnapshot:
+ return renderSlotSnapshot(s, mode, unicode)
+ case *casino.BlackjackSnapshot:
+ return renderBlackjackSnapshot(s, mode, unicode, sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt)
+ case *casino.BaccaratSnapshot:
+ return renderBaccaratSnapshot(s, mode, unicode, sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt)
+ }
+ return ""
}
// renderCasinoCommands lays every casino menu out in the same style: an
diff --git a/internal/game/casino_test.go b/internal/game/casino_test.go
index 026415f..ffd9958 100644
--- a/internal/game/casino_test.go
+++ b/internal/game/casino_test.go
@@ -74,6 +74,14 @@ func TestExecuteBetParsesBaccaratSpots(t *testing.T) {
if !strings.Contains(string(conn.data), "must be on player, banker, or tie") {
t.Fatalf("bet red 100: expected spot rejection, got %q", string(conn.data))
}
+ // A numeric token AFTER "on" is the target, not a second amount, so
+ // numeric spots (roulette numbers, craps hard ways) reach the table.
+ // Baccarat rejects the value, which proves the parser forwarded it.
+ conn.data = nil
+ g.executeBet(sess, []string{"5", "on", "17"}, "bet 5 on 17")
+ if !strings.Contains(string(conn.data), "must be on player, banker, or tie") {
+ t.Fatalf("bet 5 on 17: expected the table to receive the numeric spot, got %q", string(conn.data))
+ }
// Two amounts or two spots are usage errors.
for _, args := range [][]string{{"10", "20"}, {"player", "banker"}} {
conn.data = nil
@@ -95,7 +103,7 @@ func TestCasinoMenuRendersAlignedColoredCommands(t *testing.T) {
conn := &casinoTestConn{}
sess := &net.Session{Player: player.New("alice"), Conn: conn}
g.loggedInChars = map[string]*net.Session{"alice": sess}
- menu := &casino.MenuView{Kind: casino.MenuSlotCommands, Commands: []casino.MenuCommand{
+ menu := &casino.MenuView{Heading: "Your slot machine bet is set to 10.", ShowBalance: true, Commands: []casino.MenuCommand{
{Command: "bet <amount>", Desc: "Set your wager"},
{Command: "spin", Desc: "Spin using your current wager"},
}}
diff --git a/internal/game/cmd_bank.go b/internal/game/cmd_bank.go
index 0bfc2a3..1ec0b7c 100644
--- a/internal/game/cmd_bank.go
+++ b/internal/game/cmd_bank.go
@@ -182,7 +182,7 @@ func (g *Game) doBankDepositAll(sess *net.Session) {
p := sess.Player
var deposited int
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot == nil {
continue
@@ -299,7 +299,7 @@ func (g *Game) withdrawBankItem(sess *net.Session, p *player.Player, bankSlotIdx
// means the exact same item instance: merging two different-quality
// stacks would silently destroy the withdrawn stack's properties.
if def != nil && def.Stackable {
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
if slot := p.InvSlot(i); slot != nil && slot.ItemID == bs.ItemID &&
slot.Quality == bs.Quality && slot.MaxQuality == bs.MaxQuality {
slot.Quantity += bs.Quantity
diff --git a/internal/game/cmd_dig.go b/internal/game/cmd_dig.go
index c58ba63..ff33490 100644
--- a/internal/game/cmd_dig.go
+++ b/internal/game/cmd_dig.go
@@ -127,7 +127,7 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) {
return
}
- newID, err := findNextRoomID(curPath)
+ newID, err := findNextRoomID(filepath.Dir(curPath), g.World.RoomIndex())
if err != nil {
sess.WriteLine("Error scanning room directory.")
return
@@ -222,14 +222,22 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) {
g.checkAggro(sess)
}
-func findNextRoomID(currentRoomPath string) (int, error) {
- dir := filepath.Dir(currentRoomPath)
- entries, err := os.ReadDir(dir)
+// findNextRoomID returns the first free room ID at or above localDir's
+// minimum, checked against every room ID in the world (globallyUsed). The
+// admin allocator (admin/id_alloc.go) does the same. A directory-scoped scan
+// could hand out an ID that already exists in another area, and
+// World.AddRoomPath would then silently shadow that room for the rest of the
+// process while leaving a duplicate file on disk.
+func findNextRoomID(localDir string, globallyUsed map[int]bool) (int, error) {
+ entries, err := os.ReadDir(localDir)
if err != nil {
return 0, err
}
- used := make(map[int]bool)
+ used := make(map[int]bool, len(globallyUsed)+len(entries))
+ for id := range globallyUsed {
+ used[id] = true
+ }
minID := 0
first := true
for _, entry := range entries {
@@ -248,11 +256,11 @@ func findNextRoomID(currentRoomPath string) (int, error) {
}
}
- if first {
- return 1, nil
+ start := 1
+ if !first {
+ start = minID
}
-
- for id := minID; ; id++ {
+ for id := start; ; id++ {
if !used[id] {
return id, nil
}
diff --git a/internal/game/cmd_dig_test.go b/internal/game/cmd_dig_test.go
new file mode 100644
index 0000000..1a96c25
--- /dev/null
+++ b/internal/game/cmd_dig_test.go
@@ -0,0 +1,57 @@
+package game
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// TestFindNextRoomIDSkipsIDsInOtherDirectories: the in-game allocator must
+// check every room ID in the world, not just the current room's directory.
+// A directory-scoped scan hands out an ID that already exists elsewhere, and
+// World.AddRoomPath then silently shadows that room.
+func TestFindNextRoomIDSkipsIDsInOtherDirectories(t *testing.T) {
+ root := t.TempDir()
+ intro := filepath.Join(root, "intro")
+ arena := filepath.Join(root, "arena")
+ for _, dir := range []string{intro, arena} {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // intro owns 1001 and 1002; arena owns 1003.
+ for _, id := range []string{"1001", "1002"} {
+ if err := os.WriteFile(filepath.Join(intro, id+".yaml"), []byte("name: intro\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := os.WriteFile(filepath.Join(arena, "1003.yaml"), []byte("name: arena\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ globallyUsed := map[int]bool{1001: true, 1002: true, 1003: true}
+
+ got, err := findNextRoomID(intro, globallyUsed)
+ if err != nil {
+ t.Fatalf("findNextRoomID: %v", err)
+ }
+ if got != 1004 {
+ t.Fatalf("findNextRoomID = %d, want 1004 (1003 is used by arena)", got)
+ }
+}
+
+// TestFindNextRoomIDEmptyDirectoryStartsAtOne: an empty directory still
+// allocates the globally lowest free ID rather than blindly returning 1.
+func TestFindNextRoomIDEmptyDirectoryStartsAtOne(t *testing.T) {
+ root := t.TempDir()
+ empty := filepath.Join(root, "empty")
+ if err := os.MkdirAll(empty, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ got, err := findNextRoomID(empty, map[int]bool{1: true, 2: true})
+ if err != nil {
+ t.Fatalf("findNextRoomID: %v", err)
+ }
+ if got != 3 {
+ t.Fatalf("findNextRoomID = %d, want 3", got)
+ }
+}
diff --git a/internal/game/cmd_drop.go b/internal/game/cmd_drop.go
index 278a57d..1c30cce 100644
--- a/internal/game/cmd_drop.go
+++ b/internal/game/cmd_drop.go
@@ -5,6 +5,7 @@ import (
"strings"
"thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
)
func (g *Game) doDropAll(sess *net.Session) {
@@ -120,7 +121,7 @@ func (g *Game) doDrop(sess *net.Session, input string) {
if qty == 0 {
if def != nil && def.Stackable {
// "drop <item>" means the whole item: sum every matching stack.
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot != nil && slot.ItemID == itemID && slot.Quantity > 0 {
qty += slot.Quantity
@@ -239,7 +240,7 @@ func (g *Game) handleDropAll(sess *net.Session, input string) {
}
var dropped []string
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot == nil || slot.Quantity <= 0 {
continue
diff --git a/internal/game/cmd_estate_directory.go b/internal/game/cmd_estate_directory.go
index 9966e52..83c5b80 100644
--- a/internal/game/cmd_estate_directory.go
+++ b/internal/game/cmd_estate_directory.go
@@ -2,6 +2,7 @@ package game
import (
"fmt"
+ "log"
"os"
"path/filepath"
"sort"
@@ -182,12 +183,20 @@ func (g *Game) getOwnedHouseRoom(p *player.Player, flagKey string) int {
}
houseMap := map[string]int{
- "local_neighborhood": 99999300,
+ "local_neighborhood": estateNeighborhoodID,
}
return houseMap[v]
}
func (g *Game) teleportPlayer(sess *net.Session, p *player.Player, roomID int) {
+ // Refuse a destination that does not exist: persisting a nonexistent
+ // RoomID soft-locks the character on login (see the login rescue in
+ // core_login_char.go). This is the single choke point every trigger,
+ // module, and estate teleport flows through.
+ if _, err := g.World.LoadRoom(roomID); err != nil {
+ log.Printf("teleport %s: destination room %d does not exist; ignoring", p.Name, roomID)
+ return
+ }
if g.Hub != nil {
for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
if other != sess {
diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go
index 33f55de..a806ffa 100644
--- a/internal/game/cmd_get.go
+++ b/internal/game/cmd_get.go
@@ -76,7 +76,7 @@ func (g *Game) doGet(sess *net.Session, input string) {
if def != nil && def.Stackable {
quality, maxQuality := g.groundStackProps(p, itemID)
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot == nil || slot.ItemID != itemID || slot.Quality != quality || slot.MaxQuality != maxQuality {
continue
@@ -198,7 +198,7 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) {
if def != nil && def.Stackable {
quality, maxQuality := g.groundStackProps(p, itemID)
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot == nil || slot.ItemID != itemID || slot.Quality != quality || slot.MaxQuality != maxQuality {
continue
@@ -306,7 +306,7 @@ func (g *Game) doGetAll(sess *net.Session) {
if def != nil && def.Stackable {
stacked := false
quality, maxQuality := g.groundStackProps(p, itemID)
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot == nil || slot.ItemID != itemID || slot.Quality != quality || slot.MaxQuality != maxQuality {
continue
@@ -485,7 +485,7 @@ func (g *Game) findGroundMatches(input string, roomID int) []itemMatch {
func (g *Game) findInventoryMatches(input string, p *player.Player) []itemMatch {
return g.collectMatches(input, func(yield func(string, int) bool) {
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot == nil {
continue
diff --git a/internal/game/cmd_inventory.go b/internal/game/cmd_inventory.go
index b7b52ed..fbde186 100644
--- a/internal/game/cmd_inventory.go
+++ b/internal/game/cmd_inventory.go
@@ -4,6 +4,7 @@ import (
"fmt"
"thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
)
func (g *Game) executeInventory(sess *net.Session, args []string, rawInput string) {
@@ -15,7 +16,7 @@ func (g *Game) doInventory(sess *net.Session) {
sess.WriteLine(fmt.Sprintf("Inventory (%d free):", p.FreeSlots()))
num := 0
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot == nil {
continue
diff --git a/internal/game/cmd_remove.go b/internal/game/cmd_remove.go
index 53dd313..d03daec 100644
--- a/internal/game/cmd_remove.go
+++ b/internal/game/cmd_remove.go
@@ -98,7 +98,7 @@ func (g *Game) removeAmmo(sess *net.Session, p *player.Player, qty int) {
}
quality, maxQuality := g.pickedStackProps(ammoID)
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
s := p.InvSlot(i)
// Only a stack with the properties a fresh stack gets may absorb the
// equipped stack (see pickedStackProps).
@@ -198,7 +198,7 @@ func (g *Game) doRemoveAll(sess *net.Session) {
// failure instead of silently destroying the ammo.
func (g *Game) removeAllAmmo(sess *net.Session, p *player.Player, itemID string) {
quality, maxQuality := g.pickedStackProps(itemID)
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
s := p.InvSlot(i)
// Only a stack with the properties a fresh stack gets may absorb the
// equipped stack (see pickedStackProps).
diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go
index 8e01326..bf95282 100644
--- a/internal/game/cmd_room_insert.go
+++ b/internal/game/cmd_room_insert.go
@@ -58,7 +58,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) {
oppositeDir := world.OppositeExit[dir]
- newID, err := findNextRoomID(curPath)
+ newID, err := findNextRoomID(filepath.Dir(curPath), g.World.RoomIndex())
if err != nil {
sess.WriteLine("Error scanning room directory.")
return
diff --git a/internal/game/cmd_shop.go b/internal/game/cmd_shop.go
index e2209fc..282d3ca 100644
--- a/internal/game/cmd_shop.go
+++ b/internal/game/cmd_shop.go
@@ -382,7 +382,7 @@ func (g *Game) doShopSell(sess *net.Session, mob *world.MobInstance, input strin
func (g *Game) shopInvCapacity(p *player.Player, itemID string, def *item.ItemDef) int {
if def != nil && def.Stackable {
quality, maxQuality := stackProps(def)
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
// Capacity must agree with giveShopItems: only a stack with the
// properties a fresh stack gets can absorb a purchase (see
// stackProps). Counting a differently qualified stack here would
@@ -408,7 +408,7 @@ func (g *Game) giveShopItems(p *player.Player, itemID string, qty int, def *item
// harvest, ammo return and pickup: a differently qualified stack is
// not a merge target.
quality, maxQuality := stackProps(def)
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
if slot := p.InvSlot(i); slot != nil && slot.ItemID == itemID && slot.Quality == quality && slot.MaxQuality == maxQuality {
slot.Quantity += qty
return
diff --git a/internal/game/cmd_swapid.go b/internal/game/cmd_swapid.go
index 00b6568..8b637fb 100644
--- a/internal/game/cmd_swapid.go
+++ b/internal/game/cmd_swapid.go
@@ -117,7 +117,12 @@ func (g *Game) executeSwapID(sess *net.Session, args []string, rawInput string)
// The two swapped rooms are always re-marshaled to correct their `id:`
// field; other rooms are re-marshaled only if a reference actually moved.
idMap := map[int]int{id1: id2, id2: id1}
- var updated int
+ // Rewrite every room that references the swapped IDs in ONE journal
+ // transaction. The old per-file writeRoomFile loop swallowed errors, so
+ // a crash or a failed write mid-loop left a half-migrated world with no
+ // recovery record.
+ rewriteTxn := g.newTxn()
+ updated := 0
for id := range g.World.RoomIndex() {
room, err := g.World.LoadRoom(id)
if err != nil {
@@ -133,13 +138,19 @@ func (g *Game) executeSwapID(sess *net.Session, args []string, rawInput string)
}
data, merr := yaml.Marshal(room)
if merr != nil {
- continue
+ sess.WriteLine(fmt.Sprintf("Error rewriting room %d: %v", id, merr))
+ return
}
- if werr := writeRoomFile(path, data); werr != nil {
- continue
+ if rerr := rewriteTxn.Replace(path, data, 0644); rerr != nil {
+ sess.WriteLine(fmt.Sprintf("Error rewriting room %d: %v", id, rerr))
+ return
}
updated++
}
+ if rerr := rewriteTxn.Commit(); rerr != nil {
+ sess.WriteLine(fmt.Sprintf("Error rewriting room references: %v", rerr))
+ return
+ }
// Full runtime migration: Hub room buckets, casino room keys, world
// room-keyed state (ground items, seeded markers, object states), live
diff --git a/internal/game/cmd_trigger_combat.go b/internal/game/cmd_trigger_combat.go
index 0578c2b..0d53e94 100644
--- a/internal/game/cmd_trigger_combat.go
+++ b/internal/game/cmd_trigger_combat.go
@@ -105,7 +105,7 @@ func (g *Game) scienceAttack(sess *net.Session, p *player.Player, mob *world.Mob
func (g *Game) findInventoryItem(p *player.Player, input string) (int, *player.InventorySlot) {
lower := strings.ToLower(strings.TrimSpace(input))
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot == nil {
continue
diff --git a/internal/game/cmd_trigger_utility.go b/internal/game/cmd_trigger_utility.go
index 481cd40..fa81f23 100644
--- a/internal/game/cmd_trigger_utility.go
+++ b/internal/game/cmd_trigger_utility.go
@@ -97,7 +97,7 @@ func (g *Game) triggerBonesToNuts(sess *net.Session, p *player.Player, mod *ModD
g.cancelAction(p)
}
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot != nil && slot.ItemID == "bones" {
slot.ItemID = "nutrient_bar"
@@ -283,7 +283,7 @@ func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef
outputQty = 1
}
placed := false
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot != nil && slot.ItemID == match.ID {
slot.Quantity += outputQty
diff --git a/internal/game/cmd_undig.go b/internal/game/cmd_undig.go
index a0fd1a0..c752c86 100644
--- a/internal/game/cmd_undig.go
+++ b/internal/game/cmd_undig.go
@@ -191,7 +191,7 @@ func (g *Game) performUndig(sess *net.Session, p *player.Player, targetID int) {
return nil
}
- changed := false
+ changed := room.RemoveRoomRefs(targetID)
for ed, exitDef := range room.Exits {
if exitDef.Room == targetID {
delete(room.Exits, ed)
diff --git a/internal/game/cmd_wear.go b/internal/game/cmd_wear.go
index e459386..773e3c6 100644
--- a/internal/game/cmd_wear.go
+++ b/internal/game/cmd_wear.go
@@ -196,7 +196,7 @@ func (g *Game) unequipAmmo(p *player.Player) bool {
return true
}
quality, maxQuality := g.pickedStackProps(ammoID)
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
s := p.InvSlot(i)
// Only a stack with the properties a fresh stack gets may absorb the
// equipped stack (see pickedStackProps).
@@ -227,7 +227,7 @@ func (g *Game) doWearAll(sess *net.Session) {
invSlot int
})
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot == nil {
continue
diff --git a/internal/game/content_refs.go b/internal/game/content_refs.go
new file mode 100644
index 0000000..d3328a1
--- /dev/null
+++ b/internal/game/content_refs.go
@@ -0,0 +1,18 @@
+package game
+
+// Code-referenced room IDs.
+//
+// A few rooms are referenced by literal ID in game code rather than by YAML.
+// Renaming, moving, or deleting them compiles clean and validates clean (no
+// check inspects Go literals), but silently soft-locks the affected flow: the
+// thief's "take me to jail" option and the estate directory's "enter" both
+// teleport there. TestCodeReferencedRoomsExist pins their existence against
+// the shipped data so a move fails loudly instead.
+const (
+ // jailRoomID is the destination of the stall-guard's "Take me to jail"
+ // step in act_steal.go.
+ jailRoomID = 99999162
+ // estateNeighborhoodID is the local-neighborhood house room used by the
+ // construction/estate directory flow in cmd_estate_directory.go.
+ estateNeighborhoodID = 99999300
+)
diff --git a/internal/game/content_refs_test.go b/internal/game/content_refs_test.go
new file mode 100644
index 0000000..456a0ee
--- /dev/null
+++ b/internal/game/content_refs_test.go
@@ -0,0 +1,21 @@
+package game
+
+import (
+ "path/filepath"
+ "testing"
+
+ "thehouseoficarus/internal/world"
+)
+
+// TestCodeReferencedRoomsExist guards the literal room IDs in content_refs.go.
+// If one of those rooms is moved or deleted, the thief-jail and
+// estate-directory teleports would silently soft-lock players, so the shipped
+// data must still contain them.
+func TestCodeReferencedRoomsExist(t *testing.T) {
+ w := world.New(filepath.Join("..", "..", "data"))
+ for _, id := range []int{jailRoomID, estateNeighborhoodID} {
+ if _, err := w.LoadRoom(id); err != nil {
+ t.Errorf("code-referenced room %d is missing from data/rooms: %v", id, err)
+ }
+ }
+}
diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go
index e9fa67f..3b35b15 100644
--- a/internal/game/core_login_account.go
+++ b/internal/game/core_login_account.go
@@ -158,7 +158,10 @@ func (g *Game) handlePasswordOffOwner(sess *net.Session, input string) {
return
}
throttle := g.ensureLoginThrottle()
- key := loginThrottleKey(name, sess.RemoteAddr())
+ // Key on the host, not RemoteAddr: the latter carries the ephemeral
+ // source port, so a reconnect would land on a fresh key and reset the
+ // escalation. RemoteHost strips the port (see net.Session.RemoteHost).
+ key := loginThrottleKey(name, sess.RemoteHost())
auth := passwordAuth{}
acc, err := g.AccountStore.LoadAccount(name)
switch {
diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go
index 3a2e324..41e25b8 100644
--- a/internal/game/core_login_char.go
+++ b/internal/game/core_login_char.go
@@ -2,6 +2,7 @@ package game
import (
"fmt"
+ "log"
"strings"
"thehouseoficarus/internal/net"
@@ -128,6 +129,18 @@ func (g *Game) connectCharacter(sess *net.Session, name string) {
}
}
+ // Rescue a character whose saved room no longer exists (the room was
+ // deleted, or a code-referenced teleport target was removed). Persisting
+ // a nonexistent RoomID soft-locks the character on every later login
+ // because movement and every room lookup fail.
+ if _, err := g.World.LoadRoom(p.RoomID); err != nil {
+ log.Printf("character %s: saved room %d no longer exists; relocating to starting room %d", p.Name, p.RoomID, g.StartingRoom)
+ p.RoomID = g.StartingRoom
+ if _, err := g.World.LoadRoom(p.RoomID); err != nil {
+ log.Printf("character %s: starting room %d also missing; leaving room as %d", p.Name, g.StartingRoom, p.RoomID)
+ }
+ }
+
p.Stats.RecordRoomVisit(p.RoomID)
g.World.SeedGroundItems(p.RoomID)
g.seedRoomMobs(p.RoomID)
diff --git a/internal/game/core_production.go b/internal/game/core_production.go
index afffa7c..7a4b6e9 100644
--- a/internal/game/core_production.go
+++ b/internal/game/core_production.go
@@ -16,7 +16,7 @@ func (g *Game) findTool(p *player.Player, toolTypes []string) (toolSpeed float64
}
}
}
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot == nil {
continue
diff --git a/internal/game/game.go b/internal/game/game.go
index af2ff66..c0c6488 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -764,6 +764,10 @@ func (g *Game) ProcessQueuedCommands() {
func (g *Game) buildCraftIndex() {
items, err := g.ItemStore.LoadAll()
if err != nil {
+ // A malformed item file must not silently empty every crafting menu;
+ // surface it so the operator can fix the file (the validator also
+ // reports it).
+ log.Printf("craft index: item load failed; craft index left empty: %v", err)
return
}
g.CraftIndex.Build(items)
diff --git a/internal/game/look_target.go b/internal/game/look_target.go
index 5f3818d..49a2ec7 100644
--- a/internal/game/look_target.go
+++ b/internal/game/look_target.go
@@ -234,7 +234,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
return
}
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot == nil {
continue
diff --git a/internal/game/production_engine.go b/internal/game/production_engine.go
index 89d6f55..9795db0 100644
--- a/internal/game/production_engine.go
+++ b/internal/game/production_engine.go
@@ -440,7 +440,7 @@ func (g *Game) canContinueProduction(p *player.Player, item *item.ItemDef) bool
}
outDef, _ := g.ItemStore.Load(item.ID)
if outDef != nil && outDef.Stackable {
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot != nil && slot.ItemID == item.ID {
return true
diff --git a/internal/game/production_index.go b/internal/game/production_index.go
index 35466a7..22e944e 100644
--- a/internal/game/production_index.go
+++ b/internal/game/production_index.go
@@ -293,7 +293,7 @@ func stackProps(def *item.ItemDef) (quality, maxQuality int) {
func craftOutputFits(p *player.Player, itemID string, outDef *item.ItemDef) bool {
if outDef != nil && outDef.Stackable {
quality, maxQuality := stackProps(outDef)
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot != nil && slot.ItemID == itemID && slot.Quality == quality && slot.MaxQuality == maxQuality {
return true
@@ -311,7 +311,7 @@ func craftOutputFits(p *player.Player, itemID string, outDef *item.ItemDef) bool
func placeCraftOutput(p *player.Player, itemID string, outDef *item.ItemDef, outputQty int) bool {
quality, maxQuality := stackProps(outDef)
if outDef != nil && outDef.Stackable {
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot != nil && slot.ItemID == itemID && slot.Quality == quality && slot.MaxQuality == maxQuality {
slot.Quantity += outputQty
diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go
index a6feec2..61eb325 100644
--- a/internal/game/sys_triggers.go
+++ b/internal/game/sys_triggers.go
@@ -569,6 +569,9 @@ func (g *Game) loadGlobalTriggers(dataDir string) error {
}
data, err := os.ReadFile(full)
if err != nil {
+ // A permissions/IO error here would otherwise disable a
+ // file-backed flag trigger with no trace at all.
+ log.Printf("trigger load %q: %v (skipped)", full, err)
continue
}
var t behavior.Trigger
@@ -603,7 +606,9 @@ func copyTrigger(t behavior.Trigger) *behavior.Trigger {
func (g *Game) loadAllFlagTriggers() {
g.flagIndex.clear()
- _ = g.loadGlobalTriggers(g.DataDir)
+ if err := g.loadGlobalTriggers(g.DataDir); err != nil {
+ log.Printf("trigger load: %v (some file-backed flag triggers may be missing)", err)
+ }
}
// firePlayerFlagTriggers starts sequences for every on_flag_change trigger
diff --git a/internal/net/remote_host_test.go b/internal/net/remote_host_test.go
new file mode 100644
index 0000000..690e758
--- /dev/null
+++ b/internal/net/remote_host_test.go
@@ -0,0 +1,34 @@
+package net
+
+import (
+ "testing"
+ "time"
+)
+
+// remoteAddrConn is a minimal Conn that also reports a peer address, so the
+// Session.RemoteHost port-stripping contract can be tested without a socket.
+type remoteAddrConn struct{ addr string }
+
+func (c remoteAddrConn) ReadMessage() (string, error) { return "", nil }
+func (c remoteAddrConn) Write(b []byte) (int, error) { return len(b), nil }
+func (c remoteAddrConn) Close() error { return nil }
+func (c remoteAddrConn) SetWriteDeadline(time.Time) error { return nil }
+func (c remoteAddrConn) SetEcho(bool) error { return nil }
+func (c remoteAddrConn) RemoteAddr() string { return c.addr }
+
+// TestSessionRemoteHostStripsPort: login throttling keys on the client host,
+// so RemoteHost must drop the ephemeral source port (RemoteAddr keeps it).
+// Keying on the raw address would let a reconnect reset the limiter.
+func TestSessionRemoteHostStripsPort(t *testing.T) {
+ for _, tc := range []struct{ in, want string }{
+ {"203.0.113.7:54321", "203.0.113.7"},
+ {"[2001:db8::1]:443", "2001:db8::1"},
+ {"203.0.113.7", "203.0.113.7"},
+ {"", ""},
+ } {
+ sess := &Session{Conn: remoteAddrConn{addr: tc.in}}
+ if got := sess.RemoteHost(); got != tc.want {
+ t.Fatalf("RemoteHost(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ }
+}
diff --git a/internal/net/server.go b/internal/net/server.go
index c818eef..daf3e47 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -151,6 +151,14 @@ func (sess *Session) RemoteAddr() string {
return ""
}
+// RemoteHost returns the client's IP/host without the ephemeral source port.
+// Rate-limit keys must use this: RemoteAddr includes a fresh port on every
+// reconnect, so keying on it lets a reconnecting attacker reset the limiter.
+// Synthetic/test connections without a transport address return "".
+func (sess *Session) RemoteHost() string {
+ return remoteHost(sess.RemoteAddr())
+}
+
// sessionSeq is the source of Session.seq values. It is incremented under the
// adding Hub's mutex, so no extra atomicity is needed for correctness; atomic
// access keeps the package-level default counter safe if Hub.Add is ever
diff --git a/internal/validate/grid_test.go b/internal/validate/grid_test.go
index 0e1d2dd..8bb8950 100644
--- a/internal/validate/grid_test.go
+++ b/internal/validate/grid_test.go
@@ -159,7 +159,7 @@ func TestGridDiagonalClean(t *testing.T) {
// Room 1 -> NE -> Room 2 (at 1,-1). Clean diagonal placement.
rooms := map[int]string{
1: "exits:\n northeast: {room: 2}\n",
- 2: "name: two\n exits:\n southwest: {room: 1}\n",
+ 2: "name: two\nexits:\n southwest: {room: 1}\n",
}
if issues := runGridCheck(t, rooms, 1); len(issues) != 0 {
t.Errorf("expected no grid issues, got: %+v", issues)
diff --git a/internal/world/grid_test.go b/internal/world/grid_test.go
index 005f60f..6cf53d0 100644
--- a/internal/world/grid_test.go
+++ b/internal/world/grid_test.go
@@ -82,7 +82,7 @@ func TestBuildGridConflictsTwist(t *testing.T) {
func TestBuildGridConflictsDiagonalClean(t *testing.T) {
dir := t.TempDir()
writeGridTestRoom(t, dir, 1, "exits:\n northeast: {room: 2}\n")
- writeGridTestRoom(t, dir, 2, "name: two\n exits:\n southwest: {room: 1}\n")
+ writeGridTestRoom(t, dir, 2, "name: two\nexits:\n southwest: {room: 1}\n")
w := New(dir)
if conflicts := BuildGridConflicts(1, loadGridRoom(w)); len(conflicts) != 0 {
diff --git a/internal/world/mob.go b/internal/world/mob.go
index afd9024..4441108 100644
--- a/internal/world/mob.go
+++ b/internal/world/mob.go
@@ -747,7 +747,14 @@ func (s *MobStore) RekeyRooms(idMap map[int]int) {
}
s.mu.Lock()
defer s.mu.Unlock()
+ // A seeded instance's key embeds its home room (SeedMobs builds
+ // "<def>_<room>_<i>"). Leaving the old key in place would make SeedMobs
+ // create a duplicate instance for the renamed room and would let a
+ // recycled ID skip seeding entirely, so the map is rebuilt with the
+ // rewritten keys.
+ rekeyed := make(map[string]*MobInstance, len(s.instances))
for _, inst := range s.instances {
+ oldHome := inst.HomeRoomID
if newID, ok := idMap[inst.RoomID]; ok && newID != inst.RoomID {
inst.RoomID = newID
}
@@ -764,5 +771,23 @@ func (s *MobStore) RekeyRooms(idMap map[int]int) {
inst.DespawnRooms[i] = newID
}
}
+ if inst.HomeRoomID != oldHome {
+ inst.InstanceID = rekeySeededInstanceID(inst.InstanceID, inst.DefID, oldHome, inst.HomeRoomID)
+ }
+ rekeyed[inst.InstanceID] = inst
+ }
+ s.instances = rekeyed
+}
+
+// rekeySeededInstanceID rewrites the room component of a seeded instance ID
+// ("<def>_<oldRoom>_<suffix>"). IDs that do not match the seeded shape (a
+// transient or test instance) are returned unchanged. The DefID prefix is
+// stripped by string match rather than parsed because a mob def ID may itself
+// contain underscores.
+func rekeySeededInstanceID(instanceID, defID string, oldRoom, newRoom int) string {
+ prefix := fmt.Sprintf("%s_%d_", defID, oldRoom)
+ if !strings.HasPrefix(instanceID, prefix) {
+ return instanceID
}
+ return fmt.Sprintf("%s_%d_%s", defID, newRoom, strings.TrimPrefix(instanceID, prefix))
}
diff --git a/internal/world/mob_rekey_test.go b/internal/world/mob_rekey_test.go
index 3a0eb85..8118059 100644
--- a/internal/world/mob_rekey_test.go
+++ b/internal/world/mob_rekey_test.go
@@ -2,6 +2,29 @@ package world
import "testing"
+// TestRekeyRoomsRewritesSeededInstanceID: a seeded instance's map key embeds
+// its home room ("<def>_<room>_<i>"). Renaming the room must rewrite the key,
+// or SeedMobs creates a duplicate instance for the renamed room and a later
+// recycled ID skips seeding entirely.
+func TestRekeyRoomsRewritesSeededInstanceID(t *testing.T) {
+ s := &MobStore{instances: map[string]*MobInstance{
+ "rat_2_0": {InstanceID: "rat_2_0", DefID: "rat", RoomID: 2, HomeRoomID: 2},
+ "bat_5_1": {InstanceID: "bat_5_1", DefID: "bat", RoomID: 5, HomeRoomID: 5},
+ }}
+ s.RekeyRooms(map[int]int{2: 4})
+
+ if _, stale := s.instances["rat_2_0"]; stale {
+ t.Fatal("stale seeded key survived the rekey")
+ }
+ inst, ok := s.instances["rat_4_0"]
+ if !ok || inst.RoomID != 4 || inst.HomeRoomID != 4 {
+ t.Fatalf("rekeyed instance = %+v (present=%v), want rat_4_0 in room 4", inst, ok)
+ }
+ if _, ok := s.instances["bat_5_1"]; !ok {
+ t.Fatal("an unrelated instance was dropped")
+ }
+}
+
// TestRekeyRoomsUpdatesMobDespawnRooms: DespawnRooms is a live room reference
// (sys_triggers compares it against the owner's current room). A room rename
// must remap it alongside RoomID/HomeRoomID/WanderRooms, or a transient mob
diff --git a/internal/world/objects.go b/internal/world/objects.go
index 849c47a..fa17f08 100644
--- a/internal/world/objects.go
+++ b/internal/world/objects.go
@@ -308,6 +308,13 @@ func (w *World) TickObjWander() {
if toRoom == st.RoomID {
continue
}
+ // A wander_rooms entry may point at a room that was since deleted.
+ // Moving there would strand the object in a nonexistent room, so
+ // leave it where it is. (roomPathIndex is read under w.mu, which
+ // this method already holds; LoadRoom would deadlock here.)
+ if _, ok := w.roomPathIndex[toRoom]; !ok {
+ continue
+ }
moves = append(moves, struct {
st *ObjState
toRoom int
diff --git a/internal/world/room_migrate.go b/internal/world/room_migrate.go
index b7840bc..42cd1ae 100644
--- a/internal/world/room_migrate.go
+++ b/internal/world/room_migrate.go
@@ -101,6 +101,48 @@ func (r *Room) RewriteRoomIDs(idMap map[int]int) bool {
return changed
}
+// RemoveRoomRefs drops references to a deleted room from this room's
+// per-instance mob and object wander lists. Exit targets are removed by the
+// deletion caller. Teleport steps and conditions that name the deleted room
+// are left in place and neutralized at runtime (teleportPlayer refuses a
+// missing destination, and a Condition.Room that no longer exists simply
+// never matches), so this only removes the references the validator would
+// otherwise report as dangling. Returns whether anything changed.
+func (r *Room) RemoveRoomRefs(removedID int) bool {
+ if r == nil || removedID <= 0 {
+ return false
+ }
+ changed := false
+ for i := range r.Mobs {
+ if removeRoomRef(&r.Mobs[i].WanderRooms, removedID) {
+ changed = true
+ }
+ }
+ for i := range r.Objects {
+ if removeRoomRef(&r.Objects[i].WanderRooms, removedID) {
+ changed = true
+ }
+ }
+ return changed
+}
+
+// removeRoomRef removes target from list in place and reports whether it was
+// present.
+func removeRoomRef(list *[]int, target int) bool {
+ src := *list
+ out := src[:0]
+ removed := false
+ for _, v := range src {
+ if v == target {
+ removed = true
+ continue
+ }
+ out = append(out, v)
+ }
+ *list = out
+ return removed
+}
+
// remapTriggers remaps step fields across a []behavior.Trigger (each trigger's
// Steps + per-step Condition + trigger-level Condition).
func remapTriggers(idMap map[int]int, list []behavior.Trigger) bool {