aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md37
-rw-r--r--README.md4
-rw-r--r--building_guide/README.md32
-rw-r--r--building_guide/admin.md2
-rw-r--r--building_guide/bank.md23
-rw-r--r--building_guide/pai_gow.md100
-rw-r--r--building_guide/rooms.md11
-rw-r--r--building_guide/video_poker.md48
-rw-r--r--cmd/thoi/main.go2
-rw-r--r--data/cards.yaml29
-rw-r--r--data/help/casino.yaml3
-rw-r--r--data/help/pai_gow.yaml60
-rw-r--r--data/rooms/casino/6100.yaml2
-rw-r--r--data/rooms/casino/6192.yaml4
-rw-r--r--data/rooms/casino/6195.yaml29
-rw-r--r--data/rooms/casino/6196.yaml27
-rw-r--r--data/rooms/casino/6197.yaml25
-rw-r--r--data/rooms/casino/6198.yaml26
-rw-r--r--dev_guide/README.md20
-rw-r--r--dev_guide/bank.md26
-rw-r--r--dev_guide/concurrency.md (renamed from building_guide/concurrency.md)0
-rw-r--r--dev_guide/hacking.md (renamed from building_guide/hacking.md)0
-rw-r--r--dev_guide/pai_gow.md65
-rw-r--r--dev_guide/roulette.md (renamed from building_guide/roulette.md)0
-rw-r--r--dev_guide/tick_order.md (renamed from building_guide/tick_order.md)0
-rw-r--r--dev_guide/video_poker.md45
-rw-r--r--internal/casino/core.go2
-rw-r--r--internal/casino/pai_gow.go498
-rw-r--r--internal/casino/pai_gow_rules.go503
-rw-r--r--internal/casino/pai_gow_test.go464
-rw-r--r--internal/casino/registry.go14
-rw-r--r--internal/casino/table_ux.go3
-rw-r--r--internal/casino/types.go2
-rw-r--r--internal/casino/video_poker_config.go2
-rw-r--r--internal/casino/video_poker_rules.go2
-rw-r--r--internal/game/act_burn.go2
-rw-r--r--internal/game/casino.go12
-rw-r--r--internal/game/casino_ux.go2
-rw-r--r--internal/game/cmd_registry.go13
-rw-r--r--internal/game/hacking/liars_dice_test.go2
-rw-r--r--internal/game/hardening_tick_lifecycle_test.go2
-rw-r--r--internal/game/money.go4
-rw-r--r--internal/game/pai_gow_test.go141
-rw-r--r--internal/game/render_joker.go37
-rw-r--r--internal/game/render_pai_gow.go79
-rw-r--r--internal/game/render_roulette.go3
-rw-r--r--internal/game/sys_triggers.go2
-rw-r--r--internal/game/tick_systems.go4
-rw-r--r--internal/game/tick_systems_test.go8
-rw-r--r--internal/game/wallet_test.go2
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.
diff --git a/README.md b/README.md
index 8a0d6e0..5fb7d4d 100644
--- a/README.md
+++ b/README.md
@@ -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"