From 1082f3336085c15992ca83d5553c0ba52208d70a Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Thu, 1 Oct 2026 14:38:08 -0400 Subject: feat: pai gow poker added to the casino --- dev_guide/README.md | 20 ++++ dev_guide/bank.md | 26 ++++++ dev_guide/concurrency.md | 232 +++++++++++++++++++++++++++++++++++++++++++++++ dev_guide/hacking.md | 50 ++++++++++ dev_guide/pai_gow.md | 65 +++++++++++++ dev_guide/roulette.md | 164 +++++++++++++++++++++++++++++++++ dev_guide/tick_order.md | 143 +++++++++++++++++++++++++++++ dev_guide/video_poker.md | 45 +++++++++ 8 files changed, 745 insertions(+) create mode 100644 dev_guide/README.md create mode 100644 dev_guide/bank.md create mode 100644 dev_guide/concurrency.md create mode 100644 dev_guide/hacking.md create mode 100644 dev_guide/pai_gow.md create mode 100644 dev_guide/roulette.md create mode 100644 dev_guide/tick_order.md create mode 100644 dev_guide/video_poker.md (limited to 'dev_guide') diff --git a/dev_guide/README.md b/dev_guide/README.md new file mode 100644 index 0000000..996ce8a --- /dev/null +++ b/dev_guide/README.md @@ -0,0 +1,20 @@ +# About this directory + +These guides document **engine internals** for developers and code-assisting +agents: architecture decisions, durability contracts, presentation internals, +and per-game implementation notes. + +World-builder documentation (YAML content, room/object/mob/item guides, +casino table configuration) lives in [`../building_guide/`](../building_guide/README.md). +Do not add builder-facing content here, and do not add implementation notes, +TODOs or assessments to `building_guide/`. + +| Guide | Covers | +|---|---| +| [bank.md](bank.md) | Bank system session state and dispatch | +| [concurrency.md](concurrency.md) | Owner loop, durable money, lifecycle transactions, shutdown | +| [hacking.md](hacking.md) | Terminal objects and the hacking minigame interface | +| [pai_gow.md](pai_gow.md) | Pai Gow Poker hand evaluation, house way, settlement, Joker art | +| [roulette.md](roulette.md) | Roulette presentation internals and extension notes | +| [tick_order.md](tick_order.md) | The canonical per-tick system order (kept in sync with tests) | +| [video_poker.md](video_poker.md) | Video poker presentation and durable multiplier banks | diff --git a/dev_guide/bank.md b/dev_guide/bank.md new file mode 100644 index 0000000..ed890a7 --- /dev/null +++ b/dev_guide/bank.md @@ -0,0 +1,26 @@ +# Bank system internals + +Builder-facing bank documentation (booth object, room placement, player +commands) lives in [../building_guide/bank.md](../building_guide/bank.md). +This page covers the engine side. + +## Session state + +`StateBank` (defined in `internal/net/server.go`) is the session state for +the bank interface. Input is routed through `handleBankInput` in +`internal/game/cmd_bank.go`. + +## Player storage + +Bank items are stored in the `Player.Bank` field (`map[int]*InventorySlot`), +saved in character YAML. Bank slots are sequential and reuse freed slots. +There is no slot limit. + +## Command dispatch + +- `bank` is classified as `ClassActive` in `classifyCommand()` +- `bank` case in `executeCommand()` calls `doBank(sess)` +- `doUse()` checks for `"bank"` or `"bank booth"` targets and routes to + `doBank()` +- `doBank()` checks for a `bank_booth` object in the current room +- `handleBankInput()` dispatches deposit/withdraw/browse/leave commands diff --git a/dev_guide/concurrency.md b/dev_guide/concurrency.md new file mode 100644 index 0000000..187b942 --- /dev/null +++ b/dev_guide/concurrency.md @@ -0,0 +1,232 @@ +# Concurrency & durability + +This guide documents the runtime architecture decisions that keep live game +state safe: the single-owner loop, single-file durable money, the account +lifecycle transactions, and the graceful-shutdown sequence. + +## Single-owner game state + +One goroutine — the **owner loop** (`internal/game/owner.go`) — executes all +mutation of live game state: session input handlers, connect/disconnect +transitions, every tick callback (the master per-tick system sequence and all +dynamically registered callbacks), and admin mutations. Network and admin +goroutines submit tasks; they never touch live players, sessions, Hub +membership, or game runtime maps directly. + +Rules: + +- `Game.RunOnOwner(fn)` (and `Game.HandleSessionOnOwner` for session input) + block the caller until the closure has executed on the owner. Connection + goroutines submit one task per input line, giving per-client backpressure + without ever blocking another client. +- A FULL task queue applies backpressure: submitters block until the owner + drains a task, and a full queue never bypasses the owner — running a task + on the caller's goroutine while the owner keeps executing would break the + single-owner invariant. A STOPPED owner (graceful shutdown, after + admissions have closed) refuses the task and drops it rather than running + it on the caller's goroutine: `RunOnOwner` returns without executing the + closure, because running it there would mutate live state concurrently + with the shutdown sequence and after the final-save barrier. (Synthetic + tests that construct a Game without an owner run tasks inline on the + caller.) +- Code already running on the owner must NOT call `Game.RunOnOwner` — it + would deadlock waiting for itself. Owner-executed code calls functions + directly. +- Task execution is FIFO, so tick callbacks serialize against session input + and admin mutations in submission order. Tick callbacks still run in + engine registration-ID order (see tick_order.md); the owner wrapper only + moves their execution onto the owner goroutine. +- Slow clients cannot stall the world: `Session` writes are queued + (`net.Session.StartWriter` puts a transport session into queued mode) and a + dedicated per-session writer goroutine performs the network I/O with + per-message write deadlines. A full queue disconnects only that client. +- Saves run on the owner and serialize a value snapshot; concurrent + read-modify-write cycles of the same files cannot happen. +- Password work is the one deliberate exception: Argon2id verification and + hashing (~64 MiB, tens of milliseconds) run on the CALLER's connection + goroutine in `HandleSessionOnOwner` (`StatePassword` and + `StateNewAccount` are intercepted before any owner task is submitted). + A burst of logins therefore costs memory and CPU on connection + goroutines, never a stall of the owner's command/tick queue. The owner + task re-checks the stored hash against the one that was verified so a + password change mid-login cannot authorize a stale attempt, and + `player.hashLimiter` caps concurrent Argon2id computations at 4. + All resulting state transitions (echo, session state, account saves) + still happen on the owner. + +Unit tests construct Games without an owner; such Games run submitted tasks +inline on the caller's goroutine (legacy behavior), which is fine for +synthetic tests but production Games always have the owner (started by +`game.New`). + +## Admin editor isolation + +All `/api/` routes run through one boundary in `internal/admin/owner.go`: + +1. Authenticate, read the request body (maximum 8 MiB) on the HTTP goroutine. +2. Submit one synchronous `Game.RunAdminOnOwner` task. The **whole handler** + runs there: validation against current files, plan construction, file + commits, runtime migration/eviction, store reloads, and undo history. +3. Write the buffered response on the HTTP goroutine after the task finishes. + +Reads use the same boundary so editor API reads cannot observe half an edit. +Neither slow uploads nor slow downloads hold the owner. File I/O does: large +bulk edits can briefly pause gameplay. This deliberately avoids a second +transaction scheduler, fine-grained locks, or optimistic plan retries. Use a +maintenance window for large world reorganizations. + +`RunAdminOnOwner` rejects requests during shutdown with HTTP 503, including +requests queued before shutdown but not yet started. It never executes on the +HTTP goroutine after the owner stops. A disconnected request is skipped if it +has not started; once started, its edit completes even if the browser leaves. +A failed/lost HTTP response is not proof that the edit did not commit. + +### Endpoint inventory and callback contract + +| API family (all behind the same boundary) | Mutations / live dependencies | +| --- | --- | +| `/rooms`, `/rooms/{id}`, `/rooms/new-empty` | Room create/update/delete, room index and runtime room state | +| `/rooms/link`, `/rooms/insert`, `/rooms/remove`, `/rooms/move`, `/rooms/rename`, `/rooms/swap` | Multi-file room plans, course changes, room-ID migration or eviction where applicable | +| `/rooms/bulk-edit`, `/rooms/bulk-delete`, `/rooms/resolve-duplicate` | Bulk room changes, indexes and runtime cleanup | +| `/room-dirs/create`, `/room-dirs/rename` | Room file organization and indexes | +| `/items`, `/mobs`, `/drops`, `/hazards` and their ID routes | Definitions plus existing cache/index invalidation; items also rebuild crafting | +| `/objects`, `/techs`, `/modules`, `/triggers` and their ID routes | Definition files; existing manual-reload semantics remain unchanged | +| Entity `/rename`, `/move`, `/dirs` routes | Definition file organization, undo history, existing cache invalidation | +| `/courses`, `/courses/{id}` | Course files, exit reconciliation, course store reload | +| `/files`, `/files/rename` | Raw file data and history; NOT a semantic room-ID migration or automatic cache reload | +| `/colors`, `/colors/reset` | Persisted config and live color maps | +| `/undo/undo`, `/undo/redo` | File images, room-ID migration, index/cache rebuilds, history together | +| Remaining API routes | Read-only queries; serialized to get coherent snapshots | + +Register new endpoints on `apiMux`, never outside this boundary. Do not wrap +individual callbacks in `RunOnOwner`: they are **already on the owner**. +`cmd/thoi/main.go` wires direct course reload, craft rebuild, casino eviction, +and `MigrateRoomIDsOnOwner` callbacks. `UndoStack`'s mutex protects history, +not gameplay: its apply path and the following rebuild must remain in the +same enclosing API task. Login password rehash saves and history clearing +also run on the owner, with expensive password hashing outside it. + +This guarantees **isolation from gameplay**, not new crash-atomicity or +rollback guarantees for every editor operation. Existing file transaction +and error behavior is unchanged. Raw Files edits and external filesystem +writes still bypass semantic validation/migrations; use structured room +operations for live renumbering, and stop the game for external bulk edits. +Some definition editors still need the in-game `reload` command; serialization +does not add missing cache invalidations or migrate existing spawned entities. + +## Durable money (single-file wallet) + +Money lives in the character file and **nowhere else**. Every casino money +mutation is one atomic `AccountStore.SaveCharacter` (`persistence.WriteFile`: +temp file → fsync → rename → directory fsync) that carries both the balance +and the `Player.PendingWagers` ledger. There is no second durable store, so +there is no cross-file crash window to reconcile. + +- **Wager** — debits chips then credits and appends a `PendingWager` (its + wallet handle plus the exact chips/credits debited) in the SAME save. The + entry stays until the round resolves. +- **Settlement** — `PayCredits(amount, stakeIDs...)` credits the payout and + removes the listed stakes in the same save. A table passes the hand's stake + IDs (its payout includes their return); a slot win passes its triggering + stake (its payout is winnings only). Either way the credit and the stake + consumption are atomic, so a crash can never pay the win and refund the + stake. +- **Loss/forfeit** — `ResolveStake`/`ResolveStakes` consume the pending + stakes in one save; the round resolved and the stake is gone. +- **Refund** — `RefundOutstanding` returns every pending stake (chips in + kind, credits as credits) and clears the ledger in one save. Graceful + shutdown (`Game.runShutdown` → `Casino.RefundAll`) and eviction use it. + +A save failure **before replacement** reverts the in-memory balance and +ledger together. On `persistence.ErrDurabilityUncertain`, every wallet +operation (wager, payout, stake resolution, refund) instead keeps the applied +balance and ledger, because the visible file already carries both. The wallet +logs the storage fault and reports the operation as applied so callers do not +retry a payout or refund a resolved stake. This does not promise survival of +a subsequent OS/power crash; it prevents a later save from overwriting the +visible result with reverted memory. + +At startup (`game.New` → `recoverPendingWagers`, `internal/game/money.go`) +any stake still listed in a character's `PendingWagers` is refunded: a listed +stake means the process died before its round resolved. That is the entire +recovery story — no ordering, checkpoints, evidence, or operator queue. + +### Two-phase journal recovery is out of scope + +This game deliberately does **not** implement a durable money journal or +two-phase crash recovery. That design was built and then removed: it required +a second file (`data/economy/journal.jsonl`) plus balance checkpoints, record +ordering (`Seq`), payout `Covers` linkage, `open_payouts` evidence, and a +startup recovery pass with per-record ambiguity handling. Its entire purpose +was to make an ungraceful stop mid-round produce zero credit discrepancies, +at a cost of ~1,400 lines of engine code and ~2,000 lines of tests. + +With the crash-consistency bar deliberately lowered to "a rare ungraceful +stop may lose or refund one in-flight stake, and must never double-pay", the +single atomic character save is sufficient: a listed pending stake is +refunded, and a settlement whose save fails before replacement leaves the +stake pending and the win unannounced (never paid on top of the refund). +Post-replacement uncertainty follows the applied-state policy above. Do not reintroduce +a second money store, per-record ordering, or an operator-inspection queue +without an explicit decision to restore bank-grade guarantees. + +Currency arithmetic is checked (`internal/casino/credits.go`): explicit +`MaxCredits` cap, bounded `DefaultMaxBet` default (the explicit unlimited +sentinel is still honored), floor conversion for payout multipliers, and +overflow rejected instead of wrapping. The cap is shared by the wallet: +bank withdrawals are rejected when they would push the on-hand balance +past it, and gameplay credit grants (shop sales, pickups, activity +payouts) clamp at the cap and log rather than wrap. + +## Account lifecycle transactions + +Account and character lifecycle operations (create, rename, delete, purge) +are single journal transactions (`internal/persistence/txn.go`): a journal +entry with before-images is written durably BEFORE any file changes, the +operations apply, and the journal is removed at the commit point. Deletes +sync their parent directory; moves sync both affected directories (once if +they are the same). Rollback also syncs deletions, including retries where +the file is already absent, before retiring the journal. An apply failure +attempts rollback; if rollback fails, the journal stays for recovery. A crash +mid-commit is rolled back at the next startup by `persistence.Recover`. + +Characters carry a stable UUID (`player.Player.ID`) and owner field +(`player.Player.Account`), assigned at creation and backfilled at first +startup. Cached membership authorizes by the character file's owner, not by +its renameable filename, so a stale cached name can never authorize +connecting, renaming, or deleting a character reassigned to another account. + +After any lifecycle mutation, `Game.publishAccountUpdate` republishes the +account state to every authenticated session of that account — including +sessions sitting in menus — resetting stale pending selections. The online +check and the file mutation run as one owner step, closing the +check-then-rename gap. + +## Graceful shutdown + +Signals (SIGINT/SIGTERM) and the in-game `shutdown` command converge on +`Game.RequestShutdown`, which runs the sequence exactly once: + +1. **Admissions stop:** session input and disconnect forfeit/save callbacks + become no-ops (server-initiated shutdown is not a player disconnect). +2. **Tick stop and join:** `Engine.StopAndWait` stops the ticker and waits + for the in-flight callback to finish. +3. **Drain and persist (on the owner):** remaining queued commands execute, + outstanding wagers are refunded (`Casino.RefundAll`), and every online + character is saved. Save failures are logged for the operator. +4. **Owner stop:** the owner loop drains and stops. +5. **Transports close:** HTTP(S) servers drain, listeners close, and + sessions flush their outbound queues before their sockets drop. + `ListenAndServe` returns and main exits normally — no `os.Exit`. + +## Room-ID migration + +Live room-ID changes (admin rename, swapid) execute as one mutation plan: +the renamed room's file (id field, filename, self-loop references) plus every +other room whose references moved, applied as a single journal transaction, +followed within the same owner task by the full runtime migration +(`Game.MigrateRoomIDsOnOwner` for the editor): player +state, Hub room buckets, casino room keys, world room-keyed state (ground +items, seeded markers, object states), and live mob instances. Undo/redo +store the whole plan (all before/after images plus the idMap) and apply the +inverse/forward runtime migration through the same wired callback. diff --git a/dev_guide/hacking.md b/dev_guide/hacking.md new file mode 100644 index 0000000..dc4372b --- /dev/null +++ b/dev_guide/hacking.md @@ -0,0 +1,50 @@ +## Hacking + +Hacking minigames are played by jacking into terminal objects. Terminals are YAML objects that are registered in Go's `terminalDefs` map. + +**The filename is the ID** (`terminal_basic.yaml` → `terminal_basic`); like all objects, the loader derives the ID from the filename — don't add an `id:` field (see `objects.md`). + +### Terminal Object YAML + +```yaml +name: basic terminal +color: "28" +description: "A battered terminal with a cracked screen. {28}[Level 1 Hacking]{/}" +inroom_description: "A {28}basic terminal{/} hums quietly against the wall." +``` + +- `id` must match a key in `terminalDefs` (Go code in `internal/game/hacking.go`) +- `name` is used for matching in the `jack` command +- `color` controls the display color +- `description` is shown when examining the terminal +- `inroom_description` is shown in room listings +- No `behavior` field — terminals are registered in Go, not YAML behaviors + +### Adding a New Terminal + +1. Create an object YAML file in `data/objects/terminal_.yaml` +2. Add an entry to `terminalDefs` in `internal/game/hacking.go` +3. Optionally create a new minigame implementation in `internal/game/hacking_.go` +4. Place the terminal object in a room's `objects:` list + +### Adding a New Minigame + +New minigames implement the `HackingMinigame` interface: + +```go +type HackingMinigame interface { + Init(level int) string + HandleInput(input string) (output string, done bool, won bool) + Name() string +} +``` + +Optionally implement `XPBonuser` for bonus XP: + +```go +type XPBonuser interface { + BonusXP() int +} +``` + + diff --git a/dev_guide/pai_gow.md b/dev_guide/pai_gow.md new file mode 100644 index 0000000..9d26871 --- /dev/null +++ b/dev_guide/pai_gow.md @@ -0,0 +1,65 @@ +# Pai Gow Poker internals + +Builder-facing table configuration and the published Fortune pay table +live in [../building_guide/pai_gow.md](../building_guide/pai_gow.md). +This page covers the engine side: hand evaluation, the house way, +settlement and the Joker art. + +## Hand evaluation + +The Joker is partially wild: an ace, or any missing card completing a +straight, flush or straight flush. Five aces beats a royal flush. A2345 +is the second-highest straight (and straight flush), below TJQKA but above +9TJQK. Low hands recognize only pairs and high cards. Suits do not break +ties. Copies lose to the dealer. The high hand must strictly outrank the +low hand. + +## House way + +This is a **local, deterministic house way**, not a claim to reproduce a +particular casino's proprietary chart or optimal player strategy: + +1. Enumerate the 21 possible splits and discard fouls. +2. Preserve the strongest available high-hand category. +3. With two pair or a full house available, also allow splits leaving at + least a pair high and a pair low. +4. Maximize the low hand among these options, then the high hand. + +The dealer and automatic player setting use precisely the same algorithm. +Published main-game house-edge estimates using other house ways do not +necessarily apply to this implementation. + +## Fortune / Envy mathematics + +Of the eight published fixed tables compared on the Wizard of Odds page, +table 8 (the one configured here) has the lowest house edge: 4.4292% +before Envy, about 0.08% with six players each betting five. With Envy +disabled, quads improve from 25:1 to 30:1, a custom local schedule +lowering the published no-Envy edge by about 0.9974 percentage points to +3.4318%. These are published theoretical side-bet figures, not a +simulation of this server; Envy value depends on occupancy and wager size. + +## Face Up mechanics + +The dealer sets and reveals before players act; the automatic ace-high +push is checked against the dealer's best five-card hand (`pgDealerPush`), +so ace pairs and ace-headed flushes do not trigger it. Push Ace High +classes are resolved by `pgPushAceClass`: a Joker in the dealer's seven +cards pays the Joker rate (a natural ace plus the Joker makes a pair of +aces, which is NOT ace-high); otherwise 40:1 when the player is also +ace-high, else the plain rate. + +## Settlement + +Main, Fortune, Envy and Push Ace High settle in a single wallet +transaction per player. Failed settlements freeze the result and block +the next deal; `settle` or the table tick retries payment, never redraws. +Successful recipients are not paid twice. Shutdown uses the existing +durable stake-refund mechanism. + +## Joker art + +The shared Joker is rank `JK`, suit `joker`. `data/cards.yaml` supplies +`JK` artwork at sizes 1/5/7/9; `internal/game/render_joker.go` also has +ASCII and no-store fallbacks, including dark/light styles. Existing Joker +Poker video poker uses the same art. diff --git a/dev_guide/roulette.md b/dev_guide/roulette.md new file mode 100644 index 0000000..e3794e2 --- /dev/null +++ b/dev_guide/roulette.md @@ -0,0 +1,164 @@ +# Roulette and casino extension readiness + +The casino has a usable extension seam: `tableGame`, generic `tableCore`, +registered constructors/config validation in `registry.go`, structured snapshots, +shared participation/control routing, and durable per-stake wallet handles. +Roulette uses those seams rather than adding another command dispatcher. + +Example table (one game of each type per room remains the manager's rule): + +```yaml +casino_tables: + - id: roulette_single + game: roulette + min_bet: 1 + max_bet: 10000 + betting_ticks: 15 + roulette: + double_zero: false + spin_ticks: 4 +``` + +`double_zero: true` selects the American 38-pocket wheel; otherwise the +European 37-pocket wheel is used. Spin ticks default to 3; betting ticks use +the shared default. Ticks follow the server's configured tick length. +The wheel order is authentic and the outcome is selected uniformly only when +spinning finishes. + +## Presentation + +Art is rendered per recipient in `internal/game/render_roulette.go`, so color +mode and Unicode preference stay per player (the engine snapshot never embeds +ANSI). The views and betting tracker share one snapshot renderer: + +- **Tracker**: a five-line, 74-column strip above the betting board, rendered + by `render_roulette_tracker.go`. The betting snapshot leads with a blank line + so the tracker is separated from the prose or menu that precedes it. It shows + the latest eight hits (newest first), three hot/cold pockets with hit counts, + and red/black/zero percentages. + All stats use a table-local rolling window of 50 completed spins. Zeros are + included in the denominator; ties use numeric order (0, 00, 1, ...). + History is copied into snapshots, never sampled before settlement, and + survives round resets but not table replacement or server restart. + History is copied into snapshots, never sampled before settlement, and + survives round resets but not table replacement or server restart. +- **Snapshot cadence**: the board and tracker are drawn once per round by the + betting menu (on join and after settlement) and by an + explicit `table`/`table rules`. Placing a wager only confirms the wager; it + does not re-send the board snapshot. +- **Betting board**: numbered grid with zeros in the left lane, red/black + pockets, and outside bets. The whole five-row zero lane (three numbered rows + plus the two separators between them) carries the green background, and its + outer corners are rounded in Unicode mode. A racetrack table draws + `renderRouletteRacetrack` above the board: a numbered oval in physical + clockwise wheel order, with 26 at the right tip. Interior dividers meet + each call bet's pockets, left to right: Tiers du Cylindre, Orphelins, + Voisins du Zero, Zero. Labels are centered on the middle row; a label too + wide to leave a blank cell against its dividers wraps onto the rows above + and below it (e.g. "Tiers du" / "Cylindre"), and only the glyphs are + written so the centering padding never paints over the dividers. Connected + box-drawing steps separate Tiers from Orphelins; Zero has a curved boundary + within Voisins. Neighbors is not drawn on the board, but remains in the + payout reference and help. +- **Menu ordering**: the roulette betting menu prints its balance and + heading, then the tracker and board, and only then the wager reminder + (`bet on `, `bet / rebet`, `table / sitout / stop`) so the + instructions follow the table they describe. Other games keep their menu + above the snapshot. +- **Spinning**: a stylised 17x9 wheel (wider than tall so it reads as a circle + in a terminal cell grid). Only the rim is colored: solid red pockets alternate + with a very dark blue that stands in for black (`rouletteWheelBlack`), because + pure black vanishes on dark terminals. Pockets are two cells wide except the + top and bottom centres, which stay one cell. One pocket is green (plus its + diametrically opposite pocket on American wheels). A simple plus marks the + croupier's spindle. No pocket numbers are drawn; with color off the pockets + fall back to R/B/G so the wheel still reads. +- **Result**: the winning pocket plus two physical wheel neighbours on each + side (five cards) at playing-card size. The pocket number sits on the top row, + a rule separates it from the ball (one row above centre), and the color name + is spelled out on the bottom row only when color is off. There is no + surrounding prose. The smallest (five-row) cards drop the rule so the ball + can sit directly under the number. The wheel and result graphics each get a + blank line before and after so they do not crowd the prose. + +The colored payout tables in `internal/game/render_casino_help.go` use the +shared ANSI-aware `ui.Table` renderer, respecting ASCII/Unicode and wrap width. +They show wheel-specific win chances and net profit odds, including call-bet +exceptions. They appear only with `table` and `table rules`, not ambient art. +`table help` opens the current game's guide; `help casino` indexes the brief +casino guides in `data/help/`. `help racetrack` explains chip placements and +rounding, including `zero`/`jeu zero` versus the straight-number target `0`. +Each table's one-line +overview is kept short (`Label | Limits 1-10000 | n/m seated | state`) so it +fits beside a look minimap; `writeLookSideBySide` also wraps any content line +that would otherwise run into the map column. + +Initial scope is **one wager per player per spin**. A wager covers straight-up +numbers, outside bets (colors, parity, high/low, 1to12/13to24/25to36, rows), +and the combination bets: splits, streets, corners, six-lines, trios, and the +basket. Combination targets are a compact number list (`split 1 2`, +`street 1-2-3`, `corner 1 2 4 5`, `line 1 2 3 4 5 6`, `trio 0 00 2`, +`basket`) joined from the tokens after `on`; the leading name is optional +because the geometry of the covered set identifies the bet. Non-adjacent or +wrong-shaped sets are rejected rather than silently accepted. Multiple +simultaneous wagers remain future work: `Participant.Wager` still holds a +single stake. + +Single-zero tables may set `roulette.racetrack: true`. That adds the four +French call bets (Voisins du Zéro, Tiers du Cylindre, Orphelins, Jeu Zero) +and the Neighbors bet (a chosen number plus four on each side in wheel order). +The racetrack is a single-zero layout only: the validator rejects +`racetrack: true` together with `double_zero: true`. A call bet is **one +wager** whose amount is the total stake spread evenly over the bet's chips +(Voisins 9, Tiers 6, Orphelins 5, Jeu Zero 4, Neighbors 9). Because the +components are ordinary inside bets, each returns `36/covered` when its +number hits, so a winning pocket can pay a different multiple of the stake +than another (for example 0 on Voisins pays 24/9, while 4 pays 18/9). The +engine therefore resolves a wager to exact per-pocket `num/den` shares +(`roulette_racetrack.go`) instead of a single multiplier; `bet` validates +every possible return before committing and `settle` floors +`stake*num/den`. The rack panel is drawn by `renderRouletteRacetrack` above +the board and lists each bet's wheel-order series. + +All returns are checked before debiting a wager. Both winning and losing stakes +settle through `PayCredits(return, stakeID)`, including a zero return for losses. +Failures leave the stake pending and do not claim that payment succeeded. +Shutdown and eviction refund outstanding stakes and reset the round. +Disconnect refunds are safe because no partial outcome is sampled or exposed. + +Settlements are rendered by `settlementText` in `internal/casino/table_ux.go`: +a colored win/loss/push headline gives the round's net once (the old +"Net ... | ... returned (stake included)" split is gone), then each named bet +— a blackjack hand, insurance, or the table's spot — is listed with its own +net. Wins are green, losses red, pushes white, and a natural keeps the jackpot +gradient. The engine emits inline color tags (`{casino_result_win}WIN{/}`), +which `Game.casinoProse` expands per recipient against the session palette, so +the engine still never embeds ANSI. Casino prose defaults to white +(`casino_action`), leaving color for emphasized outcomes. + +## Assessment and boundaries + +This is a sound foundation for more testing-stage table games, not a blanket +production-readiness certification. New games must test payout math, overflow, +phase boundaries, disconnects, queued departure, refunds, persistence failure, +and spectator output. Shared card-game loss settlement still uses the void +`ResolveStakesByIDs` API: it preserves failed stakes for recovery but cannot +report that failure to its caller. Roulette avoids that limitation with the +existing error-returning payment API. Uniform per-stake error-returning +settlement would be a worthwhile follow-up for older games. + +Multiple concurrent wagers need their own explicit ledger/limit model instead +of reusing the single `Participant.Wager`. Multiple same-game tables in one room +would also require changing current addressing/validation assumptions. + +## Testing shortcut + +`data/objects/casino_test_portal.yaml` is placed in starting room 1001. +`use casino` gives 1000 chips and teleports to vestibule 6100. It is intentionally +repeatable and uses ordinary trigger/drop/teleport machinery. Keep an inventory +slot free (ordinary trigger drops fall to the ground when inventory is full). +Remove the room's `casino_test_portal` reference before production: this grants +unlimited test funds and bypasses travel/progression. No account roles are needed. + +Single-zero room 6182 and double-zero room 6183 are north of ground-floor +landing 6102. `help roulette` describes player syntax and supported bets. diff --git a/dev_guide/tick_order.md b/dev_guide/tick_order.md new file mode 100644 index 0000000..b1246dd --- /dev/null +++ b/dev_guide/tick_order.md @@ -0,0 +1,143 @@ +## Per-Tick System Order + +The server runs on a fixed game tick (600ms by default; configurable via +`tick_length` in `config.yaml`). Each tick, every online player's pending +commands resolve and a fixed sequence of systems advance world state. The +**canonical order** in which these per-tick systems fire is a load-bearing +contract: combat resolution, deplete/restock timers, regen, respawns, and +sequence-driven triggers all depend on it. Reordering it is a breaking change. + +### Source of truth + +The authoritative list lives in code, not in this doc: + +- `internal/game/tick_systems.go` — the `tickSystemOrder` slice and + `(*Game).RunTickSystems()`, the method that fires them in order. +- `cmd/thoi/main.go` — the master per-tick subscriber, registered with the + engine as **subscription ID 1**, whose body is `g.RunTickSystems()`. The + engine (`internal/engine/tick.go` `processTick`) sorts subscribers by + registration ID, so the master subscriber fires first each tick, before + any dynamically-registered combat / aggro / respawn callbacks (which get + higher IDs as players act). +- `internal/game/tick_systems_test.go` — `TestTickSystemOrder` fails if the + table drifts from the list below. `TestRunTickSystemsEmptyGameIsSafe` + guards the "safe to tick with no players connected" contract every system + must uphold (see "Per-system contracts" below). + +If you change the order, add a system, or remove one: update `tickSystemOrder` +in `tick_systems.go`, the `expectedTickSystemOrder` list in +`tick_systems_test.go`, and the table in this file. All three are intentional +duplicates so any one of them drifting fails the test loudly. + +### Canonical sequence + +| # | System | What it does | +| --- | ----------------------- | ------------------------------------------------------------------------------------------------------------------ | +| 1 | `ProcessQueuedCommands` | Drains each player's free-queued commands and the globally timestamp-sorted active queue; advances walk sequences. | +| 2 | `CasinoTick` | Advances casino betting deadlines and resolves completed casino rounds. | +| 3 | `MoveTick` | Counts down `p.MoveTicks`; on reach-zero, completes a pending run/walk and re-prompts. | +| 4 | `SequenceTick` | Advances every active trigger `sequence` (per-player and global) one step. | +| 5 | `TransientMobTick` | Despawns owned / `DespawnOnLeave` mobs whose owner has left their room. | +| 6 | `World.Tick` | World-internal: ground-item respawn/despawn/reserve timers and shared-object deplete timers. | +| 7 | `MobStore.Tick` | Mob shop restock and mob HP regenerate. Order: by `InstanceID` so processing is stable across ticks. | +| 8 | `RegenTick` | Player HP regeneration, accelerated by tech HP-regen multipliers. | +| 9 | `EnergyRegenTick` | Run-energy regeneration for every online player. | +| 10 | `DisconnectTick` | Counts down pending disconnects and hard-removes finished sessions (skipping players still in combat). | +| 11 | `WanderTick` | Mob wandering (exit-based) and object wandering (fishing spots), with enter/leave messages to observers. | +| 12 | `SharedDepletionTick` | Ticks shared-object deplete timers up (in use) or down (recovering). | +| 13 | `FireTick` | Decays fire objects; on burn-out, spawns the removal item and cancels stokers. | +| 14 | `AdvanceActions` | Advances every active player action by one tick; fires completion handlers per action type. | +| 15 | `TechTick` | Drains battery for active tech, applies equipment / PreserveDrain reductions, and deactivates all on empty. | +| 16 | `ConsumeTick` | Counts down `ConsumeCooldown` and `HomeTransportCooldown`. | +| 17 | `BroadcastRespawns` | Flushes respawned world objects and broadcasts their `respawn_broadcast` lines to players in those rooms. | +| 18 | `VisualTick` | Optional per-player "visual tick" line/counter for AFK feedback. | +| 19 | `SneakTick` | Advances guard watch timers and sneaking players' tick counters. | +| 20 | `FarmTick` | Crops grow, drain water, and yield on harvest (fires every `FarmTickInterval` ticks). | +| 21 | `BuffTick` | Potion buff duration countdown; expires and removes effects. | +| 22 | `SafespotTick` | Safespot unsafe-chance and decay-chance rolls per player. | +| 23 | `HazardTick` | Room hazard damage to non-GodMode players. | + +### Determinism contract + +Three layers of ordering decide what happens on a given tick, in priority: + +1. **Subscriber order** — deterministic. `engine.processTick` sorts + subscribers by registration ID. The master subscriber (ID 1, set in + `cmd/thoi/main.go`) always fires first; every system in + `RunTickSystems` then runs in the table order above. Dynamic combat / + aggro / respawn subscribers registered later get higher IDs and fire + after the canonical 23 systems. This is asserted by + `internal/engine/tick_test.go::TestProcessTickFiresInSubscriptionOrder`. + +2. **Per-system iteration order** — deterministic, as of the deterministic + per-tick subscriber work. Systems that iterate over players or mobs use + stable ordering: + - `Hub.AllSessions()` and `Hub.PlayersInRoom` (`internal/net/server.go`) + sort sessions by `Session.seq`, a monotonic counter assigned in + `Hub.Add` in connection order. + - `MobStore.AllInstances()`, `MobStore.MobsInRoom()`, and `MobStore.Tick()` + (`internal/world/mob.go`) sort by `MobInstance.InstanceID`. + + This means, for example, that cosmetic "X enters." / "Y leaves." + interleavings in `WanderTick`, shop-restock order in `MobStore.Tick`, + and per-player processing order in `RegenTick` / `HazardTick` / + `SneakTick` etc. are reproducible across runs. + +3. **Random-number generation** — **not deterministic across server + restarts.** The `math/rand` top-level functions used across the engine + (`rand.Float64`, `rand.Intn`, `rand.Int`, `rand.Shuffle`) are + auto-seeded at program startup (Go 1.20+; calling `rand.Seed` is + deprecated and is never done here). So combat hit rolls, drop rolls, + wandering exit picks, and minigame randomness differ between server + runs. Two ticks from the same seeded run are reproducible *relative* to + each other (the global source is deterministic), but a fresh server + start produces a different stream. + + Only `rollAggroRand` (`internal/game/sys_combat.go`) is replaceable for + tests; the rest are global-function calls. Reproducible full-tick + traces (e.g. for record/replay debugging) would require threading a + seeded `*math/rand.Rand`/`math/rand/v2.ChaCha8` through the engine; not + currently done. + +### Per-system contracts (the empty-game invariant) + +Every system in the table must be safe to invoke on a `*Game` that has its +stores initialized (`World`, `MobStore`, `Hub`, `queue`, etc. via +`game.New` + `SetHub`) but **no players connected**. Concretely: + +- Systems that touch the hub: guard with `if g.Hub == nil { return }` at the + top, *or* guard each `Hub.*` call with `if g.Hub != nil`. Either is fine. + (`main.go` now calls `SetHub` *before* `Ticks.Start`, so the production + server never ticks with a nil hub; the guards are belt-and-braces for a + `*Game` that tests construct without `SetHub`, such as + `newRunTickSystemsTestGame`.) +- Systems that iterate over maps/slices held on `*Game` (`sequences`, + `restTimers`, `guardWatchTimers`, `hackingStates`, …) must tolerate empty + / freshly-initialized maps. `game.New` initializes all of them; tests that + build a stub `*Game` must too (see `newRunTickSystemsTestGame`). + +Violating either of these is caught by +`TestRunTickSystemsEmptyGameIsSafe` in `internal/game/tick_systems_test.go`. +If you add a new system that needs a new field initialized, update that +helper as well. + +### Notes for builders + +When writing content that depends on per-tick ordering (a trigger that +should fire *after* regen, a sequence whose step messages should appear +*before* the move completes, etc.), consult the table above and remember: + +- `MoveTick` (table #3) runs before `SequenceTick` (table #4) — the table + fires top-to-bottom, so a move that completes this tick will have its + enter-room knock-on triggers fire (inside `ProcessQueuedCommands`, #1, or + `MoveTick`, #3) before trigger `sequences` advance in #4. +- `on_enter` / `on_exit` room triggers fire from command execution, not + from a per-tick system; they run inside `ProcessQueuedCommands` (#1) + or `MoveTick` (#3) when the move actually completes. +- Shop restock (`MobStore.Tick`, #7) happens *before* `RegenTick` (#8) and + *before* `AdvanceActions` (#14); if a gathering action completes this tick + it does so in #14, by which point shop state for this tick has + already advanced. + +When in doubt, the test in `tick_systems_test.go` is the executable +specification. diff --git a/dev_guide/video_poker.md b/dev_guide/video_poker.md new file mode 100644 index 0000000..46d900e --- /dev/null +++ b/dev_guide/video_poker.md @@ -0,0 +1,45 @@ +# Video poker internals + +Builder-facing cabinet configuration, payout tiers and player controls +live in [../building_guide/video_poker.md](../building_guide/video_poker.md). +This page covers presentation and money-safety internals. + +## Presentation + +Pay tables use yellow Unicode borders and dotted hand-name leaders. Blue +`#000269`, red `#fe0000` (selected coin column), and yellow `#f7e70e` are mapped +to nearest xterm colors. HELD and wins are bold yellow; no-win text is bold +white. Results are below cards. Bonus indicators and multipliers sit beside +cards. Multi-hand art shrinks; hundred-play uses compact Unicode ranks/suits. + +Payouts are total coins including stake, not profit. Multiply by denomination +for credits. Natural royals pay 250 per coin below five coins, 4000 at five. +Other categories scale linearly. Only the highest-paying category pays. +All games use fresh 52-card decks except Joker Poker's 53-card deck. Each line +shares the deal/holds but draws from its own shuffled remaining deck, excluding +**all five** original cards, including discards. + +## Shared banks and money safety + +Ultimate X multipliers belong to the **machine**, not the player. Another +occupant inherits them. Files live at +`data/casino/video_poker_banks/_.yaml`. Within each file banks +are keyed by game/tier/denomination/hand-count, preventing denomination or +edition switching from amplifying an award. Fully consumed banks are removed. +Missing files are new machines; corrupt/unreadable files block deals. + +`casino.PokerWallet` loads banks and atomically settles character credits, +pending stake removal, and the cabinet file in a multi-file undo-journal +transaction. There is no package-global persistence callback. The game owner +serializes writes. The stake is durable before revealing cards; frozen final +hands and awards survive payment retries without redraws or rerolls. Failed +transactions leave the old live bank intact. Commit-point failures are recovered +and reconciled before any retry; unrecoverable storage faults fail closed. +Startup journal recovery precedes pending-wager recovery. A process crash before +commit restores both old records, then refunds the unresolved stake. Committed +payouts cannot replay an old bank. Shutdown/eviction refunds unresolved stakes +without consuming previously saved multipliers or retaining uncommitted awards. + +Old experimental player-owned `video_poker_multipliers` fields are no longer +read. They are not merged into shared cabinets (multiple player banks cannot +unambiguously become one physical bank). -- cgit v1.2.3