diff options
| author | historia <[not public]> | 2026-09-18 05:31:09 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 05:31:09 -0400 |
| commit | 153acb7f644d755c45533b7c5311e3ffa47e8973 (patch) | |
| tree | 7645e6f7b65bc3830b078e3b9336d2e6b199d584 | |
| parent | 87d08f0151e643951769e323995f1c5ed89ee200 (diff) | |
| download | thehouseoficarus-153acb7f644d755c45533b7c5311e3ffa47e8973.tar.gz | |
fix: harden engine across casino, economy, concurrency, security, and content
140 files changed, 9658 insertions, 530 deletions
@@ -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) |
