aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 05:31:09 -0400
committerhistoria <[not public]>2026-09-18 05:31:09 -0400
commit153acb7f644d755c45533b7c5311e3ffa47e8973 (patch)
tree7645e6f7b65bc3830b078e3b9336d2e6b199d584
parent87d08f0151e643951769e323995f1c5ed89ee200 (diff)
downloadthehouseoficarus-153acb7f644d755c45533b7c5311e3ffa47e8973.tar.gz
fix: harden engine across casino, economy, concurrency, security, and content
-rw-r--r--.gitignore3
-rw-r--r--building_guide/rooms.md12
-rw-r--r--building_guide/tick_order.md23
-rw-r--r--cmd/thoi/main.go26
-rw-r--r--data/items/000_test_item.yaml40
-rw-r--r--data/items/consumables/anchovies.yaml4
-rw-r--r--data/items/consumables/bread.yaml4
-rw-r--r--data/items/consumables/herring.yaml4
-rw-r--r--data/items/consumables/pastry_dough.yaml4
-rw-r--r--data/items/consumables/pie_dish.yaml1
-rw-r--r--data/items/consumables/pizza_dough.yaml4
-rw-r--r--data/items/consumables/salmon.yaml4
-rw-r--r--data/items/consumables/sardine.yaml4
-rw-r--r--data/items/consumables/shrimps.yaml4
-rw-r--r--data/items/consumables/trout.yaml4
-rw-r--r--data/items/materials/adamantite_bar.yaml5
-rw-r--r--data/items/materials/bronze_bar.yaml5
-rw-r--r--data/items/materials/gold_bar.yaml5
-rw-r--r--data/items/materials/iron_bar.yaml5
-rw-r--r--data/items/materials/mithril_bar.yaml5
-rw-r--r--data/items/materials/runite_bar.yaml5
-rw-r--r--data/items/materials/silver_bar.yaml5
-rw-r--r--data/items/materials/steel_bar.yaml5
-rw-r--r--data/items/tools/empty_pot.yaml1
-rw-r--r--data/items/tools/plant_pot.yaml1
-rw-r--r--internal/admin/api_colors.go143
-rw-r--r--internal/admin/api_files.go61
-rw-r--r--internal/admin/api_triggers.go13
-rw-r--r--internal/admin/correct_colors_merge_limiter_test.go170
-rw-r--r--internal/admin/correct_undo_account_image_test.go262
-rw-r--r--internal/admin/hardening_login_limiter_test.go164
-rw-r--r--internal/admin/hardening_security_test.go289
-rw-r--r--internal/admin/server.go220
-rw-r--r--internal/admin/undo.go329
-rw-r--r--internal/casino/baccarat.go29
-rw-r--r--internal/casino/blackjack.go78
-rw-r--r--internal/casino/cards.go31
-rw-r--r--internal/casino/config_validate.go89
-rw-r--r--internal/casino/correct_casino_refund_bank_test.go101
-rw-r--r--internal/casino/correct_casino_room_validate_test.go36
-rw-r--r--internal/casino/credits.go16
-rw-r--r--internal/casino/credits_test.go13
-rw-r--r--internal/casino/hardening_casino_disconnect_test.go102
-rw-r--r--internal/casino/hardening_casino_locking_test.go84
-rw-r--r--internal/casino/hardening_casino_refund_test.go145
-rw-r--r--internal/casino/hardening_casino_rng_test.go75
-rw-r--r--internal/casino/hardening_casino_validate_test.go181
-rw-r--r--internal/casino/machine.go184
-rw-r--r--internal/casino/manager.go47
-rw-r--r--internal/casino/polish_refund_forfeit_test.go24
-rw-r--r--internal/casino/slots.go31
-rw-r--r--internal/casino/types.go2
-rw-r--r--internal/config/config.go13
-rw-r--r--internal/config/correct_empty_config_test.go50
-rw-r--r--internal/config/hardening_config_known_fields_test.go66
-rw-r--r--internal/game/account_txn.go90
-rw-r--r--internal/game/act.go7
-rw-r--r--internal/game/act_burn.go6
-rw-r--r--internal/game/act_clean.go5
-rw-r--r--internal/game/act_combine.go70
-rw-r--r--internal/game/act_farm.go77
-rw-r--r--internal/game/act_fletch.go80
-rw-r--r--internal/game/casino.go47
-rw-r--r--internal/game/cmd_bank.go29
-rw-r--r--internal/game/cmd_color.go6
-rw-r--r--internal/game/cmd_craft.go10
-rw-r--r--internal/game/cmd_get.go132
-rw-r--r--internal/game/cmd_option.go34
-rw-r--r--internal/game/cmd_quit.go16
-rw-r--r--internal/game/cmd_remove.go24
-rw-r--r--internal/game/cmd_shop.go14
-rw-r--r--internal/game/cmd_swapid.go41
-rw-r--r--internal/game/cmd_wear.go56
-rw-r--r--internal/game/combat_mob.go38
-rw-r--r--internal/game/core_admin.go20
-rw-r--r--internal/game/core_command_queue.go37
-rw-r--r--internal/game/core_login_account.go82
-rw-r--r--internal/game/core_login_char.go37
-rw-r--r--internal/game/correct_combine_skill_test.go100
-rw-r--r--internal/game/correct_online_name_case_test.go85
-rw-r--r--internal/game/correct_owner_panic_subscription_test.go85
-rw-r--r--internal/game/correct_quality_merge_test.go201
-rw-r--r--internal/game/correct_vanished_account_reset_test.go75
-rw-r--r--internal/game/economy.go83
-rw-r--r--internal/game/game.go131
-rw-r--r--internal/game/hardening_account_name_validation_test.go201
-rw-r--r--internal/game/hardening_admin_player_file_online_test.go70
-rw-r--r--internal/game/hardening_ammo_unequip_test.go181
-rw-r--r--internal/game/hardening_casino_entropy_wiring_test.go72
-rw-r--r--internal/game/hardening_casino_quit_autobet_test.go75
-rw-r--r--internal/game/hardening_craft_combat_helpers_test.go40
-rw-r--r--internal/game/hardening_craft_combine_fletch_test.go260
-rw-r--r--internal/game/hardening_craft_menu_skill_test.go91
-rw-r--r--internal/game/hardening_craft_message_item_test.go85
-rw-r--r--internal/game/hardening_credit_cap_test.go105
-rw-r--r--internal/game/hardening_economy_durability_test.go272
-rw-r--r--internal/game/hardening_harvest_yield_test.go168
-rw-r--r--internal/game/hardening_login_validation_test.go58
-rw-r--r--internal/game/hardening_owner_panic_test.go162
-rw-r--r--internal/game/hardening_pickup_quality_test.go123
-rw-r--r--internal/game/hardening_purge_session_reset_test.go50
-rw-r--r--internal/game/hardening_queue_cleanup_test.go139
-rw-r--r--internal/game/hardening_quit_bet_warning_test.go79
-rw-r--r--internal/game/hardening_retribution_mob_death_test.go90
-rw-r--r--internal/game/hardening_security_test.go100
-rw-r--r--internal/game/hardening_shipped_content_test.go60
-rw-r--r--internal/game/hardening_shipped_data_validation_test.go205
-rw-r--r--internal/game/hardening_shutdown_save_error_test.go63
-rw-r--r--internal/game/hardening_tick_lifecycle_test.go291
-rw-r--r--internal/game/owner.go60
-rw-r--r--internal/game/polish_craft_fail_delivery_test.go111
-rw-r--r--internal/game/polish_shop_merge_test.go55
-rw-r--r--internal/game/polish_wear_swap_quality_test.go140
-rw-r--r--internal/game/production_engine.go102
-rw-r--r--internal/game/production_index.go23
-rw-r--r--internal/game/quit_casino_test.go8
-rw-r--r--internal/game/session_registry.go8
-rw-r--r--internal/game/sys_combat.go14
-rw-r--r--internal/game/sys_triggers.go15
-rw-r--r--internal/game/tick.go34
-rw-r--r--internal/item/hardening_id_mismatch_test.go88
-rw-r--r--internal/item/store.go14
-rw-r--r--internal/net/server.go78
-rw-r--r--internal/net/server_test.go214
-rw-r--r--internal/persistence/hardening_txn_commit_durability_test.go135
-rw-r--r--internal/persistence/txn.go77
-rw-r--r--internal/player/correct_skill_validity_test.go21
-rw-r--r--internal/player/hardening_max_inventory_slots_test.go86
-rw-r--r--internal/player/hardening_password_params_test.go62
-rw-r--r--internal/player/hardening_store_sanitize_test.go114
-rw-r--r--internal/player/password.go25
-rw-r--r--internal/player/player.go36
-rw-r--r--internal/player/store.go44
-rw-r--r--internal/validate/checks.go151
-rw-r--r--internal/validate/correct_data_craft_validation_test.go102
-rw-r--r--internal/validate/hardening_craft_validation_test.go130
-rw-r--r--internal/validate/hardening_mob_stats_validation_test.go107
-rw-r--r--internal/validate/polish_destructive_fail_test.go64
-rw-r--r--internal/world/hardening_room_id_mismatch_test.go73
-rw-r--r--internal/world/world.go8
140 files changed, 9658 insertions, 530 deletions
diff --git a/.gitignore b/.gitignore
index 3466d59..844f2e7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,9 @@
!/data/players/accounts/.gitkeep
/data/players/characters/*
!/data/players/characters/.gitkeep
+# Admin undo history (password hashes are redacted, but never commit it).
+/data/.admin_history.json
+/data/.admin_history.json.bak
thoi
mud
.cache/
diff --git a/building_guide/rooms.md b/building_guide/rooms.md
index 4eaa843..ffe4f7b 100644
--- a/building_guide/rooms.md
+++ b/building_guide/rooms.md
@@ -332,9 +332,21 @@ casino configurations. Raw file editing is not a validated save endpoint.
the room's tables and machines (including legacy slots table entries).
- Supported table games are `blackjack` and `baccarat`; machines use `slots`
with the optional variant `huff_and_even_more_puff`.
+- Only one table/machine per game is addressable: a room is addressed by game
+ name (`play blackjack`), so a second entry for the same game, or the same
+ machine variant (including a legacy slots table alongside a slots machine),
+ is rejected as unreachable content.
- Omitted/zero numeric values keep their existing defaults. Negative bets,
timers, seat counts, split counts, decks, and payouts are rejected.
- An explicit `max_bet` must be at least `min_bet`; zero still means no cap.
+- A blackjack table that offers `surrender` must set `min_bet` to at least 2:
+ a surrendered hand returns half the bet, which rounds down to zero below
+ that (the omitted default of 1 is rejected too).
+- Shoe coverage is best-effort, not a hard floor. The shoe reshuffles at the
+ start of a round when it is smaller than the heuristic round size (eleven
+ cards per hand--an all-ace hand can exceed that), and `draw()` rebuilds an
+ exhausted shoe in place. A small `decks` value therefore never invalidates
+ a table or darkens the room's other casino games.
- `decks` is capped at 64 to bound shoe allocation. An explicit `shuffle_at`
must be greater than zero and less than one.
- Payouts and shuffle fractions must be finite (no NaN/infinity). Slots `payout`
diff --git a/building_guide/tick_order.md b/building_guide/tick_order.md
index b4d1f1e..b1246dd 100644
--- a/building_guide/tick_order.md
+++ b/building_guide/tick_order.md
@@ -107,9 +107,10 @@ stores initialized (`World`, `MobStore`, `Hub`, `queue`, etc. via
- Systems that touch the hub: guard with `if g.Hub == nil { return }` at the
top, *or* guard each `Hub.*` call with `if g.Hub != nil`. Either is fine.
- (Note: `Ticks.Start` runs *before* `SetHub` in `main.go`, so the master
- subscriber can fire with `Hub == nil` during startup — guards are not
- optional.)
+ (`main.go` now calls `SetHub` *before* `Ticks.Start`, so the production
+ server never ticks with a nil hub; the guards are belt-and-braces for a
+ `*Game` that tests construct without `SetHub`, such as
+ `newRunTickSystemsTestGame`.)
- Systems that iterate over maps/slices held on `*Game` (`sequences`,
`restTimers`, `guardWatchTimers`, `hackingStates`, …) must tolerate empty
/ freshly-initialized maps. `game.New` initializes all of them; tests that
@@ -126,16 +127,16 @@ When writing content that depends on per-tick ordering (a trigger that
should fire *after* regen, a sequence whose step messages should appear
*before* the move completes, etc.), consult the table above and remember:
-- `MoveTick` (step 2) runs before `SequenceTick` (step 3) — the table
+- `MoveTick` (table #3) runs before `SequenceTick` (table #4) — the table
fires top-to-bottom, so a move that completes this tick will have its
- enter-room knock-on triggers fire (inside step 1 or step 2) before
- trigger `sequences` advance in step 3.
+ enter-room knock-on triggers fire (inside `ProcessQueuedCommands`, #1, or
+ `MoveTick`, #3) before trigger `sequences` advance in #4.
- `on_enter` / `on_exit` room triggers fire from command execution, not
- from a per-tick system; they run inside `ProcessQueuedCommands` (step 1)
- or `MoveTick` (step 2) when the move actually completes.
-- Shop restock (step 6) happens *before* `RegenTick` (step 7) and *before*
- `AdvanceActions` (step 13); if a gathering action completes this tick
- it does so in step 13, by which point shop state for this tick has
+ from a per-tick system; they run inside `ProcessQueuedCommands` (#1)
+ or `MoveTick` (#3) when the move actually completes.
+- Shop restock (`MobStore.Tick`, #7) happens *before* `RegenTick` (#8) and
+ *before* `AdvanceActions` (#14); if a gathering action completes this tick
+ it does so in #14, by which point shop state for this tick has
already advanced.
When in doubt, the test in `tick_systems_test.go` is the executable
diff --git a/cmd/thoi/main.go b/cmd/thoi/main.go
index a07fbcc..cff6eb6 100644
--- a/cmd/thoi/main.go
+++ b/cmd/thoi/main.go
@@ -42,8 +42,6 @@ func main() {
}
g := game.New(dataDir, &cfg.DefaultColors, &cfg.ConstantColors, cfg.StartupValidation, cfg.StartingRoom)
- g.Ticks.Start(cfg.TickLength)
- defer g.Ticks.Stop()
// The master per-tick subscriber. Registered as engine subscription ID 1,
// the engine fires it before any dynamically-registered combat/aggro/respawn
@@ -63,6 +61,12 @@ func main() {
log.Fatalf("failed to start server: %v", err)
}
g.SetHub(server.Hub())
+ // Start the tick engine only after the hub exists: every tick system reads
+ // g.Hub on the owner goroutine, so SetHub must happen-before the first
+ // tick. Starting earlier left a window (net.NewServer generates its RSA
+ // keys in between) in which a tick could observe a nil hub.
+ g.Ticks.Start(cfg.TickLength)
+ defer g.Ticks.Stop()
// The entire editor API request is one owner task, including file commits,
// runtime migration, undo history and reloads. These callbacks already run
@@ -77,6 +81,9 @@ func main() {
if err != nil {
log.Fatalf("failed to start admin HTTP server: %v", err)
}
+ // The raw file editor must refuse a file whose character/account is
+ // online; wired before ListenAndServe so the first request sees it.
+ adminSrv.SetPlayerFileOnlineCheck(g.AdminPlayerFileOnline)
go func() {
if err := adminSrv.ListenAndServe(); err != nil {
log.Printf("admin HTTP server error: %v", err)
@@ -90,6 +97,9 @@ func main() {
if err != nil {
log.Fatalf("failed to start admin HTTPS server: %v", err)
}
+ // Same online-file refusal as the HTTP editor: both servers expose
+ // the same raw file API.
+ adminSrv.SetPlayerFileOnlineCheck(g.AdminPlayerFileOnline)
go func() {
if err := adminSrv.ListenAndServe(); err != nil {
log.Printf("admin HTTPS server error: %v", err)
@@ -118,9 +128,8 @@ func main() {
// One shutdown request handler: signals and the in-game shutdown
// command both converge on RequestShutdown, which drains work,
// resolves outstanding wagers, saves owned state, then closes the
- // transports. ListenAndServe returns after Shutdown, and main returns
- // normally (no os.Exit; a final-save failure is logged and the exit
- // code reports it via the transport error path).
+ // transports. ListenAndServe returns after Shutdown; main then reports
+ // a failed final save with a non-zero exit (see below).
g.SetTransportStop(server.Shutdown)
go func() {
<-sigCh
@@ -130,4 +139,11 @@ func main() {
if err := server.ListenAndServe(g.HandleSessionOnOwner); err != nil {
log.Fatalf("server error: %v", err)
}
+ // The transports close inside the shutdown sequence, so a clean return
+ // from ListenAndServe is where the final save is known to have finished.
+ // It needs its own report: the transport error path can only say that
+ // the server stopped, never that online state failed to persist.
+ if err := g.ShutdownSaveError(); err != nil {
+ log.Fatalf("shutdown: final save failed: %v", err)
+ }
}
diff --git a/data/items/000_test_item.yaml b/data/items/000_test_item.yaml
deleted file mode 100644
index 591708f..0000000
--- a/data/items/000_test_item.yaml
+++ /dev/null
@@ -1,40 +0,0 @@
-color: "6C"
-craft:
- - end_message: ""
- fail: ""
- ingredients:
- - items:
- - headless_arrow
- quantity: 15
- - items:
- - adamant_arrowtips
- quantity: 15
- level: 60
- output_qty: 15
- start_message: ""
- station: []
- subtype: ""
- success_message: ""
- ticks_per_cycle: 2
- tool: pickaxe
- type: fletching
- xp: 10
-description: An adamant-tipped arrow.
-equipment:
- other:
- ranged_strength: 31
- slot: ammo
- type: ammo
-farm_harvest_xp: 0
-farm_level: 0
-farm_max_yield: 0
-farm_min_yield: 0
-farm_patch_type: herb
-farm_plant_xp: 0
-farm_stages: 0
-id: 000_test_item
-max_quality: 0
-name: adamant arrow
-recoverable: true
-stackable: true
-value: 20
diff --git a/data/items/consumables/anchovies.yaml b/data/items/consumables/anchovies.yaml
index 3ddb2cb..5c05086 100644
--- a/data/items/consumables/anchovies.yaml
+++ b/data/items/consumables/anchovies.yaml
@@ -10,6 +10,10 @@ craft:
station:
- fire
- cooking_range
+ success:
+ base: 0.5
+ cap: 0.95
+ per_level: 0.02
type: cooking
ticks_per_cycle: 6
xp: 30
diff --git a/data/items/consumables/bread.yaml b/data/items/consumables/bread.yaml
index 4b85409..92b2794 100644
--- a/data/items/consumables/bread.yaml
+++ b/data/items/consumables/bread.yaml
@@ -10,6 +10,10 @@ craft:
success_message: "You bake the dough into a %n."
station:
- cooking_range
+ success:
+ base: 0.5
+ cap: 0.95
+ per_level: 0.02
type: cooking
ticks_per_cycle: 6
xp: 40
diff --git a/data/items/consumables/herring.yaml b/data/items/consumables/herring.yaml
index ce68ec3..3cd6cd5 100644
--- a/data/items/consumables/herring.yaml
+++ b/data/items/consumables/herring.yaml
@@ -10,6 +10,10 @@ craft:
station:
- fire
- cooking_range
+ success:
+ base: 0.5
+ cap: 0.95
+ per_level: 0.02
type: cooking
ticks_per_cycle: 6
xp: 50
diff --git a/data/items/consumables/pastry_dough.yaml b/data/items/consumables/pastry_dough.yaml
index 719581d..c784a92 100644
--- a/data/items/consumables/pastry_dough.yaml
+++ b/data/items/consumables/pastry_dough.yaml
@@ -5,9 +5,13 @@ craft:
- items:
- pot_of_flour
quantity: 1
+ byproducts:
+ - empty_pot
- items:
- bucket_of_water
quantity: 1
+ byproducts:
+ - empty_bucket
ticks_per_cycle: 2
description: A ball of uncooked pastry dough. It needs to be baked.
name: pastry dough
diff --git a/data/items/consumables/pie_dish.yaml b/data/items/consumables/pie_dish.yaml
index 50b4717..9cad088 100644
--- a/data/items/consumables/pie_dish.yaml
+++ b/data/items/consumables/pie_dish.yaml
@@ -4,6 +4,7 @@ craft:
- items:
- unfired_pie_dish
quantity: 1
+ fail: unfired_pie_dish
fail_message: The pie dish cracks in the oven and is ruined.
level: 7
success_message: "You fire the %i1 in the oven."
diff --git a/data/items/consumables/pizza_dough.yaml b/data/items/consumables/pizza_dough.yaml
index 3c3ea13..db69e27 100644
--- a/data/items/consumables/pizza_dough.yaml
+++ b/data/items/consumables/pizza_dough.yaml
@@ -5,9 +5,13 @@ craft:
- items:
- pot_of_flour
quantity: 1
+ byproducts:
+ - empty_pot
- items:
- bucket_of_water
quantity: 1
+ byproducts:
+ - empty_bucket
ticks_per_cycle: 2
description: A ball of uncooked pizza dough. It needs to be baked.
name: pizza dough
diff --git a/data/items/consumables/salmon.yaml b/data/items/consumables/salmon.yaml
index bd77f40..793e1d4 100644
--- a/data/items/consumables/salmon.yaml
+++ b/data/items/consumables/salmon.yaml
@@ -10,6 +10,10 @@ craft:
station:
- fire
- cooking_range
+ success:
+ base: 0.5
+ cap: 0.95
+ per_level: 0.02
type: cooking
ticks_per_cycle: 6
xp: 90
diff --git a/data/items/consumables/sardine.yaml b/data/items/consumables/sardine.yaml
index e4ae3b8..bb8b00c 100644
--- a/data/items/consumables/sardine.yaml
+++ b/data/items/consumables/sardine.yaml
@@ -10,6 +10,10 @@ craft:
station:
- fire
- cooking_range
+ success:
+ base: 0.5
+ cap: 0.95
+ per_level: 0.02
type: cooking
ticks_per_cycle: 6
xp: 40
diff --git a/data/items/consumables/shrimps.yaml b/data/items/consumables/shrimps.yaml
index 6e842e8..c52955c 100644
--- a/data/items/consumables/shrimps.yaml
+++ b/data/items/consumables/shrimps.yaml
@@ -10,6 +10,10 @@ craft:
station:
- fire
- cooking_range
+ success:
+ base: 0.5
+ cap: 0.95
+ per_level: 0.02
type: cooking
ticks_per_cycle: 6
xp: 30
diff --git a/data/items/consumables/trout.yaml b/data/items/consumables/trout.yaml
index f598fb2..06ea15a 100644
--- a/data/items/consumables/trout.yaml
+++ b/data/items/consumables/trout.yaml
@@ -10,6 +10,10 @@ craft:
station:
- fire
- cooking_range
+ success:
+ base: 0.5
+ cap: 0.95
+ per_level: 0.02
type: cooking
ticks_per_cycle: 6
xp: 70
diff --git a/data/items/materials/adamantite_bar.yaml b/data/items/materials/adamantite_bar.yaml
index f5a5010..47fbf97 100644
--- a/data/items/materials/adamantite_bar.yaml
+++ b/data/items/materials/adamantite_bar.yaml
@@ -7,11 +7,16 @@ craft:
- items:
- coal
quantity: 6
+ fail: adamantite_ore
fail_message: You fail to smelt a usable bar.
level: 65
subtype: smelting
station:
- furnace
+ success:
+ base: 0.5
+ cap: 0.95
+ per_level: 0.02
type: smithing
ticks_per_cycle: 4
xp: 37
diff --git a/data/items/materials/bronze_bar.yaml b/data/items/materials/bronze_bar.yaml
index 0f40878..3e75269 100644
--- a/data/items/materials/bronze_bar.yaml
+++ b/data/items/materials/bronze_bar.yaml
@@ -7,11 +7,16 @@ craft:
- items:
- tin_ore
quantity: 1
+ fail: copper_ore
fail_message: You fail to smelt a usable bar.
level: 1
subtype: smelting
station:
- furnace
+ success:
+ base: 0.5
+ cap: 0.95
+ per_level: 0.02
type: smithing
ticks_per_cycle: 4
xp: 6
diff --git a/data/items/materials/gold_bar.yaml b/data/items/materials/gold_bar.yaml
index 8115fbd..1d4e28d 100644
--- a/data/items/materials/gold_bar.yaml
+++ b/data/items/materials/gold_bar.yaml
@@ -4,11 +4,16 @@ craft:
- items:
- gold_ore
quantity: 1
+ fail: gold_ore
fail_message: You fail to smelt a usable bar.
level: 45
subtype: smelting
station:
- furnace
+ success:
+ base: 0.5
+ cap: 0.95
+ per_level: 0.02
type: smithing
ticks_per_cycle: 4
xp: 22
diff --git a/data/items/materials/iron_bar.yaml b/data/items/materials/iron_bar.yaml
index 9f0ac58..1b0ea27 100644
--- a/data/items/materials/iron_bar.yaml
+++ b/data/items/materials/iron_bar.yaml
@@ -4,6 +4,7 @@ craft:
- items:
- iron_ore
quantity: 1
+ fail: iron_ore
fail_message: The iron is too impure to make a workable bar.
level: 20
subtype: smelting
@@ -11,8 +12,8 @@ craft:
- furnace
success:
base: 0.5
- cap: 0.5
- per_level: 0
+ cap: 0.95
+ per_level: 0.02
type: smithing
ticks_per_cycle: 4
xp: 12
diff --git a/data/items/materials/mithril_bar.yaml b/data/items/materials/mithril_bar.yaml
index b19cbda..b93e4d9 100644
--- a/data/items/materials/mithril_bar.yaml
+++ b/data/items/materials/mithril_bar.yaml
@@ -7,11 +7,16 @@ craft:
- items:
- coal
quantity: 4
+ fail: mithril_ore
fail_message: You fail to smelt a usable bar.
level: 50
subtype: smelting
station:
- furnace
+ success:
+ base: 0.5
+ cap: 0.95
+ per_level: 0.02
type: smithing
ticks_per_cycle: 4
xp: 30
diff --git a/data/items/materials/runite_bar.yaml b/data/items/materials/runite_bar.yaml
index 24945ea..d03417b 100644
--- a/data/items/materials/runite_bar.yaml
+++ b/data/items/materials/runite_bar.yaml
@@ -7,11 +7,16 @@ craft:
- items:
- coal
quantity: 8
+ fail: runite_ore
fail_message: You fail to smelt a usable bar.
level: 80
subtype: smelting
station:
- furnace
+ success:
+ base: 0.5
+ cap: 0.95
+ per_level: 0.02
type: smithing
ticks_per_cycle: 4
xp: 50
diff --git a/data/items/materials/silver_bar.yaml b/data/items/materials/silver_bar.yaml
index 7561ba0..03feecb 100644
--- a/data/items/materials/silver_bar.yaml
+++ b/data/items/materials/silver_bar.yaml
@@ -4,11 +4,16 @@ craft:
- items:
- silver_ore
quantity: 1
+ fail: silver_ore
fail_message: You fail to smelt a usable bar.
level: 25
subtype: smelting
station:
- furnace
+ success:
+ base: 0.5
+ cap: 0.95
+ per_level: 0.02
type: smithing
ticks_per_cycle: 4
xp: 14
diff --git a/data/items/materials/steel_bar.yaml b/data/items/materials/steel_bar.yaml
index 563c887..39e0689 100644
--- a/data/items/materials/steel_bar.yaml
+++ b/data/items/materials/steel_bar.yaml
@@ -7,11 +7,16 @@ craft:
- items:
- coal
quantity: 2
+ fail: iron_ore
fail_message: You fail to smelt a usable bar.
level: 40
subtype: smelting
station:
- furnace
+ success:
+ base: 0.5
+ cap: 0.95
+ per_level: 0.02
type: smithing
ticks_per_cycle: 4
xp: 17
diff --git a/data/items/tools/empty_pot.yaml b/data/items/tools/empty_pot.yaml
index 01609c6..ea3016b 100644
--- a/data/items/tools/empty_pot.yaml
+++ b/data/items/tools/empty_pot.yaml
@@ -4,6 +4,7 @@ craft:
- items:
- unfired_pot
quantity: 1
+ fail: unfired_pot
fail_message: The pot cracks in the oven and is ruined.
level: 1
success_message: "You fire the %i1 in the oven."
diff --git a/data/items/tools/plant_pot.yaml b/data/items/tools/plant_pot.yaml
index 440a65d..17d7466 100644
--- a/data/items/tools/plant_pot.yaml
+++ b/data/items/tools/plant_pot.yaml
@@ -4,6 +4,7 @@ craft:
- items:
- unfired_plant_pot
quantity: 1
+ fail: unfired_plant_pot
fail_message: The plant pot cracks in the oven and is ruined.
level: 19
success_message: "You fire the %i1 in the oven."
diff --git a/internal/admin/api_colors.go b/internal/admin/api_colors.go
index b57ad82..7989e35 100644
--- a/internal/admin/api_colors.go
+++ b/internal/admin/api_colors.go
@@ -1,6 +1,8 @@
package admin
import (
+ "bytes"
+ "fmt"
"net/http"
"os"
"strings"
@@ -575,14 +577,145 @@ func (s *AdminServer) handleColorsReset(w http.ResponseWriter, r *http.Request)
writeJSON(w, map[string]any{"ok": true, "colors": live, "constant_colors": liveConst})
}
-// writeConfigFile marshals the entire live *config.Config back to config.yaml,
-// overwriting whatever was on disk. A full re-marshal drops the file's manual
-// comments but produces a valid, reloadable file with the edited colors.
+// writeConfigFile persists the two edited color maps back to config.yaml
+// without overwriting unrelated operator edits made after startup. The file
+// is re-read and only the default_colors / constant_colors mappings are
+// patched (comments and every other key are preserved), after writing a
+// backup. A missing/unreadable config falls back to a full marshal so a fresh
+// install still gets a valid file.
func (s *AdminServer) writeConfigFile() error {
- _ = backupFile(s.configPath) // best-effort snapshot for safety
- out, err := yaml.Marshal(s.cfg)
+ if err := s.backupConfigFile(); err != nil {
+ return err
+ }
+ data, err := os.ReadFile(s.configPath)
+ if err != nil {
+ // No config on disk yet (fresh install or removed): write the live
+ // config whole so the file exists and reloads.
+ out, merr := yaml.Marshal(s.cfg)
+ if merr != nil {
+ return merr
+ }
+ return os.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)
}
+
+// backupConfigFile copies the current config.yaml next to itself before an
+// edit, so an operator can recover from a bad color save. A fresh install
+// (no file yet) has nothing to back up.
+func (s *AdminServer) backupConfigFile() error {
+ data, err := os.ReadFile(s.configPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+ }
+ return os.WriteFile(s.configPath+".bak", data, 0600)
+}
+
+// patchColorMaps updates the default_colors and constant_colors mappings in a
+// config.yaml document, preserving every other key and all comments. Only the
+// keys present in the live maps are written: keys an operator added inside
+// those sections, and every comment around them, survive the save.
+func patchColorMaps(data []byte, defaults, constants map[string]string) ([]byte, error) {
+ var doc yaml.Node
+ if err := yaml.Unmarshal(data, &doc); err != nil {
+ return nil, fmt.Errorf("parse config: %w", err)
+ }
+ if len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode {
+ return nil, fmt.Errorf("config is not a mapping")
+ }
+ root := doc.Content[0]
+ for _, patch := range []struct {
+ key string
+ m map[string]string
+ }{
+ {"default_colors", defaults},
+ {"constant_colors", constants},
+ } {
+ if err := patchMapping(root, patch.key, patch.m); err != nil {
+ return nil, err
+ }
+ }
+ var buf bytes.Buffer
+ enc := yaml.NewEncoder(&buf)
+ enc.SetIndent(2)
+ if err := enc.Encode(&doc); err != nil {
+ return nil, err
+ }
+ if err := enc.Close(); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
+
+// patchMapping writes the entries of m into the mapping at root[key], creating
+// the mapping (and the key) when absent. Entries already on disk are updated
+// in place so their comments survive; entries on disk that m does not name are
+// left exactly as the operator wrote them.
+func patchMapping(root *yaml.Node, key string, m map[string]string) error {
+ fresh, err := colorMapNode(m)
+ if err != nil {
+ return err
+ }
+ for i := 0; i+1 < len(root.Content); i += 2 {
+ if root.Content[i].Value != key {
+ continue
+ }
+ if existing := root.Content[i+1]; existing.Kind == yaml.MappingNode {
+ mergeMapNode(existing, fresh)
+ return nil
+ }
+ root.Content[i+1] = fresh
+ return nil
+ }
+ root.Content = append(root.Content,
+ &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, fresh)
+ return nil
+}
+
+// mergeMapNode copies src's entries into dst: a key dst already has is updated
+// in place (keeping the operator's comments on it), a key it lacks is appended
+// with the node from src.
+func mergeMapNode(dst, src *yaml.Node) {
+ for i := 0; i+1 < len(src.Content); i += 2 {
+ key, value := src.Content[i], src.Content[i+1]
+ merged := false
+ for j := 0; j+1 < len(dst.Content); j += 2 {
+ if dst.Content[j].Value != key.Value {
+ continue
+ }
+ if dst.Content[j+1].Kind == yaml.ScalarNode && value.Kind == yaml.ScalarNode {
+ dst.Content[j+1].Value = value.Value
+ dst.Content[j+1].Tag = value.Tag
+ } else {
+ dst.Content[j+1] = value
+ }
+ merged = true
+ break
+ }
+ if !merged {
+ dst.Content = append(dst.Content, key, value)
+ }
+ }
+}
+
+func colorMapNode(m map[string]string) (*yaml.Node, error) {
+ b, err := yaml.Marshal(m)
+ if err != nil {
+ return nil, err
+ }
+ var n yaml.Node
+ if err := yaml.Unmarshal(b, &n); err != nil {
+ return nil, err
+ }
+ if len(n.Content) == 0 {
+ return &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}, nil
+ }
+ return n.Content[0], nil
+}
diff --git a/internal/admin/api_files.go b/internal/admin/api_files.go
index 2b59526..96b5e81 100644
--- a/internal/admin/api_files.go
+++ b/internal/admin/api_files.go
@@ -82,6 +82,10 @@ func (s *AdminServer) handleFileRename(w http.ResponseWriter, r *http.Request) {
failJSON(w, err.Error())
return
}
+ if isPlayerDataFilePath(relPath) && s.playerFileOnline(relPath) {
+ failJSON(w, "cannot rename a player/account file while it is online; log the character out first")
+ return
+ }
oldContent, err := snapshotFile(oldAbs)
if err != nil {
@@ -118,6 +122,12 @@ func (s *AdminServer) handleFileRename(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"ok": true, "path": newRelPath})
}
+// handleFiles is only reachable through ownerMiddleware (see server.go), so
+// every player/account mutation below executes on the game owner and cannot
+// interleave with a live save. This is the same single-owner guarantee the
+// lifecycle transactions rely on. Additionally, a file whose character/account
+// is currently online is refused: overwriting it would clobber the in-memory
+// player the game will later save.
func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) {
relPath := r.URL.Query().Get("path")
relPath = strings.TrimPrefix(relPath, "/")
@@ -188,6 +198,10 @@ func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) {
failJSON(w, "missing path")
return
}
+ if isPlayerDataFilePath(relPath) && s.playerFileOnline(relPath) {
+ failJSON(w, "cannot edit a player/account file while it is online; log the character out first")
+ return
+ }
body, err := io.ReadAll(r.Body)
if err != nil {
failJSON(w, "read body: "+err.Error())
@@ -232,6 +246,10 @@ func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) {
failJSON(w, "missing path")
return
}
+ if isPlayerDataFilePath(relPath) && s.playerFileOnline(relPath) {
+ failJSON(w, "cannot delete a player/account file while it is online; log the character out first")
+ return
+ }
oldContent, _ := snapshotFile(fullPath)
if err := os.Remove(fullPath); err != nil {
failJSON(w, "delete error: "+err.Error())
@@ -253,9 +271,48 @@ func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) {
// writeFilePreservingMode writes data atomically without widening an existing
// file's permissions (persistence.WriteFile preserves the current mode and
-// uses 0644 for a new file).
+// uses the caller's perm for a new file). Player/account files are treated as
+// owner-only: they carry password hashes and live player state, so a newly
+// created file is 0600 and an existing wider mode is tightened.
func writeFilePreservingMode(path string, data []byte) error {
- return persistence.WriteFile(path, data, 0644)
+ perm := os.FileMode(0644)
+ if isPlayerDataAbsPath(path) {
+ perm = 0600
+ }
+ if err := persistence.WriteFile(path, data, perm); err != nil {
+ return err
+ }
+ if perm == 0600 {
+ return os.Chmod(path, 0600)
+ }
+ return nil
+}
+
+// isPlayerDataFilePath reports whether a data-relative path is a player or
+// account YAML file (password hash / live character state).
+func isPlayerDataFilePath(relPath string) bool {
+ clean := filepath.ToSlash(filepath.Clean(relPath))
+ return strings.HasPrefix(clean, "players/accounts/") || strings.HasPrefix(clean, "players/characters/")
+}
+
+// isPlayerDataAbsPath is the absolute-path form of isPlayerDataFilePath: it
+// matches .../players/accounts/<name>.yaml and .../players/characters/<name>.yaml.
+func isPlayerDataAbsPath(path string) bool {
+ parent := filepath.Base(filepath.Dir(path))
+ if parent != "accounts" && parent != "characters" {
+ return false
+ }
+ return filepath.Base(filepath.Dir(filepath.Dir(path))) == "players"
+}
+
+// playerFileOnline reports whether the character/account addressed by a
+// data-relative player file path has a live session. The game wires this via
+// SetPlayerFileOnlineCheck; nil (unwired) disables the refusal.
+func (s *AdminServer) playerFileOnline(relPath string) bool {
+ if s.playerFileOnlineCheck == nil {
+ return false
+ }
+ return s.playerFileOnlineCheck(relPath)
}
// isAccountFilePath reports whether a data-relative path is an account file
diff --git a/internal/admin/api_triggers.go b/internal/admin/api_triggers.go
index 5b82465..08e2855 100644
--- a/internal/admin/api_triggers.go
+++ b/internal/admin/api_triggers.go
@@ -147,12 +147,21 @@ func (s *AdminServer) handleTriggerByID(w http.ResponseWriter, r *http.Request)
os.Remove(oldPath)
}
- s.undoStack.Push(ChangeDesc{
+ desc := ChangeDesc{
Description: "Update trigger " + id,
FilePath: newPath,
OldContent: oldContent,
NewContent: triggerData,
- })
+ }
+ if findErr == nil && newSubdir != oldDir {
+ // A kind change moves the file between subdirectories. Record the
+ // old path as the before-image location so undo restores the old
+ // content there (and removes the new file) instead of writing the
+ // old content into the new directory and orphaning the old path.
+ desc.FilePath = oldPath
+ desc.NewFilePath = newPath
+ }
+ s.undoStack.Push(desc)
s.rebuildTriggerIndex()
writeJSON(w, map[string]any{"ok": true, "id": id, "kind": newKind})
diff --git a/internal/admin/correct_colors_merge_limiter_test.go b/internal/admin/correct_colors_merge_limiter_test.go
new file mode 100644
index 0000000..7ee9071
--- /dev/null
+++ b/internal/admin/correct_colors_merge_limiter_test.go
@@ -0,0 +1,170 @@
+package admin
+
+// Regression tests for two infra re-verification findings:
+// - saving colors merges the live maps' keys into config.yaml instead of
+// replacing the default_colors/constant_colors nodes, so keys and comments
+// an operator added inside those sections survive the save
+// - the /login limiter never evicts an address with an active cooldown, so a
+// blocked source cannot unblock itself by filling the tracked-address map
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "thehouseoficarus/internal/config"
+)
+
+func TestColorsSavePreservesOperatorKeysInsideColorSections(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.yaml")
+ orig := "# operator note\n" +
+ "tick_length: 900\n" +
+ "default_colors:\n" +
+ " # keep this: hand-added\n" +
+ " room_name: \"74 bold\" # tweaked\n" +
+ " my_custom_color: \"FF\"\n" +
+ "# constant section\n" +
+ "constant_colors:\n" +
+ " hp_bar_low: \"A7\"\n" +
+ " my_custom_constant: \"1E\"\n"
+ if err := os.WriteFile(path, []byte(orig), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ // The live maps hold the values the process loaded, so the two hand-added
+ // keys are not in them: the save must not drop them from the file.
+ cfg := config.Default()
+ cfg.DefaultColors = config.ColorsConfig{"room_name": "74 bold"}
+ cfg.ConstantColors = config.ColorsConfig{"hp_bar_low": "A7"}
+ s := &AdminServer{cfg: cfg, configPath: path}
+
+ body, _ := json.Marshal(map[string]any{
+ "colors": map[string]string{"room_name": "FF bold", "say": "FF"},
+ "constant_colors": map[string]string{"hp_bar_low": "A7 bold"},
+ })
+ req := httptest.NewRequest(http.MethodPost, "/api/colors", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ s.saveColors(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("save status = %d: %s", w.Code, w.Body.String())
+ }
+
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ out := string(raw)
+ for _, want := range []string{
+ "# operator note",
+ "tick_length: 900",
+ "# keep this: hand-added",
+ "# tweaked",
+ "my_custom_color",
+ "# constant section",
+ "my_custom_constant",
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("saved config lost %q:\n%s", want, out)
+ }
+ }
+
+ reloaded, err := config.Load(path)
+ if err != nil {
+ t.Fatalf("saved config no longer loads: %v\n%s", err, out)
+ }
+ for key, want := range map[string]string{
+ "room_name": "FF bold",
+ "say": "FF",
+ "my_custom_color": "FF",
+ "my_custom_constant": "1E",
+ } {
+ source := reloaded.DefaultColors
+ if key == "my_custom_constant" {
+ source = reloaded.ConstantColors
+ }
+ if got := source[key]; got != want {
+ t.Errorf("default/constant color %s = %q, want %q", key, got, want)
+ }
+ }
+ if got := reloaded.ConstantColors["hp_bar_low"]; got != "A7 bold" {
+ t.Errorf("constant color hp_bar_low = %q, want %q", got, "A7 bold")
+ }
+}
+
+// TestLoginLimiterNeverEvictsAnAddressInCooldown: with the tracked-address map
+// full of cooling-down addresses, a flood from fresh addresses must not drop
+// one of them. Its failure count is reset when the block starts, so it fails
+// the idle test while still being the oldest entry: evicting it would hand a
+// blocked source a fresh allowance.
+func TestLoginLimiterNeverEvictsAnAddressInCooldown(t *testing.T) {
+ base := time.Now()
+ l := newLoginLimiter()
+ l.now = func() time.Time { return base }
+
+ blocked := make([]string, 0, adminLoginMaxTracked)
+ for i := 0; i < adminLoginMaxTracked; i++ {
+ ip := fmt.Sprintf("198.51.100.%d", i)
+ for j := 0; j < adminLoginMaxFailures; j++ {
+ l.fail(ip)
+ }
+ blocked = append(blocked, ip)
+ }
+ if got := len(l.entries); got != adminLoginMaxTracked {
+ t.Fatalf("tracked %d addresses, want the cap %d", got, adminLoginMaxTracked)
+ }
+
+ for i := 0; i < adminLoginMaxTracked*2; i++ {
+ l.fail(fmt.Sprintf("203.0.113.%d", i))
+ }
+ if got := len(l.entries); got > adminLoginMaxTracked {
+ t.Fatalf("tracked %d addresses, want at most %d", got, adminLoginMaxTracked)
+ }
+ for _, ip := range blocked {
+ if l.allow(ip) {
+ t.Fatalf("address %s was evicted while cooling down and may log in again", ip)
+ }
+ }
+ if _, ok := l.entries["203.0.113.0"]; ok {
+ t.Fatal("a fresh address was tracked by evicting an address in cooldown")
+ }
+}
+
+// TestLoginLimiterEvictsAnAddressWithNoCooldown: an address with failures but
+// no cooldown is still evictable, so the map keeps accepting new addresses
+// while any evictable entry exists.
+func TestLoginLimiterEvictsAnAddressWithNoCooldown(t *testing.T) {
+ base := time.Now()
+ l := newLoginLimiter()
+ l.now = func() time.Time { return base }
+
+ l.fail("192.0.2.1")
+ for i := 0; i < adminLoginMaxTracked-1; i++ {
+ ip := fmt.Sprintf("198.51.100.%d", i)
+ for j := 0; j < adminLoginMaxFailures; j++ {
+ l.fail(ip)
+ }
+ }
+ if _, ok := l.entries["192.0.2.1"]; !ok {
+ t.Fatal("setup: the single-failure address was not tracked")
+ }
+
+ l.fail("192.0.2.99")
+ if _, ok := l.entries["192.0.2.1"]; ok {
+ t.Fatal("the address with no cooldown was not evicted to make room")
+ }
+ if _, ok := l.entries["192.0.2.99"]; !ok {
+ t.Fatal("the fresh address was not tracked after evicting an evictable entry")
+ }
+ if got := len(l.entries); got != adminLoginMaxTracked {
+ t.Fatalf("tracked %d addresses after eviction, want %d", got, adminLoginMaxTracked)
+ }
+}
diff --git a/internal/admin/correct_undo_account_image_test.go b/internal/admin/correct_undo_account_image_test.go
new file mode 100644
index 0000000..51fe037
--- /dev/null
+++ b/internal/admin/correct_undo_account_image_test.go
@@ -0,0 +1,262 @@
+package admin
+
+// Regression tests for the undo-history account-file paths re-verified in the
+// infra pass:
+// - a history image whose password hash was redacted on disk cannot be
+// written over an account after a restart (login could never repair the
+// placeholder, and the raw editor copies it back)
+// - undo/redo refuses a character/account file whose owner is online, the
+// same refusal the raw file editor applies
+// - the refusal is scoped to player files: other data files undo normally
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+const (
+ testAccountHash = "argon2id:1:65536:2:c2FsdHNhbHRzYWx0c2E:deadbeef"
+ testAccountLive = "name: bob\npassword_hash: " + testAccountHash + "\nadmin: false\n"
+ testAccountPromote = "name: bob\npassword_hash: " + testAccountHash + "\nadmin: true\n"
+)
+
+func writeAccountFile(t *testing.T, path, content string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0600); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// TestUndoOfRedactedAccountImageDoesNotClobberAccount drives the restart the
+// history file exists to survive: Push redacts the persisted image, a fresh
+// UndoStack loads that redacted image, and the undo must then fail rather than
+// write "[redacted]" into a live account.
+func TestUndoOfRedactedAccountImageDoesNotClobberAccount(t *testing.T) {
+ t.Run("delete of a removed account fails without creating it", func(t *testing.T) {
+ dir := t.TempDir()
+ accPath := filepath.Join(dir, "players", "accounts", "bob.yaml")
+ writeAccountFile(t, accPath, testAccountLive)
+
+ us := NewUndoStack(dir)
+ us.Push(ChangeDesc{
+ Description: "delete bob",
+ FilePath: accPath,
+ OldContent: []byte(testAccountLive),
+ IsDelete: true,
+ })
+ if err := os.Remove(accPath); err != nil {
+ t.Fatal(err)
+ }
+ raw, err := os.ReadFile(filepath.Join(dir, ".admin_history.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(raw), redactedAccountHash) {
+ t.Fatalf("history file did not redact the hash:\n%s", raw)
+ }
+
+ restarted := NewUndoStack(dir)
+ if ch := restarted.Undo(); ch != nil {
+ t.Fatalf("undo wrote a redacted account image: %s", ch.ShortDesc())
+ }
+ if got, err := os.ReadFile(accPath); err == nil {
+ t.Fatalf("undo recreated the account from the redacted image:\n%s", got)
+ } else if !os.IsNotExist(err) {
+ t.Fatalf("stat recreated account: %v", err)
+ }
+ })
+
+ t.Run("rename restores the real hash from the renamed file", func(t *testing.T) {
+ dir := t.TempDir()
+ oldPath := filepath.Join(dir, "players", "accounts", "bob.yaml")
+ newPath := filepath.Join(dir, "players", "accounts", "robert.yaml")
+ writeAccountFile(t, oldPath, testAccountLive)
+
+ us := NewUndoStack(dir)
+ us.Push(ChangeDesc{
+ Description: "rename bob",
+ FilePath: oldPath,
+ NewFilePath: newPath,
+ OldContent: []byte(testAccountLive),
+ NewContent: []byte(testAccountLive),
+ })
+ if err := os.Rename(oldPath, newPath); err != nil {
+ t.Fatal(err)
+ }
+
+ restarted := NewUndoStack(dir)
+ if ch := restarted.Undo(); ch == nil {
+ t.Fatal("undo of a rename must restore the account from its live renamed copy")
+ }
+ got, err := os.ReadFile(oldPath)
+ if err != nil {
+ t.Fatalf("undo did not restore the old path: %v", err)
+ }
+ if !strings.Contains(string(got), testAccountHash) {
+ t.Fatalf("restored account lost its hash:\n%s", got)
+ }
+ if strings.Contains(string(got), redactedAccountHash) {
+ t.Fatalf("restored account carries the redaction placeholder:\n%s", got)
+ }
+ if _, err := os.Stat(newPath); !os.IsNotExist(err) {
+ t.Fatalf("undo left the renamed file behind: %v", err)
+ }
+ })
+
+ t.Run("rename whose only copy is gone fails before removing anything", func(t *testing.T) {
+ dir := t.TempDir()
+ oldPath := filepath.Join(dir, "players", "accounts", "bob.yaml")
+ newPath := filepath.Join(dir, "players", "accounts", "robert.yaml")
+
+ us := NewUndoStack(dir)
+ us.Push(ChangeDesc{
+ Description: "rename bob",
+ FilePath: oldPath,
+ NewFilePath: newPath,
+ OldContent: []byte(testAccountLive),
+ NewContent: []byte(testAccountLive),
+ })
+
+ restarted := NewUndoStack(dir)
+ if ch := restarted.Undo(); ch != nil {
+ t.Fatalf("undo wrote a redacted account image: %s", ch.ShortDesc())
+ }
+ if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
+ t.Fatalf("failed undo created the account from the redacted image: %v", err)
+ }
+ })
+
+ t.Run("edit keeps the live hash", func(t *testing.T) {
+ dir := t.TempDir()
+ accPath := filepath.Join(dir, "players", "accounts", "bob.yaml")
+ writeAccountFile(t, accPath, testAccountPromote)
+
+ us := NewUndoStack(dir)
+ us.Push(ChangeDesc{
+ Description: "make bob an admin",
+ FilePath: accPath,
+ OldContent: []byte(testAccountLive),
+ NewContent: []byte(testAccountPromote),
+ })
+ // The live file still holds the real hash after the restart below, so
+ // the undo has one to merge and must keep working.
+ restarted := NewUndoStack(dir)
+ if ch := restarted.Undo(); ch == nil {
+ t.Fatal("undo of a redacted edit must still apply while the live account file exists")
+ }
+ got, err := os.ReadFile(accPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(got), testAccountHash) {
+ t.Fatalf("undo did not merge the live hash into the restored image:\n%s", got)
+ }
+ if !strings.Contains(string(got), "admin: false") {
+ t.Fatalf("undo did not restore the pre-edit image:\n%s", got)
+ }
+ })
+}
+
+// TestUndoRedoRefusesOnlinePlayerFile wires the online check the way main.go
+// does and pins that undo and redo both consult it for a live account, and
+// that the check receives the data-relative path form the game expects.
+func TestUndoRedoRefusesOnlinePlayerFile(t *testing.T) {
+ dir := t.TempDir()
+ accPath := filepath.Join(dir, "players", "accounts", "alice.yaml")
+ writeAccountFile(t, accPath, testAccountLive)
+
+ s := &AdminServer{dataDir: dir, undoStack: NewUndoStack(dir)}
+ var seen []string
+ online := true
+ s.SetPlayerFileOnlineCheck(func(relPath string) bool {
+ seen = append(seen, relPath)
+ return online && relPath == "players/accounts/alice.yaml"
+ })
+
+ s.undoStack.Push(ChangeDesc{
+ Description: "make alice an admin",
+ FilePath: accPath,
+ OldContent: []byte(testAccountLive),
+ NewContent: []byte(testAccountPromote),
+ })
+ writeAccountFile(t, accPath, testAccountPromote)
+
+ if ch := s.undoStack.Undo(); ch != nil {
+ t.Fatalf("undo overwrote an online account file: %s", ch.ShortDesc())
+ }
+ if got, _ := os.ReadFile(accPath); string(got) != testAccountPromote {
+ t.Fatalf("online account file was modified:\n%s", got)
+ }
+ if len(seen) == 0 || seen[0] != "players/accounts/alice.yaml" {
+ t.Fatalf("online check paths = %v, want the data-relative account path first", seen)
+ }
+
+ // Logging the character out clears the refusal, and the redo of the same
+ // change is refused when it is online again. A successful undo rewrites
+ // the mapping (so its key order differs), hence the byte snapshot rather
+ // than a comparison against the original text.
+ online = false
+ if ch := s.undoStack.Undo(); ch == nil {
+ t.Fatal("undo still refused after the character logged out")
+ }
+ undone, err := os.ReadFile(accPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{"admin: false", testAccountHash} {
+ if !strings.Contains(string(undone), want) {
+ t.Fatalf("undo did not apply %q after logout:\n%s", want, undone)
+ }
+ }
+
+ online = true
+ if ch := s.undoStack.Redo(); ch != nil {
+ t.Fatalf("redo overwrote an online account file: %s", ch.ShortDesc())
+ }
+ if got, _ := os.ReadFile(accPath); string(got) != string(undone) {
+ t.Fatalf("redo modified an online account file:\n%s", got)
+ }
+ online = false
+ if ch := s.undoStack.Redo(); ch == nil {
+ t.Fatal("redo still refused after the character logged out")
+ }
+}
+
+// TestUndoOfNonPlayerFileIgnoresOnlineCheck: the refusal is scoped to player
+// files, so a hook that reports every path online cannot block room or item
+// undo.
+func TestUndoOfNonPlayerFileIgnoresOnlineCheck(t *testing.T) {
+ dir := t.TempDir()
+ roomPath := filepath.Join(dir, "rooms", "1.yaml")
+ if err := os.MkdirAll(filepath.Dir(roomPath), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(roomPath, []byte("id: 1\nname: edited\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ us := NewUndoStack(dir)
+ us.SetPlayerFileOnlineCheck(func(string) bool { return true })
+ us.Push(ChangeDesc{
+ Description: "edit room",
+ FilePath: roomPath,
+ OldContent: []byte("id: 1\nname: old\n"),
+ NewContent: []byte("id: 1\nname: edited\n"),
+ })
+
+ if ch := us.Undo(); ch == nil {
+ t.Fatal("undo of a non-player file was refused by the online check")
+ }
+ got, err := os.ReadFile(roomPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(got), "name: old") {
+ t.Fatalf("undo did not revert the room file:\n%s", got)
+ }
+}
diff --git a/internal/admin/hardening_login_limiter_test.go b/internal/admin/hardening_login_limiter_test.go
new file mode 100644
index 0000000..1ee08ed
--- /dev/null
+++ b/internal/admin/hardening_login_limiter_test.go
@@ -0,0 +1,164 @@
+package admin
+
+// Regression coverage for the admin HTTP /login failed-login limiter: after a
+// handful of failures an IP is refused for a cooldown (even with the right
+// password), another client is unaffected, a success clears the state, and the
+// tracked-address map stays bounded. Telnet login throttling is out of scope
+// (net.Conn/Session expose no client identity), so only the HTTP route is
+// exercised here.
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// loginStatus posts the login form through the real HTTP route and returns the
+// raw status: the client must not follow the success redirect, because both a
+// rendered error page and a successful login otherwise report 200.
+func loginStatus(t *testing.T, srv *httptest.Server, account, password string) int {
+ t.Helper()
+ client := &http.Client{
+ CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
+ }
+ resp, err := client.PostForm(srv.URL+"/login", url.Values{"account": {account}, "password": {password}})
+ if err != nil {
+ t.Fatalf("login request: %v", err)
+ }
+ defer resp.Body.Close()
+ return resp.StatusCode
+}
+
+// loginStatusFromIP drives the same handler with an explicit client address, so
+// a test can act as a second IP without opening a second listener.
+func loginStatusFromIP(t *testing.T, s *AdminServer, remoteAddr, account, password string) int {
+ t.Helper()
+ body := url.Values{"account": {account}, "password": {password}}.Encode()
+ req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.RemoteAddr = remoteAddr
+ rec := httptest.NewRecorder()
+ s.ServeHTTP(rec, req)
+ return rec.Code
+}
+
+func TestLoginLimiterBlocksFailedLoginsPerIP(t *testing.T) {
+ s := newSecurityTestServer(t)
+ lim := s.limitLogins()
+ base := time.Now()
+ var clockOffset atomic.Int64
+ lim.now = func() time.Time { return base.Add(time.Duration(clockOffset.Load())) }
+
+ srv := httptest.NewServer(s)
+ defer srv.Close()
+
+ fail := func() int { return loginStatus(t, srv, "alice", "wrong") }
+ succeed := func() int { return loginStatus(t, srv, "alice", "pw") }
+
+ // Below the threshold a failure still renders the login page with the
+ // invalid-credentials message: unchanged response content.
+ for i := 0; i < adminLoginMaxFailures-1; i++ {
+ if got := fail(); got != http.StatusOK {
+ t.Fatalf("attempt %d status = %d, want 200", i+1, got)
+ }
+ }
+
+ // A successful login clears the failure count, so the next batch starts
+ // over instead of tripping the threshold partway through.
+ if got := succeed(); got != http.StatusFound {
+ t.Fatalf("valid login status = %d, want 302", got)
+ }
+ for i := 0; i < adminLoginMaxFailures; i++ {
+ if got := fail(); got != http.StatusOK {
+ t.Fatalf("post-reset attempt %d status = %d, want 200 (failure count was not reset)", i+1, got)
+ }
+ }
+
+ // Threshold reached: this IP is refused, even with the right password.
+ if got := fail(); got != http.StatusTooManyRequests {
+ t.Fatalf("status after %d failures = %d, want 429", adminLoginMaxFailures, got)
+ }
+ if got := succeed(); got != http.StatusTooManyRequests {
+ t.Fatalf("valid credentials while blocked = %d, want 429", got)
+ }
+
+ // Another client is not punished for this one.
+ if got := loginStatusFromIP(t, s, "203.0.113.7:5555", "alice", "pw"); got != http.StatusFound {
+ t.Fatalf("valid login from another IP = %d, want 302", got)
+ }
+
+ // Once the cooldown lapses the address is served again.
+ clockOffset.Store(int64(adminLoginCooldown + time.Minute))
+ if got := succeed(); got != http.StatusFound {
+ t.Fatalf("valid login after the cooldown = %d, want 302", got)
+ }
+}
+
+func TestLoginLimiterCooldownAndReset(t *testing.T) {
+ base := time.Now()
+ now := base
+ l := newLoginLimiter()
+ l.now = func() time.Time { return now }
+
+ for i := 0; i < adminLoginMaxFailures; i++ {
+ l.fail("10.0.0.1")
+ }
+ if l.allow("10.0.0.1") {
+ t.Fatal("address allowed immediately after reaching the failure threshold")
+ }
+ if !l.allow("10.0.0.2") {
+ t.Fatal("an unrelated address was blocked")
+ }
+
+ // A blocked address cannot extend its own block, and the cooldown lapses.
+ l.fail("10.0.0.1")
+ now = base.Add(adminLoginCooldown - time.Second)
+ if l.allow("10.0.0.1") {
+ t.Fatal("address allowed before the cooldown lapsed")
+ }
+ now = base.Add(adminLoginCooldown)
+ if !l.allow("10.0.0.1") {
+ t.Fatal("address still blocked after the cooldown lapsed")
+ }
+
+ // A success clears both the count and any remaining cooldown.
+ for i := 0; i < adminLoginMaxFailures-1; i++ {
+ l.fail("10.0.0.3")
+ }
+ l.succeed("10.0.0.3")
+ for i := 0; i < adminLoginMaxFailures-1; i++ {
+ l.fail("10.0.0.3")
+ }
+ if !l.allow("10.0.0.3") {
+ t.Fatal("a successful login did not reset the failure count")
+ }
+}
+
+func TestLoginLimiterBoundsTrackedAddresses(t *testing.T) {
+ base := time.Now()
+ l := newLoginLimiter()
+ l.now = func() time.Time { return base }
+
+ for i := 0; i < adminLoginMaxTracked*3; i++ {
+ l.fail(fmt.Sprintf("198.51.100.%d", i))
+ }
+ if got := len(l.entries); got > adminLoginMaxTracked {
+ t.Fatalf("limiter tracked %d addresses, want at most %d", got, adminLoginMaxTracked)
+ }
+}
+
+// TestLoginLimiterKeysOnClientIPNotPort pins the key: two ports of one host
+// share a bucket, which is what r.RemoteAddr keying is meant to achieve.
+func TestLoginLimiterKeysOnClientIPNotPort(t *testing.T) {
+ if got := clientIP("127.0.0.1:54321"); got != "127.0.0.1" {
+ t.Fatalf("clientIP = %q, want 127.0.0.1", got)
+ }
+ if got := clientIP("no-port"); got != "no-port" {
+ t.Fatalf("clientIP without a port = %q, want the value verbatim", got)
+ }
+}
diff --git a/internal/admin/hardening_security_test.go b/internal/admin/hardening_security_test.go
new file mode 100644
index 0000000..a077983
--- /dev/null
+++ b/internal/admin/hardening_security_test.go
@@ -0,0 +1,289 @@
+package admin
+
+// Regression tests for the security-hardening stage:
+// - undo history is written 0600 and never stores account password hashes
+// - undo of a deleted account file recreates it 0600
+// - the unauthenticated /login route bounds its request body
+// - /static/ requires auth (except the login stylesheet) and never lists
+// - saving colors preserves unrelated config keys/comments and writes a backup
+// - a trigger kind change records a before-image at the old path
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/config"
+)
+
+func TestUndoHistoryRedactsSecretsAndIsOwnerOnly(t *testing.T) {
+ dir := t.TempDir()
+ accDir := filepath.Join(dir, "players", "accounts")
+ if err := os.MkdirAll(accDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ accPath := filepath.Join(accDir, "alice.yaml")
+ oldContent := "name: alice\npassword_hash: argon2id:1:65536:2:c2FsdHNhbHRzYWx0c2E:deadbeef\nadmin: false\n"
+ newContent := "name: alice\npassword_hash: argon2id:1:65536:2:c2FsdHNhbHRzYWx0c2E:deadbeef\nadmin: true\n"
+ if err := os.WriteFile(accPath, []byte(newContent), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ us := NewUndoStack(dir)
+ // A history file left world-readable by a previous version must be tightened.
+ if err := os.WriteFile(filepath.Join(dir, ".admin_history.json"), []byte("{}"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ us.Push(ChangeDesc{
+ Description: "edit account",
+ FilePath: accPath,
+ OldContent: []byte(oldContent),
+ NewContent: []byte(newContent),
+ })
+
+ histPath := filepath.Join(dir, ".admin_history.json")
+ info, err := os.Stat(histPath)
+ if err != nil {
+ t.Fatalf("history file not written: %v", err)
+ }
+ if info.Mode().Perm() != 0600 {
+ t.Fatalf("history file mode = %o, want 0600", info.Mode().Perm())
+ }
+ raw, err := os.ReadFile(histPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(raw), "argon2id:") || strings.Contains(string(raw), "deadbeef") {
+ t.Fatalf("history file leaked the password hash:\n%s", raw)
+ }
+ if !strings.Contains(string(raw), redactedAccountHash) {
+ t.Fatalf("history file missing redaction placeholder:\n%s", raw)
+ }
+
+ // The in-memory change keeps the real bytes so an in-session undo still
+ // restores a working account.
+ ch := us.Undo()
+ if ch == nil {
+ t.Fatal("undo returned nil")
+ }
+ got, err := os.ReadFile(accPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(got), "argon2id:") {
+ t.Fatalf("undo lost the real password hash:\n%s", got)
+ }
+}
+
+func TestUndoRestoresDeletedAccountAsOwnerOnly(t *testing.T) {
+ dir := t.TempDir()
+ accPath := filepath.Join(dir, "players", "accounts", "bob.yaml")
+ if err := os.MkdirAll(filepath.Dir(accPath), 0755); err != nil {
+ t.Fatal(err)
+ }
+ content := []byte("name: bob\npassword_hash: argon2id:1:65536:2:c2FsdHNhbHRzYWx0c2E:deadbeef\n")
+ if err := os.WriteFile(accPath, content, 0600); err != nil {
+ t.Fatal(err)
+ }
+ us := NewUndoStack(dir)
+ us.Push(ChangeDesc{
+ Description: "delete bob",
+ FilePath: accPath,
+ OldContent: content,
+ IsDelete: true,
+ })
+ if err := os.Remove(accPath); err != nil {
+ t.Fatal(err)
+ }
+ if us.Undo() == nil {
+ t.Fatal("undo of delete returned nil")
+ }
+ info, err := os.Stat(accPath)
+ if err != nil {
+ t.Fatalf("account not recreated: %v", err)
+ }
+ if info.Mode().Perm() != 0600 {
+ t.Fatalf("recreated account mode = %o, want 0600", info.Mode().Perm())
+ }
+}
+
+func TestLoginRejectsOversizedBody(t *testing.T) {
+ s := &AdminServer{}
+ big := strings.Repeat("a", maxLoginBodyBytes+1)
+ r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(big))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+ s.handleLogin(w, r)
+ if w.Code != http.StatusRequestEntityTooLarge {
+ t.Fatalf("oversized login body status = %d, want 413", w.Code)
+ }
+}
+
+func TestStaticRequiresAuthAndDoesNotList(t *testing.T) {
+ s := newSecurityTestServer(t)
+
+ // admin.css is referenced by the unauthenticated login page, so it stays
+ // public.
+ css := httptest.NewRecorder()
+ s.ServeHTTP(css, httptest.NewRequest(http.MethodGet, "/static/admin.css", nil))
+ if css.Code != http.StatusOK {
+ t.Fatalf("admin.css status = %d, want 200", css.Code)
+ }
+
+ // Every other asset requires a session.
+ js := httptest.NewRecorder()
+ s.ServeHTTP(js, httptest.NewRequest(http.MethodGet, "/static/admin.js", nil))
+ if js.Code != http.StatusUnauthorized {
+ t.Fatalf("unauthenticated admin.js status = %d, want 401", js.Code)
+ }
+
+ // A directory request must never emit a listing.
+ dir := httptest.NewRecorder()
+ s.ServeHTTP(dir, httptest.NewRequest(http.MethodGet, "/static/", nil))
+ if dir.Code == http.StatusOK {
+ t.Fatalf("GET /static/ served a directory listing:\n%s", dir.Body.String())
+ }
+}
+
+func newSecurityTestServer(t *testing.T) *AdminServer {
+ t.Helper()
+ h, _ := newEditorHarness(t)
+ s, err := NewServer(config.Default(), false, h.g.AccountStore, h.g.World, h.g.ItemStore, h.g.ObjectStore,
+ h.g.MobStore, h.dir, h.g.CourseStore.ReloadAll, h.g.MigrateRoomIDsOnOwner, h.g.EvictCasinoRoom,
+ h.g.RebuildCraftIndex, filepath.Join(h.dir, "config.yaml"), h.g.RunAdminOnOwner)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return s
+}
+
+func TestFileEditorRefusesOnlinePlayerFile(t *testing.T) {
+ dir := t.TempDir()
+ accPath := filepath.Join(dir, "players", "accounts", "alice.yaml")
+ if err := os.MkdirAll(filepath.Dir(accPath), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(accPath, []byte("name: alice\npassword_hash: x\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ s := &AdminServer{dataDir: dir, undoStack: NewUndoStack(dir)}
+ s.SetPlayerFileOnlineCheck(func(relPath string) bool {
+ return strings.HasPrefix(relPath, "players/accounts/")
+ })
+
+ r := httptest.NewRequest(http.MethodPut, "/api/files?path=players/accounts/alice.yaml",
+ strings.NewReader("name: alice\npassword_hash: y\n"))
+ w := httptest.NewRecorder()
+ s.handleFiles(w, r)
+ if !strings.Contains(w.Body.String(), "online") {
+ t.Fatalf("online player file edit not refused: %s", w.Body.String())
+ }
+ got, err := os.ReadFile(accPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(got), "password_hash: y") {
+ t.Fatalf("online account file was overwritten:\n%s", got)
+ }
+}
+
+func TestNewAccountFileIsOwnerOnly(t *testing.T) {
+ dir := t.TempDir()
+ s := &AdminServer{dataDir: dir, undoStack: NewUndoStack(dir)}
+ r := httptest.NewRequest(http.MethodPut, "/api/files?path=players/accounts/newbie.yaml",
+ strings.NewReader("name: newbie\npassword_hash: x\n"))
+ w := httptest.NewRecorder()
+ s.handleFiles(w, r)
+ info, err := os.Stat(filepath.Join(dir, "players", "accounts", "newbie.yaml"))
+ if err != nil {
+ t.Fatalf("account file not created: %v", err)
+ }
+ if info.Mode().Perm() != 0600 {
+ t.Fatalf("new account file mode = %o, want 0600", info.Mode().Perm())
+ }
+}
+
+func TestColorsSavePatchesOnlyColorMaps(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.yaml")
+ orig := "# operator note\ntick_length: 900\ndefault_colors:\n room_name: \"74 bold\"\nconstant_colors:\n hp_bar_low: \"A7\"\n"
+ if err := os.WriteFile(path, []byte(orig), 0644); err != nil {
+ t.Fatal(err)
+ }
+ loaded, err := config.Load(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := &AdminServer{cfg: loaded, configPath: path}
+
+ body := map[string]any{"colors": map[string]string{"room_name": "FF bold"}}
+ b, _ := json.Marshal(body)
+ req := httptest.NewRequest(http.MethodPost, "/api/colors", bytes.NewReader(b))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ s.saveColors(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("save status = %d: %s", w.Code, w.Body.String())
+ }
+
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ out := string(raw)
+ if !strings.Contains(out, "tick_length: 900") {
+ t.Fatalf("unrelated config key was reverted:\n%s", out)
+ }
+ if !strings.Contains(out, "# operator note") {
+ t.Fatalf("config comment was dropped:\n%s", out)
+ }
+ if !strings.Contains(out, "FF bold") {
+ t.Fatalf("color edit not persisted:\n%s", out)
+ }
+ if _, err := os.Stat(path + ".bak"); err != nil {
+ t.Fatalf("no config backup written: %v", err)
+ }
+}
+
+func TestTriggerKindChangeUndoRestoresOldPath(t *testing.T) {
+ dir := t.TempDir()
+ root := filepath.Join(dir, "triggers")
+ oldPath := filepath.Join(root, "player_flag", "t1.yaml")
+ if err := os.MkdirAll(filepath.Dir(oldPath), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(oldPath, []byte("on_player_flag: visited\nsteps: []\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ s := &AdminServer{dataDir: dir, undoStack: NewUndoStack(dir)}
+
+ body := `{"on_global_flag":"world_event","steps":[]}`
+ req := httptest.NewRequest(http.MethodPut, "/api/triggers/t1", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ s.handleTriggerByID(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("put status = %d: %s", w.Code, w.Body.String())
+ }
+ newPath := filepath.Join(root, "global_flag", "t1.yaml")
+ if _, err := os.Stat(newPath); err != nil {
+ t.Fatalf("new trigger file missing: %v", err)
+ }
+ if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
+ t.Fatalf("old trigger file not removed after kind change")
+ }
+
+ if s.undoStack.Undo() == nil {
+ t.Fatal("undo returned nil")
+ }
+ if _, err := os.Stat(oldPath); err != nil {
+ t.Fatalf("undo did not restore the old path: %v", err)
+ }
+ if _, err := os.Stat(newPath); !os.IsNotExist(err) {
+ t.Fatalf("undo left the new path behind")
+ }
+}
diff --git a/internal/admin/server.go b/internal/admin/server.go
index d9a0386..4a54c78 100644
--- a/internal/admin/server.go
+++ b/internal/admin/server.go
@@ -4,6 +4,7 @@
package admin
import (
+ "bytes"
"crypto/hmac"
"crypto/rand"
"crypto/rsa"
@@ -14,6 +15,7 @@ import (
"embed"
"encoding/hex"
"encoding/json"
+ "errors"
"fmt"
"html/template"
"io"
@@ -26,6 +28,7 @@ import (
"path/filepath"
"strconv"
"strings"
+ "sync"
"time"
"thehouseoficarus/internal/behavior"
@@ -74,6 +77,18 @@ type AdminServer struct {
// change (create/update/delete/rename/move/undo/redo). Wired from the
// Game so the web editor's item edits stay consistent with gameplay.
rebuildCraftIndex func()
+
+ // playerFileOnlineCheck reports whether the character/account addressed
+ // by a data-relative player file path is currently online. Wired from the
+ // game (SetPlayerFileOnlineCheck) so the raw file editor refuses to
+ // overwrite a live character/account's file. Optional; nil disables it.
+ playerFileOnlineCheck func(relPath string) bool
+
+ // loginLimiterOnce/loginLimiter hold the bounded per-IP failed-login
+ // throttle for /login. The limiter is created on first use so an
+ // AdminServer built directly (tests) behaves like one from NewServer.
+ loginLimiterOnce sync.Once
+ loginLimiter *loginLimiter
}
func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStore, w *world.World, is *item.ItemStore, os *object.ObjectStore, ms *world.MobStore, dataDir string, reloadCourses func(), rewriteRoomIDs func(map[int]int), evictRoom func(roomID int), rebuildCraftIndex func(), configPath string, runOnOwner func(func()) bool) (*AdminServer, error) {
@@ -139,7 +154,23 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor
if err != nil {
return nil, fmt.Errorf("static subfs: %w", err)
}
- mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
+ // Serve embedded assets by exact name: this both requires auth (except
+ // for the stylesheet the unauthenticated login page needs) and never
+ // emits the http.FileServer directory listing that leaked the UI's
+ // module names.
+ mux.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
+ name := strings.TrimPrefix(r.URL.Path, "/static/")
+ if name != "admin.css" && !s.checkAuth(r) {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ data, err := fs.ReadFile(staticFS, name)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(data))
+ })
mux.HandleFunc("/login", s.handleLogin)
mux.HandleFunc("/logout", s.handleLogout)
@@ -292,6 +323,21 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor
return s, nil
}
+// SetPlayerFileOnlineCheck wires the online-session check used by the raw
+// file editor to refuse editing/deleting/renaming a character or account file
+// while that character/account is online. It is optional: when unset the
+// editor behaves as before. main.go wires it to a game-side lookup.
+//
+// Undo/redo writes the same files through the undo stack, so the hook is
+// handed to it as well: undoing an edit to a live character's file must be
+// refused like the edit itself.
+func (s *AdminServer) SetPlayerFileOnlineCheck(fn func(relPath string) bool) {
+ s.playerFileOnlineCheck = fn
+ if s.undoStack != nil {
+ s.undoStack.SetPlayerFileOnlineCheck(fn)
+ }
+}
+
// ServeHTTP exposes the same authenticated routes used by ListenAndServe.
func (s *AdminServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.httpServer.Handler.ServeHTTP(w, r)
@@ -362,6 +408,149 @@ func (s *AdminServer) isAdminAccount(name string) bool {
// indefinitely and the server can age it out without server-side state.
const adminSessionTTL = 24 * time.Hour
+// maxLoginBodyBytes bounds the unauthenticated /login request body before any
+// form or multipart parsing. 64 KiB is far above any real login form.
+const maxLoginBodyBytes = 64 << 10
+
+// Failed-login throttling for the unauthenticated /login route. The telnet
+// login path is deliberately NOT covered: net.Conn/Session expose no client
+// identity, so there is nothing to key a limit on.
+const (
+ // adminLoginMaxFailures is the number of failed attempts an IP may make
+ // before it is refused.
+ adminLoginMaxFailures = 5
+ // adminLoginCooldown is how long a refused IP stays refused.
+ adminLoginCooldown = 5 * time.Minute
+ // adminLoginMaxTracked caps the tracked client IPs, so a source that
+ // cycles addresses cannot grow server memory without bound.
+ adminLoginMaxTracked = 1024
+)
+
+// loginLimiter is a small, bounded, in-memory failed-login limiter keyed by
+// client IP. After adminLoginMaxFailures consecutive failures an IP is
+// refused for adminLoginCooldown; a successful login clears its state. The
+// entry map holds at most adminLoginMaxTracked keys (the least recently seen
+// entry without an active cooldown is evicted first), and every method is
+// safe for concurrent use: /login is unauthenticated and served by many
+// goroutines at once.
+//
+// now is the limiter's clock, read under mu on every call. Tests replace it
+// once, before the server serves a request, to drive the cooldown
+// deterministically; production leaves it at time.Now.
+type loginLimiter struct {
+ mu sync.Mutex
+ entries map[string]loginAttempts
+ now func() time.Time
+}
+
+type loginAttempts struct {
+ failures int
+ blockedTill time.Time
+ lastSeen time.Time
+}
+
+func newLoginLimiter() *loginLimiter {
+ return &loginLimiter{entries: make(map[string]loginAttempts), now: time.Now}
+}
+
+// allow reports whether ip may attempt a login now.
+func (l *loginLimiter) allow(ip string) bool {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ e, ok := l.entries[ip]
+ if !ok {
+ return true
+ }
+ return !l.now().Before(e.blockedTill)
+}
+
+// fail records a failed attempt for ip and starts the cooldown once the
+// threshold is reached. A still-blocked IP keeps its existing cooldown: a
+// blocked source cannot extend its own block by hammering the route, and an
+// address that cannot be tracked while every tracked address is cooling down
+// is left untracked rather than evicting a live cooldown.
+func (l *loginLimiter) fail(ip string) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ now := l.now()
+ e, ok := l.entries[ip]
+ if !ok && !l.makeRoomLocked(now) {
+ return
+ }
+ e.lastSeen = now
+ if now.Before(e.blockedTill) {
+ l.entries[ip] = e
+ return
+ }
+ e.failures++
+ if e.failures >= adminLoginMaxFailures {
+ e.blockedTill = now.Add(adminLoginCooldown)
+ e.failures = 0
+ }
+ l.entries[ip] = e
+}
+
+// succeed clears ip's failure state after a successful login.
+func (l *loginLimiter) succeed(ip string) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ delete(l.entries, ip)
+}
+
+// makeRoomLocked makes room for one new tracked address once the map is at
+// its cap, reporting false when it cannot. Fully expired entries (no cooldown
+// left and no failures counted) go first; if the map is still full the least
+// recently seen entry without an active cooldown is dropped.
+//
+// An entry with an active cooldown is NEVER evicted: a blocked source must not
+// be able to unblock itself by filling the map with other addresses, which is
+// exactly what dropping its (oldest) entry would hand it. When every tracked
+// address is still cooling down the map stays bounded and the new address is
+// simply not tracked.
+func (l *loginLimiter) makeRoomLocked(now time.Time) bool {
+ if len(l.entries) < adminLoginMaxTracked {
+ return true
+ }
+ for ip, e := range l.entries {
+ if e.failures == 0 && !now.Before(e.blockedTill) {
+ delete(l.entries, ip)
+ }
+ }
+ for len(l.entries) >= adminLoginMaxTracked {
+ oldest, found := "", false
+ for ip, e := range l.entries {
+ if now.Before(e.blockedTill) {
+ continue
+ }
+ if !found || e.lastSeen.Before(l.entries[oldest].lastSeen) {
+ oldest, found = ip, true
+ }
+ }
+ if !found {
+ return false
+ }
+ delete(l.entries, oldest)
+ }
+ return true
+}
+
+// limitLogins returns the server's login limiter, creating it on first use.
+func (s *AdminServer) limitLogins() *loginLimiter {
+ s.loginLimiterOnce.Do(func() { s.loginLimiter = newLoginLimiter() })
+ return s.loginLimiter
+}
+
+// clientIP keys the login limiter by the request's client address. RemoteAddr
+// is "host:port" for TCP transports; anything without a port (synthetic
+// requests, unix sockets) is used verbatim. Forwarded headers are ignored:
+// they are client-controlled and would let one source pose as many.
+func clientIP(remoteAddr string) string {
+ if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
+ return host
+ }
+ return remoteAddr
+}
+
func (s *AdminServer) setAuthCookie(w http.ResponseWriter, account string) {
issued := strconv.FormatInt(time.Now().Unix(), 10)
mac := hmac.New(sha256.New, s.cookieSecret)
@@ -389,16 +578,44 @@ func (s *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
+ // Refuse a cooling-down source before reading its body: the limit exists
+ // to make password guessing expensive, so it must not cost a parse.
+ limiter := s.limitLogins()
+ ip := clientIP(r.RemoteAddr)
+ if !limiter.allow(ip) {
+ http.Error(w, "too many failed login attempts; try again later", http.StatusTooManyRequests)
+ return
+ }
+
+ // /login is registered before authMiddleware, so bound and buffer its body
+ // here before r.FormValue can parse it: without the cap a multipart POST
+ // spills unbounded data to temp files (ParseMultipartForm's own 32 MiB cap
+ // only bounds memory).
+ body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxLoginBodyBytes))
+ r.Body.Close()
+ if err != nil {
+ var tooLarge *http.MaxBytesError
+ if errors.As(err, &tooLarge) {
+ http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
+ return
+ }
+ http.Error(w, "cannot read request body", http.StatusBadRequest)
+ return
+ }
+ r.Body = io.NopCloser(bytes.NewReader(body))
+
account := strings.TrimSpace(r.FormValue("account"))
password := r.FormValue("password")
acc, err := s.accountStore.LoadAccount(account)
if err != nil || !player.CheckPassword(password, acc.PasswordHash) {
+ limiter.fail(ip)
s.renderPage(w, r, "login.html", map[string]any{"Error": "Invalid account name or password."})
return
}
if !acc.Admin {
+ limiter.fail(ip)
s.renderPage(w, r, "login.html", map[string]any{"Error": "Account is not authorized as admin."})
return
}
@@ -432,6 +649,7 @@ func (s *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
s.setAuthCookie(w, account)
+ limiter.succeed(ip)
http.Redirect(w, r, "/", http.StatusFound)
}
diff --git a/internal/admin/undo.go b/internal/admin/undo.go
index 1786295..7bfbe09 100644
--- a/internal/admin/undo.go
+++ b/internal/admin/undo.go
@@ -6,6 +6,7 @@ import (
"log"
"os"
"path/filepath"
+ "strings"
"sync"
"time"
)
@@ -77,6 +78,11 @@ type UndoStack struct {
// migrateRoomIDs applies a room-ID map to runtime state (players, Hub
// membership, casino room keys) during undo/redo of room-ID changes.
migrateRoomIDs func(map[int]int)
+ // playerFileOnlineCheck reports whether the character/account addressed
+ // by a data-relative player file path is currently online. undo/redo
+ // writes those files directly, so it must refuse the same files the raw
+ // file editor refuses. Optional; nil disables the refusal.
+ playerFileOnlineCheck func(relPath string) bool
}
func NewUndoStack(dataDir string) *UndoStack {
@@ -97,6 +103,16 @@ func (us *UndoStack) SetMigrateRoomIDs(fn func(map[int]int)) {
us.migrateRoomIDs = fn
}
+// SetPlayerFileOnlineCheck wires the online-session check used by undo/redo to
+// refuse touching a character/account file the game still owns. It takes the
+// same data-relative path form as AdminServer.SetPlayerFileOnlineCheck, which
+// passes its own hook through here.
+func (us *UndoStack) SetPlayerFileOnlineCheck(fn func(relPath string) bool) {
+ us.mu.Lock()
+ defer us.mu.Unlock()
+ us.playerFileOnlineCheck = fn
+}
+
func (us *UndoStack) Push(desc ChangeDesc) {
us.mu.Lock()
defer us.mu.Unlock()
@@ -172,6 +188,22 @@ func (us *UndoStack) Redo() *ChangeDesc {
// Extra-file failures propagate: a multi-file undo that restored only part
// of a change must be reported as failed, never as success.
func (us *UndoStack) applyUndo(c ChangeDesc) error {
+ if err := us.checkPlayerFilesOffline(c); err != nil {
+ return err
+ }
+ // Resolve every secret-bearing image BEFORE touching the filesystem: the
+ // rename path below removes its target before the restored file is
+ // written, so a redacted history image that cannot be repaired must fail
+ // while both files are still intact.
+ restored, err := us.restoreSecret(c.OldContent, c.FilePath, c.NewFilePath)
+ if err != nil {
+ return err
+ }
+ for _, ef := range c.ExtraFiles {
+ if _, err := us.restoreSecret(ef.OldContent, ef.FilePath, ef.NewFilePath); err != nil {
+ return fmt.Errorf("undo restore %s: %w", ef.FilePath, err)
+ }
+ }
if c.NewFilePath != "" {
if err := os.Remove(c.NewFilePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove %s: %w", c.NewFilePath, err)
@@ -186,10 +218,7 @@ func (us *UndoStack) applyUndo(c ChangeDesc) error {
if c.OldContent == nil {
return fmt.Errorf("restore deleted %s: no snapshot recorded", c.FilePath)
}
- if err := os.MkdirAll(filepath.Dir(c.FilePath), 0755); err != nil {
- return fmt.Errorf("mkdir: %w", err)
- }
- if err := os.WriteFile(c.FilePath, c.OldContent, 0644); err != nil {
+ if err := us.writeSnapshot(c.FilePath, restored); err != nil {
return fmt.Errorf("restore deleted %s: %w", c.FilePath, err)
}
}
@@ -198,15 +227,12 @@ func (us *UndoStack) applyUndo(c ChangeDesc) error {
return fmt.Errorf("remove created %s: %w", c.FilePath, err)
}
} else {
- if err := os.MkdirAll(filepath.Dir(c.FilePath), 0755); err != nil {
- return fmt.Errorf("mkdir: %w", err)
- }
- if err := os.WriteFile(c.FilePath, c.OldContent, 0644); err != nil {
+ if err := us.writeSnapshot(c.FilePath, restored); err != nil {
return fmt.Errorf("revert %s: %w", c.FilePath, err)
}
}
for _, ef := range c.ExtraFiles {
- if err := applyUndoExtraFile(ef); err != nil {
+ if err := us.applyUndoExtraFile(ef); err != nil {
return err
}
}
@@ -216,6 +242,21 @@ func (us *UndoStack) applyUndo(c ChangeDesc) error {
// applyRedo re-applies a change: the primary file, then every extra file.
// Extra-file failures propagate like undo.
func (us *UndoStack) applyRedo(c ChangeDesc) error {
+ if err := us.checkPlayerFilesOffline(c); err != nil {
+ return err
+ }
+ // See applyUndo: every image's secret is resolved before any file is
+ // removed, so a redacted image fails the redo without destroying either
+ // end of a rename.
+ restored, err := us.restoreSecret(c.NewContent, c.NewFilePath, c.FilePath)
+ if err != nil {
+ return err
+ }
+ for _, ef := range c.ExtraFiles {
+ if _, err := us.restoreSecret(ef.NewContent, ef.NewFilePath, ef.FilePath); err != nil {
+ return fmt.Errorf("redo restore %s: %w", ef.FilePath, err)
+ }
+ }
if c.NewFilePath != "" {
if err := os.Remove(c.FilePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove %s: %w", c.FilePath, err)
@@ -226,14 +267,11 @@ func (us *UndoStack) applyRedo(c ChangeDesc) error {
if c.NewFilePath != "" {
target = c.NewFilePath
}
- if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
- return fmt.Errorf("mkdir: %w", err)
- }
- if err := os.WriteFile(target, c.NewContent, 0644); err != nil {
+ if err := us.writeSnapshot(target, restored); err != nil {
return fmt.Errorf("recreate %s: %w", target, err)
}
for _, ef := range c.ExtraFiles {
- if err := applyRedoExtraFile(ef); err != nil {
+ if err := us.applyRedoExtraFile(ef); err != nil {
return err
}
}
@@ -242,24 +280,20 @@ func (us *UndoStack) applyRedo(c ChangeDesc) error {
return fmt.Errorf("re-delete %s: %w", c.FilePath, err)
}
for _, ef := range c.ExtraFiles {
- if err := applyRedoExtraFile(ef); err != nil {
+ if err := us.applyRedoExtraFile(ef); err != nil {
return err
}
}
} else {
target := c.FilePath
- content := c.NewContent
if c.NewFilePath != "" {
target = c.NewFilePath
}
- if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
- return fmt.Errorf("mkdir: %w", err)
- }
- if err := os.WriteFile(target, content, 0644); err != nil {
+ if err := us.writeSnapshot(target, restored); err != nil {
return fmt.Errorf("reapply %s: %w", target, err)
}
for _, ef := range c.ExtraFiles {
- if err := applyRedoExtraFile(ef); err != nil {
+ if err := us.applyRedoExtraFile(ef); err != nil {
return err
}
}
@@ -267,7 +301,7 @@ func (us *UndoStack) applyRedo(c ChangeDesc) error {
return nil
}
-func applyUndoExtraFile(ef ExtraFile) error {
+func (us *UndoStack) applyUndoExtraFile(ef ExtraFile) error {
switch {
case ef.IsCreate:
if err := os.Remove(ef.FilePath); err != nil && !os.IsNotExist(err) {
@@ -281,67 +315,143 @@ func applyUndoExtraFile(ef ExtraFile) error {
if ef.OldContent == nil {
return fmt.Errorf("undo restore %s: no snapshot recorded", ef.FilePath)
}
- if err := os.MkdirAll(filepath.Dir(ef.FilePath), 0755); err != nil {
- return fmt.Errorf("undo mkdir: %w", err)
- }
- if err := os.WriteFile(ef.FilePath, ef.OldContent, 0644); err != nil {
+ if err := us.writeRestored(ef.FilePath, ef.OldContent, ef.NewFilePath); err != nil {
return fmt.Errorf("undo restore %s: %w", ef.FilePath, err)
}
case ef.NewFilePath != "":
if err := os.Remove(ef.NewFilePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("undo remove %s: %w", ef.NewFilePath, err)
}
- if err := os.MkdirAll(filepath.Dir(ef.FilePath), 0755); err != nil {
- return fmt.Errorf("undo mkdir: %w", err)
- }
- if err := os.WriteFile(ef.FilePath, ef.OldContent, 0644); err != nil {
+ if err := us.writeRestored(ef.FilePath, ef.OldContent, ef.NewFilePath); err != nil {
return fmt.Errorf("undo restore %s: %w", ef.FilePath, err)
}
default:
- if err := os.MkdirAll(filepath.Dir(ef.FilePath), 0755); err != nil {
- return fmt.Errorf("undo mkdir: %w", err)
- }
- if err := os.WriteFile(ef.FilePath, ef.OldContent, 0644); err != nil {
+ if err := us.writeRestored(ef.FilePath, ef.OldContent); err != nil {
return fmt.Errorf("undo revert %s: %w", ef.FilePath, err)
}
}
return nil
}
-func applyRedoExtraFile(ef ExtraFile) error {
+func (us *UndoStack) applyRedoExtraFile(ef ExtraFile) error {
switch {
case ef.IsDelete:
if err := os.Remove(ef.FilePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("redo remove %s: %w", ef.FilePath, err)
}
case ef.IsCreate:
- if err := os.MkdirAll(filepath.Dir(ef.FilePath), 0755); err != nil {
- return fmt.Errorf("redo mkdir: %w", err)
- }
- if err := os.WriteFile(ef.FilePath, ef.NewContent, 0644); err != nil {
+ if err := us.writeRestored(ef.FilePath, ef.NewContent, ef.NewFilePath); err != nil {
return fmt.Errorf("redo create %s: %w", ef.FilePath, err)
}
case ef.NewFilePath != "":
if err := os.Remove(ef.FilePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("redo remove %s: %w", ef.FilePath, err)
}
- if err := os.MkdirAll(filepath.Dir(ef.NewFilePath), 0755); err != nil {
- return fmt.Errorf("redo mkdir: %w", err)
- }
- if err := os.WriteFile(ef.NewFilePath, ef.NewContent, 0644); err != nil {
+ if err := us.writeRestored(ef.NewFilePath, ef.NewContent, ef.FilePath); err != nil {
return fmt.Errorf("redo create %s: %w", ef.NewFilePath, err)
}
default:
- if err := os.MkdirAll(filepath.Dir(ef.FilePath), 0755); err != nil {
- return fmt.Errorf("redo mkdir: %w", err)
- }
- if err := os.WriteFile(ef.FilePath, ef.NewContent, 0644); err != nil {
+ if err := us.writeRestored(ef.FilePath, ef.NewContent); err != nil {
return fmt.Errorf("redo reapply %s: %w", ef.FilePath, err)
}
}
return nil
}
+// writeRestored writes content to path after merging the live on-disk player
+// secret back into it (see restoreSecret). A history image whose secret was
+// redacted and has no live file left to take it from fails here instead of
+// being written, so undo/redo reports the failure. Further paths are the
+// change's counterparts (a rename's other end).
+func (us *UndoStack) writeRestored(path string, content []byte, others ...string) error {
+ restored, err := us.restoreSecret(content, append([]string{path}, others...)...)
+ if err != nil {
+ return err
+ }
+ return us.writeSnapshot(path, restored)
+}
+
+// checkPlayerFilesOffline refuses an undo/redo touching a character/account
+// file whose owner is online. The raw file editor refuses those files before
+// writing them; undo/redo writes them through this stack, so it must refuse
+// them too or a live character can be clobbered behind the editor's back.
+func (us *UndoStack) checkPlayerFilesOffline(c ChangeDesc) error {
+ if us.playerFileOnlineCheck == nil {
+ return nil
+ }
+ for _, p := range changePaths(c) {
+ rel, ok := us.playerDataRelPath(p)
+ if !ok {
+ continue
+ }
+ if us.playerFileOnlineCheck(rel) {
+ return fmt.Errorf("%s is online; it must be logged out before this change can be undone or redone", rel)
+ }
+ }
+ return nil
+}
+
+// changePaths lists every file a change touches: the primary path, a rename's
+// new path, and every extra file with its own new path.
+func changePaths(c ChangeDesc) []string {
+ paths := []string{c.FilePath, c.NewFilePath}
+ for _, ef := range c.ExtraFiles {
+ paths = append(paths, ef.FilePath, ef.NewFilePath)
+ }
+ return paths
+}
+
+// writeSnapshot writes content to path, creating the parent directory and
+// choosing the file mode from the path: player/account files are owner-only.
+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 {
+ return err
+ }
+ if us.isPlayerDataPath(path) {
+ // Tighten a file that was created world-readable before this fix.
+ if err := os.Chmod(path, 0600); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// fileMode returns the mode an undo/redo write should use for path.
+func (us *UndoStack) fileMode(path string) os.FileMode {
+ if us.isPlayerDataPath(path) {
+ return 0600
+ }
+ return 0644
+}
+
+// isPlayerDataPath reports whether path is an account or character file under
+// the data directory. These carry the password hash and live player state.
+func (us *UndoStack) isPlayerDataPath(path string) bool {
+ _, ok := us.playerDataRelPath(path)
+ return ok
+}
+
+// playerDataRelPath returns the data-relative, slash-separated form of path
+// when it is an account or character file, which is the form the game's
+// online-session check (AdminPlayerFileOnline) accepts.
+func (us *UndoStack) playerDataRelPath(path string) (string, bool) {
+ if path == "" {
+ return "", false
+ }
+ rel, err := filepath.Rel(us.dataDir, path)
+ if err != nil {
+ return "", false
+ }
+ rel = filepath.ToSlash(rel)
+ if strings.HasPrefix(rel, "players/accounts/") || strings.HasPrefix(rel, "players/characters/") {
+ return rel, true
+ }
+ return "", false
+}
+
func (us *UndoStack) Info() UndoInfo {
us.mu.Lock()
defer us.mu.Unlock()
@@ -395,8 +505,8 @@ func (us *UndoStack) save() {
extra = append(extra, extraEntry{
FilePath: ef.FilePath,
NewFilePath: ef.NewFilePath,
- OldContent: string(ef.OldContent),
- NewContent: string(ef.NewContent),
+ OldContent: string(us.redactContent(ef.OldContent, ef.FilePath, ef.NewFilePath)),
+ NewContent: string(us.redactContent(ef.NewContent, ef.FilePath, ef.NewFilePath)),
IsDelete: ef.IsDelete,
IsCreate: ef.IsCreate,
IsDirDelete: ef.IsDirDelete,
@@ -407,8 +517,8 @@ func (us *UndoStack) save() {
Description: c.Description,
FilePath: c.FilePath,
NewFilePath: c.NewFilePath,
- OldContent: string(c.OldContent),
- NewContent: string(c.NewContent),
+ OldContent: string(us.redactContent(c.OldContent, c.FilePath, c.NewFilePath)),
+ NewContent: string(us.redactContent(c.NewContent, c.FilePath, c.NewFilePath)),
IsDelete: c.IsDelete,
IsCreate: c.IsCreate,
IsDirDelete: c.IsDirDelete,
@@ -427,9 +537,124 @@ func (us *UndoStack) save() {
log.Printf("undo: failed to marshal history: %v", err)
return
}
- if err := os.WriteFile(us.filePath, b, 0644); err != nil {
+ if err := os.WriteFile(us.filePath, b, 0600); err != nil {
log.Printf("undo: failed to write history file: %v", err)
+ return
+ }
+ // os.WriteFile does not change an existing file's mode: tighten a history
+ // file that a previous version created world-readable.
+ if err := os.Chmod(us.filePath, 0600); err != nil {
+ log.Printf("undo: failed to secure history file: %v", err)
+ }
+}
+
+// playerSecretKeys are the top-level YAML keys whose values must never be
+// written to the on-disk undo history. Account password hashes are the
+// primary case: the raw file editor exposes account files and an unredacted
+// history file would leak every hash to any local reader (and be stageable
+// into the repository).
+var playerSecretKeys = []string{"password_hash"}
+
+// redactContent replaces secret values with a placeholder before the content
+// is serialized into the history file. The in-memory ChangeDesc keeps the
+// real bytes; only the persisted copy is redacted. A redacted value cannot
+// restore a deleted account's hash across a restart, which is an accepted
+// trade for never storing credentials in the history.
+func (us *UndoStack) redactContent(content []byte, paths ...string) []byte {
+ if content == nil {
+ return nil
+ }
+ for _, p := range paths {
+ if us.isPlayerDataPath(p) {
+ return redactPlayerSecrets(content)
+ }
+ }
+ return content
+}
+
+// redactPlayerSecrets replaces every known secret value in a player/account
+// document with the editor's placeholder, returning content unchanged when
+// the document does not parse or carries no secret.
+func redactPlayerSecrets(content []byte) []byte {
+ m, err := yamlToMap(content)
+ if err != nil {
+ return content
+ }
+ changed := false
+ for _, key := range playerSecretKeys {
+ if _, ok := m[key]; ok {
+ m[key] = redactedAccountHash
+ changed = true
+ }
+ }
+ if !changed {
+ return content
+ }
+ out, err := marshalMapYAML(m)
+ if err != nil {
+ return content
+ }
+ return out
+}
+
+// restoreSecret merges the live on-disk password hash into content before an
+// undo/redo overwrites an account file, so a hash that was redacted in the
+// persisted history can never blank a live account after a restart. It looks
+// at the primary target path and then the change's other path (a rename).
+//
+// When no path holds a live hash and content still carries the redaction
+// placeholder, it returns an error: writing the placeholder would replace the
+// account's real hash and lock both the owner and the raw editor out of it
+// permanently, so undo/redo must report the failure instead of overwriting.
+func (us *UndoStack) restoreSecret(content []byte, paths ...string) ([]byte, error) {
+ if content == nil {
+ return nil, nil
+ }
+ playerFile := false
+ for _, p := range paths {
+ if us.isPlayerDataPath(p) {
+ playerFile = true
+ break
+ }
+ }
+ if !playerFile {
+ return content, nil
+ }
+ cm, err := yamlToMap(content)
+ if err != nil {
+ return content, nil
+ }
+ hash, ok := cm["password_hash"]
+ if !ok {
+ return content, nil
+ }
+ for _, p := range paths {
+ if !us.isPlayerDataPath(p) {
+ continue
+ }
+ existing, err := os.ReadFile(p)
+ if err != nil {
+ continue
+ }
+ em, err := yamlToMap(existing)
+ if err != nil {
+ continue
+ }
+ live, ok := em["password_hash"]
+ if !ok {
+ continue
+ }
+ cm["password_hash"] = live
+ out, err := marshalMapYAML(cm)
+ if err != nil {
+ return content, nil
+ }
+ return out, nil
+ }
+ if fmt.Sprint(hash) == redactedAccountHash {
+ return nil, fmt.Errorf("the stored history image has a redacted password hash and no live account file to take it from")
}
+ return content, nil
}
// load restores persisted undo/redo history written by save. A missing or
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go
index f4f5fbd..231a011 100644
--- a/internal/casino/baccarat.go
+++ b/internal/casino/baccarat.go
@@ -173,6 +173,8 @@ func (t *baccaratTable) action(name, action string) []Event {
}
func (t *baccaratTable) rebet(name string) (string, int) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
if p := t.players.get(name); p != nil && p.lastBet > 0 {
return p.lastSpot, p.lastBet
}
@@ -182,8 +184,13 @@ func (t *baccaratTable) rebet(name string) (string, int) {
// deal plays out the hand by the punto banco tableau, settles every wager,
// and resets the table for the next round.
func (t *baccaratTable) deal() []Event {
- // The cut-card reshuffle happens between rounds, never mid-hand.
+ // The cut-card reshuffle and the round-coverage guarantee both happen at
+ // this boundary, before a single card of the coup is dealt (deal() is
+ // synchronous, so a wager here has never had a card dealt for it):
+ // draw() rebuilds an exhausted shoe in place, which mid-coup could deal
+ // a card the coup already holds.
t.shoe.shuffleIfDue()
+ t.shoe.ensureForRound(baccaratRoundCards)
player, banker := playTableau(t.shoe)
playerTotal, bankerTotal := baccaratTotal(player), baccaratTotal(banker)
round := t.event("New baccarat hand!", true)
@@ -265,19 +272,20 @@ func (t *baccaratTable) resetRound() {
}
}
-// markDisconnected removes the player from the table, forfeiting any wager
-// they committed (deal only settles seated bettors). An open betting window
-// resolves or resets on the next tick now that they no longer hold it open.
+// markDisconnected removes the player from the table. A committed wager is
+// REFUNDED rather than forfeited: deal() is synchronous, so a seated bettor
+// whose wager is still outstanding has had no card dealt for it (the coup
+// only starts once every connected seat has bet) and nothing is revealed by
+// returning the stake. An open betting window then resolves or resets on the
+// next tick now that they no longer hold it open.
func (t *baccaratTable) markDisconnected(name string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
if !t.players.has(name) {
return nil
}
- // A disconnected player forfeits the round in flight: resolve the
- // stakes now so startup recovery does not refund them later.
if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != nil {
- p.Wallet.ResolveStakes()
+ p.Wallet.RefundOutstanding()
}
t.players.remove(name)
events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)}
@@ -395,9 +403,11 @@ func baccaratResultMessages(name, result string, payout int) (string, string) {
}
}
-// refundAll resolves every committed wager at this table (graceful
+// refundAll refunds every committed wager at this table (graceful
// shutdown/eviction): the wallet returns every outstanding stake exactly
-// once.
+// once. It then RESETS the round, so a refunded round can never settle (and
+// pay) afterwards: calling it twice, or letting the table keep running,
+// cannot pay the same round twice.
func (t *baccaratTable) refundAll() []Event {
t.mu.Lock()
defer t.mu.Unlock()
@@ -419,6 +429,7 @@ func (t *baccaratTable) refundAll() []Event {
fmt.Sprintf("%s is refunded %d as the casino closes.", name, refund),
fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund)))
}
+ t.resetRound()
return events
}
diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go
index 0a0551c..7b30699 100644
--- a/internal/casino/blackjack.go
+++ b/internal/casino/blackjack.go
@@ -72,12 +72,22 @@ type blackjackTable struct {
phase blackjackPhase
window bettingWindow
timer countdown
+ ceiling countdown
activePlayer int
activeHand int
dealer blackjackHand
shoe *shoe
}
+// loneTurnGrace multiplies the action window into an absolute per-turn
+// ceiling. A lone connected player is deliberately never timed out by the
+// action timer (a solo player's think time must not end the hand), but
+// "never" cannot mean forever: a hung-but-connected client would hold the
+// hand, the seat, and the stake open with no automatic resolution, since
+// "leave" refuses mid-round. After this many ticks on one turn the table
+// stands the hand for the player and plays on.
+const loneTurnGrace = 10
+
func newBlackjackTable(roomID int, cfg TableConfig, rng *rand.Rand) *blackjackTable {
cfg = cfg.Normalize()
rules := cfg.Blackjack.Normalize()
@@ -209,6 +219,18 @@ func (t *blackjackTable) tick() []Event {
t.activePlayer++
return t.finishHand()
}
+ // The absolute turn ceiling bounds every turn, including a lone
+ // connected player's: the action timer below deliberately never
+ // expires for a solo player, so without this a hung-but-connected
+ // client would hold the stake and the phase open forever.
+ t.ceiling.tick()
+ if t.ceiling.expired() {
+ active.hands[t.activeHand].stood = true
+ events := []Event{t.private(name, "You've been idle too long; standing.")}
+ // Restart the action timer so the next hand gets a full window.
+ t.timer.start(t.Rules.ActionTicks)
+ return append(events, t.finishHand()...)
+ }
if active.Connected && t.bettorCount() <= 1 {
// A lone connected player is never timed out. A disconnected
// active player always is, so the table can never stall.
@@ -395,8 +417,13 @@ func splitCompatible(a, b card, unlikeTens bool) bool {
}
func (t *blackjackTable) startRound() []Event {
- // The cut-card reshuffle happens between rounds, never mid-hand.
+ // The cut-card reshuffle and a best-effort round-coverage reshuffle
+ // happen at this boundary, before a single card of the round is dealt,
+ // so a boundary reshuffle cannot redeal a card the round already holds.
+ // draw() can still rebuild an exhausted shoe mid-round (a deliberate
+ // shuffle-through), so this is a heuristic, not a guarantee.
t.shoe.shuffleIfDue()
+ t.shoe.ensureForRound(t.roundCardCeiling())
t.dealer = blackjackHand{cards: []card{t.shoe.draw(), t.shoe.draw()}}
for _, p := range t.players.ordered() {
if !p.HasBet {
@@ -440,10 +467,23 @@ func (t *blackjackTable) beginPlay() {
t.activePlayer, t.activeHand = 0, 0
}
+// roundCardCeiling is the heuristic number of cards this table's round is
+// assumed to consume (see blackjackRoundCards): the dealer's hand plus every
+// seat's maximum number of split hands. startRound uses it to decide whether
+// to reshuffle at the round boundary. It is NOT a certified maximum (an
+// all-ace hand can exceed the per-hand estimate), so the shoe is not
+// guaranteed to hold it and draw() may still rebuild an exhausted shoe
+// mid-round (a deliberate shuffle-through).
+func (t *blackjackTable) roundCardCeiling() int {
+ return blackjackRoundCards(t.Config.MaxPlayers, t.Rules.MaxSplitHands)
+}
+
func (t *blackjackTable) prompt() []Event {
// Every freshly-prompted turn gets a full action window; rejected inputs
- // and timeouts can never extend the deadline.
+ // and timeouts can never extend the deadline. The turn ceiling restarts
+ // with the window for the same reason.
t.timer.start(t.Rules.ActionTicks)
+ t.ceiling.start(t.Rules.ActionTicks * loneTurnGrace)
name := t.activeName()
if name == "" {
return t.settle()
@@ -669,6 +709,7 @@ func (t *blackjackTable) resetRound() {
t.phase = blackjackWaiting
t.window.close()
t.timer.stop()
+ t.ceiling.stop()
for _, p := range t.players.ordered() {
p.HasBet, p.Wager, p.hands, p.insurance, p.insuranceStakeID, p.declinedInsurance = false, Wager{}, nil, 0, "", false
}
@@ -685,6 +726,8 @@ func (t *blackjackTable) resolveHandStakes(wallet Wallet, hand *blackjackHand) {
}
func (t *blackjackTable) rebet(name string) (string, int) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
if p := t.players.get(name); p != nil && p.lastBet > 0 {
return "", p.lastBet
}
@@ -753,10 +796,13 @@ func blackjackResultMessages(name, result string, payout int) (string, string) {
}
}
-// markDisconnected removes the player from the table, forfeiting any wager
-// they committed (settle only pays seated bettors). Removing a seat shifts
-// the seat indices the active turn points into, so mid-round play is fixed
-// up and handed to the next bettor (or settled) immediately.
+// markDisconnected removes the player from the table. A wager is forfeited
+// only when a hand is actually in flight (cards are face up, so a free
+// refund would hand back information the round carries); a stake placed in
+// the betting window has revealed nothing yet and is REFUNDED, so a
+// transient disconnect cannot destroy an undelt wager. Removing a seat
+// shifts the seat indices the active turn points into, so mid-round play is
+// fixed up and handed to the next bettor (or settled) immediately.
func (t *blackjackTable) markDisconnected(name string) []Event {
t.mu.Lock()
defer t.mu.Unlock()
@@ -768,10 +814,16 @@ func (t *blackjackTable) markDisconnectedLocked(name string) []Event {
if index < 0 {
return nil
}
- // A disconnected player forfeits the round in flight: resolve the
- // stakes now so startup recovery does not refund them later.
if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != nil {
- p.Wallet.ResolveStakes()
+ if t.phase == blackjackPlaying || t.phase == blackjackInsurance {
+ // A disconnected player forfeits the round in flight: resolve the
+ // stakes now so startup recovery does not refund them later.
+ p.Wallet.ResolveStakes()
+ } else {
+ // No card has been dealt for this stake yet, so refund it: nothing
+ // the bettor paid for has been revealed or played.
+ p.Wallet.RefundOutstanding()
+ }
}
t.players.remove(name)
events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)}
@@ -801,9 +853,12 @@ func (t *blackjackTable) hasParticipant(name string) bool {
return t.players.has(name)
}
-// refundAll resolves every committed wager at this table (graceful
+// refundAll refunds every committed wager at this table (graceful
// shutdown/eviction): the wallet returns every outstanding stake — opening
-// bet, doubles, splits, and insurance — exactly once.
+// bet, doubles, splits, and insurance — exactly once. It then RESETS the
+// round, so a refunded round can never settle (and pay) afterwards: calling
+// it twice, or letting the table keep running, cannot pay the same round
+// twice.
func (t *blackjackTable) refundAll() []Event {
t.mu.Lock()
defer t.mu.Unlock()
@@ -825,6 +880,7 @@ func (t *blackjackTable) refundAll() []Event {
fmt.Sprintf("%s is refunded %d as the casino closes.", name, refund),
fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund)))
}
+ t.resetRound()
return events
}
diff --git a/internal/casino/cards.go b/internal/casino/cards.go
index b1c2cbb..27da8aa 100644
--- a/internal/casino/cards.go
+++ b/internal/casino/cards.go
@@ -68,12 +68,17 @@ func (s *shoe) shuffle() {
s.rng.Shuffle(len(s.cards), func(i, j int) { s.cards[i], s.cards[j] = s.cards[j], s.cards[i] })
}
+// remaining reports the cards left in the shoe.
+func (s *shoe) remaining() int { return len(s.cards) }
+
func (s *shoe) draw() card {
if len(s.cards) == 0 {
- // The shoe ran dry mid-round (only possible when the configured
- // deck count is very small relative to the table): rebuild it
- // rather than panic. The cut-card reshuffle itself happens
- // between rounds via shuffleIfDue, never per draw.
+ // Shuffle-through: if the shoe runs out mid-round it is rebuilt from
+ // all decks, so a card already dealt this round can reappear. This
+ // matches real blackjack, where an exhausted shoe is shuffled through
+ // mid-round; ensureForRound makes it rare by reshuffling at round
+ // boundaries, and this keeps a directly-constructed table from
+ // panicking.
s.shuffle()
}
card := s.cards[len(s.cards)-1]
@@ -81,6 +86,24 @@ func (s *shoe) draw() card {
return card
}
+// ensureForRound reshuffles when the shoe is unlikely to cover the round that
+// is about to be dealt. It MUST be called at a round boundary, before any card
+// of that round is dealt: a rebuild there cannot redeal a card the current
+// round already holds, which is exactly what draw()'s exhausted-shoe
+// shuffle-through does. need is a heuristic estimate of the cards one round
+// can consume (see blackjackRoundCards/baccaratRoundCards), not a certified
+// maximum, so coverage is best-effort.
+func (s *shoe) ensureForRound(need int) {
+ if s.decks <= 0 {
+ // No deck composition to refill from: this is a synthetic shoe with
+ // an explicit card script (tests), which must not be replaced.
+ return
+ }
+ if len(s.cards) < need {
+ s.shuffle()
+ }
+}
+
// shuffleIfDue reshuffles the shoe once its remaining cards have fallen
// to the shuffleAt fraction (the cut card). It must be called at round
// boundaries only — shuffling between individual draws would let cards
diff --git a/internal/casino/config_validate.go b/internal/casino/config_validate.go
index 8e5e7b5..ee8e8c9 100644
--- a/internal/casino/config_validate.go
+++ b/internal/casino/config_validate.go
@@ -25,7 +25,10 @@ func (c MachineConfig) Validate() error {
if !finiteNonnegative(c.Payout) || c.Payout > 1 {
return fmt.Errorf("payout must be finite and between 0 and 1 (0 uses the default)")
}
- return nil
+ // The machine's effective profile is the built-in one scaled to the
+ // configured RTP; ValidateRTP certifies that profile, so it must be a
+ // geometry the runtime evaluator can actually play.
+ return machineSlotProfile(c).Validate()
}
func (c TableConfig) Validate() error {
@@ -47,6 +50,18 @@ func (c TableConfig) Validate() error {
if !finiteNonnegative(b.BlackjackPayout) || !finiteNonnegative(b.InsurancePayout) {
return fmt.Errorf("blackjack_payout and insurance_payout must be finite and nonnegative")
}
+ rules := b.Normalize()
+ if rules.AllowsSurrender() && c.Normalize().MinBet < minimumSurrenderBet {
+ return fmt.Errorf("surrender needs min_bet >= %d: a surrendered hand returns half the bet, which rounds to zero below that", minimumSurrenderBet)
+ }
+ // Shoe coverage is NOT a configuration floor. blackjackRoundCards is
+ // only a boundary heuristic (an all-ace hand can hold more than
+ // eleven cards), so a hard floor would reject tables that play fine
+ // and, because a room is refused whole on any error, would darken
+ // every casino game in it. shoe.ensureForRound reshuffles at the
+ // round boundary and draw()'s exhausted-shoe rebuild is a deliberate
+ // shuffle-through instead.
+ return nil
case GameBaccarat:
b := c.Baccarat
if err := validateShoe(b.Decks, b.ShuffleAt); err != nil {
@@ -55,10 +70,11 @@ func (c TableConfig) Validate() error {
if !finiteNonnegative(b.TiePayout) || !finiteNonnegative(b.BankerPayout) {
return fmt.Errorf("tie_payout and banker_payout must be finite and nonnegative")
}
+ // Non-blocking for the same reason as blackjack above.
+ return nil
default:
return fmt.Errorf("unsupported table game %q", c.Game)
}
- return nil
}
func validateIdentityAndBets(id string, minBet, maxBet int) error {
@@ -93,8 +109,58 @@ func validateShoe(decks int, shuffleAt float64) error {
return nil
}
+// minimumSurrenderBet is the smallest wager a surrendered hand can return
+// half of: half of a one-credit bet truncates to zero, which would consume
+// the whole stake.
+const minimumSurrenderBet = 2
+
+// blackjackRoundCards is the heuristic per-round card budget the shoe uses
+// to decide whether to reshuffle before a round: an estimated eleven cards
+// per hand times the most hands the table can have in play (the dealer's
+// hand plus every seat's maximum number of split hands). It is NOT a
+// certified maximum — an all-ace hand can exceed eleven cards — so it only
+// sizes the best-effort shoe.ensureForRound boundary reshuffle and must not
+// be turned back into a hard configuration floor.
+func blackjackRoundCards(maxPlayers, maxSplitHands int) int {
+ return 11 * (1 + maxPlayers*maxSplitHands)
+}
+
+// baccaratRoundCards is the maximum number of cards one baccarat coup can
+// consume: three per hand (baccarat's fixed tableau never draws more).
+const baccaratRoundCards = 6
+
+// Validate checks a slot profile the machine can actually play. The runtime
+// evaluator (generateSlotSpinWithProfile, matchingLength, stoppedScatters and
+// evaluateSlot) is pinned to the SlotSpin grid of three rows by five reels;
+// CalculateRTP/ValidateRTP are generic over Rows/Reels/Ways, so a profile
+// with another geometry would be certified by the RTP gate and then played as
+// a different game. Rejecting the geometry here keeps that gate meaningful.
+func (p SlotProfile) Validate() error {
+ profile := p.Normalize()
+ if profile.Rows != slotGridRows || profile.Reels != slotGridReels {
+ return fmt.Errorf("unsupported slot geometry %dx%d: the runtime evaluator supports %dx%d only",
+ profile.Rows, profile.Reels, slotGridRows, slotGridReels)
+ }
+ return nil
+}
+
+// machineSlotProfile builds the profile a machine configured by c plays: the
+// built-in paytable, scaled to the configured RTP. One source of truth for
+// validation and for newMachine.
+func machineSlotProfile(c MachineConfig) SlotProfile {
+ profile := DefaultHuffAndPuffProfile()
+ if c.Payout > 0 {
+ profile = ScaleToRTP(profile, c.Payout)
+ }
+ return profile.Normalize()
+}
+
// ValidateRoomConfigs checks the shared room-local ID namespace as well as
// individual definitions. Legacy slots under casino_tables remain supported.
+//
+// It also enforces one table/machine per game per room: a room is addressed
+// by game name ("play blackjack"), so a second entry for the same game (or
+// the same machine variant) could never be reached.
func ValidateRoomConfigs(tables []TableConfig, machines []MachineConfig) error {
seen := make(map[string]bool)
var errs []error
@@ -107,16 +173,31 @@ func ValidateRoomConfigs(tables []TableConfig, machines []MachineConfig) error {
errs = append(errs, fmt.Errorf("%s %q: %w", kind, id, err))
}
}
+ addressable := make(map[string]string)
+ checkAddressable := func(key, id string) {
+ if owner, ok := addressable[key]; ok {
+ errs = append(errs, fmt.Errorf("duplicate casino game %q in one room (%q and %q): only one table or machine per game name is addressable", key, owner, id))
+ return
+ }
+ addressable[key] = id
+ }
for _, c := range tables {
if c.Game == GameSlots {
+ // A slots "table" is instantiated as the room's slots machine.
legacy := MachineConfig{ID: c.ID, Game: GameSlots, MinBet: c.MinBet, MaxBet: c.MaxBet}
check("legacy slots", c.ID, legacy.Validate())
- } else {
- check("table", c.ID, c.Validate())
+ checkAddressable(strings.ToLower(string(GameSlots)), c.ID)
+ continue
}
+ check("table", c.ID, c.Validate())
+ checkAddressable(strings.ToLower(string(c.Game)), c.ID)
}
for _, c := range machines {
check("machine", c.ID, c.Validate())
+ checkAddressable(strings.ToLower(string(c.Game)), c.ID)
+ if c.Variant != "" {
+ checkAddressable(strings.ToLower(c.Variant), c.ID)
+ }
}
return errors.Join(errs...)
}
diff --git a/internal/casino/correct_casino_refund_bank_test.go b/internal/casino/correct_casino_refund_bank_test.go
new file mode 100644
index 0000000..fd90ae1
--- /dev/null
+++ b/internal/casino/correct_casino_refund_bank_test.go
@@ -0,0 +1,101 @@
+package casino
+
+import "testing"
+
+// Free-spin winnings live only in Machine.featureBank until the feature ends,
+// so a graceful shutdown (or room eviction) that zeroes the bank without
+// paying it would destroy winnings that had already landed. refundAll must
+// settle the bank through the wallet in addition to refunding the triggering
+// stake, and must keep the stake refundable (not double-paid) when either
+// side of that settlement fails.
+
+// TestMachineRefundAllPreservesBankedFeatureWinnings: a nonzero banked bonus
+// survives RefundAll while the triggering stake is refunded exactly once.
+func TestMachineRefundAllPreservesBankedFeatureWinnings(t *testing.T) {
+ m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, newTestRand())
+ wallet := &testWallet{credits: 100}
+ m.join("alice")
+ m.beginSpinLocked(10, wallet, "", "")
+ m.spin = &SlotSpin{Payout: 25, ScatterCount: 3}
+ m.finishSpin() // triggers the feature: 25 banked, the stake stays open
+ if m.featureBank != 25 || !m.inFreeSpins {
+ t.Fatalf("setup: featureBank=%v inFreeSpins=%v, want 25/true", m.featureBank, m.inFreeSpins)
+ }
+ if wallet.paid != 0 {
+ t.Fatalf("setup: a payout was journaled mid-feature (%d)", wallet.paid)
+ }
+
+ events := m.refundAll()
+ // 10 for the refunded stake plus the 25 banked credits.
+ if wallet.paid != 35 {
+ t.Fatalf("refundAll paid %d, want 35 (10 stake + 25 banked winnings)", wallet.paid)
+ }
+ if got := wallet.credits + wallet.paid; got != 125 {
+ t.Fatalf("banked winnings were destroyed: balance %d, want 125", got)
+ }
+ if !hasMessage(events, "25") {
+ t.Fatalf("missing banked-winnings settlement message: %+v", events)
+ }
+ if len(wallet.open) != 0 {
+ t.Fatalf("stake still outstanding after shutdown: %+v", wallet.open)
+ }
+}
+
+// TestMachineRefundAllFailedBankedPayoutKeepsStakeRefunded: if the banked
+// settlement cannot be persisted, the triggering stake is still refunded
+// exactly once (not paid twice) and the failure is reported rather than
+// silently swallowing the win.
+func TestMachineRefundAllFailedBankedPayoutKeepsStakeRefunded(t *testing.T) {
+ m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, newTestRand())
+ wallet := &failingPayoutWallet{testWallet{credits: 100}}
+ m.join("alice")
+ m.beginSpinLocked(10, wallet, "", "")
+ m.featureBank = 25
+
+ events := m.refundAll()
+ if wallet.paid != 10 {
+ t.Fatalf("stake refund paid %d, want 10 once", wallet.paid)
+ }
+ if len(wallet.open) != 0 {
+ t.Fatalf("stake still outstanding: %+v", wallet.open)
+ }
+ if !hasMessage(events, "could not be persisted") {
+ t.Fatalf("a failed banked payout was not reported: %+v", events)
+ }
+}
+
+// failingRefundWallet rejects every refund, mirroring the production wallet's
+// persistence-failure path: the stakes stay open for recovery.
+type failingRefundWallet struct {
+ testWallet
+}
+
+func (w *failingRefundWallet) RefundOutstanding() (int, int, error) {
+ return 0, 0, ErrWalletPersistence
+}
+
+// TestMachineRefundAllFailedRefundKeepsStakeOpenForRecovery pins the
+// load-bearing stakeFailed guard in refundAll: resetSeat must not resolve
+// (forfeit) the stake RefundOutstanding left open, or the recovery refund
+// contract is broken. Deleting `m.stakeFailed = err != nil` fails this test.
+func TestMachineRefundAllFailedRefundKeepsStakeOpenForRecovery(t *testing.T) {
+ m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, newTestRand())
+ wallet := &failingRefundWallet{testWallet{credits: 100}}
+ m.join("alice")
+ m.beginSpinLocked(10, wallet, "", "")
+ if len(wallet.open) != 1 {
+ t.Fatalf("setup: the stake must be open, got %+v", wallet.open)
+ }
+ m.featureBank = 25
+
+ events := m.refundAll()
+ if len(wallet.open) != 1 {
+ t.Fatalf("a failed refund forfeited the open stake; want it preserved for recovery: %+v", wallet.open)
+ }
+ if wallet.paid != 0 {
+ t.Fatalf("a failed refund paid %d; want 0", wallet.paid)
+ }
+ if !hasMessage(events, "recorded for recovery") {
+ t.Fatalf("the failed refund was not reported: %+v", events)
+ }
+}
diff --git a/internal/casino/correct_casino_room_validate_test.go b/internal/casino/correct_casino_room_validate_test.go
new file mode 100644
index 0000000..319a6b6
--- /dev/null
+++ b/internal/casino/correct_casino_room_validate_test.go
@@ -0,0 +1,36 @@
+package casino
+
+import "testing"
+
+// Removing the shoe-coverage configuration floor must not disturb legitimate
+// rooms: a room whose geometry validated before still validates, and one the
+// over-strict floor previously rejected (but that plays fine) now validates
+// too — so the room is neither darkened nor blocked from the admin editor.
+
+func TestRoomWithShippedGeometryStillValidates(t *testing.T) {
+ // The shipped intro room (data/rooms/intro/2001.yaml): blackjack,
+ // baccarat, and a slots machine in one room.
+ tables := []TableConfig{
+ {ID: "blackjack", Game: GameBlackjack, MaxPlayers: 6, BettingTicks: 20,
+ MinBet: 1, MaxBet: 1000,
+ Blackjack: BlackjackConfig{Decks: 8, ShuffleAt: 0.25, BlackjackPayout: 1.5}},
+ {ID: "baccarat", Game: GameBaccarat, MaxPlayers: 6, BettingTicks: 20,
+ MinBet: 1, MaxBet: 1000,
+ Baccarat: BaccaratConfig{Decks: 8, ShuffleAt: 0.25}},
+ }
+ machines := []MachineConfig{{
+ ID: "huff_puff_01", Game: GameSlots, Variant: "huff_and_even_more_puff",
+ MinBet: 1, MaxBet: 1000, Payout: 0.97,
+ }}
+ if err := ValidateRoomConfigs(tables, machines); err != nil {
+ t.Fatalf("the shipped casino room geometry no longer validates: %v", err)
+ }
+
+ // A six-deck eight-player blackjack table that the old 11*(1+P*S) floor
+ // rejected (needs 363 cards, holds 312) now validates.
+ small := []TableConfig{{ID: "blackjack", Game: GameBlackjack, MaxPlayers: 8,
+ Blackjack: BlackjackConfig{Decks: 6}}}
+ if err := ValidateRoomConfigs(small, nil); err != nil {
+ t.Fatalf("a previously-rejected but playable room still fails validation: %v", err)
+ }
+}
diff --git a/internal/casino/credits.go b/internal/casino/credits.go
index 2b4e2d7..8b7cb7a 100644
--- a/internal/casino/credits.go
+++ b/internal/casino/credits.go
@@ -30,22 +30,6 @@ var (
ErrWalletPersistence = errors.New("wallet persistence failure")
)
-// AddCredits adds two balances with an explicit cap, reporting overflow.
-// A negative addend is rejected (payouts never decrease a balance).
-func AddCredits(a, b int) (int, error) {
- if b < 0 {
- return a, errors.New("negative credit add")
- }
- sum := a + b
- if b > 0 && sum < a || a > 0 && sum < b {
- return MaxCredits, fmt.Errorf("credit overflow: %d + %d exceeds %d", a, b, MaxCredits)
- }
- if sum > MaxCredits {
- return MaxCredits, fmt.Errorf("credit overflow: %d + %d exceeds %d", a, b, MaxCredits)
- }
- return sum, nil
-}
-
// MulPayout scales an amount by a payout multiplier and converts to whole
// credits (floor, like a house paying in chips). Overflow past MaxCredits
// is an error, not a silent wrap. A non-finite or negative multiplier is
diff --git a/internal/casino/credits_test.go b/internal/casino/credits_test.go
index cff19c9..eed12fa 100644
--- a/internal/casino/credits_test.go
+++ b/internal/casino/credits_test.go
@@ -4,19 +4,6 @@ import (
"testing"
)
-func TestAddCreditsChecked(t *testing.T) {
- sum, err := AddCredits(5, 7)
- if err != nil || sum != 12 {
- t.Fatalf("AddCredits(5,7) = %d, %v", sum, err)
- }
- if sum, err := AddCredits(MaxCredits, 1); err == nil || sum != MaxCredits {
- t.Fatalf("overflow add: %d, %v", sum, err)
- }
- if _, err := AddCredits(MaxCredits, -1); err == nil {
- t.Fatal("negative add must be rejected")
- }
-}
-
func TestMulPayoutBoundsAndConverts(t *testing.T) {
payout, err := MulPayout(10, 1.5)
if err != nil || payout != 15 {
diff --git a/internal/casino/hardening_casino_disconnect_test.go b/internal/casino/hardening_casino_disconnect_test.go
new file mode 100644
index 0000000..6d25581
--- /dev/null
+++ b/internal/casino/hardening_casino_disconnect_test.go
@@ -0,0 +1,102 @@
+package casino
+
+import "testing"
+
+// A disconnect must only forfeit a hand that is actually in flight. A stake
+// placed in the betting window has had no card dealt for it, so refunding it
+// reveals nothing; destroying it there would let an ordinary transient TCP
+// drop swallow an undelt wager.
+
+// TestBlackjackPreDealDisconnectRefundsStake: alice bets, the betting window
+// is open (bob has not bet, no cards dealt), alice's socket drops. Her stake
+// comes back, the wager record is resolved, and the round that follows pays
+// nothing on top of the refund.
+func TestBlackjackPreDealDisconnectRefundsStake(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, MaxPlayers: 2, BettingTicks: 3}, newTestRand())
+ table.join("alice")
+ table.join("bob")
+ wallet := &testWallet{credits: 100}
+ table.bet("alice", "", 10, wallet)
+ if table.phase != blackjackBetting {
+ t.Fatalf("setup: expected the betting window (phase=%s)", table.phase)
+ }
+ if wallet.credits != 90 {
+ t.Fatalf("setup: stake not committed (credits=%d)", wallet.credits)
+ }
+
+ events := table.markDisconnected("alice")
+ if !hasMessage(events, "alice stands up") {
+ t.Fatalf("missing disconnect event: %+v", events)
+ }
+ if got := wallet.credits + wallet.paid; got != 100 {
+ t.Fatalf("pre-deal disconnect returned %d of 100; the undelt stake must be refunded", got)
+ }
+ if len(wallet.open) != 0 {
+ t.Fatalf("refunded stake left outstanding: %+v", wallet.open)
+ }
+
+ // The window resolves with bob sitting out; nothing may pay the
+ // refunded round afterwards.
+ for i := 0; i < 10; i++ {
+ table.tick()
+ }
+ if got := wallet.credits + wallet.paid; got != 100 {
+ t.Fatalf("refunded pre-deal round paid again: balance %d", got)
+ }
+ if table.phase != blackjackWaiting {
+ t.Fatalf("table did not reset after the window expired: phase=%s", table.phase)
+ }
+}
+
+// TestBlackjackMidHandDisconnectForfeitsStake: the documented mid-hand
+// forfeit policy is unchanged — cards are face up, so the stake stays with
+// the house.
+func TestBlackjackMidHandDisconnectForfeitsStake(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, MaxPlayers: 2}, newTestRand())
+ table.shoe = tableauShoe(
+ card{rank: "10"}, card{rank: "7"}, // dealer
+ card{rank: "10"}, card{rank: "8"}, // alice 18
+ card{rank: "10"}, card{rank: "6"}, // bob 16
+ )
+ wallet := &testWallet{credits: 100}
+ table.join("alice")
+ table.join("bob")
+ table.bet("alice", "", 10, wallet)
+ table.bet("bob", "", 10, &testWallet{credits: 100})
+ if table.phase != blackjackPlaying {
+ t.Fatalf("setup: expected a dealt hand (phase=%s)", table.phase)
+ }
+
+ table.markDisconnected("alice")
+ if wallet.credits != 90 || wallet.paid != 0 {
+ t.Fatalf("mid-hand disconnect refunded a dealt hand: credits=%d paid=%d, want 90/0 (forfeit)", wallet.credits, wallet.paid)
+ }
+}
+
+// TestBaccaratBettingWindowDisconnectRefundsStake: baccarat deals
+// synchronously once every connected seat has bet, so an outstanding wager
+// there has never had a card dealt for it either.
+func TestBaccaratBettingWindowDisconnectRefundsStake(t *testing.T) {
+ table := newBaccaratTable(1, TableConfig{ID: "baccarat", Game: GameBaccarat, MaxPlayers: 2, BettingTicks: 3}, newTestRand())
+ table.join("alice")
+ table.join("bob")
+ wallet := &testWallet{credits: 100}
+ table.bet("alice", baccaratSpotPlayer, 10, wallet)
+ if table.phase != baccaratBetting {
+ t.Fatalf("setup: expected the betting window (phase=%s)", table.phase)
+ }
+
+ table.markDisconnected("alice")
+ if got := wallet.credits + wallet.paid; got != 100 {
+ t.Fatalf("pre-deal baccarat disconnect returned %d of 100; want the stake refunded", got)
+ }
+ if len(wallet.open) != 0 {
+ t.Fatalf("refunded baccarat stake left outstanding: %+v", wallet.open)
+ }
+ for i := 0; i < 10; i++ {
+ table.tick()
+ }
+ if got := wallet.credits + wallet.paid; got != 100 {
+ t.Fatalf("refunded pre-deal coup paid again: balance %d", got)
+ }
+}
diff --git a/internal/casino/hardening_casino_locking_test.go b/internal/casino/hardening_casino_locking_test.go
new file mode 100644
index 0000000..080d941
--- /dev/null
+++ b/internal/casino/hardening_casino_locking_test.go
@@ -0,0 +1,84 @@
+package casino
+
+import (
+ "sync"
+ "testing"
+ "time"
+)
+
+// rebet is called through Manager.Rebet and reads the seat map; it must take
+// the table lock like every other game method, or a future caller off the
+// owner goroutine would race the live seat state.
+
+func TestRebetTakesTheTableLock(t *testing.T) {
+ blackjack := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand())
+ blackjack.join("alice")
+ assertRebetWaitsForLock(t, "blackjack", &blackjack.mu, func() { blackjack.rebet("alice") })
+
+ baccarat := newBaccaratTable(1, TableConfig{ID: "baccarat", Game: GameBaccarat}, newTestRand())
+ baccarat.join("alice")
+ assertRebetWaitsForLock(t, "baccarat", &baccarat.mu, func() { baccarat.rebet("alice") })
+}
+
+func assertRebetWaitsForLock(t *testing.T, name string, mu *sync.Mutex, rebet func()) {
+ t.Helper()
+ mu.Lock()
+ done := make(chan struct{})
+ go func() {
+ rebet()
+ close(done)
+ }()
+ select {
+ case <-done:
+ mu.Unlock()
+ t.Fatalf("%s rebet read the seat map without the table lock", name)
+ case <-time.After(50 * time.Millisecond):
+ }
+ mu.Unlock()
+ select {
+ case <-done:
+ case <-time.After(2 * time.Second):
+ t.Fatalf("%s rebet did not return after the lock was released", name)
+ }
+}
+
+// A lone connected player is deliberately never timed out by the action
+// window. The absolute turn ceiling must therefore end that turn, or a
+// hung-but-connected client holds the hand, the seat, and the stake forever
+// (leave refuses mid-round).
+
+func TestLoneBettorTurnCeilingSettlesTheHand(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack,
+ Blackjack: BlackjackConfig{ActionTicks: 2}}, newTestRand())
+ table.shoe = tableauShoe(
+ card{rank: "10"}, card{rank: "7"}, // dealer 17
+ card{rank: "10"}, card{rank: "6"}, // alice 16
+ )
+ wallet := &testWallet{credits: 100}
+ table.join("alice")
+ table.bet("alice", "", 10, wallet)
+ if table.phase != blackjackPlaying {
+ t.Fatalf("setup: expected a dealt hand (phase=%s)", table.phase)
+ }
+
+ // The action timer alone would have expired after two ticks, but the
+ // lone bettor gets the full grace period: 10x the action window.
+ ceiling := table.Rules.ActionTicks * loneTurnGrace
+ for i := 0; i < ceiling-1; i++ {
+ table.tick()
+ }
+ if table.phase != blackjackPlaying {
+ t.Fatalf("lone bettor's hand ended before the turn ceiling (phase=%s, ceiling=%d)", table.phase, ceiling)
+ }
+
+ events := table.tick()
+ if table.phase != blackjackWaiting {
+ t.Fatalf("turn ceiling did not settle the hand: phase=%s events=%+v", table.phase, events)
+ }
+ if !hasMessage(events, "idle too long") {
+ t.Fatalf("missing turn-ceiling message: %+v", events)
+ }
+ if len(wallet.open) != 0 {
+ t.Fatalf("stake still committed after the ceiling stood the hand: %+v", wallet.open)
+ }
+}
diff --git a/internal/casino/hardening_casino_refund_test.go b/internal/casino/hardening_casino_refund_test.go
new file mode 100644
index 0000000..7b98be8
--- /dev/null
+++ b/internal/casino/hardening_casino_refund_test.go
@@ -0,0 +1,145 @@
+package casino
+
+import (
+ "math/rand"
+ "testing"
+)
+
+// RefundAll (graceful shutdown / room eviction) must not leave a refunded
+// round alive: a round that settles after its stake was returned pays the
+// same round twice and mints credits.
+
+// TestRefundAllDiscardsTheRefundedMachineRound: the review's repro — bet,
+// refund everything, then keep ticking. The machine must not pay the spin
+// whose stake the refund returned, and a second RefundAll must not refund
+// again.
+func TestRefundAllDiscardsTheRefundedMachineRound(t *testing.T) {
+ m := NewManager(1)
+ cfg := MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 50}
+ m.JoinMachine(1, cfg, "alice")
+ wallet := &testWallet{credits: 100}
+ if events := m.SetMachineBet(1, "slots", "alice", 10, wallet); len(events) == 0 {
+ t.Fatal("setting a bet produced no events")
+ }
+ // Force a paying spin, so a live round WOULD pay after the refund.
+ machine := m.machines[tableKey(1, "slots")]
+ machine.mu.Lock()
+ machine.spin = &SlotSpin{Payout: 20}
+ machine.mu.Unlock()
+
+ m.RefundAll()
+ if wallet.paid != 10 {
+ t.Fatalf("refund paid %d, want the 10-credit stake", wallet.paid)
+ }
+ // Drive the machine to (what used to be) the end of its spin.
+ for i := 0; i < 60; i++ {
+ m.Tick()
+ }
+ if got := wallet.credits + wallet.paid; got != 100 {
+ t.Fatalf("refunded spin paid again: balance %d, want 100 (10 refunded once, no 20-credit payout)", got)
+ }
+ if machine.phase != machineReady {
+ t.Fatalf("refunded machine kept its round: phase=%s", machine.phase)
+ }
+
+ // A second shutdown (or eviction) must not double-pay either.
+ m.RefundAll()
+ if got := wallet.credits + wallet.paid; got != 100 {
+ t.Fatalf("second RefundAll double-paid: balance %d", got)
+ }
+}
+
+// TestRefundAllDiscardsTheRefundedTableRound: same rule at a card table. The
+// refunded round is reset, so the later settlement/split path cannot pay the
+// stake the refund already returned.
+func TestRefundAllDiscardsTheRefundedTableRound(t *testing.T) {
+ m := NewManager(1)
+ cfg := TableConfig{ID: "bj", Game: GameBlackjack, MaxPlayers: 2, BettingTicks: 5}
+ m.Join(1, cfg, "alice")
+ m.Join(1, cfg, "bob")
+ wallet := &testWallet{credits: 100}
+ // Only alice bets: the window stays open with no cards dealt.
+ m.Bet(1, "bj", "alice", "", 10, wallet)
+
+ m.RefundAll()
+ table := m.Table(1, "bj").game.(*blackjackTable)
+ if table.phase != blackjackWaiting || table.players.get("alice").HasBet {
+ t.Fatalf("refunded table kept its round: phase=%s hasBet=%v", table.phase, table.players.get("alice").HasBet)
+ }
+ // The window can no longer deal (the wager is gone) and nothing pays.
+ for i := 0; i < 20; i++ {
+ m.Tick()
+ }
+ if got := wallet.credits + wallet.paid; got != 100 {
+ t.Fatalf("refunded table round paid again: balance %d, want 100", got)
+ }
+}
+
+// Free-spin bookkeeping: recovery reads ANY journaled payout for the player
+// as proof that the round resolved (Wallet.PayCredits carries no stake
+// linkage), so a feature that paid each spin as it landed would make a crash
+// mid-bonus silently keep the triggering stake. The machine must journal
+// nothing until the feature — and with it the round — completes.
+
+// TestSlotsFeatureWinningsAreBankedNotJournaled: the triggering stake stays
+// outstanding and no payout at all is made while free spins remain.
+func TestSlotsFeatureWinningsAreBankedNotJournaled(t *testing.T) {
+ m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, rand.New(rand.NewSource(7)))
+ wallet := &testWallet{credits: 100}
+ m.join("alice")
+ m.beginSpinLocked(10, wallet, "", "")
+ m.spin = &SlotSpin{Payout: 25, ScatterCount: 3}
+ events := m.finishSpin()
+ if !hasMessage(events, "trigger") {
+ t.Fatalf("setup: feature did not trigger: %+v", events)
+ }
+ if wallet.paid != 0 {
+ t.Fatalf("a payout was journaled mid-feature (%d credits): recovery would read it as the round resolving", wallet.paid)
+ }
+ if len(wallet.open) != 1 {
+ t.Fatalf("the triggering stake must stay outstanding through the feature, open=%+v", wallet.open)
+ }
+ if m.featureBank != 25 {
+ t.Fatalf("featureBank=%v, want the trigger spin's 25 credits banked", m.featureBank)
+ }
+
+ // A free spin wins too and is banked as well.
+ m.spin = &SlotSpin{Payout: 40}
+ m.freeSpins = 1
+ m.finishSpin()
+ if wallet.paid != 0 || len(wallet.open) != 1 {
+ t.Fatalf("mid-feature spin paid or resolved the stake: paid=%d open=%+v", wallet.paid, wallet.open)
+ }
+ if m.featureBank != 65 {
+ t.Fatalf("featureBank=%v, want 65 banked", m.featureBank)
+ }
+}
+
+// TestSlotsFeaturePaysOneSettlementThenResolvesTheStake: the banked feature
+// winnings are paid in a single settlement when the last free spin stops, and
+// only then is the triggering stake resolved.
+func TestSlotsFeaturePaysOneSettlementThenResolvesTheStake(t *testing.T) {
+ m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, rand.New(rand.NewSource(7)))
+ wallet := &testWallet{credits: 100}
+ m.join("alice")
+ m.beginSpinLocked(10, wallet, "", "")
+ // The last free spin: the feature (and the round) resolves with it.
+ m.inFreeSpins = true
+ m.freeSpins = 0
+ m.featureBank = 25
+ m.spin = &SlotSpin{Payout: 40, ScatterCount: 3}
+
+ events := m.finishSpin()
+ if wallet.paid != 65 {
+ t.Fatalf("feature settlement paid %d, want one 65-credit payout", wallet.paid)
+ }
+ if !hasMessage(events, "The free spins pay 65 credits.") {
+ t.Fatalf("missing feature settlement message: %+v", events)
+ }
+ if len(wallet.open) != 0 {
+ t.Fatalf("stake still outstanding after the feature completed: %+v", wallet.open)
+ }
+ if m.inFreeSpins || m.freeSpins != 0 || m.featureBank != 0 {
+ t.Fatalf("feature state leaked: inFreeSpins=%v freeSpins=%d bank=%v", m.inFreeSpins, m.freeSpins, m.featureBank)
+ }
+}
diff --git a/internal/casino/hardening_casino_rng_test.go b/internal/casino/hardening_casino_rng_test.go
new file mode 100644
index 0000000..2d44441
--- /dev/null
+++ b/internal/casino/hardening_casino_rng_test.go
@@ -0,0 +1,75 @@
+package casino
+
+import "testing"
+
+// RNG seams: outcomes must not be replayable from a clock, while tests keep
+// a deterministic, injectable seed.
+
+func TestNewManagerSeedIsADeterministicSeam(t *testing.T) {
+ cfg := MachineConfig{ID: "slots", Game: GameSlots}
+ same := []*Machine{
+ NewManager(7).EnsureMachine(1, cfg),
+ NewManager(7).EnsureMachine(1, cfg),
+ }
+ other := NewManager(8).EnsureMachine(1, cfg)
+
+ first := machineDraws(same[0])
+ if got := machineDraws(same[1]); !sameDraws(first, got) {
+ t.Fatalf("the same seed produced different slot streams: %v vs %v", first, got)
+ }
+ if got := machineDraws(other); sameDraws(first, got) {
+ t.Fatalf("different seeds produced the same slot stream: %v", got)
+ }
+}
+
+func TestNewManagerEntropySeedsFromCryptoRand(t *testing.T) {
+ if seed := entropySeed(); seed < 0 {
+ t.Fatalf("entropy seed %d is negative", seed)
+ }
+ seen := make(map[int64]bool)
+ for i := 0; i < 4; i++ {
+ m := NewManagerEntropy()
+ if m.seed < 0 {
+ t.Fatalf("NewManagerEntropy seed %d is negative", m.seed)
+ }
+ seen[m.seed] = true
+ }
+ if len(seen) < 2 {
+ t.Fatalf("NewManagerEntropy reused one seed across %d managers", len(seen))
+ }
+}
+
+// Each game gets its own stream seeded from the manager's: no two tables or
+// machines may share reel results.
+func TestManagerGamesKeepIndependentStreams(t *testing.T) {
+ m := NewManager(3)
+ first := m.EnsureMachine(1, MachineConfig{ID: "slots_a", Game: GameSlots})
+ second := m.EnsureMachine(1, MachineConfig{ID: "slots_b", Game: GameSlots})
+ if first == nil || second == nil {
+ t.Fatal("machines were not created")
+ }
+ if sameDraws(machineDraws(first), machineDraws(second)) {
+ t.Fatal("two machines share one RNG stream")
+ }
+}
+
+// machineDraws consumes n values from a game's own stream.
+func machineDraws(m *Machine) []int64 {
+ draws := make([]int64, 8)
+ for i := range draws {
+ draws[i] = m.rng.Int63()
+ }
+ return draws
+}
+
+func sameDraws(a, b []int64) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/casino/hardening_casino_validate_test.go b/internal/casino/hardening_casino_validate_test.go
new file mode 100644
index 0000000..1d27d54
--- /dev/null
+++ b/internal/casino/hardening_casino_validate_test.go
@@ -0,0 +1,181 @@
+package casino
+
+import (
+ "strings"
+ "testing"
+)
+
+// Slot geometry: CalculateRTP/ValidateRTP are generic over Rows/Reels/Ways
+// while the runtime evaluator is pinned to the 3x5 grid, so a profile the
+// runtime cannot play must be rejected rather than certified.
+
+func TestSlotProfileGeometryIsValidated(t *testing.T) {
+ if err := DefaultHuffAndPuffProfile().Validate(); err != nil {
+ t.Fatalf("the built-in 3x5 profile was rejected: %v", err)
+ }
+ // The Ways model may differ from Rows^Reels (evaluateSlot divides by it),
+ // but the grid may not: a 4-row profile would be certified by the RTP
+ // gate and then played as a 3-row game.
+ wide := SlotProfile{
+ Rows: 4, Reels: 5, Ways: 256, TargetRTP: 3,
+ Symbols: []SlotSymbol{{ID: "x", Weight: 1, Payout: map[int]float64{3: 1, 4: 2, 5: 3}}},
+ }
+ if err := ValidateRTP(wide, 1000); err == nil {
+ t.Fatal("ValidateRTP certified a profile the runtime evaluator cannot play")
+ }
+ if err := (MachineConfig{ID: "slots", Game: GameSlots}).Validate(); err != nil {
+ t.Fatalf("valid slots machine rejected: %v", err)
+ }
+ // A profile that only the RTP model supports must not become a machine.
+ if err := wide.Validate(); err == nil || !strings.Contains(err.Error(), "geometry") {
+ t.Fatalf("unsupported geometry accepted: %v", err)
+ }
+}
+
+// Shoe coverage is a boundary heuristic, NOT a hard configuration floor. The
+// old bound (11 cards per hand x hands) is not a true maximum — an all-ace
+// hand can exceed eleven cards — and enforcing it rejected working tables (a
+// one-deck eight-player table; a six-deck eight-player table that holds 312
+// cards against the old 363-card floor). Because a room is refused whole on
+// any validation error, that floor silently darkened every casino game in a
+// room and blocked the room from being saved. Validation no longer rejects
+// on coverage: shoe.ensureForRound reshuffles at the round boundary and
+// draw()'s exhausted-shoe rebuild is a deliberate shuffle-through.
+
+func TestShoeCoverageIsHeuristicNotAHardFloor(t *testing.T) {
+ // The shipped eight-player geometry still validates at six decks (312
+ // cards) even though the old bound demanded 363.
+ sixDecksEightPlayers := TableConfig{ID: "bj", Game: GameBlackjack, MaxPlayers: 8,
+ Blackjack: BlackjackConfig{Decks: 6}}
+ if err := sixDecksEightPlayers.Validate(); err != nil {
+ t.Fatalf("a six-deck eight-player table was rejected: %v", err)
+ }
+ // A one-deck shoe, eight players: previously rejected, now plays with a
+ // boundary reshuffle and draw()'s in-place rebuild.
+ oneDeck := TableConfig{ID: "bj", Game: GameBlackjack, MaxPlayers: 8,
+ Blackjack: BlackjackConfig{Decks: 1}}
+ if err := oneDeck.Validate(); err != nil {
+ t.Fatalf("a one-deck eight-player table was rejected: %v", err)
+ }
+ // The common hand-edited room: a single-seat one-deck table.
+ small := TableConfig{ID: "bj", Game: GameBlackjack, MaxPlayers: 1,
+ Blackjack: BlackjackConfig{Decks: 1}}
+ if err := small.Validate(); err != nil {
+ t.Fatalf("a one-deck single-seat table was rejected: %v", err)
+ }
+ if err := (TableConfig{ID: "bac", Game: GameBaccarat, Baccarat: BaccaratConfig{Decks: 1}}).Validate(); err != nil {
+ t.Fatalf("one-deck baccarat rejected: %v", err)
+ }
+
+ // The boundary reshuffle still fires with the heuristic round size
+ // (the dealer plus every seat's maximum split hands), and the heuristic
+ // value is unchanged.
+ if got, want := blackjackRoundCards(8, 4), 363; got != want {
+ t.Fatalf("blackjackRoundCards(8,4) = %d, want %d", got, want)
+ }
+ need := blackjackRoundCards(1, 1)
+ s := newShoe(1, 0.25, newTestRand())
+ s.cards = s.cards[:need-1] // one card short of the heuristic round
+ s.ensureForRound(need)
+ if s.remaining() != 52 {
+ t.Fatalf("shoe holding %d cards was not reshuffled for a %d-card round", need-1, need)
+ }
+
+ // draw() on an exhausted shoe rebuilds in place (shuffle-through) rather
+ // than panicking — the reason coverage cannot be a hard guarantee.
+ empty := &shoe{decks: 1, rng: newTestRand()}
+ if card := empty.draw(); card.rank == "" {
+ t.Fatal("draw() on an exhausted shoe returned an empty card")
+ }
+}
+
+// Surrender returns half the bet with integer truncation, so a table whose
+// minimum wager is one credit would consume the whole stake on a surrender.
+
+func TestSurrenderRequiresATwoCreditMinimum(t *testing.T) {
+ surrender := true
+ oneCredit := TableConfig{ID: "bj", Game: GameBlackjack, MinBet: 1,
+ Blackjack: BlackjackConfig{Surrender: &surrender}}
+ if err := oneCredit.Validate(); err == nil {
+ t.Fatal("surrender accepted with a one-credit minimum; half a credit rounds to zero")
+ }
+ // min_bet 0 normalizes to 1, so it is rejected too.
+ if err := (TableConfig{ID: "bj", Game: GameBlackjack,
+ Blackjack: BlackjackConfig{Surrender: &surrender}}).Validate(); err == nil {
+ t.Fatal("surrender accepted with the default one-credit minimum")
+ }
+ twoCredits := TableConfig{ID: "bj", Game: GameBlackjack, MinBet: 2,
+ Blackjack: BlackjackConfig{Surrender: &surrender}}
+ if err := twoCredits.Validate(); err != nil {
+ t.Fatalf("surrender with a two-credit minimum rejected: %v", err)
+ }
+ // A table without surrender keeps its one-credit minimum.
+ if err := (TableConfig{ID: "bj", Game: GameBlackjack, MinBet: 1}).Validate(); err != nil {
+ t.Fatalf("plain one-credit table rejected: %v", err)
+ }
+}
+
+// The shoe refills at a round boundary, never between draws: an exhausted
+// shoe rebuilt mid-round would deal cards the round already holds.
+
+func TestShoeRefillsOnlyAtRoundBoundaries(t *testing.T) {
+ need := blackjackRoundCards(1, 1)
+ shoe := newShoe(1, 0.25, newTestRand())
+ shoe.cards = shoe.cards[:need-1] // one card short of a maximum round
+ shoe.ensureForRound(need)
+ if shoe.remaining() != 52 {
+ t.Fatalf("shoe holding %d cards was not refilled for a %d-card round", need-1, need)
+ }
+
+ before := shoe.remaining()
+ shoe.ensureForRound(need)
+ if shoe.remaining() != before {
+ t.Fatalf("a shoe that already covers the round was reshuffled: %d -> %d", before, shoe.remaining())
+ }
+
+ // A shoe without a deck composition is an explicit card script (tests);
+ // it must never be replaced by a fresh shuffled deck.
+ scripted := tableauShoe(card{rank: "A"})
+ scripted.ensureForRound(need)
+ if scripted.remaining() != 1 {
+ t.Fatalf("scripted shoe was replaced: %d cards remain", scripted.remaining())
+ }
+}
+
+// A room addresses one table/machine per game name ("play blackjack"), so a
+// second entry for the same game is unreachable content, not a second table.
+
+func TestRoomRejectsASecondEntryForOneGame(t *testing.T) {
+ twoBlackjackTables := []TableConfig{
+ {ID: "bj1", Game: GameBlackjack},
+ {ID: "bj2", Game: GameBlackjack},
+ }
+ err := ValidateRoomConfigs(twoBlackjackTables, nil)
+ if err == nil || !strings.Contains(err.Error(), "duplicate casino game") {
+ t.Fatalf("two blackjack tables accepted: %v", err)
+ }
+ // Distinct games, and a machine whose variant name is also addressable.
+ ok := ValidateRoomConfigs(
+ []TableConfig{{ID: "blackjack", Game: GameBlackjack}, {ID: "baccarat", Game: GameBaccarat}},
+ []MachineConfig{{ID: "slots", Game: GameSlots}},
+ )
+ if ok != nil {
+ t.Fatalf("one entry per game rejected: %v", ok)
+ }
+ // A legacy slots table and a slots machine both answer to "slots".
+ legacy := ValidateRoomConfigs(
+ []TableConfig{{ID: "legacy_slots", Game: GameSlots}},
+ []MachineConfig{{ID: "slots", Game: GameSlots}},
+ )
+ if legacy == nil || !strings.Contains(legacy.Error(), "duplicate casino game") {
+ t.Fatalf("legacy slots table plus a slots machine accepted: %v", legacy)
+ }
+ // Two machines of the same variant are equally unreachable.
+ twoVariants := ValidateRoomConfigs(nil, []MachineConfig{
+ {ID: "slots_a", Game: GameSlots, Variant: "huff_and_even_more_puff"},
+ {ID: "slots_b", Game: GameSlots, Variant: "huff_and_even_more_puff"},
+ })
+ if twoVariants == nil {
+ t.Fatal("two machines of one variant accepted")
+ }
+}
diff --git a/internal/casino/machine.go b/internal/casino/machine.go
index 844c5e9..cf0ccb9 100644
--- a/internal/casino/machine.go
+++ b/internal/casino/machine.go
@@ -30,11 +30,16 @@ type Machine struct {
inFreeSpins bool
spinBet int
payoutRemainder float64
- profile SlotProfile
- rng *rand.Rand
- suspense bool
- autoBet bool
- autoCountdown int
+ // featureBank holds the winnings of a paid round whose free-spin feature
+ // is still running. They are paid as ONE settlement when the feature
+ // ends: see finishSpin for why no payout may be journaled while the
+ // triggering stake is outstanding.
+ featureBank float64
+ profile SlotProfile
+ rng *rand.Rand
+ suspense bool
+ autoBet bool
+ autoCountdown int
// stakeFailed marks the in-flight stake whose payout could not be
// persisted: the stake record stays OPEN as recovery evidence, so the
// seat must not resolve (forfeit) it when the player leaves.
@@ -43,12 +48,7 @@ type Machine struct {
func newMachine(roomID int, cfg MachineConfig, rng *rand.Rand) *Machine {
cfg = cfg.Normalize()
- profile := DefaultHuffAndPuffProfile()
- if cfg.Payout > 0 {
- profile = ScaleToRTP(profile, cfg.Payout)
- }
- profile = profile.Normalize()
- return &Machine{RoomID: roomID, Config: cfg, phase: machineReady, profile: profile, rng: rng}
+ return &Machine{RoomID: roomID, Config: cfg, phase: machineReady, profile: machineSlotProfile(cfg), rng: rng}
}
func (m *Machine) join(name string) []Event {
@@ -184,11 +184,11 @@ func (m *Machine) tick() []Event {
m.reel++
message := fmt.Sprintf("Reel %d stops...", m.reel)
events := m.publicSlot(message, m.reel)
- if m.reel < 5 && stoppedScatters(m.spin.Grid, m.reel, m.profile.FreeSpinTrigger) >= 2 && !m.suspense {
+ if m.reel < slotGridReels && stoppedScatters(m.spin.Grid, m.reel, m.profile.FreeSpinTrigger) >= 2 && !m.suspense {
m.suspense = true
events = append(events, m.public("Two scatters! The remaining reels slow down...")...)
}
- if m.reel < 5 {
+ if m.reel < slotGridReels {
return events
}
m.phase = machinePayout
@@ -224,6 +224,18 @@ func (m *Machine) autoBetTick() []Event {
}
}
+// finishSpin resolves the spin that just stopped: it pays that spin's
+// winnings, advances or starts the free-spin feature, and resolves the
+// triggering stake once the whole round (feature included) is over.
+//
+// A free-spin feature pays out as ONE settlement when it ends. While the
+// feature runs its winnings are banked in memory and nothing is journaled:
+// the triggering stake stays OUTSTANDING until the feature completes, and
+// recovery reads ANY payout record for the player as proof that the round
+// resolved (a PayCredits record carries no stake linkage, see the Wallet
+// contract). Paying a free spin as it landed would therefore make a crash
+// mid-feature settle the stake instead of refunding it, contradicting the
+// refund-on-crash contract that a graceful shutdown honors.
func (m *Machine) finishSpin() []Event {
events := []Event{}
result := m.spin.Payout + m.payoutRemainder
@@ -238,9 +250,26 @@ func (m *Machine) finishSpin() []Event {
}
credits := int(math.Floor(result))
m.payoutRemainder = result - float64(credits)
+
+ // A spin belongs to a feature when it is a free spin or when it just
+ // triggered one; the feature (and with it the round) resolves on the
+ // last free spin.
+ triggered := m.spin.ScatterCount >= 3 && m.freeSpins == 0 && !m.inFreeSpins
+ inFeature := m.inFreeSpins || triggered
+ featureEnds := m.inFreeSpins && m.freeSpins == 0
+ payNow := credits
+ switch {
+ case inFeature && !featureEnds:
+ m.featureBank += float64(credits)
+ payNow = 0
+ case featureEnds:
+ payNow = credits + int(m.featureBank)
+ m.featureBank = 0
+ }
+
payFailed := false
- if credits > 0 && m.player.Wallet != nil {
- if err := m.player.Wallet.PayCredits(credits); err != nil {
+ if payNow > 0 && m.player.Wallet != nil {
+ if err := m.player.Wallet.PayCredits(payNow); 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, stop any autobet chain, and keep the stake
@@ -254,10 +283,19 @@ func (m *Machine) finishSpin() []Event {
events = append(events, failedEvents...)
}
}
- if credits > 0 && !payFailed {
- winEvents := m.private(fmt.Sprintf("\nYou win %d credits.", credits))
+ if payNow > 0 && !payFailed {
+ message := fmt.Sprintf("\nYou win %d credits.", payNow)
+ switch {
+ case inFeature && !featureEnds:
+ // Banked, not spent: say so, or the player would read the win
+ // as already in their balance.
+ message = fmt.Sprintf("\nYou win %d credits, paid when the free spins end.", payNow)
+ case featureEnds:
+ message = fmt.Sprintf("\nThe free spins pay %d credits.", payNow)
+ }
+ winEvents := m.private(message)
winEvents[0].Color = "casino_payout"
- if credits > m.spinBet {
+ if payNow > m.spinBet {
winEvents[0].Color = "casino_big_win"
}
events = append(events, winEvents...)
@@ -271,7 +309,7 @@ func (m *Machine) finishSpin() []Event {
// free spin are ignored, matching the CalculateRTP model (the feature is
// a fixed number of automatic spins). Retriggering only on a paid spin
// keeps the machine's actual return aligned with that model.
- if m.spin.ScatterCount >= 3 && m.freeSpins == 0 && !m.inFreeSpins {
+ if triggered {
m.freeSpins = m.profile.FreeSpinCount
m.inFreeSpins = true
events = append(events, m.public(fmt.Sprintf("Three scatter symbols trigger %d free spins!", m.profile.FreeSpinCount))...)
@@ -335,6 +373,7 @@ func (m *Machine) resetSeat() {
m.freeSpins = 0
m.inFreeSpins = false
m.payoutRemainder = 0
+ m.featureBank = 0
m.autoBet, m.autoCountdown = false, 0
m.stakeFailed = false
}
@@ -388,10 +427,10 @@ func (m *Machine) publicSlot(message string, stopped int) []Event {
Message: message, Color: "casino_action", Public: true, Slot: &SlotSnapshot{Grid: m.spin.Grid, StoppedReels: stopped}}}
}
-func stoppedScatters(grid [3][5]string, reels int, trigger string) int {
+func stoppedScatters(grid [slotGridRows][slotGridReels]string, reels int, trigger string) int {
count := 0
- for row := 0; row < 3; row++ {
- for reel := 0; reel < reels && reel < 5; reel++ {
+ for row := 0; row < slotGridRows; row++ {
+ for reel := 0; reel < reels && reel < slotGridReels; reel++ {
if grid[row][reel] == trigger {
count++
}
@@ -429,16 +468,23 @@ func (m *Machine) menuEvents(prefix string) []Event {
return m.privateMenu(prefix)
}
-// refundAll refunds any outstanding stake at graceful shutdown/eviction.
-// The wallet resolves every outstanding stake exactly once; a completed
-// spin has no outstanding stake and is not refunded again. A stake whose
-// payout failed to persist (stakeFailed) is NOT returned here when an open
-// payout record covers it — the journaled winnings prove the round
-// resolved, so the stake was consumed by playing it and recovery re-applies
-// the owed payout instead of double-paying the round. A bonus in progress
-// keeps its triggering stake outstanding with no payout journaled, so a
-// shutdown during the feature returns the stake (the remaining free-spin
-// EV is forfeit, never the wager). Locked.
+// refundAll refunds any outstanding stake at graceful shutdown/eviction and
+// then DISCARDS the round (the machine closes with the process). The wallet
+// resolves every outstanding stake exactly once; a completed spin has no
+// outstanding stake and is not refunded again. A stake whose payout failed
+// to persist (stakeFailed) is NOT returned here when an open payout record
+// covers it — the journaled winnings prove the round resolved, so the stake
+// was consumed by playing it and recovery re-applies the owed payout instead
+// of double-paying the round. A bonus in progress keeps its triggering stake
+// outstanding with no payout journaled, so a shutdown during the feature
+// returns the stake (only the still-unplayed free spins' EV is forfeit); the
+// winnings that have already landed are banked in featureBank and are paid
+// through the wallet as a settlement, so a graceful shutdown preserves them
+// exactly as the pre-banking code did. Locked.
+//
+// Discarding the round is what makes the call safe to repeat: a refunded
+// round can never settle (and pay its spin) afterwards, so neither a second
+// RefundAll nor a stray tick can pay the same round twice.
func (m *Machine) refundAll() []Event {
m.mu.Lock()
defer m.mu.Unlock()
@@ -446,22 +492,72 @@ func (m *Machine) refundAll() []Event {
return nil
}
name, wallet := m.player.Name, m.player.Wallet
+ // Free-spin winnings that have already landed live only in featureBank
+ // until the feature ends (see finishSpin), so discarding the round must
+ // settle them or a graceful shutdown silently destroys them. Refund the
+ // outstanding triggering stake FIRST: the settlement that pays the bank
+ // below carries no stake linkage, so a journaled settlement with the
+ // stake still open would read at recovery as covering (and consuming)
+ // that stake, suppressing its refund.
chips, credits, err := wallet.RefundOutstanding()
refund := chips + credits
- if err != nil || refund <= 0 {
- if err != nil {
- return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult,
- PrivateTo: name, Player: name,
- PrivateMessage: "Shutdown refund could not be persisted; the stake is recorded for recovery.",
- Color: "casino_action", Public: false}}
+ bank := int(m.featureBank)
+ bankFailed := false
+ if err == nil && bank > 0 {
+ // Only settle the bank once the refund is settled: on a failed refund
+ // the stake stays open for recovery, and a settlement payout would
+ // make recovery treat it as consumed instead of refunded.
+ if payErr := wallet.PayCredits(bank); payErr != nil {
+ bankFailed = true
}
- return nil
}
- return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult,
- PrivateTo: name, Player: name,
- Message: fmt.Sprintf("%s is refunded %d as the casino closes.", name, refund),
- PrivateMessage: fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund),
- Color: "casino_action", Public: true}}
+ // A refund that could not be made durable leaves the stakes open for
+ // recovery to complete; the round is still discarded, and marking the
+ // stake as failed keeps resetSeat from resolving (forfeiting) it. This is
+ // LOAD-BEARING, not dead code: on the RefundOutstanding error path the
+ // stakes are left open, and resetSeat's guard is the only thing that
+ // stops it from resolving them and erasing the recovery evidence.
+ m.stakeFailed = err != nil
+ m.resetSeat()
+ var events []Event
+ switch {
+ case err != nil:
+ events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult,
+ PrivateTo: name, Player: name,
+ PrivateMessage: "Shutdown refund could not be persisted; the stake is recorded for recovery.",
+ Color: "casino_action", Public: false})
+ case refund > 0:
+ events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult,
+ PrivateTo: name, Player: name,
+ Message: fmt.Sprintf("%s is refunded %d as the casino closes.", name, refund),
+ PrivateMessage: fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund),
+ Color: "casino_action", Public: true})
+ }
+ // Announce the banked free-spin settlement, or its loss. resetSeat has
+ // already zeroed featureBank by this point, so a failed refund does NOT
+ // keep the bank in memory: the banked winnings are forfeited and only the
+ // trigger stake is recovered. Report that forfeiture explicitly (a
+ // stake-linked settlement that survives a failed shutdown refund is not
+ // implemented) instead of dropping it silently.
+ switch {
+ case bankFailed:
+ events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult,
+ PrivateTo: name, Player: name,
+ PrivateMessage: fmt.Sprintf("Your %d credits of free-spin winnings could not be persisted; they are recorded for recovery.", bank),
+ Color: "casino_action", Public: false})
+ case err != nil && bank > 0:
+ events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult,
+ PrivateTo: name, Player: name,
+ PrivateMessage: fmt.Sprintf("Your %d credits of banked free-spin winnings were forfeited because the shutdown refund could not be persisted.", bank),
+ Color: "casino_action", Public: false})
+ case err == nil && bank > 0:
+ events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult,
+ PrivateTo: name, Player: name,
+ Message: fmt.Sprintf("%s's %d free-spin credits pay out as the casino closes.", name, bank),
+ PrivateMessage: fmt.Sprintf("The house settles your %d banked free-spin winnings for the shutdown.", bank),
+ Color: "casino_action", Public: true})
+ }
+ return events
}
// idle reports whether no player is seated: only an idle machine may have
diff --git a/internal/casino/manager.go b/internal/casino/manager.go
index 4798217..8b87147 100644
--- a/internal/casino/manager.go
+++ b/internal/casino/manager.go
@@ -1,6 +1,8 @@
package casino
import (
+ crand "crypto/rand"
+ "encoding/binary"
"fmt"
"math/rand"
"reflect"
@@ -14,13 +16,43 @@ type Manager struct {
tables map[string]*Table
machines map[string]*Machine
rng *rand.Rand
+ // seed is the seed the manager's stream was created with, so a test can
+ // assert that the entropy path really drew a fresh one.
+ seed int64
}
-func NewManager(seed int64) *Manager {
+// NewManager returns a Manager with DETERMINISTIC per-game RNG streams
+// derived from seed. It is the test seam: a fixed seed must give fixed
+// shoes, reels, and spins. Production uses NewManagerEntropy.
+func NewManager(seed int64) *Manager { return newManager(seed) }
+
+// NewManagerEntropy returns a Manager whose per-game RNG streams are seeded
+// from crypto/rand. Casino outcomes must not be reconstructible from the
+// clock: a wall-clock seed lets anyone who knows (or brute-forces) the
+// startup nanosecond replay every shoe and reel stream, so a certified
+// 0.97-RTP machine becomes an unlimited-credit one. The streams themselves
+// stay math/rand (unbiased Intn/Shuffle) and each game keeps its own.
+func NewManagerEntropy() *Manager { return newManager(entropySeed()) }
+
+// entropySeed draws a fresh 63-bit seed from crypto/rand. A broken entropy
+// source is fatal instead of silently degrading to a reconstructible clock
+// seed: the casino's game integrity depends on it.
+func entropySeed() int64 {
+ var b [8]byte
+ if _, err := crand.Read(b[:]); err != nil {
+ panic(fmt.Sprintf("casino entropy seed: %v", err))
+ }
+ // A non-negative 63-bit value: math/rand accepts any int64 seed, and a
+ // non-negative seed prints cleanly in diagnostics.
+ return int64(binary.LittleEndian.Uint64(b[:]) >> 1)
+}
+
+func newManager(seed int64) *Manager {
return &Manager{
tables: make(map[string]*Table),
machines: make(map[string]*Machine),
rng: rand.New(rand.NewSource(seed)),
+ seed: seed,
}
}
@@ -294,9 +326,16 @@ func (m *Manager) MachineBusy(name string) bool {
}
// RefundAll resolves every outstanding wager by refunding committed stakes
-// to their players. Graceful shutdown uses this: rounds cannot settle, so
-// no player may lose a stake to the shutdown itself. Refunds go through the
-// wallet (durable, journaled) and return the refund events for delivery.
+// to their players and then closing each game's round. Graceful shutdown and
+// room eviction use this: rounds cannot settle, so no player may lose a
+// stake to the shutdown itself. Refunds go through the wallet (durable,
+// journaled) and the refund events are returned for delivery.
+//
+// It is deliberately NOT a way to clear live tables: every game discards
+// the round it refunded (seats keep their last bet only for "rebet", but
+// phases, hands, spins, and timers are reset), so a refunded round cannot
+// settle later and a second call refunds nothing. A caller that wants the
+// games to keep playing must not use this.
func (m *Manager) RefundAll() []Event {
var events []Event
for _, table := range m.snapshotTables() {
diff --git a/internal/casino/polish_refund_forfeit_test.go b/internal/casino/polish_refund_forfeit_test.go
new file mode 100644
index 0000000..1408fcd
--- /dev/null
+++ b/internal/casino/polish_refund_forfeit_test.go
@@ -0,0 +1,24 @@
+package casino
+
+import "testing"
+
+// Regression: refundAll's comment claimed a failed refund kept featureBank in
+// memory, but resetSeat zeroes it before the settlement switch runs, so
+// already-landed free-spin winnings are forfeited. The forfeiture must be
+// reported explicitly rather than dropped silently.
+
+func TestMachineRefundAllReportsForfeitedBankOnFailedRefund(t *testing.T) {
+ m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, newTestRand())
+ wallet := &failingRefundWallet{testWallet{credits: 100}}
+ m.join("alice")
+ m.beginSpinLocked(10, wallet, "", "")
+ m.featureBank = 25
+
+ events := m.refundAll()
+ if m.featureBank != 0 {
+ t.Fatalf("featureBank = %v after refundAll, want 0 (resetSeat cleared it)", m.featureBank)
+ }
+ if !hasMessage(events, "forfeited") {
+ t.Fatalf("the forfeited bank was not reported: %+v", events)
+ }
+}
diff --git a/internal/casino/slots.go b/internal/casino/slots.go
index 075050c..64caf85 100644
--- a/internal/casino/slots.go
+++ b/internal/casino/slots.go
@@ -23,12 +23,22 @@ type SlotProfile struct {
}
type SlotSpin struct {
- Grid [3][5]string
+ Grid [slotGridRows][slotGridReels]string
Payout float64
ScatterCount int
Wins []SlotWin
}
+// The runtime slot evaluator is pinned to this grid: SlotSpin/SlotSnapshot
+// hold it, generateSlotSpinWithProfile fills it, and matchingLength and
+// stoppedScatters evaluate it. CalculateRTP/ValidateRTP are generic over
+// SlotProfile.Rows/Reels/Ways, so SlotProfile.Validate rejects any other
+// geometry (see config_validate.go) to keep the RTP gate honest.
+const (
+ slotGridRows = 3
+ slotGridReels = 5
+)
+
type SlotWin struct {
Symbol string
Length int
@@ -72,10 +82,12 @@ func (p SlotProfile) Normalize() SlotProfile {
}
func generateSlotSpinWithProfile(rng *rand.Rand, bet int, profile SlotProfile) *SlotSpin {
+ // SlotProfile.Normalize supplies the defaults; Validate rejects a
+ // geometry this grid cannot hold, so the bounds are exactly the grid.
p := profile.Normalize()
spin := &SlotSpin{}
- for reel := 0; reel < p.Reels && reel < 5; reel++ {
- for row := 0; row < p.Rows && row < 3; row++ {
+ for reel := 0; reel < p.Reels && reel < slotGridReels; reel++ {
+ for row := 0; row < p.Rows && row < slotGridRows; row++ {
symbol := weightedSymbol(rng, p.Symbols)
spin.Grid[row][reel] = symbol
if symbol == p.FreeSpinTrigger {
@@ -110,7 +122,7 @@ func weightedSymbol(rng *rand.Rand, symbols []SlotSymbol) string {
return symbols[len(symbols)-1].ID
}
-func evaluateSlot(grid [3][5]string, bet int, profile SlotProfile) (float64, []SlotWin) {
+func evaluateSlot(grid [slotGridRows][slotGridReels]string, bet int, profile SlotProfile) (float64, []SlotWin) {
p := profile.Normalize()
if bet <= 0 {
return 0, nil
@@ -128,12 +140,12 @@ func evaluateSlot(grid [3][5]string, bet int, profile SlotProfile) (float64, []S
return total, wins
}
-func matchingLength(grid [3][5]string, symbol string) (int, int) {
+func matchingLength(grid [slotGridRows][slotGridReels]string, symbol string) (int, int) {
ways := 1
length := 0
- for reel := 0; reel < 5; reel++ {
+ for reel := 0; reel < slotGridReels; reel++ {
count := 0
- for row := 0; row < 3; row++ {
+ for row := 0; row < slotGridRows; row++ {
if grid[row][reel] == symbol {
count++
}
@@ -204,6 +216,11 @@ func binomial(n, k int) float64 {
}
func ValidateRTP(profile SlotProfile, tolerance float64) error {
+ // A profile the runtime cannot play must never be certified: its RTP
+ // would describe a different game than the one actually paid.
+ if err := profile.Validate(); err != nil {
+ return err
+ }
if profile.TargetRTP <= 0 {
return nil
}
diff --git a/internal/casino/types.go b/internal/casino/types.go
index 49e2cc4..080e60a 100644
--- a/internal/casino/types.go
+++ b/internal/casino/types.go
@@ -268,7 +268,7 @@ type Event struct {
}
type SlotSnapshot struct {
- Grid [3][5]string
+ Grid [slotGridRows][slotGridReels]string
StoppedReels int
}
diff --git a/internal/config/config.go b/internal/config/config.go
index 362c9c1..852a6d6 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -3,6 +3,9 @@
package config
import (
+ "bytes"
+ "errors"
+ "io"
"os"
"gopkg.in/yaml.v3"
@@ -242,7 +245,15 @@ func Load(path string) (*Config, error) {
return nil, err
}
cfg := Default()
- if err := yaml.Unmarshal(data, cfg); err != nil {
+ // KnownFields makes a misspelled key (e.g. telnet_listen) a hard error
+ // instead of silently leaving the default in place. ColorsConfig is a map,
+ // so arbitrary color keys remain allowed.
+ dec := yaml.NewDecoder(bytes.NewReader(data))
+ dec.KnownFields(true)
+ // An empty or comment-only file yields io.EOF, which means "no keys set"
+ // rather than a broken config: the defaults above stay in effect. A real
+ // syntax/unknown-field error is still returned.
+ if err := dec.Decode(cfg); err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
return cfg, nil
diff --git a/internal/config/correct_empty_config_test.go b/internal/config/correct_empty_config_test.go
new file mode 100644
index 0000000..27d39da
--- /dev/null
+++ b/internal/config/correct_empty_config_test.go
@@ -0,0 +1,50 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// Regression: Load used yaml.Decoder.Decode for KnownFields, which returns
+// io.EOF for an empty or comment-only config.yaml. cmd/thoi treats any non-
+// IsNotExist error as fatal, so an empty config aborted startup instead of
+// booting on defaults (yaml.Unmarshal had returned nil).
+
+func writeEmptyConfig(t *testing.T, body string) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "config.yaml")
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+func TestLoadEmptyFileBootsOnDefaults(t *testing.T) {
+ cfg, err := Load(writeEmptyConfig(t, ""))
+ if err != nil {
+ t.Fatalf("empty config.yaml must load on defaults, got: %v", err)
+ }
+ if cfg.TickLength != Default().TickLength {
+ t.Errorf("TickLength = %d, want default %d", cfg.TickLength, Default().TickLength)
+ }
+ if cfg.Telnet.Port != Default().Telnet.Port {
+ t.Errorf("Telnet.Port = %d, want default %d", cfg.Telnet.Port, Default().Telnet.Port)
+ }
+}
+
+func TestLoadCommentOnlyFileBootsOnDefaults(t *testing.T) {
+ cfg, err := Load(writeEmptyConfig(t, "# nothing configured yet\n\n# ports come from defaults\n"))
+ if err != nil {
+ t.Fatalf("comment-only config.yaml must load on defaults, got: %v", err)
+ }
+ if cfg.StartingRoom != Default().StartingRoom {
+ t.Errorf("StartingRoom = %d, want default %d", cfg.StartingRoom, Default().StartingRoom)
+ }
+}
+
+func TestLoadEmptyFileStillRejectsUnknownKey(t *testing.T) {
+ if _, err := Load(writeEmptyConfig(t, "not_a_real_key: 1\n")); err == nil {
+ t.Fatal("unknown key must still be rejected after the io.EOF fix")
+ }
+}
diff --git a/internal/config/hardening_config_known_fields_test.go b/internal/config/hardening_config_known_fields_test.go
new file mode 100644
index 0000000..be00aa9
--- /dev/null
+++ b/internal/config/hardening_config_known_fields_test.go
@@ -0,0 +1,66 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// Regression: config.Load used plain yaml.Unmarshal, so an unknown/misspelled
+// key (e.g. telnet_listen) was silently discarded and the default applied.
+// It now uses KnownFields(true), matching the data loaders, while
+// SaveDefault's output must still round-trip through Load.
+
+func writeConfig(t *testing.T, body string) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "config.yaml")
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+func TestLoadRejectsUnknownKey(t *testing.T) {
+ path := writeConfig(t, "tick_length: 700\ntelnet_listen: 4000\n")
+ if _, err := Load(path); err == nil {
+ t.Fatal("expected an error for unknown key telnet_listen, got nil")
+ } else if !strings.Contains(err.Error(), "telnet_listen") {
+ t.Fatalf("error should name the offending key, got: %v", err)
+ }
+}
+
+func TestLoadKnownKeysOverrideDefaults(t *testing.T) {
+ path := writeConfig(t, "tick_length: 700\nstarting_room: 2002\nstartup_validation:\n root_rooms: [2002]\n")
+ cfg, err := Load(path)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ if cfg.TickLength != 700 {
+ t.Errorf("TickLength = %d, want 700", cfg.TickLength)
+ }
+ if cfg.StartingRoom != 2002 {
+ t.Errorf("StartingRoom = %d, want 2002", cfg.StartingRoom)
+ }
+ if len(cfg.StartupValidation.RootRooms) != 1 || cfg.StartupValidation.RootRooms[0] != 2002 {
+ t.Errorf("RootRooms = %v, want [2002]", cfg.StartupValidation.RootRooms)
+ }
+ // Unset values keep their defaults.
+ if cfg.Telnet.Port != Default().Telnet.Port {
+ t.Errorf("unset Telnet.Port = %d, want default %d", cfg.Telnet.Port, Default().Telnet.Port)
+ }
+}
+
+func TestSaveDefaultRoundTrips(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "config.yaml")
+ if err := SaveDefault(path); err != nil {
+ t.Fatalf("SaveDefault: %v", err)
+ }
+ cfg, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load(SaveDefault output): %v", err)
+ }
+ if cfg.TickLength != Default().TickLength {
+ t.Errorf("TickLength = %d, want %d", cfg.TickLength, Default().TickLength)
+ }
+}
diff --git a/internal/game/account_txn.go b/internal/game/account_txn.go
index cf31dea..13f7695 100644
--- a/internal/game/account_txn.go
+++ b/internal/game/account_txn.go
@@ -121,9 +121,36 @@ func (g *Game) canonicalChar(name string) (string, error) {
return g.AccountStore.FindCharacter(name)
}
+// validateAccountName and validateCharacterName reject names that any
+// AccountPath/CharPath join would interpret as a path (separators, dots) or
+// that no account/character in this store may carry. Every lifecycle
+// transaction validates its names BEFORE it touches the filesystem: a
+// caller-supplied or account-file-supplied name such as "../../x" would
+// otherwise make AccountPath/CharPath read or delete files outside
+// players/, and a transaction is not the place to discover that.
+func validateAccountName(name string) error {
+ if err := player.ValidName(name); err != nil {
+ return fmt.Errorf("invalid account name %q: %w", name, err)
+ }
+ return nil
+}
+
+func validateCharacterName(name string) error {
+ if err := player.ValidName(name); err != nil {
+ return fmt.Errorf("invalid character name %q: %w", name, err)
+ }
+ return nil
+}
+
// createCharacterTxn creates the character file and appends it to the
// owning account in a single transaction. Returns the created player.
func (g *Game) createCharacterTxn(accountName, charName string, startingRoom int) (*player.Player, error) {
+ if err := validateAccountName(accountName); err != nil {
+ return nil, err
+ }
+ if err := validateCharacterName(charName); err != nil {
+ return nil, err
+ }
p := player.New(charName)
p.RoomID = startingRoom
p.Stats.RecordRoomVisit(startingRoom)
@@ -155,6 +182,15 @@ func (g *Game) createCharacterTxn(accountName, charName string, startingRoom int
// renameCharacterTxn atomically renames the character file, persists the new
// name inside it, and updates the owning account's character list.
func (g *Game) renameCharacterTxn(accountName, oldName, newName string) error {
+ if err := validateAccountName(accountName); err != nil {
+ return err
+ }
+ if err := validateCharacterName(oldName); err != nil {
+ return err
+ }
+ if err := validateCharacterName(newName); err != nil {
+ return err
+ }
txn := g.newTxn()
if err := txn.Move(g.AccountStore.CharPath(oldName), g.AccountStore.CharPath(newName)); err != nil {
return err
@@ -202,6 +238,12 @@ func (g *Game) renameCharacterTxn(accountName, oldName, newName string) error {
// deleteCharacterTxn removes the character file and updates the account list
// atomically.
func (g *Game) deleteCharacterTxn(accountName, charName string) error {
+ if err := validateAccountName(accountName); err != nil {
+ return err
+ }
+ if err := validateCharacterName(charName); err != nil {
+ return err
+ }
txn := g.newTxn()
if err := txn.Delete(g.AccountStore.CharPath(charName)); err != nil {
return err
@@ -228,8 +270,19 @@ func (g *Game) deleteCharacterTxn(accountName, charName string) error {
}
// purgeAccountTxn removes the account file and every character file in one
-// transaction.
+// transaction. The account's character list arrives from the account file,
+// so every name in it is validated before the first CharPath join: a
+// hand-edited or corrupt entry must not turn a purge into a delete outside
+// players/characters/.
func (g *Game) purgeAccountTxn(accountName string, characters []string) error {
+ if err := validateAccountName(accountName); err != nil {
+ return err
+ }
+ for _, name := range characters {
+ if err := validateCharacterName(name); err != nil {
+ return err
+ }
+ }
txn := g.newTxn()
for _, name := range characters {
if err := txn.Delete(g.AccountStore.CharPath(name)); err != nil {
@@ -247,15 +300,30 @@ func (g *Game) purgeAccountTxn(accountName string, characters []string) error {
// transaction. Without the character rewrite the renamed account loses
// access to its characters: membership authorization compares the
// character file's owner against the account name.
+//
+// Both names and every entry of the account's character list are validated
+// up front: each one is joined into AccountPath/CharPath below, and a
+// path-like name would escape players/.
func (g *Game) renameAccountTxn(oldName, newName string) error {
- txn := g.newTxn()
- if err := txn.Move(g.AccountStore.AccountPath(oldName), g.AccountStore.AccountPath(newName)); err != nil {
+ if err := validateAccountName(oldName); err != nil {
+ return err
+ }
+ if err := validateAccountName(newName); err != nil {
return err
}
acc, err := g.AccountStore.LoadAccount(oldName)
if err != nil {
return err
}
+ for _, charName := range acc.Characters {
+ if err := validateCharacterName(charName); err != nil {
+ return err
+ }
+ }
+ txn := g.newTxn()
+ if err := txn.Move(g.AccountStore.AccountPath(oldName), g.AccountStore.AccountPath(newName)); err != nil {
+ return err
+ }
acc.Name = newName
accData, err := g.AccountStore.MarshalAccount(acc)
if err != nil {
@@ -311,7 +379,9 @@ func (g *Game) isMemberOf(accountName, characterName string) bool {
// publishAccountUpdate reloads the account from disk and republishes its
// state to every session of that account, resetting stale pending menu
// selections (pending character names, menu choices) after lifecycle
-// mutations. Called from the owner goroutine only.
+// mutations. A session whose account no longer exists on disk is reset to the
+// account-name prompt (resetToAccountName) and closed. Called from the owner
+// goroutine only.
//
// oldName and newName differ only for an account rename: sessions
// authenticated before the rename still carry the OLD cached account
@@ -321,12 +391,14 @@ func (g *Game) publishAccountUpdate(oldName, newName string) {
for _, sess := range g.allSessionsOfEitherAccount(oldName, newName) {
acc, err := g.AccountStore.LoadAccount(newName)
if err != nil {
- // The account file vanished (purge from another session); the
- // session is stale and must leave the menu.
- sess.Account = nil
- sess.PendingChar = ""
- sess.PendingMenu = nil
+ // The account file vanished (purge or a raw admin delete from
+ // another session); the session is stale and must leave the menu.
+ // resetToAccountName clears PendingPass and the pending state
+ // alongside Account: a stale confirm-password still matching
+ // PendingPass would otherwise reach a handler that expects an
+ // account bound to the session.
sess.WriteLine("\nYour account no longer exists.")
+ g.resetToAccountName(sess)
sess.Close()
continue
}
diff --git a/internal/game/act.go b/internal/game/act.go
index 64d1848..1317df4 100644
--- a/internal/game/act.go
+++ b/internal/game/act.go
@@ -198,7 +198,10 @@ func (g *Game) AdvanceActions() {
if g.Hub == nil {
return
}
- for _, sess := range g.Hub.AllSessions() {
+ // One session snapshot per tick: the background-action pass below used to
+ // re-snapshot the whole hub.
+ sessions := g.Hub.AllSessions()
+ for _, sess := range sessions {
p := sess.Player
if p == nil || p.Action == nil {
continue
@@ -245,7 +248,7 @@ func (g *Game) AdvanceActions() {
}
}
- for _, sess := range g.Hub.AllSessions() {
+ for _, sess := range sessions {
p := sess.Player
if p == nil || p.BackgroundAction == nil {
continue
diff --git a/internal/game/act_burn.go b/internal/game/act_burn.go
index 2d03ac4..84f7f14 100644
--- a/internal/game/act_burn.go
+++ b/internal/game/act_burn.go
@@ -417,6 +417,12 @@ func (g *Game) broadcastDrop(p *player.Player, itemID, itemName string) {
}
func (g *Game) FireTick() {
+ // Mandatory tick-system guard: the master per-tick subscriber can fire
+ // before SetHub during startup, and burning out a fire reaches
+ // cancelStokers -> g.Hub.PlayersInRoom. See building_guide/tick_order.md.
+ if g.Hub == nil {
+ return
+ }
for _, st := range g.World.AllFireStates() {
st.Quality--
if st.Quality <= 0 {
diff --git a/internal/game/act_clean.go b/internal/game/act_clean.go
index 2872173..3525475 100644
--- a/internal/game/act_clean.go
+++ b/internal/game/act_clean.go
@@ -71,7 +71,10 @@ func (g *Game) advanceClean(sess *net.Session, p *player.Player) {
}
g.saveCharacter(p)
- msg := g.resolveCraftMessage(sess, craft.SuccessMessage, "You clean the %i1.", craft, matchItem.ID, nil, p.HasItem)
+ // The grimy herb's slot was just overwritten with the clean product, so
+ // p.HasItem is false for it: resolve %i1 from the exact item consumed.
+ msg := g.resolveCraftMessage(sess, craft.SuccessMessage, "You clean the %i1.", craft, matchItem.ID, nil,
+ func(id string) bool { return id == consumeID })
if p.OptionBool("xp_drops") && craft.XP > 0 {
msg += g.formatXpDropSingle(sess, p, player.Pharmacy, craft.XP)
}
diff --git a/internal/game/act_combine.go b/internal/game/act_combine.go
index 014dbef..701aaec 100644
--- a/internal/game/act_combine.go
+++ b/internal/game/act_combine.go
@@ -46,6 +46,20 @@ func (g *Game) startCombine(sess *net.Session, p *player.Player, item *item.Item
g.broadcastAction(sess, "%s starts combining.", p.Name)
}
+// combineSkill resolves the skill a combine recipe trains. Combine recipes
+// declare type "combine", which is an action, not a skill: player.SkillName
+// of it is the literal "combine", so p.Level would read a map miss as level 1
+// when rolling success and awardSkillXP would persist a phantom "combine"
+// skill entry. A recipe that declares a real skill name is honored; anything
+// else trains Crafting, the skill combine work belongs to.
+func combineSkill(craft *item.CraftDef) player.SkillName {
+ skill := player.SkillName(craft.EffectiveSkill())
+ if player.ValidSkill(skill) {
+ return skill
+ }
+ return player.Crafting
+}
+
func (g *Game) advanceCombine(sess *net.Session, p *player.Player) {
d, ok := p.Action.Data.(*behavior.CombineData)
if !ok {
@@ -100,10 +114,11 @@ func (g *Game) advanceCombine(sess *net.Session, p *player.Player) {
return
}
- // A full inventory must never destroy materials: verify the output
- // can be placed BEFORE collecting byproducts and consuming.
+ // A full inventory must never destroy materials: verify BOTH the
+ // output and any fail replacement can be placed BEFORE collecting
+ // byproducts and consuming.
outDef, _ := g.ItemStore.Load(item.ID)
- if !craftOutputFits(p, item.ID, outDef) {
+ if !craftOutputFits(p, item.ID, outDef) || !g.craftFailFits(p, craft) {
sess.WriteLine("Your inventory is too full!")
g.cancelAction(p)
return
@@ -113,31 +128,40 @@ func (g *Game) advanceCombine(sess *net.Session, p *player.Player) {
// each ingredient entry to the alternative actually being consumed
// (bucket vs jug of water), which requires the items still present.
byproducts := g.collectByproducts(p, item)
+ hasItem := craftConsumedHasItem(craft, p.CountItem, p.HasItem)
- consumeIngredients(craft, p.CountItem, p.RemoveItem)
-
- outputQty := craft.OutputQty
- if outputQty <= 0 {
- outputQty = 1
- }
-
- if !placeCraftOutput(p, item.ID, outDef, outputQty) {
- // Defensive: the fits check above should have guaranteed a
- // stack or slot.
- sess.WriteLine("Your inventory is too full!")
+ if !consumeIngredients(craft, p.CountItem, p.RemoveItem) {
g.cancelAction(p)
return
}
- // Return the empty containers (and any other byproducts) alongside
- // the output; without this, combine crafts silently discarded them.
- for _, bp := range byproducts {
- if slot := p.FirstFreeSlot(); slot >= 0 {
- p.SetInvSlot(slot, &player.InventorySlot{ItemID: bp, Quantity: 1})
- } else {
- // Never silently delete a byproduct: drop the remainder on
- // the ground where the player can pick it up.
- g.World.AddGroundItem(p.RoomID, bp, 1)
+ skill := combineSkill(craft)
+ if craftRollSuccess(craft, p.Level(skill)) {
+ outputQty := craft.OutputQty
+ if outputQty <= 0 {
+ outputQty = 1
+ }
+
+ if !placeCraftOutput(p, item.ID, outDef, outputQty) {
+ // Defensive: the fits check above should have guaranteed a
+ // stack or slot.
+ sess.WriteLine("Your inventory is too full!")
+ g.cancelAction(p)
+ return
+ }
+
+ // Return the empty containers (and any other byproducts) alongside
+ // the output; without this, combine crafts silently discarded them.
+ g.placeCraftByproducts(p, byproducts)
+
+ if craft.XP > 0 {
+ g.awardSkillXP(sess, p, skill, craft.XP)
+ }
+ } else {
+ g.placeCraftFail(p, craft)
+ msg := g.resolveCraftMessage(sess, craft.FailMessage, "", craft, item.ID, nil, hasItem)
+ if msg != "" {
+ sess.WriteLine(g.colorize(sess, "damage_taken", msg))
}
}
diff --git a/internal/game/act_farm.go b/internal/game/act_farm.go
index 1e576c3..48fbec7 100644
--- a/internal/game/act_farm.go
+++ b/internal/game/act_farm.go
@@ -242,43 +242,68 @@ func (g *Game) advanceHarvest(sess *net.Session, p *player.Player) {
bonus := p.Level(player.Farming) / 20
yield += bonus
- freeSlots := p.FreeSlots()
- if yield > freeSlots {
- productDef, _ := g.ItemStore.Load(product)
- if productDef == nil || !productDef.Stackable {
+ productDef, _ := g.ItemStore.Load(product)
+ stackable := productDef != nil && productDef.Stackable
+
+ // The delivery target is chosen before the crop is consumed: a harvest
+ // that cannot be stored must leave the patch ready instead of being
+ // silently destroyed. A stackable product merges into an existing stack
+ // and therefore needs no free slot.
+ mergeSlot := -1
+ freeSlot := -1
+ noRoom := false
+ if stackable {
+ quality, maxQuality := stackProps(productDef)
+ for i := 0; i < 28; 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
+ // would silently destroy one of the two qualities.
+ if s != nil && s.ItemID == product && s.Quality == quality && s.MaxQuality == maxQuality {
+ mergeSlot = i
+ break
+ }
+ }
+ if mergeSlot < 0 {
+ freeSlot = p.FirstFreeSlot()
+ }
+ noRoom = mergeSlot < 0 && freeSlot < 0
+ } else {
+ // 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++ {
+ if p.InvSlot(i) == nil {
+ freeSlots++
+ }
+ }
+ if yield > freeSlots {
yield = freeSlots
}
+ noRoom = yield <= 0
}
- if yield <= 0 {
+ if noRoom {
sess.WriteLine("Your inventory is too full!")
p.Action = nil
return
}
- productDef, _ := g.ItemStore.Load(product)
- if productDef != nil && productDef.Stackable {
- added := 0
- for i := 0; i < 28 && added < yield; i++ {
- slot := p.InvSlot(i)
- if slot != nil && slot.ItemID == product {
- slot.Quantity += yield
- added = yield
- break
- }
- }
- if added == 0 {
- freeSlot := p.FirstFreeSlot()
- if freeSlot >= 0 {
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: product, Quantity: yield})
- }
+ placed := yield
+ if stackable {
+ if mergeSlot >= 0 {
+ p.InvSlot(mergeSlot).Quantity += yield
+ } else {
+ p.SetInvSlot(freeSlot, g.newInventorySlot(product, yield))
}
} else {
- for i := 0; i < yield; i++ {
- freeSlot := p.FirstFreeSlot()
- if freeSlot < 0 {
+ placed = 0
+ for placed < yield {
+ fs := p.FirstFreeSlot()
+ if fs < 0 {
break
}
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: product, Quantity: 1})
+ p.SetInvSlot(fs, g.newInventorySlot(product, 1))
+ placed++
}
}
@@ -302,7 +327,7 @@ func (g *Game) advanceHarvest(sess *net.Session, p *player.Player) {
productName = g.itemColorize(sess, productDef, productDef.Name)
}
patchName := patchDisplayName(prefix)
- msg := fmt.Sprintf("You harvest %d %s from the %s.", yield, productName, patchName)
+ msg := fmt.Sprintf("You harvest %d %s from the %s.", placed, productName, patchName)
if int(xp) > 0 && p.OptionBool("xp_drops") {
msg += g.formatXpDropSingle(sess, p, player.Farming, int(xp))
}
diff --git a/internal/game/act_fletch.go b/internal/game/act_fletch.go
index 9796bda..d4adff0 100644
--- a/internal/game/act_fletch.go
+++ b/internal/game/act_fletch.go
@@ -32,6 +32,7 @@ func (g *Game) doInstantFletch(sess *net.Session, p *player.Player, def *item.It
batches := 0
totalOutput := 0
+ failures := 0
skill := player.SkillName(c.EffectiveSkill())
tmpItem := &item.ItemDef{Craft: item.CraftList{*c}}
inventoryFull := false
@@ -39,24 +40,33 @@ func (g *Game) doInstantFletch(sess *net.Session, p *player.Player, def *item.It
if !g.canDoFletchItem(p, tmpItem) {
break
}
- // A full inventory must never destroy materials: check placement
- // BEFORE consuming the batch.
- if !craftOutputFits(p, def.ID, def) {
+ // A full inventory must never destroy materials: check placement of
+ // BOTH the output and any fail replacement BEFORE consuming.
+ if !craftOutputFits(p, def.ID, def) || !g.craftFailFits(p, c) {
inventoryFull = true
break
}
+ byproducts := g.collectByproducts(p, def)
+
if !consumeIngredients(c, p.CountItem, p.RemoveItem) {
break
}
- placeCraftOutput(p, def.ID, def, outputQty)
- if c.XP > 0 {
- g.awardSkillXP(sess, p, skill, c.XP)
- }
+ if craftRollSuccess(c, p.Level(skill)) {
+ placeCraftOutput(p, def.ID, def, outputQty)
+ g.placeCraftByproducts(p, byproducts)
- batches++
- totalOutput += outputQty
+ if c.XP > 0 {
+ g.awardSkillXP(sess, p, skill, c.XP)
+ }
+
+ batches++
+ totalOutput += outputQty
+ } else {
+ g.placeCraftFail(p, c)
+ failures++
+ }
if count > 0 {
count--
@@ -69,6 +79,8 @@ func (g *Game) doInstantFletch(sess *net.Session, p *player.Player, def *item.It
if batches == 0 {
if inventoryFull {
sess.WriteLine("Your inventory is too full!")
+ } else if failures > 0 {
+ sess.WriteLine("You fail to fletch anything.")
} else {
sess.WriteLine("You don't have the materials for that.")
}
@@ -153,38 +165,52 @@ func (g *Game) advanceFletch(sess *net.Session, p *player.Player) {
outputQty = 1
}
- // A full inventory must never destroy materials: verify the output can
- // be placed BEFORE consuming the batch.
- if !craftOutputFits(p, itemID, def) {
+ // A full inventory must never destroy materials: verify BOTH the output
+ // and any fail replacement can be placed BEFORE consuming the batch.
+ if !craftOutputFits(p, itemID, def) || !g.craftFailFits(p, c) {
sess.WriteLine("Your inventory is too full!")
g.cancelBgAction(p)
return
}
skill := player.SkillName(c.EffectiveSkill())
+ byproducts := g.collectByproducts(p, def)
+ hasItem := craftConsumedHasItem(c, p.CountItem, p.HasItem)
if !consumeIngredients(c, p.CountItem, p.RemoveItem) {
g.cancelBgAction(p)
return
}
- if !placeCraftOutput(p, itemID, def, outputQty) {
- // Defensive: the fits check above should have guaranteed a stack
- // or slot.
- sess.WriteLine("Your inventory is too full!")
- g.cancelBgAction(p)
- return
- }
- if c.XP > 0 {
- g.awardSkillXP(sess, p, skill, c.XP)
- }
- g.saveCharacter(p)
+ if craftRollSuccess(c, p.Level(skill)) {
+ if !placeCraftOutput(p, itemID, def, outputQty) {
+ // Defensive: the fits check above should have guaranteed a
+ // stack or slot.
+ sess.WriteLine("Your inventory is too full!")
+ g.cancelBgAction(p)
+ return
+ }
+ g.placeCraftByproducts(p, byproducts)
- msg := g.resolveCraftMessage(sess, c.SuccessMessage, "You fletch a %n.", c, def.ID, nil, p.HasItem)
- if p.OptionBool("xp_drops") && c.XP > 0 {
- msg += g.formatXpDropSingle(sess, p, skill, c.XP)
+ if c.XP > 0 {
+ g.awardSkillXP(sess, p, skill, c.XP)
+ }
+ g.saveCharacter(p)
+
+ msg := g.resolveCraftMessage(sess, c.SuccessMessage, "You fletch a %n.", c, def.ID, byproducts, hasItem)
+ if p.OptionBool("xp_drops") && c.XP > 0 {
+ msg += g.formatXpDropSingle(sess, p, skill, c.XP)
+ }
+ sess.WriteLine(msg)
+ } else {
+ g.placeCraftFail(p, c)
+ g.saveCharacter(p)
+
+ msg := g.resolveCraftMessage(sess, c.FailMessage, "", c, def.ID, nil, hasItem)
+ if msg != "" {
+ sess.WriteLine(g.colorize(sess, "damage_taken", msg))
+ }
}
- sess.WriteLine(msg)
if remaining > 0 {
remaining--
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 133eb34..bc702e1 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -124,13 +124,15 @@ func (w casinoWallet) payCreditsFlagged(amount int, refund bool, stakeIDs []stri
}
w.p.Credits = creditsAfter
// The character file is the only durable record, so it must also
- // carry the resolution evidence for this player's open stakes: a
- // crash before the games' own ResolveStakes call would otherwise
- // leave the wager records open with checkpoint balances that no
- // longer match, and recovery would refund chip stakes the paid
- // settlement had already consumed. The single save below is the
- // commit point for the payout AND the resolution.
- resolved := w.recordStakeEvidence()
+ // carry the resolution evidence for the stakes this settlement
+ // resolves: a crash before the games' own ResolveStakes call would
+ // otherwise leave the wager records open with checkpoint balances
+ // that no longer match, and recovery would refund chip stakes the
+ // paid settlement had already consumed. Only the settled stakes are
+ // recorded (see recordStakeEvidence), so a sibling hand's stake stays
+ // refundable. The single save below is the commit point for the
+ // payout AND the resolution.
+ resolved := w.recordStakeEvidence(stakeIDs)
if serr := w.g.AccountStore.SaveCharacter(w.p); serr != nil {
w.p.Credits = creditsBefore
if resolved > 0 {
@@ -218,15 +220,25 @@ func (w casinoWallet) ResolveStake(stakeID string) {
w.recordResolutionEvidence(unresolved)
}
-// recordStakeEvidence adds every currently open stake of the player to the
+// recordStakeEvidence adds the stakes the current settlement resolves to the
// file-side resolution evidence list and returns how many IDs were newly
// recorded. Used when the economy journal is unavailable: the open wager
// record IDs in the character file prove the round resolved even though no
// settled marker (or payout record) could ever be journaled. The caller
// must persist the character file for the evidence to count.
-func (w casinoWallet) recordStakeEvidence() int {
+//
+// stakeIDs are the stakes the payout settles (a table hand lists its own
+// hand's stakes). Only those are recorded: a sibling hand's stake is settled
+// by its own payout, so marking it resolved here would suppress the refund a
+// crash before that payout must still produce. An empty list (a slots payout
+// links no stake) settles the round that consumed the stake, so every open
+// stake of the player is evidence.
+func (w casinoWallet) recordStakeEvidence(stakeIDs []string) int {
added := 0
for _, rec := range w.g.Economy.openStakes(economyKey(w.p)) {
+ if len(stakeIDs) > 0 && !containsString(stakeIDs, rec.ID) {
+ continue
+ }
if !resolvedStakeRecorded(w.p, rec.ID) {
w.p.ResolvedStakes = append(w.p.ResolvedStakes, rec.ID)
added++
@@ -315,11 +327,15 @@ func (g *Game) leaveCasinoGames(p *player.Player) bool {
if table != nil {
g.emitCasinoEvents(g.Casino.Leave(table.RoomID, table.Config.ID, p.Name))
} else if machine, ok := g.Casino.MachineFor(p.Name); ok {
+ // Stop autobet first, then take the machine's own leave path: a
+ // machine between spins has no committed stake, so one quit must
+ // free it (and must not warn about a forfeit that cannot happen).
+ // A machine mid-spin refuses the leave, the player stays seated,
+ // and the caller warns about the stake that is really committed.
if events, stopped := g.Casino.StopMachineAutoBet(machine.RoomID, machine.Config.ID, p.Name); stopped {
g.emitCasinoEvents(events)
- } else {
- g.emitCasinoEvents(g.Casino.LeaveMachine(machine.RoomID, machine.Config.ID, p.Name))
}
+ g.emitCasinoEvents(g.Casino.LeaveMachine(machine.RoomID, machine.Config.ID, p.Name))
}
_, stillPlaying := g.Casino.Participation(p.Name)
return !stillPlaying
@@ -337,6 +353,11 @@ func (g *Game) inCasinoGame(name string) bool {
return playing
}
+// casinoTable returns the room's table for the named game. A room addresses
+// at most one table (or machine) per game name — "play blackjack" carries no
+// table id — so a second table for the same game would be unreachable
+// content; ValidateRoomConfigs rejects that configuration instead of silently
+// dropping it.
func (g *Game) casinoTable(roomID int, gameName string) (*casino.Table, bool) {
room, err := g.World.LoadRoom(roomID)
if err != nil {
@@ -357,6 +378,10 @@ func (g *Game) casinoTable(roomID int, gameName string) (*casino.Table, bool) {
return nil, false
}
+// casinoMachine returns the room's machine for the named game or variant.
+// Like casinoTable, only the first match is reachable, which is why
+// ValidateRoomConfigs rejects a room with two entries addressing the same
+// game name or variant.
func (g *Game) casinoMachine(roomID int, gameName string) (*casino.Machine, bool) {
room, err := g.World.LoadRoom(roomID)
if err != nil {
diff --git a/internal/game/cmd_bank.go b/internal/game/cmd_bank.go
index 6326700..468b173 100644
--- a/internal/game/cmd_bank.go
+++ b/internal/game/cmd_bank.go
@@ -2,6 +2,7 @@ package game
import (
"fmt"
+ "log"
"sort"
"strconv"
"strings"
@@ -368,9 +369,13 @@ func (g *Game) doBankDepositCredits(sess *net.Session, parts []string) {
sess.WriteLine("You don't have that many credits.")
return
}
+ creditsBefore, bankedBefore := p.Credits, p.BankedCredits
p.Credits -= amount
p.BankedCredits += amount
- g.saveCharacter(p)
+ if !g.persistBankCredits(p, creditsBefore, bankedBefore) {
+ sess.WriteLine("The bank terminal could not record the transfer; nothing was moved.")
+ return
+ }
sess.WriteLine(g.colorize(sess, "currency_pickup", fmt.Sprintf("You deposit %s credits. (Banked: %s)", formatInt(amount), formatInt(p.BankedCredits))))
g.showBankBrowse(sess)
}
@@ -396,13 +401,33 @@ func (g *Game) doBankWithdrawCredits(sess *net.Session, parts []string) {
sess.WriteLine("Your wallet cannot hold that many credits.")
return
}
+ creditsBefore, bankedBefore := p.Credits, p.BankedCredits
p.BankedCredits -= amount
p.Credits += amount
- g.saveCharacter(p)
+ if !g.persistBankCredits(p, creditsBefore, bankedBefore) {
+ sess.WriteLine("The bank terminal could not record the transfer; nothing was moved.")
+ return
+ }
sess.WriteLine(g.colorize(sess, "currency_pickup", fmt.Sprintf("You withdraw %s credits.", formatInt(amount))))
g.showBankBrowse(sess)
}
+// persistBankCredits makes a credit move between the on-hand wallet and the
+// bank durable, reverting it in memory when the save fails. A best-effort
+// save here would leave the in-memory balance ahead of the durable file, and
+// a wager taken from that phantom balance journals checkpoints the file can
+// never match — recovery then leaves the stake permanently ambiguous instead
+// of refunding or settling it. Reports whether the move may be announced.
+func (g *Game) persistBankCredits(p *player.Player, creditsBefore, bankedBefore int) bool {
+ if err := g.AccountStore.SaveCharacter(p); err != nil {
+ log.Printf("bank credit transfer %s: %v", p.Name, err)
+ p.Credits = creditsBefore
+ p.BankedCredits = bankedBefore
+ return false
+ }
+ return true
+}
+
func (g *Game) canUseBank(p *player.Player) bool {
instances := g.World.FindObjInstances(p.RoomID, "bank booth")
return len(instances) > 0
diff --git a/internal/game/cmd_color.go b/internal/game/cmd_color.go
index 614583f..104d359 100644
--- a/internal/game/cmd_color.go
+++ b/internal/game/cmd_color.go
@@ -67,6 +67,12 @@ func (g *Game) doColor(sess *net.Session, input string) {
}
func (g *Game) saveAccountColor(sess *net.Session, target, value string) {
+ // The account can be gone (purged or republished from another session)
+ // while the player is still live; there is nothing to persist to then.
+ if sess.Account == nil {
+ sess.WriteLine("You must be logged into an account to set colors.")
+ return
+ }
if value == "" {
if sess.Account.Colors != nil {
delete(sess.Account.Colors, target)
diff --git a/internal/game/cmd_craft.go b/internal/game/cmd_craft.go
index fe52511..a1e0c38 100644
--- a/internal/game/cmd_craft.go
+++ b/internal/game/cmd_craft.go
@@ -40,7 +40,7 @@ func (g *Game) doStationSkill(sess *net.Session, input string, recipeType string
}
if input != "" {
- g.doWithInput(sess, p, filtered, input, skill, flagKey, label, verbCap)
+ g.doWithInput(sess, p, filtered, input, recipeType, skill, flagKey, label, verbCap)
return
}
@@ -76,7 +76,7 @@ func (g *Game) doStationSkill(sess *net.Session, input string, recipeType string
g.showProductionTable(sess, p, available, label, recipeType, flagKey, verbCap, false)
}
-func (g *Game) doWithInput(sess *net.Session, p *player.Player, items []*item.ItemDef, input string, skill player.SkillName, flagKey, label, verbCap string) {
+func (g *Game) doWithInput(sess *net.Session, p *player.Player, items []*item.ItemDef, input, recipeType string, skill player.SkillName, flagKey, label, verbCap string) {
qty, productName := parseQty(input)
productName = strings.TrimSpace(productName)
@@ -109,7 +109,11 @@ func (g *Game) doWithInput(sess *net.Session, p *player.Player, items []*item.It
return
}
- g.showProductionTable(sess, p, available, label, label, flagKey, verbCap, false)
+ // The menu stores its skill as PendingSkill and handleProductChoice
+ // looks it up with player.SkillName: pass the recipe type ("crafting",
+ // "pharmacy", "construction"), exactly like the no-input path, never the
+ // capitalised display label (a map miss that resolves to level 1).
+ g.showProductionTable(sess, p, available, label, recipeType, flagKey, verbCap, false)
}
func (g *Game) itemVisible(p *player.Player, def *item.ItemDef) bool {
diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go
index bc48394..a2ad137 100644
--- a/internal/game/cmd_get.go
+++ b/internal/game/cmd_get.go
@@ -4,6 +4,7 @@ import (
"fmt"
"strings"
+ "thehouseoficarus/internal/casino"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
)
@@ -74,19 +75,21 @@ func (g *Game) doGet(sess *net.Session, input string) {
// doGetAllNamed). See TestPickupMergesIntoFullInventory.
if def != nil && def.Stackable {
+ quality, maxQuality := g.pickedStackProps(itemID)
for i := 0; i < 28; i++ {
slot := p.InvSlot(i)
- if slot != nil && slot.ItemID == itemID {
- removed, ok := g.World.RemoveReservedItem(p.RoomID, itemID, qty, p.Name)
- if !ok {
- sess.WriteLine("That's not yours!")
- return
- }
- slot.Quantity += removed
- g.saveCharacter(p)
- sess.WriteLine(fmt.Sprintf("You pick up %d x %s. (now %d)", qty, g.itemColorize(sess, def, def.Name), slot.Quantity))
+ if slot == nil || slot.ItemID != itemID || slot.Quality != quality || slot.MaxQuality != maxQuality {
+ continue
+ }
+ removed, ok := g.World.RemoveReservedItem(p.RoomID, itemID, qty, p.Name)
+ if !ok {
+ sess.WriteLine("That's not yours!")
return
}
+ slot.Quantity += removed
+ g.saveCharacter(p)
+ sess.WriteLine(fmt.Sprintf("You pick up %d x %s. (now %d)", qty, g.itemColorize(sess, def, def.Name), slot.Quantity))
+ return
}
freeSlot := p.FirstFreeSlot()
if freeSlot == -1 {
@@ -116,7 +119,7 @@ func (g *Game) doGet(sess *net.Session, input string) {
if _, ok := g.World.RemoveReservedItem(p.RoomID, itemID, 1, p.Name); !ok {
break
}
- p.SetInvSlot(fs, &player.InventorySlot{ItemID: itemID, Quantity: 1})
+ p.SetInvSlot(fs, g.newInventorySlot(itemID, 1))
picked++
}
@@ -192,19 +195,21 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) {
coloredName := g.itemColorize(sess, def, name)
if def != nil && def.Stackable {
+ quality, maxQuality := g.pickedStackProps(itemID)
for i := 0; i < 28; i++ {
slot := p.InvSlot(i)
- if slot != nil && slot.ItemID == itemID {
- removed, ok := g.World.RemoveReservedItem(p.RoomID, itemID, wanted, p.Name)
- if !ok {
- sess.WriteLine("That's not yours!")
- return
- }
- slot.Quantity += removed
- g.saveCharacter(p)
- sess.WriteLine(fmt.Sprintf("You pick up %d x %s. (now %d)", removed, coloredName, slot.Quantity))
+ if slot == nil || slot.ItemID != itemID || slot.Quality != quality || slot.MaxQuality != maxQuality {
+ continue
+ }
+ removed, ok := g.World.RemoveReservedItem(p.RoomID, itemID, wanted, p.Name)
+ if !ok {
+ sess.WriteLine("That's not yours!")
return
}
+ slot.Quantity += removed
+ g.saveCharacter(p)
+ sess.WriteLine(fmt.Sprintf("You pick up %d x %s. (now %d)", removed, coloredName, slot.Quantity))
+ return
}
freeSlot := p.FirstFreeSlot()
if freeSlot == -1 {
@@ -216,7 +221,7 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) {
sess.WriteLine("That's not yours!")
return
}
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: removed})
+ p.SetInvSlot(freeSlot, g.newInventorySlot(itemID, removed))
g.saveCharacter(p)
if removed == 1 {
sess.WriteLine(fmt.Sprintf("You pick up a %s.", coloredName))
@@ -236,7 +241,7 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) {
if !ok {
break
}
- p.SetInvSlot(fs, &player.InventorySlot{ItemID: itemID, Quantity: 1})
+ p.SetInvSlot(fs, g.newInventorySlot(itemID, 1))
picked++
}
@@ -265,15 +270,24 @@ func (g *Game) doGetAll(sess *net.Session) {
}
var picked []string
+ creditsCapped := false
for itemID, qty := range ground {
if qty <= 0 {
continue
}
if itemID == "credits" {
- // Same capped-removal rule as pickupCredits: the request is a
- // sentinel above any possible stock, not an exact quantity.
- taken := g.World.RemoveAvailableItem(p.RoomID, itemID, 999999, p.Name)
+ // Same capped-removal rule as pickupCredits: only the balance
+ // headroom is taken, so credits at the cap stay on the ground
+ // instead of being destroyed by the clamped grant.
+ room := creditHeadroom(p)
+ taken := 0
+ if room > 0 {
+ taken = g.World.RemoveAvailableItem(p.RoomID, itemID, room, p.Name)
+ }
if taken <= 0 {
+ if room <= 0 {
+ creditsCapped = true
+ }
continue
}
g.grantCredits(p, taken)
@@ -289,21 +303,23 @@ func (g *Game) doGetAll(sess *net.Session) {
for qty > 0 {
if def != nil && def.Stackable {
stacked := false
+ quality, maxQuality := g.pickedStackProps(itemID)
for i := 0; i < 28; i++ {
slot := p.InvSlot(i)
- if slot != nil && slot.ItemID == itemID {
- _, ok := g.World.RemoveReservedItem(p.RoomID, itemID, qty, p.Name)
- if !ok {
- qty = 0
- stacked = true
- break
- }
- slot.Quantity += qty
- picked = append(picked, fmt.Sprintf("%s (now %d)", g.itemColorize(sess, def, def.Name), slot.Quantity))
- stacked = true
+ if slot == nil || slot.ItemID != itemID || slot.Quality != quality || slot.MaxQuality != maxQuality {
+ continue
+ }
+ _, ok := g.World.RemoveReservedItem(p.RoomID, itemID, qty, p.Name)
+ if !ok {
qty = 0
+ stacked = true
break
}
+ slot.Quantity += qty
+ picked = append(picked, fmt.Sprintf("%s (now %d)", g.itemColorize(sess, def, def.Name), slot.Quantity))
+ stacked = true
+ qty = 0
+ break
}
if stacked {
break
@@ -349,7 +365,7 @@ func (g *Game) doGetAll(sess *net.Session) {
if !ok {
break
}
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: take})
+ p.SetInvSlot(freeSlot, g.newInventorySlot(itemID, take))
name := itemID
if def != nil {
name = def.Name
@@ -361,20 +377,28 @@ func (g *Game) doGetAll(sess *net.Session) {
g.saveCharacter(p)
- if len(picked) == 0 {
- sess.WriteLine("Your inventory is full.")
- } else {
+ if creditsCapped {
+ sess.WriteLine(g.colorize(sess, "currency_pickup", "Your purse can't hold any more credits."))
+ }
+ switch {
+ case len(picked) > 0:
sess.Write("You picked up: ")
formatPickupList(sess, picked)
+ case !creditsCapped:
+ sess.WriteLine("Your inventory is full.")
}
}
func (g *Game) pickupCredits(sess *net.Session, p *player.Player, itemID string) {
- // Take whatever credits are accessible: a sentinel quantity far above
- // any possible stock. RemoveReservedItem is all-or-nothing and would
- // reject a request larger than the stack; RemoveAvailableItem caps the
- // removal at what is actually on the ground.
- qty := g.World.RemoveAvailableItem(p.RoomID, itemID, 999999, p.Name)
+ // The balance cap is a hard limit: credits the purse cannot absorb must
+ // stay on the ground instead of being removed and then clamped away by
+ // grantCredits. Only the headroom below the cap is taken.
+ room := creditHeadroom(p)
+ if room <= 0 {
+ sess.WriteLine(g.colorize(sess, "currency_pickup", "Your purse can't hold any more credits."))
+ return
+ }
+ qty := g.World.RemoveAvailableItem(p.RoomID, itemID, room, p.Name)
if qty <= 0 {
return
}
@@ -387,6 +411,28 @@ func (g *Game) pickupCredits(sess *net.Session, p *player.Player, itemID string)
}
}
+// creditHeadroom is how many credits p's balance can still absorb before the
+// economy cap (casino.MaxCredits) is reached.
+func creditHeadroom(p *player.Player) int {
+ room := casino.MaxCredits - p.Credits
+ if room < 0 {
+ return 0
+ }
+ return room
+}
+
+// pickedStackProps returns the Quality and MaxQuality a freshly picked-up
+// stack receives (newInventorySlot stamps full quality onto quality-bearing
+// items). A pickup only merges into a slot with matching properties, since
+// merging a differently qualified stack would silently destroy the picked-up
+// stack's quality - the same rule withdrawBankItem applies in cmd_bank.go.
+// It reads the rule through stackProps so every other stack-creation site
+// (craft, harvest, ammo removal) agrees on the same values.
+func (g *Game) pickedStackProps(itemID string) (quality, maxQuality int) {
+ def, _ := g.ItemStore.Load(itemID)
+ return stackProps(def)
+}
+
func (g *Game) collectMatches(input string, each func(func(itemID string, slot int) bool)) []itemMatch {
var matches []itemMatch
each(func(itemID string, slot int) bool {
diff --git a/internal/game/cmd_option.go b/internal/game/cmd_option.go
index 294ed5e..1bfd1b5 100644
--- a/internal/game/cmd_option.go
+++ b/internal/game/cmd_option.go
@@ -85,24 +85,28 @@ func (g *Game) doOption(sess *net.Session, input string) {
p.Options["card_size"] = 5
}
- if sess.Account.Options == nil {
- sess.Account.Options = make(map[string]any)
- }
- sess.Account.Options[def.Name] = parsed
- if def.Name == "unicode" && parsed == false {
- sess.Account.Options["card_size"] = 5
- }
-
- acc, err := g.AccountStore.LoadAccount(sess.Account.Name)
- if err == nil {
- if acc.Options == nil {
- acc.Options = make(map[string]any)
+ // The account can be gone (purged or republished from another session)
+ // while the player is still live: the option is applied to the player
+ // either way, but the account update needs a valid account.
+ if sess.Account != nil {
+ if sess.Account.Options == nil {
+ sess.Account.Options = make(map[string]any)
}
- acc.Options[def.Name] = parsed
+ sess.Account.Options[def.Name] = parsed
if def.Name == "unicode" && parsed == false {
- acc.Options["card_size"] = 5
+ sess.Account.Options["card_size"] = 5
+ }
+
+ if acc, err := g.AccountStore.LoadAccount(sess.Account.Name); err == nil {
+ if acc.Options == nil {
+ acc.Options = make(map[string]any)
+ }
+ acc.Options[def.Name] = parsed
+ if def.Name == "unicode" && parsed == false {
+ acc.Options["card_size"] = 5
+ }
+ g.saveAccount(acc)
}
- g.saveAccount(acc)
}
sess.WriteLine(fmt.Sprintf("%s set to %s.", def.Name, formatOptionValue(p, def)))
}
diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go
index f8075c1..bf46748 100644
--- a/internal/game/cmd_quit.go
+++ b/internal/game/cmd_quit.go
@@ -30,7 +30,14 @@ func (g *Game) doQuit(sess *net.Session) {
// exits never forfeit a stake or reveal cards mid-hand. Forfeit-on-leave
// stays reserved for genuine disconnects and "quit now".
if !g.leaveCasinoGames(p) {
- sess.WriteLine(g.colorize(sess, "warning", "Type \"quit now\" to log out anyway — your bet will be forfeited."))
+ // A committed wager blocks the departure; whether it is actually lost
+ // depends on whether the game has dealt/started a round for it. A
+ // stake the game has not dealt for yet is refunded on disconnect
+ // (baccarat always, blackjack while the betting window is open), and
+ // a dealt hand or a spin in progress is forfeited. The refusal
+ // therefore states both outcomes instead of promising a forfeit that
+ // an undealt stake does not suffer.
+ sess.WriteLine(g.colorize(sess, "warning", "Type \"quit now\" to log out anyway — a bet still awaiting the deal is refunded; a hand or spin in progress is forfeited."))
return
}
@@ -86,6 +93,13 @@ func (g *Game) beginQuitCountdown(sess *net.Session) {
g.charsMu.Lock()
delete(g.loggedInChars, p.Name)
g.charsMu.Unlock()
+ // The character is leaving the world: drop its queued free/
+ // active/consume commands with the registry entry, or they
+ // retain this session and replay on a later login as the same
+ // character.
+ if g.queue != nil {
+ g.queue.ClearPlayer(p.Name)
+ }
if g.Hub != nil {
g.Hub.LeaveRoom(sess)
}
diff --git a/internal/game/cmd_remove.go b/internal/game/cmd_remove.go
index 349617d..5bc02a7 100644
--- a/internal/game/cmd_remove.go
+++ b/internal/game/cmd_remove.go
@@ -97,9 +97,12 @@ func (g *Game) removeAmmo(sess *net.Session, p *player.Player, qty int) {
removeQty = qty
}
+ quality, maxQuality := g.pickedStackProps(ammoID)
for i := 0; i < 28; i++ {
s := p.InvSlot(i)
- if s != nil && s.ItemID == ammoID {
+ // Only a stack with the properties a fresh stack gets may absorb the
+ // equipped stack (see pickedStackProps).
+ if s != nil && s.ItemID == ammoID && s.Quality == quality && s.MaxQuality == maxQuality {
s.Quantity += removeQty
p.AmmoQty -= removeQty
if p.AmmoQty <= 0 {
@@ -123,7 +126,7 @@ func (g *Game) removeAmmo(sess *net.Session, p *player.Player, qty int) {
return
}
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: ammoID, Quantity: removeQty})
+ p.SetInvSlot(freeSlot, g.newInventorySlot(ammoID, removeQty))
p.AmmoQty -= removeQty
if p.AmmoQty <= 0 {
delete(p.Equipment, item.SlotAmmo)
@@ -193,10 +196,17 @@ func (g *Game) doRemoveAll(sess *net.Session) {
g.saveCharacter(p)
}
+// removeAllAmmo moves the whole equipped ammo stack into the inventory. The
+// equipment entry is only cleared once the stack has been merged into an
+// existing stack or placed in a free slot, so a full inventory reports a
+// 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++ {
s := p.InvSlot(i)
- if s != nil && s.ItemID == itemID {
+ // Only a stack with the properties a fresh stack gets may absorb the
+ // equipped stack (see pickedStackProps).
+ if s != nil && s.ItemID == itemID && s.Quality == quality && s.MaxQuality == maxQuality {
s.Quantity += p.AmmoQty
delete(p.Equipment, item.SlotAmmo)
p.AmmoQty = 0
@@ -210,9 +220,13 @@ func (g *Game) removeAllAmmo(sess *net.Session, p *player.Player, itemID string)
}
}
freeSlot := p.FirstFreeSlot()
- if freeSlot >= 0 {
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: p.AmmoQty})
+ if freeSlot < 0 {
+ // Nowhere to put the equipped stack: leave it equipped and report
+ // nothing removed.
+ sess.WriteLine("Nowhere to put that!")
+ return
}
+ p.SetInvSlot(freeSlot, g.newInventorySlot(itemID, p.AmmoQty))
delete(p.Equipment, item.SlotAmmo)
p.AmmoQty = 0
def, _ := g.ItemStore.Load(itemID)
diff --git a/internal/game/cmd_shop.go b/internal/game/cmd_shop.go
index 7299ad3..f3211e0 100644
--- a/internal/game/cmd_shop.go
+++ b/internal/game/cmd_shop.go
@@ -366,8 +366,13 @@ func (g *Game) doShopSell(sess *net.Session, mob *world.MobInstance, input strin
// shopInvCapacity reports how many of an item the player can still carry.
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++ {
- if slot := p.InvSlot(i); slot != nil && slot.ItemID == itemID {
+ // 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
+ // let the caller charge for items the delivery path cannot place.
+ if slot := p.InvSlot(i); slot != nil && slot.ItemID == itemID && slot.Quality == quality && slot.MaxQuality == maxQuality {
return 1 << 30
}
}
@@ -383,8 +388,13 @@ func (g *Game) shopInvCapacity(p *player.Player, itemID string, def *item.ItemDe
// possible. Callers must ensure capacity via shopInvCapacity first.
func (g *Game) giveShopItems(p *player.Player, itemID string, qty int, def *item.ItemDef) {
if def != nil && def.Stackable {
+ // Merge only into a stack with the properties this purchase would
+ // itself receive (stackProps/newInventorySlot), matching craft,
+ // harvest, ammo return and pickup: a differently qualified stack is
+ // not a merge target.
+ quality, maxQuality := stackProps(def)
for i := 0; i < 28; i++ {
- if slot := p.InvSlot(i); slot != nil && slot.ItemID == itemID {
+ 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 bbcc79c..ef862b9 100644
--- a/internal/game/cmd_swapid.go
+++ b/internal/game/cmd_swapid.go
@@ -76,26 +76,35 @@ func (g *Game) executeSwapID(sess *net.Session, args []string, rawInput string)
}
}
- // Swap the two files first. After this, path1 holds the old room id2's
- // content and path2 holds the old room id1's content. Rebuilding the index
- // then makes LoadRoom(id1)/LoadRoom(id2) return the swapped content, so the
- // reference rewrite below operates on the correctly-positioned rooms and
- // re-marshaling writes an `id:` field matching the new filename (keeping
- // the file's id field consistent with its name, as the old regex did).
- tempPath := path1 + ".swapid-tmp"
- if rerr := os.Rename(path1, tempPath); rerr != nil {
- sess.WriteLine(fmt.Sprintf("Error renaming room %d: %v", id1, rerr))
+ // Swap the two files' contents first. After this, path1 holds the old room
+ // id2's content and path2 holds the old room id1's content. Rebuilding the
+ // index then makes LoadRoom(id1)/LoadRoom(id2) return the swapped content,
+ // so the reference rewrite below operates on the correctly-positioned rooms
+ // and re-marshaling writes an `id:` field matching the new filename. The
+ // swap runs as one journal transaction (like handleRoomRename/the account
+ // lifecycle), so a crash mid-swap is rolled back at the next startup
+ // instead of leaving both rooms at wrong paths with no recovery record.
+ data1, rerr := os.ReadFile(path1)
+ if rerr != nil {
+ sess.WriteLine(fmt.Sprintf("Error reading room %d: %v", id1, rerr))
return
}
- if rerr := os.Rename(path2, path1); rerr != nil {
- os.Rename(tempPath, path1)
- sess.WriteLine(fmt.Sprintf("Error renaming room %d: %v", id2, rerr))
+ data2, rerr := os.ReadFile(path2)
+ if rerr != nil {
+ sess.WriteLine(fmt.Sprintf("Error reading room %d: %v", id2, rerr))
return
}
- if rerr := os.Rename(tempPath, path2); rerr != nil {
- os.Rename(path2, path1)
- os.Rename(tempPath, path1)
- sess.WriteLine(fmt.Sprintf("Error finishing rename: %v", rerr))
+ txn := g.newTxn()
+ if rerr := txn.Replace(path1, data2, 0644); rerr != nil {
+ sess.WriteLine(fmt.Sprintf("Error swapping rooms: %v", rerr))
+ return
+ }
+ if rerr := txn.Replace(path2, data1, 0644); rerr != nil {
+ sess.WriteLine(fmt.Sprintf("Error swapping rooms: %v", rerr))
+ return
+ }
+ if rerr := txn.Commit(); rerr != nil {
+ sess.WriteLine(fmt.Sprintf("Error swapping rooms: %v", rerr))
return
}
diff --git a/internal/game/cmd_wear.go b/internal/game/cmd_wear.go
index a02fd3b..5a293de 100644
--- a/internal/game/cmd_wear.go
+++ b/internal/game/cmd_wear.go
@@ -101,7 +101,7 @@ func (g *Game) doWear(sess *net.Session, input string) {
sess.WriteLine("Your inventory is full.")
return
}
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: existingItemID, Quantity: 1})
+ p.SetInvSlot(freeSlot, g.displacedEquipSlot(p, existingItemID))
}
if slot.Quantity > 1 {
@@ -127,6 +127,24 @@ func (g *Game) doWear(sess *net.Session, input string) {
sess.WriteLine(fmt.Sprintf("You %s %s.", verb, g.itemColorize(sess, def, match.Name)))
}
+// displacedEquipSlot builds the inventory slot that receives an item
+// displaced by a swap. The item's equipped quality (EquipQuality, kept
+// because the equipment entry alone cannot carry a charge) is stamped onto
+// the returned stack and cleared from the map, so the returned item keeps
+// merging and charging like one carried normally; leaving the entry behind
+// would let act_burn charge a stale record or a zeroed slot.
+func (g *Game) displacedEquipSlot(p *player.Player, itemID string) *player.InventorySlot {
+ slot := &player.InventorySlot{ItemID: itemID, Quantity: 1}
+ if q, ok := p.EquipQuality[itemID]; ok {
+ slot.Quality = q
+ if def, _ := g.ItemStore.Load(itemID); def != nil {
+ slot.MaxQuality = def.MaxQuality
+ }
+ delete(p.EquipQuality, itemID)
+ }
+ return slot
+}
+
func (g *Game) equipAmmo(sess *net.Session, p *player.Player, invIdx int, slot *player.InventorySlot, def *item.ItemDef, qty int) {
transferQty := slot.Quantity
if qty > 0 && qty < transferQty {
@@ -135,7 +153,12 @@ func (g *Game) equipAmmo(sess *net.Session, p *player.Player, invIdx int, slot *
existingAmmoID, hasAmmo := p.Equipment[item.SlotAmmo]
if hasAmmo && existingAmmoID != slot.ItemID {
- g.unequipAmmo(p)
+ // The equipped stack stays equipped when there is nowhere to put
+ // it; equipping the new stack would otherwise strand the old one.
+ if !g.unequipAmmo(p) {
+ sess.WriteLine("Your inventory is full.")
+ return
+ }
}
if slot.Quantity <= transferQty {
@@ -156,26 +179,35 @@ func (g *Game) equipAmmo(sess *net.Session, p *player.Player, invIdx int, slot *
sess.WriteLine(fmt.Sprintf("You equip %s (x%d).", displayName, p.AmmoQty))
}
-func (g *Game) unequipAmmo(p *player.Player) {
+// unequipAmmo returns the equipped ammo to the inventory and reports whether
+// the equipped stack is now stored. With nowhere to put it the ammo stays
+// equipped: the equipment entry is only cleared once the stack has actually
+// been merged into an inventory stack or placed in a free slot.
+func (g *Game) unequipAmmo(p *player.Player) bool {
ammoID, ok := p.Equipment[item.SlotAmmo]
if !ok || p.AmmoQty <= 0 {
- return
+ return true
}
+ quality, maxQuality := g.pickedStackProps(ammoID)
for i := 0; i < 28; i++ {
s := p.InvSlot(i)
- if s != nil && s.ItemID == ammoID {
+ // Only a stack with the properties a fresh stack gets may absorb the
+ // equipped stack (see pickedStackProps).
+ if s != nil && s.ItemID == ammoID && s.Quality == quality && s.MaxQuality == maxQuality {
s.Quantity += p.AmmoQty
delete(p.Equipment, item.SlotAmmo)
p.AmmoQty = 0
- return
+ return true
}
}
freeSlot := p.FirstFreeSlot()
- if freeSlot >= 0 {
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: ammoID, Quantity: p.AmmoQty})
+ if freeSlot < 0 {
+ return false
}
+ p.SetInvSlot(freeSlot, g.newInventorySlot(ammoID, p.AmmoQty))
delete(p.Equipment, item.SlotAmmo)
p.AmmoQty = 0
+ return true
}
func (g *Game) doWearAll(sess *net.Session) {
@@ -235,8 +267,10 @@ func (g *Game) doWearAll(sess *net.Session) {
continue
}
existingID, hasAmmo := p.Equipment[item.SlotAmmo]
- if hasAmmo && existingID != best.itemID {
- g.unequipAmmo(p)
+ // No free slot for the equipped stack: leave it equipped and
+ // skip this item rather than delete the old stack.
+ if hasAmmo && existingID != best.itemID && !g.unequipAmmo(p) {
+ continue
}
transferQty := invSlot.Quantity
p.SetInvSlot(best.invSlot, nil)
@@ -256,7 +290,7 @@ func (g *Game) doWearAll(sess *net.Session) {
if freeSlot == -1 {
continue
}
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: existing, Quantity: 1})
+ p.SetInvSlot(freeSlot, g.displacedEquipSlot(p, existing))
}
invSlot := p.InvSlot(best.invSlot)
diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go
index c53d847..ace02b5 100644
--- a/internal/game/combat_mob.go
+++ b/internal/game/combat_mob.go
@@ -213,16 +213,16 @@ func (g *Game) applyMobMiss(sess *net.Session, p *player.Player, mob *world.MobI
}
}
-func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) {
- g.Combat.Leave(p.Name)
-
- if p.HP <= 0 {
- g.killPlayer(sess, p, mob)
- return
- }
-
+// resolveMobDeath runs the shared mob-death path for a mob whose HP has
+// reached zero: kill bookkeeping, the victory broadcast, drops, on-kill
+// triggers, and respawn scheduling. It returns false when the mob is nil or
+// still alive. Every path that can reduce a mob to zero HP — a player
+// attack, a finishing blow, and Dead Man's Switch retribution — must route
+// through here, or the zeroed instance lingers in MobStore, is filtered out
+// of MobsInRoom (HP > 0), and never respawns.
+func (g *Game) resolveMobDeath(sess *net.Session, p *player.Player, mob *world.MobInstance) bool {
if mob == nil || mob.HP > 0 {
- return
+ return false
}
isTask := mob.IsTask()
@@ -236,6 +236,20 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
// the player currently has that item equipped in a weapon-hand slot.
g.runTrigger(sess, p, mob.OnKill, p.RoomID, p.IsWielding, false)
g.scheduleMobRespawn(mob)
+ return true
+}
+
+func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) {
+ g.Combat.Leave(p.Name)
+
+ if p.HP <= 0 {
+ g.killPlayer(sess, p, mob)
+ return
+ }
+
+ if !g.resolveMobDeath(sess, p, mob) {
+ return
+ }
if p.EscapeDir != "" {
g.beginEscapeMove(p)
@@ -360,6 +374,12 @@ func (g *Game) killPlayer(sess *net.Session, p *player.Player, mob *world.MobIns
}
}
}
+ // Retribution can zero the mob: run the same death path the normal
+ // attack uses so it still drops loot and respawns instead of lingering
+ // at HP 0 and being permanently removed from the room.
+ if mob != nil && mob.HP <= 0 {
+ g.resolveMobDeath(sess, p, mob)
+ }
p.DeactivateAllTechs()
p.Stats.RecordDeath()
sess.WriteLine(g.colorize(sess, "death", "Oh dear, you are dead!"))
diff --git a/internal/game/core_admin.go b/internal/game/core_admin.go
index e1236ab..848130f 100644
--- a/internal/game/core_admin.go
+++ b/internal/game/core_admin.go
@@ -2,6 +2,24 @@ package game
import "thehouseoficarus/internal/net"
+// checkAdmin reports whether the session belongs to an account with the
+// on-disk admin flag set. The cached sess.Account copy is only refreshed on
+// login or account republish, so it is NOT trusted as the source of truth:
+// removing admin: true from an account file (or the admin API's reload) must
+// demote an already-connected admin without requiring a relog. The account
+// file is re-read on the owner goroutine, which serializes with logins and
+// account saves, so the check cannot race a concurrent hash/admin change.
+// Privilege checks fail closed when the account cannot be read.
func (g *Game) checkAdmin(sess *net.Session) bool {
- return sess.Account != nil && sess.Account.Admin
+ if sess.Account == nil {
+ return false
+ }
+ acc, err := g.AccountStore.LoadAccount(sess.Account.Name)
+ if err != nil || !acc.Admin {
+ // Drop the stale cached privilege too, so command gating and any
+ // code reading sess.Account.Admin agree with disk.
+ sess.Account.Admin = false
+ return false
+ }
+ return true
}
diff --git a/internal/game/core_command_queue.go b/internal/game/core_command_queue.go
index 7afc75b..28b7a36 100644
--- a/internal/game/core_command_queue.go
+++ b/internal/game/core_command_queue.go
@@ -1,6 +1,7 @@
package game
import (
+ "sort"
"sync"
"time"
@@ -18,8 +19,10 @@ type QueuedCommand struct {
// CommandQueue manages the three per-player command queues (free, active,
// consume) with proper mutex protection. Free commands stack and execute in
// order; active commands replace each other (only one survives); consume
-// commands are a single-slot eat/drink queue. All three are written from
-// session goroutines and drained from the tick goroutine.
+// commands are a single-slot eat/drink queue. Writers are the command
+// handlers (owner goroutine); the consumer is ProcessQueuedCommands on the
+// owner tick. The mutex is kept as a cheap guard for callers that reach a
+// queue from outside that single-owner path.
type CommandQueue struct {
mu sync.Mutex
freeQueue map[string][]QueuedCommand
@@ -65,8 +68,8 @@ func (q *CommandQueue) DrainFree(name string) []QueuedCommand {
return cmds
}
-// DrainActive returns all active commands sorted by timestamp (first-to-queue
-// wins), then clears the map.
+// DrainActive returns all active commands, then clears the map. It does not
+// order them: ProcessQueuedCommands sorts by Timestamp (first-to-queue wins).
func (q *CommandQueue) DrainActive() []QueuedCommand {
q.mu.Lock()
defer q.mu.Unlock()
@@ -103,19 +106,37 @@ func (q *CommandQueue) DeleteActive(name string) {
delete(q.activeQueue, name)
}
-// ActivePositions returns the sorted names of all players with active commands,
-// and the position (1-indexed) of the named player among them. Used by the
-// `queued` command to show queue order.
+// ActivePositions returns the sorted names of every player holding an active
+// command, and the position (1-indexed) of the named player among them, or 0
+// when that player has no active command. Used by the `queued` command to
+// show queue order.
func (q *CommandQueue) ActivePositions(name string) (names []string, pos int) {
q.mu.Lock()
defer q.mu.Unlock()
for n := range q.activeQueue {
names = append(names, n)
}
- // sort names (caller may want them sorted)
+ sort.Strings(names)
+ for i, n := range names {
+ if n == name {
+ return names, i + 1
+ }
+ }
return names, 0
}
+// ClearPlayer drops every queued command for the player: free, active, and
+// consume. Called when a session detaches (logout, quit, disconnect, or
+// character removal) so leftovers cannot retain the departed session and
+// player, and cannot replay against a later session for the same name.
+func (q *CommandQueue) ClearPlayer(name string) {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ delete(q.freeQueue, name)
+ delete(q.activeQueue, name)
+ delete(q.consumeQueue, name)
+}
+
// DrainConsume returns and removes the consume command for the player.
func (q *CommandQueue) DrainConsume(name string) (*QueuedCommand, bool) {
q.mu.Lock()
diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go
index 921af82..a5106ea 100644
--- a/internal/game/core_login_account.go
+++ b/internal/game/core_login_account.go
@@ -16,6 +16,14 @@ func (g *Game) handleAccountName(sess *net.Session, input string) {
sess.Write("Account name: ")
return
}
+ // Validate BEFORE any filesystem access: the name is joined into an
+ // account (and later character) path, so an invalid name must never
+ // reach FindAccount or anything downstream that reuses it.
+ if verr := player.ValidName(name); verr != nil {
+ sess.WriteLine(verr.Error())
+ sess.Write("Account name: ")
+ return
+ }
actualName, err := g.AccountStore.FindAccount(name)
if err == nil {
sess.Account = &player.Account{Name: actualName}
@@ -24,11 +32,6 @@ func (g *Game) handleAccountName(sess *net.Session, input string) {
sess.SetEcho(false)
sess.Write("Password: ")
} else {
- if verr := player.ValidName(name); verr != nil {
- sess.WriteLine(verr.Error())
- sess.Write("Account name: ")
- return
- }
sess.Account = &player.Account{Name: name}
sess.SetAuthName(name)
sess.SetState(net.StateNewAccountPass)
@@ -38,6 +41,22 @@ func (g *Game) handleAccountName(sess *net.Session, input string) {
}
}
+// resetToAccountName returns a session that has no usable account left
+// (purged, or republished out from under it) to the account-name prompt. A
+// menu-state handler must call it instead of dereferencing sess.Account:
+// a queued-mode Close only drops the transport after the writer drains, so
+// an already-buffered input line can still be dispatched to the owner
+// after the account is gone.
+func (g *Game) resetToAccountName(sess *net.Session) {
+ sess.Account = nil
+ sess.PendingChar = ""
+ sess.PendingMenu = nil
+ sess.PendingPass = ""
+ sess.SetEcho(true)
+ sess.SetState(net.StateAccountName)
+ sess.Write("Account name: ")
+}
+
func (g *Game) handlePassword(sess *net.Session, input string) {
if input == "" {
sess.Write("Password: ")
@@ -171,6 +190,13 @@ func (g *Game) applyPasswordAuth(sess *net.Session, auth passwordAuth) {
sess.SetState(net.StateAccountName)
sess.Write("Account name: ")
}
+ if sess.Account == nil {
+ // The account vanished while the password was being verified (a
+ // purge or republish from another session): there is nothing to
+ // authorize against, so start over from the account-name prompt.
+ g.resetToAccountName(sess)
+ return
+ }
if auth.loadErr {
reject("Error loading account.")
return
@@ -235,6 +261,10 @@ func (g *Game) handleNewPass(sess *net.Session, input string) {
}
func (g *Game) handleNewAccount(sess *net.Session, input string) {
+ if sess.Account == nil {
+ g.resetToAccountName(sess)
+ return
+ }
input = strings.TrimSpace(input)
if input == "" {
sess.SetEcho(true)
@@ -283,6 +313,13 @@ func (g *Game) handleNewAccountOffOwner(sess *net.Session, input string) {
// applyNewAccount creates the account on the owner. The password was
// already hashed by the caller (off the owner where possible).
func (g *Game) applyNewAccount(sess *net.Session, hash string, hashErr error) {
+ if sess.Account == nil {
+ // The account vanished while the password was being hashed off the
+ // owner (a purge or republish from another session): there is nothing
+ // to create against, and sess.Account is dereferenced below.
+ g.resetToAccountName(sess)
+ return
+ }
if hashErr != nil {
sess.SetEcho(true)
sess.WriteLine(fmt.Sprintf("Error creating account: %v", hashErr))
@@ -332,6 +369,10 @@ func (g *Game) applyNewAccount(sess *net.Session, hash string, hashErr error) {
}
func (g *Game) handleColorChoice(sess *net.Session, input string) {
+ if sess.Account == nil {
+ g.resetToAccountName(sess)
+ return
+ }
input = strings.ToLower(strings.TrimSpace(input))
if input == "y" || input == "yes" {
sess.Account.Options["color"] = "none"
@@ -359,6 +400,13 @@ func menuColorMode(sess *net.Session) string {
}
func (g *Game) showMenu(sess *net.Session) {
+ if sess.Account == nil {
+ // The account is gone (purged or republished from another session)
+ // while this session was mid-flow: there is no menu to show for a
+ // deleted account.
+ g.resetToAccountName(sess)
+ return
+ }
sess.SetState(net.StateMenu)
mode := menuColorMode(sess)
lines := []string{
@@ -387,6 +435,10 @@ func (g *Game) showMenu(sess *net.Session) {
}
func (g *Game) handleMenu(sess *net.Session, input string) {
+ if sess.Account == nil {
+ g.resetToAccountName(sess)
+ return
+ }
switch strings.ToLower(strings.TrimSpace(input)) {
case "":
sess.Write("> ")
@@ -483,6 +535,10 @@ func (g *Game) handleMenu(sess *net.Session, input string) {
}
func (g *Game) handleRenameAccount(sess *net.Session, input string) {
+ if sess.Account == nil {
+ g.resetToAccountName(sess)
+ return
+ }
newName := strings.TrimSpace(input)
if newName == "" {
sess.Write("New account name: ")
@@ -524,6 +580,10 @@ func (g *Game) handleRenameAccount(sess *net.Session, input string) {
}
func (g *Game) handlePurgeAccount(sess *net.Session, input string) {
+ if sess.Account == nil {
+ g.resetToAccountName(sess)
+ return
+ }
input = strings.TrimSpace(input)
expected := "PURGE " + sess.Account.Name
if !strings.EqualFold(input, expected) {
@@ -551,17 +611,19 @@ func (g *Game) handlePurgeAccount(sess *net.Session, input string) {
}
sess.WriteLine(fmt.Sprintf("Account %s has been purged. Thanks for playing.", sess.Account.Name))
- // Other authenticated sessions of this account are stale now; tell
- // them and close them (they cannot safely keep using cached state).
+ // Every session authenticated to this account is stale now: the account
+ // file is gone, so none of them may keep a live protocol state (a menu
+ // handler dereferencing the cached account, or a buffered input line
+ // already inside the transport, would fault). Reset each to the
+ // account-name prompt before closing it.
for _, other := range g.allSessionsOfAccount(sess.Account.Name) {
if other == sess {
continue
}
- other.Account = nil
- other.PendingChar = ""
- other.PendingMenu = nil
other.WriteLine("\nYour account has been purged from another session.")
+ g.resetToAccountName(other)
other.Close()
}
+ g.resetToAccountName(sess)
sess.Close()
}
diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go
index 0f57d3c..3a2e324 100644
--- a/internal/game/core_login_char.go
+++ b/internal/game/core_login_char.go
@@ -9,6 +9,10 @@ import (
)
func (g *Game) handleNewCharName(sess *net.Session, input string) {
+ if sess.Account == nil {
+ g.resetToAccountName(sess)
+ return
+ }
name := strings.TrimSpace(input)
if sess.PendingChar == "connect" && len(sess.Account.Characters) > 0 {
@@ -62,6 +66,10 @@ func (g *Game) handleNewCharName(sess *net.Session, input string) {
}
func (g *Game) connectCharacter(sess *net.Session, name string) {
+ if sess.Account == nil {
+ g.resetToAccountName(sess)
+ return
+ }
// This whole function runs as one owner step (invoked from session
// input): membership validation, the online check, registry
// publication, and room entry are serialized against every other
@@ -139,6 +147,10 @@ func (g *Game) connectCharacter(sess *net.Session, name string) {
}
func (g *Game) handleRenameChar(sess *net.Session, input string) {
+ if sess.Account == nil {
+ g.resetToAccountName(sess)
+ return
+ }
name := strings.TrimSpace(input)
if idx, err := parseChoiceIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) {
sess.PendingChar = sess.Account.Characters[idx-1]
@@ -168,13 +180,32 @@ func (g *Game) handleRenameChar(sess *net.Session, input string) {
// through a live session. Menu operations that rename or delete character
// files must refuse while the character is online, otherwise the in-memory
// player later overwrites or resurrects the moved/removed file.
+//
+// The lookup is case-insensitive: loggedInChars is keyed by the canonical
+// on-disk character name, but callers pass names taken from hand-editable
+// account files and admin file paths ("ALICE"), and character names keep the
+// case their owner chose. Character identity in this game is otherwise
+// case-insensitive (see isMemberOf), so liveness must be too - an exact-match
+// lookup lets an online character's file be edited or deleted.
func (g *Game) characterOnline(name string) bool {
g.charsMu.Lock()
defer g.charsMu.Unlock()
- return g.loggedInChars[name] != nil
+ if g.loggedInChars[name] != nil {
+ return true
+ }
+ for logged, sess := range g.loggedInChars {
+ if sess != nil && strings.EqualFold(logged, name) {
+ return true
+ }
+ }
+ return false
}
func (g *Game) handleRenameCharName(sess *net.Session, input string) {
+ if sess.Account == nil {
+ g.resetToAccountName(sess)
+ return
+ }
newName := strings.TrimSpace(input)
oldName := sess.PendingChar
if newName == "" {
@@ -239,6 +270,10 @@ func (g *Game) showDeleteConfirm(sess *net.Session) {
}
func (g *Game) handleDeleteChar(sess *net.Session, input string) {
+ if sess.Account == nil {
+ g.resetToAccountName(sess)
+ return
+ }
if sess.PendingChar == "" {
name := strings.TrimSpace(input)
if idx, err := parseChoiceIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) {
diff --git a/internal/game/correct_combine_skill_test.go b/internal/game/correct_combine_skill_test.go
new file mode 100644
index 0000000..d6de229
--- /dev/null
+++ b/internal/game/correct_combine_skill_test.go
@@ -0,0 +1,100 @@
+package game
+
+// Regression: combine recipes declare type "combine", which is an action and
+// not a skill. Resolving the recipe type straight to a player.SkillName made
+// p.Level read a map miss (level 1) for the success roll and made awardSkillXP
+// persist a phantom "combine" skill entry.
+
+import (
+ "path/filepath"
+ "testing"
+
+ "thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+const correctCombineYAML = `id: correct_combine_out
+name: correct combine out
+stackable: false
+value: 5
+craft:
+ - type: combine
+ ingredients:
+ - items:
+ - correct_combine_part
+ quantity: 1
+ xp: 7
+ output_qty: 1
+ ticks_per_cycle: 1
+`
+
+func TestCombineSkillResolvesARealSkill(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ typ string
+ want player.SkillName
+ }{
+ {"combine is an action, not a skill", "combine", player.Crafting},
+ {"empty type defaults to combine", "", player.Crafting},
+ {"a declared real skill is honored", "smithing", player.Smithing},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ got := combineSkill(&item.CraftDef{Type: tc.typ})
+ if got != tc.want {
+ t.Fatalf("combineSkill(type %q) = %q, want %q", tc.typ, got, tc.want)
+ }
+ if !player.ValidSkill(got) {
+ t.Fatalf("combineSkill(type %q) resolved to unknown skill %q", tc.typ, got)
+ }
+ })
+ }
+}
+
+func TestCombineXPGoesToCraftingNotAPhantomSkill(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ writeSeedFile(t, filepath.Join(h.dir, "items", "correct_combine_out.yaml"), []byte(correctCombineYAML))
+ writeSeedFile(t, filepath.Join(h.dir, "items", "correct_combine_part.yaml"),
+ []byte("id: correct_combine_part\nname: correct combine part\nstackable: false\nvalue: 1\n"))
+
+ conn := newChanConn()
+ sess := &net.Session{Conn: conn, Player: p}
+ sess.SetState(net.StateGame)
+
+ startCrafting := p.Skills[player.Crafting]
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: "correct_combine_part", Quantity: 1})
+ def := mustLoadItem(t, h.g, "correct_combine_out")
+ h.g.startCombine(sess, p, def, def.FirstCraft(), 1)
+ if p.Action == nil {
+ t.Fatal("combine did not start")
+ }
+ h.g.advanceCombine(sess, p) // phase 0 -> 1
+ h.g.advanceCombine(sess, p) // phase 1 -> 2
+ h.g.advanceCombine(sess, p) // phase 2: consume + output
+
+ if got := p.CountItem("correct_combine_out"); got != 1 {
+ t.Fatalf("combine output = %d, want 1", got)
+ }
+ if got := p.Skills[player.Crafting]; got != startCrafting+7 {
+ t.Errorf("crafting xp = %d, want %d", got, startCrafting+7)
+ }
+ if got, ok := p.Skills[player.SkillName("combine")]; ok {
+ t.Errorf("combine recipes persist a phantom skill: combine = %d", got)
+ }
+ })
+
+ saved, err := h.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Fatalf("load character: %v", err)
+ }
+ if got, ok := saved.Skills[player.SkillName("combine")]; ok {
+ t.Errorf("phantom combine skill was persisted: combine = %d", got)
+ }
+ if got := saved.Skills[player.Crafting]; got != startCrafting+7 {
+ t.Errorf("persisted crafting xp = %d, want %d", got, startCrafting+7)
+ }
+}
diff --git a/internal/game/correct_online_name_case_test.go b/internal/game/correct_online_name_case_test.go
new file mode 100644
index 0000000..74c344c
--- /dev/null
+++ b/internal/game/correct_online_name_case_test.go
@@ -0,0 +1,85 @@
+package game
+
+// Regression: character/account liveness must be matched case-insensitively,
+// like the game's own membership check (isMemberOf). The admin raw file editor
+// passes a file NAME ("players/characters/ALICE.yaml"), and an account file's
+// character list is hand-editable, so an exact-match lookup both let an online
+// character's file be overwritten and let an online character's account be
+// purged.
+
+import (
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/net"
+)
+
+func TestAdminPlayerFileOnlineMatchesNamesCaseInsensitively(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+ h.seedAccount("carol")
+ h.seedCharacter("carol", "carol")
+
+ // The account file spells its character list differently from the live
+ // session's character name.
+ acc, err := h.g.AccountStore.LoadAccount("alice")
+ if err != nil {
+ t.Fatalf("load account: %v", err)
+ }
+ acc.Characters = []string{"ALICE"}
+ if err := h.g.AccountStore.SaveAccount(acc); err != nil {
+ t.Fatalf("save account: %v", err)
+ }
+
+ h.connect("alice")
+
+ cases := []struct {
+ name string
+ path string
+ want bool
+ }{
+ {"online character, exact case", "players/characters/alice.yaml", true},
+ {"online character, upper case", "players/characters/ALICE.yaml", true},
+ {"online character, mixed case", "players/characters/Alice.yaml", true},
+ {"account of an online character, list case differs", "players/accounts/alice.yaml", true},
+ {"offline character, upper case", "players/characters/CAROL.yaml", false},
+ {"offline account", "players/accounts/carol.yaml", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := h.g.AdminPlayerFileOnline(tc.path); got != tc.want {
+ t.Fatalf("AdminPlayerFileOnline(%q) = %v, want %v", tc.path, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestPurgeRefusesCharacterListedInAnotherCase(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+
+ acc, err := h.g.AccountStore.LoadAccount("alice")
+ if err != nil {
+ t.Fatalf("load account: %v", err)
+ }
+ acc.Characters = []string{"ALICE"}
+ if err := h.g.AccountStore.SaveAccount(acc); err != nil {
+ t.Fatalf("save account: %v", err)
+ }
+
+ h.connect("alice")
+ conn := newChanConn()
+ menu := &net.Session{Conn: conn, Account: acc}
+ menu.SetState(net.StateMenu)
+
+ h.g.RunOnOwner(func() { h.g.handlePurgeAccount(menu, "PURGE alice") })
+
+ if out := string(conn.Bytes()); !strings.Contains(out, "is logged in") {
+ t.Fatalf("purge was not refused for a live character: %q", out)
+ }
+ if _, err := h.g.AccountStore.LoadAccount("alice"); err != nil {
+ t.Fatalf("account was purged while its character was online: %v", err)
+ }
+}
diff --git a/internal/game/correct_owner_panic_subscription_test.go b/internal/game/correct_owner_panic_subscription_test.go
new file mode 100644
index 0000000..f535dcc
--- /dev/null
+++ b/internal/game/correct_owner_panic_subscription_test.go
@@ -0,0 +1,85 @@
+package game
+
+// Regression: the owner loop contains a panic in a tick callback, so the tick
+// engine no longer sees it. SubscribeTicks must therefore treat "the callback
+// did not run to completion" as KEEP - a false return unsubscribes the system,
+// and the master subscriber main.go registers (RunTickSystems) is what drains
+// queued commands, settles casino rounds, and cleans up disconnects. Likewise
+// RunAdminOnOwner must not report a contained panic as "game is shutting down".
+
+import (
+ "testing"
+
+ "thehouseoficarus/internal/engine"
+)
+
+func TestSubscribeTicksKeepsSubscriptionWhenCallbackPanics(t *testing.T) {
+ g := &Game{owner: newOwner()}
+ g.Ticks = engine.New()
+ t.Cleanup(g.stopOwner)
+
+ calls := 0
+ g.SubscribeTicks(1, func() bool {
+ calls++
+ if calls == 1 {
+ panic("tick system failure")
+ }
+ return false
+ })
+
+ g.Ticks.Tick()
+ // The panicking task's runTask (recover, log, counter) has not finished
+ // when the blocked caller is released, so drain the queue before reading
+ // the counter: the owner processes tasks FIFO.
+ g.run(func() {})
+ if got := g.owner.panics.Load(); got != 1 {
+ t.Fatalf("contained panics = %d, want 1", got)
+ }
+ if calls != 1 {
+ t.Fatalf("callback calls = %d, want 1", calls)
+ }
+
+ // The subscription must still be live: a deleted one never fires again,
+ // which would leave the world frozen while the process keeps running.
+ g.Ticks.Tick()
+ if calls != 2 {
+ t.Fatalf("a contained panic unsubscribed the system: calls = %d, want 2", calls)
+ }
+
+ // An explicit false must still unsubscribe.
+ g.Ticks.Tick()
+ if calls != 2 {
+ t.Fatalf("explicit false did not unsubscribe: calls = %d, want 2", calls)
+ }
+}
+
+func TestRunAdminOnOwnerReportsContainedPanicToCaller(t *testing.T) {
+ g := &Game{owner: newOwner()}
+ t.Cleanup(g.stopOwner)
+
+ func() {
+ defer func() {
+ if r := recover(); r != adminTaskPanic {
+ t.Errorf("recovered %v, want the contained-task-panic value", r)
+ }
+ }()
+ if g.RunAdminOnOwner(func() { panic("editor task failure") }) {
+ t.Error("a panicking editor task was reported as accepted")
+ }
+ t.Fatal("a contained panic was reported as a normal result")
+ }()
+
+ g.run(func() {})
+ if got := g.owner.panics.Load(); got != 1 {
+ t.Errorf("contained panics = %d, want 1", got)
+ }
+ // The owner loop must still be draining, and the game must not have been
+ // flagged as shutting down by the panic.
+ ran := false
+ if !g.RunAdminOnOwner(func() { ran = true }) {
+ t.Fatal("the owner stopped accepting editor tasks after a contained panic")
+ }
+ if !ran {
+ t.Fatal("the owner loop did not execute later editor tasks")
+ }
+}
diff --git a/internal/game/correct_quality_merge_test.go b/internal/game/correct_quality_merge_test.go
new file mode 100644
index 0000000..d54c8c9
--- /dev/null
+++ b/internal/game/correct_quality_merge_test.go
@@ -0,0 +1,201 @@
+package game
+
+// Regression: every stack-creation and stack-merge site must agree on the
+// Quality/MaxQuality a fresh stack gets (newInventorySlot / stackProps).
+// Pickups merge only into a stack with those exact properties, so a stack
+// produced by crafting, harvesting, or ammo removal with Quality 0 could no
+// longer be merged into - a full inventory then refused a pickup of an item
+// the player already carried.
+
+import (
+ "path/filepath"
+ "testing"
+
+ "thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+const correctQualityItemYAML = `id: correct_quality_gem
+name: correct quality gem
+stackable: true
+max_quality: 5
+craft:
+ - type: crafting
+ ingredients:
+ - items:
+ - correct_quality_shard
+ quantity: 1
+ xp: 1
+ ticks_per_cycle: 1
+`
+
+func seedCorrectQualityItems(t *testing.T, h *harness) {
+ t.Helper()
+ writeSeedFile(t, filepath.Join(h.dir, "items", "correct_quality_gem.yaml"), []byte(correctQualityItemYAML))
+ writeSeedFile(t, filepath.Join(h.dir, "items", "correct_quality_shard.yaml"),
+ []byte("id: correct_quality_shard\nname: correct quality shard\nstackable: true\nmax_quality: 5\nvalue: 1\n"))
+}
+
+// fillInventoryExcept occupies every slot but slot 0 with filler.
+func fillInventoryExcept(p *player.Player) {
+ for i := 0; i < 28; i++ {
+ if i != 0 {
+ p.SetInvSlot(i, &player.InventorySlot{ItemID: "filler", Quantity: 1})
+ }
+ }
+}
+
+// TestStackPropsMatchNewInventorySlot guards the one rule that stack creation
+// and stack merging both depend on: the helper the craft/harvest/ammo sites
+// use must stamp what newInventorySlot stamps.
+func TestStackPropsMatchNewInventorySlot(t *testing.T) {
+ h := newHarness(t)
+ seedCorrectQualityItems(t, h)
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ gem := h.g.newInventorySlot("correct_quality_gem", 1)
+ if q, mq := stackProps(mustLoadItem(t, h.g, "correct_quality_gem")); q != gem.Quality || mq != gem.MaxQuality {
+ t.Errorf("stackProps = (%d,%d), newInventorySlot = (%d,%d)", q, mq, gem.Quality, gem.MaxQuality)
+ }
+ shard := h.g.newInventorySlot("correct_quality_shard", 1)
+ if q, mq := stackProps(mustLoadItem(t, h.g, "correct_quality_shard")); q != shard.Quality || mq != shard.MaxQuality {
+ t.Errorf("quality-free stackProps = (%d,%d), newInventorySlot = (%d,%d)", q, mq, shard.Quality, shard.MaxQuality)
+ }
+ })
+}
+
+func TestCraftOutputMergesIntoQualityStack(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ seedCorrectQualityItems(t, h)
+
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ def := mustLoadItem(t, h.g, "correct_quality_gem")
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: def.ID, Quantity: 10, Quality: 5, MaxQuality: 5})
+ fillInventoryExcept(p)
+
+ if !craftOutputFits(p, def.ID, def) {
+ t.Fatal("a full inventory must accept a craft output that merges into an identical stack")
+ }
+ if !placeCraftOutput(p, def.ID, def, 2) {
+ t.Fatal("craft output was not placed")
+ }
+ if slot := p.InvSlot(0); slot.Quantity != 12 || slot.Quality != 5 || slot.MaxQuality != 5 {
+ t.Errorf("merged stack = %+v, want quantity 12 at quality 5", slot)
+ }
+
+ // A differently qualified stack is not a merge target: the same
+ // full inventory must report that nothing fits.
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: def.ID, Quantity: 10, Quality: 1, MaxQuality: 5})
+ if craftOutputFits(p, def.ID, def) {
+ t.Error("craft output would merge into a stack with a different quality")
+ }
+ if placeCraftOutput(p, def.ID, def, 2) {
+ t.Error("craft output was placed with no free slot and no matching stack")
+ }
+ })
+}
+
+func TestCraftOutputCreatesStackWithFreshProps(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ seedCorrectQualityItems(t, h)
+
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ def := mustLoadItem(t, h.g, "correct_quality_gem")
+ if !placeCraftOutput(p, def.ID, def, 3) {
+ t.Fatal("craft output was not placed in an empty inventory")
+ }
+ slot := p.InvSlot(0)
+ if slot == nil || slot.ItemID != def.ID {
+ t.Fatalf("slot 0 = %+v", slot)
+ }
+ if q, mq := h.g.pickedStackProps(def.ID); slot.Quality != q || slot.MaxQuality != mq {
+ t.Fatalf("crafted stack %+v does not match what a pickup receives (%d,%d)", slot, q, mq)
+ }
+ })
+}
+
+func TestPickupMergesIntoHarvestedStackWhenFull(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ writeSeedFile(t, filepath.Join(h.dir, "items", hardeningCropID+".yaml"),
+ []byte("id: test_potato\nname: test potato\nstackable: true\nmax_quality: 5\n"))
+
+ conn := newChanConn()
+ sess := &net.Session{Conn: conn, Player: p}
+ sess.SetState(net.StateGame)
+
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ readyHarvest(p, 3, 3)
+ h.g.advanceHarvest(sess, p)
+
+ stack := p.InvSlot(0)
+ if stack == nil || stack.ItemID != hardeningCropID {
+ t.Fatalf("harvested stack = %+v", stack)
+ }
+ quality, maxQuality := h.g.pickedStackProps(hardeningCropID)
+ if stack.Quality != quality || stack.MaxQuality != maxQuality {
+ t.Fatalf("harvested stack %+v does not match what a pickup receives (%d,%d)", stack, quality, maxQuality)
+ }
+
+ // A full inventory must still merge a ground pickup into the stack
+ // the harvest just created.
+ fillInventoryExcept(p)
+ h.g.World.AddGroundItem(p.RoomID, hardeningCropID, 2)
+ h.g.doGet(sess, "test potato")
+
+ if got := p.CountItem(hardeningCropID); got != 5 {
+ t.Errorf("potatoes carried = %d, want 5", got)
+ }
+ if got := h.g.World.GroundItems(p.RoomID)[hardeningCropID]; got != 0 {
+ t.Errorf("ground potatoes = %d, want 0 (they fit the existing stack)", got)
+ }
+ })
+}
+
+func TestPickupMergesIntoAmmoReturnedStack(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ seedCorrectQualityItems(t, h)
+
+ conn := newChanConn()
+ sess := &net.Session{Conn: conn, Player: p}
+ sess.SetState(net.StateGame)
+
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ p.Equipment[item.SlotAmmo] = "correct_quality_shard"
+ p.AmmoQty = 4
+ if !h.g.unequipAmmo(p) {
+ t.Fatal("unequipAmmo refused an empty inventory")
+ }
+ stack := p.InvSlot(0)
+ if stack == nil || stack.ItemID != "correct_quality_shard" {
+ t.Fatalf("returned ammo stack = %+v", stack)
+ }
+ quality, maxQuality := h.g.pickedStackProps("correct_quality_shard")
+ if stack.Quality != quality || stack.MaxQuality != maxQuality {
+ t.Fatalf("returned ammo stack %+v does not match what a pickup receives (%d,%d)", stack, quality, maxQuality)
+ }
+
+ fillInventoryExcept(p)
+ h.g.World.AddGroundItem(p.RoomID, "correct_quality_shard", 3)
+ h.g.doGet(sess, "correct quality shard")
+
+ if got := p.CountItem("correct_quality_shard"); got != 7 {
+ t.Errorf("shards carried = %d, want 7", got)
+ }
+ if got := h.g.World.GroundItems(p.RoomID)["correct_quality_shard"]; got != 0 {
+ t.Errorf("ground shards = %d, want 0 (they fit the returned stack)", got)
+ }
+ })
+}
diff --git a/internal/game/correct_vanished_account_reset_test.go b/internal/game/correct_vanished_account_reset_test.go
new file mode 100644
index 0000000..d40f602
--- /dev/null
+++ b/internal/game/correct_vanished_account_reset_test.go
@@ -0,0 +1,75 @@
+package game
+
+// Regression: a session whose account file has vanished (purged, or deleted
+// through the admin raw file editor) must be reset to the account-name prompt
+// with no pending registration state left behind. Clearing Account alone left
+// PendingPass set, so an already-buffered confirm-password line could still
+// reach a handler that dereferences the account; applyNewAccount now also
+// refuses to dereference a nil account.
+
+import (
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+func TestPublishAccountUpdateVanishedAccountResetsSession(t *testing.T) {
+ h := newHarness(t)
+
+ conn := newChanConn()
+ sess := &net.Session{Conn: conn, Account: &player.Account{Name: "bob"}}
+ sess.SetState(net.StateNewAccount)
+ sess.PendingPass = "pw"
+ h.hub.Add(sess)
+
+ h.g.RunOnOwner(func() { h.g.publishAccountUpdate("bob", "bob") })
+
+ if got := sess.State(); got != net.StateAccountName {
+ t.Errorf("state = %v, want StateAccountName", got)
+ }
+ if sess.Account != nil {
+ t.Errorf("account not cleared: %+v", sess.Account)
+ }
+ if sess.PendingPass != "" {
+ t.Errorf("PendingPass = %q, want it cleared with the account", sess.PendingPass)
+ }
+ out := string(conn.Bytes())
+ if !strings.Contains(out, "Your account no longer exists.") {
+ t.Errorf("missing vanished-account notice; output = %q", out)
+ }
+ if !strings.Contains(out, "Account name: ") {
+ t.Errorf("missing account-name prompt; output = %q", out)
+ }
+}
+
+func TestApplyNewAccountResetsWhenAccountVanished(t *testing.T) {
+ h := newHarness(t)
+
+ conn := newChanConn()
+ sess := &net.Session{Conn: conn}
+ sess.SetState(net.StateNewAccount)
+ sess.PendingPass = "pw"
+
+ // Direct call: the menu handlers run on the owner goroutine, so the test
+ // goroutine stands in for it and an unguarded dereference fails loudly.
+ h.g.applyNewAccount(sess, "hash", nil)
+
+ if got := sess.State(); got != net.StateAccountName {
+ t.Errorf("state = %v, want StateAccountName", got)
+ }
+ if sess.Account != nil {
+ t.Errorf("account not cleared: %+v", sess.Account)
+ }
+ if sess.PendingPass != "" {
+ t.Errorf("PendingPass = %q, want it cleared", sess.PendingPass)
+ }
+ out := string(conn.Bytes())
+ if !strings.Contains(out, "Account name: ") {
+ t.Errorf("missing account-name prompt; output = %q", out)
+ }
+ if _, err := h.g.AccountStore.LoadAccount("bob"); err == nil {
+ t.Error("an account was created for a session with no account bound")
+ }
+}
diff --git a/internal/game/economy.go b/internal/game/economy.go
index b39dbe4..b436a29 100644
--- a/internal/game/economy.go
+++ b/internal/game/economy.go
@@ -52,6 +52,7 @@ import (
"strings"
"time"
+ "thehouseoficarus/internal/casino"
"thehouseoficarus/internal/player"
)
@@ -106,6 +107,12 @@ type economyRecord struct {
type economyJournal struct {
path string
+ // dirSynced records whether the directory entry of path has been made
+ // durable. A file's own fsync does not persist the entry that names it,
+ // so the first append must sync the directory (as persistence.WriteFile
+ // does) or a crash can lose the whole journal — including the record of
+ // a debit that is already durable in the character file.
+ dirSynced bool
// stakes indexes outstanding wager records per player identity key
// (economyKey: the character's stable ID when known, else its name).
// All journal mutations happen on the game owner (or during startup
@@ -132,6 +139,17 @@ func newEconomyJournal(dataDir string) *economyJournal {
return &economyJournal{path: filepath.Join(dir, "journal.jsonl"), stakes: make(map[string][]*economyRecord), openPayouts: make(map[string]bool), openPayoutRecs: make(map[string][]*economyRecord)}
}
+// syncDir fsyncs a directory so an entry created or replaced in it survives a
+// crash (the same durability step persistence.WriteFile performs).
+func syncDir(dir string) error {
+ d, err := os.Open(dir)
+ if err != nil {
+ return err
+ }
+ defer d.Close()
+ return d.Sync()
+}
+
func newEconomyRecordID() string {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
@@ -159,6 +177,12 @@ func (j *economyJournal) append(rec economyRecord) error {
if err := f.Sync(); err != nil {
return fmt.Errorf("sync economy journal: %w", err)
}
+ if !j.dirSynced {
+ if err := syncDir(filepath.Dir(j.path)); err != nil {
+ return fmt.Errorf("sync economy journal dir: %w", err)
+ }
+ j.dirSynced = true
+ }
return nil
}
@@ -497,11 +521,12 @@ func (j *economyJournal) loadOpen() ([]*economyRecord, error) {
return open, nil
}
-// foldedRecord is the latest state of one record ID together with the
-// order (line position of its latest write) it occupied in the journal.
+// foldedRecord is the latest state of one record ID. Ordering decisions
+// (which record came first) are made from economyRecord.Seq — the record's
+// creation order — never from the record's line position, which a later
+// rewrite of the same record moves.
type foldedRecord struct {
rec economyRecord
- seq int
}
// loadFolded folds the journal (latest line per record ID wins) and
@@ -556,9 +581,8 @@ func (j *economyJournal) loadFolded() ([]foldedRecord, error) {
}
if existing, ok := latest[rec.ID]; ok {
existing.rec = rec
- existing.seq = seq
} else {
- fr := &foldedRecord{rec: rec, seq: seq}
+ fr := &foldedRecord{rec: rec}
latest[rec.ID] = fr
order = append(order, fr)
}
@@ -617,9 +641,14 @@ func (j *economyJournal) compact() {
}
f.Close()
// Best-effort atomic-ish replacement; a torn compaction leaves the
- // original file intact.
+ // original file intact. The rename is made durable like the file's own
+ // sync, so a crash cannot lose the compacted journal entirely.
if err := os.Rename(tmp, j.path); err != nil {
os.Remove(tmp)
+ return
+ }
+ if err := syncDir(filepath.Dir(j.path)); err != nil {
+ fmt.Fprintf(os.Stderr, "economy compaction: sync journal dir: %v\n", err)
}
}
@@ -698,22 +727,19 @@ func (g *Game) grantCredits(p *player.Player, amount int) {
sum, err := addCreditsChecked(p.Credits, amount)
if err != nil {
fmt.Fprintf(os.Stderr, "credit grant for %s clamped at the economy cap: %v\n", p.Name, err)
- p.Credits = maxCreditsValue
+ p.Credits = casino.MaxCredits
return
}
p.Credits = sum
}
-// maxCreditsValue mirrors casino.MaxCredits; kept local to avoid a
-// dependency cycle (see addCreditsChecked).
-const maxCreditsValue = 1_000_000_000_000
-
-// addCreditsChecked is the game-side checked addition against the economy
-// cap (mirrors casino.MaxCredits; kept local to avoid a dependency cycle).
+// addCreditsChecked is the game-side checked addition against the shared
+// economy cap (casino.MaxCredits, mirrored by the casino wallet: a second,
+// independently drifting cap would let the two sides of the money path
+// disagree about what a balance may hold).
func addCreditsChecked(a, b int) (int, error) {
- const maxCredits = 1_000_000_000_000
- if b > 0 && a > maxCredits-b {
- return maxCredits, fmt.Errorf("credit overflow: %d + %d exceeds %d", a, b, maxCredits)
+ if b > 0 && a > casino.MaxCredits-b {
+ return casino.MaxCredits, fmt.Errorf("credit overflow: %d + %d exceeds %d", a, b, casino.MaxCredits)
}
if b < 0 {
return 0, fmt.Errorf("negative credit add %d", b)
@@ -907,9 +933,16 @@ func (g *Game) recoverEconomy() {
// instead marks a refund in progress: its credit part is re-applied by
// phase 1 and the chip part (if the debit was durable) still comes
// back in chips.
- stakeEvidence := func(rec *economyRecord, seq int) (evidence bool, settlement bool) {
+ stakeEvidence := func(rec *economyRecord) (evidence bool, settlement bool) {
for _, fr := range folded {
- if fr.rec.Kind != "payout" || fr.rec.Status == "void" || fr.seq <= seq ||
+ // Ordering is by the record's CREATION Seq, not by the line of
+ // its latest write: a payout created before this stake is not
+ // its settlement even when a later rewrite of that payout
+ // (a settle marker, or a recovery checkpoint re-anchor) lands
+ // after the stake's line. Line order would let an old payout
+ // mask a later stake and silently erase its refund (see
+ // stakeCovered, which orders the same way).
+ if fr.rec.Kind != "payout" || fr.rec.Status == "void" || fr.rec.Seq <= rec.Seq ||
!payoutIdentifies(fr.rec, *rec) {
continue
}
@@ -1005,8 +1038,18 @@ func (g *Game) recoverEconomy() {
// actually be paid from — without this, the balance a PAID
// sibling moved reads as this record's own durability and
// the liability is silently settled instead of paid.
+ // The re-anchored post-state must respect the cap: when it
+ // would exceed it, no reachable balance can pay the record,
+ // so the current balance is recorded as both checkpoints —
+ // the record then keeps reading as owed at this balance
+ // (retrying the re-application) instead of claiming an
+ // impossible post-state that a coincidental balance could
+ // be mistaken for as this record's own durability.
other.CreditsBefore = p.Credits
- other.CreditsAfter = p.Credits + other.Credit
+ other.CreditsAfter = p.Credits
+ if after, err := addCreditsChecked(p.Credits, other.Credit); err == nil {
+ other.CreditsAfter = after
+ }
settled[other.ID] = true // handled for the rest of this run
_ = g.Economy.append(*other)
continue
@@ -1110,7 +1153,7 @@ func (g *Game) recoverEconomy() {
compensated++
continue
}
- resolvedByPayout, resolvedBySettlement := stakeEvidence(rec, folded[openWagers[k]].seq)
+ resolvedByPayout, resolvedBySettlement := stakeEvidence(rec)
// Checkpoint comparisons use the DURABLE state, captured before any
// in-memory chip restoration below.
creditsNow, chipsNow := p.Credits, countChips(p)
diff --git a/internal/game/game.go b/internal/game/game.go
index ef48b63..de383a6 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -88,6 +88,13 @@ type Game struct {
shutdownHook func(reason string)
transportStop func()
+ // shutdownSaveMu guards shutdownSaveErr, the error of the final save
+ // runShutdown performs (nil when it succeeded or no shutdown ran). main
+ // reads it through ShutdownSaveError after the transports close, so a
+ // failed final save exits non-zero instead of only logging.
+ shutdownSaveMu sync.Mutex
+ shutdownSaveErr error
+
// Economy is the durable money journal; see economy.go.
Economy *economyJournal
@@ -117,9 +124,12 @@ func New(dataDir string, colorConfig *config.ColorsConfig, constantColorConfig *
CardArt: cardart.Load(filepath.Join(dataDir, "cards.yaml")),
DataDir: dataDir,
},
- GlobalFlags: NewGlobalFlagStore(),
- Combat: combat.NewTracker(),
- Casino: casino.NewManager(time.Now().UnixNano()),
+ GlobalFlags: NewGlobalFlagStore(),
+ Combat: combat.NewTracker(),
+ // Entropy-seeded, not clock-seeded: a wall-clock seed makes every
+ // shoe and reel stream reconstructible. casino.NewManager(seed) stays
+ // the deterministic test seam.
+ Casino: casino.NewManagerEntropy(),
flagIndex: newFlagTriggerIndex(),
queue: NewCommandQueue(),
safespot: NewSafespotManager(),
@@ -228,6 +238,27 @@ func (g *Game) SetTransportStop(stop func()) {
g.transportStop = stop
}
+// ShutdownSaveError reports the failure of the final online-character save
+// performed by runShutdown. It is nil when that save succeeded or when no
+// shutdown sequence ran. main calls it after (*net.Server).ListenAndServe
+// returns (the transports close inside the shutdown sequence) and exits
+// non-zero on a non-nil error: the transport return value reports "server
+// stopped", never data loss.
+func (g *Game) ShutdownSaveError() error {
+ g.shutdownSaveMu.Lock()
+ defer g.shutdownSaveMu.Unlock()
+ return g.shutdownSaveErr
+}
+
+// recordShutdownSaveErr stores the final-save failure for ShutdownSaveError.
+// The shutdown sequence runs on its own goroutine, so the field is mutex
+// guarded against the reader in main.
+func (g *Game) recordShutdownSaveErr(err error) {
+ g.shutdownSaveMu.Lock()
+ g.shutdownSaveErr = err
+ g.shutdownSaveMu.Unlock()
+}
+
// runShutdown executes the ordered shutdown sequence:
//
// 1. Admissions stop: setShuttingDown already makes session input and
@@ -256,6 +287,10 @@ func (g *Game) runShutdown() {
}
})
if saveErr != nil {
+ // Recorded as well as logged: main reads it after the transports
+ // close and exits non-zero, so an operator's supervisor can see
+ // that online characters were not persisted.
+ g.recordShutdownSaveErr(saveErr)
log.Printf("shutdown: final save failed: %v", saveErr)
}
@@ -394,8 +429,25 @@ func (g *Game) SubscribeTicks(interval int, cb func() bool) uint64 {
return g.Ticks.Subscribe(interval, cb)
}
return g.Ticks.Subscribe(interval, func() bool {
- keep := false
- g.run(func() { keep = cb() })
+ // keep defaults to TRUE and is only read once the callback has run to
+ // completion. The owner loop's panic barrier (owner.runTask) contains
+ // a panic in cb, so returning false on a contained panic would tell
+ // the engine to DELETE this subscription - permanently unsubscribing
+ // the master subscriber main.go registers (RunTickSystems) on the
+ // first panic inside any tick system, leaving the process alive and
+ // accepting logins while commands never drain and casino rounds
+ // never settle. A subscription is only dropped on an explicit false.
+ // g.run also drops the task outright during shutdown; that is not a
+ // failure either.
+ ran := false
+ keep := true
+ g.run(func() {
+ keep = cb()
+ ran = true
+ })
+ if !ran {
+ return true
+ }
return keep
})
}
@@ -424,6 +476,67 @@ func (g *Game) EvictCasinoRoom(roomID int) {
}
}
+// AdminPlayerFileOnline reports whether the data-relative player file path
+// belongs to a character or account that is currently online, so the admin
+// web editor (admin.AdminServer.SetPlayerFileOnlineCheck) refuses to
+// overwrite or delete a file the game still owns in memory. relPath takes
+// the form the editor passes, e.g. "players/characters/alice.yaml" or
+// "players/accounts/alice.yaml"; any other path is not a player file and
+// reports false. A missing account file also reports false (nothing is
+// online under it).
+//
+// It reads the owner-safe registry rather than the owner-only fields of a
+// session: character liveness comes from the mutex-guarded loggedInChars
+// map (see characterOnline, which matches names case-insensitively) and
+// account liveness from the account's on-disk
+// character list, the same membership source the account purge uses. That
+// keeps the hook safe whether the caller runs on the owner (where the admin
+// file editor executes) or off it.
+func (g *Game) AdminPlayerFileOnline(relPath string) bool {
+ name, kind, ok := playerDataFileName(relPath)
+ if !ok {
+ return false
+ }
+ switch kind {
+ case "characters":
+ return g.characterOnline(name)
+ case "accounts":
+ acc, err := g.AccountStore.LoadAccount(name)
+ if err != nil {
+ return false
+ }
+ // characterOnline compares case-insensitively, like the game's own
+ // membership check (isMemberOf): the account file's character list is
+ // hand-editable and need not spell a name the way the live session
+ // does, so "Alice" here must still find the online "alice".
+ for _, charName := range acc.Characters {
+ if g.characterOnline(charName) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// playerDataFileName splits a data-relative player file path into its base
+// name and its kind ("characters" or "accounts"). It reports false for any
+// path that is not players/<kind>/<name>.yaml, including a bare directory or
+// a non-YAML file, so callers never treat an unrelated file as a player file.
+func playerDataFileName(relPath string) (name, kind string, ok bool) {
+ clean := filepath.ToSlash(filepath.Clean(strings.TrimPrefix(strings.TrimPrefix(relPath, "/"), "data/")))
+ name, found := strings.CutSuffix(filepath.Base(clean), ".yaml")
+ if !found || name == "" {
+ return "", "", false
+ }
+ switch filepath.Dir(clean) {
+ case "players/characters":
+ return name, "characters", true
+ case "players/accounts":
+ return name, "accounts", true
+ }
+ return "", "", false
+}
+
func (g *Game) writePrompt(sess *net.Session) {
sess.WritePrompt(g.promptStr(sess))
}
@@ -533,6 +646,14 @@ func (g *Game) ProcessQueuedCommands() {
cmds := g.queue.DrainFree(p.Name)
for _, qc := range cmds {
+ // The queued command records the session it was typed on, which
+ // can be a departed session by now (logout, disconnect, or a
+ // re-login on a new connection): never execute against it, or
+ // a nil Player dereference below kills the owner goroutine.
+ // Mirrors the nil guard in the actives loop.
+ if qc.Session == nil || qc.Session.Player == nil {
+ continue
+ }
g.cancelRest(p.Name)
raw := qc.Command
if qc.Args != "" {
diff --git a/internal/game/hardening_account_name_validation_test.go b/internal/game/hardening_account_name_validation_test.go
new file mode 100644
index 0000000..7fca0ab
--- /dev/null
+++ b/internal/game/hardening_account_name_validation_test.go
@@ -0,0 +1,201 @@
+package game
+
+// Regression coverage for name validation in the account/character lifecycle
+// transactions: every caller-supplied (or account-file-supplied) name is
+// joined into AccountPath/CharPath, so a path-like name must be rejected
+// before the first join instead of deleting or moving a file outside the
+// players/ tree.
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// writeTraversalCanary creates a file that a path-traversal join would target,
+// so a test can prove the transaction never touched it.
+func writeTraversalCanary(t *testing.T, path string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("canary\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func assertTraversalCanary(t *testing.T, path string) {
+ t.Helper()
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("canary %s was removed: %v", path, err)
+ }
+ if string(data) != "canary\n" {
+ t.Fatalf("canary %s was overwritten: %q", path, data)
+ }
+}
+
+func TestPurgeAccountTxnRejectsPathLikeNames(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+
+ // CharPath("../../escape") would resolve to <dataDir>/escape.yaml.
+ charCanary := filepath.Join(h.dir, "escape.yaml")
+ writeTraversalCanary(t, charCanary)
+
+ err := h.g.purgeAccountTxn("alice", []string{"../../escape"})
+ if err == nil {
+ t.Fatal("purge accepted a path-traversing character name")
+ }
+ if !strings.Contains(err.Error(), "invalid character name") {
+ t.Fatalf("unclear purge error: %v", err)
+ }
+ assertTraversalCanary(t, charCanary)
+ if !h.g.AccountStore.AccountExists("alice") {
+ t.Fatal("purge ran (account file removed) despite the invalid character name")
+ }
+
+ // AccountPath("../alice") would resolve to <dataDir>/players/alice.yaml.
+ accountCanary := filepath.Join(h.dir, "players", "alice.yaml")
+ writeTraversalCanary(t, accountCanary)
+
+ err = h.g.purgeAccountTxn("../alice", nil)
+ if err == nil {
+ t.Fatal("purge accepted a path-traversing account name")
+ }
+ if !strings.Contains(err.Error(), "invalid account name") {
+ t.Fatalf("unclear purge error: %v", err)
+ }
+ assertTraversalCanary(t, accountCanary)
+ if !h.g.AccountStore.AccountExists("alice") {
+ t.Fatal("purge removed the real account despite the invalid account name")
+ }
+}
+
+func TestRenameAccountTxnRejectsPathLikeNames(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("dave")
+
+ // AccountPath("../dave") would resolve to <dataDir>/players/dave.yaml.
+ accountCanary := filepath.Join(h.dir, "players", "dave.yaml")
+ writeTraversalCanary(t, accountCanary)
+
+ err := h.g.renameAccountTxn("dave", "../dave")
+ if err == nil {
+ t.Fatal("rename accepted a path-traversing new account name")
+ }
+ if !strings.Contains(err.Error(), "invalid account name") {
+ t.Fatalf("unclear rename error: %v", err)
+ }
+ assertTraversalCanary(t, accountCanary)
+ if !h.g.AccountStore.AccountExists("dave") {
+ t.Fatal("rename moved the account file despite the invalid new name")
+ }
+
+ // A path-like entry in the account's own character list is rejected too:
+ // the list is joined into CharPath while re-pointing owners.
+ acc, err := h.g.AccountStore.LoadAccount("dave")
+ if err != nil {
+ t.Fatal(err)
+ }
+ acc.Characters = []string{"../../escape"}
+ if err := h.g.AccountStore.SaveAccount(acc); err != nil {
+ t.Fatal(err)
+ }
+ charCanary := filepath.Join(h.dir, "escape.yaml")
+ writeTraversalCanary(t, charCanary)
+
+ err = h.g.renameAccountTxn("dave", "dave2")
+ if err == nil {
+ t.Fatal("rename accepted an account whose character list is path-like")
+ }
+ if !strings.Contains(err.Error(), "invalid character name") {
+ t.Fatalf("unclear rename error: %v", err)
+ }
+ assertTraversalCanary(t, charCanary)
+ if !h.g.AccountStore.AccountExists("dave") {
+ t.Fatal("rename moved the account file despite the invalid character list")
+ }
+ if h.g.AccountStore.AccountExists("dave2") {
+ t.Fatal("rename created the new account name despite the invalid character list")
+ }
+}
+
+func TestCharacterTxnsRejectPathLikeNames(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+
+ // CharPath("../escape") resolves to <dataDir>/players/escape.yaml.
+ canary := filepath.Join(h.dir, "players", "escape.yaml")
+ writeTraversalCanary(t, canary)
+
+ if _, err := h.g.createCharacterTxn("alice", "../escape", 1); err == nil {
+ t.Fatal("create accepted a path-traversing character name")
+ } else if !strings.Contains(err.Error(), "invalid character name") {
+ t.Fatalf("unclear create error: %v", err)
+ }
+ assertTraversalCanary(t, canary)
+
+ if _, err := h.g.createCharacterTxn("../alice", "bob", 1); err == nil {
+ t.Fatal("create accepted a path-traversing account name")
+ } else if !strings.Contains(err.Error(), "invalid account name") {
+ t.Fatalf("unclear create error: %v", err)
+ }
+ assertTraversalCanary(t, canary)
+
+ if err := h.g.renameCharacterTxn("alice", "../escape", "bob"); err == nil {
+ t.Fatal("rename accepted a path-traversing old character name")
+ } else if !strings.Contains(err.Error(), "invalid character name") {
+ t.Fatalf("unclear rename error: %v", err)
+ }
+ assertTraversalCanary(t, canary)
+
+ if err := h.g.deleteCharacterTxn("alice", "../escape"); err == nil {
+ t.Fatal("delete accepted a path-traversing character name")
+ } else if !strings.Contains(err.Error(), "invalid character name") {
+ t.Fatalf("unclear delete error: %v", err)
+ }
+ assertTraversalCanary(t, canary)
+
+ // Valid names still work: the validation must not block the normal path.
+ p, err := h.g.createCharacterTxn("alice", "bob", 1)
+ if err != nil {
+ t.Fatalf("create with a valid name failed: %v", err)
+ }
+ if p.Name != "bob" {
+ t.Fatalf("created character name = %q, want bob", p.Name)
+ }
+ acc, err := h.g.AccountStore.LoadAccount("alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(acc.Characters) != 1 || acc.Characters[0] != "bob" {
+ t.Fatalf("account character list = %v, want [bob]", acc.Characters)
+ }
+ if err := h.g.deleteCharacterTxn("alice", "bob"); err != nil {
+ t.Fatalf("delete with a valid name failed: %v", err)
+ }
+ if _, err := h.g.AccountStore.LoadCharacter("bob"); err == nil {
+ t.Fatal("delete left the character file behind")
+ }
+}
+
+// TestPurgeAccountTxnAcceptsSeededNames is the positive control: the existing
+// login/session path (a genuinely registered account with its own character)
+// still purges.
+func TestPurgeAccountTxnAcceptsSeededNames(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+
+ if err := h.g.purgeAccountTxn("alice", []string{"alice"}); err != nil {
+ t.Fatalf("purge with valid names failed: %v", err)
+ }
+ if h.g.AccountStore.AccountExists("alice") {
+ t.Fatal("purge left the account file behind")
+ }
+ if _, err := h.g.AccountStore.LoadCharacter("alice"); err == nil {
+ t.Fatal("purge left the character file behind")
+ }
+}
diff --git a/internal/game/hardening_admin_player_file_online_test.go b/internal/game/hardening_admin_player_file_online_test.go
new file mode 100644
index 0000000..f0c64f3
--- /dev/null
+++ b/internal/game/hardening_admin_player_file_online_test.go
@@ -0,0 +1,70 @@
+package game
+
+// Regression coverage for Game.AdminPlayerFileOnline, the hook the admin raw
+// file editor uses (admin.AdminServer.SetPlayerFileOnlineCheck) to refuse
+// editing a character/account file whose character is live.
+
+import "testing"
+
+func TestAdminPlayerFileOnlineRefusesLivePlayerFiles(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+ // A second character of the same account, offline, so the account check
+ // cannot pass merely because the file exists.
+ h.seedCharacter("alicia", "alice")
+ h.seedAccount("carol")
+ h.seedCharacter("carol", "carol")
+
+ sess, _ := h.connect("alice")
+ if sess.Player == nil || sess.Player.Name != "alice" {
+ t.Fatalf("alice is not registered as online: %+v", sess.Player)
+ }
+
+ cases := []struct {
+ name string
+ path string
+ want bool
+ }{
+ {"online character", "players/characters/alice.yaml", true},
+ {"offline character of a live account", "players/characters/alicia.yaml", false},
+ {"offline character", "players/characters/carol.yaml", false},
+ {"unknown character", "players/characters/nobody.yaml", false},
+ {"account of an online character", "players/accounts/alice.yaml", true},
+ {"account with no online character", "players/accounts/carol.yaml", false},
+ {"unknown account", "players/accounts/nobody.yaml", false},
+ {"leading slash", "/players/characters/alice.yaml", true},
+ {"data/ prefix", "data/players/accounts/alice.yaml", true},
+ {"non-yaml file", "players/characters/alice.txt", false},
+ {"bare directory", "players/characters", false},
+ {"nested path", "players/characters/sub/alice.yaml", false},
+ {"non-player file", "rooms/1.yaml", false},
+ {"empty path", "", false},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := h.g.AdminPlayerFileOnline(tc.path); got != tc.want {
+ t.Fatalf("AdminPlayerFileOnline(%q) = %v, want %v", tc.path, got, tc.want)
+ }
+ })
+ }
+}
+
+// TestAdminPlayerFileOnlineIgnoresMenuOnlySession pins that an account sitting
+// in the character menu is NOT treated as an online character: only a live
+// character (a loggedInChars entry) makes its file, or its account's file,
+// editable-refused for that reason.
+func TestAdminPlayerFileOnlineIgnoresMenuOnlySession(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("dave")
+ h.seedCharacter("dave", "dave")
+ h.connectMenu("dave")
+
+ if h.g.AdminPlayerFileOnline("players/characters/dave.yaml") {
+ t.Fatal("a menu session was treated as a live character")
+ }
+ if h.g.AdminPlayerFileOnline("players/accounts/dave.yaml") {
+ t.Fatal("a menu session was treated as a live account character")
+ }
+}
diff --git a/internal/game/hardening_ammo_unequip_test.go b/internal/game/hardening_ammo_unequip_test.go
new file mode 100644
index 0000000..2250b83
--- /dev/null
+++ b/internal/game/hardening_ammo_unequip_test.go
@@ -0,0 +1,181 @@
+package game
+
+// Regression: returning the equipped ammo stack to the inventory must never
+// delete it. The equipment entry may only be cleared once the stack has been
+// merged into an inventory stack or placed in a free slot, so a full
+// inventory refuses the swap/removal instead of losing the ammo. The previous
+// code deleted Equipment[SlotAmmo] and zeroed AmmoQty unconditionally.
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+const (
+ hardeningArrowsID = "test_arrows"
+ hardeningBoltsID = "test_bolts"
+)
+
+// seedAmmoItems installs two stackable ammo items so a swap has a target.
+func seedAmmoItems(t *testing.T, h *harness) {
+ t.Helper()
+ writeSeedFile(t, filepath.Join(h.dir, "items", hardeningArrowsID+".yaml"),
+ []byte("id: test_arrows\nname: test arrows\nstackable: true\nequipment:\n type: ammo\n slot: ammo\n"))
+ writeSeedFile(t, filepath.Join(h.dir, "items", hardeningBoltsID+".yaml"),
+ []byte("id: test_bolts\nname: test bolts\nstackable: true\nequipment:\n type: ammo\n slot: ammo\n"))
+}
+
+// fillAllSlots occupies every inventory slot and returns the session used by
+// the wear/remove paths.
+func fillAllSlots(p *player.Player) *net.Session {
+ for i := 0; i < 28; i++ {
+ p.SetInvSlot(i, &player.InventorySlot{ItemID: "filler", Quantity: 1})
+ }
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
+ return sess
+}
+
+func TestUnequipAmmoSwapKeepsStackWhenInventoryFull(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ seedAmmoItems(t, h)
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ sess := fillAllSlots(p)
+ p.SetInvSlot(3, &player.InventorySlot{ItemID: hardeningBoltsID, Quantity: 7})
+ p.Equipment[item.SlotAmmo] = hardeningArrowsID
+ p.AmmoQty = 42
+
+ h.g.doWear(sess, "test bolts")
+
+ if got := p.Equipment[item.SlotAmmo]; got != hardeningArrowsID {
+ t.Errorf("equipped ammo = %q, want %q: a refused swap must not clear equipment", got, hardeningArrowsID)
+ }
+ if p.AmmoQty != 42 {
+ t.Errorf("AmmoQty = %d, want 42", p.AmmoQty)
+ }
+ bolts := p.InvSlot(3)
+ if bolts == nil || bolts.ItemID != hardeningBoltsID || bolts.Quantity != 7 {
+ t.Errorf("source stack changed by a refused swap: %+v", bolts)
+ }
+ if out := sess.Conn.(*chanConn).Bytes(); !strings.Contains(string(out), "inventory is full") {
+ t.Errorf("expected a full-inventory message, got %q", out)
+ }
+ })
+}
+
+func TestUnequipAmmoSwapMovesStackToFreeSlot(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ seedAmmoItems(t, h)
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ sess := fillAllSlots(p)
+ p.SetInvSlot(0, nil)
+ p.SetInvSlot(3, &player.InventorySlot{ItemID: hardeningBoltsID, Quantity: 7})
+ p.Equipment[item.SlotAmmo] = hardeningArrowsID
+ p.AmmoQty = 42
+
+ h.g.doWear(sess, "test bolts")
+
+ if got := p.Equipment[item.SlotAmmo]; got != hardeningBoltsID {
+ t.Fatalf("equipped ammo = %q, want %q", got, hardeningBoltsID)
+ }
+ if p.AmmoQty != 7 {
+ t.Errorf("AmmoQty = %d, want 7", p.AmmoQty)
+ }
+ stored := p.InvSlot(0)
+ if stored == nil || stored.ItemID != hardeningArrowsID || stored.Quantity != 42 {
+ t.Fatalf("unequipped arrows not stored in the free slot: %+v", stored)
+ }
+ })
+}
+
+func TestRemoveAllAmmoKeepsStackWhenInventoryFull(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ seedAmmoItems(t, h)
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ sess := fillAllSlots(p)
+ p.Equipment[item.SlotAmmo] = hardeningArrowsID
+ p.AmmoQty = 42
+
+ h.g.removeAllAmmo(sess, p, hardeningArrowsID)
+
+ if got := p.Equipment[item.SlotAmmo]; got != hardeningArrowsID {
+ t.Errorf("equipped ammo = %q, want %q", got, hardeningArrowsID)
+ }
+ if p.AmmoQty != 42 {
+ t.Errorf("AmmoQty = %d, want 42", p.AmmoQty)
+ }
+ out := string(sess.Conn.(*chanConn).Bytes())
+ if strings.Contains(out, "You remove") {
+ t.Errorf("nothing was removed but the removal was reported: %q", out)
+ }
+ if !strings.Contains(out, "Nowhere to put that!") {
+ t.Errorf("expected a failure message, got %q", out)
+ }
+ })
+}
+
+func TestRemoveAllKeepsAmmoWhenInventoryFull(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ seedAmmoItems(t, h)
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ sess := fillAllSlots(p)
+ p.Equipment[item.SlotAmmo] = hardeningArrowsID
+ p.AmmoQty = 42
+
+ h.g.doRemoveAll(sess)
+
+ if got := p.Equipment[item.SlotAmmo]; got != hardeningArrowsID || p.AmmoQty != 42 {
+ t.Errorf("remove all lost the equipped ammo: %q x%d", got, p.AmmoQty)
+ }
+ if got := p.CountItem(hardeningArrowsID); got != 0 {
+ t.Errorf("remove all invented %d arrows", got)
+ }
+ })
+}
+
+func TestRemoveAllAmmoMovesStackToFreeSlot(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ seedAmmoItems(t, h)
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ sess := fillAllSlots(p)
+ p.SetInvSlot(0, nil)
+ p.Equipment[item.SlotAmmo] = hardeningArrowsID
+ p.AmmoQty = 42
+
+ h.g.removeAllAmmo(sess, p, hardeningArrowsID)
+
+ if _, ok := p.Equipment[item.SlotAmmo]; ok {
+ t.Error("equipment entry survived a successful removal")
+ }
+ if p.AmmoQty != 0 {
+ t.Errorf("AmmoQty = %d, want 0", p.AmmoQty)
+ }
+ stored := p.InvSlot(0)
+ if stored == nil || stored.ItemID != hardeningArrowsID || stored.Quantity != 42 {
+ t.Fatalf("removed arrows not stored in the free slot: %+v", stored)
+ }
+ if out := string(sess.Conn.(*chanConn).Bytes()); !strings.Contains(out, "You remove") {
+ t.Errorf("removal not reported: %q", out)
+ }
+ })
+}
diff --git a/internal/game/hardening_casino_entropy_wiring_test.go b/internal/game/hardening_casino_entropy_wiring_test.go
new file mode 100644
index 0000000..4f7c75e
--- /dev/null
+++ b/internal/game/hardening_casino_entropy_wiring_test.go
@@ -0,0 +1,72 @@
+package game
+
+// Closure regressions for the casino RNG hardening: the entropy-seeded
+// constructor must be the one PRODUCTION wires, not merely available.
+
+import (
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// TestProductionCasinoSeedIsEntropy pins the production wiring: game.New must
+// build its casino manager with casino.NewManagerEntropy, and no production
+// file may call casino.NewManager(seed). That constructor exists only as the
+// deterministic test seam, because a wall-clock seed makes every shoe, reel,
+// and spin stream reconstructible from the startup nanosecond.
+//
+// The manager's seed is unexported and deliberately not observable through the
+// casino API (NewManagerEntropy and NewManager are otherwise
+// indistinguishable), so this is a source-level guard: it is the only place
+// the choice of constructor can be pinned. It fails on a revert to
+// NewManager(time.Now().UnixNano()).
+func TestProductionCasinoSeedIsEntropy(t *testing.T) {
+ root := filepath.Join("..", "..")
+ sawEntropyCall := false
+ err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ name := d.Name()
+ // Skip VCS internals, generated/test data, and the persisted
+ // game data (which is not Go source at all).
+ if path != root && (strings.HasPrefix(name, ".") || name == "testdata" || name == "vendor") {
+ return fs.SkipDir
+ }
+ return nil
+ }
+ if !strings.HasSuffix(d.Name(), ".go") || strings.HasSuffix(d.Name(), "_test.go") {
+ return nil
+ }
+ data, readErr := os.ReadFile(path)
+ if readErr != nil {
+ return readErr
+ }
+ rel, relErr := filepath.Rel(root, path)
+ if relErr != nil {
+ rel = path
+ }
+ for i, line := range strings.Split(string(data), "\n") {
+ trimmed := strings.TrimSpace(line)
+ if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "*") {
+ continue // prose about the seam, not a call
+ }
+ if strings.Contains(trimmed, "casino.NewManager(") {
+ t.Errorf("%s:%d calls the deterministic casino.NewManager(seed) outside tests: %s", rel, i+1, trimmed)
+ }
+ if strings.Contains(trimmed, "casino.NewManagerEntropy()") {
+ sawEntropyCall = true
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("walk module: %v", err)
+ }
+ if !sawEntropyCall {
+ t.Fatal("no production call to casino.NewManagerEntropy() found: the casino RNG entropy fix is not wired")
+ }
+}
diff --git a/internal/game/hardening_casino_quit_autobet_test.go b/internal/game/hardening_casino_quit_autobet_test.go
new file mode 100644
index 0000000..34f36e7
--- /dev/null
+++ b/internal/game/hardening_casino_quit_autobet_test.go
@@ -0,0 +1,75 @@
+package game
+
+import (
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+)
+
+// Quitting at a slot machine must take one command: a machine between spins
+// has no committed stake, so "quit" stops autobet AND frees the seat instead
+// of reporting the player as still playing (the old flow needed a second
+// quit and warned about a forfeit that could not happen).
+
+func TestQuitAtMachineStopsAutobetAndLeavesInOneAttempt(t *testing.T) {
+ g, sess := quitTestGame(t)
+ cfg := casino.MachineConfig{ID: "slots", Game: casino.GameSlots, MinBet: 1, MaxBet: 100, SpinTicks: 1}
+ g.Casino.JoinMachine(1, cfg, "alice")
+ wallet := casinoWallet{g: g, p: sess.Player}
+ if events := g.Casino.SetMachineBet(1, "slots", "alice", 10, wallet); len(events) == 0 {
+ t.Fatal("bet produced no events")
+ }
+ // Let the bet's spin finish: the machine is now between spins.
+ for i := 0; i < 30 && g.Casino.MachineBusy("alice"); i++ {
+ g.Casino.Tick()
+ }
+ if events := g.Casino.EnableMachineAutoBet(1, "slots", "alice", wallet); len(events) != 0 {
+ t.Fatalf("enabling autobet emitted events: %+v", events)
+ }
+
+ conn := sess.Conn.(*casinoTestConn)
+ conn.data = nil
+ g.doQuit(sess)
+ out := string(conn.data)
+
+ if !g.quitResting() {
+ t.Fatalf("quit between spins did not start the rest countdown: %q", out)
+ }
+ if _, playing := g.Casino.Participation("alice"); playing {
+ t.Fatalf("quit left the player at the machine, requiring a second quit: %q", out)
+ }
+ if !strings.Contains(out, "Autobet stopped") {
+ t.Fatalf("quit did not stop autobet: %q", out)
+ }
+ if strings.Contains(out, "WARNING") || strings.Contains(out, "forfeit") {
+ t.Fatalf("quit warned about a stake that does not exist: %q", out)
+ }
+}
+
+// A machine mid-spin really does hold a committed stake, so quit must still
+// refuse it (with the forfeit warning) rather than silently discard the spin.
+func TestQuitMidSpinStillWarnsAboutTheCommittedStake(t *testing.T) {
+ g, sess := quitTestGame(t)
+ cfg := casino.MachineConfig{ID: "slots", Game: casino.GameSlots, MinBet: 1, MaxBet: 100, SpinTicks: 50}
+ g.Casino.JoinMachine(1, cfg, "alice")
+ wallet := casinoWallet{g: g, p: sess.Player}
+ if events := g.Casino.SetMachineBet(1, "slots", "alice", 10, wallet); len(events) == 0 {
+ t.Fatal("bet produced no events")
+ }
+
+ conn := sess.Conn.(*casinoTestConn)
+ conn.data = nil
+ g.doQuit(sess)
+ out := string(conn.data)
+
+ if g.quitResting() {
+ t.Fatal("quit started the countdown with a spin in flight")
+ }
+ if _, playing := g.Casino.Participation("alice"); !playing {
+ t.Fatal("quit freed the machine mid-spin")
+ }
+ if !strings.Contains(out, "forfeit") {
+ t.Fatalf("expected the forfeit warning for a committed stake, got %q", out)
+ }
+}
diff --git a/internal/game/hardening_craft_combat_helpers_test.go b/internal/game/hardening_craft_combat_helpers_test.go
new file mode 100644
index 0000000..cfd860f
--- /dev/null
+++ b/internal/game/hardening_craft_combat_helpers_test.go
@@ -0,0 +1,40 @@
+package game
+
+// Shared helpers for the crafting/combat hardening regressions. The tests
+// build a real Game (owner loop, stores, craft index) over a temp data dir
+// but deliberately do NOT start a background ticker, so each test drives
+// production/combat cycles with explicit calls and asserts without racing
+// Game.AdvanceActions.
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "thehouseoficarus/internal/config"
+ "thehouseoficarus/internal/net"
+)
+
+// newHardeningGame builds a real Game over dir with an owner loop but no
+// background ticker. stopOwner is registered for cleanup so the owner
+// goroutine does not leak.
+func newHardeningGame(t *testing.T, dir string) *Game {
+ t.Helper()
+ colors := &config.ColorsConfig{}
+ g := New(dir, colors, colors, config.ValidationConfig{}, 1)
+ g.SetHub(net.NewHub())
+ t.Cleanup(g.stopOwner)
+ return g
+}
+
+// writeDataFile writes subdir/name under dir, creating parent directories.
+func writeDataFile(t *testing.T, dir, subdir, name, content string) {
+ t.Helper()
+ path := filepath.Join(dir, subdir, name)
+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+ t.Fatalf("mkdir %s: %v", subdir, err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0644); err != nil {
+ t.Fatalf("write %s: %v", path, err)
+ }
+}
diff --git a/internal/game/hardening_craft_combine_fletch_test.go b/internal/game/hardening_craft_combine_fletch_test.go
new file mode 100644
index 0000000..f3f6832
--- /dev/null
+++ b/internal/game/hardening_craft_combine_fletch_test.go
@@ -0,0 +1,260 @@
+package game
+
+// Regression: combine and fletch must honor the craft fields production
+// already honors — roll craft.Success when defined, create craft.Fail on
+// failure, award craft.XP, and place ingredient byproducts. Shipped combine
+// and fletch recipes set xp:0 and define no fail/success, so these paths
+// must be no-ops for them (asserted indirectly by the existing production
+// regressions).
+
+import (
+ "testing"
+
+ "thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+const combineSuccessYAML = `id: test_combine_out
+name: test combine out
+stackable: false
+value: 5
+craft:
+ - type: combine
+ ingredients:
+ - items:
+ - combine_part
+ quantity: 1
+ byproducts:
+ - combine_scrap
+ success:
+ base: 1.0
+ per_level: 0
+ cap: 1.0
+ xp: 25
+ output_qty: 1
+ ticks_per_cycle: 1
+`
+
+const combineFailYAML = `id: test_combine_fail_out
+name: test combine fail out
+stackable: false
+value: 5
+craft:
+ - type: combine
+ ingredients:
+ - items:
+ - combine_fail_part
+ quantity: 1
+ success:
+ base: 0
+ per_level: 0
+ cap: 0
+ fail: combine_ash
+ output_qty: 1
+ ticks_per_cycle: 1
+`
+
+const fletchSuccessYAML = `id: test_fletch_out
+name: test fletch out
+stackable: false
+value: 5
+craft:
+ - type: fletching
+ ingredients:
+ - items:
+ - fletch_part
+ quantity: 1
+ byproducts:
+ - fletch_scrap
+ success:
+ base: 1.0
+ per_level: 0
+ cap: 1.0
+ xp: 30
+ output_qty: 1
+ ticks_per_cycle: 1
+`
+
+const fletchFailYAML = `id: test_fletch_fail_out
+name: test fletch fail out
+stackable: false
+value: 5
+craft:
+ - type: fletching
+ ingredients:
+ - items:
+ - fletch_fail_part
+ quantity: 1
+ success:
+ base: 0
+ per_level: 0
+ cap: 0
+ fail: fletch_ash
+ output_qty: 1
+ ticks_per_cycle: 1
+`
+
+func seedCraftFieldItems(t *testing.T, dir string) {
+ t.Helper()
+ for _, id := range []string{
+ "combine_part", "combine_scrap", "combine_fail_part", "combine_ash",
+ "fletch_part", "fletch_scrap", "fletch_fail_part", "fletch_ash",
+ } {
+ writeDataFile(t, dir, "items", id+".yaml", "id: "+id+"\nname: "+id+"\nstackable: false\nvalue: 1\n")
+ }
+ writeDataFile(t, dir, "items", "test_combine_out.yaml", combineSuccessYAML)
+ writeDataFile(t, dir, "items", "test_combine_fail_out.yaml", combineFailYAML)
+ writeDataFile(t, dir, "items", "test_fletch_out.yaml", fletchSuccessYAML)
+ writeDataFile(t, dir, "items", "test_fletch_fail_out.yaml", fletchFailYAML)
+}
+
+func TestCombineHonorsSuccessXPAndByproducts(t *testing.T) {
+ dir := t.TempDir()
+ seedWorld(t, dir)
+ seedCraftFieldItems(t, dir)
+ g := newHardeningGame(t, dir)
+
+ g.RunOnOwner(func() {
+ p := player.New("alice")
+ p.RoomID = 1
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: "combine_part", Quantity: 1})
+
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
+
+ def := mustLoadItem(t, g, "test_combine_out")
+ g.startCombine(sess, p, def, def.FirstCraft(), 1)
+ if p.Action == nil {
+ t.Fatal("combine did not start")
+ }
+ g.advanceCombine(sess, p) // phase 0 -> 1
+ g.advanceCombine(sess, p) // phase 1 -> 2
+ g.advanceCombine(sess, p) // phase 2: consume + output
+
+ if got := p.CountItem("test_combine_out"); got != 1 {
+ t.Fatalf("combine output = %d, want 1", got)
+ }
+ if got := p.CountItem("combine_scrap"); got != 1 {
+ t.Errorf("combine byproduct = %d, want 1", got)
+ }
+ if got := p.CountItem("combine_part"); got != 0 {
+ t.Errorf("combine ingredient = %d, want 0 (consumed)", got)
+ }
+ if got := p.Skills[player.Crafting]; got != 25 {
+ t.Errorf("combine xp = %d, want 25 on crafting", got)
+ }
+ if _, ok := p.Skills[player.SkillName("combine")]; ok {
+ t.Error("combine recipes must not persist a phantom 'combine' skill")
+ }
+ })
+}
+
+func TestCombineCreatesFailOnFailure(t *testing.T) {
+ dir := t.TempDir()
+ seedWorld(t, dir)
+ seedCraftFieldItems(t, dir)
+ g := newHardeningGame(t, dir)
+
+ g.RunOnOwner(func() {
+ p := player.New("alice")
+ p.RoomID = 1
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: "combine_fail_part", Quantity: 1})
+
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
+
+ def := mustLoadItem(t, g, "test_combine_fail_out")
+ g.startCombine(sess, p, def, def.FirstCraft(), 1)
+ g.advanceCombine(sess, p)
+ g.advanceCombine(sess, p)
+ g.advanceCombine(sess, p)
+
+ if got := p.CountItem("test_combine_fail_out"); got != 0 {
+ t.Errorf("combine output = %d, want 0 on a guaranteed failure", got)
+ }
+ if got := p.CountItem("combine_ash"); got != 1 {
+ t.Errorf("combine fail product = %d, want 1", got)
+ }
+ if got := p.CountItem("combine_fail_part"); got != 0 {
+ t.Errorf("combine ingredient = %d, want 0 (consumed)", got)
+ }
+ })
+}
+
+func TestFletchHonorsSuccessXPAndByproducts(t *testing.T) {
+ dir := t.TempDir()
+ seedWorld(t, dir)
+ seedCraftFieldItems(t, dir)
+ g := newHardeningGame(t, dir)
+
+ g.RunOnOwner(func() {
+ p := player.New("alice")
+ p.RoomID = 1
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: "fletch_part", Quantity: 1})
+
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
+
+ def := mustLoadItem(t, g, "test_fletch_out")
+ g.startFletchAction(sess, p, def, 1)
+ if p.BackgroundAction == nil {
+ t.Fatal("fletch did not start")
+ }
+ g.advanceFletch(sess, p) // phase 0 -> 1
+ g.advanceFletch(sess, p) // phase 1: consume + output
+
+ if got := p.CountItem("test_fletch_out"); got != 1 {
+ t.Fatalf("fletch output = %d, want 1", got)
+ }
+ if got := p.CountItem("fletch_scrap"); got != 1 {
+ t.Errorf("fletch byproduct = %d, want 1", got)
+ }
+ if got := p.CountItem("fletch_part"); got != 0 {
+ t.Errorf("fletch ingredient = %d, want 0 (consumed)", got)
+ }
+ if got := p.Skills[player.Fletching]; got != 30 {
+ t.Errorf("fletch xp = %d, want 30", got)
+ }
+ })
+}
+
+func TestFletchCreatesFailOnFailure(t *testing.T) {
+ dir := t.TempDir()
+ seedWorld(t, dir)
+ seedCraftFieldItems(t, dir)
+ g := newHardeningGame(t, dir)
+
+ g.RunOnOwner(func() {
+ p := player.New("alice")
+ p.RoomID = 1
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: "fletch_fail_part", Quantity: 1})
+
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
+
+ def := mustLoadItem(t, g, "test_fletch_fail_out")
+ g.startFletchAction(sess, p, def, 1)
+ g.advanceFletch(sess, p)
+ g.advanceFletch(sess, p)
+
+ if got := p.CountItem("test_fletch_fail_out"); got != 0 {
+ t.Errorf("fletch output = %d, want 0 on a guaranteed failure", got)
+ }
+ if got := p.CountItem("fletch_ash"); got != 1 {
+ t.Errorf("fletch fail product = %d, want 1", got)
+ }
+ if got := p.CountItem("fletch_fail_part"); got != 0 {
+ t.Errorf("fletch ingredient = %d, want 0 (consumed)", got)
+ }
+ })
+}
+
+func mustLoadItem(t *testing.T, g *Game, id string) *item.ItemDef {
+ t.Helper()
+ def, err := g.ItemStore.Load(id)
+ if err != nil {
+ t.Fatalf("load item %s: %v", id, err)
+ }
+ return def
+}
diff --git a/internal/game/hardening_craft_menu_skill_test.go b/internal/game/hardening_craft_menu_skill_test.go
new file mode 100644
index 0000000..9b4e8c7
--- /dev/null
+++ b/internal/game/hardening_craft_menu_skill_test.go
@@ -0,0 +1,91 @@
+package game
+
+// Regression: the multi-match production menu (craft <name>/mix/construct)
+// must pass the real lowercase recipe type as PendingSkill. It previously
+// passed the capitalised display label ("Crafting"), which
+// handleProductChoice looks up with player.SkillName — a map miss that
+// resolves to level 1, so every recipe above level 1 was refused even for a
+// level-50 crafter.
+
+import (
+ "testing"
+
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+const craftMenuIngotYAML = `id: test_ingot
+name: test ingot
+stackable: true
+value: 1
+`
+
+const craftMenuSwordYAML = `id: test_iron_sword
+name: iron sword
+stackable: false
+value: 5
+craft:
+ - type: crafting
+ ingredients:
+ - items:
+ - test_ingot
+ quantity: 1
+ level: 1
+ output_qty: 1
+`
+
+const craftMenuPlateYAML = `id: test_iron_plate
+name: iron plate
+stackable: false
+value: 50
+craft:
+ - type: crafting
+ ingredients:
+ - items:
+ - test_ingot
+ quantity: 1
+ level: 40
+ output_qty: 1
+`
+
+func TestCraftMenuResolvesRealSkillForMultiMatch(t *testing.T) {
+ dir := t.TempDir()
+ seedWorld(t, dir)
+ writeDataFile(t, dir, "items", "test_ingot.yaml", craftMenuIngotYAML)
+ writeDataFile(t, dir, "items", "test_iron_sword.yaml", craftMenuSwordYAML)
+ writeDataFile(t, dir, "items", "test_iron_plate.yaml", craftMenuPlateYAML)
+
+ g := newHardeningGame(t, dir)
+
+ g.RunOnOwner(func() {
+ p := player.New("alice")
+ p.RoomID = 1
+ p.Skills[player.Crafting] = player.XPForLevel(50)
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: "test_ingot", Quantity: 1})
+
+ conn := newChanConn()
+ sess := &net.Session{Conn: conn, Player: p}
+ sess.SetState(net.StateGame)
+
+ // "iron" matches both products, forcing the multi-match menu path.
+ g.doCraft(sess, "iron")
+
+ if sess.PendingSkill != "crafting" {
+ t.Fatalf("PendingSkill = %q, want %q (a display label is not a skill)",
+ sess.PendingSkill, "crafting")
+ }
+ if len(sess.PendingMenu) != 2 {
+ t.Fatalf("menu entries = %d, want 2 (iron sword, iron plate)", len(sess.PendingMenu))
+ }
+
+ // Choice 2 is iron plate (level 40, the only row above level 1).
+ g.handleProductChoice(sess, "2")
+
+ if p.Action == nil {
+ t.Fatalf("level-50 crafter could not start the level-40 recipe; output=%q", conn.Bytes())
+ }
+ if p.Action.TargetID != "test_iron_plate" {
+ t.Fatalf("started recipe %q, want test_iron_plate; output=%q", p.Action.TargetID, conn.Bytes())
+ }
+ })
+}
diff --git a/internal/game/hardening_craft_message_item_test.go b/internal/game/hardening_craft_message_item_test.go
new file mode 100644
index 0000000..b516852
--- /dev/null
+++ b/internal/game/hardening_craft_message_item_test.go
@@ -0,0 +1,85 @@
+package game
+
+// Regression: a %i<n> success message must name the ingredient alternative
+// the player actually used. Production resolved the message AFTER
+// consumeIngredients, so p.HasItem was false for everything consumed and
+// resolveCraftVar fell back to the entry's first alternative — naming the
+// item the player never touched when an entry lists several (bucket vs jug
+// of water).
+
+import (
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+const craftMessageOutputYAML = `id: test_water_potion
+name: test water potion
+stackable: false
+value: 3
+craft:
+ - type: crafting
+ ingredients:
+ - items:
+ - bucket_of_water
+ - jug_of_water
+ quantity: 1
+ byproducts:
+ - empty_bucket
+ - empty_jug
+ success_message: "You mix the %i1 into a %n."
+ output_qty: 1
+`
+
+func TestCraftSuccessMessageNamesConsumedAlternative(t *testing.T) {
+ dir := t.TempDir()
+ seedWorld(t, dir)
+ writeDataFile(t, dir, "items", "bucket_of_water.yaml", "id: bucket_of_water\nname: bucket of water\nstackable: false\nvalue: 1\n")
+ writeDataFile(t, dir, "items", "jug_of_water.yaml", "id: jug_of_water\nname: jug of water\nstackable: false\nvalue: 1\n")
+ writeDataFile(t, dir, "items", "empty_bucket.yaml", "id: empty_bucket\nname: empty bucket\nstackable: false\nvalue: 1\n")
+ writeDataFile(t, dir, "items", "empty_jug.yaml", "id: empty_jug\nname: empty jug\nstackable: false\nvalue: 1\n")
+ writeDataFile(t, dir, "items", "test_water_potion.yaml", craftMessageOutputYAML)
+
+ g := newHardeningGame(t, dir)
+
+ g.RunOnOwner(func() {
+ p := player.New("alice")
+ p.RoomID = 1
+ // Only the jug is present: the entered alternative is bucket_of_water,
+ // so a post-consumption p.HasItem fallback would name the bucket.
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: "jug_of_water", Quantity: 1})
+
+ conn := newChanConn()
+ sess := &net.Session{Conn: conn, Player: p}
+ sess.SetState(net.StateGame)
+
+ def, err := g.ItemStore.Load("test_water_potion")
+ if err != nil {
+ t.Fatalf("load recipe: %v", err)
+ }
+
+ g.startProductionFromItem(sess, p, def, 1)
+ if p.Action == nil {
+ t.Fatalf("production did not start; output=%q", conn.Bytes())
+ }
+ g.advanceProduction(sess, p) // phase 0 -> 1
+ g.advanceProduction(sess, p) // phase 1: consume + produce
+
+ if got := p.CountItem("test_water_potion"); got != 1 {
+ t.Fatalf("potion = %d, want 1; output=%q", got, conn.Bytes())
+ }
+ if got := p.CountItem("empty_jug"); got != 1 {
+ t.Fatalf("empty_jug = %d, want 1 (byproduct for the consumed jug)", got)
+ }
+
+ out := string(conn.Bytes())
+ if !strings.Contains(out, "jug of water") {
+ t.Errorf("success message does not name the consumed jug of water: %q", out)
+ }
+ if strings.Contains(out, "bucket of water") {
+ t.Errorf("success message names the bucket the player never used: %q", out)
+ }
+ })
+}
diff --git a/internal/game/hardening_credit_cap_test.go b/internal/game/hardening_credit_cap_test.go
new file mode 100644
index 0000000..872c746
--- /dev/null
+++ b/internal/game/hardening_credit_cap_test.go
@@ -0,0 +1,105 @@
+package game
+
+// Regression: credit pickups must never destroy value at the economy cap.
+// Only the balance headroom below casino.MaxCredits may be removed from the
+// ground; the remainder stays there. The previous code removed the whole
+// ground stack and then let grantCredits clamp the excess away.
+
+import (
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/net"
+)
+
+func TestPickupCreditsAtCapLeavesRemainderOnGround(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.Credits = casino.MaxCredits - 5
+ h.g.World.AddGroundItem(p.RoomID, "credits", 20)
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
+
+ h.g.pickupCredits(sess, p, "credits")
+
+ if p.Credits != casino.MaxCredits {
+ t.Errorf("balance = %d, want the cap %d", p.Credits, casino.MaxCredits)
+ }
+ if got := h.g.World.GroundItems(p.RoomID)["credits"]; got != 15 {
+ t.Errorf("ground credits = %d, want 15 left behind", got)
+ }
+ })
+}
+
+func TestPickupCreditsAtCapDestroysNothing(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.Credits = casino.MaxCredits
+ h.g.World.AddGroundItem(p.RoomID, "credits", 20)
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
+
+ h.g.pickupCredits(sess, p, "credits")
+
+ if p.Credits != casino.MaxCredits {
+ t.Errorf("balance = %d, want the cap %d", p.Credits, casino.MaxCredits)
+ }
+ if got := h.g.World.GroundItems(p.RoomID)["credits"]; got != 20 {
+ t.Errorf("ground credits = %d, want the full stack to survive", got)
+ }
+ if out := string(sess.Conn.(*chanConn).Bytes()); !strings.Contains(out, "can't hold any more credits") {
+ t.Errorf("expected a cap message, got %q", out)
+ }
+ })
+}
+
+func TestGetAllCreditsAtCapLeavesRemainderOnGround(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.Credits = casino.MaxCredits - 5
+ h.g.World.AddGroundItem(p.RoomID, "credits", 20)
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
+
+ h.g.doGetAll(sess)
+
+ if p.Credits != casino.MaxCredits {
+ t.Errorf("balance = %d, want the cap %d", p.Credits, casino.MaxCredits)
+ }
+ if got := h.g.World.GroundItems(p.RoomID)["credits"]; got != 15 {
+ t.Errorf("ground credits = %d, want 15 left behind", got)
+ }
+ })
+}
+
+func TestGetAllCreditsAtCapReportsShortfall(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.Credits = casino.MaxCredits
+ h.g.World.AddGroundItem(p.RoomID, "credits", 20)
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
+
+ h.g.doGetAll(sess)
+
+ if got := h.g.World.GroundItems(p.RoomID)["credits"]; got != 20 {
+ t.Errorf("ground credits = %d, want the full stack to survive", got)
+ }
+ out := string(sess.Conn.(*chanConn).Bytes())
+ if !strings.Contains(out, "can't hold any more credits") {
+ t.Errorf("expected a cap message, got %q", out)
+ }
+ if strings.Contains(out, "inventory is full") {
+ t.Errorf("cap shortfall misreported as a full inventory: %q", out)
+ }
+ })
+}
diff --git a/internal/game/hardening_economy_durability_test.go b/internal/game/hardening_economy_durability_test.go
new file mode 100644
index 0000000..55fff50
--- /dev/null
+++ b/internal/game/hardening_economy_durability_test.go
@@ -0,0 +1,272 @@
+package game
+
+// Persistence/economy durability regressions: the economy journal's directory
+// entry must be synced, recovery must order payout evidence by the record's
+// creation order (not by its last-written line), the recovery checkpoint
+// re-anchor must respect the economy cap, the journal-unavailable settlement
+// fallback must only resolve the stakes it settles, and a bank credit move
+// whose save failed must be refused instead of leaving memory ahead of the
+// durable file.
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/player"
+
+ "gopkg.in/yaml.v3"
+)
+
+// waitForConnText blocks until the session's writer flushes text containing
+// substr; the writer goroutine is asynchronous.
+func waitForConnText(t *testing.T, conn *chanConn, substr string) {
+ t.Helper()
+ deadline := time.Now().Add(2 * time.Second)
+ for !conn.Contains(substr) && time.Now().Before(deadline) {
+ time.Sleep(time.Millisecond)
+ }
+ if !conn.Contains(substr) {
+ t.Errorf("expected %q in session output; got=%s", substr, conn.Bytes())
+ }
+}
+
+// TestRecoveryIgnoresRewrittenOlderPayoutAsStakeEvidence: payout evidence for
+// a stake is ordered by the payout's CREATION sequence, not by the line of its
+// latest write. A payout created before the stake that is rewritten afterwards
+// (a settle marker, or a recovery checkpoint re-anchor) must not be read as
+// the stake's settlement — otherwise recovery settles the stake without
+// refunding it and the wager is silently lost.
+func TestRecoveryIgnoresRewrittenOlderPayoutAsStakeEvidence(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.Credits = 1000
+ if err := h.g.AccountStore.SaveCharacter(p); err != nil {
+ t.Error(err)
+ return
+ }
+ // An older payout record (created first) ...
+ old, err := h.g.Economy.start("payout", p, 0, 20, 1000, 1020, 0, 0)
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ // ... then the stake ...
+ if _, err := h.g.Economy.start("wager", p, 0, 50, 1000, 950, 0, 0); err != nil {
+ t.Error(err)
+ return
+ }
+ // ... and finally a later write of the OLD payout lands after the
+ // stake's line, so line order alone would call it the stake's
+ // settlement.
+ if !h.g.Economy.settleDurable(old) {
+ t.Error("the older payout's settle marker could not be journaled")
+ return
+ }
+ // The stake's debit is durable; the crashed round is recoverable.
+ p.Credits = 950
+ if err := h.g.AccountStore.SaveCharacter(p); err != nil {
+ t.Error(err)
+ return
+ }
+ h.g.recoverEconomy()
+ })
+ loaded, err := h.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.Credits != 1000 {
+ t.Errorf("balance after recovery = %d, want 1000: a payout created before the stake must not settle it (the stake is refunded, not silently kept)",
+ loaded.Credits)
+ }
+ if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 {
+ t.Errorf("records left open after recovery: %+v", open)
+ }
+}
+
+// TestRecoveryReanchorKeepsCheckpointsWithinCreditCap: when the economy cap
+// blocks a payout's re-application, the re-anchored checkpoints must stay
+// within the cap. An impossible post-state above the cap can never be read as
+// durability, and the record is owed at the current balance: it must keep
+// reading that way across repeated recoveries instead of being settled as
+// "already durable". Legacy records without the tracked-evidence flag take
+// this checkpoint-compensating path.
+func TestRecoveryReanchorKeepsCheckpointsWithinCreditCap(t *testing.T) {
+ dir := t.TempDir()
+ seedWorld(t, dir)
+
+ hash, _ := player.HashPassword("pw")
+ accData, _ := yaml.Marshal(&player.Account{Name: "alice", PasswordHash: hash})
+ writeSeedFile(t, filepath.Join(dir, "players", "accounts", "alice.yaml"), accData)
+ p := player.New("alice")
+ p.Credits = casino.MaxCredits - 30
+ p.Account = "alice"
+ pData, _ := yaml.Marshal(p)
+ writeSeedFile(t, filepath.Join(dir, "players", "characters", "alice.yaml"), pData)
+
+ // Two identical open payouts of 20 at the cap: the first can be
+ // re-applied, the second cannot. Each record is its own journal line.
+ var journal []byte
+ for i := 0; i < 2; i++ {
+ line, err := json.Marshal(economyRecord{
+ ID: fmt.Sprintf("unpaid-%d", i), Player: "alice", Kind: "payout",
+ Credit: 20, Status: "open",
+ CreditsBefore: casino.MaxCredits - 30, CreditsAfter: casino.MaxCredits - 10,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ journal = append(journal, line...)
+ journal = append(journal, '\n')
+ }
+ journalDir := filepath.Join(dir, "economy")
+ if err := os.MkdirAll(journalDir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(journalDir, "journal.jsonl"), journal, 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ // The first recovery runs in New.
+ h := harnessFromDir(t, dir)
+ for round := 1; round <= 3; round++ {
+ if round > 1 {
+ h.g.recoverEconomy()
+ }
+ open := h.g.Economy.mustLoadOpen(t)
+ if len(open) != 1 {
+ t.Fatalf("recovery %d: the unpaid liability must stay open exactly once, open=%d: %+v", round, len(open), open)
+ }
+ for _, rec := range open {
+ if rec.CreditsBefore > casino.MaxCredits || rec.CreditsAfter > casino.MaxCredits {
+ t.Fatalf("recovery %d re-anchored %s outside the economy cap: before=%d after=%d cap=%d",
+ round, rec.ID, rec.CreditsBefore, rec.CreditsAfter, casino.MaxCredits)
+ }
+ }
+ }
+ loaded, err := h.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.Credits != casino.MaxCredits-10 {
+ t.Fatalf("balance after repeated recovery = %d, want %d (one 20-credit payout applied, the other still owed)",
+ loaded.Credits, casino.MaxCredits-10)
+ }
+}
+
+// TestJournalUnavailableSettlementSettlesOnlyItsOwnStakes: when the economy
+// journal is unavailable, the character-file fallback records resolution
+// evidence only for the stakes the payout settles. A sibling hand's stake is
+// settled by its own payout, so a crash before that payout must still refund
+// it.
+func TestJournalUnavailableSettlementSettlesOnlyItsOwnStakes(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.Credits = 100
+ w := casinoWallet{g: h.g, p: p}
+ // Two sibling stakes (a split, or two spots). The sibling is placed
+ // first so its post-debit balance is the durable one.
+ if _, err := w.Wager(10); err != nil {
+ t.Error(err)
+ return
+ }
+ settled, err := w.Wager(10)
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ // The journal breaks for the second hand's push: the payout lands in
+ // the character file only, and may record evidence only for the
+ // stake it settles.
+ failJournalDuring(h, func() {
+ if err := w.PayStakeCover(10, []string{settled.ID}); err != nil {
+ t.Errorf("fallback settlement failed: %v", err)
+ return
+ }
+ })
+ if p.Credits != 90 {
+ t.Errorf("after the fallback settlement: credits=%d, want 90", p.Credits)
+ return
+ }
+ h.g.recoverEconomy()
+ })
+ loaded, err := h.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.Credits != 100 {
+ t.Errorf("balance after recovery = %d, want 100: the sibling hand's stake must be refunded, not resolved by the other hand's settlement",
+ loaded.Credits)
+ }
+ if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 {
+ t.Errorf("records left open after recovery: %+v", open)
+ }
+}
+
+// TestBankCreditTransferRefusedWhenSaveFails: a credit move between the wallet
+// and the bank that cannot be made durable is reverted and refused. Leaving it
+// in memory only would put the balance ahead of the character file, and a
+// wager taken from that phantom balance journals checkpoints recovery can
+// never match.
+func TestBankCreditTransferRefusedWhenSaveFails(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ p.Credits = 100
+ p.BankedCredits = 50
+ if err := h.g.AccountStore.SaveCharacter(p); err != nil {
+ t.Fatal(err)
+ }
+ sess, conn := h.connect("alice")
+
+ h.g.RunOnOwner(func() {
+ failingSaveDuring(h.g, h.dir, func() {
+ h.g.doBankDepositCredits(sess, []string{"deposit", "credits", "40"})
+ })
+ })
+ if sess.Player.Credits != 100 || sess.Player.BankedCredits != 50 {
+ t.Errorf("a deposit whose save failed must be reverted in memory: credits=%d banked=%d, want 100/50",
+ sess.Player.Credits, sess.Player.BankedCredits)
+ }
+ waitForConnText(t, conn, "nothing was moved")
+ if loaded, err := h.g.AccountStore.LoadCharacter("alice"); err != nil {
+ t.Error(err)
+ } else if loaded.Credits != 100 || loaded.BankedCredits != 50 {
+ t.Errorf("durable balances after a refused deposit: credits=%d banked=%d, want 100/50",
+ loaded.Credits, loaded.BankedCredits)
+ }
+
+ h.g.RunOnOwner(func() {
+ failingSaveDuring(h.g, h.dir, func() {
+ h.g.doBankWithdrawCredits(sess, []string{"withdraw", "credits", "10"})
+ })
+ })
+ if sess.Player.Credits != 100 || sess.Player.BankedCredits != 50 {
+ t.Errorf("a withdrawal whose save failed must be reverted in memory: credits=%d banked=%d, want 100/50",
+ sess.Player.Credits, sess.Player.BankedCredits)
+ }
+ waitForConnText(t, conn, "nothing was moved")
+
+ // A durable move still works.
+ h.g.RunOnOwner(func() {
+ h.g.doBankWithdrawCredits(sess, []string{"withdraw", "credits", "10"})
+ })
+ if sess.Player.Credits != 110 || sess.Player.BankedCredits != 40 {
+ t.Errorf("successful withdrawal: credits=%d banked=%d, want 110/40",
+ sess.Player.Credits, sess.Player.BankedCredits)
+ }
+ if loaded, err := h.g.AccountStore.LoadCharacter("alice"); err != nil {
+ t.Error(err)
+ } else if loaded.Credits != 110 || loaded.BankedCredits != 40 {
+ t.Errorf("durable balances after a successful withdrawal: credits=%d banked=%d, want 110/40",
+ loaded.Credits, loaded.BankedCredits)
+ }
+}
diff --git a/internal/game/hardening_harvest_yield_test.go b/internal/game/hardening_harvest_yield_test.go
new file mode 100644
index 0000000..8bfdc6a
--- /dev/null
+++ b/internal/game/hardening_harvest_yield_test.go
@@ -0,0 +1,168 @@
+package game
+
+// Regression: a harvest that cannot be stored must not consume the crop or
+// report a yield that was never delivered. Stackable products merge into an
+// existing stack (so they fit a full inventory) but need their own slot
+// otherwise; non-stackable products need one slot per unit, and the reported
+// yield must match what was actually placed.
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+const (
+ hardeningCropPrefix = "farm_allot_1"
+ hardeningCropSeed = "test_seed"
+ hardeningCropID = "test_potato"
+)
+
+// readyHarvest installs the patch flags a ready crop has and an in-flight
+// harvest action for hardeningCropPrefix, matching what cmd_farm.go queues.
+func readyHarvest(p *player.Player, minYield, maxYield int) {
+ p.EnsureFlags()
+ p.Flags[hardeningCropPrefix+"_weeds"] = false
+ p.Flags[hardeningCropPrefix+"_seed"] = hardeningCropSeed
+ p.Flags[hardeningCropPrefix+"_stage"] = 4
+ p.Flags[hardeningCropPrefix+"_watered"] = false
+ p.Flags[hardeningCropPrefix+"_diseased"] = false
+ p.Flags[hardeningCropPrefix+"_dead"] = false
+ p.Flags[hardeningCropPrefix+"_ready"] = true
+ p.Action = &behavior.Action{
+ Type: "harvest",
+ Data: &behavior.FarmData{
+ Prefix: hardeningCropPrefix,
+ SeedID: hardeningCropSeed,
+ Product: hardeningCropID,
+ MinYield: float64(minYield),
+ MaxYield: float64(maxYield),
+ },
+ }
+}
+
+func TestHarvestStackableFullInventoryKeepsCrop(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ writeSeedFile(t, filepath.Join(h.dir, "items", hardeningCropID+".yaml"),
+ []byte("id: test_potato\nname: test potato\nstackable: true\n"))
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ for i := 0; i < 28; i++ {
+ p.SetInvSlot(i, &player.InventorySlot{ItemID: "filler", Quantity: 1})
+ }
+ readyHarvest(p, 1, 1)
+
+ h.g.advanceHarvest(sess, p)
+
+ if got := p.CountItem(hardeningCropID); got != 0 {
+ t.Errorf("harvest stored %d potatoes with no room", got)
+ }
+ if ready, _ := p.Flags[hardeningCropPrefix+"_ready"].(bool); !ready {
+ t.Error("crop was consumed even though nothing could be stored")
+ }
+ if seed, _ := p.Flags[hardeningCropPrefix+"_seed"].(string); seed != hardeningCropSeed {
+ t.Errorf("patch seed = %q, want it left planted", seed)
+ }
+ out := string(sess.Conn.(*chanConn).Bytes())
+ if strings.Contains(out, "You harvest") {
+ t.Errorf("reported a yield that was not delivered: %q", out)
+ }
+ if !strings.Contains(out, "too full") {
+ t.Errorf("expected an inventory-full message, got %q", out)
+ }
+ })
+}
+
+func TestHarvestStackableMergesIntoExistingStackWhenFull(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ writeSeedFile(t, filepath.Join(h.dir, "items", hardeningCropID+".yaml"),
+ []byte("id: test_potato\nname: test potato\nstackable: true\n"))
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ for i := 0; i < 28; i++ {
+ p.SetInvSlot(i, &player.InventorySlot{ItemID: "filler", Quantity: 1})
+ }
+ p.SetInvSlot(5, &player.InventorySlot{ItemID: hardeningCropID, Quantity: 4})
+ readyHarvest(p, 1, 1)
+
+ h.g.advanceHarvest(sess, p)
+
+ if got := p.CountItem(hardeningCropID); got != 5 {
+ t.Errorf("stackable harvest did not merge into the existing stack: have %d, want 5", got)
+ }
+ if ready, _ := p.Flags[hardeningCropPrefix+"_ready"].(bool); ready {
+ t.Error("patch still ready after a successful harvest")
+ }
+ if out := string(sess.Conn.(*chanConn).Bytes()); !strings.Contains(out, "You harvest 1") {
+ t.Errorf("expected the delivered yield in the message, got %q", out)
+ }
+ })
+}
+
+func TestHarvestNonStackableReportsPlacedYield(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ writeSeedFile(t, filepath.Join(h.dir, "items", hardeningCropID+".yaml"),
+ []byte("id: test_potato\nname: test potato\n"))
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ for i := 0; i < 26; i++ {
+ p.SetInvSlot(i, &player.InventorySlot{ItemID: "filler", Quantity: 1})
+ }
+ readyHarvest(p, 5, 5)
+
+ h.g.advanceHarvest(sess, p)
+
+ if got := p.CountItem(hardeningCropID); got != 2 {
+ t.Fatalf("placed %d potatoes, want 2 (two free slots)", got)
+ }
+ out := string(sess.Conn.(*chanConn).Bytes())
+ if !strings.Contains(out, "You harvest 2 ") {
+ t.Errorf("reported yield must match what was placed: %q", out)
+ }
+ })
+}
+
+func TestHarvestNonStackableFullInventoryKeepsCrop(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ writeSeedFile(t, filepath.Join(h.dir, "items", hardeningCropID+".yaml"),
+ []byte("id: test_potato\nname: test potato\n"))
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ for i := 0; i < 28; i++ {
+ p.SetInvSlot(i, &player.InventorySlot{ItemID: "filler", Quantity: 1})
+ }
+ readyHarvest(p, 5, 5)
+
+ h.g.advanceHarvest(sess, p)
+
+ if got := p.CountItem(hardeningCropID); got != 0 {
+ t.Errorf("harvest stored %d potatoes with no room", got)
+ }
+ if ready, _ := p.Flags[hardeningCropPrefix+"_ready"].(bool); !ready {
+ t.Error("crop was consumed even though nothing could be stored")
+ }
+ if out := string(sess.Conn.(*chanConn).Bytes()); strings.Contains(out, "You harvest") {
+ t.Errorf("reported a yield that was not delivered: %q", out)
+ }
+ })
+}
diff --git a/internal/game/hardening_login_validation_test.go b/internal/game/hardening_login_validation_test.go
new file mode 100644
index 0000000..fa60497
--- /dev/null
+++ b/internal/game/hardening_login_validation_test.go
@@ -0,0 +1,58 @@
+package game
+
+// Regression: the account name typed at the prompt must be validated with
+// player.ValidName BEFORE it reaches the filesystem. The name is later stored
+// on the session and reused to build account/character paths, so an unvalidated
+// "../../" style name must never be joined into a path, even if such a path
+// happens to resolve to an existing file.
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/net"
+)
+
+func TestAccountNameValidatedBeforeFilesystemAccess(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+
+ // A file that "../escape" resolves to once joined under players/accounts/
+ // (filepath.Join cleans the traversal). Before the fix this name reached
+ // FindAccount first and was accepted into the password state.
+ escapePath := filepath.Join(h.dir, "players", "escape.yaml")
+ if err := os.WriteFile(escapePath, []byte("name: escape\npassword_hash: x\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ conn := newChanConn()
+ sess := &net.Session{Conn: conn}
+ sess.SetState(net.StateAccountName)
+
+ h.g.RunOnOwner(func() { h.g.handleAccountName(sess, "../escape") })
+
+ if got := sess.State(); got != net.StateAccountName {
+ t.Fatalf("traversal account name accepted: state = %v, want StateAccountName", got)
+ }
+ if sess.Account != nil {
+ t.Fatalf("rejected name bound to the session: %+v", sess.Account)
+ }
+ if out := string(conn.Bytes()); !strings.Contains(out, "name must be") {
+ t.Fatalf("missing name-validation message; output = %q", out)
+ }
+
+ // A valid existing name still reaches the password prompt.
+ conn2 := newChanConn()
+ valid := &net.Session{Conn: conn2}
+ valid.SetState(net.StateAccountName)
+ h.g.RunOnOwner(func() { h.g.handleAccountName(valid, "alice") })
+
+ if got := valid.State(); got != net.StatePassword {
+ t.Fatalf("valid account name rejected: state = %v, want StatePassword", got)
+ }
+ if valid.Account == nil || valid.Account.Name != "alice" {
+ t.Fatalf("valid account name not bound: %+v", valid.Account)
+ }
+}
diff --git a/internal/game/hardening_owner_panic_test.go b/internal/game/hardening_owner_panic_test.go
new file mode 100644
index 0000000..aa1070b
--- /dev/null
+++ b/internal/game/hardening_owner_panic_test.go
@@ -0,0 +1,162 @@
+package game
+
+// Crash-safety regressions for the owner goroutine and the account-menu
+// handlers. The owner goroutine is the single execution authority for live
+// game state, so an unrecovered panic there (from command parsing, a tick
+// system, or a session whose account/player has gone away) takes down every
+// session and all unsaved state. These tests pin both halves of the fix: the
+// loop contains panics, and the menu handlers refuse to dereference a nil
+// account.
+
+import (
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+// TestOwnerLoopSurvivesPanickingTask proves a panicking task is contained and
+// counted, and that the loop keeps draining the queue afterwards.
+func TestOwnerLoopSurvivesPanickingTask(t *testing.T) {
+ h := newHarness(t)
+
+ before := h.g.owner.panics.Load()
+ h.g.RunOnOwner(func() { panic("boom") })
+ // Game.run releases its caller from the task's OWN defer (close(task)),
+ // which unwinds before runTask's deferred recover runs. The panic counter
+ // is incremented by that recover, so reading it right here could observe
+ // before+0. Submit a no-op task and wait for it: the owner drains tasks in
+ // FIFO order, so the panicking task's runTask — recover and counter
+ // increment included — has fully settled before the no-op task executes.
+ h.barrier()
+ if got := h.g.owner.panics.Load(); got != before+1 {
+ t.Fatalf("panic counter = %d, want %d", got, before+1)
+ }
+
+ ran := false
+ h.g.RunOnOwner(func() { ran = true })
+ if !ran {
+ t.Fatal("owner loop did not execute a task submitted after a panic")
+ }
+}
+
+// TestOwnerSessionInputPanicDoesNotKillLoop reproduces the production crash
+// path: a session in StateGame with no Player (a departed session whose
+// connection goroutine is still dispatching buffered input) dereferences a nil
+// Player. The owner must log and contain the panic instead of taking the
+// process down with it.
+func TestOwnerSessionInputPanicDoesNotKillLoop(t *testing.T) {
+ h := newHarness(t)
+ sess := &net.Session{Conn: newChanConn()}
+ sess.SetState(net.StateGame)
+
+ h.g.HandleSessionOnOwner(sess, "look")
+
+ if got := h.g.owner.panics.Load(); got == 0 {
+ t.Fatal("expected the nil-Player dispatch to panic and be contained")
+ }
+ h.barrier() // the owner loop is still alive and serving tasks
+}
+
+// assertResetToAccountName requires a session to be back at the account-name
+// prompt with no account bound.
+func assertResetToAccountName(t *testing.T, sess *net.Session, conn *chanConn) {
+ t.Helper()
+ if got := sess.State(); got != net.StateAccountName {
+ t.Fatalf("state = %v, want StateAccountName", got)
+ }
+ if sess.Account != nil {
+ t.Fatalf("account not cleared: %+v", sess.Account)
+ }
+ if out := string(conn.Bytes()); !strings.Contains(out, "Account name: ") {
+ t.Fatalf("missing account-name prompt; output = %q", out)
+ }
+}
+
+// TestNilAccountHandlersResetToAccountName drives every menu-state handler with
+// a session whose account is nil (purged, or republished from another session)
+// and requires each to fall back to the account-name prompt instead of
+// dereferencing the nil account. The handlers run on the test goroutine so an
+// unguarded dereference fails loudly here rather than being absorbed by the
+// owner loop's panic barrier.
+func TestNilAccountHandlersResetToAccountName(t *testing.T) {
+ h := newHarness(t)
+
+ cases := []struct {
+ name string
+ state net.SessionState
+ input string
+ run func(*Game, *net.Session, string)
+ }{
+ {"menu-connect", net.StateMenu, "c", (*Game).handleMenu},
+ {"menu-list", net.StateMenu, "l", (*Game).handleMenu},
+ {"menu-new", net.StateMenu, "n", (*Game).handleMenu},
+ {"menu-rename-account", net.StateMenu, "a", (*Game).handleMenu},
+ {"menu-rename-char", net.StateMenu, "r", (*Game).handleMenu},
+ {"menu-delete-char", net.StateMenu, "d", (*Game).handleMenu},
+ {"menu-purge", net.StateMenu, "p", (*Game).handleMenu},
+ {"menu-default", net.StateMenu, "zzz", (*Game).handleMenu},
+ {"color-choice", net.StateColorChoice, "y", (*Game).handleColorChoice},
+ {"new-account-confirm", net.StateNewAccount, "pw", (*Game).handleNewAccount},
+ {"new-char-name", net.StateNewCharName, "bob", (*Game).handleNewCharName},
+ {"rename-account", net.StateRenameAccount, "bob", (*Game).handleRenameAccount},
+ {"rename-char", net.StateRenameChar, "1", (*Game).handleRenameChar},
+ {"rename-char-name", net.StateRenameCharName, "bob", (*Game).handleRenameCharName},
+ {"delete-char", net.StateDeleteChar, "1", (*Game).handleDeleteChar},
+ {"purge-account", net.StatePurgeAccount, "PURGE bob", (*Game).handlePurgeAccount},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ conn := newChanConn()
+ sess := &net.Session{Conn: conn}
+ sess.SetState(tc.state)
+ tc.run(h.g, sess, tc.input)
+ assertResetToAccountName(t, sess, conn)
+ })
+ }
+
+ t.Run("show-menu", func(t *testing.T) {
+ conn := newChanConn()
+ sess := &net.Session{Conn: conn}
+ sess.SetState(net.StateGame)
+ h.g.showMenu(sess)
+ assertResetToAccountName(t, sess, conn)
+ })
+
+ t.Run("connect-character", func(t *testing.T) {
+ conn := newChanConn()
+ sess := &net.Session{Conn: conn}
+ sess.SetState(net.StateMenu)
+ h.g.connectCharacter(sess, "bob")
+ assertResetToAccountName(t, sess, conn)
+ })
+
+ t.Run("apply-password-auth", func(t *testing.T) {
+ conn := newChanConn()
+ sess := &net.Session{Conn: conn}
+ sess.SetState(net.StatePassword)
+ h.g.applyPasswordAuth(sess, passwordAuth{ok: true, stored: "stale-hash"})
+ assertResetToAccountName(t, sess, conn)
+ })
+}
+
+// TestCommandsTolerateNilAccount covers the in-game command handlers that
+// persist to the account: with the account gone they must still work for the
+// live player instead of faulting on the nil account.
+func TestCommandsTolerateNilAccount(t *testing.T) {
+ h := newHarness(t)
+ sess := &net.Session{Conn: newChanConn(), Player: player.New("alice")}
+ sess.SetState(net.StateGame)
+
+ h.g.doOption(sess, "unicode off")
+ if sess.Player.Options["unicode"] != false {
+ t.Fatalf("unicode option not applied to the player: %v", sess.Player.Options["unicode"])
+ }
+
+ h.g.doColor(sess, "room_name D0")
+ h.g.saveAccountColor(sess, "room_name", "D0")
+ h.g.doAlias(sess, []string{"x", "look"})
+ h.g.doUnalias(sess, []string{"x"})
+}
diff --git a/internal/game/hardening_pickup_quality_test.go b/internal/game/hardening_pickup_quality_test.go
new file mode 100644
index 0000000..135b5a6
--- /dev/null
+++ b/internal/game/hardening_pickup_quality_test.go
@@ -0,0 +1,123 @@
+package game
+
+// Regression: a picked-up stack of a quality-bearing stackable item may only
+// merge into a slot with the same Quality/MaxQuality. Otherwise it takes a
+// free slot, mirroring withdrawBankItem in cmd_bank.go, so a merge can never
+// silently destroy the picked-up stack's quality.
+
+import (
+ "path/filepath"
+ "testing"
+
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+const hardeningGemID = "test_gem"
+
+func seedQualityGem(t *testing.T, h *harness) {
+ t.Helper()
+ writeSeedFile(t, filepath.Join(h.dir, "items", hardeningGemID+".yaml"),
+ []byte("id: test_gem\nname: test gem\nstackable: true\nmax_quality: 5\n"))
+}
+
+// gemSlots returns every inventory slot holding the test gem.
+func gemSlots(p *player.Player) map[int]*player.InventorySlot {
+ found := make(map[int]*player.InventorySlot)
+ for i := 0; i < 28; i++ {
+ if slot := p.InvSlot(i); slot != nil && slot.ItemID == hardeningGemID {
+ found[i] = slot
+ }
+ }
+ return found
+}
+
+func TestPickupMismatchedQualityUsesFreeSlot(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ seedQualityGem(t, h)
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: hardeningGemID, Quantity: 10, Quality: 1, MaxQuality: 5})
+ h.g.World.AddGroundItem(p.RoomID, hardeningGemID, 5)
+
+ h.g.doGet(sess, "test gem")
+
+ slots := gemSlots(p)
+ if len(slots) != 2 {
+ t.Fatalf("gem slots = %d, want the pickup in its own slot: %v", len(slots), slots)
+ }
+ if low := p.InvSlot(0); low.Quantity != 10 || low.Quality != 1 {
+ t.Errorf("existing low-quality stack changed: %+v", low)
+ }
+ found := false
+ for i, slot := range slots {
+ if i == 0 {
+ continue
+ }
+ found = true
+ if slot.Quantity != 5 || slot.Quality != 5 || slot.MaxQuality != 5 {
+ t.Errorf("picked-up stack lost its properties: %+v", slot)
+ }
+ }
+ if !found {
+ t.Error("pickup did not take a free slot")
+ }
+ if got := h.g.World.GroundItems(p.RoomID)[hardeningGemID]; got != 0 {
+ t.Errorf("ground gems = %d, want 0", got)
+ }
+ })
+}
+
+func TestPickupMatchingQualityMergesIntoStack(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ seedQualityGem(t, h)
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: hardeningGemID, Quantity: 10, Quality: 5, MaxQuality: 5})
+ h.g.World.AddGroundItem(p.RoomID, hardeningGemID, 5)
+
+ h.g.doGet(sess, "test gem")
+
+ slots := gemSlots(p)
+ if len(slots) != 1 {
+ t.Fatalf("matching-quality pickup must merge, slots = %v", slots)
+ }
+ if slot := p.InvSlot(0); slot.Quantity != 15 || slot.Quality != 5 || slot.MaxQuality != 5 {
+ t.Errorf("merged stack = %+v, want quantity 15 at quality 5", slot)
+ }
+ })
+}
+
+func TestPickupMismatchedQualityFullInventoryRefused(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ seedQualityGem(t, h)
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ for i := 0; i < 28; i++ {
+ p.SetInvSlot(i, &player.InventorySlot{ItemID: "filler", Quantity: 1})
+ }
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: hardeningGemID, Quantity: 10, Quality: 1, MaxQuality: 5})
+ h.g.World.AddGroundItem(p.RoomID, hardeningGemID, 5)
+
+ h.g.doGet(sess, "test gem")
+
+ if slot := p.InvSlot(0); slot.Quantity != 10 || slot.Quality != 1 {
+ t.Errorf("a refused pickup must leave the mismatched stack alone: %+v", slot)
+ }
+ if got := h.g.World.GroundItems(p.RoomID)[hardeningGemID]; got != 5 {
+ t.Errorf("ground gems = %d, want the stack left in place", got)
+ }
+ })
+}
diff --git a/internal/game/hardening_purge_session_reset_test.go b/internal/game/hardening_purge_session_reset_test.go
new file mode 100644
index 0000000..c3462a6
--- /dev/null
+++ b/internal/game/hardening_purge_session_reset_test.go
@@ -0,0 +1,50 @@
+package game
+
+// Regression (P0): a purged account must leave every one of its sessions in a
+// safe protocol state. A queued-mode Close only drops the transport after the
+// writer drains its outbox, so input already buffered in the connection can
+// still be dispatched to the owner after the purge; a session left in the
+// character menu with a nil account then dereferences sess.Account and kills
+// the owner goroutine (and with it every session and all unsaved state).
+
+import (
+ "testing"
+
+ "thehouseoficarus/internal/net"
+)
+
+func TestPurgeResetsEveryAccountSession(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+
+ other := h.connectMenu("alice")
+ purger := h.connectMenu("alice")
+
+ h.g.RunOnOwner(func() { h.g.handlePurgeAccount(purger, "PURGE alice") })
+
+ for name, sess := range map[string]*net.Session{"other": other, "purger": purger} {
+ if got := sess.State(); got != net.StateAccountName {
+ t.Errorf("%s session state = %v, want StateAccountName", name, got)
+ }
+ if sess.Account != nil {
+ t.Errorf("%s session retained the purged account: %+v", name, sess.Account)
+ }
+ }
+
+ // Input already buffered in either transport is still routed to the owner:
+ // it must land on the account-name prompt instead of faulting.
+ before := h.g.owner.panics.Load()
+ h.g.RunOnOwner(func() {
+ h.g.handleMenu(other, "c")
+ h.g.handleMenu(other, "l")
+ h.g.handlePurgeAccount(purger, "PURGE alice")
+ h.g.handleNewCharName(purger, "bob")
+ })
+ if got := h.g.owner.panics.Load(); got != before {
+ t.Fatalf("post-purge input panicked the owner (panic count %d -> %d)", before, got)
+ }
+ if got := other.State(); got != net.StateAccountName {
+ t.Errorf("state = %v after post-purge input, want StateAccountName", got)
+ }
+}
diff --git a/internal/game/hardening_queue_cleanup_test.go b/internal/game/hardening_queue_cleanup_test.go
new file mode 100644
index 0000000..95b96bf
--- /dev/null
+++ b/internal/game/hardening_queue_cleanup_test.go
@@ -0,0 +1,139 @@
+package game
+
+// Session-lifecycle regressions for the per-player command queues: a session
+// that logs out, quits, or disconnects must not leave queued commands behind.
+// Leftovers retain the departed *net.Session (and its transport) and replay
+// against the next session that logs in as the same character — including the
+// nil-Player crash path in ProcessQueuedCommands.
+
+import (
+ "reflect"
+ "testing"
+ "time"
+
+ "thehouseoficarus/internal/net"
+)
+
+// TestClearPlayerDropsAllQueues proves ClearPlayer empties the free, active,
+// and consume queues, and that detachSession applies it.
+func TestClearPlayerDropsAllQueues(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+ sess, _ := h.connect("alice")
+
+ h.g.RunOnOwner(func() {
+ h.g.queue.EnqueueFree("alice", QueuedCommand{Session: sess, Command: "wear", Args: "sword"})
+ h.g.queue.EnqueueActive("alice", QueuedCommand{Session: sess, Command: "wear", Args: "sword"})
+ h.g.queue.EnqueueConsume("alice", QueuedCommand{Session: sess, Command: "eat", Args: "bread"})
+
+ h.g.detachSession("alice")
+ })
+
+ if cmds := h.g.queue.PeekFree("alice"); len(cmds) != 0 {
+ t.Errorf("free queue retained %d commands after detach", len(cmds))
+ }
+ if _, ok := h.g.queue.PeekActive("alice"); ok {
+ t.Error("active queue retained a command after detach")
+ }
+ if _, ok := h.g.queue.DrainConsume("alice"); ok {
+ t.Error("consume queue retained a command after detach")
+ }
+}
+
+// TestQuitCountdownDropsQueuedCommands covers the logout path in cmd_quit.go:
+// the countdown drops the registry entry, so it must drop the queues too. The
+// consume slot is asserted because nothing else ever drains it — a leftover
+// there survives the logout entirely without the cleanup.
+func TestQuitCountdownDropsQueuedCommands(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+ sess, _ := h.connect("alice")
+
+ h.g.RunOnOwner(func() {
+ h.g.queue.EnqueueConsume("alice", QueuedCommand{
+ Session: sess,
+ Command: "eat",
+ Args: "bread",
+ Timestamp: time.Now(),
+ })
+ })
+ h.g.HandleSessionOnOwner(sess, "quit")
+ // "quit" is an active command: it executes on the next tick drain. The
+ // harness registers no master tick system, so drain it explicitly.
+ h.g.RunOnOwner(func() { h.g.ProcessQueuedCommands() })
+
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) && sess.State() != net.StateMenu {
+ time.Sleep(5 * time.Millisecond)
+ }
+ if got := sess.State(); got != net.StateMenu {
+ t.Fatalf("logout countdown never completed (state %v)", got)
+ }
+
+ if qc, ok := h.g.queue.DrainConsume("alice"); ok {
+ t.Errorf("consume command survived logout and would replay on re-login: %+v", qc)
+ }
+ if cmds := h.g.queue.PeekFree("alice"); len(cmds) != 0 {
+ t.Errorf("free commands survived logout: %d", len(cmds))
+ }
+ if _, ok := h.g.queue.PeekActive("alice"); ok {
+ t.Error("active command survived logout")
+ }
+}
+
+// TestProcessQueuedCommandsSkipsStaleSession reproduces the crash path where a
+// free command enqueued on a session that has since departed (Player nil) is
+// drained while a live session for the same character is in the hub.
+func TestProcessQueuedCommandsSkipsStaleSession(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+ h.connect("alice")
+
+ stale := &net.Session{Conn: newChanConn()}
+ stale.SetState(net.StateGame) // departed session: no Player
+
+ h.g.RunOnOwner(func() {
+ h.g.queue.EnqueueFree("alice", QueuedCommand{
+ Session: stale,
+ Command: "wear",
+ Args: "sword",
+ Timestamp: time.Now(),
+ })
+ })
+
+ before := h.g.owner.panics.Load()
+ h.g.RunOnOwner(func() { h.g.ProcessQueuedCommands() })
+ if got := h.g.owner.panics.Load(); got != before {
+ t.Fatalf("stale queued command panicked the owner (panic count %d -> %d)", before, got)
+ }
+ if cmds := h.g.queue.PeekFree("alice"); len(cmds) != 0 {
+ t.Errorf("free queue not drained for the live session: %d commands left", len(cmds))
+ }
+}
+
+// TestActivePositionsReturnsSortedNamesAndOneBasedPosition pins the documented
+// contract of ActivePositions: names sorted, position 1-indexed, 0 when the
+// player holds no active command.
+func TestActivePositionsReturnsSortedNamesAndOneBasedPosition(t *testing.T) {
+ q := NewCommandQueue()
+ for _, name := range []string{"carol", "alice", "bob"} {
+ q.EnqueueActive(name, QueuedCommand{Command: "look", Timestamp: time.Now()})
+ }
+
+ names, pos := q.ActivePositions("bob")
+ if want := []string{"alice", "bob", "carol"}; !reflect.DeepEqual(names, want) {
+ t.Errorf("names = %v, want %v", names, want)
+ }
+ if pos != 2 {
+ t.Errorf("position of bob = %d, want 2", pos)
+ }
+ if _, pos := q.ActivePositions("alice"); pos != 1 {
+ t.Errorf("position of alice = %d, want 1", pos)
+ }
+ if _, pos := q.ActivePositions("nobody"); pos != 0 {
+ t.Errorf("position of an unqueued player = %d, want 0", pos)
+ }
+}
diff --git a/internal/game/hardening_quit_bet_warning_test.go b/internal/game/hardening_quit_bet_warning_test.go
new file mode 100644
index 0000000..4daef49
--- /dev/null
+++ b/internal/game/hardening_quit_bet_warning_test.go
@@ -0,0 +1,79 @@
+package game
+
+// Regression coverage for the quit refusal warning: the departure is refused
+// whenever a wager is committed, but only a dealt hand (or a spin in progress)
+// is actually forfeited on disconnect -- a stake still in the betting window is
+// refunded. The warning must not promise a forfeit in that case.
+
+import (
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/player"
+)
+
+// TestQuitRefusalWarningMatchesRefundedBet places a bet with a second seat
+// holding the round in the betting window (no card dealt for alice's stake),
+// then runs a plain quit. The message must state both outcomes, and the
+// refusal must still leave the player seated with no countdown running.
+func TestQuitRefusalWarningMatchesRefundedBet(t *testing.T) {
+ g, sess := quitTestGame(t)
+ sess.Account = &player.Account{Name: "alice"}
+ cfg := casino.TableConfig{ID: "blackjack", Game: casino.GameBlackjack, MinBet: 1, MaxBet: 100}
+ g.Casino.Join(1, cfg, "alice")
+ g.Casino.Join(1, cfg, "bob") // bob never bets: the window stays open
+ g.Casino.Bet(1, "blackjack", "alice", "", 10, casinoWallet{g: g, p: sess.Player})
+
+ conn := sess.Conn.(*casinoTestConn)
+ conn.data = nil
+ g.doQuit(sess)
+ out := string(conn.data)
+
+ if !strings.Contains(out, `"quit now"`) {
+ t.Fatalf("expected the quit-now reminder, got %q", out)
+ }
+ if strings.Contains(out, "your bet will be forfeited") {
+ t.Fatalf("warning still promises an unconditional forfeit for a betting-window stake: %q", out)
+ }
+ if !strings.Contains(out, "forfeit") {
+ t.Fatalf("warning does not mention the forfeit that a dealt hand suffers: %q", out)
+ }
+ if !strings.Contains(out, "refund") {
+ t.Fatalf("warning does not mention the refund a betting-window stake receives: %q", out)
+ }
+
+ // Behavior is unchanged: the refusal keeps the seat and the wager.
+ if _, playing := g.Casino.Participation("alice"); !playing {
+ t.Fatal("refused quit removed the player from the table")
+ }
+ if g.quitResting() {
+ t.Fatal("refused quit started the rest countdown")
+ }
+}
+
+// TestQuitNowBettingWindowStakeIsRefunded proves the refund half of the
+// warning: after "quit now" runs its countdown to the logout tick, the
+// betting-window stake is returned, so the old unconditional forfeit promise
+// would have been wrong.
+func TestQuitNowBettingWindowStakeIsRefunded(t *testing.T) {
+ g, sess := quitTestGame(t)
+ sess.Account = &player.Account{Name: "alice"}
+ cfg := casino.TableConfig{ID: "blackjack", Game: casino.GameBlackjack, MinBet: 1, MaxBet: 100}
+ g.Casino.Join(1, cfg, "alice")
+ g.Casino.Join(1, cfg, "bob")
+ g.Casino.Bet(1, "blackjack", "alice", "", 10, casinoWallet{g: g, p: sess.Player})
+ wallet := sess.Player
+
+ g.doQuitNow(sess)
+ for i := 0; i < 15 && sess.Player != nil; i++ {
+ g.Ticks.Tick()
+ }
+
+ if _, playing := g.Casino.Participation("alice"); playing {
+ t.Fatal("quit now did not free the seat at the logout tick")
+ }
+ if wallet.Credits != 1000 {
+ t.Fatalf("betting-window stake was not refunded on logout: credits = %d, want 1000", wallet.Credits)
+ }
+}
diff --git a/internal/game/hardening_retribution_mob_death_test.go b/internal/game/hardening_retribution_mob_death_test.go
new file mode 100644
index 0000000..cf05820
--- /dev/null
+++ b/internal/game/hardening_retribution_mob_death_test.go
@@ -0,0 +1,90 @@
+package game
+
+// Regression: Dead Man's Switch retribution can zero a mob inside killPlayer,
+// after endCombat has already taken the player-death branch and returned.
+// That left the mob at HP 0 in MobStore: filtered out of MobsInRoom (HP > 0)
+// and skipped by SeedMobs, it vanished from the room until restart, with no
+// drops and no respawn. Retribution kills must route through the same
+// mob-death helper the normal attack path uses.
+
+import (
+ "testing"
+
+ "thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+const retributionMobYAML = `id: retribution_target
+name: test target
+combat:
+ aggressive: false
+ attack_types:
+ - crush
+ kind: combat
+ respawn_ticks: 1
+ size: medium
+ stats:
+ attack: 1
+ defense: 1
+ hp: 1
+ max_melee_hit: 1
+ speed: 5
+ strength: 1
+drops:
+ remains: bones
+`
+
+func TestRetributionKillRunsMobDeathPath(t *testing.T) {
+ dir := t.TempDir()
+ seedWorld(t, dir)
+ writeDataFile(t, dir, "items", "bones.yaml", "id: bones\nname: bones\nstackable: false\nvalue: 1\n")
+ writeDataFile(t, dir, "techs", "retribution.yaml",
+ "name: Dead Man's Switch\nlevel: 46\neffects:\n retribution_pct: 0.25\n")
+ writeDataFile(t, dir, "mobs", "retribution_target.yaml", retributionMobYAML)
+
+ g := newHardeningGame(t, dir)
+
+ g.RunOnOwner(func() {
+ g.MobStore.ReloadDefs(dir)
+ def, err := g.MobStore.LoadDef("retribution_target")
+ if err != nil {
+ t.Fatalf("load mob def: %v", err)
+ }
+ mob := g.MobStore.SpawnTransient(def, &behavior.SpawnMobConfig{ID: "retribution_target"}, 2, "")
+ // A normal seeded mob, not a trigger despawn: respawn must restore it.
+ mob.SpawnedByTrigger = false
+ mob.RespawnTicks = 1
+ mob.Drops.Remains = "bones"
+ mob.HP = 1
+ mob.MaxHP = 1
+
+ p := player.New("alice")
+ p.RoomID = 2
+ p.HP = 0
+ p.ActivateTech("retribution")
+
+ conn := newChanConn()
+ sess := &net.Session{Conn: conn, Player: p}
+ sess.SetState(net.StateGame)
+
+ g.endCombat(sess, p, mob)
+
+ if mob.HP != 0 {
+ t.Fatalf("mob HP = %d, want 0 after retribution", mob.HP)
+ }
+ if ground := g.World.GroundItems(2); ground["bones"] != 1 {
+ t.Fatalf("retribution kill did not run the drops path: ground=%v", ground)
+ }
+
+ // The death path schedules a respawn; one tick must restore the mob
+ // in its home room rather than leave it gone at HP 0.
+ g.Ticks.Tick()
+ if mob.HP != mob.MaxHP || mob.HP <= 0 {
+ t.Fatalf("mob did not respawn: HP=%d MaxHP=%d", mob.HP, mob.MaxHP)
+ }
+ if mob.RoomID != 2 {
+ t.Fatalf("mob respawned in room %d, want 2", mob.RoomID)
+ }
+ })
+}
diff --git a/internal/game/hardening_security_test.go b/internal/game/hardening_security_test.go
new file mode 100644
index 0000000..edf3c29
--- /dev/null
+++ b/internal/game/hardening_security_test.go
@@ -0,0 +1,100 @@
+package game
+
+// Regression tests for the security-hardening stage:
+// - swapid swaps the two room files through a journal transaction and leaves
+// no transient temp file behind
+// - the admin privilege check re-reads the on-disk flag instead of trusting
+// the cached sess.Account.Admin copy
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestSwapIDSwapsRoomFilesTransactionally(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("admin")
+ h.seedCharacter("admin", "admin")
+ acc, err := h.g.AccountStore.LoadAccount("admin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ acc.Admin = true
+ if err := h.g.AccountStore.SaveAccount(acc); err != nil {
+ t.Fatal(err)
+ }
+ sess, _ := h.connect("admin")
+
+ h.g.RunOnOwner(func() { h.g.executeSwapID(sess, []string{"1", "2"}, "swapid 1 2") })
+
+ r1, err := os.ReadFile(filepath.Join(h.dir, "rooms", "1.yaml"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ r2, err := os.ReadFile(filepath.Join(h.dir, "rooms", "2.yaml"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(r1), "name: North") {
+ t.Fatalf("room 1 not swapped:\n%s", r1)
+ }
+ if !strings.Contains(string(r2), "name: Central") {
+ t.Fatalf("room 2 not swapped:\n%s", r2)
+ }
+
+ // The old implementation renamed through rooms/<id>.yaml.swapid-tmp; the
+ // transaction-based swap must not leave one behind.
+ entries, err := os.ReadDir(filepath.Join(h.dir, "rooms"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, e := range entries {
+ if strings.Contains(e.Name(), "swapid-tmp") {
+ t.Fatalf("leftover swap temp file: %s", e.Name())
+ }
+ }
+
+ // A committed transaction removes its journal.
+ if jentries, err := os.ReadDir(filepath.Join(h.dir, ".journal")); err == nil {
+ for _, e := range jentries {
+ t.Fatalf("leftover journal after successful swap: %s", e.Name())
+ }
+ }
+}
+
+func TestCheckAdminReReadsDiskFlag(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+ acc, err := h.g.AccountStore.LoadAccount("alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ acc.Admin = true
+ if err := h.g.AccountStore.SaveAccount(acc); err != nil {
+ t.Fatal(err)
+ }
+ sess, _ := h.connect("alice")
+
+ var got bool
+ h.g.RunOnOwner(func() { got = h.g.checkAdmin(sess) })
+ if !got {
+ t.Fatal("admin session not recognized")
+ }
+
+ // Revoke admin on disk; the cached session must no longer be privileged.
+ revoked, err := h.g.AccountStore.LoadAccount("alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ revoked.Admin = false
+ if err := h.g.AccountStore.SaveAccount(revoked); err != nil {
+ t.Fatal(err)
+ }
+ h.g.RunOnOwner(func() { got = h.g.checkAdmin(sess) })
+ if got {
+ t.Fatal("stale cached admin flag was trusted after disk revocation")
+ }
+}
diff --git a/internal/game/hardening_shipped_content_test.go b/internal/game/hardening_shipped_content_test.go
new file mode 100644
index 0000000..9379395
--- /dev/null
+++ b/internal/game/hardening_shipped_content_test.go
@@ -0,0 +1,60 @@
+package game
+
+// Regression: pins the shipped content fixed alongside the validator work.
+// The iron-bar smelt formula must improve with level (it previously capped at
+// 50% at every level, destroying half the ore forever), and the pastry/pizza
+// dough combines must return their empty containers like bread dough does.
+
+import (
+ "path/filepath"
+ "testing"
+
+ "thehouseoficarus/internal/item"
+)
+
+func TestShippedIronBarSmeltFormulaImprovesWithLevel(t *testing.T) {
+ store := item.NewItemStore(filepath.Join("..", "..", "data"))
+ def, err := store.Load("iron_bar")
+ if err != nil {
+ t.Fatalf("load iron_bar: %v", err)
+ }
+ c := def.FirstCraft()
+ if c == nil {
+ t.Fatal("iron_bar has no craft recipe")
+ }
+ if c.Success == nil {
+ t.Fatal("iron_bar must define a success formula (fail_message/smelting failure otherwise unreachable)")
+ }
+ if c.Success.PerLevel <= 0 {
+ t.Errorf("iron_bar success per_level = %v, want > 0 (failure must become rarer with level)", c.Success.PerLevel)
+ }
+ if c.Success.Cap <= c.Success.Base {
+ t.Errorf("iron_bar success base=%v cap=%v, want cap > base (a permanent fixed chance is the defect)",
+ c.Success.Base, c.Success.Cap)
+ }
+}
+
+func TestShippedDoughRecipesReturnEmptyContainers(t *testing.T) {
+ store := item.NewItemStore(filepath.Join("..", "..", "data"))
+ for _, id := range []string{"pastry_dough", "pizza_dough"} {
+ def, err := store.Load(id)
+ if err != nil {
+ t.Fatalf("load %s: %v", id, err)
+ }
+ c := def.FirstCraft()
+ if c == nil {
+ t.Fatalf("%s has no craft recipe", id)
+ }
+ got := map[string]bool{}
+ for _, e := range c.Ingredients {
+ for _, bp := range e.Byproducts {
+ got[bp] = true
+ }
+ }
+ for _, want := range []string{"empty_pot", "empty_bucket"} {
+ if !got[want] {
+ t.Errorf("%s must return %s as a byproduct so the container is not destroyed", id, want)
+ }
+ }
+ }
+}
diff --git a/internal/game/hardening_shipped_data_validation_test.go b/internal/game/hardening_shipped_data_validation_test.go
new file mode 100644
index 0000000..dee464b
--- /dev/null
+++ b/internal/game/hardening_shipped_data_validation_test.go
@@ -0,0 +1,205 @@
+package game
+
+// Regression: the authored data/ tree must pass the full startup validator in
+// CI, not only when a server boots. Before this test the suite ran just
+// validateKnownFields; validate.Run (the twelve semantic/reference checks
+// behind game.ValidateAndLog) was reachable only from game.New, so broken
+// content (dangling references, bad craft byproducts, unreachable fail
+// branches, invalid coords) reached a running server before CI noticed.
+//
+// The gate fails on every ERROR, and on WARNs that are known-fatal dead data
+// (see fatalShippedDataWarnings). It deliberately does NOT fail on every WARN:
+// production only escalates ERROR (validate.LogIssues) and game.New never
+// refuses to boot on a WARN, and many WARNs are by-design informational (an
+// orphan room while content is authored, "has no name" placeholders, ignored
+// shared-drop-table hints). Failing on all of them made CI stricter than the
+// server and blocked legitimate content edits.
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/config"
+ "thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/object"
+ "thehouseoficarus/internal/validate"
+ "thehouseoficarus/internal/world"
+)
+
+// fatalShippedDataWarnings lists WARN message substrings that still fail the
+// shipped-data gate. It keeps protection against dead fail data returning: a
+// declared fail product or fail_message on a recipe with no success formula can
+// never be produced or shown (see validate.craftFailureReachable). The second
+// entry keeps protection against a failable recipe that declares no fail
+// product, which consumes materials and yields nothing; every shipped failable
+// recipe returns one of its own ingredients.
+var fatalShippedDataWarnings = []string{
+ "is unreachable",
+ "failure destroys materials with no fail product",
+}
+
+// shippedDataIssueFails reports whether issue must fail the shipped-data gate:
+// every ERROR, plus a WARN matching fatalShippedDataWarnings.
+func shippedDataIssueFails(issue validate.Issue) bool {
+ if issue.Level == "ERROR" {
+ return true
+ }
+ for _, fatal := range fatalShippedDataWarnings {
+ if strings.Contains(issue.Message, fatal) {
+ return true
+ }
+ }
+ return false
+}
+
+// TestShippedDataPassesStartupValidation runs validate.Run over ../../data
+// using a Source equivalent to game.ValidateAndLog's (the same stores, techs
+// and configured root rooms).
+func TestShippedDataPassesStartupValidation(t *testing.T) {
+ dataDir := filepath.Join("..", "..", "data")
+ cfg, err := config.Load(filepath.Join("..", "..", "config.yaml"))
+ if err != nil {
+ t.Fatalf("load config.yaml: %v", err)
+ }
+
+ // A minimal, side-effect-free Game: the stores and config the validator
+ // reads, without game.New's character migration / journal recovery (which
+ // would write to the shipped tree).
+ g := &Game{
+ Deps: Deps{
+ World: world.New(dataDir),
+ ObjectStore: object.NewObjectStore(dataDir),
+ ItemStore: item.NewItemStore(dataDir),
+ MobStore: world.NewMobStore(dataDir),
+ CourseStore: NewCourseStore(dataDir),
+ DataDir: dataDir,
+ },
+ ValidationConfig: cfg.StartupValidation,
+ }
+ g.CourseStore.SetWorld(g.World)
+ g.CourseStore.LoadAll()
+ if err := g.LoadTechs(); err != nil {
+ t.Fatalf("load techs: %v", err)
+ }
+
+ for _, issue := range validate.Run(g.validationSource()) {
+ if shippedDataIssueFails(issue) {
+ t.Errorf("[%s] %s: %s", issue.Level, issue.Type, issue.Message)
+ }
+ }
+}
+
+// TestShippedDataGateCatchesDeadFailDataAndAllowsBenignWarns pins the gate's
+// severity policy: an ERROR or dead craft-fail WARN fails, a benign
+// content WARN (an orphan room) does not.
+func TestShippedDataGateCatchesDeadFailDataAndAllowsBenignWarns(t *testing.T) {
+ errIssue := validate.Issue{Level: "ERROR", Type: "reference", Message: "Room 1: spawns nonexistent item \"ghost\""}
+ if !shippedDataIssueFails(errIssue) {
+ t.Error("an ERROR must fail the shipped-data gate")
+ }
+
+ deadFail := validate.Issue{
+ Level: "WARN",
+ Type: "semantic",
+ Message: `Item "x" (craft: cooking): fail product "burnt_fish" is unreachable — success is always 100% (add a success formula)`,
+ }
+ if !shippedDataIssueFails(deadFail) {
+ t.Error("dead fail data must still fail the shipped-data gate")
+ }
+
+ orphan := validate.Issue{Level: "WARN", Type: "integrity", Message: "Orphan rooms (no exit leads to them): 1099"}
+ if shippedDataIssueFails(orphan) {
+ t.Error("a benign content WARN must not fail the shipped-data gate")
+ }
+}
+
+// TestShippedDataGateTripsOnDeadFailRecipe proves the gate still trips on a
+// real validator WARN emitted for a recipe whose fail branch can never run,
+// not merely on a hand-built Issue.
+func TestShippedDataGateTripsOnDeadFailRecipe(t *testing.T) {
+ dataDir := t.TempDir()
+ writeShippedDataFile(t, filepath.Join(dataDir, "items", "dead_fail.yaml"), `name: dead fail
+craft:
+ - type: cooking
+ fail: dead_fail
+ fail_message: You burn it.
+ ingredients:
+ - items: [dead_fail]
+ quantity: 1
+`)
+
+ src := validate.Source{
+ DataDir: dataDir,
+ Items: item.NewItemStore(dataDir),
+ Objects: object.NewObjectStore(dataDir),
+ Mobs: world.NewMobStore(dataDir),
+ World: world.New(dataDir),
+ }
+
+ found := false
+ for _, issue := range validate.Run(src) {
+ if !strings.Contains(issue.Message, "is unreachable") {
+ continue
+ }
+ found = true
+ if !shippedDataIssueFails(issue) {
+ t.Fatalf("dead-fail WARN must fail the shipped-data gate, got: %+v", issue)
+ }
+ }
+ if !found {
+ t.Fatal("expected a dead-fail WARN for a recipe with an unreachable fail branch")
+ }
+}
+
+// TestShippedSmeltingBarsRecoverMaterialOnFailure verifies fix for the
+// regression where the bars gained a success formula but no fail product: a
+// lost roll consumed every ingredient and returned nothing. A smelt that can
+// fail must return one of its own ingredient ids.
+func TestShippedSmeltingBarsRecoverMaterialOnFailure(t *testing.T) {
+ dataDir := filepath.Join("..", "..", "data")
+ defs, err := item.NewItemStore(dataDir).LoadAll()
+ if err != nil {
+ t.Fatalf("load items: %v", err)
+ }
+
+ smelts := 0
+ for _, def := range defs {
+ for ci := range def.Craft {
+ c := &def.Craft[ci]
+ if c.Subtype != "smelting" || c.Success == nil {
+ continue
+ }
+ smelts++
+ if c.Fail == "" {
+ t.Errorf("Item %q: a failing smelt consumes every ingredient but declares no fail product", def.ID)
+ continue
+ }
+ returned := false
+ for _, e := range c.Ingredients {
+ for _, id := range e.Items {
+ if id == c.Fail {
+ returned = true
+ }
+ }
+ }
+ if !returned {
+ t.Errorf("Item %q: fail product %q is not one of the recipe's own ingredients", def.ID, c.Fail)
+ }
+ }
+ }
+ if smelts == 0 {
+ t.Fatal("expected at least one smelting recipe with a success formula")
+ }
+}
+
+func writeShippedDataFile(t *testing.T, path, body string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/game/hardening_shutdown_save_error_test.go b/internal/game/hardening_shutdown_save_error_test.go
new file mode 100644
index 0000000..c75f6fc
--- /dev/null
+++ b/internal/game/hardening_shutdown_save_error_test.go
@@ -0,0 +1,63 @@
+package game
+
+// Regression coverage for the shutdown final-save report: runShutdown records
+// a failed saveAllOnlineCharacters on the Game, and ShutdownSaveError exposes
+// it so main can exit non-zero instead of losing online state silently.
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// TestShutdownSaveErrorReportsFailedFinalSave makes the character save fail
+// (the character file is replaced by a directory, so the atomic replace cannot
+// land) and requires the shutdown sequence to record the failure on the Game.
+func TestShutdownSaveErrorReportsFailedFinalSave(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+ h.connect("alice")
+
+ charPath := filepath.Join(h.dir, "players", "characters", "alice.yaml")
+ if err := os.Remove(charPath); err != nil {
+ t.Fatalf("remove character file: %v", err)
+ }
+ if err := os.Mkdir(charPath, 0o755); err != nil {
+ t.Fatalf("block character path: %v", err)
+ }
+
+ h.g.runShutdown()
+
+ err := h.g.ShutdownSaveError()
+ if err == nil {
+ t.Fatal("a failed final save was not recorded")
+ }
+ if !strings.Contains(err.Error(), "alice") {
+ t.Fatalf("shutdown save error does not name the unsaved character: %v", err)
+ }
+}
+
+// TestShutdownSaveErrorNilWhenFinalSaveSucceeds pins the other direction:
+// nothing is reported when every online character was persisted, so main does
+// not exit non-zero on a clean shutdown.
+func TestShutdownSaveErrorNilWhenFinalSaveSucceeds(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+ h.connect("alice")
+
+ if err := h.g.ShutdownSaveError(); err != nil {
+ t.Fatalf("ShutdownSaveError before any shutdown = %v, want nil", err)
+ }
+
+ h.g.runShutdown()
+
+ if err := h.g.ShutdownSaveError(); err != nil {
+ t.Fatalf("clean shutdown recorded a save failure: %v", err)
+ }
+ if _, err := h.g.AccountStore.LoadCharacter("alice"); err != nil {
+ t.Fatalf("online character was not saved: %v", err)
+ }
+}
diff --git a/internal/game/hardening_tick_lifecycle_test.go b/internal/game/hardening_tick_lifecycle_test.go
new file mode 100644
index 0000000..b5b3bb9
--- /dev/null
+++ b/internal/game/hardening_tick_lifecycle_test.go
@@ -0,0 +1,291 @@
+package game
+
+// Tick and lifecycle liveness regressions: a pending aggro retry must never
+// start combat for a departed player, an in-combat disconnect must not pin the
+// character in the registry forever, per-tick systems must stay safe with a
+// nil Hub and must advance in a reproducible order, and the per-tick session
+// snapshots must not drop the wander broadcasts they replaced.
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+// TestFireTickNilHubIsSafe guards the mandatory tick-system guard: FireTick
+// reaches cancelStokers -> g.Hub.PlayersInRoom when a fire burns out, and the
+// master tick subscriber can fire before SetHub during startup.
+func TestFireTickNilHubIsSafe(t *testing.T) {
+ t.Run("nil_hub_burning_fire", func(t *testing.T) {
+ g := escapeTestGame()
+ // Quality 1 burns out on this tick, which is the path that touches
+ // the hub; without the guard it panics on g.Hub == nil.
+ g.World.AddObjInstance(1, "fire", 1)
+ g.Hub = nil
+
+ defer func() {
+ if r := recover(); r != nil {
+ t.Fatalf("FireTick panicked with a nil Hub: %v", r)
+ }
+ }()
+ g.FireTick()
+ })
+
+ t.Run("hub_present_still_burns_out", func(t *testing.T) {
+ h := newHarness(t)
+ h.g.RunOnOwner(func() {
+ h.g.World.AddObjInstance(1, "fire", 1)
+ h.g.FireTick()
+ if fires := h.g.World.AllFireStates(); len(fires) != 0 {
+ t.Errorf("fire should have burned out, %d states remain", len(fires))
+ }
+ })
+ })
+}
+
+// TestSequenceTickFiresInDeterministicOrder guards the sorted iteration over
+// g.sequences: map iteration randomly chose which simultaneous sequence
+// advanced first, contradicting the determinism contract in
+// building_guide/tick_order.md.
+func TestSequenceTickFiresInDeterministicOrder(t *testing.T) {
+ g := escapeTestGame()
+ g.sequences = make(map[string]*sequence)
+ g.loggedInChars = make(map[string]*net.Session)
+
+ const rounds = 32
+ for round := 0; round < rounds; round++ {
+ conn := newChanConn()
+ p := player.New(fmt.Sprintf("seq_order_%d", round))
+ p.RoomID = 1
+ sess := &net.Session{Player: p, Conn: conn}
+ sess.SetState(net.StateGame)
+ g.loggedInChars[p.Name] = sess
+
+ // Inserted in reverse key order: only sorting can make the advance
+ // order stable.
+ g.seqMu.Lock()
+ g.sequences["z_second"] = &sequence{
+ sess: sess, player: p, playerName: p.Name, roomID: p.RoomID,
+ steps: []behavior.Step{{Messages: []string{"ORDER-MARKER-SECOND"}}},
+ scope: scopePlayer, key: "z_second",
+ }
+ g.sequences["a_first"] = &sequence{
+ sess: sess, player: p, playerName: p.Name, roomID: p.RoomID,
+ steps: []behavior.Step{{Messages: []string{"ORDER-MARKER-FIRST"}}},
+ scope: scopePlayer, key: "a_first",
+ }
+ g.seqMu.Unlock()
+
+ g.SequenceTick()
+
+ out := string(conn.Bytes())
+ first, second := strings.Index(out, "ORDER-MARKER-FIRST"), strings.Index(out, "ORDER-MARKER-SECOND")
+ if first < 0 || second < 0 {
+ t.Fatalf("round %d: both sequences should have fired, got %q", round, out)
+ }
+ if first > second {
+ t.Fatalf("round %d: sequences advanced in map order instead of key order: %q", round, out)
+ }
+ }
+}
+
+// TestWanderTickBroadcastsWithSingleSessionSnapshot guards the hoisted
+// AllSessions snapshot: the mob leave/enter broadcasts to other rooms must
+// still fire after the per-move snapshot was replaced by one per system.
+func TestWanderTickBroadcastsWithSingleSessionSnapshot(t *testing.T) {
+ h := newHarness(t)
+ writeSeedFile(t, filepath.Join(h.dir, "mobs", "wanderer.yaml"), []byte(
+ "id: wanderer\nname: wanderer\ncombat:\n kind: combat\n stats:\n hp: 1\n"))
+
+ // Sessions without a write queue: broadcasts reach the transport
+ // synchronously with the tick, so the assertions need no polling.
+ aliceConn := wanderTestSession(h, "alice", 1)
+ bobConn := wanderTestSession(h, "bob", 2)
+
+ var mobRoom int
+ h.g.RunOnOwner(func() {
+ // Room 1 has a north exit to room 2 (see seedWorld).
+ h.g.MobStore.ReloadDefs(h.dir)
+ def, err := h.g.MobStore.LoadDef("wanderer")
+ if err != nil {
+ t.Errorf("load seeded mob: %v", err)
+ return
+ }
+ inst := h.g.MobStore.SpawnTransient(def, &behavior.SpawnMobConfig{ID: "wanderer"}, 1, "")
+ if inst.HP <= 0 {
+ t.Errorf("seeded mob has no HP (%d); it would be skipped", inst.HP)
+ return
+ }
+ inst.WanderInterval = 1
+ inst.WanderTickCounter = 1
+ inst.WanderRooms = []int{2}
+
+ h.g.WanderTick()
+ mobRoom = inst.RoomID
+ })
+
+ if mobRoom != 2 {
+ t.Fatalf("mob did not wander into room 2 (room %d)", mobRoom)
+ }
+ if sessions := h.g.Hub.AllSessions(); len(sessions) != 2 {
+ t.Fatalf("snapshot changed hub membership: %d sessions", len(sessions))
+ }
+
+ leaving := string(aliceConn.Bytes())
+ entering := string(bobConn.Bytes())
+ if !strings.Contains(leaving, "wanderer") || !strings.Contains(leaving, "moves away.") {
+ t.Errorf("departing room did not get the leave broadcast: %q", leaving)
+ }
+ if !strings.Contains(entering, "wanderer") || !strings.Contains(entering, "drifts in.") {
+ t.Errorf("arrival room did not get the enter broadcast: %q", entering)
+ }
+ if strings.Contains(leaving, "drifts in.") {
+ t.Errorf("enter broadcast leaked into the departing room: %q", leaving)
+ }
+ if strings.Contains(entering, "moves away.") {
+ t.Errorf("leave broadcast leaked into the arrival room: %q", entering)
+ }
+}
+
+// wanderTestSession registers a live in-game session for a fresh character in
+// roomID, bypassing the login menu and the write queue.
+func wanderTestSession(h *harness, name string, roomID int) *chanConn {
+ h.t.Helper()
+ p := player.New(name)
+ p.RoomID = roomID
+ conn := newChanConn()
+ sess := &net.Session{Conn: conn, Player: p}
+ sess.SetState(net.StateGame)
+ h.hub.Add(sess)
+ h.hub.EnterRoom(sess, roomID)
+ return conn
+}
+
+// TestAggroRetryStopsForDepartedPlayer guards keepRolling's liveness guard: a
+// pending aggro timer outlives its session, and must never start combat for a
+// player who has logged out, disconnected, or re-logged in elsewhere.
+func TestAggroRetryStopsForDepartedPlayer(t *testing.T) {
+ t.Run("player_detached", func(t *testing.T) {
+ defer patchAggroRand(0.5)()
+ g := escapeTestGame()
+ spawnTestMob(g, 100)
+ p := player.New("aggro_ghost")
+ p.RoomID = 100
+ sess := escapeTestSession(p)
+
+ g.checkAggro(sess)
+ if g.Combat.Get(p.Name) != nil {
+ t.Fatal("inline roll should have missed (val=0.5)")
+ }
+ // The logout path nils the session's player before the retry fires.
+ sess.Player = nil
+ rollAggroRand = func() float64 { return 0.0 }
+ g.Ticks.Tick()
+
+ if cs := g.Combat.Get(p.Name); cs != nil {
+ t.Fatalf("combat started for a departed player: %+v", cs)
+ }
+ })
+
+ t.Run("session_disconnecting", func(t *testing.T) {
+ defer patchAggroRand(0.5)()
+ g := escapeTestGame()
+ spawnTestMob(g, 150)
+ p := player.New("aggro_offline")
+ p.RoomID = 150
+ sess := escapeTestSession(p)
+
+ g.checkAggro(sess)
+ sess.StartDisconnect(10)
+ rollAggroRand = func() float64 { return 0.0 }
+ g.Ticks.Tick()
+
+ if g.Combat.Get(p.Name) != nil {
+ t.Error("combat started for a session whose transport is gone")
+ }
+ })
+
+ t.Run("replaced_by_new_session", func(t *testing.T) {
+ defer patchAggroRand(0.5)()
+ g := escapeTestGame()
+ spawnTestMob(g, 200)
+ p := player.New("aggro_relogin")
+ p.RoomID = 200
+ sess := escapeTestSession(p)
+
+ g.checkAggro(sess)
+ // The same character logged in again on another connection.
+ g.loggedInChars = map[string]*net.Session{p.Name: escapeTestSession(p)}
+ rollAggroRand = func() float64 { return 0.0 }
+ g.Ticks.Tick()
+
+ if g.Combat.Get(p.Name) != nil {
+ t.Error("combat started for the retired session of a re-logged-in character")
+ }
+ })
+
+ t.Run("live_session_still_rolls", func(t *testing.T) {
+ defer patchAggroRand(0.5)()
+ g := escapeTestGame()
+ spawnTestMob(g, 300)
+ p := player.New("aggro_live")
+ p.RoomID = 300
+ sess := escapeTestSession(p)
+ g.loggedInChars = map[string]*net.Session{p.Name: sess}
+
+ g.checkAggro(sess)
+ rollAggroRand = func() float64 { return 0.0 }
+ g.Ticks.Tick()
+
+ if g.Combat.Get(p.Name) == nil {
+ t.Error("the live session's retry should still start combat")
+ }
+ })
+}
+
+// TestDisconnectTickCombatDeferralIsBounded guards the disconnect countdown:
+// an unresolvable fight used to freeze it forever, so the session (and the
+// character's "logged in elsewhere" lock) never cleared.
+func TestDisconnectTickCombatDeferralIsBounded(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+ sess, _ := h.connect("alice")
+ p := sess.Player
+
+ var sessionsAfterFirstTick, remaining int
+ h.g.RunOnOwner(func() {
+ sess.StartDisconnect(1)
+ // A fight that never resolves (no subscriber ever ends it).
+ h.g.Combat.Enter(p.Name, "skeleton")
+
+ h.g.DisconnectTick()
+ sessionsAfterFirstTick = len(h.g.Hub.AllSessions())
+
+ for i := 0; i < disconnectCombatGraceTicks+5 && len(h.g.Hub.AllSessions()) > 0; i++ {
+ h.g.DisconnectTick()
+ }
+ remaining = len(h.g.Hub.AllSessions())
+ })
+
+ if sessionsAfterFirstTick != 1 {
+ t.Fatalf("in-combat session should be deferred on the first tick, %d sessions remain", sessionsAfterFirstTick)
+ }
+ if remaining != 0 {
+ t.Fatalf("in-combat disconnect never completed: %d sessions remain", remaining)
+ }
+ if _, err := h.g.AccountStore.LoadCharacter("alice"); err != nil {
+ t.Fatalf("character was not saved before the hard remove: %v", err)
+ }
+ h.g.charsMu.Lock()
+ _, stillRegistered := h.g.loggedInChars["alice"]
+ h.g.charsMu.Unlock()
+ if stillRegistered {
+ t.Error("character registry entry survived the hard remove")
+ }
+}
diff --git a/internal/game/owner.go b/internal/game/owner.go
index 9c7be64..d4d3839 100644
--- a/internal/game/owner.go
+++ b/internal/game/owner.go
@@ -1,7 +1,9 @@
package game
import (
+ "log"
"runtime"
+ "runtime/debug"
"strconv"
"strings"
"sync"
@@ -39,6 +41,10 @@ type owner struct {
// detects itself — an "owner is busy" flag would wrongly let outside
// callers bypass the queue while a task is in flight.
goid atomic.Uint64
+ // panics counts the task panics the loop has contained (see runTask).
+ // A nonzero value means a command, tick callback, or disconnect handler
+ // panicked and was logged instead of killing the process.
+ panics atomic.Uint64
}
const ownerTaskQueue = 4096
@@ -57,13 +63,34 @@ func (o *owner) loop() {
o.goid.Store(currentGoroutineID())
defer close(o.done)
for task := range o.tasks {
- task()
+ o.runTask(task)
// A queue slot freed up; wake one blocked submitter waiting for
- // room (see enqueue).
+ // room (see enqueue). The signal MUST be sent while holding o.mu:
+ // a submitter can otherwise observe a full queue, watch this loop
+ // free a slot and signal, and only then reach cond.Wait() — the
+ // signal is lost and the submitter sleeps with capacity available.
+ o.mu.Lock()
o.cond.Signal()
+ o.mu.Unlock()
}
}
+// runTask executes one queued task behind a panic barrier. A panic in a
+// command, tick callback, or disconnect handler must not kill the owner
+// goroutine: that goroutine is the only execution authority for live game
+// state, so its death takes down every session and all unsaved state with
+// it. The panic is logged with a stack trace and counted (see panics) so a
+// storm is visible, and the loop keeps draining the queue.
+func (o *owner) runTask(task func()) {
+ defer func() {
+ if r := recover(); r != nil {
+ o.panics.Add(1)
+ log.Printf("owner: recovered panic in task: %v\n%s", r, debug.Stack())
+ }
+ }()
+ task()
+}
+
// submit enqueues without blocking. It returns false when the owner has
// stopped or the queue is full; callers that need guaranteed delivery use
// enqueue instead.
@@ -132,8 +159,10 @@ func (g *Game) run(fn func()) {
}
task := make(chan struct{})
if !o.enqueue(func() {
+ // Deferred so a panic contained by runTask still releases the
+ // caller blocked on <-task below instead of hanging it forever.
+ defer close(task)
fn()
- close(task)
}) {
// The owner loop has stopped (graceful shutdown in progress).
// Admissions are closed: DROP the task. Executing it on the
@@ -146,10 +175,25 @@ func (g *Game) run(fn func()) {
<-task
}
+// adminTaskPanic is the value RunAdminOnOwner re-raises on its caller's
+// goroutine when the owner loop contained a panic in an editor task. It is a
+// distinct value so a caller can tell a contained task panic from the
+// shutdown refusal that a false result means.
+const adminTaskPanic = "owner: admin task panicked; the owner loop contained it, see the log for the stack trace"
+
// RunAdminOnOwner synchronously executes one complete editor request. Unlike
// RunOnOwner it rejects work during shutdown and NEVER falls back to executing
// on the HTTP goroutine when the owner has stopped. Call only from outside the
// owner; runtime callbacks within fn must call owner-only methods directly.
+//
+// A panic inside fn is contained by the owner loop's panic barrier (runTask
+// logs and counts it and the loop keeps draining), but it is NOT reported as a
+// false result: false means "the owner refused the work", which the admin
+// middleware renders as 503 "game is shutting down" - a wrong diagnosis for a
+// task that panicked, and one that hides a real internal error. Instead the
+// panic is re-raised on the CALLER's goroutine, so the admin HTTP handler's
+// own panic recovery (admin.ownerMiddleware preserves net/http's) answers an
+// internal error while the owner loop stays untouched.
func (g *Game) RunAdminOnOwner(fn func()) bool {
if g.isShuttingDown() {
return false
@@ -167,13 +211,21 @@ func (g *Game) RunAdminOnOwner(fn func()) bool {
return accepted
}
done := make(chan struct{})
+ // finished is written on the owner goroutine before close(done), so the
+ // caller reads it race-free after <-done. A deferred close(done) releases
+ // the caller whether the task completed or panicked.
+ finished := false
if !g.owner.enqueue(func() {
+ defer close(done)
task()
- close(done)
+ finished = true
}) {
return false
}
<-done
+ if !finished {
+ panic(adminTaskPanic)
+ }
return accepted
}
diff --git a/internal/game/polish_craft_fail_delivery_test.go b/internal/game/polish_craft_fail_delivery_test.go
new file mode 100644
index 0000000..f16e2a9
--- /dev/null
+++ b/internal/game/polish_craft_fail_delivery_test.go
@@ -0,0 +1,111 @@
+package game
+
+// Regression: failed-craft delivery used FirstFreeSlot only, while the
+// pre-roll fit gate (craftFailFits -> craftOutputFits) counts a matching
+// stack. A stackable fail replacement could therefore be destroyed on a full
+// inventory even though the gate promised it would fit. Delivery must merge
+// like craft/harvest/pickup does.
+
+import (
+ "path/filepath"
+ "testing"
+
+ "thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+const polishFailRecipeYAML = `id: polish_out
+name: polish out
+stackable: true
+craft:
+ - type: smithing
+ subtype: smelting
+ fail: polish_ash
+ ingredients:
+ - items:
+ - polish_ore
+ quantity: 1
+ success:
+ base: 0
+ cap: 0
+ per_level: 0
+ output_qty: 1
+ ticks_per_cycle: 1
+`
+
+func seedPolishFailItems(t *testing.T, dir string) {
+ t.Helper()
+ writeSeedFile(t, filepath.Join(dir, "items", "polish_out.yaml"), []byte(polishFailRecipeYAML))
+ writeSeedFile(t, filepath.Join(dir, "items", "polish_ash.yaml"),
+ []byte("id: polish_ash\nname: polish ash\nstackable: true\nvalue: 1\n"))
+ writeSeedFile(t, filepath.Join(dir, "items", "polish_ore.yaml"),
+ []byte("id: polish_ore\nname: polish ore\nstackable: true\nvalue: 1\n"))
+}
+
+func TestPlaceCraftFailMergesIntoMatchingStack(t *testing.T) {
+ h := newHarness(t)
+ seedPolishFailItems(t, h.dir)
+ p := player.New("alice")
+
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: "polish_ash", Quantity: 3})
+ fillInventoryExcept(p)
+
+ h.g.placeCraftFail(p, &item.CraftDef{Fail: "polish_ash"})
+ if slot := p.InvSlot(0); slot == nil || slot.Quantity != 4 {
+ t.Fatalf("fail replacement was not merged into the matching stack: %+v", p.InvSlot(0))
+ }
+ })
+}
+
+func TestCraftFailureMergesFailStackWhenFull(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ seedPolishFailItems(t, h.dir)
+
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
+
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ // Full inventory: a matching output stack, a matching fail stack and
+ // the ingredient. Consuming the ingredient frees its slot, so the old
+ // FirstFreeSlot delivery landed the fail item there as a second stack.
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: "polish_ash", Quantity: 1})
+ p.SetInvSlot(1, &player.InventorySlot{ItemID: "polish_ore", Quantity: 1})
+ p.SetInvSlot(2, &player.InventorySlot{ItemID: "polish_out", Quantity: 1})
+ for i := 3; i < 28; i++ {
+ p.SetInvSlot(i, &player.InventorySlot{ItemID: "filler", Quantity: 1})
+ }
+
+ p.Action = &behavior.Action{
+ Type: behavior.TypeSmelt,
+ TargetID: "polish_out",
+ Data: &behavior.ProductionData{ItemID: "polish_out", Phase: 1, Remaining: 1},
+ }
+ h.g.advanceProduction(sess, p)
+
+ if got := p.CountItem("polish_ore"); got != 0 {
+ t.Errorf("ingredient = %d, want 0 (consumed)", got)
+ }
+ if got := p.CountItem("polish_ash"); got != 2 {
+ t.Errorf("fail product = %d, want 2", got)
+ }
+ stacks := 0
+ for i := 0; i < 28; i++ {
+ if s := p.InvSlot(i); s != nil && s.ItemID == "polish_ash" {
+ stacks++
+ }
+ }
+ if stacks != 1 {
+ t.Errorf("fail product was delivered as %d stacks, want 1 merged stack", stacks)
+ }
+ if slot := p.InvSlot(0); slot == nil || slot.Quantity != 2 {
+ t.Errorf("matching fail stack = %+v, want quantity 2", p.InvSlot(0))
+ }
+ })
+}
diff --git a/internal/game/polish_shop_merge_test.go b/internal/game/polish_shop_merge_test.go
new file mode 100644
index 0000000..8851464
--- /dev/null
+++ b/internal/game/polish_shop_merge_test.go
@@ -0,0 +1,55 @@
+package game
+
+// Regression: giveShopItems used to merge a purchase into any inventory slot
+// holding the same item id, ignoring Quality/MaxQuality, while every other
+// merge site (craft, harvest, ammo return, pickup) merges only into a stack
+// with the properties a fresh stack gets (stackProps). A purchase could
+// therefore merge into a differently qualified stack. shopInvCapacity had the
+// same blind spot, which mattered because the buy path charges for the
+// quantity capacity reports as placeable.
+
+import (
+ "path/filepath"
+ "testing"
+
+ "thehouseoficarus/internal/player"
+)
+
+func TestGiveShopItemsDoesNotMergeIntoDifferentQualityStack(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ writeSeedFile(t, filepath.Join(h.dir, "items", "polish_shop_gem.yaml"),
+ []byte("id: polish_shop_gem\nname: polish shop gem\nstackable: true\nmax_quality: 5\nvalue: 1\n"))
+
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ def := mustLoadItem(t, h.g, "polish_shop_gem")
+
+ // A differently qualified stack is not a merge target: with no free
+ // slot, capacity must report 0 and the purchase must not be placed.
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: def.ID, Quantity: 10, Quality: 1, MaxQuality: 5})
+ fillInventoryExcept(p)
+ if got := h.g.shopInvCapacity(p, def.ID, def); got != 0 {
+ t.Fatalf("shopInvCapacity = %d, want 0 (the only stack has a different quality)", got)
+ }
+ h.g.giveShopItems(p, def.ID, 3, def)
+ if slot := p.InvSlot(0); slot.Quantity != 10 || slot.Quality != 1 || slot.MaxQuality != 5 {
+ t.Errorf("purchase merged into a differently qualified stack: %+v", slot)
+ }
+ if got := p.CountItem(def.ID); got != 10 {
+ t.Errorf("gems carried = %d, want 10 (nothing should have been delivered)", got)
+ }
+
+ // The same full inventory accepts the purchase once the stack carries
+ // the properties a fresh stack gets.
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: def.ID, Quantity: 10, Quality: 5, MaxQuality: 5})
+ if got := h.g.shopInvCapacity(p, def.ID, def); got == 0 {
+ t.Fatalf("shopInvCapacity = %d, want room for a matching stack", got)
+ }
+ h.g.giveShopItems(p, def.ID, 3, def)
+ if slot := p.InvSlot(0); slot.Quantity != 13 || slot.Quality != 5 || slot.MaxQuality != 5 {
+ t.Errorf("matched stack = %+v, want quantity 13 at quality 5", slot)
+ }
+ })
+}
diff --git a/internal/game/polish_wear_swap_quality_test.go b/internal/game/polish_wear_swap_quality_test.go
new file mode 100644
index 0000000..bb9e996
--- /dev/null
+++ b/internal/game/polish_wear_swap_quality_test.go
@@ -0,0 +1,140 @@
+package game
+
+// Regression: swapping worn equipment used to return the displaced item as a
+// raw Quality 0/MaxQuality 0 slot and leave its stale EquipQuality entry
+// behind. Reachable with the shipped lighter (the only max_quality > 0 item):
+// wielding a weapon after the lighter returned it zeroed, and act_burn charged
+// the stale equipment record instead of the carried stack. The displaced
+// item's equipped quality must move onto the returned slot and out of the map.
+
+import (
+ "path/filepath"
+ "testing"
+
+ "thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+const polishLighterYAML = `id: polish_lighter
+name: polish lighter
+stackable: false
+max_quality: 100
+equipment:
+ slot: main_hand
+ type: tool
+tool:
+ type: fire
+`
+
+const polishBladeYAML = `id: polish_blade
+name: polish blade
+stackable: false
+equipment:
+ slot: main_hand
+ type: melee_weapon
+`
+
+func seedPolishWearItems(t *testing.T, dir string) {
+ t.Helper()
+ writeSeedFile(t, filepath.Join(dir, "items", "polish_lighter.yaml"), []byte(polishLighterYAML))
+ writeSeedFile(t, filepath.Join(dir, "items", "polish_blade.yaml"), []byte(polishBladeYAML))
+}
+
+// carriedInventSlot returns the first inventory slot holding itemID.
+func carriedInventSlot(p *player.Player, itemID string) *player.InventorySlot {
+ for i := 0; i < 28; i++ {
+ if s := p.InvSlot(i); s != nil && s.ItemID == itemID {
+ return s
+ }
+ }
+ return nil
+}
+
+func TestWearSwapReturnsEquippedQualityToInventory(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ seedPolishWearItems(t, h.dir)
+
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
+
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ p.SetInvSlot(0, h.g.newInventorySlot("polish_lighter", 1))
+
+ h.g.doWear(sess, "lighter")
+ if got := p.Equipment[item.SlotMainHand]; got != "polish_lighter" {
+ t.Fatalf("lighter not equipped: %q", got)
+ }
+ // Burn two charges while the lighter is equipped.
+ for i := 0; i < 2; i++ {
+ if !h.g.consumeFireTool(sess, p, "polish_lighter") {
+ t.Fatal("consumeFireTool refused a fresh lighter")
+ }
+ }
+ if got := p.EquipQuality["polish_lighter"]; got != 98 {
+ t.Fatalf("equipped lighter charge = %d, want 98", got)
+ }
+
+ // Wielding a weapon displaces the lighter back into the inventory.
+ p.SetInvSlot(1, h.g.newInventorySlot("polish_blade", 1))
+ h.g.doWear(sess, "blade")
+ if got := p.Equipment[item.SlotMainHand]; got != "polish_blade" {
+ t.Fatalf("blade not equipped: %q", got)
+ }
+
+ lighter := carriedInventSlot(p, "polish_lighter")
+ if lighter == nil {
+ t.Fatal("the displaced lighter was not returned to the inventory")
+ }
+ if lighter.Quality != 98 || lighter.MaxQuality != 100 {
+ t.Errorf("returned lighter = %+v, want quality 98/100", lighter)
+ }
+ if _, ok := p.EquipQuality["polish_lighter"]; ok {
+ t.Error("stale EquipQuality entry left behind after the swap")
+ }
+
+ // act_burn must charge the carried stack, not a stale equipment record.
+ if !h.g.consumeFireTool(sess, p, "polish_lighter") {
+ t.Fatal("consumeFireTool refused the carried lighter stack")
+ }
+ if lighter.Quality != 97 {
+ t.Errorf("carried lighter charge = %d, want 97", lighter.Quality)
+ }
+ })
+}
+
+func TestWearAllSwapReturnsEquippedQualityToInventory(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ seedPolishWearItems(t, h.dir)
+
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
+
+ h.g.RunOnOwner(func() {
+ h.g.ItemStore.Reload(h.dir)
+ p.SetInvSlot(0, h.g.newInventorySlot("polish_lighter", 1))
+ h.g.doWear(sess, "lighter")
+ if got := p.EquipQuality["polish_lighter"]; got != 100 {
+ t.Fatalf("equipped lighter charge = %d, want 100", got)
+ }
+
+ p.SetInvSlot(1, h.g.newInventorySlot("polish_blade", 1))
+ h.g.doWearAll(sess)
+
+ lighter := carriedInventSlot(p, "polish_lighter")
+ if lighter == nil {
+ t.Fatal("wear all did not return the displaced lighter")
+ }
+ if lighter.Quality != 100 || lighter.MaxQuality != 100 {
+ t.Errorf("returned lighter = %+v, want quality 100/100", lighter)
+ }
+ if _, ok := p.EquipQuality["polish_lighter"]; ok {
+ t.Error("stale EquipQuality entry left behind by wear all")
+ }
+ })
+}
diff --git a/internal/game/production_engine.go b/internal/game/production_engine.go
index 035b7e2..ddada6b 100644
--- a/internal/game/production_engine.go
+++ b/internal/game/production_engine.go
@@ -172,11 +172,11 @@ func (g *Game) loadCraftItem(itemID string) *item.ItemDef {
return item
}
-func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool {
+func (g *Game) advanceProduction(sess *net.Session, p *player.Player) {
d, ok := p.Action.Data.(*behavior.ProductionData)
if !ok {
g.cancelAction(p)
- return false
+ return
}
itemID := d.ItemID
phase := d.Phase
@@ -188,7 +188,7 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool {
item := g.loadCraftItem(itemID)
if item == nil {
g.cancelAction(p)
- return false
+ return
}
craft := item.FirstCraft()
@@ -198,7 +198,7 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool {
}
d.Phase = 1
p.Action.WaitLeft = engine.ToTicks(wait)
- return true
+ return
}
skill := craft.EffectiveSkill()
@@ -222,9 +222,14 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool {
if !craftOutputFits(p, itemID, item) || (failDef != nil && !craftOutputFits(p, craft.Fail, failDef)) {
sess.WriteLine("Your inventory is too full!")
g.cancelAction(p)
- return false
+ return
}
+ // Resolve %i<n> names from the alternative that will actually be
+ // consumed: consumption empties the inventory, so p.HasItem can no
+ // longer identify it when the message is rendered below.
+ hasItem := craftConsumedHasItem(craft, p.CountItem, p.HasItem)
+
if rand.Float64() < chance {
outputQty := craft.OutputQty
if outputQty <= 0 {
@@ -240,14 +245,14 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool {
// Cannot happen after the availability gate above, but never
// produce output from a failed consumption.
g.cancelAction(p)
- return false
+ return
}
if !placeCraftOutput(p, itemID, item, outputQty) {
// Defensive: the fits check above should have guaranteed a
// stack or slot.
sess.WriteLine("Your inventory is too full!")
g.cancelAction(p)
- return false
+ return
}
for _, bp := range byproducts {
@@ -273,7 +278,7 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool {
} else {
defaultSuccess = "You produce %n."
}
- msg := g.resolveCraftMessage(sess, craft.SuccessMessage, defaultSuccess, craft, item.ID, byproducts, p.HasItem)
+ msg := g.resolveCraftMessage(sess, craft.SuccessMessage, defaultSuccess, craft, item.ID, byproducts, hasItem)
if p.OptionBool("xp_drops") && craft.XP > 0 {
msg += g.formatXpDropSingle(sess, p, player.SkillName(skill), craft.XP)
}
@@ -281,18 +286,15 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool {
} else {
if !consumeIngredients(craft, p.CountItem, p.RemoveItem) {
g.cancelAction(p)
- return false
+ return
}
if craft.Fail != "" {
- freeSlot := p.FirstFreeSlot()
- if freeSlot >= 0 {
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: craft.Fail, Quantity: 1})
- }
+ g.placeCraftFail(p, craft)
}
g.saveCharacter(p)
- msg := g.resolveCraftMessage(sess, craft.FailMessage, info.FailMessage, craft, item.ID, nil, p.HasItem)
+ msg := g.resolveCraftMessage(sess, craft.FailMessage, info.FailMessage, craft, item.ID, nil, hasItem)
if msg != "" {
sess.WriteLine(g.colorize(sess, "damage_taken", msg))
}
@@ -306,20 +308,19 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool {
sess.WriteLine(endMsg)
}
g.cancelAction(p)
- return false
+ return
}
}
if g.canContinueProduction(p, item) {
p.Action.WaitLeft = engine.ToTicks(wait)
- return true
+ return
}
if endMsg != "" {
sess.WriteLine(endMsg)
}
g.cancelAction(p)
- return false
}
func (g *Game) collectByproducts(p *player.Player, item *item.ItemDef) []string {
@@ -354,6 +355,73 @@ func (g *Game) collectByproducts(p *player.Player, item *item.ItemDef) []string
return byproducts
}
+// craftConsumedHasItem returns a hasItem predicate that reports the
+// ingredient alternatives consumeIngredients will remove right now — the
+// same aggregate plan availability and consumption use. A %i<n> message
+// resolved AFTER consumption therefore still names the item the player
+// actually used (p.HasItem is false for everything consumed). Falls back to
+// the supplied predicate when no plan can be built.
+func craftConsumedHasItem(craft *item.CraftDef, countItem func(string) int, fallback func(string) bool) func(string) bool {
+ plan := planIngredients(craft, countItem)
+ if plan == nil {
+ return fallback
+ }
+ consumed := make(map[string]bool, len(plan))
+ for _, c := range plan {
+ consumed[c.id] = true
+ }
+ return func(id string) bool { return consumed[id] }
+}
+
+// craftRollSuccess rolls one craft cycle's success: true when the recipe
+// defines no success formula or the roll succeeds.
+func craftRollSuccess(craft *item.CraftDef, level int) bool {
+ if craft == nil || craft.Success == nil {
+ return true
+ }
+ chance := behavior.SuccessChance(behavior.SuccessFormula(*craft.Success), level, craft.Level)
+ return rand.Float64() < chance
+}
+
+// craftFailFits reports whether the recipe's fail replacement (if any) can
+// be placed right now. Like the output fit check, it must run BEFORE
+// consumption so a full inventory never destroys materials.
+func (g *Game) craftFailFits(p *player.Player, craft *item.CraftDef) bool {
+ if craft == nil || craft.Fail == "" {
+ return true
+ }
+ def, _ := g.ItemStore.Load(craft.Fail)
+ if def == nil {
+ return true
+ }
+ return craftOutputFits(p, craft.Fail, def)
+}
+
+// placeCraftFail delivers a failed craft's fail replacement into the
+// inventory, merging into a matching stack when the fail item is stackable
+// just like the success output does. FirstFreeSlot alone is not enough: the
+// pre-roll gate counts a stack merge (craftFailFits), so a full inventory
+// with a matching stack must not silently destroy the fail replacement.
+func (g *Game) placeCraftFail(p *player.Player, craft *item.CraftDef) {
+ if craft == nil || craft.Fail == "" {
+ return
+ }
+ def, _ := g.ItemStore.Load(craft.Fail)
+ placeCraftOutput(p, craft.Fail, def, 1)
+}
+
+// placeCraftByproducts delivers byproducts into free slots, dropping any
+// remainder on the ground rather than silently deleting it.
+func (g *Game) placeCraftByproducts(p *player.Player, byproducts []string) {
+ for _, bp := range byproducts {
+ if slot := p.FirstFreeSlot(); slot >= 0 {
+ p.SetInvSlot(slot, &player.InventorySlot{ItemID: bp, Quantity: 1})
+ } else {
+ g.World.AddGroundItem(p.RoomID, bp, 1)
+ }
+ }
+}
+
func (g *Game) canContinueProduction(p *player.Player, item *item.ItemDef) bool {
craft := item.FirstCraft()
if craft == nil {
diff --git a/internal/game/production_index.go b/internal/game/production_index.go
index ffacd8a..35466a7 100644
--- a/internal/game/production_index.go
+++ b/internal/game/production_index.go
@@ -272,6 +272,19 @@ func consumeIngredients(c *item.CraftDef, countItem func(string) int, removeItem
return true
}
+// stackProps returns the Quality and MaxQuality a NEW inventory stack of def
+// receives, mirroring newInventorySlot (core_utils.go): a quality-bearing
+// definition is stamped at its full quality. Every stack-creation and
+// stack-merge site must agree on these values, or a stack produced by one
+// path (craft, harvest, ammo removal, shop) stops merging with the same item
+// picked up from the ground while the inventory is full.
+func stackProps(def *item.ItemDef) (quality, maxQuality int) {
+ if def != nil && def.MaxQuality > 0 {
+ return def.MaxQuality, def.MaxQuality
+ }
+ return 0, 0
+}
+
// craftOutputFits reports whether outputQty of an item can be placed into
// the inventory right now: merged into an existing stack for stackable
// definitions, otherwise a free slot. Crafters must check BEFORE consuming
@@ -279,8 +292,10 @@ func consumeIngredients(c *item.CraftDef, countItem func(string) int, removeItem
// producing the output.
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++ {
- if slot := p.InvSlot(i); slot != nil && slot.ItemID == itemID {
+ slot := p.InvSlot(i)
+ if slot != nil && slot.ItemID == itemID && slot.Quality == quality && slot.MaxQuality == maxQuality {
return true
}
}
@@ -294,9 +309,11 @@ func craftOutputFits(p *player.Player, itemID string, outDef *item.ItemDef) bool
// (consumption cannot free fewer slots than the check saw on the owner
// goroutine, so a false return is defensive).
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++ {
- if slot := p.InvSlot(i); slot != nil && slot.ItemID == itemID {
+ slot := p.InvSlot(i)
+ if slot != nil && slot.ItemID == itemID && slot.Quality == quality && slot.MaxQuality == maxQuality {
slot.Quantity += outputQty
return true
}
@@ -306,6 +323,6 @@ func placeCraftOutput(p *player.Player, itemID string, outDef *item.ItemDef, out
if freeSlot == -1 {
return false
}
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: outputQty})
+ p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: outputQty, Quality: quality, MaxQuality: maxQuality})
return true
}
diff --git a/internal/game/quit_casino_test.go b/internal/game/quit_casino_test.go
index 50f9ac8..f373457 100644
--- a/internal/game/quit_casino_test.go
+++ b/internal/game/quit_casino_test.go
@@ -169,7 +169,9 @@ func TestQuitMidRoundMentionsQuitNow(t *testing.T) {
// "quit now" starts the logout countdown while the player keeps their seat
// and wager; the stake is forfeited and the seat freed only on the final
-// tick, immediately before the disconnect.
+// tick, immediately before the disconnect. The round must be IN FLIGHT for
+// that: a stake placed in the betting window has had no card dealt for it and
+// is refunded on disconnect (see markDisconnected).
func TestQuitNowForfeitsOnlyAtLogout(t *testing.T) {
g, sess := quitTestGame(t)
sess.Account = &player.Account{Name: "alice"}
@@ -177,6 +179,10 @@ func TestQuitNowForfeitsOnlyAtLogout(t *testing.T) {
g.Casino.Join(1, cfg, "alice")
g.Casino.Join(1, cfg, "bob")
g.Casino.Bet(1, "blackjack", "alice", "", 10, casinoWallet{g: g, p: sess.Player})
+ // Bob bets too so the round actually starts and alice holds a dealt hand.
+ bobWallet := player.New("bob")
+ bobWallet.Credits = 1000
+ g.Casino.Bet(1, "blackjack", "bob", "", 10, casinoWallet{g: g, p: bobWallet})
wallet := sess.Player
g.doQuitNow(sess)
diff --git a/internal/game/session_registry.go b/internal/game/session_registry.go
index 0e47f82..c4f5d67 100644
--- a/internal/game/session_registry.go
+++ b/internal/game/session_registry.go
@@ -11,9 +11,15 @@ func (g *Game) attachSession(sess *net.Session) {
g.charsMu.Unlock()
}
-// detachSession removes a character's registry entry.
+// detachSession removes a character's registry entry and drops every command
+// queued under its name. Leftover queue entries retain the departed session
+// (and its transport) and would otherwise replay against the next session
+// that logs in as the same character.
func (g *Game) detachSession(name string) {
g.charsMu.Lock()
delete(g.loggedInChars, name)
g.charsMu.Unlock()
+ if g.queue != nil {
+ g.queue.ClearPlayer(name)
+ }
}
diff --git a/internal/game/sys_combat.go b/internal/game/sys_combat.go
index 26dac2a..b2a2463 100644
--- a/internal/game/sys_combat.go
+++ b/internal/game/sys_combat.go
@@ -105,6 +105,20 @@ func (g *Game) checkAggro(sess *net.Session) {
aggMob := mob
keepRolling := func() (keep bool) {
+ // The retry subscription outlives the session that armed it (only
+ // mob/combat/room state ends it), so a player who has since logged
+ // out, disconnected, or logged in again on a new connection must
+ // not have combat started for them here: the mob's own subscribers
+ // would then keep fighting a character who is not in the world.
+ if sess.Player == nil || sess.IsDisconnecting() {
+ return false
+ }
+ g.charsMu.Lock()
+ current := g.loggedInChars[p.Name]
+ g.charsMu.Unlock()
+ if current != nil && current != sess {
+ return false
+ }
if g.Combat.Get(p.Name) != nil {
return false
}
diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go
index 2dbde9f..312fe50 100644
--- a/internal/game/sys_triggers.go
+++ b/internal/game/sys_triggers.go
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "sort"
"strings"
"sync"
@@ -98,9 +99,17 @@ func (g *Game) startSequence(sess *net.Session, p *player.Player, steps []behavi
// sequences are removed.
func (g *Game) SequenceTick() {
g.seqMu.Lock()
- playerSeqs := make([]*sequence, 0, len(g.sequences))
- for _, s := range g.sequences {
- playerSeqs = append(playerSeqs, s)
+ // g.sequences is a map: collect its keys and sort them so the per-tick
+ // order in which simultaneous sequences advance is reproducible (the
+ // determinism contract in building_guide/tick_order.md).
+ keys := make([]string, 0, len(g.sequences))
+ for key := range g.sequences {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ playerSeqs := make([]*sequence, 0, len(keys))
+ for _, key := range keys {
+ playerSeqs = append(playerSeqs, g.sequences[key])
}
globalSeqs := append([]*sequence(nil), g.globalSeqs...)
g.seqMu.Unlock()
diff --git a/internal/game/tick.go b/internal/game/tick.go
index cd9b2fd..4181622 100644
--- a/internal/game/tick.go
+++ b/internal/game/tick.go
@@ -5,10 +5,25 @@ import (
"math/rand"
"thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
"thehouseoficarus/internal/world"
)
+// disconnectCombatGraceTicks bounds how many ticks DisconnectTick defers the
+// hard remove of a session that is still in combat, so an unresolvable fight
+// cannot keep the character's registry entry (and its "logged in elsewhere"
+// lockout) forever.
+const disconnectCombatGraceTicks = 5
+
+// disconnectCombatDeferrals counts, per session, how many ticks its pending
+// disconnect has already been deferred for combat. DisconnectTick is its only
+// reader/writer and every tick system runs on the owner goroutine, so the map
+// needs no lock. An entry is dropped when this path completes the hard remove;
+// a session hard-removed elsewhere (a second Hub.Remove, shutdown) leaves at
+// most one stale int behind.
+var disconnectCombatDeferrals = map[*net.Session]int{}
+
func (g *Game) DisconnectTick() {
if g.Hub == nil {
return
@@ -22,10 +37,20 @@ func (g *Game) DisconnectTick() {
g.Hub.Remove(sess)
continue
}
+ // An unresolvable fight (a task/worksite mob that takes no progress, a
+ // zero-damage stalemate) must not postpone the hard remove forever:
+ // the character registry entry only drops with the remove, so an
+ // unbounded deferral locks the player out ("logged in elsewhere")
+ // until a restart. Defer while the fight can still resolve, but only
+ // for a bounded grace period, then let the countdown proceed.
if g.Combat.Get(p.Name) != nil {
- continue
+ if deferred := disconnectCombatDeferrals[sess]; deferred < disconnectCombatGraceTicks {
+ disconnectCombatDeferrals[sess] = deferred + 1
+ continue
+ }
}
if sess.TickDisconnectCountdown() {
+ delete(disconnectCombatDeferrals, sess)
g.restoreGodPlayer(p)
g.saveCharacter(p)
g.Hub.HardRemove(sess)
@@ -76,6 +101,11 @@ func (g *Game) WanderTick() {
return
}
+ // One session snapshot per system invocation: the destination broadcast
+ // below used to re-snapshot every session (lock, allocate, sort) once per
+ // wandering mob/object.
+ sessions := g.Hub.AllSessions()
+
type moveEvent struct {
name string
fromRoom int
@@ -134,7 +164,7 @@ func (g *Game) WanderTick() {
}
for _, m := range moves {
- for _, sess := range g.Hub.AllSessions() {
+ for _, sess := range sessions {
p := sess.Player
if p == nil {
continue
diff --git a/internal/item/hardening_id_mismatch_test.go b/internal/item/hardening_id_mismatch_test.go
new file mode 100644
index 0000000..5123fc0
--- /dev/null
+++ b/internal/item/hardening_id_mismatch_test.go
@@ -0,0 +1,88 @@
+package item
+
+import (
+ "bytes"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// Regression: an item file whose declared id: disagrees with its filename was
+// silently loaded under the filename with no diagnostic. ItemStore must warn,
+// because the author has no other sign that the id they wrote is ignored.
+
+func captureLog(t *testing.T, fn func()) string {
+ t.Helper()
+ var buf bytes.Buffer
+ old := log.Writer()
+ log.SetOutput(&buf)
+ defer log.SetOutput(old)
+ fn()
+ return buf.String()
+}
+
+func writeItemFile(t *testing.T, dir, name, body string) {
+ t.Helper()
+ path := filepath.Join(dir, "items", name)
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestItemStoreWarnsOnDeclaredIDMismatch(t *testing.T) {
+ dir := t.TempDir()
+ writeItemFile(t, dir, "foo.yaml", "id: bar\nname: Foo\n")
+ s := NewItemStore(dir)
+
+ out := captureLog(t, func() {
+ def, err := s.Load("foo")
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ if def.ID != "foo" {
+ t.Fatalf("filename must win: ID = %q, want foo", def.ID)
+ }
+ })
+ if !strings.Contains(out, "declares id") {
+ t.Fatalf("expected an id-mismatch warning, got log %q", out)
+ }
+}
+
+func TestItemStoreDoesNotWarnWhenIDMatchesOrAbsent(t *testing.T) {
+ dir := t.TempDir()
+ writeItemFile(t, dir, "foo.yaml", "id: foo\nname: Foo\n")
+ writeItemFile(t, dir, "baz.yaml", "name: Baz\n")
+ s := NewItemStore(dir)
+
+ out := captureLog(t, func() {
+ if _, err := s.Load("foo"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Load("baz"); err != nil {
+ t.Fatal(err)
+ }
+ })
+ if strings.Contains(out, "declares id") {
+ t.Fatalf("unexpected id-mismatch warning: %q", out)
+ }
+}
+
+func TestItemStoreLoadAllWarnsOnDeclaredIDMismatch(t *testing.T) {
+ dir := t.TempDir()
+ writeItemFile(t, dir, "foo.yaml", "id: bar\nname: Foo\n")
+ s := NewItemStore(dir)
+
+ out := captureLog(t, func() {
+ if _, err := s.LoadAll(); err != nil {
+ t.Fatal(err)
+ }
+ })
+ if !strings.Contains(out, "declares id") {
+ t.Fatalf("expected an id-mismatch warning from LoadAll, got %q", out)
+ }
+}
diff --git a/internal/item/store.go b/internal/item/store.go
index e50e6e9..12aa729 100644
--- a/internal/item/store.go
+++ b/internal/item/store.go
@@ -2,6 +2,7 @@ package item
import (
"fmt"
+ "log"
"os"
"path/filepath"
"sync"
@@ -44,6 +45,7 @@ func (s *ItemStore) Load(id string) (*ItemDef, error) {
if err := yaml.Unmarshal(data, &def); err != nil {
return nil, fmt.Errorf("parse item %s: %w", id, err)
}
+ warnDeclaredIDMismatch("item", path, id, def.ID)
def.ID = id
s.cache[id] = &def
return &def, nil
@@ -63,6 +65,7 @@ func (s *ItemStore) LoadAll() ([]*ItemDef, error) {
if err := yaml.Unmarshal(data, &def); err != nil {
return fmt.Errorf("parse item %s: %w", id, err)
}
+ warnDeclaredIDMismatch("item", path, id, def.ID)
def.ID = id
s.cache[id] = &def
defs = append(defs, &def)
@@ -71,6 +74,17 @@ func (s *ItemStore) LoadAll() ([]*ItemDef, error) {
return defs, err
}
+// warnDeclaredIDMismatch logs when a data file declares an id: that disagrees
+// with its filename. IDs come from the filename (def.ID = id above), so the
+// declared value is silently ignored at runtime; without this warning an author
+// sees no sign that the id they wrote is dead.
+func warnDeclaredIDMismatch(kind, path, filenameID, declaredID string) {
+ if declaredID != "" && declaredID != filenameID {
+ log.Printf("%s %s: file declares id %q but is loaded as %q; the filename is authoritative and the declared id is ignored",
+ kind, path, declaredID, filenameID)
+ }
+}
+
// PathIndex returns a copy of the id→path index, safe to iterate concurrently.
func (s *ItemStore) PathIndex() map[string]string {
s.mu.Lock()
diff --git a/internal/net/server.go b/internal/net/server.go
index 9d99acc..05a98d1 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -168,6 +168,14 @@ type Server struct {
// stopping flips when Shutdown begins so listener goroutines treat
// their subsequent Accept/serve errors as expected, not fatal.
stopping atomic.Bool
+ // conns counts live sessions across every transport. handleSession
+ // admits against maxConnections so a pre-auth connection flood cannot
+ // hold an unbounded number of reader goroutines, buffers, and outboxes.
+ conns atomic.Int64
+ // idleTimeout returns the read timeout armed before each message. It is a
+ // field so tests can drive the idle-drop path without waiting out the
+ // production windows; nil means sessionIdleTimeout.
+ idleTimeout func(*Session) time.Duration
}
type Hub struct {
@@ -187,6 +195,64 @@ const (
closeFlushTimeout = 2 * time.Second
)
+// Connection admission and idle-read limits. The listener admits at most
+// maxConnections live sessions; each read arms an idle deadline (short before
+// authentication, generous once the session has an account) that every
+// message resets, so a silent client cannot pin a reader goroutine forever
+// while legitimate long sessions keep working.
+const (
+ maxConnections = 1000
+ loginReadTimeout = 2 * time.Minute
+ idleReadTimeout = 30 * time.Minute
+)
+
+// admitConnection reserves a connection slot, returning false when the server
+// is at capacity. Every admitted connection must call releaseConnection.
+func (s *Server) admitConnection() bool {
+ if s.conns.Add(1) > maxConnections {
+ s.conns.Add(-1)
+ return false
+ }
+ return true
+}
+
+func (s *Server) releaseConnection() {
+ s.conns.Add(-1)
+}
+
+// sessionIdleTimeout returns the read timeout to arm for this session.
+func (s *Server) sessionIdleTimeout(sess *Session) time.Duration {
+ if s.idleTimeout != nil {
+ return s.idleTimeout(sess)
+ }
+ return sessionIdleTimeout(sess)
+}
+
+// sessionIdleTimeout returns the read timeout for a session: the account-name
+// and password prompts get a short window (pre-auth sockets are the ones an
+// anonymous flood targets), everything else gets a generous idle timeout.
+func sessionIdleTimeout(sess *Session) time.Duration {
+ switch sess.State() {
+ case StateAccountName, StatePassword:
+ return loginReadTimeout
+ default:
+ return idleReadTimeout
+ }
+}
+
+// armReadDeadline sets an idle read deadline on the session's transport when
+// it supports one. tcpConn and wsConn (both in this package) expose their
+// underlying connection, so telnet, telnet-TLS, and websocket are all covered.
+func armReadDeadline(sess *Session, d time.Duration) {
+ deadline := time.Now().Add(d)
+ switch c := sess.Conn.(type) {
+ case *tcpConn:
+ _ = c.conn.SetReadDeadline(deadline)
+ case *wsConn:
+ _ = c.conn.SetReadDeadline(deadline)
+ }
+}
+
// StartWriter puts the session into queued mode: writes are enqueued and a
// dedicated goroutine drains the queue to the network with per-message write
// deadlines. The goroutine exits after Close() — draining whatever was
@@ -705,12 +771,24 @@ func (s *Server) handleSession(sess *Session, handler func(*Session, string)) {
sess.Close()
}()
+ // Admit before reading anything: an over-capacity connection (including
+ // one arriving through the websocket transport, whose handler shares this
+ // path) is told so and dropped without holding a read loop.
+ if !s.admitConnection() {
+ sess.Write("Server is at full capacity. Please try again later.\r\n")
+ return
+ }
+ defer s.releaseConnection()
+
sess.Write("\033[2J\033[H")
sess.Write(WelcomeBanner())
sess.Write("What's your account name? ")
for {
+ // Re-armed every iteration so any message from the client resets the
+ // idle countdown.
+ armReadDeadline(sess, s.sessionIdleTimeout(sess))
line, err := sess.Conn.ReadMessage()
if err != nil {
log.Printf("session read error: %v", err)
diff --git a/internal/net/server_test.go b/internal/net/server_test.go
index c91c701..da8084a 100644
--- a/internal/net/server_test.go
+++ b/internal/net/server_test.go
@@ -1,6 +1,11 @@
package net
import (
+ "bytes"
+ "errors"
+ "io"
+ "net"
+ "strings"
"sync"
"testing"
"time"
@@ -46,6 +51,215 @@ func (c *testConn) SetWriteDeadline(time.Time) error { return nil }
func (c *testConn) Close() error { return nil }
func (c *testConn) SetEcho(bool) error { return nil }
+// fakeNetConn is a net.Conn stub for the session read loop: Read reports the
+// peer hung up so the read loop ends immediately, Write captures what the
+// server sent, and read deadlines are recorded so a test can assert the
+// transport was armed.
+type fakeNetConn struct {
+ mu sync.Mutex
+ written bytes.Buffer
+ deadline time.Time
+}
+
+func newFakeNetConn() *fakeNetConn { return &fakeNetConn{} }
+
+func (c *fakeNetConn) Read([]byte) (int, error) { return 0, io.EOF }
+
+func (c *fakeNetConn) Write(b []byte) (int, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.written.Write(b)
+}
+
+func (c *fakeNetConn) Close() error { return nil }
+
+func (c *fakeNetConn) LocalAddr() net.Addr { return nil }
+func (c *fakeNetConn) RemoteAddr() net.Addr { return nil }
+
+func (c *fakeNetConn) SetDeadline(t time.Time) error { return c.SetReadDeadline(t) }
+
+func (c *fakeNetConn) SetReadDeadline(t time.Time) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.deadline = t
+ return nil
+}
+
+func (c *fakeNetConn) SetWriteDeadline(time.Time) error { return nil }
+
+func (c *fakeNetConn) output() string {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.written.String()
+}
+
+func (c *fakeNetConn) readDeadline() time.Time {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.deadline
+}
+
+// TestConnectionCapRefusalKeepsTheServerDrained: at maxConnections a new
+// connection is refused without holding a slot (the counter stays at the cap),
+// and the refused peer is told why. Once the admitted sessions end, the live
+// count is back to zero.
+func TestConnectionCapRefusalKeepsTheServerDrained(t *testing.T) {
+ s := &Server{hub: NewHub()}
+ s.handler = func(*Session, string) {}
+
+ for i := 0; i < maxConnections; i++ {
+ if !s.admitConnection() {
+ t.Fatalf("connection %d of %d refused below the cap", i+1, maxConnections)
+ }
+ }
+
+ refused := newFakeNetConn()
+ s.handleSession(&Session{Conn: newTCPConn(refused)}, s.handler)
+
+ if got := s.conns.Load(); got != maxConnections {
+ t.Fatalf("live connection count after a refusal = %d, want %d (the refused connection held a slot)", got, maxConnections)
+ }
+ if !strings.Contains(refused.output(), "full capacity") {
+ t.Fatalf("refused connection was not told the server is full: %q", refused.output())
+ }
+
+ for i := 0; i < maxConnections; i++ {
+ s.releaseConnection()
+ }
+ if got := s.conns.Load(); got != 0 {
+ t.Fatalf("live connection count after every session ended = %d, want 0", got)
+ }
+}
+
+// TestAdmittedSessionReleasesItsSlot: an admitted session whose client hangs
+// up returns its slot, so a client cannot leak capacity by disconnecting.
+func TestAdmittedSessionReleasesItsSlot(t *testing.T) {
+ s := &Server{hub: NewHub()}
+ s.handler = func(*Session, string) {}
+
+ s.handleSession(&Session{Conn: newTCPConn(newFakeNetConn())}, s.handler)
+
+ if got := s.conns.Load(); got != 0 {
+ t.Fatalf("live connection count after the session ended = %d, want 0", got)
+ }
+}
+
+// TestArmReadDeadlineArmsTheTransport: the idle window is applied to the real
+// transport (not just computed), and the window itself depends on the protocol
+// state: pre-auth prompts get the short one.
+func TestArmReadDeadlineArmsTheTransport(t *testing.T) {
+ rec := newFakeNetConn()
+ sess := &Session{Conn: newTCPConn(rec)}
+
+ before := time.Now()
+ armReadDeadline(sess, loginReadTimeout)
+ got := rec.readDeadline()
+ if got.Before(before.Add(loginReadTimeout)) || got.After(time.Now().Add(loginReadTimeout)) {
+ t.Fatalf("armed read deadline = %v, want about %v", got, before.Add(loginReadTimeout))
+ }
+
+ sess.SetState(StateAccountName)
+ if got := sessionIdleTimeout(sess); got != loginReadTimeout {
+ t.Fatalf("account-name window = %v, want %v", got, loginReadTimeout)
+ }
+ sess.SetState(StatePassword)
+ if got := sessionIdleTimeout(sess); got != loginReadTimeout {
+ t.Fatalf("password window = %v, want %v", got, loginReadTimeout)
+ }
+ sess.SetState(StateGame)
+ if got := sessionIdleTimeout(sess); got != idleReadTimeout {
+ t.Fatalf("in-game window = %v, want %v", got, idleReadTimeout)
+ }
+}
+
+// TestIdleSessionDroppedWithoutAffectingActiveOne: an idle socket is dropped
+// once its armed read deadline expires, while a client that keeps sending
+// messages survives well past that window because every read re-arms the
+// deadline.
+func TestIdleSessionDroppedWithoutAffectingActiveOne(t *testing.T) {
+ s := &Server{hub: NewHub()}
+ var mu sync.Mutex
+ served := 0
+ s.handler = func(*Session, string) {
+ mu.Lock()
+ served++
+ mu.Unlock()
+ }
+ const window = 200 * time.Millisecond
+ s.idleTimeout = func(*Session) time.Duration { return window }
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer ln.Close()
+ go func() {
+ for {
+ conn, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ sess := &Session{Conn: newTCPConn(conn)}
+ sess.SetState(StateAccountName)
+ s.hub.Add(sess)
+ go s.handleSession(sess, s.handler)
+ }
+ }()
+
+ idle, err := net.Dial("tcp", ln.Addr().String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer idle.Close()
+ active, err := net.Dial("tcp", ln.Addr().String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer active.Close()
+
+ // The active client talks for five windows' worth of messages: it can only
+ // be served to the end if each message re-armed its read deadline.
+ sent := 10
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ for i := 0; i < sent; i++ {
+ if _, err := active.Write([]byte("look\r\n")); err != nil {
+ return
+ }
+ time.Sleep(window / 4)
+ }
+ }()
+
+ // The silent client is dropped: its socket is closed by the server as soon
+ // as the armed deadline expires.
+ if err := idle.SetReadDeadline(time.Now().Add(10 * window)); err != nil {
+ t.Fatal(err)
+ }
+ buf := make([]byte, 4096)
+ for {
+ n, err := idle.Read(buf)
+ if n > 0 {
+ continue
+ }
+ if err == nil {
+ continue
+ }
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ t.Fatalf("idle client was not dropped after its read deadline: %v", err)
+ }
+
+ <-done
+ mu.Lock()
+ got := served
+ mu.Unlock()
+ if got < sent-2 {
+ t.Fatalf("the active client was served %d of %d messages; its read deadline was not re-armed per message", got, sent)
+ }
+}
+
func TestSessionWriteConcurrency(t *testing.T) {
conn := &testConn{}
sess := &Session{Conn: conn}
diff --git a/internal/persistence/hardening_txn_commit_durability_test.go b/internal/persistence/hardening_txn_commit_durability_test.go
new file mode 100644
index 0000000..f02bc9f
--- /dev/null
+++ b/internal/persistence/hardening_txn_commit_durability_test.go
@@ -0,0 +1,135 @@
+package persistence
+
+// Commit-point durability regressions: the unlink that completes a
+// transaction is the commit point, so it must be retried and made durable
+// exactly like the journal write and every applied file. Otherwise a crash
+// resurrects the journal and Recover rolls back an operation the caller was
+// told succeeded.
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// TestCommitPointSyncsJournalDirectory: the journal removal is followed by a
+// sync of the journal directory. Without it the unlink may not survive a
+// crash and the replayed before-images revert committed work (and clobber
+// later writes to the same files).
+func TestCommitPointSyncsJournalDirectory(t *testing.T) {
+ dir := t.TempDir()
+ journalDir := filepath.Join(dir, ".journal")
+ writeTestFile(t, filepath.Join(dir, "a.yaml"), "old-a")
+
+ txn := NewTransaction(journalDir)
+ if err := txn.Replace(filepath.Join(dir, "a.yaml"), []byte("new-a"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ var synced []string
+ txn.dirSync = func(d string) error {
+ synced = append(synced, d)
+ return syncDir(d)
+ }
+ if err := txn.Commit(); err != nil {
+ t.Fatalf("commit: %v", err)
+ }
+ if len(synced) == 0 {
+ t.Fatal("commit point removed the journal without syncing the journal directory: a crash can resurrect it and roll back the committed transaction")
+ }
+ for _, d := range synced {
+ if d != journalDir {
+ t.Fatalf("synced %s, want the journal directory %s", d, journalDir)
+ }
+ }
+ // The commit point holds: the journal is gone and nothing is rolled back.
+ if err := Recover(journalDir); err != nil {
+ t.Fatalf("recover after commit: %v", err)
+ }
+ if got := readTestFile(t, filepath.Join(dir, "a.yaml")); got != "new-a" {
+ t.Fatalf("committed write reverted: %q", got)
+ }
+}
+
+// TestCommitReportsNonDurableCommitPoint: when the directory sync after the
+// removal fails, the removal may not survive a crash, so Commit must not
+// report a completed transaction: the error names the rollback risk.
+func TestCommitReportsNonDurableCommitPoint(t *testing.T) {
+ dir := t.TempDir()
+ journalDir := filepath.Join(dir, ".journal")
+ writeTestFile(t, filepath.Join(dir, "a.yaml"), "old-a")
+
+ txn := NewTransaction(journalDir)
+ if err := txn.Replace(filepath.Join(dir, "a.yaml"), []byte("new-a"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ txn.dirSync = func(string) error { return errors.New("injected sync failure") }
+ err := txn.Commit()
+ if err == nil {
+ t.Fatal("a non-durable commit point must be reported as a failure")
+ }
+ if !strings.Contains(err.Error(), "could not be synced") {
+ t.Fatalf("error must name the unsynced removal, got: %v", err)
+ }
+ // The journal is gone from the visible filesystem but its removal is not
+ // durable: recovery at the next startup may roll the operation back.
+ if entries, _ := ListJournalFiles(journalDir); len(entries) != 0 {
+ t.Fatalf("journal removal did not happen: %v", entries)
+ }
+}
+
+// TestRemoveJournalRetainsJournalOnPersistentFailure: when the unlink itself
+// keeps failing, Commit must return an error that names the retained journal
+// and the rollback that will follow, and the journal must stay on disk as the
+// durable record. Once the fault is repaired the removal succeeds.
+//
+// The failure is injected with a journal path that cannot be unlinked (a
+// non-empty directory, which unlink refuses regardless of privilege) instead
+// of a read-only directory: a chmod-based injection is a no-op for root, so
+// under the root-owned test runner the test used to skip and never executed
+// the retained-journal branch at all.
+func TestRemoveJournalRetainsJournalOnPersistentFailure(t *testing.T) {
+ dir := t.TempDir()
+ journalDir := filepath.Join(dir, ".journal")
+ if err := os.MkdirAll(journalDir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ journalPath := filepath.Join(journalDir, "txn-test.json")
+ if err := os.Mkdir(journalPath, 0700); err != nil {
+ t.Fatal(err)
+ }
+ blocker := filepath.Join(journalPath, "keep")
+ if err := os.WriteFile(blocker, []byte("keep"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Remove(journalPath); err == nil || os.IsNotExist(err) {
+ t.Fatalf("removing a non-empty journal path unexpectedly succeeded: a removal failure cannot be injected")
+ }
+
+ txn := NewTransaction(journalDir)
+ err := txn.removeJournalDurable(journalPath)
+ if err == nil {
+ t.Fatal("a persistent journal removal failure must be reported, not swallowed")
+ }
+ for _, want := range []string{"retained", "rolled back"} {
+ if !strings.Contains(err.Error(), want) {
+ t.Fatalf("error must state that the journal is %q, got: %v", want, err)
+ }
+ }
+ if _, statErr := os.Stat(journalPath); statErr != nil {
+ t.Fatalf("the retained journal is the durable record and must stay on disk: %v", statErr)
+ }
+
+ // Repair: the path is unlinkable again, so the next attempt removes the
+ // journal and succeeds.
+ if err := os.Remove(blocker); err != nil {
+ t.Fatal(err)
+ }
+ if err := txn.removeJournalDurable(journalPath); err != nil {
+ t.Fatalf("removal after repair: %v", err)
+ }
+ if _, statErr := os.Stat(journalPath); !os.IsNotExist(statErr) {
+ t.Fatalf("journal not removed after repair: %v", statErr)
+ }
+}
diff --git a/internal/persistence/txn.go b/internal/persistence/txn.go
index 3016a19..d09feaa 100644
--- a/internal/persistence/txn.go
+++ b/internal/persistence/txn.go
@@ -29,6 +29,11 @@ import (
"path/filepath"
)
+// journalRemoveAttempts bounds the retries of a journal unlink: the unlink is
+// a transaction's commit point, so a transient failure must not be reported
+// as a committed transaction that the next startup will roll back.
+const journalRemoveAttempts = 3
+
type fileOp struct {
// Replace / Delete / Move data
Path string `json:"path"` // file this record is keyed by (its before-image lives here)
@@ -59,6 +64,51 @@ type Transaction struct {
ops map[string]*fileOp
order []string
journalDir string
+ // dirSync makes a journal directory entry durable (nil means syncDir).
+ // It is a field rather than a package-level function so a test can
+ // assert that the commit point is synced and inject a sync failure.
+ dirSync func(string) error
+}
+
+// syncDir fsyncs a directory so an entry created or removed in it survives a
+// crash. POSIX does not tie a file's own fsync to its directory entry, so the
+// journal write, its removal, and every applied file are synced in both.
+func syncDir(dir string) error {
+ d, err := os.Open(dir)
+ if err != nil {
+ return err
+ }
+ defer d.Close()
+ return d.Sync()
+}
+
+// syncJournalDir syncs the transaction's journal directory.
+func (t *Transaction) syncJournalDir() error {
+ if t.dirSync != nil {
+ return t.dirSync(t.journalDir)
+ }
+ return syncDir(t.journalDir)
+}
+
+// removeJournalDurable unlinks a journal and syncs its directory, so the
+// removal itself is durable. The removal is the transaction's commit point: an
+// unlink that a crash forgets resurrects the journal, and Recover then rolls
+// back an operation the caller was told succeeded. A failing remove is retried;
+// a persistent failure is reported as such, with the journal left on disk as
+// the durable record of the transaction (the next startup rolls it back).
+func (t *Transaction) removeJournalDurable(path string) error {
+ var removeErr error
+ for attempt := 0; attempt < journalRemoveAttempts; attempt++ {
+ removeErr = os.Remove(path)
+ if removeErr == nil || os.IsNotExist(removeErr) {
+ if err := t.syncJournalDir(); err != nil {
+ return fmt.Errorf("journal %s removed but its directory could not be synced (a crash may resurrect the journal and roll the transaction back): %w", path, err)
+ }
+ return nil
+ }
+ }
+ return fmt.Errorf("journal %s could not be removed after %d attempts and is retained on disk (the transaction is not durable and will be rolled back at the next startup): %w",
+ path, journalRemoveAttempts, removeErr)
}
func NewTransaction(journalDir string) *Transaction {
@@ -167,9 +217,10 @@ func (t *Transaction) MarshalJournal() ([]byte, error) {
return json.Marshal(journalEntry{Ops: ops})
}
-// Commit writes the journal (durable), applies all operations, and removes
-// the journal. Any failure rolls back every already-applied operation and
-// the journal itself, leaving the filesystem exactly as before the commit.
+// Commit writes the journal (durable), applies all operations, and removes the
+// journal durably, so the commit point itself survives a crash. Any failure
+// rolls back every already-applied operation and the journal itself, leaving
+// the filesystem exactly as before the commit.
func (t *Transaction) Commit() error {
if len(t.order) == 0 {
return nil
@@ -200,16 +251,22 @@ func (t *Transaction) Commit() error {
// TestCommitKeepsJournalAfterFailedRollback.
return fmt.Errorf("%v; rollback failed (journal kept for recovery: %s): %v", err, journalPath, rbErr)
}
- if rErr := os.Remove(journalPath); rErr != nil && !os.IsNotExist(rErr) {
+ // The operations were rolled back, so the leftover journal holds
+ // nothing but the pre-transaction before-images; removing it must be
+ // durable for the same reason the commit point is.
+ if rErr := t.removeJournalDurable(journalPath); rErr != nil {
err = fmt.Errorf("%v; journal cleanup failed: %v", err, rErr)
}
return err
}
// Commit point: the journal is the record of applied operations. After
- // it is removed the transaction is complete and unrecoverable.
- if err := os.Remove(journalPath); err != nil && !os.IsNotExist(err) {
- return fmt.Errorf("commit completed; remove journal: %w", err)
+ // it is removed the transaction is complete and unrecoverable, so the
+ // removal must be durable like every other step: a crash that forgets it
+ // would replay the before-images over operations reported as committed
+ // (and over later writes to the same files).
+ if err := t.removeJournalDurable(journalPath); err != nil {
+ return err
}
return nil
}
@@ -322,8 +379,10 @@ func Recover(journalDir string) error {
if err := t.rollback(); err != nil {
return fmt.Errorf("recover %s: %w", path, err)
}
- if err := os.Remove(path); err != nil {
- return fmt.Errorf("remove recovered journal %s: %w", path, err)
+ // The rollback is durable only once its journal removal is: a
+ // resurrected journal would roll back later writes to the same files.
+ if err := t.removeJournalDurable(path); err != nil {
+ return err
}
}
return nil
diff --git a/internal/player/correct_skill_validity_test.go b/internal/player/correct_skill_validity_test.go
new file mode 100644
index 0000000..941bfab
--- /dev/null
+++ b/internal/player/correct_skill_validity_test.go
@@ -0,0 +1,21 @@
+package player
+
+// Regression: craft recipes declare their skill as the recipe TYPE, and not
+// every type is a skill (a combine recipe declares "combine"). Callers must be
+// able to tell a real skill name from an action name, because Level reads an
+// unknown name as a map miss (level 1) and AddSkillXP persists it.
+
+import "testing"
+
+func TestValidSkillRecognizesDeclaredSkillsOnly(t *testing.T) {
+ for _, s := range AllSkills {
+ if !ValidSkill(s) {
+ t.Errorf("ValidSkill(%q) = false, want true", s)
+ }
+ }
+ for _, name := range []SkillName{"combine", "fletch", "craft", "", "not_a_skill"} {
+ if ValidSkill(name) {
+ t.Errorf("ValidSkill(%q) = true, want false", name)
+ }
+ }
+}
diff --git a/internal/player/hardening_max_inventory_slots_test.go b/internal/player/hardening_max_inventory_slots_test.go
new file mode 100644
index 0000000..8cd9c37
--- /dev/null
+++ b/internal/player/hardening_max_inventory_slots_test.go
@@ -0,0 +1,86 @@
+package player
+
+// Regression coverage for the inventory slot accounting helpers: the capacity
+// is a named constant, FirstFreeSlot stays inside it, and FreeSlots ignores
+// entries a corrupt save can carry that no gameplay path can free.
+
+import "testing"
+
+func TestMaxInventorySlotsMatchesSlotHelpers(t *testing.T) {
+ if MaxInventorySlots != 28 {
+ t.Fatalf("MaxInventorySlots = %d, want 28 (on-disk capacity)", MaxInventorySlots)
+ }
+ p := New("alice")
+ if got := p.FreeSlots(); got != MaxInventorySlots {
+ t.Fatalf("empty inventory free slots = %d, want %d", got, MaxInventorySlots)
+ }
+ if got := p.FirstFreeSlot(); got != 0 {
+ t.Fatalf("empty inventory first free slot = %d, want 0", got)
+ }
+
+ // The last valid slot is addressable, and the first free slot walks up to
+ // the capacity and only then fails.
+ for i := 0; i < MaxInventorySlots-1; i++ {
+ p.SetInvSlot(i, &InventorySlot{ItemID: "rock", Quantity: 1})
+ }
+ if got := p.FirstFreeSlot(); got != MaxInventorySlots-1 {
+ t.Fatalf("first free slot = %d, want %d", got, MaxInventorySlots-1)
+ }
+ if got := p.FreeSlots(); got != 1 {
+ t.Fatalf("free slots = %d, want 1", got)
+ }
+ p.SetInvSlot(MaxInventorySlots-1, &InventorySlot{ItemID: "rock", Quantity: 1})
+ if got := p.FirstFreeSlot(); got != -1 {
+ t.Fatalf("full inventory first free slot = %d, want -1", got)
+ }
+ if got := p.FreeSlots(); got != 0 {
+ t.Fatalf("full inventory free slots = %d, want 0", got)
+ }
+}
+
+// TestFreeSlotsIgnoresInvalidEntries: out-of-range keys (a negative one, and
+// one past the capacity) and nil-valued entries must not consume capacity or
+// drive the count negative, while the valid slots keep counting exactly as
+// before.
+func TestFreeSlotsIgnoresInvalidEntries(t *testing.T) {
+ p := New("alice")
+ p.Inventory = map[int]*InventorySlot{
+ -1: {ItemID: "rock", Quantity: 1},
+ MaxInventorySlots: {ItemID: "rock", Quantity: 1},
+ MaxInventorySlots + 5: {ItemID: "rock", Quantity: 1},
+ 3: nil, // an explicitly nil entry frees its slot
+ 7: {ItemID: "rock", Quantity: 1},
+ }
+
+ if got := p.FreeSlots(); got != MaxInventorySlots-1 {
+ t.Fatalf("free slots with invalid entries = %d, want %d", got, MaxInventorySlots-1)
+ }
+ if got := p.FirstFreeSlot(); got != 0 {
+ t.Fatalf("first free slot = %d, want 0", got)
+ }
+
+ // Only valid, non-nil slots are occupied: with every in-range slot filled
+ // the invalid keys must not make the count negative.
+ p.Inventory = make(map[int]*InventorySlot, MaxInventorySlots+2)
+ for i := 0; i < MaxInventorySlots; i++ {
+ p.Inventory[i] = &InventorySlot{ItemID: "rock", Quantity: 1}
+ }
+ p.Inventory[-7] = &InventorySlot{ItemID: "rock", Quantity: 1}
+ p.Inventory[99] = &InventorySlot{ItemID: "rock", Quantity: 1}
+ if got := p.FreeSlots(); got != 0 {
+ t.Fatalf("free slots of a full inventory with stray keys = %d, want 0", got)
+ }
+ if got := p.FirstFreeSlot(); got != -1 {
+ t.Fatalf("first free slot of a full inventory with stray keys = %d, want -1", got)
+ }
+}
+
+func TestFreeSlotsCountsValidSlotsLikeLength(t *testing.T) {
+ p := New("alice")
+ for _, i := range []int{0, 5, 27} {
+ p.SetInvSlot(i, &InventorySlot{ItemID: "rock", Quantity: 1})
+ }
+ if got, want := p.FreeSlots(), MaxInventorySlots-3; got != want {
+ t.Fatalf("free slots = %d, want %d", got, want)
+ }
+}
diff --git a/internal/player/hardening_password_params_test.go b/internal/player/hardening_password_params_test.go
new file mode 100644
index 0000000..4c171f1
--- /dev/null
+++ b/internal/player/hardening_password_params_test.go
@@ -0,0 +1,62 @@
+package player
+
+// Regression: Argon2id parameters parsed from a stored hash must be bounded
+// before they reach argon2.IDKey. A hand-edited or corrupted account file
+// (e.g. argon2id:1:4294967295:...) must fail the check, not request a
+// multi-TiB allocation that kills the process.
+
+import (
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "testing"
+)
+
+func TestCheckPasswordRejectsOutOfRangeArgonParams(t *testing.T) {
+ // Enormous memory cost (0xFFFFFFFF KiB) with an otherwise well-formed
+ // hash: must be rejected before hashing.
+ huge := "argon2id:1:4294967295:2:AAAAAAAAAAAAAAAAAAAAAA:0011223344556677889900112233445566778899001122334455667788990011"
+ if CheckPassword("pw", huge) {
+ t.Fatal("out-of-range Argon2 memory cost was accepted")
+ }
+
+ // Negative values would wrap through uint32 under a naive conversion.
+ negative := "argon2id:-1:-1:-1:AAAAAAAAAAAAAAAAAAAAAA:0011223344556677889900112233445566778899001122334455667788990011"
+ if CheckPassword("pw", negative) {
+ t.Fatal("negative Argon2 parameters were accepted")
+ }
+
+ // Absurd time cost is bounded too.
+ slow := "argon2id:4294967295:65536:2:AAAAAAAAAAAAAAAAAAAAAA:0011223344556677889900112233445566778899001122334455667788990011"
+ if CheckPassword("pw", slow) {
+ t.Fatal("out-of-range Argon2 time cost was accepted")
+ }
+}
+
+func TestCheckPasswordAcceptsNormalAndLegacyHashes(t *testing.T) {
+ hashed, err := HashPassword("s3cret")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !CheckPassword("s3cret", hashed) {
+ t.Fatal("valid Argon2id hash rejected")
+ }
+ if CheckPassword("wrong", hashed) {
+ t.Fatal("wrong password accepted")
+ }
+
+ // Legacy single-pass sha256 hashes must keep verifying so old accounts
+ // can still log in and be upgraded.
+ salt := []byte("sixteen-byte-slt")
+ sum := sha256.Sum256(append(append([]byte{}, salt...), []byte("pw")...))
+ legacy := "sha256:" + base64.RawStdEncoding.EncodeToString(salt) + ":" + hex.EncodeToString(sum[:])
+ if !CheckPassword("pw", legacy) {
+ t.Fatal("legacy sha256 hash rejected")
+ }
+ if CheckPassword("nope", legacy) {
+ t.Fatal("wrong password accepted against legacy sha256 hash")
+ }
+ if !NeedsRehash(legacy) {
+ t.Fatal("legacy sha256 hash not flagged for rehash")
+ }
+}
diff --git a/internal/player/hardening_store_sanitize_test.go b/internal/player/hardening_store_sanitize_test.go
new file mode 100644
index 0000000..9416856
--- /dev/null
+++ b/internal/player/hardening_store_sanitize_test.go
@@ -0,0 +1,114 @@
+package player
+
+// Regression: LoadCharacter must sanitize the inventory/bank slot maps. A
+// hand-edited or corrupt save with out-of-range keys, nil entries, or
+// non-positive quantities used to leave items no gameplay loop could reach
+// while still consuming capacity, and could drive FreeSlots negative.
+
+import (
+ "os"
+ "testing"
+)
+
+func TestLoadCharacterSanitizesSlotMaps(t *testing.T) {
+ s := testAccountStore(t)
+ content := `name: Corrupt
+room_id: 1
+inventory:
+ 0:
+ item_id: rope
+ quantity: 2
+ 3: null
+ 5:
+ item_id: ghost
+ quantity: 0
+ 7:
+ item_id: negative
+ quantity: -4
+ 99:
+ item_id: out_of_range
+ quantity: 1
+bank:
+ 0:
+ item_id: coins
+ quantity: 100
+ 30:
+ item_id: far_slot
+ quantity: 1
+ -2:
+ item_id: bogus
+ quantity: 3
+ 4: null
+ 6:
+ item_id: empty
+ quantity: 0
+`
+ if err := os.WriteFile(s.CharPath("Corrupt"), []byte(content), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ p, err := s.LoadCharacter("Corrupt")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(p.Inventory) != 1 {
+ t.Fatalf("inventory = %v, want only the valid slot 0", p.Inventory)
+ }
+ slot := p.Inventory[0]
+ if slot == nil || slot.ItemID != "rope" || slot.Quantity != 2 {
+ t.Errorf("valid slot 0 changed: %+v", slot)
+ }
+ if got := p.FreeSlots(); got != 27 {
+ t.Errorf("FreeSlots() = %d, want 27", got)
+ }
+ if got := p.FreeSlots(); got < 0 {
+ t.Errorf("FreeSlots() went negative: %d", got)
+ }
+
+ if len(p.Bank) != 2 {
+ t.Fatalf("bank = %v, want only slots 0 and 30", p.Bank)
+ }
+ if bs := p.Bank[0]; bs == nil || bs.ItemID != "coins" || bs.Quantity != 100 {
+ t.Errorf("valid bank slot 0 changed: %+v", bs)
+ }
+ // Bank slots past the inventory's 28 are valid: the bank grows unbounded.
+ if bs := p.Bank[30]; bs == nil || bs.ItemID != "far_slot" {
+ t.Errorf("valid bank slot 30 dropped: %+v", bs)
+ }
+ if _, ok := p.Bank[-2]; ok {
+ t.Error("negative bank slot survived sanitization")
+ }
+}
+
+func TestSaveLoadKeepsValidSlots(t *testing.T) {
+ s := testAccountStore(t)
+ p := New("Alice")
+ p.SetInvSlot(0, &InventorySlot{ItemID: "rope", Quantity: 2, Quality: 3, MaxQuality: 5})
+ p.SetInvSlot(27, &InventorySlot{ItemID: "torch", Quantity: 1})
+ p.SetBankSlot(0, &InventorySlot{ItemID: "coins", Quantity: 100})
+ p.SetBankSlot(30, &InventorySlot{ItemID: "spade", Quantity: 1})
+ if err := s.SaveCharacter(p); err != nil {
+ t.Fatal(err)
+ }
+
+ loaded, err := s.LoadCharacter("Alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(loaded.Inventory) != 2 || len(loaded.Bank) != 2 {
+ t.Fatalf("slots lost on a round trip: inventory=%v bank=%v", loaded.Inventory, loaded.Bank)
+ }
+ if got := loaded.Inventory[0]; got == nil || got.ItemID != "rope" || got.Quantity != 2 || got.Quality != 3 || got.MaxQuality != 5 {
+ t.Errorf("inventory slot 0 changed: %+v", got)
+ }
+ if got := loaded.Inventory[27]; got == nil || got.ItemID != "torch" {
+ t.Errorf("inventory slot 27 changed: %+v", got)
+ }
+ if got := loaded.Bank[30]; got == nil || got.ItemID != "spade" {
+ t.Errorf("bank slot 30 changed: %+v", got)
+ }
+ if loaded.FreeSlots() != 26 {
+ t.Errorf("FreeSlots() = %d, want 26", loaded.FreeSlots())
+ }
+}
diff --git a/internal/player/password.go b/internal/player/password.go
index 9be39fb..5a43e1b 100644
--- a/internal/player/password.go
+++ b/internal/player/password.go
@@ -31,6 +31,23 @@ const (
argonSaltLen = 16
)
+// Sanity bounds applied to Argon2id parameters parsed from a stored hash.
+// HashPassword always writes the constants above, but a hand-edited or
+// corrupted account file can carry arbitrary numbers; without these bounds a
+// value like 4294967295 (or a negative that wraps through uint32) makes
+// argon2.IDKey request a multi-TiB allocation and kills the process instead of
+// failing the login. Anything outside these ranges is rejected before hashing.
+const (
+ argonMinTime = 1
+ argonMaxTime = 8
+ argonMinMemory = 8 * 1024 // KiB (8 MiB)
+ argonMaxMemory = 256 * 1024 // KiB (256 MiB)
+ argonMinThreads = 1
+ argonMaxThreads = 16
+ argonMinKeyLen = 16
+ argonMaxKeyLen = 64
+)
+
// hashLimiter bounds concurrent Argon2id computations. Each hash costs
// ~64 MiB and tens of milliseconds, so an unbounded burst of logins (or
// login attempts) would spike memory and CPU in parallel. Password work
@@ -81,6 +98,14 @@ func CheckPassword(password, stored string) bool {
if errT != nil || errM != nil || errP != nil || errSalt != nil || errKey != nil {
return false
}
+ if t < argonMinTime || t > argonMaxTime ||
+ m < argonMinMemory || m > argonMaxMemory ||
+ p < argonMinThreads || p > argonMaxThreads ||
+ len(expected) < argonMinKeyLen || len(expected) > argonMaxKeyLen {
+ // Out-of-range parameters: fail the check instead of trusting the
+ // stored values and requesting an enormous allocation.
+ return false
+ }
acquireHashSlot()
key := argon2.IDKey([]byte(password), salt, uint32(t), uint32(m), uint8(p), uint32(len(expected)))
releaseHashSlot()
diff --git a/internal/player/player.go b/internal/player/player.go
index 0a48b04..f8d9a68 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -43,6 +43,21 @@ var AllSkills = []SkillName{
Pharmacy, Thieving, Agility, Construction, Scavenging, Hacking, Assassin, Farming,
}
+// ValidSkill reports whether name is a skill this game defines. A craft
+// recipe's declared skill is item.CraftDef.EffectiveSkill, which is the recipe
+// TYPE, and not every type is a skill: a combine recipe carries type "combine".
+// Level reads an undefined name as a map miss (level 1) and AddSkillXP would
+// persist a phantom entry under it, so callers that roll success or award XP
+// against a recipe type must check with this first.
+func ValidSkill(name SkillName) bool {
+ for _, s := range AllSkills {
+ if s == name {
+ return true
+ }
+ }
+ return false
+}
+
var SkillAbbr = map[SkillName]string{
Accuracy: "acc",
Strength: "str",
@@ -353,8 +368,13 @@ func (p *Player) SetInvSlot(i int, slot *InventorySlot) {
}
}
+// MaxInventorySlots is the inventory capacity: the valid slots are
+// 0..MaxInventorySlots-1. characterInventorySlots in store.go mirrors this
+// value for on-disk sanitization.
+const MaxInventorySlots = 28
+
func (p *Player) FirstFreeSlot() int {
- for i := 0; i < 28; i++ {
+ for i := 0; i < MaxInventorySlots; i++ {
if p.Inventory[i] == nil {
return i
}
@@ -362,9 +382,19 @@ func (p *Player) FirstFreeSlot() int {
return -1
}
+// FreeSlots counts the empty inventory slots. Only valid slots (0..27) with a
+// non-nil entry count as used: a corrupt, hand-edited, or partially-migrated
+// save can carry out-of-range or nil-valued map entries that no gameplay path
+// can free, and counting those would understate the free space (or drive the
+// count negative). For a valid character the result is unchanged.
func (p *Player) FreeSlots() int {
- used := len(p.Inventory)
- return 28 - used
+ used := 0
+ for i := 0; i < MaxInventorySlots; i++ {
+ if p.Inventory[i] != nil {
+ used++
+ }
+ }
+ return MaxInventorySlots - used
}
func New(name string) *Player {
diff --git a/internal/player/store.go b/internal/player/store.go
index d461485..4c8dce5 100644
--- a/internal/player/store.go
+++ b/internal/player/store.go
@@ -41,7 +41,18 @@ func (s *AccountStore) SaveAccount(acc *Account) error {
if err != nil {
return err
}
- return persistence.WriteFile(s.AccountPath(acc.Name), data, 0600)
+ return writePrivate(s.AccountPath(acc.Name), data)
+}
+
+// writePrivate writes data atomically and then forces 0600. persistence.WriteFile
+// deliberately preserves an existing file's mode, so an account/character file
+// created before this rule (or by an external tool) would otherwise stay
+// world-readable through every later save and password rehash.
+func writePrivate(path string, data []byte) error {
+ if err := persistence.WriteFile(path, data, 0600); err != nil {
+ return err
+ }
+ return os.Chmod(path, 0600)
}
// MarshalAccount serializes an account; callers that own the account (the
@@ -78,6 +89,10 @@ func (s *AccountStore) FindAccount(name string) (string, error) {
return "", fmt.Errorf("account not found: %s", name)
}
+// characterInventorySlots is the on-disk inventory capacity, matching
+// Player.FreeSlots and Player.FirstFreeSlot in player.go.
+const characterInventorySlots = MaxInventorySlots
+
func (s *AccountStore) CharPath(name string) string {
return filepath.Join(s.dataDir, "players", "characters", name+".yaml")
}
@@ -101,18 +116,43 @@ func (s *AccountStore) LoadCharacter(name string) (*Player, error) {
if p.Inventory == nil {
p.Inventory = make(map[int]*InventorySlot)
}
+ sanitizeInventory(p.Inventory)
+ sanitizeBank(p.Bank)
if p.MapSymbols == nil {
p.MapSymbols = make(map[int]MapSymbolData)
}
return &p, nil
}
+// sanitizeInventory drops inventory entries a hand-edited or corrupt save can
+// carry that no gameplay path can reach or free: nil slots, keys outside the
+// 0..characterInventorySlots-1 range, and non-positive quantities. FreeSlots
+// already ignores invalid entries, but FirstFreeSlot and the on-disk save still
+// need the map itself to be clean, so sanitize here as well.
+func sanitizeInventory(slots map[int]*InventorySlot) {
+ for i, slot := range slots {
+ if slot == nil || slot.Quantity <= 0 || i < 0 || i >= characterInventorySlots {
+ delete(slots, i)
+ }
+ }
+}
+
+// sanitizeBank applies the same rule to the bank, which unlike the inventory
+// grows past 28 slots (NextBankSlot), so only negative keys are out of range.
+func sanitizeBank(slots map[int]*InventorySlot) {
+ for i, slot := range slots {
+ if slot == nil || slot.Quantity <= 0 || i < 0 {
+ delete(slots, i)
+ }
+ }
+}
+
func (s *AccountStore) SaveCharacter(p *Player) error {
data, err := s.MarshalCharacter(p)
if err != nil {
return err
}
- return persistence.WriteFile(s.CharPath(p.Name), data, 0600)
+ return writePrivate(s.CharPath(p.Name), data)
}
// MarshalCharacter serializes a character for a same-directory temporary
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index 5eb4288..eb24721 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -13,6 +13,7 @@ import (
"thehouseoficarus/internal/casino"
"thehouseoficarus/internal/color"
"thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/item"
"thehouseoficarus/internal/world"
)
@@ -351,6 +352,56 @@ func validateMobAttackTypes(id string, types []string) []Issue {
return issues
}
+// validateMobCombatStats enforces the numbers a fighting mob needs: a positive
+// attack speed (engine.ToTicks clips 0 to 1, so a missing speed makes the mob
+// attack every tick) and a positive max hit for each declared attack type (a
+// missing/zero max hit turns that attack into a silent no-op). Task worksites
+// (kind: task) are intentionally inert and exempt — e.g.
+// data/mobs/examples/solar_panel_frame.yaml ships max_melee_hit: 0.
+func validateMobCombatStats(id string, c *world.MobCombat) []Issue {
+ var issues []Issue
+ if c.Kind == "task" {
+ return issues
+ }
+ stats := c.Stats
+ if stats.Speed <= 0 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: stats.speed must be > 0 (0 is clipped to 1 tick, so the mob would attack every tick)", id),
+ })
+ }
+ for _, t := range c.AttackTypes {
+ switch {
+ case combat.IsMeleeType(t):
+ if stats.MaxMeleeHit <= 0 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: stats.max_melee_hit must be > 0 for attack type %q", id, t),
+ })
+ }
+ case t == combat.AttackRanged:
+ if stats.MaxRangedHit <= 0 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: stats.max_ranged_hit must be > 0 for attack type %q", id, t),
+ })
+ }
+ case t == combat.AttackScience:
+ if stats.MaxScienceHit <= 0 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: stats.max_science_hit must be > 0 for attack type %q", id, t),
+ })
+ }
+ }
+ }
+ return issues
+}
+
func validateMobs(s Source) []Issue {
var issues []Issue
itemIDs := s.Items.IDSet()
@@ -400,6 +451,10 @@ func validateMobs(s Source) []Issue {
issues = append(issues, validateMobAttackTypes(id, def.Combat.AttackTypes)...)
}
+ if def.Combat != nil {
+ issues = append(issues, validateMobCombatStats(id, def.Combat)...)
+ }
+
if def.Drops.Remains != "" && !itemIDs[def.Drops.Remains] {
issues = append(issues, Issue{
Level: "ERROR",
@@ -682,6 +737,17 @@ func validateCraftBlocks(s Source) []Issue {
return issues
}
+ // CraftDef.Tool names a tool *type* ("knife", "pickaxe", "fire"), not an
+ // item id: every runtime consumer matches it against ItemDef.ToolType() via
+ // hasToolType. Types such as "pickaxe" or "big_net" have no same-named item
+ // file, so membership is checked against the types the item defs define.
+ toolTypes := make(map[string]bool)
+ for _, def := range allItems {
+ if t := def.ToolType(); t != "" {
+ toolTypes[t] = true
+ }
+ }
+
for _, item := range allItems {
if len(item.Craft) == 0 {
continue
@@ -695,6 +761,18 @@ func validateCraftBlocks(s Source) []Issue {
}
for _, e := range c.Ingredients {
+ // Byproducts pair with the alternatives by index
+ // (collectByproducts reads Byproducts[j] for Items[j]), so a
+ // short or reordered list silently returns the wrong
+ // container. Require an exact pairing.
+ if len(e.Byproducts) != 0 && len(e.Byproducts) != len(e.Items) {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Item %q (craft: %s): ingredient entry has %d byproduct(s) for %d alternative(s) — byproducts pair with items by index and must be empty or match in length",
+ item.ID, craftLabel, len(e.Byproducts), len(e.Items)),
+ })
+ }
for _, ingredientID := range e.Items {
if ingredientID != "" && !itemIDs[ingredientID] {
issues = append(issues, Issue{
@@ -705,6 +783,28 @@ func validateCraftBlocks(s Source) []Issue {
})
}
}
+ // Byproducts are delivered into inventory (or dropped) after
+ // consumption, so a typo here has no runtime error to catch it:
+ // it silently creates a phantom slot with no item definition.
+ for _, byproductID := range e.Byproducts {
+ if byproductID != "" && !itemIDs[byproductID] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Item %q (craft: %s): returns nonexistent byproduct %q",
+ item.ID, craftLabel, byproductID),
+ })
+ }
+ }
+ }
+
+ if c.Tool != "" && !toolTypes[c.Tool] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Item %q (craft: %s): requires nonexistent tool %q",
+ item.ID, craftLabel, c.Tool),
+ })
}
if c.Fail != "" && !itemIDs[c.Fail] {
@@ -716,6 +816,45 @@ func validateCraftBlocks(s Source) []Issue {
})
}
+ // A fail product or fail_message is only reachable when the recipe
+ // can actually fail. With no success formula the engine hardcodes a
+ // 100% success chance (see production_engine.go), so either field is
+ // dead data that would never show or drop. Kept as WARN (not ERROR)
+ // because a handful of shipped recipes author one without the other.
+ if !craftFailureReachable(c) {
+ if c.Fail != "" {
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "semantic",
+ Message: fmt.Sprintf("Item %q (craft: %s): fail product %q is unreachable — success is always 100%% (add a success formula)",
+ item.ID, craftLabel, c.Fail),
+ })
+ }
+ if c.FailMessage != "" {
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "semantic",
+ Message: fmt.Sprintf("Item %q (craft: %s): fail_message is unreachable — success is always 100%% (add a success formula)",
+ item.ID, craftLabel),
+ })
+ }
+ }
+
+ // A recipe that can fail but declares no fail: product destroys the
+ // materials it consumes: the failure branch removes the ingredients
+ // and returns nothing. Every shipped failable recipe hands back one
+ // of its own ingredients (the bars, the pottery, the cooked fish),
+ // so flag new ones rather than let the loss be silent. WARN, not
+ // ERROR: a recipe may legitimately choose to destroy materials.
+ if c.Fail == "" && (craftFailureReachable(c) || c.FailMessage != "") {
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "semantic",
+ Message: fmt.Sprintf("Item %q (craft: %s): failure destroys materials with no fail product",
+ item.ID, craftLabel),
+ })
+ }
+
for _, sid := range c.Station {
if sid != "" && !objIDs[sid] {
issues = append(issues, Issue{
@@ -732,6 +871,18 @@ func validateCraftBlocks(s Source) []Issue {
return issues
}
+// craftFailureReachable reports whether a recipe can ever fail. With no
+// success formula the engine treats the success chance as 1.0, so a declared
+// fail product or fail_message would never be produced or shown. A formula is
+// reachable when the chance at the required level (base, clamped to cap) is
+// below 1.0.
+func craftFailureReachable(c *item.CraftDef) bool {
+ if c.Success == nil {
+ return false
+ }
+ return behavior.SuccessChance(behavior.SuccessFormula(*c.Success), c.Level, c.Level) < 1
+}
+
func validateDropTables(s Source) []Issue {
var issues []Issue
dropIDs := dropTableIDSet(s.DataDir)
diff --git a/internal/validate/correct_data_craft_validation_test.go b/internal/validate/correct_data_craft_validation_test.go
new file mode 100644
index 0000000..6b5e7f6
--- /dev/null
+++ b/internal/validate/correct_data_craft_validation_test.go
@@ -0,0 +1,102 @@
+package validate
+
+import (
+ "testing"
+)
+
+// Regression: CraftDef.Tool was validated against item ids, but it is a tool
+// *type* ("knife", "pickaxe", "fire"): runtime consumers match it with
+// ItemDef.ToolType() (g.hasToolType). Tool types such as pickaxe, axe, fire,
+// big_net and fly_rod have no same-named item file, so the old check rejected
+// legitimate recipes. These tests pin the tool-type namespace and the
+// byproduct/index pairing rule.
+
+func TestCraftToolTypeAcceptedWithoutSameNamedItem(t *testing.T) {
+ dir := t.TempDir()
+ // A pickaxe item defines tool.type: pickaxe but there is no item "pickaxe".
+ writeCraftItem(t, dir, "bronze_pickaxe", `id: bronze_pickaxe
+name: bronze pickaxe
+tool:
+ type: pickaxe
+ speed: 2
+`)
+ writeCraftItem(t, dir, "test_in", "id: test_in\nname: test in\n")
+ writeCraftItem(t, dir, "test_out", `id: test_out
+name: test out
+craft:
+ - type: crafting
+ tool: pickaxe
+ ingredients:
+ - items: [test_in]
+ quantity: 1
+`)
+
+ issues := validateCraftBlocks(newSource(dir))
+ for _, issue := range issues {
+ if issue.Level == "ERROR" {
+ t.Fatalf("a bare tool type must validate, got: %+v", issue)
+ }
+ }
+}
+
+func TestCraftToolTypeUnknownIsRejected(t *testing.T) {
+ dir := t.TempDir()
+ writeCraftItem(t, dir, "bronze_pickaxe", "id: bronze_pickaxe\nname: bronze pickaxe\ntool:\n type: pickaxe\n")
+ writeCraftItem(t, dir, "test_in", "id: test_in\nname: test in\n")
+ writeCraftItem(t, dir, "test_out", `id: test_out
+name: test out
+craft:
+ - type: crafting
+ tool: made_up_type
+ ingredients:
+ - items: [test_in]
+ quantity: 1
+`)
+
+ if issues := validateCraftBlocks(newSource(dir)); !containsMsg(issues, "nonexistent tool") {
+ t.Fatalf("expected nonexistent-tool error, got: %+v", issues)
+ }
+}
+
+func TestCraftByproductLengthMismatchIsFlagged(t *testing.T) {
+ dir := t.TempDir()
+ writeCraftItem(t, dir, "test_in", "id: test_in\nname: test in\n")
+ writeCraftItem(t, dir, "test_alt", "id: test_alt\nname: test alt\n")
+ writeCraftItem(t, dir, "empty_container", "id: empty_container\nname: empty container\n")
+ // Two alternatives paired with a single byproduct: consuming the second
+ // alternative returns nothing (collectByproducts indexes by position).
+ writeCraftItem(t, dir, "test_out", `id: test_out
+name: test out
+craft:
+ - type: combine
+ ingredients:
+ - items: [test_in, test_alt]
+ quantity: 1
+ byproducts: [empty_container]
+`)
+
+ if issues := validateCraftBlocks(newSource(dir)); !containsMsg(issues, "pair with items by index") {
+ t.Fatalf("expected byproduct length-mismatch error, got: %+v", issues)
+ }
+}
+
+func TestCraftByproductLengthMatchIsAccepted(t *testing.T) {
+ dir := t.TempDir()
+ writeCraftItem(t, dir, "test_in", "id: test_in\nname: test in\n")
+ writeCraftItem(t, dir, "test_alt", "id: test_alt\nname: test alt\n")
+ writeCraftItem(t, dir, "empty_one", "id: empty_one\nname: empty one\n")
+ writeCraftItem(t, dir, "empty_two", "id: empty_two\nname: empty two\n")
+ writeCraftItem(t, dir, "test_out", `id: test_out
+name: test out
+craft:
+ - type: combine
+ ingredients:
+ - items: [test_in, test_alt]
+ quantity: 1
+ byproducts: [empty_one, empty_two]
+`)
+
+ if issues := validateCraftBlocks(newSource(dir)); len(issues) != 0 {
+ t.Fatalf("positionally paired byproducts must validate cleanly, got: %+v", issues)
+ }
+}
diff --git a/internal/validate/hardening_craft_validation_test.go b/internal/validate/hardening_craft_validation_test.go
new file mode 100644
index 0000000..5e5d83f
--- /dev/null
+++ b/internal/validate/hardening_craft_validation_test.go
@@ -0,0 +1,130 @@
+package validate
+
+import (
+ "path/filepath"
+ "testing"
+
+ "thehouseoficarus/internal/item"
+)
+
+// Regression: validateCraftBlocks must validate byproducts, the required tool,
+// and success/fail coherence. A typo in a byproduct previously passed startup
+// validation and the runtime wrote a phantom inventory slot with no item
+// definition; a tool that names no item was also unchecked; and a fail product
+// or fail_message on a recipe with no success formula can never be reached
+// (the engine hardcodes a 100% success chance).
+
+func writeCraftItem(t *testing.T, dir, name, body string) {
+ t.Helper()
+ writeFile(t, filepath.Join(dir, "items", name+".yaml"), body)
+}
+
+func TestCraftByproductMustExist(t *testing.T) {
+ dir := t.TempDir()
+ writeCraftItem(t, dir, "test_in", "id: test_in\nname: test in\n")
+ writeCraftItem(t, dir, "test_out", `id: test_out
+name: test out
+craft:
+ - type: crafting
+ ingredients:
+ - items: [test_in]
+ quantity: 1
+ byproducts: [missing_byproduct]
+`)
+ issues := validateCraftBlocks(newSource(dir))
+ if !containsMsg(issues, "nonexistent byproduct") {
+ t.Fatalf("expected nonexistent-byproduct error, got: %+v", issues)
+ }
+}
+
+func TestCraftToolMustExist(t *testing.T) {
+ dir := t.TempDir()
+ writeCraftItem(t, dir, "test_in", "id: test_in\nname: test in\n")
+ writeCraftItem(t, dir, "test_out", `id: test_out
+name: test out
+craft:
+ - type: crafting
+ tool: missing_tool
+ ingredients:
+ - items: [test_in]
+ quantity: 1
+`)
+ issues := validateCraftBlocks(newSource(dir))
+ if !containsMsg(issues, "nonexistent tool") {
+ t.Fatalf("expected nonexistent-tool error, got: %+v", issues)
+ }
+}
+
+func TestCraftFailWithoutSuccessFormulaIsWarned(t *testing.T) {
+ dir := t.TempDir()
+ writeCraftItem(t, dir, "test_in", "id: test_in\nname: test in\n")
+ writeCraftItem(t, dir, "burnt", "id: burnt\nname: burnt\n")
+ writeCraftItem(t, dir, "test_out", `id: test_out
+name: test out
+craft:
+ - type: cooking
+ fail: burnt
+ fail_message: You burn it.
+ ingredients:
+ - items: [test_in]
+ quantity: 1
+`)
+ issues := validateCraftBlocks(newSource(dir))
+ if !containsMsg(issues, "unreachable") {
+ t.Fatalf("expected unreachable fail warning, got: %+v", issues)
+ }
+ for _, issue := range issues {
+ if issue.Level == "ERROR" {
+ t.Fatalf("dead fail data must not fail startup validation: %+v", issue)
+ }
+ }
+}
+
+func TestCraftReachableFailProducesNoIssue(t *testing.T) {
+ dir := t.TempDir()
+ writeCraftItem(t, dir, "test_in", "id: test_in\nname: test in\n")
+ writeCraftItem(t, dir, "burnt", "id: burnt\nname: burnt\n")
+ writeCraftItem(t, dir, "test_out", `id: test_out
+name: test out
+craft:
+ - type: cooking
+ fail: burnt
+ fail_message: You burn it.
+ success:
+ base: 0.5
+ cap: 0.95
+ per_level: 0.02
+ ingredients:
+ - items: [test_in]
+ quantity: 1
+`)
+ if issues := validateCraftBlocks(newSource(dir)); len(issues) != 0 {
+ t.Fatalf("reachable fail branch must validate cleanly, got: %+v", issues)
+ }
+}
+
+func TestCraftFailureReachableFormula(t *testing.T) {
+ cases := []struct {
+ name string
+ base float64
+ perLvl float64
+ cap float64
+ nilF bool
+ want bool
+ }{
+ {name: "no formula", nilF: true, want: false},
+ {name: "50% base", base: 0.5, perLvl: 0.02, cap: 0.95, want: true},
+ {name: "always succeeds", base: 1.0, perLvl: 0, cap: 1.0, want: false},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ craft := &item.CraftDef{Level: 1}
+ if !c.nilF {
+ craft.Success = &item.SuccessFormula{Base: c.base, PerLevel: c.perLvl, Cap: c.cap}
+ }
+ if got := craftFailureReachable(craft); got != c.want {
+ t.Errorf("craftFailureReachable = %v, want %v", got, c.want)
+ }
+ })
+ }
+}
diff --git a/internal/validate/hardening_mob_stats_validation_test.go b/internal/validate/hardening_mob_stats_validation_test.go
new file mode 100644
index 0000000..1ea30f9
--- /dev/null
+++ b/internal/validate/hardening_mob_stats_validation_test.go
@@ -0,0 +1,107 @@
+package validate
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+// Regression: validateMobs must reject a fighting mob authored without an
+// attack speed (engine.ToTicks clips 0 to 1, so the mob would attack every
+// tick) or without a max hit for a declared attack type (a silent no-op).
+// Task worksites are explicitly exempt: data/mobs/examples/solar_panel_frame.yaml
+// ships max_melee_hit: 0 and must remain valid.
+
+func writeMob(t *testing.T, dir, name, body string) {
+ t.Helper()
+ writeFile(t, filepath.Join(dir, "mobs", name+".yaml"), body)
+}
+
+func TestMobMissingSpeedIsError(t *testing.T) {
+ dir := t.TempDir()
+ writeMob(t, dir, "test_mob", `id: test_mob
+name: test mob
+combat:
+ kind: combat
+ attack_types: [crush]
+ stats:
+ hp: 10
+ speed: 0
+ max_melee_hit: 5
+`)
+ issues := validateMobs(newSource(dir))
+ if !containsMsg(issues, "stats.speed") {
+ t.Fatalf("expected missing-speed error, got: %+v", issues)
+ }
+}
+
+func TestMobMissingMaxMeleeHitIsError(t *testing.T) {
+ dir := t.TempDir()
+ writeMob(t, dir, "test_mob", `id: test_mob
+name: test mob
+combat:
+ kind: combat
+ attack_types: [crush]
+ stats:
+ hp: 10
+ speed: 5
+ max_melee_hit: 0
+`)
+ issues := validateMobs(newSource(dir))
+ if !containsMsg(issues, "max_melee_hit") {
+ t.Fatalf("expected missing-melee-hit error, got: %+v", issues)
+ }
+}
+
+func TestMobMissingMaxRangedHitIsError(t *testing.T) {
+ dir := t.TempDir()
+ writeMob(t, dir, "test_mob", `id: test_mob
+name: test mob
+combat:
+ kind: combat
+ attack_types: [crush, ranged]
+ stats:
+ hp: 10
+ speed: 5
+ max_melee_hit: 3
+`)
+ issues := validateMobs(newSource(dir))
+ if !containsMsg(issues, "max_ranged_hit") {
+ t.Fatalf("expected missing-ranged-hit error, got: %+v", issues)
+ }
+}
+
+func TestTaskWorksiteMobExemptFromCombatStatChecks(t *testing.T) {
+ dir := t.TempDir()
+ writeMob(t, dir, "test_worksite", `id: test_worksite
+name: test worksite
+combat:
+ kind: task
+ attack_types: [crush]
+ stats:
+ hp: 10
+ speed: 0
+ max_melee_hit: 0
+`)
+ if issues := validateMobs(newSource(dir)); len(issues) != 0 {
+ t.Fatalf("task worksite must be exempt from combat-stat checks, got: %+v", issues)
+ }
+}
+
+func TestValidCombatMobProducesNoIssue(t *testing.T) {
+ dir := t.TempDir()
+ writeMob(t, dir, "test_mob", `id: test_mob
+name: test mob
+combat:
+ kind: combat
+ attack_types: [slash, ranged, science]
+ stats:
+ hp: 10
+ speed: 4
+ max_melee_hit: 3
+ max_ranged_hit: 4
+ max_science_hit: 5
+`)
+ if issues := validateMobs(newSource(dir)); len(issues) != 0 {
+ t.Fatalf("valid combat mob must validate cleanly, got: %+v", issues)
+ }
+}
diff --git a/internal/validate/polish_destructive_fail_test.go b/internal/validate/polish_destructive_fail_test.go
new file mode 100644
index 0000000..8c9a038
--- /dev/null
+++ b/internal/validate/polish_destructive_fail_test.go
@@ -0,0 +1,64 @@
+package validate
+
+// Regression: the "a failable recipe must not yield nothing" invariant was
+// enforced only for smelting (and only by a shipped-data test), so a future
+// cooking/fletching/combine recipe with a success formula (or a declared
+// fail_message) and no fail product would silently consume materials and
+// return nothing. validateCraftBlocks now warns on it.
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+func writePolishCraftRecipe(t *testing.T, dir, craftBody string) {
+ t.Helper()
+ writeFile(t, filepath.Join(dir, "items", "polish_in.yaml"), "id: polish_in\nname: polish in\n")
+ writeFile(t, filepath.Join(dir, "items", "polish_out.yaml"),
+ "id: polish_out\nname: polish out\ncraft:\n - type: cooking\n"+craftBody+
+ " ingredients:\n - items: [polish_in]\n quantity: 1\n")
+}
+
+func TestCraftFailableWithoutFailProductIsWarned(t *testing.T) {
+ cases := []struct {
+ name string
+ craftBody string
+ }{
+ {"success formula", " success:\n base: 0.5\n cap: 0.95\n per_level: 0.02\n"},
+ {"declared fail_message", " fail_message: You burn it.\n"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ dir := t.TempDir()
+ writePolishCraftRecipe(t, dir, c.craftBody)
+ issues := validateCraftBlocks(newSource(dir))
+ if !containsMsg(issues, "failure destroys materials with no fail product") {
+ t.Fatalf("expected a destructive-failure warning, got: %+v", issues)
+ }
+ for _, issue := range issues {
+ if issue.Level != "WARN" {
+ t.Fatalf("destructive failure must be a WARN, got: %+v", issue)
+ }
+ }
+ })
+ }
+}
+
+func TestCraftWithFailProductOrGuaranteedSuccessDoesNotWarn(t *testing.T) {
+ cases := []struct {
+ name string
+ craftBody string
+ }{
+ {"declares a fail product", " fail: polish_in\n success:\n base: 0.5\n cap: 0.95\n per_level: 0.02\n"},
+ {"success cannot fall below 1", " success:\n base: 1.0\n cap: 1.0\n per_level: 0\n"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ dir := t.TempDir()
+ writePolishCraftRecipe(t, dir, c.craftBody)
+ if issues := validateCraftBlocks(newSource(dir)); containsMsg(issues, "failure destroys materials with no fail product") {
+ t.Fatalf("did not expect a destructive-failure warning, got: %+v", issues)
+ }
+ })
+ }
+}
diff --git a/internal/world/hardening_room_id_mismatch_test.go b/internal/world/hardening_room_id_mismatch_test.go
new file mode 100644
index 0000000..1ac1711
--- /dev/null
+++ b/internal/world/hardening_room_id_mismatch_test.go
@@ -0,0 +1,73 @@
+package world
+
+import (
+ "bytes"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// Regression: a room file whose declared id: disagrees with its numeric
+// filename was silently loaded under the filename with no diagnostic.
+// World.LoadRoom must warn, because room identity comes from the filename.
+
+func writeRoomFile(t *testing.T, dir, name, body string) {
+ t.Helper()
+ path := filepath.Join(dir, "rooms", name)
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func captureRoomLog(t *testing.T, fn func()) string {
+ t.Helper()
+ var buf bytes.Buffer
+ old := log.Writer()
+ log.SetOutput(&buf)
+ defer log.SetOutput(old)
+ fn()
+ return buf.String()
+}
+
+func TestWorldWarnsOnDeclaredRoomIDMismatch(t *testing.T) {
+ dir := t.TempDir()
+ writeRoomFile(t, dir, "1.yaml", "id: 2\nname: Wrong Id\n")
+ w := New(dir)
+
+ out := captureRoomLog(t, func() {
+ room, err := w.LoadRoom(1)
+ if err != nil {
+ t.Fatalf("load room: %v", err)
+ }
+ if room.ID != 1 {
+ t.Fatalf("filename must win: room.ID = %d, want 1", room.ID)
+ }
+ })
+ if !strings.Contains(out, "declares id") {
+ t.Fatalf("expected a room id-mismatch warning, got log %q", out)
+ }
+}
+
+func TestWorldDoesNotWarnWhenRoomIDMatchesOrAbsent(t *testing.T) {
+ dir := t.TempDir()
+ writeRoomFile(t, dir, "1.yaml", "id: 1\nname: Match\n")
+ writeRoomFile(t, dir, "2.yaml", "name: Absent\n")
+ w := New(dir)
+
+ out := captureRoomLog(t, func() {
+ if _, err := w.LoadRoom(1); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := w.LoadRoom(2); err != nil {
+ t.Fatal(err)
+ }
+ })
+ if strings.Contains(out, "declares id") {
+ t.Fatalf("unexpected room id-mismatch warning: %q", out)
+ }
+}
diff --git a/internal/world/world.go b/internal/world/world.go
index c5d0841..6a0313d 100644
--- a/internal/world/world.go
+++ b/internal/world/world.go
@@ -2,6 +2,7 @@ package world
import (
"fmt"
+ "log"
"os"
"path/filepath"
"strconv"
@@ -52,6 +53,13 @@ func (w *World) LoadRoom(id int) (*Room, error) {
if err := yaml.Unmarshal(data, &room); err != nil {
return nil, fmt.Errorf("parse room %d: %w", id, err)
}
+ // Room ID comes from the filename (room.ID = id below); a mismatched
+ // declared id: is silently ignored, so surface it rather than leave the
+ // author believing their written id is in use.
+ if room.ID != 0 && room.ID != id {
+ log.Printf("room %d: file %s declares id %d but is loaded as room %d; the filename is authoritative and the declared id is ignored",
+ id, path, room.ID, id)
+ }
room.ID = id
if room.Exits == nil {
room.Exits = make(map[ExitDir]ExitDef)