aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-06 12:57:23 -0400
committerhistoria <[not public]>2026-10-06 12:57:23 -0400
commit9666676766bf21364f4fc4d0d1d336e72f2a91b4 (patch)
tree0cade34091d7602790dc38cd44053f0b74bbadf2
parent4bc1daaf259e0c19c5fe4a12ec8bc7f6e5a735c1 (diff)
downloadthehouseoficarus-9666676766bf21364f4fc4d0d1d336e72f2a91b4.tar.gz
refactor: split casino.go, dedup helpers, remove audit docs
-rw-r--r--FIXES.md136
-rw-r--r--internal/admin/api_entity.go9
-rw-r--r--internal/admin/api_rooms.go46
-rw-r--r--internal/admin/api_triggers.go19
-rw-r--r--internal/admin/server.go2
-rw-r--r--internal/admin/undo.go12
-rw-r--r--internal/admin/yaml_util.go10
-rw-r--r--internal/behavior/behavior.go24
-rw-r--r--internal/behavior/store.go20
-rw-r--r--internal/behavior/yml_test.go35
-rw-r--r--internal/casino/baccarat.go11
-rw-r--r--internal/casino/big_six.go2
-rw-r--r--internal/casino/blackjack.go12
-rw-r--r--internal/casino/blackjack_rules.go2
-rw-r--r--internal/casino/cash_hunt.go7
-rw-r--r--internal/casino/casino_review_regression_test.go2
-rw-r--r--internal/casino/core.go2
-rw-r--r--internal/casino/craps.go3
-rw-r--r--internal/casino/craps_clock_test.go10
-rw-r--r--internal/casino/craps_regression_test.go2
-rw-r--r--internal/casino/craps_test.go2
-rw-r--r--internal/casino/manager_test.go23
-rw-r--r--internal/casino/pai_gow.go13
-rw-r--r--internal/casino/pai_gow_rules.go14
-rw-r--r--internal/casino/plinko.go6
-rw-r--r--internal/casino/roulette.go2
-rw-r--r--internal/casino/table_ux.go2
-rw-r--r--internal/casino/types.go13
-rw-r--r--internal/casino/video_poker_lifecycle_test.go7
-rw-r--r--internal/color/color.go21
-rw-r--r--internal/combat/state.go7
-rw-r--r--internal/combat/state_test.go20
-rw-r--r--internal/cryptomarket/chart.go5
-rw-r--r--internal/cryptomarket/client.go44
-rw-r--r--internal/cryptomarket/client_test.go21
-rw-r--r--internal/game/account_txn.go4
-rw-r--r--internal/game/act_combine.go35
-rw-r--r--internal/game/act_state_test.go2
-rw-r--r--internal/game/act_steal.go6
-rw-r--r--internal/game/act_trigger_module.go2
-rw-r--r--internal/game/baccarat_rules_test.go2
-rw-r--r--internal/game/baseline_race_test.go2
-rw-r--r--internal/game/blackjack_rules.go31
-rw-r--r--internal/game/blackjack_rules_test.go4
-rw-r--r--internal/game/casino.go622
-rw-r--r--internal/game/casino_commands.go3
-rw-r--r--internal/game/casino_house_edge.go2
-rw-r--r--internal/game/casino_ux.go21
-rw-r--r--internal/game/casino_wallet.go252
-rw-r--r--internal/game/cmd_alias.go2
-rw-r--r--internal/game/cmd_colortable.go2
-rw-r--r--internal/game/cmd_dig.go30
-rw-r--r--internal/game/cmd_estate_directory.go22
-rw-r--r--internal/game/cmd_global.go7
-rw-r--r--internal/game/cmd_goto.go15
-rw-r--r--internal/game/cmd_hide.go32
-rw-r--r--internal/game/cmd_look.go6
-rw-r--r--internal/game/cmd_move.go22
-rw-r--r--internal/game/cmd_option.go7
-rw-r--r--internal/game/cmd_prompt.go7
-rw-r--r--internal/game/cmd_remove.go30
-rw-r--r--internal/game/cmd_room.go12
-rw-r--r--internal/game/cmd_room_insert.go15
-rw-r--r--internal/game/cmd_say.go7
-rw-r--r--internal/game/cmd_summon.go15
-rw-r--r--internal/game/cmd_trigger_utility.go20
-rw-r--r--internal/game/combat_attack.go13
-rw-r--r--internal/game/core_login_account.go23
-rw-r--r--internal/game/core_login_char.go5
-rw-r--r--internal/game/exit_display.go6
-rw-r--r--internal/game/exit_migrate.go9
-rw-r--r--internal/game/fix_game_regression_test.go53
-rw-r--r--internal/game/game.go17
-rw-r--r--internal/game/hardening_tick_lifecycle_test.go12
-rw-r--r--internal/game/login_offowner_regression_test.go2
-rw-r--r--internal/game/look_casino.go2
-rw-r--r--internal/game/look_casino_test.go8
-rw-r--r--internal/game/look_target.go9
-rw-r--r--internal/game/money.go14
-rw-r--r--internal/game/owner.go15
-rw-r--r--internal/game/owner_goexit_test.go26
-rw-r--r--internal/game/production_engine.go43
-rw-r--r--internal/game/render_baccarat_felt.go4
-rw-r--r--internal/game/render_blackjack_felt.go2
-rw-r--r--internal/game/render_cards.go330
-rw-r--r--internal/game/render_casino_settlement.go14
-rw-r--r--internal/game/render_color.go16
-rw-r--r--internal/game/render_craps.go3
-rw-r--r--internal/game/render_felt_precision_test.go41
-rw-r--r--internal/game/render_help.go8
-rw-r--r--internal/game/render_map.go21
-rw-r--r--internal/game/render_plinko.go2
-rw-r--r--internal/game/render_prompt.go18
-rw-r--r--internal/game/render_roulette.go14
-rw-r--r--internal/game/render_roulette_tracker.go2
-rw-r--r--internal/game/render_slots.go86
-rw-r--r--internal/game/sys_combat.go9
-rw-r--r--internal/game/sys_combat_test.go6
-rw-r--r--internal/game/sys_escape_test.go38
-rw-r--r--internal/game/sys_safespot.go23
-rw-r--r--internal/game/sys_science.go3
-rw-r--r--internal/game/sys_technology.go2
-rw-r--r--internal/game/sys_triggers.go28
-rw-r--r--internal/game/video_poker_cabinet_test.go6
-rw-r--r--internal/game/wallet_refund_kind_test.go40
-rw-r--r--internal/game/wallet_test.go1
-rw-r--r--internal/item/store.go1
-rw-r--r--internal/net/conn.go14
-rw-r--r--internal/net/iac_test.go15
-rw-r--r--internal/net/server.go9
-rw-r--r--internal/net/web.go4
-rw-r--r--internal/net/web_test.go10
-rw-r--r--internal/object/object.go5
-rw-r--r--internal/object/object_test.go13
-rw-r--r--internal/object/store.go1
-rw-r--r--internal/persistence/txn.go34
-rw-r--r--internal/persistence/txn_containment_test.go79
-rw-r--r--internal/player/player.go2
-rw-r--r--internal/player/roomset.go2
-rw-r--r--internal/player/roomset_test.go11
-rw-r--r--internal/player/store.go2
-rw-r--r--internal/player/store_test.go28
-rw-r--r--internal/player/validate.go3
-rw-r--r--internal/ui/table.go22
-rw-r--r--internal/validate/checks.go64
-rw-r--r--internal/validate/grid_test.go2
-rw-r--r--internal/world/fix_regressions_test.go61
-rw-r--r--internal/world/mob.go26
-rw-r--r--internal/world/mob_rekey_test.go48
-rw-r--r--internal/world/objects.go15
-rw-r--r--internal/world/room.go4
-rw-r--r--internal/world/room_test.go31
132 files changed, 2083 insertions, 1202 deletions
diff --git a/FIXES.md b/FIXES.md
deleted file mode 100644
index f89cce9..0000000
--- a/FIXES.md
+++ /dev/null
@@ -1,136 +0,0 @@
-# Fix Tranche 1 — verified P0/P1 plus high-value P2s
-
-Date: 2026-10-05
-Companion to `AUDIT.md` (findings) and `reports/audit/` (raw lane reports).
-
-## Status
-
-- `go build ./...` — pass
-- `go vet ./...` — pass
-- `make lint` (`golangci-lint run`) — **now 0 issues** (was 15)
-- `go test ./...` — pass
-- `go test -race` (engine, net, persistence, player, world, combat, casino, game, hacking) — pass
-- Changes are uncommitted. `git diff --shortstat` = 51 files, +509/−131 (plus new test files).
-
-Nothing was staged or committed. The audit artifacts (`AUDIT.md`, `reports/`) are locally excluded via `.git/info/exclude` so they do not dirty the tree.
-
-## Fixed (each with a regression test unless noted)
-
-### Correctness / exploit (P0/P1)
-1. **Gather verbs broken (P0)** — restored `mine/chop/fish/cut → gather` aliases in `internal/game/act.go`; corrected the misleading comment. Test: `TestGatherVerbsNormalizeToGatherAction`.
-2. **`walk <huge>n` remote OOM** — `parseWalkDirections` now clamps the count before allocating (`internal/game/cmd_walk.go`). Test: `TestParseWalkDirectionsRejectsHugeCount`.
-3. **`sell`/`deposit` dead-end on 2+ stacks** — dedup by name with `uniqueItemNames` (`cmd_shop.go`, `cmd_bank.go`).
-4. **Admin `//` path bypass** — added `canonicalDataRelPath`, classify on the canonical path, removed the shadowed `fullPath` join (`internal/admin/api_files.go`); hardened the game-side `playerDataFileName` the same way. Tests: `TestCanonicalDataRelPathStripsRepeatedPrefixes`, `TestPlayerDataFileNameCanonicalizesPrefixes`.
-5. **Undo can truncate a live file to 0 bytes** — `load()` maps an empty before-image back to nil so `requireSnapshot` fires (`internal/admin/undo.go`). (Fail-safe: an intentionally empty before-image is refused rather than written; the explicit-existence-flag fix remains a follow-up.)
-6. **Roulette selective-refund exploit** — disconnect during `phasePlaying` now forfeits the stake, matching Big 6 (`internal/casino/roulette.go`); updated `TestRouletteRefundAndPersistenceFailure` to assert forfeit vs refund.
-7. **Resolved predictions destroyed by the credit cap** — compute totals and run the cap guard before mutating contract status (`internal/game/predictions.go`). Test: `TestCollectPredictionsKeepsContractsWhenCreditCapBlocks`.
-8. **`RekeyRooms` stranded wanderers** — rebuild the object-state map from every state and remap `RoomID`/`HomeRoomID`/`WanderRooms` (`internal/world/world.go`). Test: `TestRekeyRoomsUpdatesAwayFromHomeObject`.
-9. **Admin room swap/undo did not reconcile live state** — `handleRoomSwap` now reconciles both rooms; `rebuildAfterUndo` reconciles when the room file still exists (`internal/admin/api_rooms.go`, `server.go`).
-10. **Estate directory was dead** — allow `estate_directory` through the interactability gate (`act.go`); exempt the `Cfg == nil && EstateDirectory` prompt in `advanceTalk` (`act_talk.go`). Live content places it in `data/rooms/tuccia/3124.yaml`.
-11. **`ui.Table` title overhang** — truncate the title to the interior width (`internal/ui/table.go`). Test: `TestTableLongTitleStaysWithinBorder`.
-12. **Hacking minigames printed raw color tags** — expand tags before writing (`internal/game/core_hacking.go`).
-13. **Pai gow betting window never expired with one bettor + one idle seat** — count seated players, not bettors (`internal/casino/pai_gow.go`). Test: `TestPaiGowBettingWindowExpiresWithOneBettorAndIdleSeat`.
-
-### Combat / player / items
-14. **Double combat entry** — `startCombat` now refuses re-entry (`internal/game/combat_attack.go`); `Tracker.Enter` frees the previous mob (`internal/combat/state.go`). Test: `TestTrackerEnterReleasesPreviousMob`.
-15. **Retribution skipped `StartRegen`** — surviving mob no longer heals 100× too fast (`internal/game/combat_mob.go`).
-16. **`RemoveItem` drained items on failure** — now all-or-nothing (`internal/player/player.go`). Test: `TestRemoveItemIsAllOrNothing`.
-17. **`drop all <item>` zeroed quality** — preserve Quality/MaxQuality via `AddGroundItemQ` (`internal/game/cmd_drop.go`).
-18. **`RegenerateTick` was persisted** — added `yaml:"-"` (`internal/player/player.go`).
-19. **`XPTable` was `[100]int` with 99 values** — now `[99]int` (`internal/player/xp.go`). Test: `TestXPTableBounds`.
-
-### Command / UX
-20. **Prefix/abbreviation lowercased arguments** — preserve original case for `rawInput` consumers (`internal/game/game.go`). Test: `TestGlobalCommandPreservesArgumentCase`.
-21. **`queued` ignored the consume slot** — added `PeekConsume` and display (`core_command_queue.go`, `cmd_queued.go`).
-22. **`queued` hardcoded 600 ms** — now reports the configured tick length (`cmd_queued.go`).
-23. **`verbs` ignored `wrap_width`** — honors the option (`cmd_verbs.go`).
-24. **Unknown craft subtype soft-locked the action** — fallback uses `behavior.TypeCraft` so it advances (`production_engine.go`).
-25. **Empty-ingredient craft was infinite/free** — `planIngredients` returns nil (`production_index.go`). Test: `TestPlanIngredientsRejectsEmptyCraft`.
-
-### Persistence / validation / net
-26. **`Move` + `Delete`/`Replace` silently lost files** — conflicting ops now error (`internal/persistence/txn.go`). Tests: `TestTxnRejectsConflictingMoveThenDelete`, `TestTxnRejectsConflictingMoveThenReplace`, `TestTxnMoveThenReplaceDestination`.
-27. **`Recover` dropped unknown op kinds and deleted the journal** — unknown kind is now a rollback error, keeping the journal (`txn.go`). Test: `TestRecoverKeepsJournalWithUnknownOpKind`.
-28. **Journal directory entry not durable** — `Recover` (the startup path that creates it) now fsyncs its parent (`txn.go`).
-29. **`ExitDef.Condition` never validated** — added the check (`internal/validate/checks.go`).
-30. **`stripIAC` leaked a byte for a truncated IAC** — drop the remainder (`internal/net/conn.go`). Test: `TestStripIACDropsTruncatedSequence`.
-
-### Tooling
-31. **`make lint` gate restored** — fixed the 15 reported issues (error-string casing, doc-comment form, unused params, ineffassign) without weakening `.golangci.yml`.
-
-## Tranche 2
-
-- **Security**: `restoreAccountSecret` no longer fails open on a non-mapping body (the admin raw editor can no longer blank `password_hash`); `validEntityID` rejects control characters and leading dots.
-- **Info disclosure**: video-poker bank/wager errors are mapped instead of printing raw `os`/YAML errors and server paths.
-- **Casino**: slots free-spin counter no longer prints "0 free spins remain" before the last spin; `leave` settles banked free-spin winnings instead of silently dropping them; craps repeated-odds returns the correct error; a craps shooter change resets the hand (point/made/seen) so the next shooter gets a fresh come-out.
-- **Economy**: `get <n> credits` honors the requested quantity; bank withdraw ambiguity list no longer duplicates entries.
-- **Admin**: `entityCreate` drops editor-only `_path`/`_raw`; `detachRoomFromCourse` checks `os.Remove` and detaches from every matching course.
-- **Validation**: `.yml` files are discovered/validated (matching the runtime trigger loader); talk configs with nodes but no `start` node are an error.
-- **Persistence**: `Recover` sweeps crash-created `.save-*` temp files.
-- **Net**: `Conn` now requires `SetReadDeadline`, so every transport gets an idle read deadline (removed the concrete-type switch); hub room buckets are pruned and `RekeyRooms` merges instead of clobbering.
-- Tests added: `TestRestoreAccountSecretRefusesNonMappingBody`, `TestWalkYAMLDirIncludesYml`, `TestValidateTalkConfigRequiresStartNode`, `TestPickupCreditsHonorsRequestedQuantity`, `TestMachineLeavePaysBankedFeature`, `TestCrapsShooterChangeResetsHand`.
-
-## Tranche 3 (this pass)
-
-- **Undo**: a failed undo/redo is now reported distinctly from an empty history (`UndoStack.LastError()`), so the admin UI no longer shows "Nothing to undo" for a real failure. Test: `TestUndoFailureIsReportedDistinctly`.
-- **Quit**: `quit`/`logout` are `ClassInstant`, so a queued active command can no longer silently replace or delay a quit.
-- **World**: a reference-style room object entry (`id:` only) that carries behavior fields is rejected at load instead of silently dropping the behavior. Test: `TestRoomObjectReferenceRejectsBehaviorFields`.
-- **Validation**: gather drops with `weight <= 0` (never selectable) are warned about. Test: `TestValidateGatherWarnsOnWeightlessDrop`.
-- **Admin**: trigger POST now serializes through `triggerMapToYAML` like PUT (no unknown-key divergence); `resolve-duplicate` verifies the path is genuinely a duplicate-ID room file before deleting/renaming it.
-- **Net**: the shutdown flush gives each session a bounded slice instead of letting the first stalled writer starve the rest; the telnet echo CRLF refreshes its write deadline so it is not dropped on an idle connection.
-- **Casino**: video-poker cabinet IDs are claimed in the addressable namespace, so an ID colliding with a machine variant/game name is rejected instead of producing an unreachable cabinet. Test: `TestVideoPokerCabinetIDCollisionIsRejected`.
-
-## Tranche 4 (this pass)
-
-- **Production**: the multi-recipe bug is fixed. Availability scanned every recipe but execution used `Craft[0]`, so 5 of 6 `arrow_shaft` recipes were unreachable (start-then-abort). A `CraftIndex` is now carried through `ProductionData`/`CombineData`/`FletchData`; `usableCraftIndex` selects the recipe the player satisfies; `collectByproducts`/`canContinueProduction` use the selected recipe; the production menu displays the usable recipe; and `findFletchItems` scans every recipe. Test: `TestUsableCraftIndexSelectsSatisfiedRecipe`.
-- **Validation**: mob loot and steal drops with `weight <= 0` are now warned about (gather already was).
-- **Persistence**: journals now carry a schema `version`; `Recover` refuses a version it does not understand instead of misreading a newer journal. Test: `TestRecoverRefusesUnknownJournalVersion`.
-- **Casino**: the plinko rules text now states the measured per-risk returns rather than only the target RTP.
-- **Refactor**: the duplicated `countExtraInbound` (game + admin, byte-for-byte) is now one `world.CountExtraInbound`, so the two room-removal paths cannot drift.
-
-## Tranche 5 (this pass)
-
-- **Refactor**: the three YAML map encoders (`admin.marshalYAML`, `admin.marshalMapYAML`, `behavior.MarshalYAMLMap`) now share one `behavior.MarshalYAML`.
-- **Refactor**: the `.journal` path is now `persistence.JournalDir(dataDir)` used by both the game and the admin API, so a future rename cannot leave one writer journaling to a directory startup recovery never scans.
-- **Test infrastructure**: added a `make test-race` target (`go test -race ./... -timeout 300s`); the shared game harness now stops the Game owner goroutine on cleanup (previously every harness test leaked one); added the first WebSocket transport tests (`TestWebSocketCheckOrigin` unit-tests the origin boundary with a non-loopback peer, `TestWebSocketUpgradeAccepted` drives the `/ws` upgrade end-to-end).
-
-## Tranche 6 (this pass)
-
-- **Refactor**: `GridConflict.Kind` is now the typed `world.GridConflictKind` (`GridConflictTwist`/`GridConflictOverlap`), replacing the magic strings switched on in five packages.
-- **Refactor**: the duplicated self-signed cert generator is now `tlscert.SelfSigned(commonName)`, shared by the game and admin servers.
-- **Validation**: craft `type`/`subtype` is checked against `item.KnownCraftKeys` — an unknown key left the action set but never advancing (player stuck). Test: `TestValidateCraftBlocksRejectsUnknownType`.
-- **Validation**: the mob/safespot size vocabulary moved to `object.MobSizeRank`; `validateObjects` rejects an unknown `safespot.max_block_size` (silently disables cover) and out-of-range chances, and `validateMobCombatStats` rejects an unknown mob `size`. Test: `TestValidateObjectsRejectsUnknownSafespotSize`.
-
-## Tranche 7 (this pass)
-
-- **Security**: `resolveDataPath` now resolves symlinks on the longest existing prefix and re-checks containment, so a symlink inside `data/` cannot redirect a raw-editor read or write outside the data directory. Test: `TestResolveDataPathRejectsSymlinkEscape`.
-- **UX**: the production "how many" prompt now says "How many batches (each makes N, return for all)?" so a batch count cannot silently mean N× the materials/units.
-- **Test coverage**: added `TestGatherYieldsDrop`, an end-to-end gather test (seed object + pickaxe, start the action, tick to a drop) — the first coverage for the gather system the audit flagged.
-- **Investigated (no change)**: the craps Fire bet. Settling it at the 4th/5th point as the audit suggested would cap the payout at the lower tier and under-pay; the bet must ride to the seven-out to pay the highest tier reached. Left as-is and noted here.
-
-## Tranche 8 (this pass)
-
-- **Undo**: added an explicit before/after snapshot-existence flag (`ChangeDesc.OldExists`/`NewExists`, carried through `ExtraFile` and the persisted `undoEntry`). `requireSnapshot` now keys on existence, not `len == 0`, so an intentionally empty file can be undone after a restart while a missing (failed-read) image is still refused. Older history files infer the flag from non-empty content. Test: `TestUndoRestoresIntentionallyEmptyFile`.
-- **Refactor**: the duplicated `world.remapInt` and `game.remapScalar` are now one exported `world.RemapInt`, used by both the room-file and player-state migrations.
-
-## Tranche 9 (this pass)
-
-- **Test coverage**: added `TestBurnCreatesFire`, an end-to-end firemaking test (seed logs + tinderbox, start the action, tick until a fire object appears) — the burn coverage the audit called out.
-- **Test infrastructure**: added `newHarnessWithTicks`, a harness variant that registers the production master tick subscription, plus `TestHarnessWithTicksCompletesDisconnect`, which exercises the real `Hub.Remove → DisconnectTick → onRemove` countdown end-to-end (the gap the audit found in the disconnect race tests).
-- **Refactor**: exported `ui.FitWidths` (the `Table.Render` shrink algorithm) and replaced the duplicated shrink loop in `casino_house_edge.go`, so the two cannot disagree on which column yields.
-
-## Tranche 10 (this pass)
-
-- **Bug fix (previously missed)**: a terminal talk node (messages, no options, no goto) now clears the talk action in `showTalkNode`, so it no longer lingers as a stale action that suppresses the prompt and no-ops every tick. Test: `TestTerminalTalkNodeClearsAction`.
-- **Test coverage**: added `TestObjectStealYieldsLoot`, an end-to-end object-steal test (seed a stealable stall, start the action, tick to loot). Talk and steal are now covered (gather and burn were in earlier tranches).
-- **Refactor**: `paiGowTable.phase` is now the typed `tablePhase` with `phaseSetting`/`phaseSettling` constants, removing the 29 raw phase-string literals (a safe subset of the `tableCore` consistency work).
-- **Docs**: `recoverJournals` now documents that it must run only at startup or on the owner goroutine, making the mid-game `Recover` invariant explicit.
-
-## Still open
-
-- **Casino**: craps Fire bet forfeiture on disconnect (design decision; shutdown refunds).
-- **Structural refactors** remaining from `AUDIT.md` §3–4 (`tableCore` for pai gow — now partly addressed by the typed phase — `refs` struct in validate, flex-aware predictions table).
-- **CI**: wire `make test-race` into a workflow.
-
-## Suggested next step
-
-The remaining structural refactors are cosmetic DRY with real regression risk and no functional benefit; the correctness backlog is now empty. Suggest stopping here unless a specific item is wanted. Keep `make lint`, `go test ./...`, and `go test -race` green.
diff --git a/internal/admin/api_entity.go b/internal/admin/api_entity.go
index 04b9dca..1049b96 100644
--- a/internal/admin/api_entity.go
+++ b/internal/admin/api_entity.go
@@ -322,7 +322,14 @@ func (s *AdminServer) entityDelete(w http.ResponseWriter, id string, c entityCRU
c.fail(w, http.StatusNotFound, c.notFound(id))
return
}
- oldContent := backupFile(path)
+ oldContent, exists, snapErr := snapshotBefore(path)
+ if snapErr != nil {
+ c.fail(w, http.StatusInternalServerError, c.removeErr(snapErr))
+ return
+ }
+ if !exists {
+ oldContent = nil
+ }
if err := os.Remove(path); err != nil {
c.fail(w, http.StatusInternalServerError, c.removeErr(err))
return
diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go
index 06e736c..72f4a2c 100644
--- a/internal/admin/api_rooms.go
+++ b/internal/admin/api_rooms.go
@@ -349,26 +349,9 @@ func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) {
return
}
- courseID, _ := s.detachRoomFromCourse(id)
-
- if err := os.Remove(path); err != nil {
- failJSON(w, fmt.Sprintf("delete room: %v", err))
- return
- }
-
- s.world.RebuildRoomIndex(s.dataDir)
- s.world.ClearRoomState(id)
- // Drop the room's live mob instances too, or they keep wandering and
- // are returned by MobsInRoom for a room that no longer exists.
- if s.mobStore != nil {
- s.mobStore.RemoveMobsInRoom(id)
- }
- // Drop any cached in-room games (tables/machines) and refund their
- // outstanding wagers — same eviction semantics as bulk delete.
- if s.evictRoom != nil {
- s.evictRoom(id)
- }
-
+ // Clean up references and exits in every OTHER room BEFORE deleting this
+ // one: a failed write then aborts with the world untouched, instead of
+ // leaving dangling exits while reporting success.
allIDs, _ := listRoomIDs(s.dataDir)
var extraFiles []ExtraFile
var exitCleaned int
@@ -395,7 +378,8 @@ func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) {
oldExtra, _ := snapshotFile(otherPath)
newContent, writeErr := writeYAMLFile(otherPath, otherRoom)
if writeErr != nil {
- continue
+ failJSON(w, fmt.Sprintf("clean exits in room %d: %v", otherID, writeErr))
+ return
}
extraFiles = append(extraFiles, ExtraFile{
FilePath: otherPath,
@@ -407,6 +391,26 @@ func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) {
}
}
+ courseID, _ := s.detachRoomFromCourse(id)
+
+ if err := os.Remove(path); err != nil {
+ failJSON(w, fmt.Sprintf("delete room: %v", err))
+ return
+ }
+
+ s.world.RebuildRoomIndex(s.dataDir)
+ s.world.ClearRoomState(id)
+ // Drop the room's live mob instances too, or they keep wandering and
+ // are returned by MobsInRoom for a room that no longer exists.
+ if s.mobStore != nil {
+ s.mobStore.RemoveMobsInRoom(id)
+ }
+ // Drop any cached in-room games (tables/machines) and refund their
+ // outstanding wagers — same eviction semantics as bulk delete.
+ if s.evictRoom != nil {
+ s.evictRoom(id)
+ }
+
s.undoStack.Push(ChangeDesc{
Description: fmt.Sprintf("delete room %d (cleaned %d exits)", id, exitCleaned),
FilePath: path,
diff --git a/internal/admin/api_triggers.go b/internal/admin/api_triggers.go
index ff3e7d6..b392317 100644
--- a/internal/admin/api_triggers.go
+++ b/internal/admin/api_triggers.go
@@ -120,6 +120,7 @@ func (s *AdminServer) handleTriggerByID(w http.ResponseWriter, r *http.Request)
delete(body, "id")
delete(body, "_path")
delete(body, "_raw")
+ delete(body, "_kind")
oldPath, oldDir, findErr := findTriggerFile(s.dataDir, id)
newKind := triggerKind(body)
@@ -131,10 +132,10 @@ func (s *AdminServer) handleTriggerByID(w http.ResponseWriter, r *http.Request)
oldContent = existing
}
- // If the kind changed, ensure the target directory exists and delete
- // the old path after writing the new one.
- if findErr == nil && newSubdir != oldDir {
- os.MkdirAll(filepath.Dir(newPath), 0755)
+ // Ensure the target subdirectory exists even for PUT-as-create.
+ if err := os.MkdirAll(filepath.Dir(newPath), 0755); err != nil {
+ failJSON(w, "mkdir error: "+err.Error())
+ return
}
triggerData, err := triggerMapToYAML(body)
@@ -170,7 +171,6 @@ func (s *AdminServer) handleTriggerByID(w http.ResponseWriter, r *http.Request)
desc.NewFilePath = newPath
}
s.undoStack.Push(desc)
- s.rebuildTriggerIndex()
writeJSON(w, map[string]any{"ok": true, "id": id, "kind": newKind})
case http.MethodDelete:
@@ -196,7 +196,6 @@ func (s *AdminServer) handleTriggerByID(w http.ResponseWriter, r *http.Request)
// new-content image over the file.
IsDelete: true,
})
- s.rebuildTriggerIndex()
writeJSON(w, map[string]any{"ok": true})
default:
@@ -285,6 +284,12 @@ func triggerMapToYAML(m map[string]any) ([]byte, error) {
}
// Handle simple fields.
+ if v, ok := m["item_id"]; ok {
+ if s, ok := v.(string); ok {
+ t.ItemID = s
+ }
+ delete(m, "item_id")
+ }
if v, ok := m["on_player_flag"]; ok {
if s, ok := v.(string); ok {
t.OnPlayerFlag = s
@@ -311,5 +316,3 @@ func triggerMapToYAML(m map[string]any) ([]byte, error) {
}
return yamlBytes, nil
}
-
-func (s *AdminServer) rebuildTriggerIndex() {}
diff --git a/internal/admin/server.go b/internal/admin/server.go
index 9819924..a68eeda 100644
--- a/internal/admin/server.go
+++ b/internal/admin/server.go
@@ -946,6 +946,7 @@ func (s *AdminServer) handleEntityDirs(entityType string) http.HandlerFunc {
for _, ch := range changes {
s.undoStack.Push(ch)
}
+ s.invalidateEntityCaches(entityType)
writeJSON(w, map[string]any{"ok": true, "moved": len(changes) - 1})
case "rename_dir":
if err := renameEntityDir(s.dataDir, entityType, body.Dir, body.NewName); err != nil {
@@ -959,6 +960,7 @@ func (s *AdminServer) handleEntityDirs(entityType string) http.HandlerFunc {
newPrefix := filepath.Join(s.dataDir, entityType, body.NewName)
s.undoStack.RewritePaths(oldPrefix, newPrefix)
}
+ s.invalidateEntityCaches(entityType)
writeJSON(w, map[string]any{"ok": true, "name": body.NewName})
default:
failJSON(w, "unknown action")
diff --git a/internal/admin/undo.go b/internal/admin/undo.go
index d9ef263..d352100 100644
--- a/internal/admin/undo.go
+++ b/internal/admin/undo.go
@@ -835,18 +835,6 @@ func (us *UndoStack) load() {
us.redo = toChanges(sd.Redo)
}
-func (us *UndoStack) Clear() {
- us.mu.Lock()
- defer us.mu.Unlock()
- us.history = nil
- us.redo = nil
- us.lastErr = nil
- os.Remove(us.filePath)
- log.Printf("undo: cleared history")
-}
-
-// LastError returns the error from the most recent failed Undo/Redo, or nil.
-// A nil change plus a nil LastError means the history/redo stack was empty.
func (us *UndoStack) LastError() error {
us.mu.Lock()
defer us.mu.Unlock()
diff --git a/internal/admin/yaml_util.go b/internal/admin/yaml_util.go
index 2ab7ec4..050a3f6 100644
--- a/internal/admin/yaml_util.go
+++ b/internal/admin/yaml_util.go
@@ -2,7 +2,6 @@ package admin
import (
"fmt"
- "log"
"os"
"path/filepath"
"strings"
@@ -133,15 +132,6 @@ func listYAMLTree(dataDir, subdir string) (*YAMLTree, error) {
return tree, nil
}
-func backupFile(path string) []byte {
- data, err := os.ReadFile(path)
- if err != nil {
- log.Printf("backup: WARNING failed to read %s: %v (undo will not be able to restore this file)", path, err)
- return nil
- }
- return data
-}
-
func snapshotFile(path string) ([]byte, error) {
return os.ReadFile(path)
}
diff --git a/internal/behavior/behavior.go b/internal/behavior/behavior.go
index e5039af..975624e 100644
--- a/internal/behavior/behavior.go
+++ b/internal/behavior/behavior.go
@@ -114,11 +114,11 @@ type Trigger struct {
// SpawnMobConfig configures a transient mob spawned by a trigger step. The
// mob id is required; other fields are optional.
type SpawnMobConfig struct {
- ID string `yaml:"id"`
- OwnerOnly bool `yaml:"owner_only"`
- DespawnOnLeave bool `yaml:"despawn_on_leave"`
- DespawnRooms []int `yaml:"despawn_rooms"`
- DespawnTicks float64 `yaml:"despawn_ticks"`
+ ID string `yaml:"id" json:"id"`
+ OwnerOnly bool `yaml:"owner_only" json:"owner_only"`
+ DespawnOnLeave bool `yaml:"despawn_on_leave" json:"despawn_on_leave"`
+ DespawnRooms []int `yaml:"despawn_rooms" json:"despawn_rooms"`
+ DespawnTicks float64 `yaml:"despawn_ticks" json:"despawn_ticks"`
}
// ShopConfig is a root-level mob property (mob YAML `shop:`). Buy prices are
@@ -193,10 +193,20 @@ func (c *ShopConfig) SellPrice(value, stock int) int {
// RestockInterval returns the effective restock interval (in ticks) for an item,
// honoring the per-item override, then the configurable global default.
func (c *ShopConfig) RestockInterval(item *ShopItem) int {
+ var ticks float64
if item != nil && item.RestockTicks > 0 {
- return int(item.RestockTicks)
+ ticks = item.RestockTicks
+ } else {
+ ticks = ShopDefaultRestock
}
- return int(ShopDefaultRestock)
+ // restock_ticks is authored as a float, so truncation can turn a valid
+ // fractional interval (e.g. 0.5) into 0; the shop loop would then
+ // decrement to -1 and restock every tick. Clamp to at least one tick.
+ n := int(ticks)
+ if n < 1 {
+ n = 1
+ }
+ return n
}
// FindItem returns the configured shop item with the given id, or nil.
diff --git a/internal/behavior/store.go b/internal/behavior/store.go
index d31fe84..3ea527c 100644
--- a/internal/behavior/store.go
+++ b/internal/behavior/store.go
@@ -32,13 +32,16 @@ func excludedContentPath(root, path string) bool {
func BuildPathIndex(dir string) map[string]string {
index := make(map[string]string)
filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
- if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") {
+ if err != nil || d.IsDir() {
+ return nil
+ }
+ id, ok := yamlID(d.Name())
+ if !ok {
return nil
}
if excludedContentPath(dir, path) {
return nil
}
- id := strings.TrimSuffix(d.Name(), ".yaml")
index[id] = path
return nil
})
@@ -48,13 +51,16 @@ func BuildPathIndex(dir string) map[string]string {
func BuildRoomIndex(dir string) map[int]string {
index := make(map[int]string)
filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
- if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") {
+ if err != nil || d.IsDir() {
+ return nil
+ }
+ id, ok := yamlID(d.Name())
+ if !ok {
return nil
}
if excludedContentPath(dir, path) {
return nil
}
- id := strings.TrimSuffix(d.Name(), ".yaml")
n, convErr := strconv.Atoi(id)
if convErr != nil {
// A non-numeric filename (for example a "1001_copy.yaml"
@@ -187,6 +193,9 @@ func LoadDropTable(dataDir, id string) (*DropTableDef, error) {
index := loadDropIndex(dataDir)
path, ok := index[id]
if !ok {
+ if id == "" || strings.Contains(id, "..") || strings.ContainsAny(id, `/\`) {
+ return nil, fmt.Errorf("invalid drop table id %q", id)
+ }
path = filepath.Join(dataDir, "drops", id+".yaml")
}
data, err := os.ReadFile(path)
@@ -268,6 +277,9 @@ func resolveDrop(dataDir string, drops []DropEntry, depth int) *DropEntry {
Quantity: qty,
Depletes: picked.Depletes,
SuccessMessage: picked.SuccessMessage,
+ Tool: picked.Tool,
+ Level: picked.Level,
+ XP: picked.XP,
}
}
return picked
diff --git a/internal/behavior/yml_test.go b/internal/behavior/yml_test.go
index 9452e9e..82acb19 100644
--- a/internal/behavior/yml_test.go
+++ b/internal/behavior/yml_test.go
@@ -27,3 +27,38 @@ func TestWalkYAMLDirIncludesYml(t *testing.T) {
t.Fatalf("WalkYAMLDir ids = %v, want both a and b", ids)
}
}
+
+func TestBuildPathIndexIncludesYml(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "a.yaml"), []byte("x: 1"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "b.yml"), []byte("x: 2"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ idx := BuildPathIndex(dir)
+ if idx["a"] == "" || idx["b"] == "" {
+ t.Fatalf("BuildPathIndex = %v, want both a and b", idx)
+ }
+}
+
+func TestBuildRoomIndexIncludesYml(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "1001.yaml"), []byte("name: a"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "1002.yml"), []byte("name: b"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ idx := BuildRoomIndex(dir)
+ if idx[1001] == "" || idx[1002] == "" {
+ t.Fatalf("BuildRoomIndex = %v, want both 1001 and 1002", idx)
+ }
+}
+
+func TestLoadDropTableRejectsPathEscape(t *testing.T) {
+ dataDir := t.TempDir()
+ if _, err := LoadDropTable(dataDir, "../../../etc/passwd"); err == nil {
+ t.Fatal("accepted a path-escaping drop table id")
+ }
+}
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go
index d385834..94d960a 100644
--- a/internal/casino/baccarat.go
+++ b/internal/casino/baccarat.go
@@ -264,6 +264,11 @@ func (t *baccaratTable) rebetAll(name string, wallet Wallet) []Event {
if !p.hasLastBet() {
return []Event{t.private(name, "Bet on what? Try bet N on "+t.Rules.betChoices()+".")}
}
+ // Require the main wager before committing remembered side bets: a
+ // side-only memory would otherwise charge the sides and never deal.
+ if p.mainSpot == "" || p.lastBets[p.mainSpot] <= 0 {
+ return []Event{t.private(name, "Your last bet had no main wager. Use bet N on " + t.Rules.betChoices() + ".")}
+ }
var events []Event
for _, spot := range t.Rules.spots() {
if baccaratIsMainSpot(spot) {
@@ -374,7 +379,7 @@ func (t *baccaratTable) unbetAllLocked(name string) []Event {
return []Event{t.private(name, "Nothing to take back.")}
}
if p.Wallet != nil {
- if err := p.Wallet.PayCredits(total, ids...); err != nil {
+ if err := p.Wallet.RefundStakes(ids...); err != nil {
return []Event{t.private(name, payoutFailedMessage())}
}
}
@@ -399,7 +404,7 @@ func (t *baccaratTable) refundSidesLocked(p *baccaratPlayer) []Event {
return nil
}
if p.Wallet != nil {
- if err := p.Wallet.PayCredits(total, ids...); err != nil {
+ if err := p.Wallet.RefundStakes(ids...); err != nil {
return []Event{t.private(p.Name, "Your side bets could not be returned just now; the stake stays pending and will be refunded after the next server restart.")}
}
}
@@ -476,7 +481,7 @@ func (t *baccaratTable) deal() []Event {
settlement := t.playerAction(p.Name, publicResult, privateResult)
// Returning players get their balance in the next bet menu; only
// departures/sit-outs need it attached to the settlement.
- settlement.ShowBalance = !payoutFailed && (p.LeaveAfter || p.SitOutAfter || !p.Connected)
+ settlement.ShowBalance = p.SettlementBalanceNeeded(payoutFailed)
if !payoutFailed {
settlement.Settlement = lines
switch {
diff --git a/internal/casino/big_six.go b/internal/casino/big_six.go
index b17bde4..36e9c1d 100644
--- a/internal/casino/big_six.go
+++ b/internal/casino/big_six.go
@@ -433,7 +433,7 @@ func (t *bigSixTable) settle() []Event {
// The next-spin menu already shows the balance for seats that remain.
// Only a departing or disconnected seat needs it here, otherwise the
// wallet would print twice after a result.
- ev.ShowBalance = !failed && (p.LeaveAfter || p.SitOutAfter || !p.Connected)
+ ev.ShowBalance = p.SettlementBalanceNeeded(failed)
events = append(events, ev)
}
t.reset()
diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go
index b5beb1e..83c183f 100644
--- a/internal/casino/blackjack.go
+++ b/internal/casino/blackjack.go
@@ -187,6 +187,9 @@ func (t *blackjackTable) bet(name, spot string, amount int, wallet Wallet) []Eve
if amount < t.Config.MinBet || amount > t.Config.MaxBet {
return []Event{t.private(name, fmt.Sprintf("Blackjack bets must be %s.", betRangeText(t.Config.MinBet, t.Config.MaxBet)))}
}
+ if wallet == nil {
+ return []Event{t.private(name, "Wallet unavailable.")}
+ }
wager, err := wallet.Wager(amount)
if err != nil {
return []Event{t.private(name, walletErrorMessage(err))}
@@ -244,6 +247,7 @@ func (t *blackjackTable) tick() []Event {
if active == nil {
// Unreachable while mid-round leaves are blocked; keep the game moving.
t.activePlayer++
+ t.activeHand = 0
return t.finishHand()
}
// A lone bettor has no time limit at all. With two or more bettors
@@ -581,10 +585,12 @@ func (t *blackjackTable) turnMenuLocked(name string) Event {
e := t.private(name, "Your turn.")
e.Color = "casino_menu"
deadline := t.timer.remaining()
+ heading := fmt.Sprintf("Your turn | %d ticks remaining", deadline)
if p.Connected && t.bettorCount() <= 1 {
- deadline = t.ceiling.remaining()
+ // A lone bettor's clock never ticks, so a countdown would be frozen.
+ heading = "Your turn | no time limit"
}
- e.Menu = &MenuView{Heading: fmt.Sprintf("Your turn | %d ticks remaining", deadline), Commands: withTableHints(blackjackActionCommands(actions), nil)}
+ e.Menu = &MenuView{Heading: heading, Commands: withTableHints(blackjackActionCommands(actions), nil)}
for i := range e.Menu.Commands {
c := &e.Menu.Commands[i]
if c.Command == "double" || c.Command == "split" {
@@ -693,7 +699,7 @@ func (t *blackjackTable) settle() []Event {
if !p.HasBet {
continue
}
- needsBalance := p.LeaveAfter || p.SitOutAfter || !p.Connected
+ needsBalance := p.SettlementBalanceNeeded(false)
lines := make([]settlementLine, 0, len(p.hands)+1)
for i, hand := range p.hands {
result, payout, settleErr := settleHand(hand, dealerTotal, dealerBlackjack, t.Rules)
diff --git a/internal/casino/blackjack_rules.go b/internal/casino/blackjack_rules.go
index 8bf63a8..50a5786 100644
--- a/internal/casino/blackjack_rules.go
+++ b/internal/casino/blackjack_rules.go
@@ -47,7 +47,7 @@ func (c TableConfig) BlackjackRulesText() string {
"Surrender: " + surrender,
"Insurance: Offered against an ace; costs half the original bet; pays " + payoutRatio(b.InsurancePayout),
"Fractional credits: Rounded down (including insurance stake and surrender return)",
- fmt.Sprintf("Shuffle threshold: %g%% of shoe remaining; may shuffle earlier for round coverage", b.ShuffleAt*100),
+ fmt.Sprintf("Shuffle threshold: %.6g%% of shoe remaining; may shuffle earlier for round coverage", b.ShuffleAt*100),
"Shoe exhaustion: Reshuffle mid-round if necessary",
"Bet limits: " + betRangeText(c.MinBet, c.MaxBet) + " credits",
fmt.Sprintf("Seats: %d; betting window: %d ticks; action window: %d ticks", c.MaxPlayers, c.BettingTicks, b.ActionTicks),
diff --git a/internal/casino/cash_hunt.go b/internal/casino/cash_hunt.go
index a07b09a..c04d644 100644
--- a/internal/casino/cash_hunt.go
+++ b/internal/casino/cash_hunt.go
@@ -267,6 +267,13 @@ func (t *cashHuntTable) action(n, action string) []Event {
if len(f) != 1 {
return []Event{t.private(n, "Choose a square with pick N or a number from 1 to 100.")}
}
+ // A non-numeric action while a hunt is live (e.g. the stop path's
+ // leave_after token) must not fall through to the square parser.
+ if t.active {
+ if _, err := strconv.Atoi(f[0]); err != nil {
+ return []Event{t.private(n, "Finish your hunt before leaving.")}
+ }
+ }
square, err := strconv.Atoi(f[0])
if err != nil || square < 1 || square > CashHuntSquares {
return []Event{t.private(n, "Choose a square from 1 to 100.")}
diff --git a/internal/casino/casino_review_regression_test.go b/internal/casino/casino_review_regression_test.go
index 8860bd7..c66dee6 100644
--- a/internal/casino/casino_review_regression_test.go
+++ b/internal/casino/casino_review_regression_test.go
@@ -44,7 +44,7 @@ func TestCrapsFailedUnbetPreservesAllLiveWagers(t *testing.T) {
if retry {
table.action("a", "unbet")
table.action("a", "unbet")
- if w.paid != 22 || len(w.open) != 1 || table.players.get("a").bets[0].odds.Total != 0 {
+ if w.chips != 990 || len(w.open) != 1 || table.players.get("a").bets[0].odds.Total != 0 {
t.Fatalf("refund retry was not exact: %+v", w)
}
} else {
diff --git a/internal/casino/core.go b/internal/casino/core.go
index d04e046..6bcd4f9 100644
--- a/internal/casino/core.go
+++ b/internal/casino/core.go
@@ -78,7 +78,7 @@ func (c *tableCore[P]) closeIfEmptyLocked(events []Event) []Event {
// openBettingLocked opens the timed betting window once a second player has a
// wager down, and announces it.
func (c *tableCore[P]) openBettingLocked(events []Event) []Event {
- if c.players.count() > 1 && c.phase == phaseWaiting {
+ if c.players.count() > 1 && c.phase == phaseWaiting && !allBet(c.players.participants()) {
c.phase = phaseBetting
c.window.open(c.Config.BettingTicks)
events = append(events, c.event(fmt.Sprintf("%s betting is open for %d ticks.", gameTitle(c.Config.Game), c.Config.BettingTicks), true))
diff --git a/internal/casino/craps.go b/internal/casino/craps.go
index 0abf24b..208799e 100644
--- a/internal/casino/craps.go
+++ b/internal/casino/craps.go
@@ -51,7 +51,6 @@ type CrapsSnapshot struct {
Queue []string
Made, Seen []int
Bets []CrapsBetView
- Remaining int // legacy snapshot field; presentation uses ticks below
ShootIn int
DealerIn int
AutoRoll bool
@@ -585,7 +584,7 @@ func (t *crapsTable) unbetAllLocked(p *crapsPlayer) []Event {
}
// Keep the live wagers intact until the atomic refund succeeds. On a
// failed save they still belong to the next roll, or a later unbet retry.
- if err := p.Wallet.PayCredits(total, ids...); err != nil {
+ if err := p.Wallet.RefundStakes(ids...); err != nil {
return []Event{t.private(p.Name, "The house could not take down your bets. All wagers remain live; try unbet again.")}
}
for _, b := range live {
diff --git a/internal/casino/craps_clock_test.go b/internal/casino/craps_clock_test.go
index 97758f8..3a68bf8 100644
--- a/internal/casino/craps_clock_test.go
+++ b/internal/casino/craps_clock_test.go
@@ -236,14 +236,14 @@ func TestCrapsUnbetAllAndRollTableFormat(t *testing.T) {
tb.bet("alice", "odds pass", 20, w)
tb.bet("alice", "place 4", 10, w)
tb.bet("alice", "field", 10, w)
- before := w.paid
+ before := w.chips
events := tb.action("alice", "unbet")
text := crapsEventText(events)
if !strings.Contains(text, "take down {casino_chips}40{/}") || !strings.Contains(text, "contracts stay up") {
t.Fatal(text)
}
- if w.paid != before+40 {
- t.Fatal("refund amount", w.paid-before)
+ if w.chips != before+40 {
+ t.Fatal("in-kind refund amount", w.chips-before)
}
bets := tb.players.get("alice").bets
if len(bets) != 2 || bets[0].kind != "fire" || bets[1].kind != "pass" || bets[1].odds.Total != 0 {
@@ -253,8 +253,8 @@ func TestCrapsUnbetAllAndRollTableFormat(t *testing.T) {
t.Fatal("pass and fire stakes must stay pending", w.open)
}
tb.action("alice", "unbet")
- if w.paid != before+40 {
- t.Fatal("second unbet all moved money", w.paid)
+ if w.chips != before+40 {
+ t.Fatal("second unbet all moved money", w.chips)
}
// Roll outcome line: every total once, single-level tags only.
tb2 := crapsFixture()
diff --git a/internal/casino/craps_regression_test.go b/internal/casino/craps_regression_test.go
index 0bf8ba6..e18108a 100644
--- a/internal/casino/craps_regression_test.go
+++ b/internal/casino/craps_regression_test.go
@@ -28,7 +28,7 @@ func TestCrapsHopIsUnordered(t *testing.T) {
t.Fatal("duplicate unordered hop accepted")
}
tb.action("a", "unbet")
- if len(w.open) != 0 || w.paid != 10 || w.chips != 90 {
+ if len(w.open) != 0 || w.paid != 0 || w.chips != 100 {
t.Fatal("reverse-order removal failed")
}
}
diff --git a/internal/casino/craps_test.go b/internal/casino/craps_test.go
index 8e6e669..622e541 100644
--- a/internal/casino/craps_test.go
+++ b/internal/casino/craps_test.go
@@ -310,7 +310,7 @@ func TestCrapsTimersControlsAndRefunds(t *testing.T) {
}
tb.bet("a", "place 6", 12, w)
tb.action("a", "unbet")
- if w.paid != 12 || tb.players.get("a").HasBet {
+ if w.chips != 100 || w.paid != 0 || tb.players.get("a").HasBet {
t.Fatal("optional removal")
}
tb.bet("a", "pass", 10, w)
diff --git a/internal/casino/manager_test.go b/internal/casino/manager_test.go
index 889618c..1e77b0b 100644
--- a/internal/casino/manager_test.go
+++ b/internal/casino/manager_test.go
@@ -74,6 +74,29 @@ func (w *testWallet) RefundOutstanding() (chips, credits int, err error) {
return chips, credits, nil
}
+// RefundStakes returns the named outstanding stakes to the balances in kind,
+// mirroring the production wallet's take-back path.
+func (w *testWallet) RefundStakes(stakeIDs ...string) error {
+ wanted := make(map[string]bool, len(stakeIDs))
+ for _, id := range stakeIDs {
+ wanted[id] = true
+ }
+ var chips, credits int
+ kept := w.open[:0]
+ for _, wager := range w.open {
+ if wanted[wager.ID] {
+ chips += wager.Chips
+ credits += wager.Credits
+ continue
+ }
+ kept = append(kept, wager)
+ }
+ w.open = kept
+ w.chips += chips
+ w.paid += credits
+ return nil
+}
+
func TestMixedWagerUsesChipsFirst(t *testing.T) {
w := &testWallet{chips: 7, credits: 50}
wager, err := w.Wager(12)
diff --git a/internal/casino/pai_gow.go b/internal/casino/pai_gow.go
index 55dc601..8a7ddf3 100644
--- a/internal/casino/pai_gow.go
+++ b/internal/casino/pai_gow.go
@@ -88,6 +88,7 @@ type paiGowTable struct {
dealer []card
dealerSplit pgSplit
tie [2]card
+ settleTried bool // guards against re-saving/re-messaging every tick while settling
}
func newPaiGowTable(room int, c TableConfig, rng *rand.Rand) tableGame {
@@ -601,6 +602,7 @@ func (t *paiGowTable) action(name, action string) []Event {
}
func (t *paiGowTable) prepareSettlementLocked() []Event {
t.phase = phaseSettling
+ t.settleTried = false
for _, p := range t.players {
if p.main.Total == 0 {
continue
@@ -742,6 +744,13 @@ func (t *paiGowTable) tick() []Event {
defer t.mu.Unlock()
switch t.phase {
case phaseSettling:
+ // Try to settle once automatically. If storage is persistently failing,
+ // do not re-attempt the full save (and failure message) every tick; the
+ // explicit `settle` command retries instead.
+ if t.settleTried {
+ return nil
+ }
+ t.settleTried = true
return t.payLocked()
case phaseBetting:
if t.allBetLocked() {
@@ -868,7 +877,7 @@ func (t *paiGowTable) refundSideBetsLocked(p *pgPlayer) []Event {
}
e := t.private(p.name, fmt.Sprintf("Your %d in side bets is returned; no main wager was placed.", total))
if p.wallet != nil {
- if err := p.wallet.PayCredits(total, ids...); err != nil {
+ if err := p.wallet.RefundStakes(ids...); err != nil {
e.Message = payoutFailedMessage()
}
}
@@ -938,7 +947,7 @@ func (t *paiGowTable) unbetLocked(p *pgPlayer) []Event {
return []Event{t.private(p.name, "Nothing to take back.")}
}
if p.wallet != nil {
- if err := p.wallet.PayCredits(total, ids...); err != nil {
+ if err := p.wallet.RefundStakes(ids...); err != nil {
return []Event{t.private(p.name, payoutFailedMessage())}
}
}
diff --git a/internal/casino/pai_gow_rules.go b/internal/casino/pai_gow_rules.go
index edfa38c..231b3fb 100644
--- a/internal/casino/pai_gow_rules.go
+++ b/internal/casino/pai_gow_rules.go
@@ -153,13 +153,6 @@ func pgCompare(a, b pgRank) int {
return 0
}
-// pgNatural keeps the default-rules entry point used by tests and callers that
-// do not thread a table configuration. Production evaluation goes through
-// pgNaturalRules.
-//
-//nolint:unused // default-rules entry point
-func pgNatural(cards []card) pgRank { return pgNaturalRules(cards, true) }
-
func pgNaturalRules(cards []card, a2345High bool) pgRank {
counts := [15]int{}
flush := len(cards) == 5
@@ -511,13 +504,6 @@ func pgFortuneRules(cards []card, envy, a2345High bool) (FortuneRow, bool) {
return rows[bestIndex], true
}
-// pgBest keeps the default-rules entry point used by tests and callers that do
-// not thread a table configuration. Production evaluation goes through
-// pgBestRules.
-//
-//nolint:unused // default-rules entry point
-func pgBest(cards []card) pgRank { return pgBestRules(cards, true) }
-
func pgBestRules(cards []card, a2345High bool) pgRank {
var best pgRank
for i := 0; i < 7; i++ {
diff --git a/internal/casino/plinko.go b/internal/casino/plinko.go
index 867978a..bcfae83 100644
--- a/internal/casino/plinko.go
+++ b/internal/casino/plinko.go
@@ -29,6 +29,12 @@ func validatePlinko(c TableConfig) error {
if math.IsNaN(r) || math.IsInf(r, 0) || r < 31 || r > 100 {
return fmt.Errorf("plinko rtp must be between 31 and 100 percent")
}
+ // The one-decimal paytable rounding can push the realized expert return
+ // above 100%. Reject any target whose expert paytable pays more than it
+ // takes, so the house never runs a losing plinko board.
+ if ret := plinkoReturn(3, r); ret > 1.0 {
+ return fmt.Errorf("plinko rtp %.1f makes the expert paytable return %.3f%%; lower it", r, 100*ret)
+ }
if c.MaxPlayers > 1 {
return fmt.Errorf("plinko has one seat")
}
diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go
index 1069a68..71a0916 100644
--- a/internal/casino/roulette.go
+++ b/internal/casino/roulette.go
@@ -754,7 +754,7 @@ func (t *rouletteTable) settle() []Event {
event := t.playerAction(p.Name, public, text)
// Returning players get their balance in the next-spin menu. Only
// departures/sit-outs need a balance attached to the settlement.
- event.ShowBalance = !failed && (p.LeaveAfter || p.SitOutAfter || !p.Connected)
+ event.ShowBalance = p.SettlementBalanceNeeded(failed)
if !failed {
event.Settlement = []SettlementLine{{Label: p.lastSpot, Odds: rouletteSettlementOdds(wager, pocket), Stake: p.Wager.Total, Returned: payout, Result: result}}
if result == "win" {
diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go
index 6ba76f2..d23c8a2 100644
--- a/internal/casino/table_ux.go
+++ b/internal/casino/table_ux.go
@@ -144,7 +144,7 @@ func (c *tableCore[P]) unbetLocked(name string) ([]Event, bool) {
ids = append(ids, p.Wager.ID)
}
if p.Wallet != nil {
- if err := p.Wallet.PayCredits(total, ids...); err != nil {
+ if err := p.Wallet.RefundStakes(ids...); err != nil {
return []Event{c.private(name, payoutFailedMessage())}, true
}
}
diff --git a/internal/casino/types.go b/internal/casino/types.go
index f6a828d..9a3f0a1 100644
--- a/internal/casino/types.go
+++ b/internal/casino/types.go
@@ -237,6 +237,11 @@ type Wallet interface {
// refund could not be made durable: nothing was returned and the stakes
// remain pending for a later attempt or startup recovery.
RefundOutstanding() (chips, credits int, err error)
+ // RefundStakes returns the named pending stakes in kind — chips as chips,
+ // credits as credits — and removes them from the ledger in one save. It is
+ // the take-back path for a seat that holds specific handles; unlike
+ // PayCredits it never converts casino-only chips into spendable credits.
+ RefundStakes(stakeIDs ...string) error
}
type EventType string
@@ -381,3 +386,11 @@ type Participant struct {
SitOutAfter bool
LeaveAfter bool
}
+
+// SettlementBalanceNeeded reports whether a settlement should print the seat's
+// balance: the seat is leaving, sitting out after the round, or disconnected,
+// and the payout did not fail. Shared by every table so the rule lives in one
+// place instead of being copy-pasted per game.
+func (p *Participant) SettlementBalanceNeeded(payoutFailed bool) bool {
+ return !payoutFailed && (p.LeaveAfter || p.SitOutAfter || !p.Connected)
+}
diff --git a/internal/casino/video_poker_lifecycle_test.go b/internal/casino/video_poker_lifecycle_test.go
index f817225..b9b6dd7 100644
--- a/internal/casino/video_poker_lifecycle_test.go
+++ b/internal/casino/video_poker_lifecycle_test.go
@@ -29,6 +29,13 @@ func (w *pokerTestWallet) PayCredits(n int, ids ...string) error {
return w.testWallet.PayCredits(n, ids...)
}
+func (w *pokerTestWallet) RefundStakes(ids ...string) error {
+ if w.fail {
+ return ErrWalletPersistence
+ }
+ return w.testWallet.RefundStakes(ids...)
+}
+
func (e Event) MessageStarts(prefix string) bool { return strings.HasPrefix(e.Message, prefix) }
type pokerTestBank struct{ writes int }
diff --git a/internal/color/color.go b/internal/color/color.go
index ff47a47..8376dde 100644
--- a/internal/color/color.go
+++ b/internal/color/color.go
@@ -36,7 +36,18 @@ var ansi16RGB = [16][3]int{
var cubeValues = [6]int{0, 95, 135, 175, 215, 255}
+// Plain reports whether a color mode renders no color. The empty mode and
+// "none" are equivalent (Render treats both as plain), so callers should use
+// this instead of open-coding the pair.
+func Plain(mode string) bool { return mode == "" || mode == "none" }
+
func Xterm256ToRGB(index int) (int, int, int) {
+ if index < 0 {
+ index = 0
+ }
+ if index > 255 {
+ index = 255
+ }
if index < 16 {
return ansi16RGB[index][0], ansi16RGB[index][1], ansi16RGB[index][2]
}
@@ -205,8 +216,14 @@ func TruncateVisible(s string, maxVisible int, unicode bool) string {
}
var pre, suf string
rest := s
- if idx := strings.IndexByte(rest, 'm'); idx >= 0 && strings.HasPrefix(rest, "\x1b[") {
- pre = rest[:idx+1]
+ // Render emits style and foreground as separate SGR sequences; peel every
+ // leading one, not just the first, or the second is counted as visible text.
+ for strings.HasPrefix(rest, "\x1b[") {
+ idx := strings.IndexByte(rest, 'm')
+ if idx < 0 {
+ break
+ }
+ pre += rest[:idx+1]
rest = rest[idx+1:]
}
if strings.HasSuffix(rest, Reset) {
diff --git a/internal/combat/state.go b/internal/combat/state.go
index e1ee8e7..17a9c09 100644
--- a/internal/combat/state.go
+++ b/internal/combat/state.go
@@ -36,6 +36,13 @@ func (t *Tracker) Enter(playerName, mobID string) {
if prev, ok := t.combatants[playerName]; ok {
delete(t.mobTargets, prev.MobID)
}
+ // If another player already holds this mob, replacing the mobTargets
+ // entry would leave their combatants entry pointing at a mob this new
+ // player now owns. Clear it so their later Leave cannot delete this
+ // player's claim and unlock a mob that is still being fought.
+ if prevOwner, ok := t.mobTargets[mobID]; ok && prevOwner != playerName {
+ delete(t.combatants, prevOwner)
+ }
t.combatants[playerName] = &State{
PlayerName: playerName,
MobID: mobID,
diff --git a/internal/combat/state_test.go b/internal/combat/state_test.go
index 8005ba3..d21bc0d 100644
--- a/internal/combat/state_test.go
+++ b/internal/combat/state_test.go
@@ -19,3 +19,23 @@ func TestTrackerEnterReleasesPreviousMob(t *testing.T) {
t.Fatal("mobB still marked in combat after leave")
}
}
+
+// Regression: two players claiming the same mob must not leave a dangling
+// combatants entry for the displaced player; otherwise that player's later
+// Leave deletes the new owner's mobTargets claim and unlocks a live fight.
+func TestTrackerEnterDisplacesOtherPlayer(t *testing.T) {
+ tr := NewTracker()
+ tr.Enter("alice", "mobA")
+ tr.Enter("bob", "mobA")
+ if !tr.IsMobInCombat("mobA") {
+ t.Fatal("mobA should still be in combat after the second claim")
+ }
+ if tr.MobTarget("mobA") != "bob" {
+ t.Fatalf("mobTarget = %q, want bob", tr.MobTarget("mobA"))
+ }
+ // Alice was displaced; her leave must not unlock Bob's fight.
+ tr.Leave("alice")
+ if !tr.IsMobInCombat("mobA") || tr.MobTarget("mobA") != "bob" {
+ t.Fatal("alice's leave unlocked the mob bob is still fighting")
+ }
+}
diff --git a/internal/cryptomarket/chart.go b/internal/cryptomarket/chart.go
index c1fef29..5ea477c 100644
--- a/internal/cryptomarket/chart.go
+++ b/internal/cryptomarket/chart.go
@@ -13,7 +13,10 @@ func Chart(candles []Candle, width int, unicode bool, paint func(string, bool) s
width = max(12, min(80, width))
label := 12
count := width - label - 2
- if count < 2 || len(candles) < 2 {
+ if count < 2 {
+ return []string{"Chart too narrow; widen wrap_width."}
+ }
+ if len(candles) < 2 {
return []string{"Not enough completed daily candles."}
}
if len(candles) > count {
diff --git a/internal/cryptomarket/client.go b/internal/cryptomarket/client.go
index 4d6d0a8..8445f4a 100644
--- a/internal/cryptomarket/client.go
+++ b/internal/cryptomarket/client.go
@@ -95,16 +95,16 @@ func (s Snapshot) Expiry(symbol string, expiry int64) (string, bool) {
}
type Client struct {
- HTTP *http.Client
- Base string
- once sync.Once
- gate chan struct{}
- cache Snapshot
- fetchedAt map[string]time.Time
- historyDay int64
- history map[string][]Candle
- retryAfter time.Time
- lastError error
+ HTTP *http.Client
+ Base string
+ once sync.Once
+ gate chan struct{}
+ cache Snapshot
+ attemptedAt map[string]time.Time
+ historyDay int64
+ history map[string][]Candle
+ retryAfter time.Time
+ lastError error
}
func NewClient() *Client {
@@ -179,27 +179,27 @@ func (c *Client) Fetch(ctx context.Context, symbols []string) (Snapshot, error)
c.retryAfter = time.Now().Add(15 * time.Second)
return result, err
}
- if c.fetchedAt == nil {
- c.fetchedAt = map[string]time.Time{}
+ if c.attemptedAt == nil {
+ c.attemptedAt = map[string]time.Time{}
}
c.cache = c.cache.merge(result)
now := time.Now()
- for sym := range result.Quotes {
- c.fetchedAt[sym] = now
+ for _, sym := range symbols {
+ c.attemptedAt[sym] = now
}
return result, nil
}
-// fresh reports whether every requested symbol has a recent quote. Each
-// symbol ages independently, so another currency's fetch cannot make a stale
-// quote look usable. No background work runs: only an explicit interaction
-// refreshes anything.
+// fresh reports whether every requested symbol has a recent fetch attempt. A
+// symbol that was attempted but produced no quote (halted/delisted, or a
+// missing/empty ticker) is still considered fresh: without this, every crypto
+// command would re-run the full request chain for a permanently-unavailable
+// pair. No background work runs: only an explicit interaction refreshes
+// anything.
func (c *Client) fresh(symbols []string) bool {
for _, sym := range symbols {
- if _, ok := c.cache.Quotes[sym]; !ok {
- return false
- }
- if at, ok := c.fetchedAt[sym]; !ok || time.Since(at) >= 15*time.Second {
+ at, ok := c.attemptedAt[sym]
+ if !ok || time.Since(at) >= 15*time.Second {
return false
}
}
diff --git a/internal/cryptomarket/client_test.go b/internal/cryptomarket/client_test.go
index 39367fc..ab77b24 100644
--- a/internal/cryptomarket/client_test.go
+++ b/internal/cryptomarket/client_test.go
@@ -214,3 +214,24 @@ func TestClientCancellationDoesNotPoisonSharedBackoff(t *testing.T) {
t.Fatalf("cancelled fetch poisoned the shared client: %v", e)
}
}
+
+func TestHaltedSymbolIsFreshAfterAttempt(t *testing.T) {
+ c, calls := mockKraken(t, "halted")
+ if s, e := c.Fetch(context.Background(), []string{"BTC"}); e != nil {
+ t.Fatal(e)
+ } else if _, ok := s.Quotes["BTC"]; ok {
+ t.Fatal("halted symbol unexpectedly quoted")
+ }
+ first := calls.Load()
+ if first != 4 {
+ t.Fatalf("first fetch calls = %d, want 4", first)
+ }
+ // A halted/delisted pair has no quote, but the attempt must be cached so
+ // the next command does not re-run the full request chain forever.
+ if _, e := c.Fetch(context.Background(), []string{"BTC"}); e != nil {
+ t.Fatal(e)
+ }
+ if calls.Load() != first {
+ t.Fatalf("halted symbol re-fetched: %d -> %d", first, calls.Load())
+ }
+}
diff --git a/internal/game/account_txn.go b/internal/game/account_txn.go
index 77a4219..32c02f8 100644
--- a/internal/game/account_txn.go
+++ b/internal/game/account_txn.go
@@ -411,6 +411,10 @@ func (g *Game) publishAccountUpdate(oldName, newName string) {
Options: acc.Options,
Admin: acc.Admin,
}
+ // A session still sitting at the password prompt for the OLD name must
+ // authorize against the NEW name on its next submit, or it loads the
+ // stale name and fails with "Error loading account.".
+ sess.SetAuthName(newName)
if sess.Account.Aliases == nil {
sess.Account.Aliases = make(map[string]string)
}
diff --git a/internal/game/act_combine.go b/internal/game/act_combine.go
index d7aed04..a50d310 100644
--- a/internal/game/act_combine.go
+++ b/internal/game/act_combine.go
@@ -18,6 +18,21 @@ func (g *Game) startCombine(sess *net.Session, p *player.Player, item *item.Item
return
}
+ // See startProduction: combine recipes can start outside the crafting
+ // menus, so the station/tool gate must live at the execution site.
+ if len(craft.Station) > 0 {
+ if stID, _ := g.findStation(p.RoomID, craft.Station); stID == "" {
+ sess.WriteLine("You are not near the required station.")
+ g.reprompt(sess)
+ return
+ }
+ }
+ if craft.Tool != "" && !g.hasToolType(p, craft.Tool) {
+ sess.WriteLine("You do not have the required tool.")
+ g.reprompt(sess)
+ return
+ }
+
outDef, _ := g.ItemStore.Load(item.ID)
outputName := item.ID
if outDef != nil {
@@ -47,13 +62,12 @@ 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 {
+// recipeSkill resolves the skill a recipe trains and rolls against. A recipe
+// type is an action name, not necessarily a skill: player.SkillName of e.g.
+// "combine" or "cleaning" is a literal action, so p.Level would read a map
+// miss as level 1 and AddSkillXP would persist a phantom skill key. A recipe
+// that declares a real skill name is honored; anything else trains Crafting.
+func recipeSkill(craft *item.CraftDef) player.SkillName {
skill := player.SkillName(craft.EffectiveSkill())
if player.ValidSkill(skill) {
return skill
@@ -61,6 +75,13 @@ func combineSkill(craft *item.CraftDef) player.SkillName {
return player.Crafting
}
+// combineSkill resolves the skill a combine recipe trains. Combine recipes
+// declare type "combine", which is an action, not a skill: the shared
+// recipeSkill fallback keeps success rolls and XP on Crafting.
+func combineSkill(craft *item.CraftDef) player.SkillName {
+ return recipeSkill(craft)
+}
+
func (g *Game) advanceCombine(sess *net.Session, p *player.Player) {
d, ok := p.Action.Data.(*behavior.CombineData)
if !ok {
diff --git a/internal/game/act_state_test.go b/internal/game/act_state_test.go
index 4182c61..75b82ec 100644
--- a/internal/game/act_state_test.go
+++ b/internal/game/act_state_test.go
@@ -7,7 +7,7 @@ import (
)
func TestPlayerActionDisplayFightingInCombat(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
inst := spawnTestMob(g, 100)
p := player.New("combatant")
p.EscapeDir = "north"
diff --git a/internal/game/act_steal.go b/internal/game/act_steal.go
index 7df400b..50c83ee 100644
--- a/internal/game/act_steal.go
+++ b/internal/game/act_steal.go
@@ -146,6 +146,12 @@ func (g *Game) resolveStealTarget(p *player.Player, input string) (targetType st
})
if len(matchingMobs) > 0 {
+ // Out-of-range 1-based selectors are no-target, matching findMob. The
+ // previous fallthrough silently stole from the first mob for a single
+ // candidate and reported "nothing here" only for 2+ candidates.
+ if instanceIdx >= 0 && instanceIdx >= len(matchingMobs) {
+ return "", nil, nil, "There's nothing here to steal from."
+ }
uniqueDefs := make(map[string]bool)
for _, m := range matchingMobs {
uniqueDefs[m.DefID] = true
diff --git a/internal/game/act_trigger_module.go b/internal/game/act_trigger_module.go
index 467cb83..c222dd7 100644
--- a/internal/game/act_trigger_module.go
+++ b/internal/game/act_trigger_module.go
@@ -44,7 +44,7 @@ func (g *Game) advanceTriggerModule(sess *net.Session, p *player.Player) {
} else {
g.triggerModReward(sess, p, mod, 1.0)
if g.Combat.Get(p.Name) != nil {
- g.Combat.Leave(p.Name)
+ g.leaveCombat(p)
}
switch mod.Category {
case ModTransport:
diff --git a/internal/game/baccarat_rules_test.go b/internal/game/baccarat_rules_test.go
index 63e2dc2..34f8a79 100644
--- a/internal/game/baccarat_rules_test.go
+++ b/internal/game/baccarat_rules_test.go
@@ -35,7 +35,7 @@ func TestLookBaccaratFixtureWithoutScenery(t *testing.T) {
}
}
}
- if g.lookBaccaratTable(sess, "other") {
+ if g.lookCasinoTable(sess, "other", casino.GameBaccarat) {
t.Fatal("matched unrelated target")
}
}
diff --git a/internal/game/baseline_race_test.go b/internal/game/baseline_race_test.go
index 4efc907..e0b28f3 100644
--- a/internal/game/baseline_race_test.go
+++ b/internal/game/baseline_race_test.go
@@ -45,7 +45,9 @@ func TestBaselineRaceSessionInputTickDisconnect(t *testing.T) {
}()
// Disconnect bob from a third goroutine while everything else runs.
+ wg.Add(1)
go func() {
+ defer wg.Done()
for i := 0; i < 50; i++ {
h.hub.PlayersInRoom(1)
if i == 25 {
diff --git a/internal/game/blackjack_rules.go b/internal/game/blackjack_rules.go
index f163fb8..2a0712d 100644
--- a/internal/game/blackjack_rules.go
+++ b/internal/game/blackjack_rules.go
@@ -7,37 +7,6 @@ import (
"thehouseoficarus/internal/net"
)
-// Tables are room fixtures in their own right, not authored scenery objects.
-// Each game resolves through the shared details view; the felt games append
-// their per-table rule card there.
-func (g *Game) lookBlackjackTable(sess *net.Session, target string) bool {
- return g.lookCasinoTable(sess, target, casino.GameBlackjack)
-}
-
-func (g *Game) lookBaccaratTable(sess *net.Session, target string) bool {
- return g.lookCasinoTable(sess, target, casino.GameBaccarat)
-}
-
-func (g *Game) lookCrapsTable(sess *net.Session, target string) bool {
- return g.lookCasinoTable(sess, target, casino.GameCraps)
-}
-
-func (g *Game) lookBigSixTable(sess *net.Session, target string) bool {
- return g.lookCasinoTable(sess, target, casino.GameBigSix)
-}
-
-func (g *Game) lookRouletteTable(sess *net.Session, target string) bool {
- return g.lookCasinoTable(sess, target, casino.GameRoulette)
-}
-
-func (g *Game) lookPlinkoTable(sess *net.Session, target string) bool {
- return g.lookCasinoTable(sess, target, casino.GamePlinko)
-}
-
-func (g *Game) lookPaiGowTable(sess *net.Session, target string) bool {
- return g.lookCasinoTable(sess, target, casino.GamePaiGow)
-}
-
// A cabinet's published pay table is public; another player's cards are not.
func (g *Game) lookVideoPokerCabinet(sess *net.Session, target string) bool {
if g.Casino == nil || sess.Player == nil {
diff --git a/internal/game/blackjack_rules_test.go b/internal/game/blackjack_rules_test.go
index 47d057c..ebf5f64 100644
--- a/internal/game/blackjack_rules_test.go
+++ b/internal/game/blackjack_rules_test.go
@@ -26,7 +26,7 @@ func TestLookBlackjackFixtureWithoutScenery(t *testing.T) {
sess.Player.RoomID = 1
for _, target := range []string{"blackjack", "blackjack table", "table", "test", "test table"} {
conn.data = nil
- if !g.lookBlackjackTable(sess, target) {
+ if !g.lookCasinoTable(sess, target, casino.GameBlackjack) {
t.Fatalf("did not match %q", target)
}
text := plainRoulette(string(conn.data))
@@ -36,7 +36,7 @@ func TestLookBlackjackFixtureWithoutScenery(t *testing.T) {
}
}
}
- if g.lookBlackjackTable(sess, "other") {
+ if g.lookCasinoTable(sess, "other", casino.GameBlackjack) {
t.Fatal("matched unrelated target")
}
}
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 87b7176..a0cc675 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -1,211 +1,15 @@
package game
import (
- "errors"
"fmt"
- "os"
"strconv"
"strings"
- "unicode/utf8"
- "thehouseoficarus/internal/cardart"
"thehouseoficarus/internal/casino"
- "thehouseoficarus/internal/color"
"thehouseoficarus/internal/net"
- "thehouseoficarus/internal/persistence"
"thehouseoficarus/internal/player"
)
-type casinoWallet struct {
- g *Game
- p *player.Player
- // Optional per-wallet persistence seam for fault-injection tests.
- saveCharacter func(*player.Player) error
-}
-
-// save treats a completed replacement as applied, even if its durability is
-// uncertain. Reverting memory then would disagree with the visible file and
-// could overwrite a paid win or resurrect a resolved stake on the next save.
-// Report the storage fault, but do not tell the game to retry/refund an
-// already-applied mutation. Only pre-replacement failures require rollback.
-func (w casinoWallet) save(operation string) error {
- save := w.saveCharacter
- if save == nil {
- save = w.g.AccountStore.SaveCharacter
- }
- err := save(w.p)
- if errors.Is(err, persistence.ErrDurabilityUncertain) {
- fmt.Fprintf(os.Stderr, "casino %s: %s save durability uncertain: %v — keeping applied state\n", operation, w.p.Name, err)
- return nil
- }
- return err
-}
-
-// Wager debits the stake and records it in PendingWagers in the SAME atomic
-// character save. The entry stays until the round resolves: a settlement
-// removes the stakes it returns (PayCredits) or consumes them
-// (ResolveStake/ResolveStakes), and startup refunds anything still listed
-// (see recoverPendingWagers). A pre-replacement save failure reverts the
-// debit and ledger entry; an uncertain replacement keeps both (see save).
-func (w casinoWallet) Wager(amount int) (casino.Wager, error) {
- if amount <= 0 {
- return casino.Wager{}, casino.ErrInsufficientFunds
- }
- creditsBefore := w.p.Credits
- chips := countChips(w.p)
- if chips > amount {
- chips = amount
- }
- credits := amount - chips
- if creditsBefore < credits {
- return casino.Wager{}, casino.ErrInsufficientFunds
- }
- if chips > 0 {
- w.p.RemoveItem("chips", chips)
- }
- w.p.Credits -= credits
- pending := player.PendingWager{ID: newPendingWagerID(), Chips: chips, Credits: credits}
- w.p.PendingWagers = append(w.p.PendingWagers, pending)
- if err := w.save("wager"); err != nil {
- // Revert the in-memory mutation: the play must not proceed on a
- // stake that could not be made durable.
- if chips > 0 {
- if !w.g.giveChips(w.p, chips) {
- // Cannot happen (the debit just freed or reduced the chips
- // stack), but surface it instead of silently losing chips.
- fmt.Fprintf(os.Stderr, "casino wager revert: no inventory room for %d chips\n", chips)
- }
- }
- w.p.Credits = creditsBefore
- removePendingWager(w.p, pending.ID)
- return casino.Wager{}, casino.ErrWalletPersistence
- }
- return casino.Wager{Total: amount, Chips: chips, Credits: credits, ID: pending.ID}, nil
-}
-
-// PayCredits banks a payout durably and, in the SAME save, resolves the
-// listed stakes. stakeIDs may be empty for a payout that returns no stake (a
-// free-spin bank already refunded by RefundOutstanding). A table settlement
-// passes the hand's stake IDs so the credit and the stake return are one
-// atomic step; a slot win passes its triggering stake for the same reason.
-// Either way a crash can never pay the payout AND refund the same stake. A
-// pre-replacement failure reverts both the credit and the ledger and reports
-// failure. An uncertain replacement is applied and logged (see save).
-func (w casinoWallet) PayCredits(amount int, stakeIDs ...string) error {
- return w.payCredits(amount, stakeIDs)
-}
-
-// payCredits credits the payout and, when stakeIDs is non-empty, removes
-// those stakes from the pending ledger in the same atomic save. On a
-// pre-replacement failure the credit and the ledger are reverted together.
-func (w casinoWallet) payCredits(amount int, stakeIDs []string) error {
- if amount <= 0 && len(stakeIDs) == 0 {
- return nil
- }
- creditsBefore := w.p.Credits
- sum, capErr := addCreditsChecked(creditsBefore, amount)
- if capErr != nil {
- return capErr
- }
- var removed []player.PendingWager
- if len(stakeIDs) > 0 {
- removed = takePendingWagers(w.p, stakeIDs)
- }
- w.p.Credits = sum
- if err := w.save("payout"); err != nil {
- w.p.Credits = creditsBefore
- w.p.PendingWagers = append(w.p.PendingWagers, removed...)
- return casino.ErrWalletPersistence
- }
- return nil
-}
-
-// ResolveStake consumes ONE pending stake by wallet handle: its hand lost or
-// was paid by a cover, so it must never be refunded. A settlement that
-// already removed it (PayCredits) makes this a no-op, so no redundant
-// save is paid.
-func (w casinoWallet) ResolveStake(stakeID string) {
- if stakeID == "" {
- return
- }
- removed := takePendingWagers(w.p, []string{stakeID})
- if len(removed) == 0 {
- return
- }
- if err := w.save("resolve stake"); err != nil {
- // The durable file still lists the stake, so restore it in memory to
- // keep the two records consistent for a later retry/recovery.
- w.p.PendingWagers = append(w.p.PendingWagers, removed...)
- fmt.Fprintf(os.Stderr, "casino resolve stake: save for %s: %v (ledger restored in memory)\n", w.p.Name, err)
- }
-}
-
-// ResolveStakesByIDs consumes the listed pending stakes in one durable save.
-// A losing split/doubled hand used to call ResolveStake once per handle, each
-// with its own full character save; batching removes that repeated fsync and
-// the crash window between saves.
-func (w casinoWallet) ResolveStakesByIDs(stakeIDs ...string) {
- if len(stakeIDs) == 0 {
- return
- }
- removed := takePendingWagers(w.p, stakeIDs)
- if len(removed) == 0 {
- return
- }
- if err := w.save("resolve stakes"); err != nil {
- // The durable file still lists the stakes, so restore them in memory to
- // keep the two records consistent for a later retry/recovery.
- w.p.PendingWagers = append(w.p.PendingWagers, removed...)
- fmt.Fprintf(os.Stderr, "casino resolve stakes: save for %s: %v (ledger restored in memory)\n", w.p.Name, err)
- }
-}
-
-// RefundOutstanding returns every pending stake to the player — chips in
-// kind, credits as credits — and clears the ledger in one save. It is used
-// by graceful shutdown/eviction and by a pre-deal disconnect. A pre-replacement
-// failure reverts the return and ledger for retry; an uncertain replacement
-// keeps both applied (see save).
-func (w casinoWallet) RefundOutstanding() (chips, credits int, err error) {
- before := append([]player.PendingWager(nil), w.p.PendingWagers...)
- for _, pending := range before {
- chips += pending.Chips
- credits += pending.Credits
- }
- if chips == 0 && credits == 0 {
- return 0, 0, nil
- }
- creditsBefore := w.p.Credits
- w.p.PendingWagers = nil
- if chips > 0 {
- if !w.g.giveChips(w.p, chips) {
- w.p.PendingWagers = before
- return 0, 0, casino.ErrWalletPersistence
- }
- }
- sum, capErr := addCreditsChecked(creditsBefore, credits)
- if capErr != nil {
- if chips > 0 {
- w.p.RemoveItem("chips", chips)
- }
- w.p.PendingWagers = before
- return 0, 0, casino.ErrWalletPersistence
- }
- w.p.Credits = sum
- if err := w.save("refund"); err != nil {
- w.p.Credits = creditsBefore
- if chips > 0 {
- w.p.RemoveItem("chips", chips)
- }
- w.p.PendingWagers = before
- return 0, 0, casino.ErrWalletPersistence
- }
- return chips, credits, nil
-}
-
-// casinoAffordable is the most a player can wager: chips are spent first,
-// then credits.
-func casinoAffordable(p *player.Player) int { return p.Credits + countChips(p) }
-
// leaveCasinoGames departs every casino game the player participates in
// using the graceful leave policy each game defines for player-initiated
// exits (the same path the "stop" command takes): a table with a wager
@@ -349,18 +153,13 @@ func (g *Game) executePlay(sess *net.Session, args []string, rawInput string) {
g.showCasinoChoices(sess, choices)
return
}
- if len(args) > 1 {
- amount, err := strconv.Atoi(args[1])
- if !choice.table || len(args) > 3 || (args[1] != "min" && args[1] != "max" && (err != nil || amount <= 0)) {
- sess.WriteLine("Use play GAME [N|min|max] [on BET]. No wager was placed.")
- return
- }
- }
g.startCasinoGame(sess, p, choice.address)
if len(args) > 1 {
- if t, joined := g.Casino.Participation(p.Name); joined && t != nil {
- g.executeBet(sess, args[1:], rawInput)
- }
+ // Let executeBet parse the wager/target tokens. Pre-validating here
+ // rejected the advertised "play GAME N on BET" form (len > 3) and any
+ // machine amount (choice.table is false for machines); executeBet's own
+ // parser already produces precise errors for each game's syntax.
+ g.executeBet(sess, args[1:], rawInput)
}
}
@@ -667,8 +466,8 @@ func (g *Game) emitCasinoEvents(events []casino.Event) {
// casinoMenuParts resolves a structured menu into the shared header/balance
// heading/commands used by both menu render paths: the balance line, the
-// heading (falling back to the event message), and a copy of the commands
-// annotated with an unavailability note for anything the player can't afford.
+// heading (falling back to the event message), and a copy of the commands.
+// Unaffordable commands are filtered at render time by casinoCommandColumns.
func (g *Game) casinoMenuParts(sess *net.Session, menu *casino.MenuView, message string) (balance, heading string, commands []casino.MenuCommand) {
balance = ""
if menu.ShowBalance {
@@ -682,11 +481,9 @@ func (g *Game) casinoMenuParts(sess *net.Session, menu *casino.MenuView, message
if !g.casinoHintsVisible(sess, menu) {
commands = nil
}
- for i := range commands {
- if commands[i].Cost > casinoAffordable(sess.Player) {
- commands[i].Desc += " [unavailable: insufficient funds]"
- }
- }
+ // Unaffordable commands are hidden by casinoCommandColumns; an
+ // "unavailable: insufficient funds" annotation here would be dead, since
+ // the renderer drops exactly those commands with the same test.
return balance, heading, commands
}
@@ -726,7 +523,7 @@ func (g *Game) casinoBoardReminder(sess *net.Session, category string, menu *cas
if !isCashHunt && !isPlinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && (!isRoulette || s.Phase == "playing" || s.Phase == "result") {
return "", false
}
- if !isCashHunt && !isPlinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) {
+ if isRoulette && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) {
// The compact reminder is already a short block; leave it intact.
return "", false
}
@@ -881,400 +678,3 @@ func (g *Game) casinoMenuHeader(sess *net.Session, balance, heading string) stri
func (g *Game) casinoMenuCommands(sess *net.Session, commands []casino.MenuCommand) string {
return g.casinoCommandColumns(sess, commands)
}
-
-func renderBlackjackSnapshotFor(snapshot *casino.BlackjackSnapshot, mode string, unicode bool, size int, style string, art *cardart.Store, viewer string) string {
- if snapshot == nil {
- return ""
- }
- size = cardRenderSize(unicode, size)
- var lines []string
- dealerTitle := "Dealer:"
- if snapshot.DealerRevealed {
- dealerTitle = "Dealer (" + snapshot.DealerScore + "):"
- } else if snapshot.DealerShownScore != "" {
- dealerTitle = "Dealer (" + snapshot.DealerShownScore + "):"
- }
- if size == 1 {
- lines = append(lines, cardLine(mode, dealerTitle)+" "+strings.Join(cardsLine(mode, snapshot.Dealer, size, unicode, style, art), " "))
- } else {
- lines = append(lines, cardLine(mode, dealerTitle))
- lines = append(lines, cardsLine(mode, snapshot.Dealer, size, unicode, style, art)...)
- }
- for _, player := range snapshot.Players {
- name := player.Name
- if name == viewer {
- name = "You"
- }
- marker := " "
- if player.Active {
- marker = "> "
- }
- detail := ""
- if player.Hand > 0 {
- detail = fmt.Sprintf(" | Hand %d/%d | Wager %d | %s", player.Hand, player.Hands, player.Bet, player.Status)
- }
- if player.Active && player.Name != viewer {
- detail = strings.ReplaceAll(detail, "Your turn", "Playing")
- }
- title := fmt.Sprintf("%s%s (%s)%s:", marker, name, player.Score, detail)
- if size == 1 {
- lines = append(lines, cardLine(mode, title)+" "+strings.Join(cardsLine(mode, player.Cards, size, unicode, style, art), " "))
- } else {
- lines = append(lines, cardLine(mode, title))
- lines = append(lines, cardsLine(mode, player.Cards, size, unicode, style, art)...)
- }
- }
- return strings.Join(lines, "\n")
-}
-
-// renderBaccaratSnapshot draws the player and banker hands followed by the
-// list of wagers riding on the round.
-func renderBaccaratSnapshot(snapshot *casino.BaccaratSnapshot, mode string, unicode bool, size int, style string, art *cardart.Store) string {
- if snapshot == nil {
- return ""
- }
- size = cardRenderSize(unicode, size)
- var lines []string
- if size == 1 {
- lines = append(lines, cardLine(mode, "Player ("+snapshot.PlayerScore+"):")+" "+strings.Join(cardsLine(mode, snapshot.Player, size, unicode, style, art), " "), cardLine(mode, "Banker ("+snapshot.BankerScore+"):")+" "+strings.Join(cardsLine(mode, snapshot.Banker, size, unicode, style, art), " "))
- } else {
- lines = append(lines, cardLine(mode, "Player ("+snapshot.PlayerScore+"):"))
- lines = append(lines, cardsLine(mode, snapshot.Player, size, unicode, style, art)...)
- lines = append(lines, cardLine(mode, "Banker ("+snapshot.BankerScore+"):"))
- lines = append(lines, cardsLine(mode, snapshot.Banker, size, unicode, style, art)...)
- }
- for _, bet := range snapshot.Bets {
- lines = append(lines, cardLine(mode, fmt.Sprintf("%s bets %d on %s.", bet.Name, bet.Amount, bet.Spot)))
- }
- return strings.Join(lines, "\n")
-}
-
-func cardLine(mode, text string) string {
- spec := color.NoColor()
- spec.Fg = 39
- return color.Render(mode, spec, text)
-}
-
-func cardsLine(mode string, cards []casino.CardView, size int, unicode bool, style string, art *cardart.Store) []string {
- if len(cards) == 0 {
- return nil
- }
- if size == 1 {
- parts := make([]string, len(cards))
- for i, card := range cards {
- if !unicode {
- if card.Hidden {
- parts[i] = "[??]"
- } else {
- suit := map[string]string{"hearts": "H", "diamonds": "D", "spades": "S", "clubs": "C"}[card.Suit]
- parts[i] = card.Rank + suit
- }
- } else {
- parts[i] = colorizeCardSymbol(mode, card)
- }
- }
- return []string{strings.Join(parts, " ")}
- }
- artLines := make([][]string, len(cards))
- for i, card := range cards {
- artLines[i] = cardArtLines(card, size, unicode, style, art)
- }
- lines := make([]string, size)
- for row := 0; row < size; row++ {
- var lineParts []string
- for i, card := range artLines {
- lineParts = append(lineParts, colorizeCardLine(mode, card[row], cards[i], row, size, style))
- }
- lines[row] = strings.Join(lineParts, " ")
- }
- return lines
-}
-
-// renderCardRow lays cards side by side, each column padded to its own art
-// or label width, and appends an optional centered label row. minWidth lets
-// the caller force a floor (video poker's HELD labels need it) while Pai Gow
-// passes 0. It is the shared "cards with numbers underneath" presentation.
-func renderCardRow(cards []casino.CardView, labels []string, minWidth int, mode string, unicode bool, size int, style string, art *cardart.Store) []string {
- if len(cards) == 0 {
- return nil
- }
- artLines := make([][]string, len(cards))
- widths := make([]int, len(cards))
- height := 0
- for i, card := range cards {
- artLines[i] = cardsLine(mode, []casino.CardView{card}, size, unicode, style, art)
- height = max(height, len(artLines[i]))
- widths[i] = minWidth
- for _, line := range artLines[i] {
- widths[i] = max(widths[i], color.VisibleLen(line))
- }
- if i < len(labels) {
- widths[i] = max(widths[i], color.VisibleLen(labels[i]))
- }
- }
- lines := make([]string, 0, height+1)
- for row := 0; row < height; row++ {
- parts := make([]string, len(cards))
- for i := range cards {
- text := ""
- if row < len(artLines[i]) {
- text = artLines[i][row]
- }
- parts[i] = rouletteCenter(text, widths[i])
- }
- lines = append(lines, strings.Join(parts, " "))
- }
- if len(labels) > 0 {
- parts := make([]string, len(cards))
- for i := range cards {
- text := ""
- if i < len(labels) {
- text = labels[i]
- }
- parts[i] = rouletteCenter(text, widths[i])
- }
- lines = append(lines, strings.Join(parts, " "))
- }
- return lines
-}
-
-func cardRenderSize(unicode bool, size int) int {
- if size == 1 {
- return 1
- }
- if !unicode {
- return 5
- }
- if size == 1 || size == 5 || size == 7 || size == 9 {
- return size
- }
- return 7
-}
-
-var suitSymbols = map[string]string{"hearts": "♥", "spades": "♠", "diamonds": "♦", "clubs": "♣", "joker": "★"}
-
-func cardSymbol(card casino.CardView) string {
- if card.Hidden {
- return "?"
- }
- return card.Rank + suitSymbols[card.Suit]
-}
-
-func cardArtLines(card casino.CardView, size int, unicode bool, style string, art *cardart.Store) []string {
- if card.Hidden {
- return generatedCardArt("?", "", size, unicode, style)
- }
- key := card.Rank
- if card.Rank == "JK" {
- return jokerCardArt(size, unicode, style, art)
- }
- if card.Suit != "" {
- key += strings.ToUpper(card.Suit[:1])
- }
- if configured := art.Art(key, size); len(configured) == size {
- if unicode && style == "light" {
- // The shared pip art draws a dark box frame, which leaves the top
- // and bottom rows looking like the dark style. Replace only those
- // two rows with the half-block edges the white body meets.
- configured = append([]string(nil), configured...)
- configured[0] = strings.Repeat("▄", utf8.RuneCountInString(configured[0]))
- configured[size-1] = strings.Repeat("▀", utf8.RuneCountInString(configured[size-1]))
- }
- return configured
- }
- return generatedCardArt(card.Rank, card.Suit, size, unicode, style)
-}
-
-func generatedCardArt(rank, suit string, size int, unicode bool, style string) []string {
- if size < 5 {
- size = 5
- }
- border := "-"
- left, right := "+", "+"
- if unicode {
- border, left, right = "─", "┌", "┐"
- }
- bottomLeft, bottomRight := left, right
- if unicode {
- bottomLeft, bottomRight = "└", "┘"
- }
- vertical := "|"
- if unicode {
- vertical = "│"
- }
- if unicode && style == "light" {
- lines := []string{strings.Repeat("▄", size)}
- lines = append(lines, generatedCardBody(rank, suit, size, unicode, vertical)...)
- return append(lines, strings.Repeat("▀", size))
- }
- lines := []string{left + strings.Repeat(border, size-2) + right}
- lines = append(lines, generatedCardBody(rank, suit, size, unicode, vertical)...)
- lines = append(lines, bottomLeft+strings.Repeat(border, size-2)+bottomRight)
- return lines
-}
-
-func generatedCardBody(rank, suit string, size int, unicode bool, vertical string) []string {
- inner := size - 2
- lines := make([]string, 0, size-2)
- for row := 1; row < size-1; row++ {
- content := strings.Repeat(" ", inner)
- if row == 1 {
- content = rank + strings.Repeat(" ", inner-len(rank))
- }
- if row == (size-1)/2 {
- suitText := ""
- if suit != "" {
- suitText = suitLetter(suit, unicode)
- }
- suitWidth := utf8.RuneCountInString(suitText)
- leftPadding := (inner - suitWidth) / 2
- content = strings.Repeat(" ", leftPadding) + suitText + strings.Repeat(" ", inner-leftPadding-suitWidth)
- }
- if row == size-2 {
- content = strings.Repeat(" ", inner-len(rank)) + rank
- }
- lines = append(lines, vertical+content+vertical)
- }
- return lines
-}
-
-func suitLetter(suit string, unicode bool) string {
- if !unicode {
- return strings.ToUpper(suit[:1])
- }
- return suitSymbols[suit]
-}
-
-func colorizeCardLine(mode, line string, card casino.CardView, row, size int, style string) string {
- red := card.Suit == "hearts" || card.Suit == "diamonds"
- rankColor := 27
- if red {
- rankColor = 196
- }
- suitColor := 27
- if red {
- suitColor = 196
- }
- rankLine := row == 1 || row == size-2
- light := style == "light"
- bodyBackground := light && row > 0 && row < size-1
- var builder strings.Builder
- for _, r := range line {
- if strings.ContainsRune("┌─┐└┘│┬┴├┤┼╔╗╚╝═║╦╩╠╣╬▀▄+|-", r) {
- spec := color.ColorSpec{Fg: 15}
- if bodyBackground {
- spec.Bg = 15
- }
- builder.WriteString(color.Render(mode, spec, string(r)))
- } else if r == ' ' {
- if bodyBackground {
- builder.WriteString(color.Render(mode, color.ColorSpec{Fg: 15, Bg: 15}, " "))
- } else {
- builder.WriteRune(r)
- }
- } else {
- value := suitColor
- if rankLine {
- value = rankColor
- }
- spec := color.ColorSpec{Fg: value}
- if bodyBackground {
- spec.Bg = 15
- }
- builder.WriteString(color.Render(mode, spec, string(r)))
- }
- }
- return builder.String()
-}
-
-func colorizeCardSymbol(mode string, card casino.CardView) string {
- if card.Hidden {
- return color.Render(mode, color.ColorSpec{Fg: 15}, "?")
- }
- rankColor, suitColor := 27, 27
- if card.Suit == "hearts" || card.Suit == "diamonds" {
- rankColor, suitColor = 196, 196
- }
- runes := []rune(cardSymbol(card))
- rankLength := len([]rune(card.Rank))
- return color.Render(mode, color.ColorSpec{Fg: rankColor}, string(runes[:rankLength])) +
- color.Render(mode, color.ColorSpec{Fg: suitColor}, string(runes[rankLength:]))
-}
-
-func renderSlotSnapshot(snapshot *casino.SlotSnapshot, mode string, unicode bool) string {
- if snapshot == nil {
- return ""
- }
- left, junction, right, horizontal, vertical := '+', '+', '+', '-', '|'
- bottomLeft, bottomJunction, bottomRight := '+', '+', '+'
- if unicode {
- left, junction, right, horizontal, vertical = '\u250c', '\u252c', '\u2510', '\u2500', '\u2502'
- bottomLeft, bottomJunction, bottomRight = '\u2514', '\u2534', '\u2518'
- }
- separator := string(left)
- for reel := 0; reel < 5; reel++ {
- if reel > 0 {
- separator += string(junction)
- }
- separator += strings.Repeat(string(horizontal), 9)
- }
- separator += string(right)
- lines := []string{casinoFrame(mode, separator)}
- frameVertical := casinoFrame(mode, string(vertical))
- for row := 0; row < 3; row++ {
- line := frameVertical
- for reel := 0; reel < 5; reel++ {
- value := "..."
- if reel < snapshot.StoppedReels {
- value = snapshot.Grid[row][reel]
- }
- // Pad to the fixed 7-cell reel width; a malformed profile could
- // otherwise make this negative and panic. Validation caps the id
- // length, so this is a defence-in-depth backstop.
- pad := 7 - len(value)
- if pad < 0 {
- pad = 0
- }
- line += " " + colorSlotSymbol(mode, value) + strings.Repeat(" ", pad) + " " + frameVertical
- }
- lines = append(lines, line)
- }
- bottom := string(bottomLeft)
- for reel := 0; reel < 5; reel++ {
- if reel > 0 {
- bottom += string(bottomJunction)
- }
- bottom += strings.Repeat(string(horizontal), 9)
- }
- bottom += string(bottomRight)
- lines = append(lines, casinoFrame(mode, bottom))
- return strings.Join(lines, "\n")
-}
-
-func casinoFrame(mode, text string) string {
- spec := color.NoColor()
- spec.Fg = 244
- return color.Render(mode, spec, text)
-}
-
-func colorSlotSymbol(mode, symbol string) string {
- spec := color.NoColor()
- spec.Fg = 250
- switch symbol {
- case "straw":
- spec.Fg = 220
- case "stick":
- spec.Fg = 46
- case "brick":
- spec.Fg = 196
- case "hat":
- spec.Fg = 201
- case "wolf":
- spec.Fg = 39
- case "scatter":
- spec.Fg = 226
- default:
- spec.Dim = true
- }
- return color.Render(mode, spec, symbol)
-}
diff --git a/internal/game/casino_commands.go b/internal/game/casino_commands.go
index b6deb21..95259c0 100644
--- a/internal/game/casino_commands.go
+++ b/internal/game/casino_commands.go
@@ -88,6 +88,9 @@ func (g *Game) casinoCommandColumns(sess *net.Session, commands []casino.MenuCom
// Input is uncolored command/description text; paint only after measuring.
func casinoWrapWords(text string, width int) []string {
+ if width < 1 {
+ width = 1
+ }
var lines []string
line := ""
for _, word := range strings.Fields(text) {
diff --git a/internal/game/casino_house_edge.go b/internal/game/casino_house_edge.go
index ff3ce59..2bd1f4d 100644
--- a/internal/game/casino_house_edge.go
+++ b/internal/game/casino_house_edge.go
@@ -157,7 +157,7 @@ func (g *Game) showCasinoListTable(sess *net.Session, betHeader string, rows []c
width = min(width, wrap)
}
}
- budget := max(len(headers), width-3*len(headers)-1)
+ budget := ui.TableBudget(len(headers), width)
// Size the pre-wrapped cells with the same shrink algorithm Table.Render
// uses, instead of a second copy that can disagree on which column yields.
ui.FitWidths(widths, budget)
diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go
index e2b74db..88562cd 100644
--- a/internal/game/casino_ux.go
+++ b/internal/game/casino_ux.go
@@ -115,8 +115,8 @@ func (g *Game) showCasinoChoices(sess *net.Session, choices []casinoChoice) {
// renderCasinoFelt draws a table's felt (layout, payouts, board) without the
// prose rule text. The table command shows this; rules opens the help page.
-// Craps and roulette already render their felt through the live status
-// snapshot, so they return nothing here.
+// Games that draw their felt through Board() (craps, roulette, Big Six,
+// Plinko, Cash Hunt, video poker) return nothing here.
func (g *Game) renderCasinoFelt(sess *net.Session, cfg casino.TableConfig) string {
switch cfg.Game {
case casino.GameBlackjack:
@@ -143,13 +143,22 @@ func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) {
topic = "slots"
}
g.doHelp(sess, topic)
- } else {
- g.showCasinoChoices(sess, g.casinoChoices(sess.Player.RoomID))
+ return
+ }
+ // A machine player has no table (Participation returns nil for
+ // machines), but bare table/status must still describe the machine they
+ // occupy instead of the room's "choose a game" list.
+ if playing {
+ if machine, ok := g.Casino.MachineFor(sess.Player.Name); ok {
+ sess.WriteLine(g.renderCasinoCommands(sess, g.casinoBalance(sess), machine.Config.ID, machine.Hints()))
+ return
+ }
}
+ g.showCasinoChoices(sess, g.casinoChoices(sess.Player.RoomID))
return
}
- // table help remains a shortcut to the seated game's help page; the old
- // "table rules" subcommand is gone, so bare table always shows the felt.
+ // Only "help" is special-cased here; any other argument is ignored and the
+ // table/status command falls through to the felt below.
if len(args) > 0 && args[0] == "help" {
g.doHelp(sess, t.HelpTopic())
if t.Config.Game != casino.GameVideoPoker {
diff --git a/internal/game/casino_wallet.go b/internal/game/casino_wallet.go
new file mode 100644
index 0000000..2ec0dea
--- /dev/null
+++ b/internal/game/casino_wallet.go
@@ -0,0 +1,252 @@
+package game
+
+import (
+ "errors"
+ "fmt"
+ "os"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/persistence"
+ "thehouseoficarus/internal/player"
+)
+
+type casinoWallet struct {
+ g *Game
+ p *player.Player
+ // Optional per-wallet persistence seam for fault-injection tests.
+ saveCharacter func(*player.Player) error
+}
+
+// save treats a completed replacement as applied, even if its durability is
+// uncertain. Reverting memory then would disagree with the visible file and
+// could overwrite a paid win or resurrect a resolved stake on the next save.
+// Report the storage fault, but do not tell the game to retry/refund an
+// already-applied mutation. Only pre-replacement failures require rollback.
+func (w casinoWallet) save(operation string) error {
+ save := w.saveCharacter
+ if save == nil {
+ save = w.g.AccountStore.SaveCharacter
+ }
+ err := save(w.p)
+ if errors.Is(err, persistence.ErrDurabilityUncertain) {
+ fmt.Fprintf(os.Stderr, "casino %s: %s save durability uncertain: %v — keeping applied state\n", operation, w.p.Name, err)
+ return nil
+ }
+ return err
+}
+
+// Wager debits the stake and records it in PendingWagers in the SAME atomic
+// character save. The entry stays until the round resolves: a settlement
+// removes the stakes it returns (PayCredits) or consumes them
+// (ResolveStake/ResolveStakes), and startup refunds anything still listed
+// (see recoverPendingWagers). A pre-replacement save failure reverts the
+// debit and ledger entry; an uncertain replacement keeps both (see save).
+func (w casinoWallet) Wager(amount int) (casino.Wager, error) {
+ if amount <= 0 {
+ return casino.Wager{}, casino.ErrInsufficientFunds
+ }
+ creditsBefore := w.p.Credits
+ chips := countChips(w.p)
+ if chips > amount {
+ chips = amount
+ }
+ credits := amount - chips
+ if creditsBefore < credits {
+ return casino.Wager{}, casino.ErrInsufficientFunds
+ }
+ if chips > 0 {
+ w.p.RemoveItem("chips", chips)
+ }
+ w.p.Credits -= credits
+ pending := player.PendingWager{ID: newPendingWagerID(), Chips: chips, Credits: credits}
+ w.p.PendingWagers = append(w.p.PendingWagers, pending)
+ if err := w.save("wager"); err != nil {
+ // Revert the in-memory mutation: the play must not proceed on a
+ // stake that could not be made durable.
+ if chips > 0 {
+ if !w.g.giveChips(w.p, chips) {
+ // Cannot happen (the debit just freed or reduced the chips
+ // stack), but surface it instead of silently losing chips.
+ fmt.Fprintf(os.Stderr, "casino wager revert: no inventory room for %d chips\n", chips)
+ }
+ }
+ w.p.Credits = creditsBefore
+ removePendingWager(w.p, pending.ID)
+ return casino.Wager{}, casino.ErrWalletPersistence
+ }
+ return casino.Wager{Total: amount, Chips: chips, Credits: credits, ID: pending.ID}, nil
+}
+
+// PayCredits banks a payout durably and, in the SAME save, resolves the
+// listed stakes. stakeIDs may be empty for a payout that returns no stake (a
+// free-spin bank already refunded by RefundOutstanding). A table settlement
+// passes the hand's stake IDs so the credit and the stake return are one
+// atomic step; a slot win passes its triggering stake for the same reason.
+// Either way a crash can never pay the payout AND refund the same stake. A
+// pre-replacement failure reverts both the credit and the ledger and reports
+// failure. An uncertain replacement is applied and logged (see save).
+func (w casinoWallet) PayCredits(amount int, stakeIDs ...string) error {
+ return w.payCredits(amount, stakeIDs)
+}
+
+// payCredits credits the payout and, when stakeIDs is non-empty, removes
+// those stakes from the pending ledger in the same atomic save. On a
+// pre-replacement failure the credit and the ledger are reverted together.
+func (w casinoWallet) payCredits(amount int, stakeIDs []string) error {
+ if amount <= 0 && len(stakeIDs) == 0 {
+ return nil
+ }
+ creditsBefore := w.p.Credits
+ sum, capErr := addCreditsChecked(creditsBefore, amount)
+ if capErr != nil {
+ return capErr
+ }
+ var removed []player.PendingWager
+ if len(stakeIDs) > 0 {
+ removed = takePendingWagers(w.p, stakeIDs)
+ // Fail closed like PayPoker: a payout resolves the stakes it names. If
+ // any stake is already gone (or duplicated away), crediting anyway
+ // would double-pay with no ledger entry for startup recovery to see.
+ if len(removed) != countUniqueNonEmpty(stakeIDs) {
+ w.p.PendingWagers = append(w.p.PendingWagers, removed...)
+ return casino.ErrWalletPersistence
+ }
+ }
+ w.p.Credits = sum
+ if err := w.save("payout"); err != nil {
+ w.p.Credits = creditsBefore
+ w.p.PendingWagers = append(w.p.PendingWagers, removed...)
+ return casino.ErrWalletPersistence
+ }
+ return nil
+}
+
+// ResolveStake consumes ONE pending stake by wallet handle: its hand lost or
+// was paid by a cover, so it must never be refunded. A settlement that
+// already removed it (PayCredits) makes this a no-op, so no redundant
+// save is paid.
+func (w casinoWallet) ResolveStake(stakeID string) {
+ if stakeID == "" {
+ return
+ }
+ removed := takePendingWagers(w.p, []string{stakeID})
+ if len(removed) == 0 {
+ return
+ }
+ if err := w.save("resolve stake"); err != nil {
+ // The durable file still lists the stake, so restore it in memory to
+ // keep the two records consistent for a later retry/recovery.
+ w.p.PendingWagers = append(w.p.PendingWagers, removed...)
+ fmt.Fprintf(os.Stderr, "casino resolve stake: save for %s: %v (ledger restored in memory)\n", w.p.Name, err)
+ }
+}
+
+// ResolveStakesByIDs consumes the listed pending stakes in one durable save.
+// A losing split/doubled hand used to call ResolveStake once per handle, each
+// with its own full character save; batching removes that repeated fsync and
+// the crash window between saves.
+func (w casinoWallet) ResolveStakesByIDs(stakeIDs ...string) {
+ if len(stakeIDs) == 0 {
+ return
+ }
+ removed := takePendingWagers(w.p, stakeIDs)
+ if len(removed) == 0 {
+ return
+ }
+ if err := w.save("resolve stakes"); err != nil {
+ // The durable file still lists the stakes, so restore them in memory to
+ // keep the two records consistent for a later retry/recovery.
+ w.p.PendingWagers = append(w.p.PendingWagers, removed...)
+ fmt.Fprintf(os.Stderr, "casino resolve stakes: save for %s: %v (ledger restored in memory)\n", w.p.Name, err)
+ }
+}
+
+// RefundOutstanding returns every pending stake to the player — chips in
+// kind, credits as credits — and clears the ledger in one save. It is used
+// by graceful shutdown/eviction and by a pre-deal disconnect. A pre-replacement
+// failure reverts the return and ledger for retry; an uncertain replacement
+// keeps both applied (see save).
+func (w casinoWallet) RefundOutstanding() (chips, credits int, err error) {
+ before := append([]player.PendingWager(nil), w.p.PendingWagers...)
+ for _, pending := range before {
+ chips += pending.Chips
+ credits += pending.Credits
+ }
+ if chips == 0 && credits == 0 {
+ return 0, 0, nil
+ }
+ creditsBefore := w.p.Credits
+ w.p.PendingWagers = nil
+ if chips > 0 {
+ if !w.g.giveChips(w.p, chips) {
+ w.p.PendingWagers = before
+ return 0, 0, casino.ErrWalletPersistence
+ }
+ }
+ sum, capErr := addCreditsChecked(creditsBefore, credits)
+ if capErr != nil {
+ if chips > 0 {
+ w.p.RemoveItem("chips", chips)
+ }
+ w.p.PendingWagers = before
+ return 0, 0, casino.ErrWalletPersistence
+ }
+ w.p.Credits = sum
+ if err := w.save("refund"); err != nil {
+ w.p.Credits = creditsBefore
+ if chips > 0 {
+ w.p.RemoveItem("chips", chips)
+ }
+ w.p.PendingWagers = before
+ return 0, 0, casino.ErrWalletPersistence
+ }
+ return chips, credits, nil
+}
+
+// RefundStakes returns the named pending stakes in kind — chips as chips,
+// credits as credits — and clears them from the ledger in one save. It is the
+// game-side take-back path: using PayCredits here would convert casino-only
+// chips into spendable credits.
+func (w casinoWallet) RefundStakes(stakeIDs ...string) error {
+ if len(stakeIDs) == 0 {
+ return nil
+ }
+ removed := takePendingWagers(w.p, stakeIDs)
+ if len(removed) == 0 {
+ return nil
+ }
+ var chips, credits int
+ for _, pending := range removed {
+ chips += pending.Chips
+ credits += pending.Credits
+ }
+ creditsBefore := w.p.Credits
+ if chips > 0 {
+ if !w.g.giveChips(w.p, chips) {
+ w.p.PendingWagers = append(w.p.PendingWagers, removed...)
+ return casino.ErrWalletPersistence
+ }
+ }
+ sum, capErr := addCreditsChecked(creditsBefore, credits)
+ if capErr != nil {
+ if chips > 0 {
+ w.p.RemoveItem("chips", chips)
+ }
+ w.p.PendingWagers = append(w.p.PendingWagers, removed...)
+ return casino.ErrWalletPersistence
+ }
+ w.p.Credits = sum
+ if err := w.save("refund stakes"); err != nil {
+ w.p.Credits = creditsBefore
+ if chips > 0 {
+ w.p.RemoveItem("chips", chips)
+ }
+ w.p.PendingWagers = append(w.p.PendingWagers, removed...)
+ return casino.ErrWalletPersistence
+ }
+ return nil
+}
+
+// casinoAffordable is the most a player can wager: chips are spent first,
+// then credits.
+func casinoAffordable(p *player.Player) int { return p.Credits + countChips(p) }
diff --git a/internal/game/cmd_alias.go b/internal/game/cmd_alias.go
index 33d8de7..9e30db6 100644
--- a/internal/game/cmd_alias.go
+++ b/internal/game/cmd_alias.go
@@ -9,7 +9,7 @@ import (
)
func (g *Game) executeAlias(sess *net.Session, args []string, rawInput string) {
- g.doAlias(sess, args)
+ g.doAlias(sess, rawArgsFrom(rawInput, "alias"))
}
func (g *Game) executeUnalias(sess *net.Session, args []string, rawInput string) {
diff --git a/internal/game/cmd_colortable.go b/internal/game/cmd_colortable.go
index d228a34..3a5d353 100644
--- a/internal/game/cmd_colortable.go
+++ b/internal/game/cmd_colortable.go
@@ -10,7 +10,7 @@ import (
func (g *Game) doColortable(sess *net.Session) {
mode := g.colorMode(sess)
- if mode == "none" || mode == "" {
+ if color.Plain(mode) {
sess.WriteLine("Enable colors first: option color ansi (or xterm256)")
return
}
diff --git a/internal/game/cmd_dig.go b/internal/game/cmd_dig.go
index ff33490..9ca5f10 100644
--- a/internal/game/cmd_dig.go
+++ b/internal/game/cmd_dig.go
@@ -86,20 +86,7 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) {
sess.WriteLine(fmt.Sprintf("That spot is already occupied by %s. Created a two-way link instead.", conflictName))
oldRoom := p.RoomID
- p.ClearMoveState()
- if g.Combat.Get(p.Name) != nil {
- g.stopCombat(p.Name)
- }
- p.Action = nil
- g.cancelRest(p.Name)
- g.cancelSequences(p.Name)
- if ss, ok := g.safespot.Get(p.Name); ok {
- g.forceLeaveSafespot(sess, p, &ss, "")
- }
-
- p.RoomID = conflictID
- p.HazardTimer = 0
- p.Stats.RecordRoomVisit(conflictID)
+ g.relocatePlayer(sess, p, conflictID)
g.saveCharacter(p)
g.World.SeedGroundItems(conflictID)
@@ -182,20 +169,7 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) {
}
oldRoom := p.RoomID
- p.ClearMoveState()
- if g.Combat.Get(p.Name) != nil {
- g.stopCombat(p.Name)
- }
- p.Action = nil
- g.cancelRest(p.Name)
- g.cancelSequences(p.Name)
- if ss, ok := g.safespot.Get(p.Name); ok {
- g.forceLeaveSafespot(sess, p, &ss, "")
- }
-
- p.RoomID = newID
- p.HazardTimer = 0
- p.Stats.RecordRoomVisit(newID)
+ g.relocatePlayer(sess, p, newID)
g.saveCharacter(p)
g.World.SeedGroundItems(newID)
diff --git a/internal/game/cmd_estate_directory.go b/internal/game/cmd_estate_directory.go
index 83c5b80..f29f671 100644
--- a/internal/game/cmd_estate_directory.go
+++ b/internal/game/cmd_estate_directory.go
@@ -205,26 +205,22 @@ func (g *Game) teleportPlayer(sess *net.Session, p *player.Player, roomID int) {
}
}
- // A teleport changes rooms just like a walk, so it must clear any safespot
- // cover: isSafespotted/safespotBlocksMelee/safespotBlocksHazard compare no
- // room, so leaving the state active grants melee and hazard immunity in the
- // destination and keeps the player as a phantom occupant of the old cover.
- if ss, ok := g.safespot.Get(p.Name); ok {
- g.forceLeaveSafespot(sess, p, &ss, "")
- }
+ // A teleport changes rooms just like a walk: clear the safespot cover and
+ // every in-flight move/action/combat/sequence so no stale state follows the
+ // player into the destination or drags them back later (see relocatePlayer).
+ g.relocatePlayer(sess, p, roomID)
+ g.saveCharacter(p)
- p.RoomID = roomID
- p.Stats.RecordRoomVisit(roomID)
+ g.World.SeedGroundItems(p.RoomID)
+ g.seedRoomMobs(p.RoomID)
+ g.seedRoomObjects(p.RoomID)
if g.Hub != nil {
g.Hub.LeaveRoom(sess)
g.Hub.EnterRoom(sess, p.RoomID)
}
- g.World.SeedGroundItems(p.RoomID)
- g.seedRoomMobs(p.RoomID)
- g.seedRoomObjects(p.RoomID)
- g.saveCharacter(p)
g.doLook(sess)
g.runEnterSteps(sess, p.RoomID)
+ g.checkAggro(sess)
g.writePrompt(sess)
}
diff --git a/internal/game/cmd_global.go b/internal/game/cmd_global.go
index 32577c3..ec44b99 100644
--- a/internal/game/cmd_global.go
+++ b/internal/game/cmd_global.go
@@ -13,12 +13,7 @@ func (g *Game) executeGlobal(sess *net.Session, args []string, rawInput string)
return
}
- msgStart := strings.Index(strings.ToLower(rawInput), "global ") + 7
- if msgStart >= 7 && msgStart < len(rawInput) {
- g.doGlobal(sess, rawInput[msgStart:])
- } else {
- g.doGlobal(sess, strings.Join(args, " "))
- }
+ g.doGlobal(sess, strings.Join(rawArgsFrom(rawInput, "global"), " "))
}
func (g *Game) doGlobal(sess *net.Session, msg string) {
diff --git a/internal/game/cmd_goto.go b/internal/game/cmd_goto.go
index 96984b9..b3b7558 100644
--- a/internal/game/cmd_goto.go
+++ b/internal/game/cmd_goto.go
@@ -31,20 +31,7 @@ func (g *Game) executeGoto(sess *net.Session, args []string, rawInput string) {
}
oldRoom := p.RoomID
- p.ClearMoveState()
- if g.Combat.Get(p.Name) != nil {
- g.stopCombat(p.Name)
- }
- p.Action = nil
- g.cancelRest(p.Name)
- g.cancelSequences(p.Name)
- if ss, ok := g.safespot.Get(p.Name); ok {
- g.forceLeaveSafespot(sess, p, &ss, "")
- }
-
- p.RoomID = roomID
- p.HazardTimer = 0
- p.Stats.RecordRoomVisit(roomID)
+ g.relocatePlayer(sess, p, roomID)
g.saveCharacter(p)
g.World.SeedGroundItems(roomID)
diff --git a/internal/game/cmd_hide.go b/internal/game/cmd_hide.go
index b5d38fe..59ddf9e 100644
--- a/internal/game/cmd_hide.go
+++ b/internal/game/cmd_hide.go
@@ -2,6 +2,7 @@ package game
import (
"fmt"
+ "strconv"
"strings"
"thehouseoficarus/internal/net"
@@ -26,13 +27,29 @@ func (g *Game) executeHide(sess *net.Session, args []string, rawInput string) {
target = strings.Join(args[1:], " ")
}
- instances := g.World.FindObjInstances(p.RoomID, target)
+ // The disambiguation prompt prints "N.name", so accept the same "N."
+ // selector every sibling verb supports (act.go, look_target.go).
+ instanceIdx := -1
+ searchName := strings.ToLower(target)
+ if dotPos := strings.Index(searchName, "."); dotPos > 0 {
+ if n, err := strconv.Atoi(searchName[:dotPos]); err == nil && n > 0 {
+ instanceIdx = n - 1
+ searchName = searchName[dotPos+1:]
+ }
+ }
+
+ instances := g.World.FindObjInstances(p.RoomID, searchName)
if len(instances) == 0 {
sess.WriteLine("You don't see that here.")
return
}
- if len(instances) > 1 && objectInstancesNeedDisambiguation(instances) {
+ if instanceIdx >= 0 && instanceIdx >= len(instances) {
+ sess.WriteLine("You don't see that here.")
+ return
+ }
+
+ if instanceIdx < 0 && len(instances) > 1 && objectInstancesNeedDisambiguation(instances) {
sess.WriteLines(
"That's ambiguous, which one?",
formatObjInstanceChoices(instances),
@@ -40,14 +57,19 @@ func (g *Game) executeHide(sess *net.Session, args []string, rawInput string) {
return
}
- objDef, err := g.ObjectStore.Load(instances[0].DefID)
+ chosen := instances[0]
+ if instanceIdx >= 0 {
+ chosen = instances[instanceIdx]
+ }
+
+ objDef, err := g.ObjectStore.Load(chosen.DefID)
if err != nil || !objDef.IsSafespot() {
sess.WriteLine("You can't hide behind that.")
return
}
cfg := objDef.Safespot
- st := g.World.GetObjState(p.RoomID, objDef.ID, instances[0].Index)
+ st := g.World.GetObjState(p.RoomID, objDef.ID, chosen.Index)
if st == nil {
sess.WriteLine("You can't hide behind that.")
return
@@ -56,7 +78,7 @@ func (g *Game) executeHide(sess *net.Session, args []string, rawInput string) {
existingSS, alreadyHiding := g.safespot.Get(p.Name)
if alreadyHiding && existingSS.Active {
- if existingSS.ObjectDefID == objDef.ID && existingSS.ObjectIndex == instances[0].Index {
+ if existingSS.ObjectDefID == objDef.ID && existingSS.ObjectIndex == chosen.Index {
if existingSS.HideCountdown > 0 {
sess.WriteLine("You're already moving into position there.")
} else {
diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go
index 92aeea6..e6ed5d3 100644
--- a/internal/game/cmd_look.go
+++ b/internal/game/cmd_look.go
@@ -26,6 +26,12 @@ func (g *Game) doLook(sess *net.Session) {
if wrapWidth < 80 {
wrapWidth = 80
}
+ if wrapWidth > maxMapDimension {
+ // Do not trust the persisted option: writeLookSideBySide pads every
+ // content line to the wrap width, so an unbounded value is a
+ // one-command OOM (the same clamp map_width already has).
+ wrapWidth = maxMapDimension
+ }
var mapLines []string
mapOn := p.OptionString("tiny_map") != "off"
diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go
index 35cccba..96c2d2d 100644
--- a/internal/game/cmd_move.go
+++ b/internal/game/cmd_move.go
@@ -233,6 +233,28 @@ func (g *Game) completeMove(sess *net.Session, p *player.Player) {
g.checkAggro(sess)
}
+// relocatePlayer tears down every in-flight movement, combat, action, rest and
+// sequence state before moving p to roomID. Admin/trigger teleports bypass the
+// normal move queue, so they must run the same teardown completeMove/goto
+// perform or a stashed move/escape can later drag the player back and combat
+// can follow them across rooms.
+func (g *Game) relocatePlayer(sess *net.Session, p *player.Player, roomID int) {
+ p.ClearMoveState()
+ if g.Combat.Get(p.Name) != nil {
+ g.stopCombat(p.Name)
+ }
+ g.cancelAction(p)
+ g.cancelBgAction(p)
+ g.cancelRest(p.Name)
+ g.cancelSequences(p.Name)
+ if ss, ok := g.safespot.Get(p.Name); ok {
+ g.forceLeaveSafespot(sess, p, &ss, "")
+ }
+ p.RoomID = roomID
+ p.HazardTimer = 0
+ p.Stats.RecordRoomVisit(roomID)
+}
+
func (g *Game) seedRoomMobs(roomID int) {
room, err := g.World.LoadRoom(roomID)
if err != nil || len(room.Mobs) == 0 {
diff --git a/internal/game/cmd_option.go b/internal/game/cmd_option.go
index 1bfd1b5..41346fb 100644
--- a/internal/game/cmd_option.go
+++ b/internal/game/cmd_option.go
@@ -62,6 +62,11 @@ func (g *Game) doOption(sess *net.Session, input string) {
}
value := strings.ToLower(parts[1])
+ if def.Type == player.OptString && len(def.ValidValues) == 0 {
+ // Free-text string options take the whole remaining input verbatim
+ // ("option visual_tick_text Tick Tock" stores "Tick Tock").
+ value = strings.Join(parts[1:], " ")
+ }
if def.Name == "card_size" {
switch value {
case "1", "5", "7", "9":
@@ -112,7 +117,7 @@ func (g *Game) doOption(sess *net.Session, input string) {
}
func (g *Game) executeOption(sess *net.Session, args []string, rawInput string) {
- g.doOption(sess, strings.Join(args, " "))
+ g.doOption(sess, strings.Join(rawArgsFrom(rawInput, "option"), " "))
}
func formatOptionValue(p *player.Player, def *player.OptionDef) string {
diff --git a/internal/game/cmd_prompt.go b/internal/game/cmd_prompt.go
index b613f44..de7b063 100644
--- a/internal/game/cmd_prompt.go
+++ b/internal/game/cmd_prompt.go
@@ -11,12 +11,7 @@ func (g *Game) executePrompt(sess *net.Session, args []string, rawInput string)
if len(args) == 0 {
g.doHelp(sess, "prompt")
} else {
- msgStart := strings.Index(strings.ToLower(rawInput), "prompt ") + 7
- if msgStart >= 7 && msgStart < len(rawInput) {
- g.doPrompt(sess, rawInput[msgStart:])
- } else {
- g.doPrompt(sess, strings.Join(args, " "))
- }
+ g.doPrompt(sess, strings.Join(rawArgsFrom(rawInput, "prompt"), " "))
}
}
diff --git a/internal/game/cmd_remove.go b/internal/game/cmd_remove.go
index d03daec..8ea21a9 100644
--- a/internal/game/cmd_remove.go
+++ b/internal/game/cmd_remove.go
@@ -150,7 +150,21 @@ func (g *Game) doRemoveAll(sess *net.Session) {
return
}
- needed := len(p.Equipment)
+ // Ammo routes through removeAllAmmo, which merges into an existing
+ // matching-quality stack and therefore needs no fresh slot. Count only the
+ // entries that will actually consume a slot so a mergeable ammo stack does
+ // not make the pre-gate report a false "no room".
+ needed := 0
+ for _, slot := range EquipSlots {
+ itemID, ok := p.Equipment[slot]
+ if !ok {
+ continue
+ }
+ if slot == item.SlotAmmo && p.AmmoQty > 0 && g.ammoHasMergeableStack(p, itemID) {
+ continue
+ }
+ needed++
+ }
if needed > p.FreeSlots() {
sess.WriteLine("You don't have room to remove everything!")
return
@@ -232,3 +246,17 @@ func (g *Game) removeAllAmmo(sess *net.Session, p *player.Player, itemID string)
}
sess.WriteLine(fmt.Sprintf("You remove %s.", g.itemColorize(sess, def, name)))
}
+
+// ammoHasMergeableStack reports whether removeAllAmmo will merge the equipped
+// ammo stack into an existing inventory stack with the same freshly-picked
+// properties (see pickedStackProps), which consumes no free slot.
+func (g *Game) ammoHasMergeableStack(p *player.Player, itemID string) bool {
+ quality, maxQuality := g.pickedStackProps(itemID)
+ for i := 0; i < player.MaxInventorySlots; i++ {
+ s := p.InvSlot(i)
+ if s != nil && s.ItemID == itemID && s.Quality == quality && s.MaxQuality == maxQuality {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/game/cmd_room.go b/internal/game/cmd_room.go
index 33627eb..8a76e81 100644
--- a/internal/game/cmd_room.go
+++ b/internal/game/cmd_room.go
@@ -37,10 +37,10 @@ func (g *Game) executeRoom(sess *net.Session, args []string, rawInput string) {
case "addobj", "remobj", "hide", "unhide":
g.roomObject(sess, action, rest)
case "addlocalobj":
- name, aliases, desc := parseLocalObjArgs(rawInput, "addlocalobj")
+ name, aliases, desc := parseLocalObjArgs(strings.Join(rawArgsFrom(rawInput, "room")[1:], " "))
g.roomAddLocalObj(sess, name, aliases, desc)
case "remlocalobj":
- name, _, _ := parseLocalObjArgs(rawInput, "remlocalobj")
+ name, _, _ := parseLocalObjArgs(strings.Join(rawArgsFrom(rawInput, "room")[1:], " "))
g.roomRemLocalObj(sess, name)
case "addmob", "remmob":
g.roomMob(sess, action, rest)
@@ -162,12 +162,8 @@ func (g *Game) loadRoomWrite(p *player.Player) (*world.Room, string, error) {
return room, path, nil
}
-func parseLocalObjArgs(rawInput, action string) (name string, aliases []string, desc string) {
- prefix := "room " + action
- rest := rawInput
- if idx := strings.Index(strings.ToLower(rest), prefix); idx >= 0 {
- rest = strings.TrimSpace(rest[idx+len(prefix):])
- }
+func parseLocalObjArgs(rest string) (name string, aliases []string, desc string) {
+ rest = strings.TrimSpace(rest)
if rest == "" {
return "", nil, ""
}
diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go
index 8087cca..713e6b3 100644
--- a/internal/game/cmd_room_insert.go
+++ b/internal/game/cmd_room_insert.go
@@ -172,20 +172,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) {
g.World.AddRoomPath(newID, newPath)
oldRoom := p.RoomID
- p.ClearMoveState()
- if g.Combat.Get(p.Name) != nil {
- g.stopCombat(p.Name)
- }
- p.Action = nil
- g.cancelRest(p.Name)
- g.cancelSequences(p.Name)
- if ss, ok := g.safespot.Get(p.Name); ok {
- g.forceLeaveSafespot(sess, p, &ss, "")
- }
-
- p.RoomID = newID
- p.HazardTimer = 0
- p.Stats.RecordRoomVisit(newID)
+ g.relocatePlayer(sess, p, newID)
g.saveCharacter(p)
g.World.SeedGroundItems(newID)
diff --git a/internal/game/cmd_say.go b/internal/game/cmd_say.go
index b7d3982..61ece74 100644
--- a/internal/game/cmd_say.go
+++ b/internal/game/cmd_say.go
@@ -11,12 +11,7 @@ func (g *Game) executeSay(sess *net.Session, args []string, rawInput string) {
if len(args) == 0 {
sess.WriteLine("Say what?")
} else {
- msgStart := strings.Index(strings.ToLower(rawInput), "say ") + 4
- if msgStart >= 4 && msgStart < len(rawInput) {
- g.doSay(sess, rawInput[msgStart:])
- } else {
- g.doSay(sess, strings.Join(args, " "))
- }
+ g.doSay(sess, strings.Join(rawArgsFrom(rawInput, "say"), " "))
}
}
diff --git a/internal/game/cmd_summon.go b/internal/game/cmd_summon.go
index 6477b11..306d467 100644
--- a/internal/game/cmd_summon.go
+++ b/internal/game/cmd_summon.go
@@ -46,20 +46,7 @@ func (g *Game) executeSummon(sess *net.Session, args []string, rawInput string)
adminRoom := adminP.RoomID
oldRoom := tp.RoomID
- tp.ClearMoveState()
- if g.Combat.Get(tp.Name) != nil {
- g.stopCombat(tp.Name)
- }
- tp.Action = nil
- g.cancelRest(tp.Name)
- g.cancelSequences(tp.Name)
- if ss, ok := g.safespot.Get(tp.Name); ok {
- g.forceLeaveSafespot(targetSess, tp, &ss, "")
- }
-
- tp.RoomID = adminRoom
- tp.HazardTimer = 0
- tp.Stats.RecordRoomVisit(adminRoom)
+ g.relocatePlayer(targetSess, tp, adminRoom)
g.saveCharacter(tp)
g.World.SeedGroundItems(adminRoom)
diff --git a/internal/game/cmd_trigger_utility.go b/internal/game/cmd_trigger_utility.go
index fa81f23..6b502cf 100644
--- a/internal/game/cmd_trigger_utility.go
+++ b/internal/game/cmd_trigger_utility.go
@@ -2,6 +2,7 @@ package game
import (
"fmt"
+ "sort"
"strings"
"thehouseoficarus/internal/item"
@@ -118,8 +119,15 @@ func (g *Game) triggerEmGrab(sess *net.Session, p *player.Player, mod *ModDef, t
// Reservation-aware: only items this player may take are considered, so
// em grab cannot magnetize kill loot reserved for another player.
groundItems := g.World.AccessibleGround(p.RoomID, p.Name)
- var matchedID string
+ itemIDs := make([]string, 0, len(groundItems))
for itemID := range groundItems {
+ itemIDs = append(itemIDs, itemID)
+ }
+ // Map iteration is randomized; pick deterministically so `em grab <name>`
+ // always resolves the same matching stack.
+ sort.Strings(itemIDs)
+ var matchedID string
+ for _, itemID := range itemIDs {
def, _ := g.ItemStore.Load(itemID)
if def != nil && def.MatchesName(targetArg) {
matchedID = itemID
@@ -276,7 +284,12 @@ func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef
return
}
- consumeIngredients(c, p.CountItem, p.RemoveItem)
+ if !consumeIngredients(c, p.CountItem, p.RemoveItem) {
+ // planIngredients reserves stock across entries, so a weak pre-check
+ // (duplicate entries) can fail here. Abort without producing output.
+ sess.WriteLine("You don't have the required materials.")
+ return
+ }
outputQty := c.OutputQty
if outputQty <= 0 {
@@ -381,7 +394,8 @@ func (g *Game) triggerPlankMake(sess *net.Session, p *player.Player, mod *ModDef
return
}
- for logID, info := range logTypes {
+ for _, logID := range []string{"logs", "oak_logs", "teak_logs", "mahogany_logs"} {
+ info := logTypes[logID]
qty := p.CountItem(logID)
if qty == 0 {
continue
diff --git a/internal/game/combat_attack.go b/internal/game/combat_attack.go
index 180038b..3cbb9c8 100644
--- a/internal/game/combat_attack.go
+++ b/internal/game/combat_attack.go
@@ -116,6 +116,7 @@ func (g *Game) findMob(sess *net.Session, input string, roomID int) *world.MobIn
if idx-1 < len(candidates) {
return candidates[idx-1]
}
+ sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input))
return nil
}
@@ -134,6 +135,14 @@ func (g *Game) findMob(sess *net.Session, input string, roomID int) *world.MobIn
return candidates[0]
}
+// leaveCombat ends the player's combat and clears any stashed flee state.
+// Combat.Leave alone leaves EscapeDir/Target/IsWalk set with no move in flight,
+// so the next mob miss would start an unprompted escape in the stale direction.
+func (g *Game) leaveCombat(p *player.Player) {
+ g.Combat.Leave(p.Name)
+ p.ClearEscape()
+}
+
func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) {
// One player fights one mob at a time. doAttack and the aggro path already
// check this, but act_steal/act_talk call startCombat directly; without the
@@ -447,7 +456,7 @@ func (g *Game) applyPlayerHit(sess *net.Session, p *player.Player, mob *world.Mo
if isRanged && p.AmmoQty <= 0 {
sess.WriteLine("You've run out of ammo!")
- g.Combat.Leave(p.Name)
+ g.leaveCombat(p)
}
}
@@ -458,7 +467,7 @@ func (g *Game) applyPlayerMiss(sess *net.Session, p *player.Player, mob *world.M
g.consumeAmmo(p)
if p.AmmoQty <= 0 {
sess.WriteLine("You've run out of ammo!")
- g.Combat.Leave(p.Name)
+ g.leaveCombat(p)
}
}
}
diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go
index 0224a06..67e692d 100644
--- a/internal/game/core_login_account.go
+++ b/internal/game/core_login_account.go
@@ -49,6 +49,7 @@ func (g *Game) handleAccountName(sess *net.Session, input string) {
// after the account is gone.
func (g *Game) resetToAccountName(sess *net.Session) {
sess.Account = nil
+ sess.SetAuthName("")
sess.PendingChar = ""
sess.PendingMenu = nil
sess.PendingPass = ""
@@ -361,15 +362,21 @@ func (g *Game) handleColorChoice(sess *net.Session, input string) {
}
input = strings.ToLower(strings.TrimSpace(input))
if input == "y" || input == "yes" {
- sess.Account.Options["color"] = "none"
acc, err := g.AccountStore.LoadAccount(sess.Account.Name)
- if err == nil {
- if acc.Options == nil {
- acc.Options = make(map[string]any)
- }
- acc.Options["color"] = "none"
- g.saveAccount(acc)
+ if err != nil {
+ sess.WriteLine("Error loading account.")
+ g.resetToAccountName(sess)
+ return
+ }
+ if acc.Options == nil {
+ acc.Options = make(map[string]any)
}
+ acc.Options["color"] = "none"
+ g.saveAccount(acc)
+ if sess.Account.Options == nil {
+ sess.Account.Options = make(map[string]any)
+ }
+ sess.Account.Options["color"] = "none"
}
g.showMenu(sess)
}
@@ -479,6 +486,7 @@ func (g *Game) handleMenu(sess *net.Session, input string) {
sess.Write("New name: ")
return
}
+ sess.PendingChar = ""
sess.SetState(net.StateRenameChar)
sess.WriteLine("\nRename which character?")
for i, name := range sess.Account.Characters {
@@ -495,6 +503,7 @@ func (g *Game) handleMenu(sess *net.Session, input string) {
if len(sess.Account.Characters) == 1 {
sess.PendingChar = sess.Account.Characters[0]
} else {
+ sess.PendingChar = ""
sess.SetState(net.StateDeleteChar)
sess.WriteLine("\nDelete which character?")
for i, name := range sess.Account.Characters {
diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go
index 479e397..12a8cfb 100644
--- a/internal/game/core_login_char.go
+++ b/internal/game/core_login_char.go
@@ -177,13 +177,14 @@ func (g *Game) handleRenameChar(sess *net.Session, input string) {
found := false
for _, c := range sess.Account.Characters {
- if c == sess.PendingChar {
+ if equalsFold(c, sess.PendingChar) {
found = true
break
}
}
if !found {
sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar))
+ sess.PendingChar = ""
g.showMenu(sess)
return
}
@@ -300,7 +301,7 @@ func (g *Game) handleDeleteChar(sess *net.Session, input string) {
}
found := false
for _, c := range sess.Account.Characters {
- if c == sess.PendingChar {
+ if equalsFold(c, sess.PendingChar) {
found = true
break
}
diff --git a/internal/game/exit_display.go b/internal/game/exit_display.go
index 4540669..7e3f591 100644
--- a/internal/game/exit_display.go
+++ b/internal/game/exit_display.go
@@ -38,10 +38,12 @@ func (g *Game) exitDisplayState(sess *net.Session, roomID int, dir world.ExitDir
if p != nil && g.exitDiscovered(p, roomID, dir) {
ed.Visible = true
ed.TagHidden = true
+ } else {
+ return ed
}
- return ed
+ } else {
+ ed.Visible = true
}
- ed.Visible = true
if p == nil || p.GodMode {
return ed
}
diff --git a/internal/game/exit_migrate.go b/internal/game/exit_migrate.go
index 4edab95..4f5dd72 100644
--- a/internal/game/exit_migrate.go
+++ b/internal/game/exit_migrate.go
@@ -25,6 +25,15 @@ func rewritePlayerRoomIDs(p *player.Player, idMap map[int]int) bool {
if world.RemapInt(idMap, &p.RoomID) {
changed = true
}
+ // A multi-tick move or a stashed flee escape keeps its destination in
+ // MoveTarget/EscapeTarget; remap them too or completeMove will assign a
+ // pre-migration room ID with no existence check.
+ if world.RemapInt(idMap, &p.MoveTarget) {
+ changed = true
+ }
+ if world.RemapInt(idMap, &p.EscapeTarget) {
+ changed = true
+ }
if p.PendingSequence != nil && p.PendingSequence.RoomID != 0 && world.RemapInt(idMap, &p.PendingSequence.RoomID) {
changed = true
}
diff --git a/internal/game/fix_game_regression_test.go b/internal/game/fix_game_regression_test.go
new file mode 100644
index 0000000..7edbf8a
--- /dev/null
+++ b/internal/game/fix_game_regression_test.go
@@ -0,0 +1,53 @@
+package game
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestRawArgsFromPreservesCaseAndSkipsVerb(t *testing.T) {
+ cases := []struct {
+ name string
+ raw string
+ verb string
+ want []string
+ }{
+ {"option free text", "option visual_tick_text Tick Tock", "option", []string{"visual_tick_text", "Tick", "Tock"}},
+ {"options fallback", "options visual_tick_text Tick", "option", []string{"visual_tick_text", "Tick"}},
+ {"alias case", "alias g Say Hi", "alias", []string{"g", "Say", "Hi"}},
+ {"collapsed whitespace", "say hello world", "say", []string{"hello", "world"}},
+ {"room action double space", "room addlocalobj [lamp] A brass lamp", "room", []string{"addlocalobj", "[lamp]", "A", "brass", "lamp"}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := rawArgsFrom(tc.raw, tc.verb)
+ if !reflect.DeepEqual(got, tc.want) {
+ t.Fatalf("rawArgsFrom(%q, %q) = %#v, want %#v", tc.raw, tc.verb, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestParseLocalObjArgsUsesSplitRest(t *testing.T) {
+ name, aliases, desc := parseLocalObjArgs("[lamp] A brass lamp")
+ if name != "lamp" || len(aliases) != 0 || desc != "A brass lamp" {
+ t.Fatalf("bracket form = (%q, %v, %q)", name, aliases, desc)
+ }
+ name, aliases, desc = parseLocalObjArgs("[vase, urn] A clay pot")
+ if name != "vase" || !reflect.DeepEqual(aliases, []string{"urn"}) || desc != "A clay pot" {
+ t.Fatalf("bracket aliases = (%q, %v, %q)", name, aliases, desc)
+ }
+ name, aliases, desc = parseLocalObjArgs("lamp A brass lamp")
+ if name != "lamp" || aliases != nil || desc != "A brass lamp" {
+ t.Fatalf("plain form = (%q, %v, %q)", name, aliases, desc)
+ }
+}
+
+func TestCountUniqueNonEmpty(t *testing.T) {
+ if got := countUniqueNonEmpty([]string{"a", "b", "a", ""}); got != 2 {
+ t.Fatalf("countUniqueNonEmpty = %d, want 2", got)
+ }
+ if got := countUniqueNonEmpty(nil); got != 0 {
+ t.Fatalf("countUniqueNonEmpty(nil) = %d, want 0", got)
+ }
+}
diff --git a/internal/game/game.go b/internal/game/game.go
index df8dab2..448dcce 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -597,6 +597,23 @@ func (g *Game) reprompt(sess *net.Session) {
}
}
+// rawArgsFrom returns the whitespace-separated arguments following verb in
+// rawInput, preserving their original case. Dispatch lowercases args before
+// handlers run, so free-text handlers (option/alias/say/global/prompt) use
+// this instead of the lowercased args slice.
+func rawArgsFrom(rawInput, verb string) []string {
+ fields := strings.Fields(rawInput)
+ for i, f := range fields {
+ if strings.EqualFold(f, verb) {
+ return fields[i+1:]
+ }
+ }
+ if len(fields) == 0 {
+ return nil
+ }
+ return fields[1:]
+}
+
func (g *Game) handleGameCommand(sess *net.Session, input string) {
if input == "" {
g.reprompt(sess)
diff --git a/internal/game/hardening_tick_lifecycle_test.go b/internal/game/hardening_tick_lifecycle_test.go
index d9a348a..f230ad7 100644
--- a/internal/game/hardening_tick_lifecycle_test.go
+++ b/internal/game/hardening_tick_lifecycle_test.go
@@ -22,7 +22,7 @@ import (
// 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()
+ g := escapeTestGame(t)
// 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)
@@ -52,7 +52,7 @@ func TestFireTickNilHubIsSafe(t *testing.T) {
// g.sequences: map iteration randomly chose which simultaneous sequence
// advanced first, contradicting the deterministic-order contract.
func TestSequenceTickFiresInDeterministicOrder(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
g.sequences = make(map[string]*sequence)
g.loggedInChars = make(map[string]*net.Session)
@@ -171,7 +171,7 @@ func wanderTestSession(h *harness, name string, roomID int) *chanConn {
func TestAggroRetryStopsForDepartedPlayer(t *testing.T) {
t.Run("player_detached", func(t *testing.T) {
defer patchAggroRand(0.5)()
- g := escapeTestGame()
+ g := escapeTestGame(t)
spawnTestMob(g, 100)
p := player.New("aggro_ghost")
p.RoomID = 100
@@ -193,7 +193,7 @@ func TestAggroRetryStopsForDepartedPlayer(t *testing.T) {
t.Run("session_disconnecting", func(t *testing.T) {
defer patchAggroRand(0.5)()
- g := escapeTestGame()
+ g := escapeTestGame(t)
spawnTestMob(g, 150)
p := player.New("aggro_offline")
p.RoomID = 150
@@ -211,7 +211,7 @@ func TestAggroRetryStopsForDepartedPlayer(t *testing.T) {
t.Run("replaced_by_new_session", func(t *testing.T) {
defer patchAggroRand(0.5)()
- g := escapeTestGame()
+ g := escapeTestGame(t)
spawnTestMob(g, 200)
p := player.New("aggro_relogin")
p.RoomID = 200
@@ -230,7 +230,7 @@ func TestAggroRetryStopsForDepartedPlayer(t *testing.T) {
t.Run("live_session_still_rolls", func(t *testing.T) {
defer patchAggroRand(0.5)()
- g := escapeTestGame()
+ g := escapeTestGame(t)
spawnTestMob(g, 300)
p := player.New("aggro_live")
p.RoomID = 300
diff --git a/internal/game/login_offowner_regression_test.go b/internal/game/login_offowner_regression_test.go
index 4826c4c..bcf03f6 100644
--- a/internal/game/login_offowner_regression_test.go
+++ b/internal/game/login_offowner_regression_test.go
@@ -41,6 +41,7 @@ func TestLoginThroughServerEntryUsesOffOwnerPath(t *testing.T) {
sess.StartWriter()
lines := make(chan string, 8)
go h.input(sess, lines)
+ t.Cleanup(func() { close(lines) })
lines <- "alice" // existing account → password prompt (echo off)
lines <- "wrongpw" // rejected, echo restored, back to account name
@@ -71,6 +72,7 @@ func TestNewAccountThroughServerEntryHashesOffOwner(t *testing.T) {
sess.StartWriter()
lines := make(chan string, 8)
go h.input(sess, lines)
+ t.Cleanup(func() { close(lines) })
lines <- "newbie" // unknown name → new-account password prompt
lines <- "hunter2pass"
diff --git a/internal/game/look_casino.go b/internal/game/look_casino.go
index 32eb360..eb0b5f9 100644
--- a/internal/game/look_casino.go
+++ b/internal/game/look_casino.go
@@ -104,7 +104,7 @@ func (g *Game) renderCasinoTableDetails(sess *net.Session, cfg casino.TableConfi
func casinoTableSummary(cfg casino.TableConfig) string {
switch cfg.Game {
case casino.GameCashHunt:
- return "200 squares; three difficulties; 98% RTP; prizes up to 50000x"
+ return "100 squares; three difficulties; 98% RTP; prizes up to 50000x"
case casino.GamePlinko:
return fmt.Sprintf("16 rows; four risks; 1-10 balls; near %.4g%% target RTP", cfg.Plinko.Normalize().RTP)
case casino.GameBlackjack:
diff --git a/internal/game/look_casino_test.go b/internal/game/look_casino_test.go
index d53e02d..f48ba89 100644
--- a/internal/game/look_casino_test.go
+++ b/internal/game/look_casino_test.go
@@ -33,7 +33,7 @@ func TestLookCasinoTableDetailsAndBoards(t *testing.T) {
// Craps: details only. The graphical felt and the full rule dump are gone.
conn.data = nil
- if !g.lookCrapsTable(sess, "craps") {
+ if !g.lookCasinoTable(sess, "craps", casino.GameCraps) {
t.Fatal("look craps did not match")
}
text := stripANSI(string(conn.data))
@@ -50,7 +50,7 @@ func TestLookCasinoTableDetailsAndBoards(t *testing.T) {
// Roulette: details plus the live wheel (tracker, layout, and history).
conn.data = nil
- if !g.lookRouletteTable(sess, "roulette") {
+ if !g.lookCasinoTable(sess, "roulette", casino.GameRoulette) {
t.Fatal("look roulette did not match")
}
text = stripANSI(string(conn.data))
@@ -78,7 +78,7 @@ func TestLookCasinoTableAliasesAndFeltGames(t *testing.T) {
t.Fatalf("look %s missing %q: %s", target, want, text)
}
}
- if g.lookCrapsTable(sess, "bj") {
+ if g.lookCasinoTable(sess, "bj", casino.GameCraps) {
t.Fatal("look craps matched a blackjack target")
}
}
@@ -88,7 +88,7 @@ func TestLookCasinoUsesLiveNormalizedConfig(t *testing.T) {
// defaults EnsureTable fills in, not zero values.
g, sess, conn := casinoCrapsGame(t, "name: Bare\ncasino_tables:\n - id: wheel\n game: roulette\n")
conn.data = nil
- if !g.lookRouletteTable(sess, "wheel") {
+ if !g.lookCasinoTable(sess, "wheel", casino.GameRoulette) {
t.Fatal("look by fixture id did not match")
}
text := stripANSI(string(conn.data))
diff --git a/internal/game/look_target.go b/internal/game/look_target.go
index 1c3d112..0ddd8f4 100644
--- a/internal/game/look_target.go
+++ b/internal/game/look_target.go
@@ -48,7 +48,14 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
return
}
- if g.lookCasinoTable(sess, lower, casino.GameCashHunt) || g.lookPlinkoTable(sess, lower) || g.lookBigSixTable(sess, lower) || g.lookBlackjackTable(sess, lower) || g.lookBaccaratTable(sess, lower) || g.lookRouletteTable(sess, lower) || g.lookCrapsTable(sess, lower) || g.lookPaiGowTable(sess, lower) || g.lookVideoPokerCabinet(sess, lower) {
+ // Felt tables resolve through one data-driven loop; the video-poker cabinet
+ // has its own cabinet-id/pay-table handling.
+ for _, game := range []casino.GameName{casino.GameCashHunt, casino.GamePlinko, casino.GameBigSix, casino.GameBlackjack, casino.GameBaccarat, casino.GameRoulette, casino.GameCraps, casino.GamePaiGow} {
+ if g.lookCasinoTable(sess, lower, game) {
+ return
+ }
+ }
+ if g.lookVideoPokerCabinet(sess, lower) {
return
}
diff --git a/internal/game/money.go b/internal/game/money.go
index 6317df8..6965859 100644
--- a/internal/game/money.go
+++ b/internal/game/money.go
@@ -48,6 +48,20 @@ func removePendingWager(p *player.Player, id string) bool {
return removed
}
+// countUniqueNonEmpty returns the number of distinct non-empty IDs in ids.
+// PayCredits/PayPoker use it to fail closed when a payout names a stake that
+// is not present (or names one twice): paying it anyway would double-pay with
+// no ledger entry left for startup recovery.
+func countUniqueNonEmpty(ids []string) int {
+ seen := make(map[string]bool, len(ids))
+ for _, id := range ids {
+ if id != "" {
+ seen[id] = true
+ }
+ }
+ return len(seen)
+}
+
// takePendingWagers removes and returns the pending stakes whose IDs are
// listed, leaving the rest. It is used by a settlement that returns a
// specific set of stakes (a table hand).
diff --git a/internal/game/owner.go b/internal/game/owner.go
index d4d3839..3954ab7 100644
--- a/internal/game/owner.go
+++ b/internal/game/owner.go
@@ -172,7 +172,13 @@ func (g *Game) run(fn func()) {
// TestShutdownRejectsSessionInput.
return
}
- <-task
+ select {
+ case <-task:
+ case <-o.done:
+ // The owner goroutine exited without draining the queue (a task
+ // called runtime.Goexit, e.g. a test's t.Fatal). Return instead of
+ // blocking forever on a task that no longer has a consumer.
+ }
}
// adminTaskPanic is the value RunAdminOnOwner re-raises on its caller's
@@ -222,7 +228,12 @@ func (g *Game) RunAdminOnOwner(fn func()) bool {
}) {
return false
}
- <-done
+ select {
+ case <-done:
+ case <-g.owner.done:
+ // Owner goroutine exited; treat as a refused task rather than hanging.
+ return false
+ }
if !finished {
panic(adminTaskPanic)
}
diff --git a/internal/game/owner_goexit_test.go b/internal/game/owner_goexit_test.go
new file mode 100644
index 0000000..5be643f
--- /dev/null
+++ b/internal/game/owner_goexit_test.go
@@ -0,0 +1,26 @@
+package game
+
+import (
+ "runtime"
+ "testing"
+ "time"
+)
+
+// A task that calls runtime.Goexit (t.Fatal on the owner goroutine) stops the
+// owner loop. Later RunOnOwner calls must return instead of blocking forever
+// waiting for a consumer that no longer exists.
+func TestOwnerGoexitDoesNotHangLaterTasks(t *testing.T) {
+ h := newHarness(t)
+ h.g.RunOnOwner(func() { runtime.Goexit() })
+
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ h.g.RunOnOwner(func() {})
+ }()
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ t.Fatal("RunOnOwner hung after the owner goroutine exited")
+ }
+}
diff --git a/internal/game/production_engine.go b/internal/game/production_engine.go
index 4717fbf..682cea3 100644
--- a/internal/game/production_engine.go
+++ b/internal/game/production_engine.go
@@ -76,14 +76,12 @@ func (g *Game) startProduction(sess *net.Session, p *player.Player, item *item.I
g.cancelAction(p)
g.cancelBgAction(p)
- skill := craft.EffectiveSkill()
- if skill != "" {
- skillLevel := p.Level(player.SkillName(skill))
- if skillLevel < craft.Level {
- sess.WriteLine(fmt.Sprintf("You need level %d %s to do that.", craft.Level, skill))
- g.reprompt(sess)
- return
- }
+ skill := recipeSkill(craft)
+ skillLevel := p.Level(skill)
+ if skillLevel < craft.Level {
+ sess.WriteLine(fmt.Sprintf("You need level %d %s to do that.", craft.Level, skill))
+ g.reprompt(sess)
+ return
}
if !craftHasAllItemsQty(craft, p.CountItem) {
@@ -92,6 +90,23 @@ func (g *Game) startProduction(sess *net.Session, p *player.Player, item *item.I
return
}
+ // Enforce station and tool requirements here, at the single execution
+ // chokepoint. Recipes can also start via `use X with Y` and trigger
+ // transports that bypass the crafting menus; without this, a recipe can
+ // be executed anywhere with no station or tool.
+ if len(craft.Station) > 0 {
+ if stID, _ := g.findStation(p.RoomID, craft.Station); stID == "" {
+ sess.WriteLine("You are not near the required station.")
+ g.reprompt(sess)
+ return
+ }
+ }
+ if craft.Tool != "" && !g.hasToolType(p, craft.Tool) {
+ sess.WriteLine("You do not have the required tool.")
+ g.reprompt(sess)
+ return
+ }
+
outDef, _ := g.ItemStore.Load(item.ID)
wait := craft.TicksPerCycle
@@ -129,8 +144,8 @@ func (g *Game) startProduction(sess *net.Session, p *player.Player, item *item.I
func (g *Game) usableCraftIndex(p *player.Player, item *item.ItemDef) int {
for i := range item.Craft {
c := &item.Craft[i]
- skill := c.EffectiveSkill()
- if skill != "" && p.Level(player.SkillName(skill)) < c.Level {
+ skill := recipeSkill(c)
+ if p.Level(skill) < c.Level {
continue
}
if !craftHasAllItemsQty(c, p.CountItem) {
@@ -248,8 +263,8 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) {
return
}
- skill := craft.EffectiveSkill()
- skillLevel := p.Level(player.SkillName(skill))
+ skill := recipeSkill(craft)
+ skillLevel := p.Level(skill)
chance := 1.0
if craft.Success != nil {
chance = behavior.SuccessChance(behavior.SuccessFormula(*craft.Success), skillLevel, craft.Level)
@@ -313,7 +328,7 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) {
}
if craft.XP > 0 {
- if newLevel := p.AddSkillXP(player.SkillName(skill), craft.XP); newLevel > 0 {
+ if newLevel := p.AddSkillXP(skill, craft.XP); newLevel > 0 {
sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, skill)))
}
}
@@ -327,7 +342,7 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) {
}
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)
+ msg += g.formatXpDropSingle(sess, p, skill, craft.XP)
}
sess.WriteLine(msg)
} else {
diff --git a/internal/game/render_baccarat_felt.go b/internal/game/render_baccarat_felt.go
index 608ef93..3120bd7 100644
--- a/internal/game/render_baccarat_felt.go
+++ b/internal/game/render_baccarat_felt.go
@@ -49,7 +49,7 @@ func renderBaccaratRulesFelt(cfg casino.TableConfig, mode string, unicode bool)
}
house := [][2]string{
{"DECKS", fmt.Sprintf("%d", b.Decks)},
- {"SHUFFLE AT", fmt.Sprintf("%g%% REMAINING", b.ShuffleAt*100)},
+ {"SHUFFLE AT", fmt.Sprintf("%.6g%% REMAINING", b.ShuffleAt*100)},
{"BET LIMITS", betLimitsText(cfg.MinBet, cfg.MaxBet)},
{"SEATS", fmt.Sprintf("%d", cfg.MaxPlayers)},
{"BETTING WINDOW", fmt.Sprintf("%d TICKS", cfg.BettingTicks)},
@@ -112,7 +112,7 @@ func bankerNoteText(b casino.BaccaratConfig) string {
case casino.BaccaratEZ:
return "3-CARD 7 PUSHES"
default:
- return fmt.Sprintf("%s%% COMMISSION", strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.6g", (1-b.BankerPayout)*100), "0"), "."))
+ return fmt.Sprintf("%s%% COMMISSION", strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.6f", (1-b.BankerPayout)*100), "0"), "."))
}
}
func optionalText(enabled bool, payout string) string {
diff --git a/internal/game/render_blackjack_felt.go b/internal/game/render_blackjack_felt.go
index 41a5382..ad93a88 100644
--- a/internal/game/render_blackjack_felt.go
+++ b/internal/game/render_blackjack_felt.go
@@ -37,7 +37,7 @@ func renderBlackjackRulesFelt(cfg casino.TableConfig, mode string, unicode bool)
rows := [4][][2]string{
{
{"DECKS", fmt.Sprintf("%d", b.Decks)},
- {"SHUFFLE AT", fmt.Sprintf("%g%% REMAINING", b.ShuffleAt*100)},
+ {"SHUFFLE AT", fmt.Sprintf("%.6g%% REMAINING", b.ShuffleAt*100)},
{"SHOE LOW", "RESHUFFLE MID-ROUND"},
{"HOLE CARD", "YES (PEEK)"},
{"", ""},
diff --git a/internal/game/render_cards.go b/internal/game/render_cards.go
new file mode 100644
index 0000000..ca1fc62
--- /dev/null
+++ b/internal/game/render_cards.go
@@ -0,0 +1,330 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+ "unicode/utf8"
+
+ "thehouseoficarus/internal/cardart"
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+)
+
+func renderBlackjackSnapshotFor(snapshot *casino.BlackjackSnapshot, mode string, unicode bool, size int, style string, art *cardart.Store, viewer string) string {
+ if snapshot == nil {
+ return ""
+ }
+ size = cardRenderSize(unicode, size)
+ var lines []string
+ dealerTitle := "Dealer:"
+ if snapshot.DealerRevealed {
+ dealerTitle = "Dealer (" + snapshot.DealerScore + "):"
+ } else if snapshot.DealerShownScore != "" {
+ dealerTitle = "Dealer (" + snapshot.DealerShownScore + "):"
+ }
+ if size == 1 {
+ lines = append(lines, cardLine(mode, dealerTitle)+" "+strings.Join(cardsLine(mode, snapshot.Dealer, size, unicode, style, art), " "))
+ } else {
+ lines = append(lines, cardLine(mode, dealerTitle))
+ lines = append(lines, cardsLine(mode, snapshot.Dealer, size, unicode, style, art)...)
+ }
+ for _, player := range snapshot.Players {
+ name := player.Name
+ if name == viewer {
+ name = "You"
+ }
+ marker := " "
+ if player.Active {
+ marker = "> "
+ }
+ detail := ""
+ if player.Hand > 0 {
+ detail = fmt.Sprintf(" | Hand %d/%d | Wager %d | %s", player.Hand, player.Hands, player.Bet, player.Status)
+ }
+ if player.Active && player.Name != viewer {
+ detail = strings.ReplaceAll(detail, "Your turn", "Playing")
+ }
+ title := fmt.Sprintf("%s%s (%s)%s:", marker, name, player.Score, detail)
+ if size == 1 {
+ lines = append(lines, cardLine(mode, title)+" "+strings.Join(cardsLine(mode, player.Cards, size, unicode, style, art), " "))
+ } else {
+ lines = append(lines, cardLine(mode, title))
+ lines = append(lines, cardsLine(mode, player.Cards, size, unicode, style, art)...)
+ }
+ }
+ return strings.Join(lines, "\n")
+}
+
+// renderBaccaratSnapshot draws the player and banker hands followed by the
+// list of wagers riding on the round.
+func renderBaccaratSnapshot(snapshot *casino.BaccaratSnapshot, mode string, unicode bool, size int, style string, art *cardart.Store) string {
+ if snapshot == nil {
+ return ""
+ }
+ size = cardRenderSize(unicode, size)
+ var lines []string
+ if size == 1 {
+ lines = append(lines, cardLine(mode, "Player ("+snapshot.PlayerScore+"):")+" "+strings.Join(cardsLine(mode, snapshot.Player, size, unicode, style, art), " "), cardLine(mode, "Banker ("+snapshot.BankerScore+"):")+" "+strings.Join(cardsLine(mode, snapshot.Banker, size, unicode, style, art), " "))
+ } else {
+ lines = append(lines, cardLine(mode, "Player ("+snapshot.PlayerScore+"):"))
+ lines = append(lines, cardsLine(mode, snapshot.Player, size, unicode, style, art)...)
+ lines = append(lines, cardLine(mode, "Banker ("+snapshot.BankerScore+"):"))
+ lines = append(lines, cardsLine(mode, snapshot.Banker, size, unicode, style, art)...)
+ }
+ for _, bet := range snapshot.Bets {
+ lines = append(lines, cardLine(mode, fmt.Sprintf("%s bets %d on %s.", bet.Name, bet.Amount, bet.Spot)))
+ }
+ return strings.Join(lines, "\n")
+}
+
+func cardLine(mode, text string) string {
+ spec := color.NoColor()
+ spec.Fg = 39
+ return color.Render(mode, spec, text)
+}
+
+func cardsLine(mode string, cards []casino.CardView, size int, unicode bool, style string, art *cardart.Store) []string {
+ if len(cards) == 0 {
+ return nil
+ }
+ if size == 1 {
+ parts := make([]string, len(cards))
+ for i, card := range cards {
+ if !unicode {
+ if card.Hidden {
+ parts[i] = "[??]"
+ } else {
+ suit := map[string]string{"hearts": "H", "diamonds": "D", "spades": "S", "clubs": "C"}[card.Suit]
+ parts[i] = card.Rank + suit
+ }
+ } else {
+ parts[i] = colorizeCardSymbol(mode, card)
+ }
+ }
+ return []string{strings.Join(parts, " ")}
+ }
+ artLines := make([][]string, len(cards))
+ for i, card := range cards {
+ artLines[i] = cardArtLines(card, size, unicode, style, art)
+ }
+ lines := make([]string, size)
+ for row := 0; row < size; row++ {
+ var lineParts []string
+ for i, card := range artLines {
+ lineParts = append(lineParts, colorizeCardLine(mode, card[row], cards[i], row, size, style))
+ }
+ lines[row] = strings.Join(lineParts, " ")
+ }
+ return lines
+}
+
+// renderCardRow lays cards side by side, each column padded to its own art
+// or label width, and appends an optional centered label row. minWidth lets
+// the caller force a floor (video poker's HELD labels need it) while Pai Gow
+// passes 0. It is the shared "cards with numbers underneath" presentation.
+func renderCardRow(cards []casino.CardView, labels []string, minWidth int, mode string, unicode bool, size int, style string, art *cardart.Store) []string {
+ if len(cards) == 0 {
+ return nil
+ }
+ artLines := make([][]string, len(cards))
+ widths := make([]int, len(cards))
+ height := 0
+ for i, card := range cards {
+ artLines[i] = cardsLine(mode, []casino.CardView{card}, size, unicode, style, art)
+ height = max(height, len(artLines[i]))
+ widths[i] = minWidth
+ for _, line := range artLines[i] {
+ widths[i] = max(widths[i], color.VisibleLen(line))
+ }
+ if i < len(labels) {
+ widths[i] = max(widths[i], color.VisibleLen(labels[i]))
+ }
+ }
+ lines := make([]string, 0, height+1)
+ for row := 0; row < height; row++ {
+ parts := make([]string, len(cards))
+ for i := range cards {
+ text := ""
+ if row < len(artLines[i]) {
+ text = artLines[i][row]
+ }
+ parts[i] = rouletteCenter(text, widths[i])
+ }
+ lines = append(lines, strings.Join(parts, " "))
+ }
+ if len(labels) > 0 {
+ parts := make([]string, len(cards))
+ for i := range cards {
+ text := ""
+ if i < len(labels) {
+ text = labels[i]
+ }
+ parts[i] = rouletteCenter(text, widths[i])
+ }
+ lines = append(lines, strings.Join(parts, " "))
+ }
+ return lines
+}
+
+func cardRenderSize(unicode bool, size int) int {
+ if size == 1 {
+ return 1
+ }
+ if !unicode {
+ return 5
+ }
+ if size == 5 || size == 7 || size == 9 {
+ return size
+ }
+ return 7
+}
+
+var suitSymbols = map[string]string{"hearts": "♥", "spades": "♠", "diamonds": "♦", "clubs": "♣", "joker": "★"}
+
+func cardSymbol(card casino.CardView) string {
+ if card.Hidden {
+ return "?"
+ }
+ return card.Rank + suitSymbols[card.Suit]
+}
+
+func cardArtLines(card casino.CardView, size int, unicode bool, style string, art *cardart.Store) []string {
+ if card.Hidden {
+ return generatedCardArt("?", "", size, unicode, style)
+ }
+ key := card.Rank
+ if card.Rank == "JK" {
+ return jokerCardArt(size, unicode, style, art)
+ }
+ if card.Suit != "" {
+ key += strings.ToUpper(card.Suit[:1])
+ }
+ if configured := art.Art(key, size); len(configured) == size {
+ if unicode && style == "light" {
+ // The shared pip art draws a dark box frame, which leaves the top
+ // and bottom rows looking like the dark style. Replace only those
+ // two rows with the half-block edges the white body meets.
+ configured = append([]string(nil), configured...)
+ configured[0] = strings.Repeat("▄", utf8.RuneCountInString(configured[0]))
+ configured[size-1] = strings.Repeat("▀", utf8.RuneCountInString(configured[size-1]))
+ }
+ return configured
+ }
+ return generatedCardArt(card.Rank, card.Suit, size, unicode, style)
+}
+
+func generatedCardArt(rank, suit string, size int, unicode bool, style string) []string {
+ if size < 5 {
+ size = 5
+ }
+ border := "-"
+ left, right := "+", "+"
+ if unicode {
+ border, left, right = "─", "┌", "┐"
+ }
+ bottomLeft, bottomRight := left, right
+ if unicode {
+ bottomLeft, bottomRight = "└", "┘"
+ }
+ vertical := "|"
+ if unicode {
+ vertical = "│"
+ }
+ if unicode && style == "light" {
+ lines := []string{strings.Repeat("▄", size)}
+ lines = append(lines, generatedCardBody(rank, suit, size, unicode, vertical)...)
+ return append(lines, strings.Repeat("▀", size))
+ }
+ lines := []string{left + strings.Repeat(border, size-2) + right}
+ lines = append(lines, generatedCardBody(rank, suit, size, unicode, vertical)...)
+ lines = append(lines, bottomLeft+strings.Repeat(border, size-2)+bottomRight)
+ return lines
+}
+
+func generatedCardBody(rank, suit string, size int, unicode bool, vertical string) []string {
+ inner := size - 2
+ lines := make([]string, 0, size-2)
+ for row := 1; row < size-1; row++ {
+ content := strings.Repeat(" ", inner)
+ if row == 1 {
+ content = rank + strings.Repeat(" ", inner-len(rank))
+ }
+ if row == (size-1)/2 {
+ suitText := ""
+ if suit != "" {
+ suitText = suitLetter(suit, unicode)
+ }
+ suitWidth := utf8.RuneCountInString(suitText)
+ leftPadding := (inner - suitWidth) / 2
+ content = strings.Repeat(" ", leftPadding) + suitText + strings.Repeat(" ", inner-leftPadding-suitWidth)
+ }
+ if row == size-2 {
+ content = strings.Repeat(" ", inner-len(rank)) + rank
+ }
+ lines = append(lines, vertical+content+vertical)
+ }
+ return lines
+}
+
+func suitLetter(suit string, unicode bool) string {
+ if !unicode {
+ return strings.ToUpper(suit[:1])
+ }
+ return suitSymbols[suit]
+}
+
+func colorizeCardLine(mode, line string, card casino.CardView, row, size int, style string) string {
+ red := card.Suit == "hearts" || card.Suit == "diamonds"
+ rankColor := 27
+ if red {
+ rankColor = 196
+ }
+ suitColor := 27
+ if red {
+ suitColor = 196
+ }
+ rankLine := row == 1 || row == size-2
+ light := style == "light"
+ bodyBackground := light && row > 0 && row < size-1
+ var builder strings.Builder
+ for _, r := range line {
+ if strings.ContainsRune("┌─┐└┘│┬┴├┤┼╔╗╚╝═║╦╩╠╣╬▀▄+|-", r) {
+ spec := color.ColorSpec{Fg: 15}
+ if bodyBackground {
+ spec.Bg = 15
+ }
+ builder.WriteString(color.Render(mode, spec, string(r)))
+ } else if r == ' ' {
+ if bodyBackground {
+ builder.WriteString(color.Render(mode, color.ColorSpec{Fg: 15, Bg: 15}, " "))
+ } else {
+ builder.WriteRune(r)
+ }
+ } else {
+ value := suitColor
+ if rankLine {
+ value = rankColor
+ }
+ spec := color.ColorSpec{Fg: value}
+ if bodyBackground {
+ spec.Bg = 15
+ }
+ builder.WriteString(color.Render(mode, spec, string(r)))
+ }
+ }
+ return builder.String()
+}
+
+func colorizeCardSymbol(mode string, card casino.CardView) string {
+ if card.Hidden {
+ return color.Render(mode, color.ColorSpec{Fg: 15}, "?")
+ }
+ rankColor, suitColor := 27, 27
+ if card.Suit == "hearts" || card.Suit == "diamonds" {
+ rankColor, suitColor = 196, 196
+ }
+ runes := []rune(cardSymbol(card))
+ rankLength := len([]rune(card.Rank))
+ return color.Render(mode, color.ColorSpec{Fg: rankColor}, string(runes[:rankLength])) +
+ color.Render(mode, color.ColorSpec{Fg: suitColor}, string(runes[rankLength:]))
+}
diff --git a/internal/game/render_casino_settlement.go b/internal/game/render_casino_settlement.go
index 2b5acc9..8bdf9e1 100644
--- a/internal/game/render_casino_settlement.go
+++ b/internal/game/render_casino_settlement.go
@@ -90,17 +90,14 @@ func renderCasinoSettlement(lines []casino.SettlementLine, unicode bool, paint f
// Borders and cell padding occupy sixteen columns.
widths[0] = min(widths[0], 79-16-widths[1]-widths[2]-widths[3]-widths[4])
widths[0] = max(widths[0], 5)
- inner := 4
- for _, w := range widths {
- inner += w + 2
- }
- // A readable footer without forcing tiny one-wager receipts into a wide box.
- inner = max(inner, 50)
+ // Grow the label column to reach the 50-wide minimum box (a readable footer
+ // without forcing tiny one-wager receipts wide).
sum := 4
for _, w := range widths {
sum += w + 2
}
- widths[0] += inner - sum
+ widths[0] += max(0, 50-sum)
+ inner := max(50, sum)
border := func(left, join, right string) string {
parts := make([]string, 5)
for i, w := range widths {
@@ -182,9 +179,6 @@ func renderCasinoSettlement(lines []casino.SettlementLine, unicode bool, paint f
title, amount = "NET LOSS", fmt.Sprintf("%d credits", net)
}
tag := netTag(net)
- if settled == 0 {
- title, amount, tag = "NET PENDING", dash, "warning"
- }
footer(title+strings.Repeat(" ", max(1, inner-2-len(title)-color.VisibleLen(amount)))+amount, tag)
out = append(out, paint("dim", bl+strings.Repeat(h, inner)+br))
return strings.Join(out, "\n")
diff --git a/internal/game/render_color.go b/internal/game/render_color.go
index ccce05e..cd239ca 100644
--- a/internal/game/render_color.go
+++ b/internal/game/render_color.go
@@ -115,33 +115,25 @@ func (g *Game) itemColorize(sess *net.Session, itemDef *item.ItemDef, text strin
func (g *Game) namedColorSpecs(sess *net.Session) map[string]color.ColorSpec {
names := make(map[string]color.ColorSpec)
for name := range config.DefaultColors() {
- if spec := g.resolveColor(sess, name); !spec.Empty() {
- names[name] = spec
- }
+ names[name] = g.resolveColor(sess, name)
}
if g.ColorConfig != nil {
for name := range *g.ColorConfig {
if _, ok := names[name]; ok {
continue
}
- if spec := g.resolveColor(sess, name); !spec.Empty() {
- names[name] = spec
- }
+ names[name] = g.resolveColor(sess, name)
}
}
for name := range config.DefaultConstantColors() {
- if spec := g.resolveConstant(name); !spec.Empty() {
- names[name] = spec
- }
+ names[name] = g.resolveConstant(name)
}
if g.ConstantColorConfig != nil {
for name := range *g.ConstantColorConfig {
if _, ok := names[name]; ok {
continue
}
- if spec := g.resolveConstant(name); !spec.Empty() {
- names[name] = spec
- }
+ names[name] = g.resolveConstant(name)
}
}
return names
diff --git a/internal/game/render_craps.go b/internal/game/render_craps.go
index e4e6618..57b7b79 100644
--- a/internal/game/render_craps.go
+++ b/internal/game/render_craps.go
@@ -448,7 +448,7 @@ func renderCrapsDice(dice [2]int, mode string, unicode bool) string {
{" ● ● ", " ● ", " ● ● "},
{" ● ● ", " ● ● ", " ● ● "},
}
- colored := mode != "" && mode != "none"
+ colored := !color.Plain(mode)
face := color.ColorSpec{Fg: 0, Bg: 15}
cap := color.ColorSpec{Fg: 15, Bg: -1}
var out strings.Builder
@@ -555,7 +555,6 @@ func renderCrapsSnapshot(s *casino.CrapsSnapshot, mode string, unicode bool, sty
}
hint := ""
if s.Phase != "waiting" && s.Phase != "" {
- hint = ""
if s.Variant == "simplified" {
if s.ShootIn > 0 {
hint = fmt.Sprintf("The dice roll in %dt.", s.ShootIn)
diff --git a/internal/game/render_felt_precision_test.go b/internal/game/render_felt_precision_test.go
new file mode 100644
index 0000000..da96d71
--- /dev/null
+++ b/internal/game/render_felt_precision_test.go
@@ -0,0 +1,41 @@
+package game
+
+import (
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+)
+
+// The felt is fixed-width art: a commission percentage must not lose its
+// trailing zero (0.9 used to trim "10" down to "1").
+func TestBaccaratFeltPrintsExactCommission(t *testing.T) {
+ cases := map[float64]string{
+ 0.9: "10% COMMISSION",
+ 0.8: "20% COMMISSION",
+ 0.95: "5% COMMISSION",
+ 0.99: "1% COMMISSION",
+ }
+ for payout, want := range cases {
+ cfg := casino.TableConfig{ID: "x", Game: casino.GameBaccarat,
+ Baccarat: casino.BaccaratConfig{Variant: casino.BaccaratCommission, BankerPayout: payout}}
+ art := renderBaccaratRulesFelt(cfg, "none", false)
+ if !strings.Contains(art, want) {
+ t.Fatalf("payout %v: missing %q in felt", payout, want)
+ }
+ }
+}
+
+// Bare %g prints shortest-round-trip floats (0.29 -> 28.999999999999996),
+// which overruns the fixed-width box borders.
+func TestBlackjackFeltPrintsRoundedShuffle(t *testing.T) {
+ cfg := casino.TableConfig{ID: "x", Game: casino.GameBlackjack,
+ Blackjack: casino.BlackjackConfig{ShuffleAt: 0.29}}
+ art := renderBlackjackRulesFelt(cfg, "none", false)
+ if !strings.Contains(art, "29% REMAINING") {
+ t.Fatalf("missing rounded shuffle value: %s", art)
+ }
+ if strings.Contains(art, "99999") || strings.Contains(art, "000000") {
+ t.Fatalf("float noise leaked into the felt: %s", art)
+ }
+}
diff --git a/internal/game/render_help.go b/internal/game/render_help.go
index 9e8b45a..bfc40f7 100644
--- a/internal/game/render_help.go
+++ b/internal/game/render_help.go
@@ -47,7 +47,7 @@ var commandList = []cmdEntry{
{"drink", "Free", "Drink a potion"},
{"drop", "Active", "Drop items to the ground"},
{"eat", "Free", "Eat food to restore hitpoints"},
- {"equipment / eq", "Instant", "Show equipped items"},
+ {"equipment / eq", "Free", "Show equipped items"},
{"exits", "Instant", "List available exits"},
{"fish", "Active", "Fish at fishing spots (Fishing)"},
{"fletch", "Free", "Fletch logs into bows (Fletching)"},
@@ -81,7 +81,7 @@ var commandList = []cmdEntry{
{"prompt", "Instant", "Set custom command prompt"},
{"pull / push", "Active", "Interact with objects"},
{"queued", "Instant", "Show pending tick actions"},
- {"quit", "Active", "Rest and disconnect (\"quit now\" forfeits a mid-game bet)"},
+ {"quit", "Instant", "Rest and disconnect (\"quit now\" forfeits a mid-game bet)"},
{"rake", "Active", "Rake a farming patch"},
{"remove / unwear / unwield", "Free", "Unequip items"},
{"say", "Instant", "Chat with players in your room"},
@@ -115,7 +115,7 @@ var commandList = []cmdEntry{
func LoadHelp(dataDir string) ([]HelpDef, error) {
dir := filepath.Join(dataDir, "help")
var helps []HelpDef
- behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error {
+ err := behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error {
h := parseHelpDef(data)
if h == nil {
return nil
@@ -123,7 +123,7 @@ func LoadHelp(dataDir string) ([]HelpDef, error) {
helps = append(helps, *h)
return nil
})
- return helps, nil
+ return helps, err
}
// helpRaw mirrors the canonical help schema and is also used by data
diff --git a/internal/game/render_map.go b/internal/game/render_map.go
index e8d7cea..ff36344 100644
--- a/internal/game/render_map.go
+++ b/internal/game/render_map.go
@@ -57,10 +57,23 @@ type mapGraph struct {
const playerZ = 0
func buildGraph(g *Game, sess *net.Session, startRoomID int) *mapGraph {
- rg := world.BuildGrid(startRoomID, func(id int) (*world.Room, bool) {
- return loadRoom(g, id)
- }, nil, func(fromID int, dir world.ExitDir, targetID int) bool {
- room, ok := loadRoom(g, fromID)
+ // BuildGrid calls load for the BFS node AND edgeInclude re-loads the same
+ // room for its exit check, so memoize per render: without this every room
+ // reachable from the player is os.ReadFile+yaml.Unmarshal'd twice per map.
+ cache := make(map[int]*world.Room)
+ load := func(id int) (*world.Room, bool) {
+ if r, ok := cache[id]; ok {
+ return r, true
+ }
+ r, ok := loadRoom(g, id)
+ if !ok {
+ return nil, false
+ }
+ cache[id] = r
+ return r, true
+ }
+ rg := world.BuildGrid(startRoomID, load, nil, func(fromID int, dir world.ExitDir, targetID int) bool {
+ room, ok := load(fromID)
if !ok {
return true
}
diff --git a/internal/game/render_plinko.go b/internal/game/render_plinko.go
index 7988d8c..7f566b3 100644
--- a/internal/game/render_plinko.go
+++ b/internal/game/render_plinko.go
@@ -188,7 +188,7 @@ func renderPlinko(s *casino.PlinkoSnapshot, mode string, unicode bool) string {
continue
}
glyph := ball
- if mode == "none" && count > 1 {
+ if color.Plain(mode) && count > 1 {
glyph = rune('0' + count)
if count == 10 {
glyph = '*'
diff --git a/internal/game/render_prompt.go b/internal/game/render_prompt.go
index 2b4f50c..4bf7977 100644
--- a/internal/game/render_prompt.go
+++ b/internal/game/render_prompt.go
@@ -62,11 +62,14 @@ func (g *Game) expandPromptVars(sess *net.Session, text string) string {
continue
}
letter := exitPromptLetter(dir)
- allExits += letter
- if exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) {
+ ed := g.exitDisplayState(sess, p.RoomID, dir, exitDef)
+ if !ed.Visible {
continue
}
- openExits += letter
+ allExits += letter
+ if !ed.Blocked {
+ openExits += letter
+ }
}
}
text = strings.ReplaceAll(text, "%E", allExits)
@@ -75,7 +78,7 @@ func (g *Game) expandPromptVars(sess *net.Session, text string) string {
for _, sk := range player.AllSkills {
tag := string(sk)
text = strings.ReplaceAll(text, "%X_"+tag, fmt.Sprint(p.Skills[sk]))
- text = strings.ReplaceAll(text, "%x_"+tag, fmt.Sprint(xpToLevel(p, sk)))
+ text = strings.ReplaceAll(text, "%x_"+tag, fmt.Sprint(player.XPForNextLevel(p.Skills[sk])))
}
text = strings.ReplaceAll(text, "\x00", "%")
@@ -110,13 +113,6 @@ func attackStyleLong(s player.AttackStyle) string {
return "accurate"
}
-func xpToLevel(p *player.Player, sk player.SkillName) int {
- currentXP := p.Skills[sk]
- currentLevel := p.Level(sk)
- nextLevelXP := player.XPForLevel(currentLevel + 1)
- return nextLevelXP - currentXP
-}
-
// Cardinal exits get uppercase single letters; intercardinal exits get lowercase
// two-letter strings. This visual distinction helps players scan the prompt at a glance.
func exitPromptLetter(dir world.ExitDir) string {
diff --git a/internal/game/render_roulette.go b/internal/game/render_roulette.go
index a0f1e04..89c14c6 100644
--- a/internal/game/render_roulette.go
+++ b/internal/game/render_roulette.go
@@ -134,7 +134,7 @@ func renderRouletteRacetrack(mode string, unicode bool) string {
pocket := func(x, y, index, width int) {
n := wheel[index%len(wheel)]
bg, name := roulettePocketColor(n)
- if mode == "none" || mode == "" {
+ if color.Plain(mode) {
n += name[:1]
}
put(x, y, rouletteCenter(n, width), bg)
@@ -318,7 +318,7 @@ func renderRouletteLayout(double, racetrack bool, mode string, unicode bool) str
case !double && row == 2:
zero = "0"
}
- if mode == "none" || mode == "" {
+ if color.Plain(mode) {
zero += "G"
}
// The 0/00 lane is one green rectangle, so its blank cells above and
@@ -327,7 +327,7 @@ func renderRouletteLayout(double, racetrack bool, mode string, unicode bool) str
for n := row; n <= 36; n += 3 {
pocket := strconv.Itoa(n)
bg, name := roulettePocketColor(pocket)
- if mode == "none" || mode == "" {
+ if color.Plain(mode) {
pocket += name[:1]
}
line += roulettePaint(mode, rouletteCenter(pocket, 4), bg) + rouletteInk(mode, b.v)
@@ -382,7 +382,7 @@ func renderRouletteLayout(double, racetrack bool, mode string, unicode bool) str
}
bottom += b.br
lines = append(lines, line, rouletteInk(mode, bottom))
- if mode == "none" || mode == "" {
+ if color.Plain(mode) {
lines = append(lines, "R = red, B = black, G = green")
}
return strings.Join(lines, "\n")
@@ -419,7 +419,7 @@ func renderRouletteWheel(wheel []string, mode string, unicode bool) string {
grid[y][x] = rouletteCell{' ', -1}
}
}
- plain := mode == "none" || mode == ""
+ plain := color.Plain(mode)
pocketRune := func(bg int) rune {
if !plain {
return ' '
@@ -553,7 +553,7 @@ func renderRouletteRow(row []rouletteCell, mode string) string {
j++
}
text := string(seg)
- if bg >= 0 && mode != "none" && mode != "" {
+ if bg >= 0 && !color.Plain(mode) {
sb.WriteString(roulettePaint(mode, text, bg))
} else {
sb.WriteString(rouletteInk(mode, text))
@@ -584,7 +584,7 @@ func renderRouletteResult(pockets [5]string, mode string, unicode bool, size int
ballRow := size / 2
hasRule := ballRow > ruleRow
colorRow := size - 2
- plain := mode == "none" || mode == ""
+ plain := color.Plain(mode)
var lines []string
for row := 0; row < size; row++ {
var cells []string
diff --git a/internal/game/render_roulette_tracker.go b/internal/game/render_roulette_tracker.go
index 3602224..645d28f 100644
--- a/internal/game/render_roulette_tracker.go
+++ b/internal/game/render_roulette_tracker.go
@@ -18,7 +18,7 @@ func renderRouletteTracker(s *casino.RouletteSnapshot, mode string, unicode bool
ink := func(text string) string { return rouletteInk(mode, text) }
pocket := func(n string) string {
bg, name := roulettePocketColor(n)
- if mode == "none" || mode == "" {
+ if color.Plain(mode) {
n += name[:1]
}
return roulettePaint(mode, rouletteCenter(n, 4), bg)
diff --git a/internal/game/render_slots.go b/internal/game/render_slots.go
new file mode 100644
index 0000000..82c57e7
--- /dev/null
+++ b/internal/game/render_slots.go
@@ -0,0 +1,86 @@
+package game
+
+import (
+ "strings"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+)
+
+func renderSlotSnapshot(snapshot *casino.SlotSnapshot, mode string, unicode bool) string {
+ if snapshot == nil {
+ return ""
+ }
+ left, junction, right, horizontal, vertical := '+', '+', '+', '-', '|'
+ bottomLeft, bottomJunction, bottomRight := '+', '+', '+'
+ if unicode {
+ left, junction, right, horizontal, vertical = '\u250c', '\u252c', '\u2510', '\u2500', '\u2502'
+ bottomLeft, bottomJunction, bottomRight = '\u2514', '\u2534', '\u2518'
+ }
+ separator := string(left)
+ for reel := 0; reel < 5; reel++ {
+ if reel > 0 {
+ separator += string(junction)
+ }
+ separator += strings.Repeat(string(horizontal), 9)
+ }
+ separator += string(right)
+ lines := []string{casinoFrame(mode, separator)}
+ frameVertical := casinoFrame(mode, string(vertical))
+ for row := 0; row < 3; row++ {
+ line := frameVertical
+ for reel := 0; reel < 5; reel++ {
+ value := "..."
+ if reel < snapshot.StoppedReels {
+ value = snapshot.Grid[row][reel]
+ }
+ // Pad to the fixed 7-cell reel width; a malformed profile could
+ // otherwise make this negative and panic. Validation caps the id
+ // length, so this is a defence-in-depth backstop.
+ pad := 7 - len(value)
+ if pad < 0 {
+ pad = 0
+ }
+ line += " " + colorSlotSymbol(mode, value) + strings.Repeat(" ", pad) + " " + frameVertical
+ }
+ lines = append(lines, line)
+ }
+ bottom := string(bottomLeft)
+ for reel := 0; reel < 5; reel++ {
+ if reel > 0 {
+ bottom += string(bottomJunction)
+ }
+ bottom += strings.Repeat(string(horizontal), 9)
+ }
+ bottom += string(bottomRight)
+ lines = append(lines, casinoFrame(mode, bottom))
+ return strings.Join(lines, "\n")
+}
+
+func casinoFrame(mode, text string) string {
+ spec := color.NoColor()
+ spec.Fg = 244
+ return color.Render(mode, spec, text)
+}
+
+func colorSlotSymbol(mode, symbol string) string {
+ spec := color.NoColor()
+ spec.Fg = 250
+ switch symbol {
+ case "straw":
+ spec.Fg = 220
+ case "stick":
+ spec.Fg = 46
+ case "brick":
+ spec.Fg = 196
+ case "hat":
+ spec.Fg = 201
+ case "wolf":
+ spec.Fg = 39
+ case "scatter":
+ spec.Fg = 226
+ default:
+ spec.Dim = true
+ }
+ return color.Render(mode, spec, symbol)
+}
diff --git a/internal/game/sys_combat.go b/internal/game/sys_combat.go
index af8cc75..4ccce64 100644
--- a/internal/game/sys_combat.go
+++ b/internal/game/sys_combat.go
@@ -145,6 +145,15 @@ func (g *Game) checkAggro(sess *net.Session) {
if g.Combat.IsMobInCombat(aggMob.InstanceID) {
return false
}
+ // The retry outlives the immediate roll: a player can god or enter a
+ // safespot after the first miss, so re-check both gates on every
+ // retry before rolling again.
+ if p.GodMode {
+ return false
+ }
+ if g.isSafespotted(p.Name) && g.safespotBlocksMob(p, aggMob) {
+ return false
+ }
if rollAggroRand() >= aggroChancePerTick {
return true
}
diff --git a/internal/game/sys_combat_test.go b/internal/game/sys_combat_test.go
index dcd8e5e..090ec5f 100644
--- a/internal/game/sys_combat_test.go
+++ b/internal/game/sys_combat_test.go
@@ -27,7 +27,7 @@ func patchAggroRand(val float64) func() {
func TestAggroInlineRollFiresOnArrival(t *testing.T) {
defer patchAggroRand(0.0)()
- g := escapeTestGame()
+ g := escapeTestGame(t)
spawnTestMob(g, 100)
p := player.New("agro_fire")
p.RoomID = 100
@@ -44,7 +44,7 @@ func TestAggroPerTickRetrySubscriber(t *testing.T) {
t.Run("miss_then_retry_fires", func(t *testing.T) {
defer patchAggroRand(0.5)()
- g := escapeTestGame()
+ g := escapeTestGame(t)
spawnTestMob(g, 200)
p := player.New("agro_retry")
p.RoomID = 200
@@ -67,7 +67,7 @@ func TestAggroPerTickRetrySubscriber(t *testing.T) {
t.Run("miss_then_ineligible_unsubscribes", func(t *testing.T) {
defer patchAggroRand(0.5)()
- g := escapeTestGame()
+ g := escapeTestGame(t)
spawnTestMob(g, 300)
p := player.New("agro_leave")
p.RoomID = 300
diff --git a/internal/game/sys_escape_test.go b/internal/game/sys_escape_test.go
index fd4c9ab..aff37ae 100644
--- a/internal/game/sys_escape_test.go
+++ b/internal/game/sys_escape_test.go
@@ -1,7 +1,6 @@
package game
import (
- "os"
"testing"
"time"
@@ -29,11 +28,8 @@ func (c *escapeTestConn) SetReadDeadline(time.Time) error { return nil }
func (c *escapeTestConn) Close() error { return nil }
func (c *escapeTestConn) SetEcho(bool) error { return nil }
-func escapeTestGame() *Game {
- tmpDir, err := os.MkdirTemp("", "escape_test")
- if err != nil {
- panic(err)
- }
+func escapeTestGame(t *testing.T) *Game {
+ t.Helper()
dataDir := "../../data"
return &Game{
Combat: combat.NewTracker(),
@@ -44,7 +40,7 @@ func escapeTestGame() *Game {
World: world.New(dataDir),
MobStore: world.NewMobStore(dataDir),
ItemStore: item.NewItemStore(dataDir),
- AccountStore: player.NewAccountStore(tmpDir),
+ AccountStore: player.NewAccountStore(t.TempDir()),
Ticks: engine.New(),
},
}
@@ -84,7 +80,7 @@ func TestClearMoveStateClearsEscape(t *testing.T) {
}
func TestBeginEscapeMoveWithEnergy(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("run")
p.EscapeDir = "north"
p.EscapeTarget = 10
@@ -108,7 +104,7 @@ func TestBeginEscapeMoveWithEnergy(t *testing.T) {
}
func TestBeginEscapeMoveWithoutEnergy(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("walk")
p.RunEnergy = 0
p.EscapeDir = "east"
@@ -126,7 +122,7 @@ func TestBeginEscapeMoveWithoutEnergy(t *testing.T) {
}
func TestBeginEscapeMoveWithCapeOfAgility(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("cape")
p.Equipment[item.SlotBack] = capeOfAgilityID
@@ -140,7 +136,7 @@ func TestBeginEscapeMoveWithCapeOfAgility(t *testing.T) {
}
func TestApplyMobHitCancelsEscape(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("ouch")
p.EscapeDir = "south"
p.EscapeTarget = 3
@@ -172,7 +168,7 @@ func TestApplyMobHitCancelsEscape(t *testing.T) {
}
func TestApplyMobMissFiresEscape(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("lucky")
p.EscapeDir = "east"
p.EscapeTarget = 8
@@ -217,7 +213,7 @@ func TestEscapeReaimSilently(t *testing.T) {
}
func TestDoAttackAbortsFlee(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("attacker")
p.EscapeDir = "west"
p.EscapeTarget = 4
@@ -247,7 +243,7 @@ func TestEscapeClearedOnStop(t *testing.T) {
// TestPlayerActionDisplayNoPreparingToFlee verifies the
// "preparing to flee" display branch has been removed.
func TestPlayerActionDisplayNoPreparingToFlee(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("fleer")
p.EscapeDir = "north"
@@ -261,7 +257,7 @@ func TestPlayerActionDisplayNoPreparingToFlee(t *testing.T) {
// consecutive escape-canceling mob hit in the same combat triggers the flee
// as if the mob had missed, instead of cancelling.
func TestEscapeReliefFiresOnThirdHit(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("relief")
p.HP = 100
p.EscapeDir = "north"
@@ -298,7 +294,7 @@ func TestEscapeReliefFiresOnThirdHit(t *testing.T) {
// TestEscapeReliefDoesNotFireBeforeThirdHit verifies the relief waits until
// the 3rd hit; the 1st and 2nd should still cancel normally.
func TestEscapeReliefDoesNotFireBeforeThirdHit(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("patient")
p.HP = 100
p.EscapeDir = "north"
@@ -343,7 +339,7 @@ func TestEscapeReliefDoesNotFireBeforeThirdHit(t *testing.T) {
// after a successful flee path (miss) — so the next flee attempt in the same
// combat starts fresh at 0.
func TestEscapeFailCountResetsOnSuccessfulMissFlee(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("reclucky")
p.HP = 100
@@ -382,7 +378,7 @@ func TestEscapeFailCountResetsOnSuccessfulMissFlee(t *testing.T) {
// TestEscapeFailCountResetsOnCombatEntry verifies the counter resets when a
// new combat starts (startCombat), so a fresh engagement begins at 0.
func TestEscapeFailCountResetsOnCombatEntry(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("reset")
p.HP = 100
p.EscapeFailCount = 7 // carryover from prior engagement
@@ -404,7 +400,7 @@ func TestEscapeFailCountResetsOnCombatEntry(t *testing.T) {
// TestEscapeReliefSuppressedOnKillingBlow verifies that when the 3rd hit
// would kill the player, the relief is suppressed.
func TestEscapeReliefSuppressedOnKillingBlow(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("doomed")
p.HP = 3 // exactly enough to absorb three maxHit==1 (deterministic) hits
@@ -439,7 +435,7 @@ func TestEscapeReliefSuppressedOnKillingBlow(t *testing.T) {
}
func TestEndCombatFiresEscapeOnMobDeath(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("finisher")
p.EscapeDir = "east"
p.EscapeTarget = 10
@@ -466,7 +462,7 @@ func TestEndCombatFiresEscapeOnMobDeath(t *testing.T) {
}
func TestEndCombatDoesNotFireEscapeIfMobAlive(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("patient")
p.EscapeDir = "east"
p.EscapeTarget = 10
diff --git a/internal/game/sys_safespot.go b/internal/game/sys_safespot.go
index 539f69d..ce99610 100644
--- a/internal/game/sys_safespot.go
+++ b/internal/game/sys_safespot.go
@@ -104,6 +104,18 @@ func (g *Game) activateSafespot(sess *net.Session, p *player.Player, ss *Safespo
return
}
+ // Two players can start hiding in the same tick and both pass the cap check
+ // at hide time (the occupant is only appended here, when the countdown
+ // completes). Re-check the cap now so a max_occupants cover never ends with
+ // more occupants than it allows.
+ if cfg.MaxOccupants > 0 && len(st.SafespotOccupants) >= cfg.MaxOccupants {
+ g.safespot.Delete(p.Name)
+ sess.WriteLine("")
+ sess.WriteLine(fmt.Sprintf("%s already hiding there!",
+ joinSafespotOccupants(st.SafespotOccupants)))
+ return
+ }
+
st.SafespotOccupants = append(st.SafespotOccupants, p.Name)
var msg string
@@ -143,11 +155,22 @@ func (g *Game) safespotMaintenance(sess *net.Session, p *player.Player, ss *Safe
return
}
+ // Decay accumulation and the decay roll are per-object, not per-occupant:
+ // SafespotTick calls this once per occupant, so N players on one cover
+ // would otherwise advance the counter N times and roll decay N times per
+ // tick. The first occupant does the object-level work; the per-player
+ // UnsafeChance slip below stays per occupant.
+ isFirstOccupant := st.SafespotOccupants[0] == p.Name
+
if cfg.UnsafeChance > 0 && rand.Float64() < cfg.UnsafeChance {
g.forceLeaveSafespot(sess, p, ss, "You slip and are no longer covered!")
return
}
+ if !isFirstOccupant {
+ return
+ }
+
levelIdx := st.SafespotLevel - 1
degraded := false
diff --git a/internal/game/sys_science.go b/internal/game/sys_science.go
index 58e2db0..b28fc5e 100644
--- a/internal/game/sys_science.go
+++ b/internal/game/sys_science.go
@@ -47,7 +47,7 @@ func (g *Game) LoadMods() error {
if err := behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error {
var m ModDef
if err := yaml.Unmarshal(data, &m); err != nil {
- return nil
+ return fmt.Errorf("%s: %w", path, err)
}
m.ID = id
AllMods = append(AllMods, &m)
@@ -249,7 +249,6 @@ func (g *Game) triggerModReward(sess *net.Session, p *player.Player, mod *ModDef
func (g *Game) junkCostString(p *player.Player, mod *ModDef) string {
cost := g.effectiveJunkCost(p, mod)
- delete(cost, "scrap")
if len(cost) == 0 {
return "free"
}
diff --git a/internal/game/sys_technology.go b/internal/game/sys_technology.go
index 6f03276..edf0051 100644
--- a/internal/game/sys_technology.go
+++ b/internal/game/sys_technology.go
@@ -46,7 +46,7 @@ func (g *Game) LoadTechs() error {
if err := behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error {
var t TechDef
if err := yaml.Unmarshal(data, &t); err != nil {
- return nil
+ return fmt.Errorf("%s: %w", path, err)
}
t.ID = id
AllTechs = append(AllTechs, &t)
diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go
index 790eb12..6ae5e21 100644
--- a/internal/game/sys_triggers.go
+++ b/internal/game/sys_triggers.go
@@ -33,6 +33,7 @@ type sequence struct {
key string // dedup key
locked bool // atomic + resumable on disconnect
roomLocked bool // skip steps whose player has left roomID
+ dirty bool // a step advanced since the last persisted snapshot
}
// verbSeqKey builds a dedup key for a verb-triggered sequence so two of the
@@ -144,6 +145,7 @@ func (g *Game) advanceSequence(seq *sequence) {
seq.wait--
if seq.wait <= 0 && len(seq.steps) > 0 {
seq.steps = seq.steps[1:]
+ seq.dirty = true
if len(seq.steps) > 0 {
seq.wait = seq.steps[0].Wait
}
@@ -160,6 +162,7 @@ func (g *Game) advanceSequence(seq *sequence) {
for seq.wait <= 0 && len(seq.steps) > 0 {
step := seq.steps[0]
seq.steps = seq.steps[1:]
+ seq.dirty = true
jobs = append(jobs, step)
if len(seq.steps) > 0 {
seq.wait = seq.steps[0].Wait
@@ -233,6 +236,10 @@ func (g *Game) syncPendingSequence(seq *sequence, p *player.Player) {
if len(seq.steps) == 0 {
return
}
+ if !seq.dirty {
+ return
+ }
+ seq.dirty = false
p.PendingSequence.Steps = append([]behavior.Step(nil), seq.steps...)
p.PendingSequence.Wait = seq.wait
g.saveCharacter(p)
@@ -301,6 +308,15 @@ func (g *Game) cancelSequences(name string) {
delete(g.sequences, k)
}
g.seqMu.Unlock()
+ // Clearing the live sequences is not enough: the persisted marker would
+ // otherwise re-arm the cancelled steps on the next login.
+ g.charsMu.Lock()
+ sess := g.loggedInChars[name]
+ g.charsMu.Unlock()
+ if sess != nil && sess.Player != nil && sess.Player.PendingSequence != nil {
+ sess.Player.PendingSequence = nil
+ g.saveCharacter(sess.Player)
+ }
}
// abortAllSequences drops every in-flight sequence (player and global) and
@@ -360,7 +376,8 @@ func (g *Game) resumePendingSequence(sess *net.Session, p *player.Player) {
return
}
ps := p.PendingSequence
- p.PendingSequence = nil
+ // Keep the marker: completePlayerSequence clears it when the matching key
+ // finishes, so a crash mid-resume still has a durable snapshot to replay.
seq := &sequence{
sess: sess,
player: p,
@@ -400,11 +417,12 @@ func (g *Game) TransientMobTick() {
g.charsMu.Lock()
ownerSess := g.loggedInChars[inst.Owner]
g.charsMu.Unlock()
- if ownerSess == nil || ownerSess.Player == nil {
- continue
+ // An offline owner counts as out of the allowed rooms so the despawn
+ // grace still runs; otherwise the mob persists forever after logout.
+ ownerRoom := -1
+ if ownerSess != nil && ownerSess.Player != nil {
+ ownerRoom = ownerSess.Player.RoomID
}
-
- ownerRoom := ownerSess.Player.RoomID
allowed := inst.DespawnRooms
if len(allowed) == 0 {
allowed = []int{inst.HomeRoomID}
diff --git a/internal/game/video_poker_cabinet_test.go b/internal/game/video_poker_cabinet_test.go
index 0e46c82..df45365 100644
--- a/internal/game/video_poker_cabinet_test.go
+++ b/internal/game/video_poker_cabinet_test.go
@@ -143,7 +143,11 @@ func TestVestaVideoPokerPayoutTiers(t *testing.T) {
func TestPokerInterruptedTransactionRecovery(t *testing.T) {
g, sess, _ := casinoCrapsGame(t, uxTestRoom)
- g.DataDir = t.TempDir()
+ // Align the journal root with the account store's data root: Recover
+ // refuses op paths that escape the journal's parent directory, and the
+ // harness otherwise leaves g.DataDir (../../data) different from the
+ // temp dir the stores were built with.
+ g.DataDir = filepath.Dir(filepath.Dir(g.AccountStore.CharPath("alice")))
w := casinoWallet{g: g, p: sess.Player}
stake, err := w.Wager(10)
if err != nil {
diff --git a/internal/game/wallet_refund_kind_test.go b/internal/game/wallet_refund_kind_test.go
new file mode 100644
index 0000000..370c286
--- /dev/null
+++ b/internal/game/wallet_refund_kind_test.go
@@ -0,0 +1,40 @@
+package game
+
+import "testing"
+
+// RefundStakes must return chips as chips. PayCredits here used to convert the
+// casino-only chip stake into spendable credits (an economy exploit via unbet).
+func TestRefundStakesReturnsChipsInKind(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}
+ if !h.g.giveChips(p, 30) {
+ t.Error("could not seed chips")
+ return
+ }
+ wager, err := w.Wager(30) // spends chips first, 0 credits
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if wager.Chips != 30 || wager.Credits != 0 {
+ t.Errorf("wager = %+v, want 30 chips / 0 credits", wager)
+ }
+ if err := w.RefundStakes(wager.ID); err != nil {
+ t.Error(err)
+ }
+ })
+ h.barrier()
+ if p.Credits != 100 {
+ t.Fatalf("credits changed to %d, want 100 (chips must return in kind)", p.Credits)
+ }
+ if got := countChips(p); got != 30 {
+ t.Fatalf("chips after refund = %d, want 30", got)
+ }
+ if len(p.PendingWagers) != 0 {
+ t.Fatalf("pending stake not cleared: %+v", p.PendingWagers)
+ }
+}
diff --git a/internal/game/wallet_test.go b/internal/game/wallet_test.go
index 56789d8..dba8a5f 100644
--- a/internal/game/wallet_test.go
+++ b/internal/game/wallet_test.go
@@ -49,6 +49,7 @@ func harnessFromDir(t *testing.T, dir string) *harness {
}
h.stop = func() { close(stopTick); <-done }
t.Cleanup(h.stop)
+ t.Cleanup(g.stopOwner)
return h
}
diff --git a/internal/item/store.go b/internal/item/store.go
index 12aa729..0ced57b 100644
--- a/internal/item/store.go
+++ b/internal/item/store.go
@@ -109,6 +109,7 @@ func (s *ItemStore) IDSet() map[string]bool {
func (s *ItemStore) Reload(dataDir string) {
s.mu.Lock()
defer s.mu.Unlock()
+ s.dataDir = dataDir
s.cache = make(map[string]*ItemDef)
s.pathIndex = behavior.BuildPathIndex(filepath.Join(dataDir, "items"))
}
diff --git a/internal/net/conn.go b/internal/net/conn.go
index d8c867e..df473f5 100644
--- a/internal/net/conn.go
+++ b/internal/net/conn.go
@@ -196,7 +196,19 @@ func stripIAC(s string) string {
i += end + 4
continue
}
- i += 3
+ if b[i+1] == iac {
+ // IAC IAC is an escaped data byte, not a command.
+ out = append(out, iac)
+ i += 2
+ continue
+ }
+ // WILL/WONT/DO/DONT carry an option byte; every other IAC command is
+ // the two-byte form and must not eat the following input byte.
+ if b[i+1] == will || b[i+1] == wont || b[i+1] == do || b[i+1] == dont {
+ i += 3
+ } else {
+ i += 2
+ }
if i > len(b) {
i = len(b)
}
diff --git a/internal/net/iac_test.go b/internal/net/iac_test.go
index 71570db..6f91f19 100644
--- a/internal/net/iac_test.go
+++ b/internal/net/iac_test.go
@@ -15,3 +15,18 @@ func TestStripIACDropsTruncatedSequence(t *testing.T) {
t.Fatalf("stripIAC(subnegotiation) = %q, want xy", got)
}
}
+
+func TestStripIACPreservesInputAfterTwoByteCommand(t *testing.T) {
+ // A 2-byte command (NOP) in the middle must not eat the following byte.
+ if got := stripIAC("a" + string([]byte{iac, 0xf1}) + "b"); got != "ab" {
+ t.Fatalf("stripIAC(mid two-byte command) = %q, want %q", got, "ab")
+ }
+ // A 3-byte negotiation command consumes its option byte.
+ if got := stripIAC("a" + string([]byte{iac, will, echo}) + "b"); got != "ab" {
+ t.Fatalf("stripIAC(three-byte command) = %q, want %q", got, "ab")
+ }
+ // IAC IAC is an escaped literal 0xFF, not a command.
+ if got := stripIAC("a" + string([]byte{iac, iac}) + "b"); got != "a"+string([]byte{iac})+"b" {
+ t.Fatalf("stripIAC(escaped IAC) = %q, want literal 0xFF", got)
+ }
+}
diff --git a/internal/net/server.go b/internal/net/server.go
index 0854426..d1a0492 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -570,7 +570,14 @@ func (h *Hub) RekeyRooms(idMap map[int]int) {
moved := make(map[int]map[*Session]bool, len(h.rooms))
for id, set := range h.rooms {
if newID, ok := idMap[id]; ok && newID != id {
- moved[newID] = set
+ if moved[newID] == nil {
+ moved[newID] = make(map[*Session]bool)
+ }
+ // Merge rather than assign: a many-to-one idMap (A->C, B->C)
+ // would otherwise clobber the first source bucket's sessions.
+ for s := range set {
+ moved[newID][s] = true
+ }
delete(h.rooms, id)
}
}
diff --git a/internal/net/web.go b/internal/net/web.go
index 0ba0c98..d21fb0c 100644
--- a/internal/net/web.go
+++ b/internal/net/web.go
@@ -34,10 +34,6 @@ var upgrader = websocket.Upgrader{
strings.EqualFold(origin, "https://"+hostname) {
return true
}
- if hn, _, err := net.SplitHostPort(r.RemoteAddr); err == nil &&
- (hn == "127.0.0.1" || hn == "::1") {
- return true
- }
log.Printf("ws rejected origin: %s (host: %s)", origin, r.Host)
return false
},
diff --git a/internal/net/web_test.go b/internal/net/web_test.go
index d16b31a..20bf7c8 100644
--- a/internal/net/web_test.go
+++ b/internal/net/web_test.go
@@ -10,8 +10,9 @@ import (
)
// The browser transport previously had no tests. CheckOrigin is the security
-// boundary for the /ws upgrade; test it directly with a non-loopback peer so
-// the loopback bypass does not mask a hostile Origin.
+// boundary for the /ws upgrade; test it directly, including a loopback peer:
+// being on the server host must not re-open the cross-site WebSocket hole for
+// a hostile browser.
func TestWebSocketCheckOrigin(t *testing.T) {
cases := []struct {
name string
@@ -25,7 +26,7 @@ func TestWebSocketCheckOrigin(t *testing.T) {
{"same host https", "https://mud.example:4000", "mud.example:4000", "203.0.113.5:1234", true},
{"same hostname no port", "http://mud.example", "mud.example:4000", "203.0.113.5:1234", true},
{"hostile origin", "http://evil.example", "mud.example:4000", "203.0.113.5:1234", false},
- {"loopback bypass", "http://evil.example", "mud.example:4000", "127.0.0.1:1234", true},
+ {"loopback hostile origin", "http://evil.example", "mud.example:4000", "127.0.0.1:1234", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -41,7 +42,8 @@ func TestWebSocketCheckOrigin(t *testing.T) {
}
// The /ws upgrade wires a live session and writer. Drive it end-to-end over a
-// real HTTP test server (loopback, so the origin check passes).
+// real HTTP test server (the dialer sends no Origin header, which CheckOrigin
+// accepts).
func TestWebSocketUpgradeAccepted(t *testing.T) {
s := &Server{hub: NewHub()}
done := make(chan struct{})
diff --git a/internal/object/object.go b/internal/object/object.go
index 376f4be..e0a5b16 100644
--- a/internal/object/object.go
+++ b/internal/object/object.go
@@ -70,8 +70,9 @@ func (d *ObjectDef) BehaviorType() string {
return "gather"
case d.Talk != nil:
return "talk"
- case d.Safespot != nil:
- return "safespot"
}
+ // Safespot (and any other passive/use-driven object) is not an ActionType
+ // verb. Returning a non-ActionType string here would make the verb gate in
+ // startAction reject "use"-style verbs against it.
return ""
}
diff --git a/internal/object/object_test.go b/internal/object/object_test.go
new file mode 100644
index 0000000..251dc61
--- /dev/null
+++ b/internal/object/object_test.go
@@ -0,0 +1,13 @@
+package object
+
+import "testing"
+
+// TestBehaviorTypeSafespotIsNotActionType: BehaviorType must not return
+// "safespot" (it is not a behavior.ActionType), or the generic verb gate
+// treats a safespot-only object as an unknown action type.
+func TestBehaviorTypeSafespotIsNotActionType(t *testing.T) {
+ def := &ObjectDef{Safespot: &SafespotConfig{}}
+ if got := def.BehaviorType(); got != "" {
+ t.Fatalf("BehaviorType(safespot-only) = %q, want empty", got)
+ }
+}
diff --git a/internal/object/store.go b/internal/object/store.go
index ee154cf..e974186 100644
--- a/internal/object/store.go
+++ b/internal/object/store.go
@@ -77,6 +77,7 @@ func (s *ObjectStore) IDSet() map[string]bool {
func (s *ObjectStore) Reload(dataDir string) {
s.mu.Lock()
defer s.mu.Unlock()
+ s.dataDir = dataDir
s.cache = make(map[string]*ObjectDef)
s.pathIndex = behavior.BuildPathIndex(filepath.Join(dataDir, "objects"))
}
diff --git a/internal/persistence/txn.go b/internal/persistence/txn.go
index 1babfc5..df2b2e4 100644
--- a/internal/persistence/txn.go
+++ b/internal/persistence/txn.go
@@ -226,6 +226,9 @@ func (t *Transaction) Delete(path string) error {
// Move relocates a file (rename semantics). If newPath already exists its
// before-image is captured in a separate move-target record for rollback.
func (t *Transaction) Move(oldPath, newPath string) error {
+ if oldPath == newPath {
+ return fmt.Errorf("move source and destination are the same: %s", oldPath)
+ }
src, err := t.getOp(oldPath)
if err != nil {
return err
@@ -240,6 +243,12 @@ func (t *Transaction) Move(oldPath, newPath string) error {
if src.Kind == kindReplace || src.Kind == kindDelete {
return fmt.Errorf("conflicting operation on %s: already replaced or deleted", oldPath)
}
+ // Moving a path that already received a Replace as a move destination
+ // would drop that after-image: the kindMove branch of apply never writes
+ // op.New. Refuse rather than silently lose the write.
+ if src.Kind == kindMoveTarget && src.NewExists {
+ return fmt.Errorf("conflicting operation on %s: already replaced as a move destination", oldPath)
+ }
if dst.Kind != kindMoveTarget && dst.Kind != "" {
return fmt.Errorf("conflicting operation on %s", newPath)
}
@@ -366,6 +375,8 @@ func (t *Transaction) apply() error {
return fmt.Errorf("write %s: %w", op.Path, err)
}
}
+ default:
+ return fmt.Errorf("unknown op kind %q for %s", op.Kind, op.Path)
}
}
return nil
@@ -449,9 +460,16 @@ func Recover(journalDir string) error {
if entry.Version != 0 && entry.Version != journalVersion {
return fmt.Errorf("journal %s: unsupported version %d", path, entry.Version)
}
+ root := filepath.Dir(journalDir)
t := &Transaction{ops: make(map[string]*fileOp), journalDir: journalDir}
for i := range entry.Ops {
op := entry.Ops[i]
+ if !withinDir(root, op.Path) {
+ return fmt.Errorf("recover %s: op path %q escapes data root %s", path, op.Path, root)
+ }
+ if op.MoveTo != "" && !withinDir(root, op.MoveTo) {
+ return fmt.Errorf("recover %s: move target %q escapes data root %s", path, op.MoveTo, root)
+ }
t.ops[op.Path] = &op
t.order = append(t.order, op.Path)
}
@@ -475,6 +493,22 @@ func hasPrefix(s, prefix string) bool {
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
}
+// withinDir reports whether path is lexically contained in root. Journal
+// recovery replays before-images at the paths recorded in the journal, so a
+// journal whose ops escape the data root (hand-edited on disk, or written by
+// a future/attacker version) must be refused instead of allowed to write or
+// delete arbitrary files as the service user.
+func withinDir(root, path string) bool {
+ if path == "" {
+ return false
+ }
+ rel, err := filepath.Rel(root, path)
+ if err != nil || filepath.IsAbs(rel) {
+ return false
+ }
+ return rel != ".." && !hasPrefix(rel, ".."+string(filepath.Separator))
+}
+
// ListJournalFiles is a debugging helper: returns sorted journal file names.
func ListJournalFiles(journalDir string) ([]string, error) {
entries, err := os.ReadDir(journalDir)
diff --git a/internal/persistence/txn_containment_test.go b/internal/persistence/txn_containment_test.go
new file mode 100644
index 0000000..cfcd4c2
--- /dev/null
+++ b/internal/persistence/txn_containment_test.go
@@ -0,0 +1,79 @@
+package persistence
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestTxnMoveRejectsSamePath(t *testing.T) {
+ dir := t.TempDir()
+ txn := NewTransaction(filepath.Join(dir, ".journal"))
+ p := filepath.Join(dir, "a.yaml")
+ if err := txn.Move(p, p); err == nil {
+ t.Fatal("expected Move(path, path) to be rejected")
+ }
+}
+
+// A path that already received a Replace as a move destination carries an
+// after-image in its move-target record; a later Move would silently drop it.
+func TestTxnMoveRejectsReplacedMoveDestination(t *testing.T) {
+ dir := t.TempDir()
+ a := filepath.Join(dir, "a.yaml")
+ b := filepath.Join(dir, "b.yaml")
+ writeTestFile(t, a, "A")
+ writeTestFile(t, b, "B")
+ txn := NewTransaction(filepath.Join(dir, ".journal"))
+ if err := txn.Move(a, b); err != nil {
+ t.Fatal(err)
+ }
+ if err := txn.Replace(b, []byte("X"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := txn.Move(b, filepath.Join(dir, "c.yaml")); err == nil {
+ t.Fatal("expected Move over a replaced move destination to be rejected")
+ }
+}
+
+// A journal on disk is untrusted input: recovery must not replay a before-image
+// at a path outside the journal's data root.
+func TestRecoverRejectsEscapingOpPath(t *testing.T) {
+ dir := t.TempDir()
+ outside := t.TempDir()
+ journalDir := filepath.Join(dir, ".journal")
+ if err := os.MkdirAll(journalDir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ escape := filepath.Join(outside, "secret.yaml")
+ body := `{"version":1,"ops":[{"path":"` + escape + `","kind":"delete","old_exists":false}]}`
+ evil := filepath.Join(journalDir, "txn-evil.json")
+ if err := os.WriteFile(evil, []byte(body), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := Recover(journalDir); err == nil {
+ t.Fatal("expected Recover to reject an escaping op path")
+ }
+ // Fail loud and keep the journal rather than silently touching the path.
+ if _, err := os.Stat(evil); err != nil {
+ t.Fatalf("journal should be retained on rejection: %v", err)
+ }
+ if _, err := os.Stat(escape); !os.IsNotExist(err) {
+ t.Fatalf("escaping path must not be touched: %v", err)
+ }
+}
+
+func TestRecoverRejectsEscapingMoveTarget(t *testing.T) {
+ dir := t.TempDir()
+ outside := t.TempDir()
+ journalDir := filepath.Join(dir, ".journal")
+ if err := os.MkdirAll(journalDir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ body := `{"version":1,"ops":[{"path":"` + filepath.Join(dir, "a.yaml") + `","kind":"move","move_to":"` + filepath.Join(outside, "b.yaml") + `"}]}`
+ if err := os.WriteFile(filepath.Join(journalDir, "txn-evil.json"), []byte(body), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := Recover(journalDir); err == nil {
+ t.Fatal("expected Recover to reject an escaping move target")
+ }
+}
diff --git a/internal/player/player.go b/internal/player/player.go
index 92b017a..f7dd7bd 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -211,7 +211,7 @@ type Player struct {
BackgroundAction *behavior.Action `yaml:"-"`
WalkSequence []string `yaml:"-"`
ConsumeCooldown int `yaml:"-"`
- HomeTransportCooldown int `yaml:"-"`
+ HomeTransportCooldown int `yaml:"home_transport_cooldown,omitempty"`
MoveTicks int `yaml:"-"`
MoveDirection string `yaml:"-"`
MoveTarget int `yaml:"-"`
diff --git a/internal/player/roomset.go b/internal/player/roomset.go
index d7e1b47..2a327d1 100644
--- a/internal/player/roomset.go
+++ b/internal/player/roomset.go
@@ -46,7 +46,7 @@ func (rs *RoomSet) ensure() {
}
func (rs *RoomSet) Add(roomID int) {
- if roomID < 0 {
+ if roomID < 0 || roomID > roomSetMaxID {
return
}
rs.ensure()
diff --git a/internal/player/roomset_test.go b/internal/player/roomset_test.go
index 38bd993..94b1714 100644
--- a/internal/player/roomset_test.go
+++ b/internal/player/roomset_test.go
@@ -143,3 +143,14 @@ func TestRoomSetRejectsOutOfRangeDelta(t *testing.T) {
t.Fatal("expected error for out-of-range delta")
}
}
+
+func TestRoomSetAddRejectsOutOfRange(t *testing.T) {
+ rs := NewRoomSetFrom(roomSetMaxID)
+ if !rs.Has(roomSetMaxID) {
+ t.Fatal("roomSetMaxID itself should be accepted")
+ }
+ rs.Add(roomSetMaxID + 1)
+ if rs.Has(roomSetMaxID + 1) {
+ t.Fatal("out-of-range room ID was accepted")
+ }
+}
diff --git a/internal/player/store.go b/internal/player/store.go
index 5d69ec3..a0e2849 100644
--- a/internal/player/store.go
+++ b/internal/player/store.go
@@ -33,6 +33,7 @@ func (s *AccountStore) LoadAccount(name string) (*Account, error) {
if err := yaml.Unmarshal(data, &acc); err != nil {
return nil, fmt.Errorf("parse account: %w", err)
}
+ acc.Name = name
return &acc, nil
}
@@ -124,6 +125,7 @@ func (s *AccountStore) LoadCharacter(name string) (*Player, error) {
if err := yaml.Unmarshal(data, &p); err != nil {
return nil, fmt.Errorf("parse character: %w", err)
}
+ p.Name = name
if p.Skills == nil {
p.Skills = make(map[SkillName]int)
}
diff --git a/internal/player/store_test.go b/internal/player/store_test.go
index 593e9d1..2987d09 100644
--- a/internal/player/store_test.go
+++ b/internal/player/store_test.go
@@ -79,3 +79,31 @@ func TestAccountStoreIgnoresNonYAMLFiles(t *testing.T) {
}
}
}
+
+func TestLoadCharacterReconcilesNameToStem(t *testing.T) {
+ s := testAccountStore(t)
+ if err := os.WriteFile(s.CharPath("alice"), []byte("name: WRONG\nroom_id: 1\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ p, err := s.LoadCharacter("alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if p.Name != "alice" {
+ t.Fatalf("LoadCharacter reconciled name to %q, want %q", p.Name, "alice")
+ }
+}
+
+func TestLoadAccountReconcilesNameToStem(t *testing.T) {
+ s := testAccountStore(t)
+ if err := os.WriteFile(s.AccountPath("alice"), []byte("name: WRONG\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ acc, err := s.LoadAccount("alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if acc.Name != "alice" {
+ t.Fatalf("LoadAccount reconciled name to %q, want %q", acc.Name, "alice")
+ }
+}
diff --git a/internal/player/validate.go b/internal/player/validate.go
index 015b3d8..3a06985 100644
--- a/internal/player/validate.go
+++ b/internal/player/validate.go
@@ -16,6 +16,9 @@ func ValidName(name string) error {
}
func ValidPassword(pw string) error {
+ if len(pw) < 8 {
+ return fmt.Errorf("password must be at least 8 characters")
+ }
if len(pw) > 128 {
return fmt.Errorf("password too long")
}
diff --git a/internal/ui/table.go b/internal/ui/table.go
index 437c445..a1800ba 100644
--- a/internal/ui/table.go
+++ b/internal/ui/table.go
@@ -45,6 +45,17 @@ func FitWidths(colWidths []int, budget int) {
rebalanceWidths(colWidths, budget)
}
+// TableBudget returns the total cell width available to nCols columns in a
+// table rendered at maxWidth. Callers that pre-wrap cells must use this so
+// their shrink target matches Render's padding/separator arithmetic.
+func TableBudget(nCols, maxWidth int) int {
+ budget := maxWidth - (3*nCols + 1)
+ if budget < nCols {
+ budget = nCols
+ }
+ return budget
+}
+
// rebalanceWidths shrinks column widths in place so their sum fits budget.
// Rather than crushing only the last column, every column wider than the
// fair share (budget/nCols) is capped first, then columns are trimmed one
@@ -52,6 +63,9 @@ func FitWidths(colWidths []int, budget int) {
// handed back round-robin to columns that lost width so wide layouts still
// use the full line.
func rebalanceWidths(colWidths []int, budget int) {
+ if len(colWidths) == 0 {
+ return
+ }
original := make([]int, len(colWidths))
copy(original, colWidths)
@@ -138,13 +152,7 @@ func (t *Table) Render(unicode bool, maxWidth int) []string {
bodyRows := t.Rows
if maxWidth > 0 {
- // Cell padding: one space on each side of every cell, plus one
- // separator between columns.
- overhead := 3*nCols + 1
- budget := maxWidth - overhead
- if budget < nCols {
- budget = nCols
- }
+ budget := TableBudget(nCols, maxWidth)
total := 0
for _, w := range colWidths {
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index c636b4f..a152956 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -816,6 +816,17 @@ func validateObjects(s Source) []Issue {
return issues
}
+var validEquipSlots = map[item.EquipSlot]bool{
+ item.SlotHead: true, item.SlotNeck: true, item.SlotTorso: true, item.SlotLegs: true,
+ item.SlotHands: true, item.SlotFeet: true, item.SlotBack: true, item.SlotAmmo: true,
+ item.SlotMainHand: true, item.SlotOffHand: true, item.SlotRing: true,
+}
+
+var validEquipTypes = map[item.EquipmentType]bool{
+ item.EquipMeleeWeapon: true, item.EquipRangedWeapon: true, item.EquipScienceWeapon: true,
+ item.EquipArmor: true, item.EquipTool: true, item.EquipAmmo: true,
+}
+
func validateItems(s Source) []Issue {
var issues []Issue
itemIDs := s.Items.IDSet()
@@ -842,6 +853,27 @@ func validateItems(s Source) []Issue {
})
}
+ // Equipment fields are free-form YAML; a typo'd slot/type/attack_type
+ // equips into an invisible slot or grants bonuses from nowhere.
+ if def.Equipment != nil {
+ if def.Equipment.Type != "" && !validEquipTypes[def.Equipment.Type] {
+ issues = append(issues, Issue{Level: "ERROR", Type: "config",
+ Message: fmt.Sprintf("Item %q: unknown equipment.type %q", id, def.Equipment.Type)})
+ }
+ if def.Equipment.Slot != "" && !validEquipSlots[def.Equipment.Slot] {
+ issues = append(issues, Issue{Level: "ERROR", Type: "config",
+ Message: fmt.Sprintf("Item %q: unknown equipment.slot %q", id, def.Equipment.Slot)})
+ }
+ if def.Equipment.AttackType != "" && !combat.IsValidAttackType(def.Equipment.AttackType) {
+ issues = append(issues, Issue{Level: "ERROR", Type: "config",
+ Message: fmt.Sprintf("Item %q: invalid equipment.attack_type %q", id, def.Equipment.AttackType)})
+ }
+ }
+ if def.HouseEdgePrecision != nil && *def.HouseEdgePrecision != 0 && *def.HouseEdgePrecision != 2 {
+ issues = append(issues, Issue{Level: "ERROR", Type: "config",
+ Message: fmt.Sprintf("Item %q: house_edge_precision must be 0 or 2, got %d", id, *def.HouseEdgePrecision)})
+ }
+
if len(def.Search) > 0 {
issues = append(issues, validateTriggerBlock(
fmt.Sprintf("Item %q: search", id), def.Search, itemIDs, roomIndex, mobIDs, dropIDs)...)
@@ -911,6 +943,16 @@ func validateCraftBlocks(s Source) []Issue {
craftLabel = c.Type + "/" + c.Subtype
}
+ // An empty ingredient list is unsatisfiable: planIngredients returns
+ // nil, so the craft can never produce output.
+ if len(c.Ingredients) == 0 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Item %q (craft: %s): has no ingredients; the recipe can never be satisfied", item.ID, craftLabel),
+ })
+ }
+
// The runtime selects the action by subtype (or type when no subtype
// is set), defaulting to combine. An unknown key leaves the action
// set but never advancing, so the player appears stuck.
@@ -1106,6 +1148,13 @@ func validateDropTables(s Source) []Issue {
}
for i, d := range dt.Drops {
+ if d.Weight <= 0 {
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "integrity",
+ Message: fmt.Sprintf("Drop table %q entry %d: weight %d is never selectable", id, i+1, d.Weight),
+ })
+ }
if d.ItemID != "" && !itemIDs[d.ItemID] {
issues = append(issues, Issue{
Level: "ERROR",
@@ -1314,6 +1363,21 @@ func validateFlagTriggerFiles(s Source) []Issue {
if t.Condition != nil {
issues = append(issues, validateCondition(rel, t.Condition, roomIndex, itemIDs)...)
}
+ // Flag triggers fire only on Condition/Steps/Lock; item_id is dead here.
+ if t.ItemID != "" {
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "trigger",
+ Message: fmt.Sprintf("trigger %s: item_id %q is ignored for flag triggers", rel, t.ItemID),
+ })
+ if !itemIDs[t.ItemID] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("trigger %s: item_id %q does not exist", rel, t.ItemID),
+ })
+ }
+ }
for si := range t.Steps {
issues = append(issues, validateStep(fmt.Sprintf("%s: step[%d]", rel, si), &t.Steps[si], itemIDs, roomIndex, mobIDs, dropIDs)...)
}
diff --git a/internal/validate/grid_test.go b/internal/validate/grid_test.go
index 8bb8950..1b1755d 100644
--- a/internal/validate/grid_test.go
+++ b/internal/validate/grid_test.go
@@ -111,7 +111,7 @@ func TestGrid3DCleanViaUpDown(t *testing.T) {
2: "exits:\n east: {room: 3}\n down: {room: 1}\n",
3: "exits:\n down: {room: 4}\n west: {room: 2}\n",
4: "exits:\n south: {room: 5}\n up: {room: 3}\n",
- 5: "name: five\n exits:\n north: {room: 4}\n",
+ 5: "name: five\nexits:\n north: {room: 4}\n",
}
if issues := runGridCheck(t, rooms, 1); len(issues) != 0 {
t.Errorf("expected no grid issues, got: %+v", issues)
diff --git a/internal/world/fix_regressions_test.go b/internal/world/fix_regressions_test.go
index f2ef795..770d2ef 100644
--- a/internal/world/fix_regressions_test.go
+++ b/internal/world/fix_regressions_test.go
@@ -151,3 +151,64 @@ func TestRekeyRoomsRemapsHomeRoomID(t *testing.T) {
t.Fatalf("HomeRoomID after rekey = %d, want 3", moved.HomeRoomID)
}
}
+
+// TestSetObjPropsMatchHomeRoomNotCurrentRoom: content edits pass the declaring
+// room, so property setters must match on HomeRoomID rather than the object's
+// current RoomID (which differs while it has wandered away).
+func TestSetObjPropsMatchHomeRoomNotCurrentRoom(t *testing.T) {
+ w := New(t.TempDir())
+ w.objStates[w.ObjStateKey(2, "anvil", 0)] = &ObjState{
+ RoomID: 2,
+ HomeRoomID: 1,
+ DefID: "anvil",
+ Index: 0,
+ }
+ w.SetObjName(1, "anvil", "iron anvil")
+ w.SetObjAliases(1, "anvil", []string{"workbench"})
+ w.SetObjWander(1, "anvil", []int{2, 3}, 4)
+ w.SetObjDepleteTimer(1, "anvil", 7)
+
+ st := w.GetObjState(2, "anvil", 0)
+ if st.Name != "iron anvil" {
+ t.Fatalf("name not applied: %+v", st)
+ }
+ if len(st.Aliases) != 1 || st.Aliases[0] != "workbench" {
+ t.Fatalf("aliases not applied: %+v", st)
+ }
+ if len(st.WanderRooms) != 2 || st.WanderInterval != 4 {
+ t.Fatalf("wander not applied: %+v", st)
+ }
+ if st.SharedMax != 7 {
+ t.Fatalf("deplete timer not applied: %+v", st)
+ }
+}
+
+// TestTickObjWanderSkipsOccupiedDestination: a wandering instance whose
+// destination key is already occupied must not evict the occupant for the
+// process lifetime.
+func TestTickObjWanderSkipsOccupiedDestination(t *testing.T) {
+ w := New(t.TempDir())
+ w.AddRoomPath(1, "room-1")
+ w.AddRoomPath(2, "room-2")
+ w.objStates[w.ObjStateKey(2, "anvil", 0)] = &ObjState{
+ RoomID: 2,
+ HomeRoomID: 2,
+ DefID: "anvil",
+ Index: 0,
+ }
+ w.objStates[w.ObjStateKey(1, "anvil", 0)] = &ObjState{
+ RoomID: 1,
+ HomeRoomID: 1,
+ DefID: "anvil",
+ Index: 0,
+ WanderRooms: []int{2},
+ WanderInterval: 1,
+ }
+ w.TickObjWander()
+ if w.GetObjState(2, "anvil", 0) == nil {
+ t.Fatal("destination occupant was evicted by a colliding wanderer")
+ }
+ if w.GetObjState(1, "anvil", 0) == nil {
+ t.Fatal("wanderer was lost instead of skipping the occupied destination")
+ }
+}
diff --git a/internal/world/mob.go b/internal/world/mob.go
index 4441108..10759f8 100644
--- a/internal/world/mob.go
+++ b/internal/world/mob.go
@@ -241,6 +241,9 @@ func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []in
if def.Combat != nil {
aggressive = def.Combat.Aggressive
respawnTicks = def.Combat.RespawnTicks
+ if respawnTicks <= 0 {
+ respawnTicks = 30
+ }
attackType = append([]string(nil), def.Combat.AttackTypes...)
cs := def.Combat.Stats
@@ -723,12 +726,20 @@ func (s *MobStore) SeedMobs(roomID int, entries []RoomMob) {
s.mu.Lock()
defer s.mu.Unlock()
- for i, dw := range defs {
+ // Instance keys use a per-defID occurrence counter, not the slice index:
+ // reordering a room's mobs: list must not orphan the old instances and
+ // spawn duplicates. Two entries of the same defID get suffixes 0 and 1.
+ declared := make(map[string]bool, len(entries))
+ occurrence := make(map[string]int)
+ for _, dw := range defs {
+ defID := dw.rm.ID
+ n := occurrence[defID]
+ occurrence[defID]++
+ instID := fmt.Sprintf("%s_%d_%d", defID, roomID, n)
+ declared[instID] = true
if dw.err != nil {
continue
}
- defID := dw.rm.ID
- instID := fmt.Sprintf("%s_%d_%d", defID, roomID, i)
if _, exists := s.instances[instID]; exists {
continue
}
@@ -736,6 +747,15 @@ func (s *MobStore) SeedMobs(roomID int, entries []RoomMob) {
inst.IdleDescription = pickIdleDescription(dw.def.IdleDescriptions)
s.instances[instID] = inst
}
+ // Drop seeded instances this room no longer declares. Trigger-spawned and
+ // wandered-away instances are never pruned here: a declared mob that has
+ // wandered keeps its deterministic key, and transient mobs are owned by
+ // the trigger system.
+ for id, inst := range s.instances {
+ if inst.HomeRoomID == roomID && !inst.SpawnedByTrigger && !declared[id] {
+ delete(s.instances, id)
+ }
+ }
}
// RekeyRooms remaps every live mob instance's RoomID and HomeRoomID per
diff --git a/internal/world/mob_rekey_test.go b/internal/world/mob_rekey_test.go
index 8118059..af4dd5c 100644
--- a/internal/world/mob_rekey_test.go
+++ b/internal/world/mob_rekey_test.go
@@ -51,3 +51,51 @@ func TestRekeyRoomsUpdatesMobDespawnRooms(t *testing.T) {
t.Fatalf("despawn_rooms = %v, want [3 7]", got)
}
}
+
+// TestSeedMobsKeysByDefOccurrenceAndPrunes: reordering a room's mobs list
+// must not orphan old instances (spawning duplicates) and removing an entry
+// must delete its seeded instance. Keys use a per-defID occurrence counter, so
+// the same declaration keeps the same key across reorders.
+func TestSeedMobsKeysByDefOccurrenceAndPrunes(t *testing.T) {
+ s := &MobStore{
+ defs: map[string]*MobDef{
+ "rat": {ID: "rat", Name: "rat"},
+ "bat": {ID: "bat", Name: "bat"},
+ },
+ instances: map[string]*MobInstance{
+ "rat_1_0": {InstanceID: "rat_1_0", DefID: "rat", RoomID: 1, HomeRoomID: 1, HP: 1, MaxHP: 1},
+ "bat_1_0": {InstanceID: "bat_1_0", DefID: "bat", RoomID: 1, HomeRoomID: 1, HP: 1, MaxHP: 1},
+ },
+ }
+ // Reorder and drop rat: bat keeps its key, rat is pruned, no duplicates.
+ s.SeedMobs(1, []RoomMob{{ID: "bat"}})
+ if s.instances["bat_1_0"] == nil {
+ t.Fatal("surviving declared mob was dropped")
+ }
+ if _, ok := s.instances["rat_1_0"]; ok {
+ t.Fatal("removed mob was not pruned")
+ }
+ if len(s.instances) != 1 {
+ t.Fatalf("instances = %v, want only bat_1_0", s.instances)
+ }
+}
+
+func TestSeedMobsReorderDoesNotDuplicate(t *testing.T) {
+ s := &MobStore{
+ defs: map[string]*MobDef{
+ "rat": {ID: "rat", Name: "rat"},
+ "bat": {ID: "bat", Name: "bat"},
+ },
+ instances: map[string]*MobInstance{
+ "rat_1_0": {InstanceID: "rat_1_0", DefID: "rat", RoomID: 1, HomeRoomID: 1, HP: 1, MaxHP: 1},
+ "bat_1_0": {InstanceID: "bat_1_0", DefID: "bat", RoomID: 1, HomeRoomID: 1, HP: 1, MaxHP: 1},
+ },
+ }
+ s.SeedMobs(1, []RoomMob{{ID: "bat"}, {ID: "rat"}})
+ if len(s.instances) != 2 {
+ t.Fatalf("reorder changed instance count: %v", s.instances)
+ }
+ if s.instances["rat_1_0"] == nil || s.instances["bat_1_0"] == nil {
+ t.Fatalf("reorder lost a declared mob: %v", s.instances)
+ }
+}
diff --git a/internal/world/objects.go b/internal/world/objects.go
index 3c004a5..9101fbc 100644
--- a/internal/world/objects.go
+++ b/internal/world/objects.go
@@ -2,6 +2,7 @@ package world
import (
"fmt"
+ "log"
"math/rand"
"sort"
"strings"
@@ -343,7 +344,7 @@ func (w *World) SetObjName(roomID int, defID string, name string) {
w.mu.Lock()
defer w.mu.Unlock()
for _, st := range w.objStates {
- if st.RoomID == roomID && st.DefID == defID {
+ if st.HomeRoomID == roomID && st.DefID == defID {
st.Name = name
}
}
@@ -353,7 +354,7 @@ func (w *World) SetObjAliases(roomID int, defID string, aliases []string) {
w.mu.Lock()
defer w.mu.Unlock()
for _, st := range w.objStates {
- if st.RoomID == roomID && st.DefID == defID {
+ if st.HomeRoomID == roomID && st.DefID == defID {
st.Aliases = aliases
}
}
@@ -363,7 +364,7 @@ func (w *World) SetObjWander(roomID int, defID string, rooms []int, interval flo
w.mu.Lock()
defer w.mu.Unlock()
for _, st := range w.objStates {
- if st.RoomID == roomID && st.DefID == defID {
+ if st.HomeRoomID == roomID && st.DefID == defID {
st.WanderRooms = rooms
st.WanderInterval = interval
}
@@ -374,7 +375,7 @@ func (w *World) SetObjDepleteTimer(roomID int, defID string, max float64) {
w.mu.Lock()
defer w.mu.Unlock()
for _, st := range w.objStates {
- if st.RoomID == roomID && st.DefID == defID && st.SharedMax == 0 {
+ if st.HomeRoomID == roomID && st.DefID == defID && st.SharedMax == 0 {
st.SharedMax = max
st.SharedTimer = int(max)
}
@@ -451,8 +452,12 @@ func (w *World) TickObjWander() {
for _, m := range moves {
oldKey := w.ObjStateKey(m.st.RoomID, m.st.DefID, m.st.Index)
fromRoom := m.st.RoomID
+ newKey := w.ObjStateKey(m.toRoom, m.st.DefID, m.st.Index)
+ if existing, ok := w.objStates[newKey]; ok && existing != m.st {
+ log.Printf("skip wander of object %q index %d from room %d to %d: destination key %q is already occupied", m.st.DefID, m.st.Index, fromRoom, m.toRoom, newKey)
+ continue
+ }
m.st.RoomID = m.toRoom
- newKey := w.ObjStateKey(m.st.RoomID, m.st.DefID, m.st.Index)
delete(w.objStates, oldKey)
w.objStates[newKey] = m.st
w.objMoves = append(w.objMoves, ObjMove{
diff --git a/internal/world/room.go b/internal/world/room.go
index 9b9bef7..af7a712 100644
--- a/internal/world/room.go
+++ b/internal/world/room.go
@@ -199,6 +199,8 @@ func (ro RoomObject) MarshalYAML() (interface{}, error) {
Talk *behavior.TalkConfig `yaml:"talk,omitempty"`
Safespot *object.SafespotConfig `yaml:"safespot,omitempty"`
OnLook []behavior.Trigger `yaml:"on_look,omitempty"`
+ WanderRooms []int `yaml:"wander_rooms,omitempty"`
+ WanderInterval float64 `yaml:"wander_interval,omitempty"`
}
def := ro.Local
return inlineObj{
@@ -215,6 +217,8 @@ func (ro RoomObject) MarshalYAML() (interface{}, error) {
Talk: def.Talk,
Safespot: def.Safespot,
OnLook: def.OnLook,
+ WanderRooms: ro.WanderRooms,
+ WanderInterval: ro.WanderInterval,
}, nil
}
type rawRef struct {
diff --git a/internal/world/room_test.go b/internal/world/room_test.go
index 855eba5..893f858 100644
--- a/internal/world/room_test.go
+++ b/internal/world/room_test.go
@@ -6,6 +6,8 @@ import (
"testing"
"gopkg.in/yaml.v3"
+ "thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/object"
)
func writeRoom(t *testing.T, dir string, body string) {
@@ -137,3 +139,32 @@ func TestExitDefUnmarshalAlwaysBlocked(t *testing.T) {
t.Error("east exit should not have AlwaysBlocked")
}
}
+
+func TestRoomObjectMarshalYAMLRoundTripsWander(t *testing.T) {
+ ro := RoomObject{
+ ID: "fence",
+ WanderRooms: []int{2, 3},
+ WanderInterval: 5,
+ Local: &object.ObjectDef{
+ Name: "fence",
+ Description: behavior.DescList{{Text: "A fence."}},
+ },
+ }
+ data, err := yaml.Marshal(ro)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ var out RoomObject
+ if err := yaml.Unmarshal(data, &out); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if len(out.WanderRooms) != 2 || out.WanderRooms[0] != 2 || out.WanderRooms[1] != 3 {
+ t.Fatalf("wander_rooms round trip = %v, want [2 3]", out.WanderRooms)
+ }
+ if out.WanderInterval != 5 {
+ t.Fatalf("wander_interval round trip = %v, want 5", out.WanderInterval)
+ }
+ if out.Local == nil || out.Local.Name != "fence" {
+ t.Fatalf("local object lost: %+v", out.Local)
+ }
+}