diff options
50 files changed, 2336 insertions, 87 deletions
diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d81deca --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,37 @@ +# AGENTS.md + +Guidance for AI coding agents working in this repository. + +## Documentation layout + +- `building_guide/` is for **human world builders only**: how to author the + game's YAML content (rooms, objects, mobs, items, drops, triggers, quests, + casino tables, and so on). See its README for the index. +- `dev_guide/` is for **engine internals**: architecture, concurrency, + durability contracts, presentation internals, and per-game implementation + notes. Code comments that cite documentation must point here. +- `README.md` is the project front page; keep it short and player/operator + oriented. + +When writing documentation, pick the right directory by audience, not by +topic: + +1. A builder placing a Pai Gow table reads `building_guide/pai_gow.md`. +2. Someone changing hand evaluation reads `dev_guide/pai_gow.md`. +3. If an article mixes both, split it across the two guides and cross-link. + +**Do not** use `building_guide/` as a scratch space: no TODOs, roadmaps, +progress logs, implementation assessments, or "future work" sections. If +you find yourself writing one, put it in `dev_guide/`, a code comment, or +delete it. Conversely, don't bury builder-relevant configuration examples +in `dev_guide/` — builders read only `building_guide/`. + +## House rules + +- Go: keep tests deterministic (seeded RNG); money flows through + `internal/casino/credits.go` helpers; run `go test ./...` before handing + work back. +- YAML data files are validated at startup; run the server or the validate + tests after editing anything under `data/`. +- If a test or code comment names a documentation file, that file must + exist at the cited path; update both together. @@ -48,6 +48,10 @@ There is not much here yet. # Building / Playing +Docs: `building_guide/` is for human world builders (rooms, objects, mobs, +items, casino tables); `dev_guide/` documents engine internals for +contributors. `AGENTS.md` explains the split for AI coding agents. + Install Go then ``` 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/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/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). diff --git a/cmd/thoi/main.go b/cmd/thoi/main.go index 142b227..4a8353a 100644 --- a/cmd/thoi/main.go +++ b/cmd/thoi/main.go @@ -49,7 +49,7 @@ func main() { // callbacks (engine.processTick sorts subscribers by registration ID). Its // body — the canonical per-tick system sequence — is enforced and tested in // internal/game/tick_systems.go and documented in - // building_guide/tick_order.md. Reordering it is a breaking change. + // dev_guide/tick_order.md. Reordering it is a breaking change. // SubscribeTicks executes the body on the game's owner goroutine so the // whole tick sequence serializes against session input and admin tasks. g.SubscribeTicks(1, func() bool { diff --git a/data/cards.yaml b/data/cards.yaml index 543d8de..765f53b 100644 --- a/data/cards.yaml +++ b/data/cards.yaml @@ -1,4 +1,5 @@ -# Unicode casino card art, shared by every card game (blackjack, baccarat). +# Unicode casino card art, shared by every card game. +# JK is the Joker, with explicit art at sizes 1, 5, 7 and 9. # Each card defines 7 and 9 line art (5 line cards use the generated renderer: # there is no room for a pip layout, so crowded ranks fall back to a single # centered suit symbol). Keys are rank plus suit: A/K/Q/J/10/2-9 + C/D/H/S. @@ -8,6 +9,32 @@ # AH: # 5: ["┌───┐", "│ A │", "│ ♥ │", "│ A │", "└───┘"] cards: + JK: + 1: ['JK'] + 5: + - '┌───┐' + - '│JK │' + - '│ ★ │' + - '│ JK│' + - '└───┘' + 7: + - '┌─────┐' + - '│JK │' + - '│ ^ ^ │' + - '│(o o)│' + - '│ v │' + - '│ JK│' + - '└─────┘' + 9: + - '┌───────┐' + - '│JK │' + - '│ /\ /\ │' + - '│ o o │' + - '│ <J> │' + - '│ \_/ │' + - '│ ★ │' + - '│ JK│' + - '└───────┘' AC: {} AD: {} AH: {} diff --git a/data/help/casino.yaml b/data/help/casino.yaml index f401c12..84ea49f 100644 --- a/data/help/casino.yaml +++ b/data/help/casino.yaml @@ -1,7 +1,7 @@ name: casino aliases: [play, bet, rebet, table, watch, rules] category: Interaction -related: [roulette, racetrack, blackjack, baccarat, craps, slots, video poker] +related: [roulette, racetrack, blackjack, baccarat, craps, slots, video poker, pai gow poker] description: | Casino quick guide @@ -29,5 +29,6 @@ description: | help craps Multiplayer dice, free odds, Fire and ATS bets. help slots Matching symbols, ways and free spins. help video poker Five cabinet types, selectable games, toggle holds and bonuses. + help pai gow poker Seven cards, two hands; Classic, EZ, No Push and Fortune/Envy. Rules and limits vary by table. No bet guarantees a profit. diff --git a/data/help/pai_gow.yaml b/data/help/pai_gow.yaml new file mode 100644 index 0000000..a0e21e5 --- /dev/null +++ b/data/help/pai_gow.yaml @@ -0,0 +1,60 @@ +name: pai gow poker +aliases: [pai_gow, paigow, pai gow, ez pai gow poker, no push pai gow poker, face up pai gow, fortune, envy] +category: Interaction +related: [casino, video poker] +description: | + Pai Gow Poker — seven cards, two hands + + Find Pai Gow east of the Video Poker Parlor. Classic is in the main salon, + EZ north, No Push south, Face Up beyond EZ. play pai joins; rules teaches, + table rules displays the exact rules and Fortune payout felt. + + bet 20 places your main wager. Optionally bet 5 fortune BEFORE deal. + deal marks you ready; the round starts when all bettors are ready or the + betting window ends. Each player and the dealer receive seven cards from + a fresh deck with one Joker. Your cards are numbered 1 through 7. + + Type 24 to put cards 2 and 4 in your small (two-card) hand. + Type 13456 to put those five cards in your big (five-card) hand instead. + Spaces and order do not matter. set 24 also works. No repeated numbers. + Your five-card hand must outrank your two-card hand: a foul produces a + warning and leaves your hand unset. A valid selection LOCKS your hand. + house sets automatically; timeout or disconnect also uses the house way. + + House way preserves the best high-hand category, maximizing low within + that category. Two pair/full houses may be split to put pairs in both. + The dealer uses this same deterministic house way. It is not a claim + of optimal strategy. Full details appear in table rules. + + The Joker acts as an ace, or completes a straight, flush or straight flush. + A2345 is the second-highest straight. Low hands have only pairs/high cards. + Exact copies lose to the dealer. Win both hands to win the main bet; + lose both to lose; otherwise push. Classic charges 5% on winning profit + (whole credits rounded down, minimum one credit of profit). + + EZ: no commission; dealer's best five cards exactly queen-high pushes + every main bet. This is the partially-wild Joker version. + No Push: no commission; split results compare shared player/dealer extra + cards. Higher player card wins; ties lose. Joker counts as an ace. + Face Up: no commission; the dealer sets and reveals BOTH hands before + you set yours. Dealer's best five cards exactly ace-high (a pair of + aces does not count) automatically pushes every main bet; the optional + Push Ace High side bet pays that push: without Joker 5:1, with Joker + 15:1, or 40:1 when BOTH sides are ace-high. bet <amount> pushace, + up to half your main wager, before deal. + + Fortune uses ALL seven cards, independent of your split and main result. + Only the highest listed prize pays, plus your Fortune stake back. + Three pair pays 1:1; trips pay 3:1, even with a straight available (2:1). + Royal match is a royal flush with a suited king/queen in the other two cards. + Exact prizes are printed on the felt. This is published Fortune table 8. + + With Envy enabled, a Fortune bet of at least five also earns fixed awards + when OTHER active players have quads or better. Never your own hand, the + dealer, or unused seats. Multiple bonuses add. The other player need not + bet Fortune. A losing Fortune stake stays lost even when Envy pays. + Without Envy, quads improve from 25:1 to 30:1 to compensate modestly. + + stop leaves between rounds. Committed wagers must finish first. + A failed bank payment stays pending: settle retries the SAME result, + without dealing again. No new round starts until payments finish. diff --git a/data/rooms/casino/6100.yaml b/data/rooms/casino/6100.yaml index cf85c90..bcde393 100644 --- a/data/rooms/casino/6100.yaml +++ b/data/rooms/casino/6100.yaml @@ -12,7 +12,7 @@ mobs: objects: - name: wayboard description: 'Domita Fortuna in one line: take the stairs up to the gaming street — blackjack, - baccarat, roulette, craps, slots, then video poker, going east. Each salon keeps its own house + baccarat, roulette, craps, slots, video poker, then Pai Gow Poker, going east. Each salon keeps its own house editions in the galleries north and south. The bank booth by the door stores your valuables; no wager is needed to explore.' inroom_description: A wayboard stands where travelers can read it. diff --git a/data/rooms/casino/6192.yaml b/data/rooms/casino/6192.yaml index 2f47b9e..b0419a7 100644 --- a/data/rooms/casino/6192.yaml +++ b/data/rooms/casino/6192.yaml @@ -1,8 +1,10 @@ name: Video Poker Parlor description: A row of low-limit single-seat poker cabinets glows blue beneath yellow pay-table borders. Classic poker, two Ultimate X editions, Super Times Pay and Hot Roll stand side by side. The high-limit - room waits north; the reels are back west. + room waits north; the reels are back west and Pai Gow Poker waits east. exits: + east: + room: 6195 west: room: 6118 north: diff --git a/data/rooms/casino/6195.yaml b/data/rooms/casino/6195.yaml new file mode 100644 index 0000000..4e8084d --- /dev/null +++ b/data/rooms/casino/6195.yaml @@ -0,0 +1,29 @@ +name: Fortune Pai Gow Salon +description: Seven-card fans gleam beneath jade lamps. The green felt displays Fortune + prizes beside a pair of small and large hand boxes. EZ Pai Gow waits north and + No Push south; the video poker cabinets glow to the west. +exits: + west: + room: 6192 + north: + room: 6196 + south: + room: 6197 +casino_tables: +- id: fortune + game: pai_gow + max_players: 6 + min_bet: 5 + max_bet: 1000 + betting_ticks: 30 + pai_gow: + variant: classic + envy: true + action_ticks: 60 +objects: +- name: fortune plaque + description: 'play pai; bet 20, optionally bet 5 fortune, then deal. Set two low cards + with 24 or five high cards with 13456; house sets automatically. High must beat low. + Classic wins pay 0.95:1. Fortune pays independently; five credits qualifies for Envy + on other active players. table rules shows the payout felt.' + inroom_description: A jade fortune plaque explains the table. diff --git a/data/rooms/casino/6196.yaml b/data/rooms/casino/6196.yaml new file mode 100644 index 0000000..07dd081 --- /dev/null +++ b/data/rooms/casino/6196.yaml @@ -0,0 +1,27 @@ +name: EZ Pai Gow Gallery +description: A queen of jade watches over a commission-free Pai Gow table. The felt + warns that a dealer queen-high pushes the main bets. Face Up Pai Gow waits north + and the Fortune salon south. +exits: + south: + room: 6195 + north: + room: 6198 +casino_tables: +- id: ez + game: pai_gow + max_players: 6 + min_bet: 5 + max_bet: 1000 + betting_ticks: 30 + pai_gow: + variant: ez + envy: true + action_ticks: 60 +objects: +- name: ez plaque + description: 'play pai; main wins pay 1:1, but a dealer queen-high pushes. The Joker is + an ace or completes a straight or flush. bet 20, optionally bet 5 fortune, then deal. + Set your low hand with two numbers or high with five; house sets automatically. + table rules shows Fortune payouts and fixed Envy awards.' + inroom_description: A queen-shaped plaque marks the EZ rules. diff --git a/data/rooms/casino/6197.yaml b/data/rooms/casino/6197.yaml new file mode 100644 index 0000000..fabbc75 --- /dev/null +++ b/data/rooms/casino/6197.yaml @@ -0,0 +1,25 @@ +name: No Push Pai Gow Gallery +description: Two extra card boxes sit above the dealer's hand on a jade-green felt. + Here every main wager wins or loses; even a split result has a tiebreaker. + The Fortune salon is north. +exits: + north: + room: 6195 +casino_tables: +- id: no_push + game: pai_gow + max_players: 6 + min_bet: 5 + max_bet: 1000 + betting_ticks: 30 + pai_gow: + variant: no_push + envy: false + action_ticks: 60 +objects: +- name: no push plaque + description: 'play pai; bet 20, optionally bet 5 fortune, then deal. Two numbers set + your low hand; five set your high hand; house sets automatically. Main wins pay 1:1. + Split results compare two extra cards: player must be higher, ties lose, Joker is ace. + Envy is off here, so Fortune quads improve to 30:1. table rules shows all prizes.' + inroom_description: A two-card plaque explains the tiebreaker. diff --git a/data/rooms/casino/6198.yaml b/data/rooms/casino/6198.yaml new file mode 100644 index 0000000..e60b5dd --- /dev/null +++ b/data/rooms/casino/6198.yaml @@ -0,0 +1,26 @@ +name: Face Up Pai Gow Parlor +description: Seven-card fans face a glass dealer rail; the dealer's cards are turned + face up here. The felt warns that a dealer ace-high pai gow pushes every main + bet, and offers the Push Ace High side bet. EZ Pai Gow waits south. +exits: + south: + room: 6196 +casino_tables: +- id: face_up + game: pai_gow + max_players: 6 + min_bet: 5 + max_bet: 1000 + betting_ticks: 30 + pai_gow: + variant: face_up + push_ace_high: 5_15_40 + envy: true + action_ticks: 60 +objects: +- name: face up plaque + description: 'play pai; bet 20, optionally bet 5 fortune and/or bet 10 pushace, then + deal. The dealer sets and reveals first; dealer ace-high pai gow pushes main bets. + Push Ace High pays 5:1 (no Joker), 15:1 (with Joker) or 40:1 (both ace-high). + Set two low numbers or five high; house sets automatically. table rules shows the felt.' + inroom_description: A glass-rail plaque explains the face-up rules. 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/building_guide/concurrency.md b/dev_guide/concurrency.md index 187b942..187b942 100644 --- a/building_guide/concurrency.md +++ b/dev_guide/concurrency.md diff --git a/building_guide/hacking.md b/dev_guide/hacking.md index dc4372b..dc4372b 100644 --- a/building_guide/hacking.md +++ b/dev_guide/hacking.md 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/building_guide/roulette.md b/dev_guide/roulette.md index e3794e2..e3794e2 100644 --- a/building_guide/roulette.md +++ b/dev_guide/roulette.md diff --git a/building_guide/tick_order.md b/dev_guide/tick_order.md index b1246dd..b1246dd 100644 --- a/building_guide/tick_order.md +++ b/dev_guide/tick_order.md 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/<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). diff --git a/internal/casino/core.go b/internal/casino/core.go index fdb7877..8ab44f5 100644 --- a/internal/casino/core.go +++ b/internal/casino/core.go @@ -157,7 +157,7 @@ func resolveStakeIDs(w Wallet, ids []string) { // settleStakeOutcome applies one wager's outcome through the wallet and // reports whether the settlement could NOT be made durable. It carries the // money-critical ordering shared by every table game (documented in -// building_guide/concurrency.md): compute the payout, pay it via PayCredits +// dev_guide/concurrency.md): compute the payout, pay it via PayCredits // BEFORE announcing the win — that one save credits the payout and resolves // the stakes it returns, so a crash can never pay the payout AND refund the // same stake — and only then stand the hand down. On a persistence failure diff --git a/internal/casino/pai_gow.go b/internal/casino/pai_gow.go new file mode 100644 index 0000000..21abf4b --- /dev/null +++ b/internal/casino/pai_gow.go @@ -0,0 +1,498 @@ +package casino + +import ( + "fmt" + "math/rand" + "strings" + "sync" +) + +type PaiGowSnapshot struct { + Config TableConfig + Phase string + Cards, High, Low, DealerHigh, DealerLow []CardView + Tiebreaker []CardView + Result string +} + +func (*PaiGowSnapshot) snapshot() {} + +type pgPlayer struct { + name string + wallet Wallet + main, fortune Wager + pushace Wager + last int + cards []card + split pgSplit + ready, disconnected, paid bool + payout int + result string +} +type paiGowTable struct { + *tableBase + mu sync.Mutex + rng *rand.Rand + players []*pgPlayer // stable seat/deal/settlement order + phase string + ticks int + dealer []card + dealerSplit pgSplit + tie [2]card +} + +func newPaiGowTable(room int, c TableConfig, rng *rand.Rand) tableGame { + return &paiGowTable{tableBase: &tableBase{RoomID: room, Config: c}, rng: rng, phase: "waiting"} +} +func (t *paiGowTable) base() *tableBase { return t.tableBase } +func (t *paiGowTable) player(name string) *pgPlayer { + for _, p := range t.players { + if p.name == name { + return p + } + } + return nil +} +func (t *paiGowTable) remove(name string) { + for i, p := range t.players { + if p.name == name { + t.players = append(t.players[:i], t.players[i+1:]...) + return + } + } +} +func (t *paiGowTable) idle() bool { t.mu.Lock(); defer t.mu.Unlock(); return len(t.players) == 0 } +func (t *paiGowTable) hasParticipant(name string) bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.player(name) != nil +} +func (t *paiGowTable) overview() string { + t.mu.Lock() + defer t.mu.Unlock() + return fmt.Sprintf("%s | %s | %d/%d seats", t.Config.Label(), t.phase, len(t.players), t.Config.MaxPlayers) +} +func (t *paiGowTable) hintsLocked(name string) []MenuCommand { + p := t.player(name) + if p == nil { + return nil + } + commands := []MenuCommand{} + switch t.phase { + case "waiting", "betting": + if p.main.Total == 0 { + commands = append(commands, MenuCommand{Command: "bet <amount>", Desc: "Main wager; opens a timed betting window"}) + } else { + if p.fortune.Total == 0 { + commands = append(commands, MenuCommand{Command: "bet <amount> fortune", Desc: "Optional Fortune side bet before deal"}) + } + if t.Config.PaiGow.Variant == PaiGowFaceUp && p.pushace.Total == 0 { + commands = append(commands, MenuCommand{Command: "bet <amount> pushace", Desc: "Optional Push Ace High side bet (max half the main wager)"}) + } + commands = append(commands, MenuCommand{Command: "deal", Desc: "Mark ready; deal when all bettors are ready"}) + } + case "setting": + if p.main.Total > 0 && !p.ready { + commands = append(commands, MenuCommand{Command: "24 / 13456", Desc: "Choose two low cards OR five high cards; locks the hand"}, MenuCommand{Command: "house", Desc: "Set your hand the house way"}) + } + case "settling": + commands = append(commands, MenuCommand{Command: "settle", Desc: "Retry the same pending payment"}) + } + return append(commands, MenuCommand{Command: "table rules", Desc: "Rules and Fortune felt"}, MenuCommand{Command: "rules", Desc: "Learn Pai Gow"}, MenuCommand{Command: "stop", Desc: "Leave after finishing your wager"}) +} +func (t *paiGowTable) hints(name string) []MenuCommand { + t.mu.Lock() + defer t.mu.Unlock() + return t.hintsLocked(name) +} +func (t *paiGowTable) wagers(name string) []WagerRow { + t.mu.Lock() + defer t.mu.Unlock() + p := t.player(name) + if p == nil || t.phase == "setting" || t.phase == "settling" { + return nil + } + rows := []WagerRow{{Bet: "main", Payout: "See table rules", Win: "Beat both dealer hands"}, {Bet: "fortune", Payout: "See Fortune felt", Win: "Best seven-card bonus; independent of main result"}} + if t.Config.PaiGow.Variant == PaiGowFaceUp { + rows = append(rows, WagerRow{Bet: "pushace", Payout: "40/15/5:1", Win: "Dealer ace-high pai gow; both 40:1"}) + } + return rows +} +func pgViews(cards []card) []CardView { + var out []CardView + for _, c := range cards { + out = append(out, CardView{Rank: c.rank, Suit: c.suit}) + } + return out +} +func (t *paiGowTable) statusLocked(name string) []Event { + s := &PaiGowSnapshot{Config: t.Config, Phase: t.phase} + if p := t.player(name); p != nil { + s.Cards = pgViews(p.cards) + s.High = pgViews(p.split.high) + s.Low = pgViews(p.split.low) + s.Result = p.result + if t.phase == "settling" && !p.paid && p.main.Total > 0 { + s.Result += "; PAYMENT PENDING (not yet credited)" + } + } + if t.phase == "settling" || t.phase == "waiting" || t.phase == "setting" && t.Config.PaiGow.Variant == PaiGowFaceUp { + s.DealerHigh = pgViews(t.dealerSplit.high) + s.DealerLow = pgViews(t.dealerSplit.low) + if len(t.dealer) > 0 && t.Config.PaiGow.Variant == PaiGowNoPush { + s.Tiebreaker = pgViews(t.tie[:]) + } + } + e := t.private(name, t.Config.Label()) + e.Snapshot = s + e.Menu = &MenuView{Heading: t.Config.Label() + " | " + t.phase, ShowBalance: true, Commands: t.hintsLocked(name)} + return []Event{e} +} +func (t *paiGowTable) status(name string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + return t.statusLocked(name) +} +func (t *paiGowTable) join(name string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if p := t.player(name); p != nil { + p.disconnected = false + return t.statusLocked(name) + } + if len(t.players) >= t.Config.MaxPlayers { + return []Event{t.private(name, "This Pai Gow table is full.")} + } + t.players = append(t.players, &pgPlayer{name: name}) + return t.statusLocked(name) +} +func (t *paiGowTable) leave(name string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if p := t.player(name); p != nil && p.main.Total > 0 { + return []Event{t.private(name, "Finish your Pai Gow round before leaving; house sets your hand.")} + } + t.remove(name) + return []Event{t.private(name, "You leave Pai Gow Poker.")} +} +func (t *paiGowTable) rebet(name string) (string, int) { + t.mu.Lock() + defer t.mu.Unlock() + if p := t.player(name); p != nil && p.last > 0 { + return "main", p.last + } + return "main", t.Config.MinBet +} +func (t *paiGowTable) bet(name, spot string, amount int, w Wallet) []Event { + t.mu.Lock() + defer t.mu.Unlock() + p := t.player(name) + if p == nil { + return nil + } + fail := func(s string) []Event { return []Event{t.private(name, s)} } + if t.phase != "waiting" && t.phase != "betting" { + return fail("Betting is closed.") + } + if spot == "" { + spot = "main" + } + if spot != "main" && spot != "fortune" && spot != "pushace" { + return fail("Bet on main or fortune.") + } + if spot == "pushace" && t.Config.PaiGow.Variant != PaiGowFaceUp { + return fail("Push Ace High is a Face Up side bet only.") + } + if spot == "main" && p.main.Total > 0 || spot == "fortune" && p.fortune.Total > 0 || spot == "pushace" && p.pushace.Total > 0 { + return fail("That wager is already committed.") + } + if spot != "main" && (p.main.Total == 0 || p.ready) { + return fail("Place a main bet first, then side bets before marking ready with deal.") + } + if spot == "pushace" && amount > p.main.Total/2 { + return fail("Push Ace High is capped at half the main wager.") + } + if amount < t.Config.MinBet || amount > t.Config.MaxBet || amount > pgMaxWager { + return fail("Wager must be " + betRangeText(t.Config.MinBet, min(t.Config.MaxBet, pgMaxWager)) + ".") + } + if w == nil { + return fail("Wallet unavailable.") + } + stake, err := w.Wager(amount) + if err != nil { + return fail(walletErrorMessage(err)) + } + p.wallet = w + if spot == "main" { + p.main = stake + p.last = amount + p.cards = nil + p.split = pgSplit{} + p.result = "" + p.ready = false + p.paid = false + p.fortune = Wager{} + p.pushace = Wager{} + } else if spot == "pushace" { + p.pushace = stake + } else { + p.fortune = stake + } + if t.phase == "waiting" { + t.phase = "betting" + t.ticks = t.Config.BettingTicks + t.dealer = nil + t.dealerSplit = pgSplit{} + } + return append(fail(fmt.Sprintf("Committed %d on %s. Betting closes in %d ticks; Fortune is optional, then deal.", amount, spot, t.ticks)), t.statusLocked(name)...) +} +func (t *paiGowTable) allReady() bool { + found := false + for _, p := range t.players { + if p.main.Total > 0 { + found = true + if !p.ready { + return false + } + } + } + return found +} +func (t *paiGowTable) dealLocked() []Event { + deck := newShoe(1, 0, t.rng) + deck.cards = append(deck.cards, card{"JK", "joker"}) + t.rng.Shuffle(len(deck.cards), func(i, j int) { deck.cards[i], deck.cards[j] = deck.cards[j], deck.cards[i] }) + // Seven packets, including unused seats, leave the published No Push + // tiebreakers at cards 50 and 51. Empty packets never qualify for Envy. + packets := [7][]card{} + for i := range packets { + for j := 0; j < 7; j++ { + packets[i] = append(packets[i], deck.draw()) + } + } + t.dealer = packets[6] + t.dealerSplit = pgHouse(t.dealer) + t.tie = [2]card{deck.draw(), deck.draw()} + t.phase = "setting" + t.ticks = t.Config.PaiGow.ActionTicks + // Face Up ace-high settles immediately: there is no decision to make. + // Still assign every player's seven cards for independent Fortune/Envy. + autoPush := t.Config.PaiGow.Variant == PaiGowFaceUp && pgDealerPush(t.dealer, PaiGowFaceUp) != "" + var events []Event + for i, p := range t.players { + if p.main.Total == 0 { + continue + } + p.cards = packets[i] + p.ready = p.disconnected || autoPush + if p.ready { + p.split = pgHouse(p.cards) + } + if !autoPush { + events = append(events, t.statusLocked(p.name)...) + } + } + if t.allReady() { + events = append(events, t.prepareSettlementLocked()...) + } + return events +} +func (t *paiGowTable) action(name, action string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + p := t.player(name) + if p == nil { + return nil + } + action = strings.TrimSpace(strings.ToLower(action)) + if action == "paytable" || action == "status" { + return t.statusLocked(name) + } + if t.phase == "settling" && action == "settle" { + return t.payLocked() + } + if action == "deal" && t.phase == "betting" && p.main.Total > 0 { + p.ready = true + if t.allReady() { + return t.dealLocked() + } + return []Event{t.private(name, "Ready. Waiting for other bettors or the betting timer.")} + } + if t.phase != "setting" || p.main.Total == 0 { + return []Event{t.private(name, "Place your bets, then deal; set cards only after they are dealt.")} + } + if p.ready { + return []Event{t.private(name, "Your hand is locked; waiting for the other players.")} + } + var split pgSplit + if action == "house" || action == "house way" || action == "set house" { + split = pgHouse(p.cards) + } else { + digits, ok := ParsePaiGowDigits(strings.TrimPrefix(action, "set ")) + if !ok { + return []Event{t.private(name, "Enter exactly two or five different numbers from 1-7 (24 or 13456), or house.")} + } + split = pgSet(p.cards, digits) + } + if !split.valid() { + return []Event{t.private(name, "FOUL HAND: your five-card high hand must be higher than your two-card low hand. Nothing was set; choose again or use house.")} + } + p.split = split + p.ready = true + if t.allReady() { + return t.prepareSettlementLocked() + } + return t.statusLocked(name) +} +func (t *paiGowTable) prepareSettlementLocked() []Event { + t.phase = "settling" + for _, p := range t.players { + if p.main.Total == 0 { + continue + } + result := pgMainResult(p.split, t.dealerSplit, t.dealer, t.Config.PaiGow.Variant, t.tie) + p.payout = pgMainPay(p.main.Total, result, t.Config.PaiGow.Variant) + p.result = "Main: " + result + if t.Config.PaiGow.Variant == PaiGowFaceUp { + rates, _ := t.Config.PaiGow.pushAceRates() + if class := pgPushAceClass(t.dealer, p.cards, rates); class.klass != "none" { + p.payout = p.pushace.Total * (class.rate + 1) + p.result += fmt.Sprintf("; Push Ace High: %s (%d:1)", class.desc, class.rate) + } else if p.pushace.Total > 0 { + p.result += "; Push Ace High: lost (dealer not ace-high)" + } + } + if p.fortune.Total > 0 { + row, win := pgFortune(p.cards, t.Config.PaiGow.Envy) + p.result += "; Fortune: " + row.Hand + if win { + p.payout += p.fortune.Total * (row.Odds + 1) + } + if t.Config.PaiGow.Envy && p.fortune.Total >= 5 { + envy := 0 + for _, other := range t.players { + if other != p && other.main.Total > 0 { + row, _ := pgFortune(other.cards, true) + envy += row.Envy + } + } + p.payout += envy + p.result += fmt.Sprintf("; Envy: %d", envy) + } + } + } + return t.payLocked() +} +func (t *paiGowTable) payLocked() []Event { + var events []Event + var newlyPaid []string + pending := false + for _, p := range t.players { + if p.main.Total == 0 || p.paid { + continue + } + ids := []string{p.main.ID} + if p.fortune.Total > 0 { + ids = append(ids, p.fortune.ID) + } + if p.pushace.Total > 0 { + ids = append(ids, p.pushace.ID) + } + // One durable transaction settles both wagers and Envy, including zero + // returns. Freeze the round on failure; retries never redeal or pay twice. + if err := p.wallet.PayCredits(p.payout, ids...); err != nil { + pending = true + events = append(events, t.private(p.name, payoutFailedMessage()+" Use settle to retry the same result.")) + continue + } + p.paid = true + p.result += fmt.Sprintf("; total returned %d credits", p.payout) + newlyPaid = append(newlyPaid, p.name) + } + if pending { + for _, name := range newlyPaid { + events = append(events, t.statusLocked(name)...) + } + return events + } + t.phase = "waiting" + kept := t.players[:0] + for _, p := range t.players { + p.main = Wager{} + p.fortune = Wager{} + p.ready = false + if !p.disconnected { + kept = append(kept, p) + } + } + t.players = kept + for _, p := range t.players { + events = append(events, t.statusLocked(p.name)...) + } + return events +} +func (t *paiGowTable) tick() []Event { + t.mu.Lock() + defer t.mu.Unlock() + switch t.phase { + case "settling": + return t.payLocked() + case "betting", "setting": + t.ticks-- + if t.ticks > 0 { + return nil + } + if t.phase == "betting" { + return t.dealLocked() + } + for _, p := range t.players { + if p.main.Total > 0 && !p.ready { + p.split = pgHouse(p.cards) + p.ready = true + } + } + return t.prepareSettlementLocked() + } + return nil +} +func (t *paiGowTable) markDisconnected(name string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + p := t.player(name) + if p == nil { + return nil + } + if p.main.Total == 0 { + t.remove(name) + return nil + } + p.disconnected = true + p.ready = true + if t.phase == "setting" { + if p.split.high == nil { + p.split = pgHouse(p.cards) + } + if t.allReady() { + return t.prepareSettlementLocked() + } + } + return nil +} +func (t *paiGowTable) refundAll() []Event { + t.mu.Lock() + defer t.mu.Unlock() + var events []Event + for _, p := range t.players { + if p.wallet == nil || p.main.Total == 0 || p.paid { + continue + } + if _, _, err := p.wallet.RefundOutstanding(); err != nil { + events = append(events, t.private(p.name, "Shutdown refund remains pending for startup recovery.")) + } else { + p.main = Wager{} + p.fortune = Wager{} + p.pushace = Wager{} + } + } + return events +} diff --git a/internal/casino/pai_gow_rules.go b/internal/casino/pai_gow_rules.go new file mode 100644 index 0000000..8a9842c --- /dev/null +++ b/internal/casino/pai_gow_rules.go @@ -0,0 +1,503 @@ +package casino + +import ( + "fmt" + "sort" + "strings" + "unicode" +) + +const ( + PaiGowClassic = "classic" + PaiGowEZ = "ez" + PaiGowNoPush = "no_push" + PaiGowFaceUp = "face_up" + // Two independently funded wagers plus all five Envy bonuses must fit + // MaxCredits. 2000 conservatively exceeds the maximum combined multiplier. + pgMaxWager = MaxCredits / 2000 +) + +type PaiGowConfig struct { + Variant string `yaml:"variant,omitempty"` + Envy bool `yaml:"envy,omitempty"` + PushAceHigh string `yaml:"push_ace_high,omitempty"` // Face Up only: 5_15_40 or 5_12_40 + ActionTicks int `yaml:"action_ticks,omitempty"` +} + +func (c PaiGowConfig) Normalize() PaiGowConfig { + if c.Variant == "" { + c.Variant = PaiGowClassic + } + if c.ActionTicks == 0 { + c.ActionTicks = 60 + } + return c +} + +// pushAceRates returns the three published rates (plain/joker/both) for a +// Face Up table's configured schedule; ok is false on any other value. +func (c PaiGowConfig) pushAceRates() ([3]int, bool) { + switch c.Normalize().PushAceHigh { + case "5_15_40": + return [3]int{5, 15, 40}, true + case "5_12_40": + return [3]int{5, 12, 40}, true + } + return [3]int{}, false +} +func validatePaiGow(c TableConfig) error { + r := c.PaiGow.Normalize() + if c.MinBet > pgMaxWager { + return fmt.Errorf("pai_gow min_bet must not exceed %d", pgMaxWager) + } + if r.Variant != PaiGowClassic && r.Variant != PaiGowEZ && r.Variant != PaiGowNoPush && r.Variant != PaiGowFaceUp { + return fmt.Errorf("pai_gow variant must be classic, ez, no_push, or face_up") + } + if c.MaxPlayers > 6 { + return fmt.Errorf("pai gow allows at most six players") + } + if r.ActionTicks < 1 || r.ActionTicks > 600 { + return fmt.Errorf("pai_gow action_ticks must be 1-600") + } + if r.PushAceHigh != "" { + if r.Variant != PaiGowFaceUp { + return fmt.Errorf("push_ace_high belongs on a face_up table") + } + if _, ok := r.pushAceRates(); !ok { + return fmt.Errorf("push_ace_high must be 5_15_40 or 5_12_40") + } + } + if r.Variant == PaiGowFaceUp && r.PushAceHigh == "" { + return fmt.Errorf("face_up requires push_ace_high 5_15_40 or 5_12_40") + } + return nil +} +func (c PaiGowConfig) Label() string { + switch c.Normalize().Variant { + case PaiGowEZ: + return "EZ Pai Gow Poker" + case PaiGowNoPush: + return "No Push Pai Gow Poker" + case PaiGowFaceUp: + return "Face Up Pai Gow Poker" + } + return "Pai Gow Poker" +} +func paiGowRules(c TableConfig) string { + r := c.PaiGow.Normalize() + s := r.Label() + "\nSeven cards from a fresh 53-card deck. Joker is an ace unless completing a straight, flush or straight flush. A2345 is the second-highest straight.\nSet two cards low and five high: 24 chooses low cards 2/4; 13456 chooses the high hand. High must strictly outrank low. Copies go to the dealer.\nBoth hands win: win; both lose: loss; one wins: push." + switch r.Variant { + case PaiGowClassic: + s += " Wins pay 0.95:1 (profit rounded down, minimum one credit)." + case PaiGowEZ: + s += " No commission; dealer's best five-card hand exactly queen-high pushes all main bets." + case PaiGowFaceUp: + s += " No commission; dealer sets and reveals both hands before you set yours. Dealer's best five-card hand exactly ace-high automatically pushes all main bets; Fortune and Envy still settle." + case PaiGowNoPush: + s += " No commission; split results use two extra cards: player must beat dealer, ties lose; Joker is ace." + } + s += "\nHouse way: preserve the best high-hand category, maximize low within it; split two pair, full houses and trips-pairs when this improves low to a pair. Never foul. Use house or set <digits>; set locks your hand. Timeout/disconnect uses house way.\nBet main first, optionally bet <amount> fortune before dealing. Fortune uses all seven cards regardless of split; highest listed prize only, profit odds plus stake returned." + if r.Envy { + s += "\nEnvy: Fortune stake of at least 5 qualifies for fixed bonuses on OTHER active players' hands, never your own or dealer; multiple bonuses add." + } else { + s += "\nEnvy off: four of a kind pays 30:1 instead of 25:1 (custom enhancement)." + } + return s +} + +// ParsePaiGowDigits accepts exactly two or five UNIQUE card positions; unlike +// video poker toggles, repeated digits are an error rather than silently ignored. +func ParsePaiGowDigits(s string) ([]int, bool) { + var out []int + var seen [7]bool + for _, r := range s { + if unicode.IsSpace(r) { + continue + } + if r < '1' || r > '7' || seen[r-'1'] { + return nil, false + } + seen[r-'1'] = true + out = append(out, int(r-'1')) + } + return out, len(out) == 2 || len(out) == 5 +} + +// Lexicographic category and kickers. Zero padding permits comparisons between +// five and two cards without treating a two-card flush/straight as a hand. +type pgRank [6]int + +func pgCompare(a, b pgRank) int { + for i := range a { + if a[i] > b[i] { + return 1 + } + if a[i] < b[i] { + return -1 + } + } + return 0 +} +func pgNatural(cards []card) pgRank { + counts := [15]int{} + flush := len(cards) == 5 + for _, c := range cards { + counts[pokerRank(c)]++ + if c.suit != cards[0].suit { + flush = false + } + } + var ranks []int + for r := 14; r >= 2; r-- { + if counts[r] > 0 { + ranks = append(ranks, r) + } + } + sort.SliceStable(ranks, func(i, j int) bool { return counts[ranks[i]] > counts[ranks[j]] }) + straight := 0 + if len(cards) == 5 && len(ranks) == 5 { + sorted := append([]int(nil), ranks...) + sort.Ints(sorted) + if sorted[4]-sorted[0] == 4 { + straight = sorted[4] + } + if sorted[0] == 2 && sorted[1] == 3 && sorted[2] == 4 && sorted[3] == 5 && sorted[4] == 14 { + straight = 15 + } + if straight == 14 { + straight = 16 + } + } + out := pgRank{} + switch { + case counts[ranks[0]] == 5: + out[0] = 9 + case straight > 0 && flush: + out[0] = 8 + out[1] = straight + return out + case counts[ranks[0]] == 4: + out[0] = 7 + case len(ranks) > 1 && counts[ranks[0]] == 3 && counts[ranks[1]] == 2: + out[0] = 6 + case flush: + out[0] = 5 + case straight > 0: + out[0] = 4 + out[1] = straight + return out + case counts[ranks[0]] == 3: + out[0] = 3 + case len(ranks) > 1 && counts[ranks[0]] == 2 && counts[ranks[1]] == 2: + out[0] = 2 + case counts[ranks[0]] == 2: + out[0] = 1 + } + copy(out[1:], ranks) + return out +} +func pgEvaluate(cards []card) pgRank { + j := -1 + for i, c := range cards { + if c.rank == "JK" { + j = i + break + } + } + if j < 0 { + return pgNatural(cards) + } + copyCards := append([]card(nil), cards...) + copyCards[j] = card{"A", "joker"} + best := pgNatural(copyCards) + if len(cards) != 5 { + return best + } + for _, s := range []string{"clubs", "diamonds", "hearts", "spades"} { + for _, r := range []string{"2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A"} { + replacement := card{r, s} + duplicate := false + for i, c := range cards { + if i != j && c == replacement { + duplicate = true + } + } + if duplicate { + continue + } + copyCards[j] = replacement + v := pgNatural(copyCards) + if (v[0] == 4 || v[0] == 5 || v[0] == 8) && pgCompare(v, best) > 0 { + best = v + } + } + } + return best +} + +type pgSplit struct{ high, low []card } + +func pgSet(cards []card, digits []int) pgSplit { + var selected [7]bool + for _, i := range digits { + selected[i] = true + } + s := pgSplit{} + for i, c := range cards { + if selected[i] == (len(digits) == 2) { + s.low = append(s.low, c) + } else { + s.high = append(s.high, c) + } + } + return s +} +func (s pgSplit) valid() bool { + return len(s.high) == 5 && len(s.low) == 2 && pgCompare(pgEvaluate(s.high), pgEvaluate(s.low)) > 0 +} +func pgHouse(cards []card) pgSplit { + var options []pgSplit + best := pgRank{} + for i := 0; i < 7; i++ { + for j := i + 1; j < 7; j++ { + s := pgSet(cards, []int{i, j}) + if !s.valid() { + continue + } + options = append(options, s) + if pgCompare(pgEvaluate(s.high), best) > 0 { + best = pgEvaluate(s.high) + } + } + } + var chosen pgSplit + var low, high pgRank + for _, s := range options { + h, l := pgEvaluate(s.high), pgEvaluate(s.low) + eligible := h[0] == best[0] + // Split two pair/full house only for a paired front with at least a pair behind. + if (best[0] == 2 || best[0] == 6) && l[0] == 1 && h[0] >= 1 { + eligible = true + } + if !eligible { + continue + } + if chosen.high == nil || pgCompare(l, low) > 0 || pgCompare(l, low) == 0 && pgCompare(h, high) > 0 { + chosen, low, high = s, l, h + } + } + return chosen +} + +type FortuneRow struct { + Hand string + Odds, Envy int +} + +// Wizard of Odds Fortune table 8, the lowest-edge published fixed schedule +// among its eight tables. With Envy disabled, quads receive a custom +5. +func FortunePayTable(envy bool) []FortuneRow { + rows := []FortuneRow{{"Natural seven-card straight flush", 1000, 500}, {"Royal flush + royal match", 750, 250}, {"Wild seven-card straight flush", 500, 150}, {"Five aces", 250, 100}, {"Royal flush", 150, 50}, {"Straight flush", 50, 20}, {"Four of a kind", 25, 5}, {"Full house", 5, 0}, {"Flush", 4, 0}, {"Three of a kind", 3, 0}, {"Straight", 2, 0}, {"Three pair", 1, 0}} + if !envy { + rows[6].Odds = 30 + for i := range rows { + rows[i].Envy = 0 + } + } + return rows +} +func pgSevenFlush(cards []card) bool { + suit := "" + counts := [15]int{} + wild := 0 + for _, c := range cards { + if c.rank == "JK" { + wild++ + continue + } + if suit == "" { + suit = c.suit + } + if suit != c.suit { + return false + } + counts[pokerRank(c)]++ + } + for high := 7; high <= 14; high++ { + missing := 0 + for r := high - 6; r <= high; r++ { + v := r + if v == 1 { + v = 14 + } + if counts[v] == 0 { + missing++ + } + } + if missing == wild { + return true + } + } + return false +} +func pgFortune(cards []card, envy bool) (FortuneRow, bool) { + rows := FortunePayTable(envy) + joker := false + counts := [15]int{} + for _, c := range cards { + if c.rank == "JK" { + joker = true + counts[14]++ + } else { + counts[pokerRank(c)]++ + } + } + if pgSevenFlush(cards) && !joker { + return rows[0], true + } + bestIndex := len(rows) + // Enumerate all 21 five-card subsets. Fortune ranks trips above straights, + // so choose by pay-table priority, NOT conventional poker rank. + for i := 0; i < 7; i++ { + for j := i + 1; j < 7; j++ { + s := pgSet(cards, []int{i, j}) + v := pgEvaluate(s.high) + index := len(rows) + switch v[0] { + case 9: + index = 3 + case 8: + if v[1] == 16 { + index = 4 + if s.low[0].suit == s.low[1].suit && (s.low[0].rank == "K" && s.low[1].rank == "Q" || s.low[0].rank == "Q" && s.low[1].rank == "K") { + index = 1 + } + } else { + index = 5 + } + case 7: + index = 6 + case 6: + index = 7 + case 5: + index = 8 + case 3: + index = 9 + case 4: + index = 10 + } + if index < bestIndex { + bestIndex = index + } + } + } + if joker && pgSevenFlush(cards) && bestIndex > 2 { + bestIndex = 2 + } + pairs := 0 + for _, n := range counts { + if n >= 2 { + pairs++ + } + } + if pairs >= 3 && bestIndex > 11 { + bestIndex = 11 + } + if bestIndex == len(rows) { + return FortuneRow{Hand: "No Fortune win"}, false + } + return rows[bestIndex], true +} +func pgBest(cards []card) pgRank { + var best pgRank + for i := 0; i < 7; i++ { + for j := i + 1; j < 7; j++ { + v := pgEvaluate(pgSet(cards, []int{i, j}).high) + if pgCompare(v, best) > 0 { + best = v + } + } + } + return best +} + +// Automatic pushes use the best of all seven dealer cards, not just the +// chosen high hand. Pair/straight/flush hands headed by an ace do not qualify. +func pgDealerPush(cards []card, variant string) string { + if variant != PaiGowEZ && variant != PaiGowFaceUp { + return "" + } + v := pgBest(cards) + if v[0] == 0 { + if variant == PaiGowEZ && v[1] == 12 { + return "push (dealer queen-high)" + } + if variant == PaiGowFaceUp && v[1] == 14 { + return "push (dealer ace-high)" + } + } + return "" +} +func pgMainResult(player, dealer pgSplit, dealerCards []card, variant string, tie [2]card) string { + if push := pgDealerPush(dealerCards, variant); push != "" { + return push + } + high := pgCompare(pgEvaluate(player.high), pgEvaluate(dealer.high)) > 0 + low := pgCompare(pgEvaluate(player.low), pgEvaluate(dealer.low)) > 0 + if high && low { + return "win" + } + if !high && !low { + return "loss" + } + if variant == PaiGowNoPush { + rank := func(c card) int { + if c.rank == "JK" { + return 14 + } + return pokerRank(c) + } + if rank(tie[0]) > rank(tie[1]) { + return "win (tiebreaker)" + } + return "loss (tiebreaker)" + } + return "push" +} + +// pgPushAceClass resolves Face Up's optional Push Ace High side bet. +// The dealer's seven cards decide the base event at reveal time; ace-high +// with a Joker pays 15:1, without a Joker 5:1, and both sides ace-high 40:1 +type pgPushAce struct { + klass string // "both", "joker", "plain", or "none" + rate int + desc string +} + +func pgPushAceClass(dealer, player []card, rates [3]int) pgPushAce { + if pgBest(dealer)[0] != 0 || pgBest(dealer)[1] != 14 { + return pgPushAce{klass: "none"} + } + for _, c := range dealer { + if c.rank == "JK" { + return pgPushAce{klass: "joker", rate: rates[1], desc: "dealer ace-high with Joker"} + } + } + if pgBest(player)[0] == 0 && pgBest(player)[1] == 14 { + return pgPushAce{klass: "both", rate: rates[2], desc: "both ace-high"} + } + return pgPushAce{klass: "plain", rate: rates[0], desc: "dealer ace-high without Joker"} +} + +func pgMainPay(stake int, result, variant string) int { + if strings.HasPrefix(result, "push") { + return stake + } + if !strings.HasPrefix(result, "win") { + return 0 + } + if variant == PaiGowClassic { + return stake + max(1, stake*95/100) + } + return stake * 2 +} diff --git a/internal/casino/pai_gow_test.go b/internal/casino/pai_gow_test.go new file mode 100644 index 0000000..f258b04 --- /dev/null +++ b/internal/casino/pai_gow_test.go @@ -0,0 +1,464 @@ +package casino + +import ( + "math" + "math/rand" + "reflect" + "strings" + "testing" +) + +func pgCards(s string) []card { + var out []card + for _, v := range strings.Fields(s) { + if v == "JK" { + out = append(out, card{"JK", "joker"}) + continue + } + suit := map[byte]string{'s': "spades", 'h': "hearts", 'd': "diamonds", 'c': "clubs"}[v[len(v)-1]] + out = append(out, card{v[:len(v)-1], suit}) + } + return out +} +func TestPaiGowParsingAndFouls(t *testing.T) { + for _, s := range []string{"24", "42", "2 4", "13456", "6 5 4 3 1"} { + d, ok := ParsePaiGowDigits(s) + if !ok { + t.Fatal(s) + } + split := pgSet(pgCards("As Ah 2c 3d 4s 7h 9c"), d) + if len(split.high) != 5 || len(split.low) != 2 { + t.Fatal(split) + } + } + for _, s := range []string{"", "2", "123", "1234", "123456", "1234567", "22", "11234", "08", "28", "-24", "2x4"} { + if _, ok := ParsePaiGowDigits(s); ok { + t.Fatalf("accepted %q", s) + } + } + cards := pgCards("As Ah 2c 3d 4s 7h 9c") + if pgSet(cards, []int{0, 1}).valid() { + t.Fatal("pair low above high-card high") + } + if !pgSet(cards, []int{5, 6}).valid() { + t.Fatal("legal pair high") + } + if !reflect.DeepEqual(pgSet(cards, []int{1, 3}), pgSet(cards, []int{0, 2, 4, 5, 6})) { + t.Fatal("complement selection differs") + } +} +func TestPaiGowRanksAndBug(t *testing.T) { + cases := []struct { + cards string + rank pgRank + }{ + {"As Ah", pgRank{1, 14}}, {"JK Kh", pgRank{0, 14, 13}}, + {"Ks Qh Jc 10d 9s", pgRank{4, 13}}, {"As 2h 3c 4d 5s", pgRank{4, 15}}, {"As Kh Qc Jd 10s", pgRank{4, 16}}, + {"Ks Kh Kd JK 2s", pgRank{3, 13, 14, 2}}, // Joker cannot become a fourth king. + {"As Ah Ad Ac JK", pgRank{9, 14}}, + {"2h 5h 8h Kh JK", pgRank{5, 14, 13, 8, 5, 2}}, + {"10s Js Qs Ks JK", pgRank{8, 16}}, {"As 2h 3c 4d JK", pgRank{4, 15}}, + } + for _, tt := range cases { + if got := pgEvaluate(pgCards(tt.cards)); got != tt.rank { + t.Errorf("%s = %v want %v", tt.cards, got, tt.rank) + } + } +} +func TestPaiGowFortuneCategories(t *testing.T) { + cases := []struct { + cards, hand string + odds int + }{ + {"2s 3s 4s 5s 6s 7s 8s", "Natural seven-card straight flush", 1000}, + {"As 2s 3s 4s 5s 6s 7s", "Natural seven-card straight flush", 1000}, + {"10s Js Qs Ks As Kh Qh", "Royal flush + royal match", 750}, + {"10s Js Qs Ks JK Kh Qh", "Royal flush + royal match", 750}, + {"2s 3s 4s 5s 6s 8s JK", "Wild seven-card straight flush", 500}, + {"As Ah Ac Ad JK 2c 3s", "Five aces", 250}, + {"10s Js Qs Ks As 2h 3d", "Royal flush", 150}, + {"2s 3s 4s 5s 6s 8h 9d", "Straight flush", 50}, + {"Ks Kh Kc Kd 2s 3c JK", "Four of a kind", 25}, + {"Ks Kh Kc Qd Qs 3c 8h", "Full house", 5}, + {"2s 4s 6s 8s Ks 3h 9d", "Flush", 4}, + {"2s 3h 4c 5d 6s 6h 6c", "Three of a kind", 3}, // trips outrank straight for Fortune + {"2s 3h 4c 5d 6s 9h Kc", "Straight", 2}, + {"As Ah Kc Kd 2s 2h 7c", "Three pair", 1}, + {"2s 4h 6c 8d Js Qh Ac", "No Fortune win", 0}, + } + for _, tt := range cases { + row, win := pgFortune(pgCards(tt.cards), true) + if row.Hand != tt.hand || row.Odds != tt.odds || win != (tt.odds > 0) { + t.Errorf("%s: %+v %v", tt.cards, row, win) + } + } + rows := FortunePayTable(false) + if rows[6].Odds != 30 { + t.Fatal(rows) + } + for _, r := range rows { + if r.Envy != 0 { + t.Fatal(r) + } + } +} + +// Published category combination counts also check that the effective +// schedules and the documented theoretical edges cannot silently drift. +func TestPaiGowFortunePublishedEdge(t *testing.T) { + counts := []int{32, 72, 196, 1128, 26020, 184644, 307472, 4188528, 6172088, 7672500, 11034204, 2862000} + for _, envy := range []bool{false, true} { + profit := -121694196 + for i, row := range FortunePayTable(envy) { + profit += row.Odds * counts[i] + } + edge := -float64(profit) / 154143080 + want := 0.034318 + if envy { + want = 0.044292 + } + if math.Abs(edge-want) > 0.000001 { + t.Fatalf("envy %v edge %g", envy, edge) + } + } +} +func TestPaiGowHouseNeverFouls(t *testing.T) { + rng := rand.New(rand.NewSource(21)) + for n := 0; n < 500; n++ { + deck := newShoe(1, 0, rng) + cards := append([]card(nil), deck.cards[:7]...) + if n%2 == 0 { + cards[0] = card{"JK", "joker"} + } + s := pgHouse(cards) + if !s.valid() { + t.Fatalf("foul %v: %+v", cards, s) + } + if !reflect.DeepEqual(s, pgHouse(cards)) { + t.Fatal("nondeterministic house way") + } + } + s := pgHouse(pgCards("As Ah Ks Kh 2s 4h 7c")) + if pgEvaluate(s.low)[0] != 1 || pgEvaluate(s.high)[0] != 1 { + t.Fatal("house should split two pair", s) + } +} +func TestPaiGowVariants(t *testing.T) { + dealer := pgSplit{high: pgCards("Ks Kh 8s 7d 2c"), low: pgCards("Qs Jh")} + win := pgSplit{high: pgCards("As Ah 8h 6d 3c"), low: pgCards("Ks Qh")} + split := pgSplit{high: win.high, low: pgCards("10s 9h")} + for _, variant := range []string{PaiGowClassic, PaiGowEZ, PaiGowNoPush} { + if pgMainResult(win, dealer, append(append([]card(nil), dealer.high...), dealer.low...), variant, [2]card{}) != "win" { + t.Fatal(variant) + } + } + if pgMainResult(dealer, dealer, nil, PaiGowClassic, [2]card{}) != "loss" { + t.Fatal("copies must lose") + } + if pgMainResult(split, dealer, nil, PaiGowClassic, [2]card{}) != "push" { + t.Fatal("split should push") + } + for _, tt := range []struct{ cards, result string }{{"Ks Qh", "win (tiebreaker)"}, {"Ks Kh", "loss (tiebreaker)"}, {"JK As", "loss (tiebreaker)"}, {"JK Ks", "win (tiebreaker)"}} { + cards := pgCards(tt.cards) + if got := pgMainResult(split, dealer, nil, PaiGowNoPush, [2]card{cards[0], cards[1]}); got != tt.result { + t.Fatal(tt, got) + } + } + queen := pgCards("Qs Jh 9c 8d 6s 4h 2c") + if got := pgMainResult(win, dealer, queen, PaiGowEZ, [2]card{}); got != "push (dealer queen-high)" { + t.Fatal(got) + } + if pgMainPay(20, "win", PaiGowClassic) != 39 || pgMainPay(20, "win", PaiGowEZ) != 40 || pgMainPay(20, "push", PaiGowEZ) != 20 || pgMainPay(20, "loss", PaiGowClassic) != 0 { + t.Fatal("wrong payout") + } +} +func pgTestTable(envy bool) *paiGowTable { + cfg := TableConfig{ID: "pg", Game: GamePaiGow, BettingTicks: 2, PaiGow: PaiGowConfig{Envy: envy, ActionTicks: 2}}.Normalize() + return newPaiGowTable(1, cfg, rand.New(rand.NewSource(3))).(*paiGowTable) +} +func TestPaiGowLifecyclePrivacyAndUniqueDeck(t *testing.T) { + tab := pgTestTable(true) + wallets := map[string]*testWallet{} + for _, name := range []string{"a", "b", "c", "d", "e", "f"} { + tab.join(name) + w := &testWallet{chips: 100} + wallets[name] = w + tab.bet(name, "main", 10, w) + tab.bet(name, "fortune", 5, w) + } + tab.join("overflow") + if tab.hasParticipant("overflow") { + t.Fatal("seventh seat") + } + tab.tick() + events := tab.tick() + if tab.phase != "setting" { + t.Fatal(tab.phase) + } + seen := map[card]bool{} + for _, p := range tab.players { + for _, c := range p.cards { + if seen[c] { + t.Fatal("duplicate", c) + } + seen[c] = true + } + } + for _, c := range append(append([]card(nil), tab.dealer...), tab.tie[:]...) { + if seen[c] { + t.Fatal("duplicate", c) + } + seen[c] = true + } + if len(seen) != 51 { + t.Fatal(len(seen)) + } + for _, e := range events { + if e.Public || e.PrivateTo == "" { + t.Fatal("private cards leaked") + } + s := e.Snapshot.(*PaiGowSnapshot) + if len(s.DealerHigh) > 0 || len(s.Tiebreaker) > 0 { + t.Fatal("dealer revealed before set") + } + } + tab.leave("a") + if !tab.hasParticipant("a") { + t.Fatal("left committed round") + } + before := wallets["a"].chips + tab.bet("a", "fortune", 5, wallets["a"]) + if wallets["a"].chips != before { + t.Fatal("late bet charged") + } + tab.markDisconnected("a") + tab.tick() + tab.tick() + if tab.phase != "waiting" || tab.hasParticipant("a") { + t.Fatal("disconnect/timeout did not finish") + } + for _, w := range wallets { + if len(w.open) > 0 { + t.Fatal("unresolved stakes") + } + } +} + +type pgFailWallet struct { + testWallet + fail bool + calls int + ids []string +} + +func (w *pgFailWallet) PayCredits(n int, ids ...string) error { + w.calls++ + if w.fail { + return ErrWalletPersistence + } + w.ids = append([]string(nil), ids...) + return w.testWallet.PayCredits(n, ids...) +} +func TestPaiGowAtomicRetryAndEnvy(t *testing.T) { + tab := pgTestTable(true) + a := &pgFailWallet{testWallet: testWallet{chips: 100}, fail: true} + b := &pgFailWallet{testWallet: testWallet{chips: 100}} + tab.join("a") + tab.join("b") + tab.bet("a", "main", 20, a) + tab.bet("a", "fortune", 5, a) + tab.bet("b", "main", 20, b) + tab.action("a", "deal") + tab.action("b", "deal") + // Script the dealt cards before either player sets. a loses Fortune but + // collects b's fixed quad Envy, even though b didn't bet Fortune. + tab.player("a").cards = pgCards("2s 4h 6c 8d Js Qh Ac") + tab.player("b").cards = pgCards("Ks Kh Kc Kd 2h 3c 7h") + tab.action("a", "house") + tab.action("b", "house") + if tab.phase != "settling" || len(a.open) != 2 || len(b.open) != 0 || b.calls != 1 { + t.Fatal("failed settlement wasn't held atomically") + } + p := tab.player("a") + if !strings.Contains(p.result, "Envy: 5") { + t.Fatal(p.result) + } + cards := append([]card(nil), p.cards...) + amount := p.payout + tab.action("a", "house") + if !reflect.DeepEqual(cards, p.cards) { + t.Fatal("redrew pending result") + } + a.fail = false + tab.action("a", "settle") + if tab.phase != "waiting" || a.paid != amount || len(a.ids) != 2 || len(a.open) != 0 || b.calls != 1 { + t.Fatal("retry did not settle exactly once") + } + tab.tick() + if a.calls != 2 || b.calls != 1 { + t.Fatal("double payout") + } +} +func TestPaiGowRejectedFoulDoesNotLock(t *testing.T) { + tab := pgTestTable(false) + w := &testWallet{chips: 100} + tab.join("a") + tab.bet("a", "", 10, w) + tab.action("a", "deal") + p := tab.player("a") + p.cards = pgCards("As Ah 2c 3d 4s 7h 9c") + events := tab.action("a", "12") + if p.ready || p.split.high != nil || !strings.Contains(events[0].Message, "FOUL HAND") { + t.Fatal(events) + } + tab.action("a", "34567") + if p.ready { + t.Fatal("complement foul should fail") + } + tab.action("a", "67") + if tab.phase != "waiting" { + t.Fatal("legal set did not settle") + } +} +func TestPaiGowEnvyEligibilityAndMultipleAwards(t *testing.T) { + for _, tt := range []struct { + enabled bool + stake, envy int + }{{true, 5, 25}, {true, 4, 0}, {false, 5, 0}} { + tab := pgTestTable(tt.enabled) + for _, name := range []string{"a", "b", "c"} { + tab.join(name) + w := &testWallet{chips: 100} + tab.bet(name, "main", 20, w) + if name == "a" { + tab.bet(name, "fortune", tt.stake, w) + } + } + tab.tick() + tab.tick() + // Own royal and dealer seven-card straight flush must NOT earn Envy. + tab.player("a").cards = pgCards("10s Js Qs Ks As 2h 3d") + tab.player("b").cards = pgCards("Ks Kh Kc Kd 2h 3c 7h") + tab.player("c").cards = pgCards("2s 3s 4s 5s 6s 8h 9d") + tab.dealer = pgCards("2h 3h 4h 5h 6h 7h 8h") + tab.dealerSplit = pgHouse(tab.dealer) + for _, name := range []string{"a", "b", "c"} { + tab.action(name, "house") + } + p := tab.player("a") + main := pgMainPay(20, pgMainResult(p.split, tab.dealerSplit, tab.dealer, PaiGowClassic, tab.tie), PaiGowClassic) + if want := main + tt.stake*151 + tt.envy; p.payout != want { + t.Fatalf("%+v payout=%d want=%d", tt, p.payout, want) + } + } +} +func TestPaiGowFaceUpAndPushAceHigh(t *testing.T) { + // A pair of aces or an ace-headed flush is NOT an ace-high pai gow; only + // a genuine best-five=ace-high triggers the automatic push. + aceHigh := pgCards("As Ks Qh 9c 7d 4s 3h") + pairAces := pgCards("As Ah Ks Qh 9c 7d 3s") + flushAce := pgCards("Ah Kh Qh Jh 9h 3d 2c") + for _, c := range []struct { + cards []card + push bool + }{{aceHigh, true}, {pairAces, false}, {flushAce, false}} { + if got := pgDealerPush(c.cards, PaiGowFaceUp) != ""; got != c.push { + t.Fatalf("%v push=%v", c.cards, got) + } + } + if pgDealerPush(aceHigh, PaiGowEZ) != "" { + t.Fatal("ace-high must not trigger the EZ queen-high push") + } + if pgDealerPush(pgCards("Qs Jh 9c 8d 6s 4h 2c"), PaiGowEZ) == "" { + t.Fatal("EZ queen-high detection drifted") + } + rates, _ := (PaiGowConfig{PushAceHigh: "5_12_40"}).pushAceRates() + if rates[0] != 5 || rates[1] != 12 { + t.Fatal(rates) + } + // Side-bet classes: plain, with-Joker, both-ace-high, and no win. + for _, tt := range []struct { + dealer, player []card + klass string + rate int + }{{ + pgCards("As Kh Qd 9c 7d 4s 3h"), pgCards("2h 3c 4d 5s 6h 7d 8c"), "plain", 5, + }, { + pgCards("JK Kh Qd 9c 7d 4s 2h"), pgCards("2h 3c 4d 5s 6h 7d 8c"), "joker", 15, + }, { + pgCards("Ah Kh Qd 9c 7d 4s 3h"), pgCards("Ad Kd Qh Jc 9s 5d 2c"), "both", 40, + }, { + pgCards("2h 3c 4d 5s 6h 7d 8c"), pgCards("As Kh Qd 9c 7d 4s 3h"), "none", 0, + }} { + got := pgPushAceClass(tt.dealer, tt.player, [3]int{5, 15, 40}) + if got.klass != tt.klass || got.rate != tt.rate { + t.Fatalf("class %v want %v/%d", got, tt.klass, tt.rate) + } + } + // Full engine pass on a Face Up table with the side bet. + cfg := TableConfig{ID: "pg", Game: GamePaiGow, BettingTicks: 1, PaiGow: PaiGowConfig{Variant: PaiGowFaceUp, PushAceHigh: "5_15_40", Envy: true, ActionTicks: 1}}.Normalize() + tab := newPaiGowTable(1, cfg, rand.New(rand.NewSource(5))).(*paiGowTable) + w := &testWallet{chips: 500} + tab.join("a") + tab.bet("a", "pushace", 10, w) + if len(w.open) > 0 { + t.Fatal("pushace before main") + } + tab.bet("a", "main", 20, w) + if events := tab.bet("a", "pushace", 11, w); len(w.open) != 1 || !strings.Contains(events[0].Message, "half the main wager") { + t.Fatal("pushace cap not enforced", events, w.open) + } + tab.bet("a", "pushace", 10, w) + tab.bet("a", "fortune", 5, w) + tab.action("a", "deal") + snap := tab.status("a")[0].Snapshot.(*PaiGowSnapshot) + // The dealer acts first and the reveal happens in the setting phase. + if len(snap.DealerHigh) != 5 || len(snap.DealerLow) != 2 { + t.Fatal("Face Up dealer was not revealed during setting") + } + // The player must still set (or time out) unless the round auto-pushed. + if tab.phase == "waiting" { + t.Fatal("round resolved before player set") + } + tab.action("a", "house") + if tab.phase != "waiting" || len(w.open) != 0 { + t.Fatal("Face Up settlement did not finish", tab.phase, w.open) + } + // Reconstruct the outcome class from the announced result. + p := tab.player("a") + main := pgMainPay(20, pgMainResult(p.split, tab.dealerSplit, tab.dealer, PaiGowFaceUp, tab.tie), PaiGowFaceUp) + expect := main + if row, win := pgFortune(p.cards, true); win { + expect += 5 * (row.Odds + 1) // the Fortune stake, before settlement resets it + } + if class := pgPushAceClass(tab.dealer, p.cards, [3]int{5, 15, 40}); class.klass != "none" { + expect += p.pushace.Total * (class.rate + 1) + } + if p.payout != expect { + t.Fatalf("payout %d want %d (dealer %v, result %s)", p.payout, expect, tab.dealer, p.result) + } +} +func TestPaiGowConfigurationAndRefund(t *testing.T) { + for _, c := range []TableConfig{{ID: "pg", Game: GamePaiGow, MaxPlayers: 7}, {ID: "pg", Game: GamePaiGow, PaiGow: PaiGowConfig{Variant: "fake"}}, {ID: "pg", Game: GamePaiGow, PaiGow: PaiGowConfig{ActionTicks: -1}}, {ID: "pg", Game: GamePaiGow, PaiGow: PaiGowConfig{Variant: PaiGowFaceUp}}, {ID: "pg", Game: GamePaiGow, PaiGow: PaiGowConfig{Variant: PaiGowFaceUp, PushAceHigh: "9_9_9"}}, {ID: "pg", Game: GamePaiGow, PaiGow: PaiGowConfig{Variant: PaiGowEZ, PushAceHigh: "5_15_40"}}, {ID: "bj", Game: GameBlackjack, PaiGow: PaiGowConfig{Envy: true}}} { + if c.Validate() == nil { + t.Fatal("accepted", c) + } + } + c := TableConfig{ID: "pg", Game: GamePaiGow} + if c.Validate() != nil || c.Normalize().MaxPlayers != 6 { + t.Fatal("defaults") + } + tab := pgTestTable(false) + w := &testWallet{chips: 100} + tab.join("a") + tab.bet("a", "fortune", 5, w) + if len(w.open) > 0 { + t.Fatal("side before main") + } + tab.bet("a", "main", 10, w) + tab.bet("a", "fortune", 5, w) + tab.refundAll() + if w.chips != 100 || len(w.open) > 0 { + t.Fatal("refund failed") + } +} diff --git a/internal/casino/registry.go b/internal/casino/registry.go index dfce704..25fbce5 100644 --- a/internal/casino/registry.go +++ b/internal/casino/registry.go @@ -41,6 +41,20 @@ var tableGames map[GameName]tableGameDef func init() { tableGames = map[GameName]tableGameDef{ + GamePaiGow: { + validate: validatePaiGow, + normalize: func(c TableConfig) TableConfig { + c.PaiGow = c.PaiGow.Normalize() + c.MaxPlayers = min(c.MaxPlayers, 6) + c.MaxBet = min(c.MaxBet, pgMaxWager) + return c + }, + ruleBlockName: "pai_gow", + hasRuleBlock: func(c TableConfig) bool { return c.PaiGow != (PaiGowConfig{}) }, + new: newPaiGowTable, + ruleText: paiGowRules, + helpTopic: func(TableConfig) string { return "pai gow poker" }, + }, GameVideoPoker: { validate: func(c TableConfig) error { if c.MaxPlayers > 1 { diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go index 7c3ab36..55c491b 100644 --- a/internal/casino/table_ux.go +++ b/internal/casino/table_ux.go @@ -40,6 +40,9 @@ func withTableHints(commands []MenuCommand, wagerRows []WagerRow) []MenuCommand func (c TableConfig) RulesText() string { return rulesTextOnTable(c) } func (c TableConfig) Label() string { + if c.Game == GamePaiGow { + return c.PaiGow.Label() + } if c.Game == GameVideoPoker { return c.VideoPoker.CabinetLabel() } diff --git a/internal/casino/types.go b/internal/casino/types.go index b863ee7..1b0826a 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -14,6 +14,7 @@ func betRangeText(minBet, maxBet int) string { type GameName string const ( + GamePaiGow GameName = "pai_gow" GameVideoPoker GameName = "video_poker" GameSlots GameName = "slots" GameBlackjack GameName = "blackjack" @@ -133,6 +134,7 @@ type TableConfig struct { Roulette RouletteConfig `yaml:"roulette,omitempty"` Craps CrapsConfig `yaml:"craps,omitempty"` VideoPoker VideoPokerConfig `yaml:"video_poker,omitempty"` + PaiGow PaiGowConfig `yaml:"pai_gow,omitempty"` } type MachineConfig struct { diff --git a/internal/casino/video_poker_config.go b/internal/casino/video_poker_config.go index 8d67252..0a39913 100644 --- a/internal/casino/video_poker_config.go +++ b/internal/casino/video_poker_config.go @@ -56,7 +56,7 @@ var cabinetGames = map[string][]string{ // Each tuple is low, normal, full. These are named published schedules, not // interpolated payouts. Full means the highest offered on this cabinet, not -// a promise of identical RTP across games. Sources: building_guide/video_poker.md. +// a promise of identical RTP across games. Sources: dev_guide/video_poker.md. var cabinetSchedules = map[string]map[string][3]string{ CabinetClassic: { "jacks_or_better": {"7/5", "8/5", "9/6"}, diff --git a/internal/casino/video_poker_rules.go b/internal/casino/video_poker_rules.go index 340815d..bfff0c4 100644 --- a/internal/casino/video_poker_rules.go +++ b/internal/casino/video_poker_rules.go @@ -15,7 +15,7 @@ var pokerCategories = []string{"Natural royal flush", "Royal flush", "Four deuce // pokerPayTable resolves the per-coin (five-coin) rates for a resolved // game/pay-table pair. Every listed schedule is a published one; see -// building_guide/video_poker.md for the sources. +// dev_guide/video_poker.md for the sources. func pokerPayTable(c VideoPokerConfig) (pokerSchedule, bool) { c = c.Normalize() if c.Variant == "" { diff --git a/internal/game/act_burn.go b/internal/game/act_burn.go index 877fd78..706d0a7 100644 --- a/internal/game/act_burn.go +++ b/internal/game/act_burn.go @@ -424,7 +424,7 @@ func (g *Game) broadcastDrop(p *player.Player, itemID, itemName string) { func (g *Game) FireTick() { // Mandatory tick-system guard: the master per-tick subscriber can fire // before SetHub during startup, and burning out a fire reaches - // cancelStokers -> g.Hub.PlayersInRoom. See building_guide/tick_order.md. + // cancelStokers -> g.Hub.PlayersInRoom. See dev_guide/tick_order.md. if g.Hub == nil { return } diff --git a/internal/game/casino.go b/internal/game/casino.go index d433f2a..e6c0f27 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -726,6 +726,8 @@ func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot) // how verbose the command menus are. size := sess.Player.OptionInt("card_size") switch s := snapshot.(type) { + case *casino.PaiGowSnapshot: + return renderPaiGow(s, mode, unicode, size, sess.Player.OptionString("card_style"), g.CardArt) case *casino.VideoPokerSnapshot: return renderVideoPoker(s, mode, unicode, size, sess.Player.OptionString("card_style"), g.CardArt) case *casino.CrapsSnapshot: @@ -904,7 +906,7 @@ func cardRenderSize(unicode bool, size int) int { return 7 } -var suitSymbols = map[string]string{"hearts": "♥", "spades": "♠", "diamonds": "♦", "clubs": "♣"} +var suitSymbols = map[string]string{"hearts": "♥", "spades": "♠", "diamonds": "♦", "clubs": "♣", "joker": "★"} func cardSymbol(card casino.CardView) string { if card.Hidden { @@ -917,7 +919,13 @@ func cardArtLines(card casino.CardView, size int, unicode bool, style string, ar if card.Hidden { return generatedCardArt("?", "", size, unicode, style) } - key := card.Rank + strings.ToUpper(card.Suit[:1]) + key := card.Rank + if card.Rank == "JK" { + return jokerCardArt(size, unicode, style, art) + } + if card.Suit != "" { + key += strings.ToUpper(card.Suit[:1]) + } if configured := art.Art(key, size); len(configured) == size { if unicode && style == "light" { // The shared pip art draws a dark box frame, which leaves the top diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go index fc37e7d..6f7964f 100644 --- a/internal/game/casino_ux.go +++ b/internal/game/casino_ux.go @@ -79,6 +79,8 @@ func (g *Game) casinoChoices(roomID int) []casinoChoice { names = append(names, "bj") case casino.GameBaccarat: names = append(names, "bac") + case casino.GamePaiGow: + names = append(names, "paigow", "pai", "pai gow", "pai gow poker") } choices = append(choices, casinoChoice{address: string(c.Game), names: names, table: true}) } diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index fbb401c..d8f6b76 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -223,6 +223,9 @@ func classifyCommand(cmd string) CommandClass { if _, ok := casino.ParsePokerDigits(cmd); ok { return ClassInstant } + if _, ok := casino.ParsePaiGowDigits(cmd); ok { + return ClassInstant + } if def, ok := commandRegistry[cmd]; ok { return def.class } @@ -293,6 +296,16 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI } } + // Pai Gow owns these inputs only while seated; leave normal commands alone. + if p != nil && g.Casino != nil { + if t, playing := g.Casino.Participation(p.Name); playing && t != nil && t.Config.Game == casino.GamePaiGow { + numeric := cmd != "" && cmd[0] >= '0' && cmd[0] <= '9' + if numeric || cmd == "set" || cmd == "house" || cmd == "settle" { + g.executeTableAction(sess, strings.Join(append([]string{cmd}, args...), " ")) + return + } + } + } // Video poker: numeric tokens toggle holds and d draws, so both are // intercepted before the shared registry (where d means "down"). _, pokerDigits := casino.ParsePokerDigits(cmd) diff --git a/internal/game/hacking/liars_dice_test.go b/internal/game/hacking/liars_dice_test.go index 59ebb92..83bed93 100644 --- a/internal/game/hacking/liars_dice_test.go +++ b/internal/game/hacking/liars_dice_test.go @@ -10,7 +10,7 @@ import ( // "illegal use of non-zero Builder copied by value" on ordinary input — a // forced challenge resolves on the very first bid. A panic here runs on the // game owner goroutine, which has no recovery, so it terminated the server. -// See building_guide/concurrency.md for the owner's no-recovery guarantee. +// See dev_guide/concurrency.md for the owner's no-recovery guarantee. func TestLiarsDiceForcedChallengeDoesNotPanic(t *testing.T) { g := NewLiarsDiceGame() diff --git a/internal/game/hardening_tick_lifecycle_test.go b/internal/game/hardening_tick_lifecycle_test.go index b5b3bb9..d16c2c8 100644 --- a/internal/game/hardening_tick_lifecycle_test.go +++ b/internal/game/hardening_tick_lifecycle_test.go @@ -51,7 +51,7 @@ func TestFireTickNilHubIsSafe(t *testing.T) { // TestSequenceTickFiresInDeterministicOrder guards the sorted iteration over // g.sequences: map iteration randomly chose which simultaneous sequence // advanced first, contradicting the determinism contract in -// building_guide/tick_order.md. +// dev_guide/tick_order.md. func TestSequenceTickFiresInDeterministicOrder(t *testing.T) { g := escapeTestGame() g.sequences = make(map[string]*sequence) diff --git a/internal/game/money.go b/internal/game/money.go index 35355ca..4c3316e 100644 --- a/internal/game/money.go +++ b/internal/game/money.go @@ -7,7 +7,7 @@ package game // still listed as pending, because a listed stake means the process died // before the round resolved. Two-phase journal recovery was deliberately // removed as out of scope for a game this size; see -// building_guide/concurrency.md ("Durable money") before reintroducing it. +// dev_guide/concurrency.md ("Durable money") before reintroducing it. import ( "crypto/rand" @@ -128,7 +128,7 @@ func addCreditsChecked(a, b int) (int, error) { // same save that settled them. // // This is intentionally the entire recovery story. Two-phase journal recovery -// is out of scope for this game (see building_guide/concurrency.md). +// is out of scope for this game (see dev_guide/concurrency.md). func (g *Game) recoverPendingWagers() { names, err := g.AccountStore.ListCharacterNames() if err != nil { diff --git a/internal/game/pai_gow_test.go b/internal/game/pai_gow_test.go new file mode 100644 index 0000000..614d0dd --- /dev/null +++ b/internal/game/pai_gow_test.go @@ -0,0 +1,141 @@ +package game + +import ( + "strings" + "testing" + "unicode/utf8" + + "thehouseoficarus/internal/cardart" + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/world" +) + +func TestPaiGowCommandsLookAndSettlement(t *testing.T) { + g, sess, conn := casinoCrapsGame(t, "name: Pai Gow\ncasino_tables:\n - id: fortune\n game: pai_gow\n pai_gow:\n envy: true\n") + g.executePlay(sess, []string{"pai"}, "play pai") + table, playing := g.Casino.Participation(sess.Player.Name) + if !playing || table.Config.Game != casino.GamePaiGow { + t.Fatal("alias failed") + } + conn.data = nil + g.executeTableStatus(sess, []string{"rules"}, "table rules") + text := stripANSI(string(conn.data)) + for _, want := range []string{"FORTUNE", "ENVY (FIXED)", "750:1", "Three pair"} { + if !strings.Contains(text, want) { + t.Fatalf("missing %s: %s", want, text) + } + } + g.executeBet(sess, []string{"20"}, "bet 20") + g.executeBet(sess, []string{"5", "fortune"}, "bet 5 fortune") + g.executeTableAction(sess, "deal") + snap := table.Status(sess.Player.Name)[0].Snapshot.(*casino.PaiGowSnapshot) + if len(snap.Cards) != 7 || len(snap.DealerHigh) != 0 || len(sess.Player.PendingWagers) != 2 { + t.Fatal("deal/side bet") + } + conn.data = nil + g.executeCommand(sess, "22", nil, "22") + if text = string(conn.data); !strings.Contains(text, "different numbers") { + t.Fatal(text) + } + if classifyCommand("67") != ClassInstant || classifyCommand("13456") != ClassInstant { + t.Fatal("numeric classification") + } + // A numeric selection either locks a valid split or gives a clear foul; + // either way it must reach Pai Gow rather than the generic unknown verb. + conn.data = nil + g.executeCommand(sess, "67", nil, "67") + text = string(conn.data) + if table.Status(sess.Player.Name)[0].Snapshot.(*casino.PaiGowSnapshot).Phase == "setting" && !strings.Contains(text, "FOUL HAND") { + t.Fatal(text) + } + g.executeCommand(sess, "house", nil, "house") + if table.Status(sess.Player.Name)[0].Snapshot.(*casino.PaiGowSnapshot).Phase != "waiting" || len(sess.Player.PendingWagers) != 0 { + t.Fatal("settlement did not finish") + } +} +func TestPaiGowFeltAndJokerAllModes(t *testing.T) { + store := cardart.Load("../../data/cards.yaml") + for _, size := range []int{1, 5, 7, 9} { + if len(store.Art("JK", size)) != size { + t.Fatalf("missing JK art at %d", size) + } + for _, unicode := range []bool{false, true} { + for _, style := range []string{"dark", "light"} { + for _, art := range []*cardart.Store{nil, store} { + lines := jokerCardArt(size, unicode, style, art) + if len(lines) != size { + t.Fatal(size, lines) + } + for _, line := range lines { + if size > 1 && utf8.RuneCountInString(line) != size { + t.Fatalf("%d width: %q", size, line) + } + if !unicode { + for _, r := range line { + if r > 127 { + t.Fatalf("ASCII Joker has Unicode: %q", line) + } + } + } + } + for _, mode := range []string{"none", "xterm256", "truecolor"} { + out := cardsLine(mode, []casino.CardView{{Rank: "JK", Suit: "joker"}}, cardRenderSize(unicode, size), unicode, style, art) + if !strings.Contains(strings.Join(out, "\n"), "J") { + t.Fatal(out) + } + } + } + } + } + } + for _, envy := range []bool{false, true} { + for _, unicode := range []bool{false, true} { + cfg := casino.TableConfig{Game: casino.GamePaiGow, PaiGow: casino.PaiGowConfig{Envy: envy}} + felt := renderPaiGowFelt(cfg, "none", unicode) + lines := strings.Split(strings.TrimRight(felt, "\n"), "\n") + width := utf8.RuneCountInString(lines[0]) + for _, line := range lines { + if utf8.RuneCountInString(line) != width { + t.Fatal("jagged felt", line) + } + } + if !strings.Contains(felt, "Three pair") || !strings.Contains(felt, "1000:1") { + t.Fatal(felt) + } + if !envy && (!strings.Contains(felt, "ENVY OFF") || !strings.Contains(felt, "30:1")) { + t.Fatal(felt) + } + if cfg.PaiGow.Envy && (strings.Contains(felt, "ENVY OFF") || !strings.Contains(felt, "ENVY (FIXED)")) { + t.Fatal(felt) + } + } + } + cfg := casino.TableConfig{MinBet: 5, MaxBet: 1000, Game: casino.GamePaiGow, PaiGow: casino.PaiGowConfig{Variant: casino.PaiGowFaceUp, PushAceHigh: "5_15_40"}} + felt := renderPaiGowFelt(cfg, "none", true) + if !strings.Contains(felt, "DEALER FACE UP") || !strings.Contains(felt, "PUSH ACE HIGH 5/15/40:1") { + t.Fatal(felt) + } +} +func TestVestaPaiGowRooms(t *testing.T) { + _, rooms, _ := loadVesta(t) + for id, variant := range map[int]string{6195: casino.PaiGowClassic, 6196: casino.PaiGowEZ, 6197: casino.PaiGowNoPush, 6198: casino.PaiGowFaceUp} { + r, ok := rooms[id] + if !ok || len(r.CasinoTables) != 1 { + t.Fatalf("missing Pai Gow room %d", id) + } + cfg := r.CasinoTables[0] + if cfg.Validate() != nil || cfg.Game != casino.GamePaiGow || cfg.PaiGow.Variant != variant || cfg.PaiGow.Envy != (id != 6197) { + t.Fatalf("bad Pai Gow configuration: %+v", cfg) + } + } + if rooms[6192].Exits[world.East].Room != 6195 || rooms[6195].Exits[world.West].Room != 6192 || rooms[6195].Exits[world.North].Room != 6196 || rooms[6196].Exits[world.South].Room != 6195 || rooms[6195].Exits[world.South].Room != 6197 || rooms[6197].Exits[world.North].Room != 6195 || rooms[6196].Exits[world.North].Room != 6198 || rooms[6198].Exits[world.South].Room != 6196 { + t.Fatal("broken Pai Gow navigation") + } +} +func TestJokerEmptySuitDoesNotPanic(t *testing.T) { + for _, size := range []int{5, 7, 9} { + if len(cardArtLines(casino.CardView{Rank: "JK"}, size, true, "dark", nil)) != size { + t.Fatal(size) + } + } +} diff --git a/internal/game/render_joker.go b/internal/game/render_joker.go new file mode 100644 index 0000000..821f9f4 --- /dev/null +++ b/internal/game/render_joker.go @@ -0,0 +1,37 @@ +package game + +import ( + "strings" + "thehouseoficarus/internal/cardart" +) + +// Dedicated Joker face in every supported size, also usable without cards.yaml. +// Never reuse Unicode store art for ASCII-only clients. +func jokerCardArt(size int, unicode bool, style string, store *cardart.Store) []string { + if size == 1 { + return []string{"JK"} + } + var lines []string + if unicode { + lines = store.Art("JK", size) + } + if len(lines) != size { + lines = generatedCardArt("JK", "", size, unicode, style) + motifs := map[int][]string{5: {" * "}, 7: {" ^ ^ ", "(o o)", " v "}, 9: {" /\u005c /\u005c ", " o o ", " <J> ", " \\_/ ", " * "}} + if motif, ok := motifs[size]; ok { + for i, text := range motif { + left, right := "|", "|" + if unicode { + left, right = "│", "│" + } + lines[2+i] = left + text + right + } + } + } + lines = append([]string(nil), lines...) + if unicode && style == "light" { + lines[0] = strings.Repeat("▄", size) + lines[size-1] = strings.Repeat("▀", size) + } + return lines +} diff --git a/internal/game/render_pai_gow.go b/internal/game/render_pai_gow.go new file mode 100644 index 0000000..2005daf --- /dev/null +++ b/internal/game/render_pai_gow.go @@ -0,0 +1,79 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/cardart" + "thehouseoficarus/internal/casino" +) + +func renderPaiGowFelt(cfg casino.TableConfig, mode string, unicode bool) string { + c := newCrapsCanvas(25) + c.box(0, 0, 76, 24) + c.center(1, 75, 1, strings.ToUpper(cfg.PaiGow.Label()), crapsGold) + c.center(1, 75, 2, "FIVE HIGH / TWO LOW | COPIES LOSE | LIMITS "+betLimitsText(cfg.MinBet, cfg.MaxBet), crapsInk) + note := "MAIN WIN 0.95:1 | 5% COMMISSION" + switch cfg.PaiGow.Normalize().Variant { + case casino.PaiGowEZ: + note = "MAIN WIN 1:1 | DEALER QUEEN-HIGH PUSHES" + case casino.PaiGowNoPush: + note = "MAIN WIN 1:1 | SPLIT RESULT: EXTRA CARD HIGH WINS, TIES LOSE" + case casino.PaiGowFaceUp: + note = "DEALER FACE UP | ACE-HIGH PUSHES | PUSH ACE HIGH " + strings.ReplaceAll(cfg.PaiGow.PushAceHigh, "_", "/") + ":1" + } + c.center(1, 75, 3, note, crapsInk) + c.box(0, 4, 76, 19) + c.text(3, 5, "FORTUNE - BEST OF ALL SEVEN CARDS", crapsGold) + c.text(48, 5, "PROFIT ODDS", crapsGold) + envy := "ENVY OFF" + if cfg.PaiGow.Envy { + envy = "ENVY (FIXED)" + } + c.text(62, 5, envy, crapsGold) + for i, row := range casino.FortunePayTable(cfg.PaiGow.Envy) { + c.text(3, 6+i, row.Hand, crapsInk) + c.text(50, 6+i, fmt.Sprintf("%d:1", row.Odds), crapsInk) + if cfg.PaiGow.Envy { + c.text(65, 6+i, fmt.Sprintf("%d", row.Envy), crapsInk) + } + } + c.center(1, 75, 18, "Highest prize only. Winning Fortune wagers also return their stake.", crapsMuted) + note = "Envy disabled: quads improved from 25:1 to 30:1." + if cfg.PaiGow.Envy { + note = "Fortune 5+ earns Envy on other players, not yourself or dealer." + } + c.center(1, 75, 20, note, crapsInk) + c.center(1, 75, 21, "24 sets cards 2/4 LOW; 13456 sets five HIGH; house sets house way.", crapsInk) + c.center(1, 75, 22, "High must outrank low. Joker: ace or completes straight/flush.", crapsMuted) + c.center(1, 75, 23, "Bet main, optional bet <amount> fortune, then deal.", crapsMuted) + return c.render(mode, unicode) +} +func renderPaiGow(s *casino.PaiGowSnapshot, mode string, unicode bool, size int, style string, store *cardart.Store) string { + size = cardRenderSize(unicode, size) + lines := []string{renderPaiGowFelt(s.Config, mode, unicode)} + if len(s.Cards) > 0 { + lines = append(lines, "Your seven cards (positions 1-7):") + lines = append(lines, cardsLine(mode, s.Cards, size, unicode, style, store)...) + // Position labels use each card's actual rendered width; compact cards are + // variable-width, so print a separate unambiguous position-to-card key. + var positions []string + for i, c := range s.Cards { + positions = append(positions, fmt.Sprintf("%d=%s", i+1, cardSymbol(c))) + } + lines = append(lines, strings.Join(positions, " ")) + } + for _, h := range []struct { + label string + cards []casino.CardView + }{{"Your high", s.High}, {"Your low", s.Low}, {"Dealer high", s.DealerHigh}, {"Dealer low", s.DealerLow}, {"Tiebreaker: player / dealer", s.Tiebreaker}} { + if len(h.cards) > 0 { + lines = append(lines, h.label+":") + lines = append(lines, cardsLine(mode, h.cards, size, unicode, style, store)...) + } + } + if s.Result != "" { + lines = append(lines, s.Result) + } + return strings.Join(lines, "\n") +} diff --git a/internal/game/render_roulette.go b/internal/game/render_roulette.go index 0e4b1a3..9ffd355 100644 --- a/internal/game/render_roulette.go +++ b/internal/game/render_roulette.go @@ -601,6 +601,9 @@ func (g *Game) renderCasinoRules(sess *net.Session, cfg casino.TableConfig) stri // The whole rule card is a rendered felt. return g.renderCasinoSnapshot(sess, &casino.BlackjackRulesView{Config: cfg}) } + if cfg.Game == casino.GamePaiGow { + return cfg.RulesText() + "\n" + renderPaiGowFelt(cfg, g.colorMode(sess), sess.Player.OptionBool("unicode")) + } if cfg.Game == casino.GameBaccarat { return g.renderCasinoSnapshot(sess, &casino.BaccaratRulesView{Config: cfg}) } diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go index 7a6466a..d1ce336 100644 --- a/internal/game/sys_triggers.go +++ b/internal/game/sys_triggers.go @@ -102,7 +102,7 @@ func (g *Game) SequenceTick() { g.seqMu.Lock() // g.sequences is a map: collect its keys and sort them so the per-tick // order in which simultaneous sequences advance is reproducible (the - // determinism contract in building_guide/tick_order.md). + // determinism contract in dev_guide/tick_order.md). keys := make([]string, 0, len(g.sequences)) for key := range g.sequences { keys = append(keys, key) diff --git a/internal/game/tick_systems.go b/internal/game/tick_systems.go index b819454..f85cdfa 100644 --- a/internal/game/tick_systems.go +++ b/internal/game/tick_systems.go @@ -1,7 +1,7 @@ package game // tickSystem is one step of the canonical per-tick system sequence. Name is -// the human-readable identifier also encoded in building_guide/tick_order.md; +// the human-readable identifier also encoded in dev_guide/tick_order.md; // Run invokes the underlying system. The slice tickSystemOrder is the single // executable source of truth for the order in which per-tick systems fire. // @@ -9,7 +9,7 @@ package game // (registered as engine subscription ID 1, which the engine fires before all // dynamically-registered combat/aggro/respawn callbacks per processTick's // registration-ID sort) depend on. Reordering this slice is a breaking change -// to game behavior; keep building_guide/tick_order.md in sync. +// to game behavior; keep dev_guide/tick_order.md in sync. type tickSystem struct { Name string Run func(*Game) diff --git a/internal/game/tick_systems_test.go b/internal/game/tick_systems_test.go index 2fa67ac..82028d6 100644 --- a/internal/game/tick_systems_test.go +++ b/internal/game/tick_systems_test.go @@ -14,7 +14,7 @@ import ( // expectedTickSystemOrder is the canonical per-tick system sequence. It MUST // stay in sync with tickSystemOrder in tick_systems.go and with -// building_guide/tick_order.md. Updating one without the others is a bug. +// dev_guide/tick_order.md. Updating one without the others is a bug. // // The list is intentionally spelled out here (rather than derived from // tickSystemOrder) so the test fails loudly when someone reorders, adds, or @@ -48,17 +48,17 @@ var expectedTickSystemOrder = []string{ // TestTickSystemOrder asserts the canonical per-tick system sequence matches // the documented order exactly. This is the regression guard referenced in -// building_guide/tick_order.md and in the master subscriber doc comment in +// dev_guide/tick_order.md and in the master subscriber doc comment in // cmd/thoi/main.go. func TestTickSystemOrder(t *testing.T) { got := TickSystemOrder() if len(got) != len(expectedTickSystemOrder) { - t.Fatalf("tick system count changed: got %d, want %d (update expectedTickSystemOrder and building_guide/tick_order.md)", + t.Fatalf("tick system count changed: got %d, want %d (update expectedTickSystemOrder and dev_guide/tick_order.md)", len(got), len(expectedTickSystemOrder)) } for i, want := range expectedTickSystemOrder { if got[i].Name != want { - t.Errorf("tick system %d: got %q, want %q (reordered? update building_guide/tick_order.md)", + t.Errorf("tick system %d: got %q, want %q (reordered? update dev_guide/tick_order.md)", i, got[i].Name, want) } } diff --git a/internal/game/wallet_test.go b/internal/game/wallet_test.go index f20f96f..a74ccd5 100644 --- a/internal/game/wallet_test.go +++ b/internal/game/wallet_test.go @@ -4,7 +4,7 @@ package game // stake in one atomic character save, a settlement credits and clears the // stakes it returns in one save, a failed save reverts, and startup refunds // anything left in flight. See internal/game/money.go for the design and -// building_guide/concurrency.md for why two-phase recovery is out of scope. +// dev_guide/concurrency.md for why two-phase recovery is out of scope. import ( "os" |
