diff options
Diffstat (limited to 'building_guide')
| -rw-r--r-- | building_guide/README.md | 32 | ||||
| -rw-r--r-- | building_guide/admin.md | 2 | ||||
| -rw-r--r-- | building_guide/bank.md | 23 | ||||
| -rw-r--r-- | building_guide/concurrency.md | 232 | ||||
| -rw-r--r-- | building_guide/hacking.md | 50 | ||||
| -rw-r--r-- | building_guide/pai_gow.md | 100 | ||||
| -rw-r--r-- | building_guide/rooms.md | 11 | ||||
| -rw-r--r-- | building_guide/roulette.md | 164 | ||||
| -rw-r--r-- | building_guide/tick_order.md | 143 | ||||
| -rw-r--r-- | building_guide/video_poker.md | 48 |
10 files changed, 152 insertions, 653 deletions
diff --git a/building_guide/README.md b/building_guide/README.md new file mode 100644 index 0000000..de70acd --- /dev/null +++ b/building_guide/README.md @@ -0,0 +1,32 @@ +# About this directory + +These guides are for **human world builders** working on the game's content: +rooms, objects, mobs, items, drops, triggers, quests, hazards, casino table +configuration, and similar data-driven world YAML. + +**Do not put engine documentation here.** Anything about Go internals, +architecture, concurrency, testing, TODOs, roadmaps, or implementation +assessments belongs in [`../dev_guide/`](../dev_guide/README.md) or in code +comments next to the code it describes. Keeping the split clean matters: +builders should be able to read this directory top to bottom without +knowing the source layout. + +| Guide | Covers | +|---|---| +| [admin.md](admin.md) | In-game admin commands and the web admin panel | +| [bank.md](bank.md) | Bank booths and player banking commands | +| [behaviors.md](behaviors.md) | Gather, talk and shop behaviors | +| [construction.md](construction.md) | Player-owned housing and furniture | +| [courses.md](courses.md) | Agility courses | +| [drops.md](drops.md) | Shared drop tables | +| [hazards.md](hazards.md) | Environmental hazards | +| [items.md](items.md) | Items, equipment, food, combinations | +| [mobs.md](mobs.md) | Mobs, combat stats, shops, task mobs | +| [objects.md](objects.md) | Objects, interactions, safespots | +| [pai_gow.md](pai_gow.md) | Pai Gow Poker table configuration | +| [quests.md](quests.md) | Quest walkthrough example | +| [recipes.md](recipes.md) | Crafting and recipes | +| [rooms.md](rooms.md) | Rooms, exits, casino tables, triggers | +| [tips.md](tips.md) | Building tips | +| [triggers.md](triggers.md) | Trigger scenarios | +| [video_poker.md](video_poker.md) | Video poker cabinet configuration | diff --git a/building_guide/admin.md b/building_guide/admin.md index 01a2188..b29ed00 100644 --- a/building_guide/admin.md +++ b/building_guide/admin.md @@ -277,7 +277,7 @@ Editor API requests (including undo/redo) execute as one game-owner task: file writes and their runtime callbacks/reloads cannot interleave with a tick or player command. Large edits briefly pause gameplay; slow browser uploads and downloads do not. Requests are limited to 8 MiB and rejected during game -shutdown. See [Concurrency & durability](concurrency.md#admin-editor-isolation) +shutdown. See [Concurrency & durability](../dev_guide/concurrency.md#admin-editor-isolation) for the endpoint inventory and developer rules. This does not make every edit automatically hot-reloadable. Existing reload diff --git a/building_guide/bank.md b/building_guide/bank.md index acf9954..a6eb97f 100644 --- a/building_guide/bank.md +++ b/building_guide/bank.md @@ -39,25 +39,7 @@ objects: | `browse` / `list` | Show bank contents | | `leave` | Exit bank interface | -## Implementation - -### 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 - -### Data Files +## Data files | File | Purpose | |------|---------| @@ -65,3 +47,6 @@ Bank items are stored in the `Player.Bank` field (`map[int]*InventorySlot`), sav | `data/help/bank.yaml` | Help topic for the bank system | Default bank booth locations: Town Square (room 1), Forge (room 12). + +Engine internals (session state, storage layout, command dispatch) are +documented in [../dev_guide/bank.md](../dev_guide/bank.md). diff --git a/building_guide/concurrency.md b/building_guide/concurrency.md deleted file mode 100644 index 187b942..0000000 --- a/building_guide/concurrency.md +++ /dev/null @@ -1,232 +0,0 @@ -# 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/building_guide/hacking.md b/building_guide/hacking.md deleted file mode 100644 index dc4372b..0000000 --- a/building_guide/hacking.md +++ /dev/null @@ -1,50 +0,0 @@ -## 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_<name>.yaml` -2. Add an entry to `terminalDefs` in `internal/game/hacking.go` -3. Optionally create a new minigame implementation in `internal/game/hacking_<name>.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/building_guide/pai_gow.md b/building_guide/pai_gow.md new file mode 100644 index 0000000..40e6029 --- /dev/null +++ b/building_guide/pai_gow.md @@ -0,0 +1,100 @@ +# Pai Gow Poker tables + +Configure a `casino_tables` entry: + +```yaml +- id: fortune + game: pai_gow + max_players: 6 + min_bet: 5 + max_bet: 1000 + betting_ticks: 30 + pai_gow: + variant: classic # classic, ez, no_push, face_up + envy: true + action_ticks: 60 +``` + +There are six player seats and one dealer. Each round deals seven packets +from a fresh 53-card deck, including unused seats. No player banking. +`bet 20` places the main wager; `bet 5 fortune` adds the optional side bet. +`deal` marks a bettor ready; when all bettors are ready, or the betting +window expires, the cards are dealt. Place side bets before marking ready. +A Fortune wager requires a main wager. Both use the table's limits; each +is also bounded to 500,000,000 credits to keep combined payouts below the +economy's credit cap even on unlimited tables. + +## Player hand setting + +`24` or `set 24` selects positions 2 and 4 for the two-card low hand. +`13456` selects those five positions for the high hand. Spaces and order +are immaterial; duplicates, out-of-range positions and other counts are +rejected. Setting locks the hand. Fouls are rejected with a clear warning +and leave the hand unset. `house` (`house way`, `set house`) sets +automatically. Timeout or disconnect sets an unfinished hand the house +way; a committed round cannot be abandoned. + +## Variants + +* Classic: win both hands for 0.95:1 profit; lose both to lose; otherwise push. + Whole-credit profit rounds down, with a minimum of one credit on a win, + consistent with the existing casino economy. This affects small-bet odds. +* EZ: the **partially-wild Joker edition**. No commission. Dealer's best + five-card hand exactly queen-high pushes the main bets, regardless of split. +* No Push: no commission. When one player hand beats the dealer and the other + does not, compare the two extra cards dealt after the seven packets. The + first is the shared player tiebreaker, the second the dealer's. Joker is an + ace; equal ranks lose to the dealer. Both-win/both-loss results are unchanged. +* Face Up: no commission; the dealer's seven cards are revealed before + players set theirs. If the dealer's best five-card hand is exactly + ace-high the round is an automatic push for every main bet. Requires + `push_ace_high: 5_15_40` or `5_12_40`, which enables the optional Push + Ace High side bet (`bet <amount> pushace`, capped at half the main + wager): dealer ace-high without a Joker 5:1, with a Joker 15:1, both + sides ace-high 40:1, else lost. + +Fortune and Envy are independent of all main-game results, including +variant pushes. `push_ace_high` on a non-Face-Up table (or a Face Up table +without it) is rejected by validation. + +## Fortune / Envy + +Source: [Wizard of Odds Fortune tables](https://wizardofodds.com/games/pai-gow-poker/side-bets/fortune/). +Of the eight published fixed tables compared there, **table 8** has the +lowest house edge: 4.4292% before Envy, about 0.08% with six players each +betting five. These are published theoretical side-bet figures, not a +simulation of this server. Envy value depends on occupancy and wager size. + +| Seven-card holding | Profit odds | Fixed Envy credits | +|---|---:|---:| +| Natural seven-card straight flush | 1000:1 | 500 | +| Royal flush + royal match | 750:1 | 250 | +| Wild seven-card straight flush | 500:1 | 150 | +| Five aces | 250:1 | 100 | +| Royal flush | 150:1 | 50 | +| Straight flush | 50:1 | 20 | +| Four of a kind | 25:1 | 5 | +| Full house | 5:1 | 0 | +| Flush | 4:1 | 0 | +| Three of a kind | 3:1 | 0 | +| Straight | 2:1 | 0 | +| Three pair | 1:1 | 0 | + +Only the highest listed category pays, using all seven cards regardless +of the actual split. Royal match means suited KQ in the two remaining +cards. Fortune winning wagers also return their stake. A Fortune wager +of at least five qualifies for fixed Envy prizes on **other active players**, +not the bettor, dealer, unused packets or spectators. Multiple qualifying +hands add. The other player need not have a Fortune bet. A losing Fortune +stake is still lost when Envy pays. + +With `envy: false` (default), quads improve to **30:1**, a custom local +schedule. The felt displays the effective table. + +Variant sources: +* https://wizardofodds.com/games/ez-pai-gow-poker/partially-wild-joker/ +* https://wizardofodds.com/games/pai-gow-poker/appendix/4/ +* https://wizardofodds.com/games/face-up-pai-gow-poker/ + +House-way algorithm, settlement internals and Joker art notes are in +[../dev_guide/pai_gow.md](../dev_guide/pai_gow.md). diff --git a/building_guide/rooms.md b/building_guide/rooms.md index 2ed6195..c8b9c6e 100644 --- a/building_guide/rooms.md +++ b/building_guide/rooms.md @@ -448,9 +448,14 @@ casino configurations. Raw file editing is not a validated save endpoint. - IDs must be nonempty, have no surrounding whitespace, and be unique across the room's tables and machines. -- Supported table games are `blackjack`, `baccarat`, `roulette`, and `craps` - (craps rules and house editions are listed in-game via `help craps` and - `table rules`); slots are machines only +- Supported table games are `blackjack`, `baccarat`, `roulette`, `craps`, and + `pai_gow` ([Pai Gow configuration and variants](pai_gow.md)). Single-seat + `video_poker` cabinets also use the table adapter ([cabinet guide](video_poker.md)). + Roulette configuration basics: `roulette.double_zero` selects the American + 38-pocket wheel (otherwise the European 37-pocket wheel), `roulette.spin_ticks` + defaults to 3, and `roulette.racetrack: true` adds the French call bets on + single-zero tables only. Craps rules and house editions are listed in-game via + `help craps` and `table rules`; slots are machines only and must be declared under `casino_machines`. A `slots` entry under `casino_tables` is rejected. Machines use `slots` with the optional variant `huff_and_even_more_puff`. A machine variant is diff --git a/building_guide/roulette.md b/building_guide/roulette.md deleted file mode 100644 index e3794e2..0000000 --- a/building_guide/roulette.md +++ /dev/null @@ -1,164 +0,0 @@ -# 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 <amount> on <target>`, `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/building_guide/tick_order.md b/building_guide/tick_order.md deleted file mode 100644 index b1246dd..0000000 --- a/building_guide/tick_order.md +++ /dev/null @@ -1,143 +0,0 @@ -## 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/building_guide/video_poker.md b/building_guide/video_poker.md index 0faf8f3..497f64e 100644 --- a/building_guide/video_poker.md +++ b/building_guide/video_poker.md @@ -55,9 +55,6 @@ games. Some classic full schedules exceed 100% with optimal play. than inventing a third tier. Other omissions are deliberately conservative, not claims that no physical edition exists. -The authored tier persists across occupants of the live cabinet. -Multiplier banks for *all* tiers remain durable and separate. - | Edition | Supported hands | |---|---| | Classic Jacks or Better | 1, 3, 5, 10, 25, 50, 100 | @@ -67,7 +64,7 @@ Multiplier banks for *all* tiers remain durable and separate. | Super Times Pay | 1, 3, 5, 10 | | Hot Roll | 3, 5, 10 | -## Controls and presentation +## Player controls - `play <id>` seats you; `game` lists games; `game jacks` (or a full game name) selects one. The house sets the payout tier when the cabinet is authored in @@ -90,19 +87,6 @@ Multiplier banks for *all* tiers remain durable and separate. `list` retains available-wagers semantics and is not a pay-table command. - `stop` leaves between rounds. Disconnect completes the current holds. -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. - ## Bonuses Feature ON requires five base coins per hand. UX and Hot Roll add five coins @@ -117,30 +101,9 @@ Feature ON requires five base coins per hand. UX and Hot Roll add five coins - **Hot Roll:** a pre-deal 1/6 trigger rolls two fair dice; their sum multiplies every line. STP and Hot Roll have no next-hand bank. -## 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/<room>_<cabinet>.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). +Ultimate X multiplier banks belong to the **machine**, not the player: another +occupant inherits them, and they persist on disk under +`data/casino/video_poker_banks/`. ## Sources @@ -157,3 +120,6 @@ Published schedules, award tables and feature rules: - https://wizardofodds.com/games/video-poker/tables/double-bonus/ - https://wizardofodds.com/games/video-poker/tables/double-double-bonus/ - https://www.videopoker.com/games/ (single/multi/hundred-play editions) + +Presentation internals and durable-bank transaction details are in +[../dev_guide/video_poker.md](../dev_guide/video_poker.md). |
