diff options
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) + } +} |
