diff options
41 files changed, 3349 insertions, 37 deletions
diff --git a/building_guide/video_poker.md b/building_guide/video_poker.md new file mode 100644 index 0000000..ca2ece7 --- /dev/null +++ b/building_guide/video_poker.md @@ -0,0 +1,151 @@ +# Video poker cabinets + +Room 6192, east of Penny Reels (6118), contains exactly five single-seat +cabinets: Classic, Ultimate X Single Line, Ultimate X Multi-Hand, Super Times +Pay, and Hot Roll. Choose a game **inside** a cabinet; schedules are not games. +The shared table lifecycle adapter provides room routing, wallet settlement, +disconnect and eviction handling, so author cabinets under `casino_tables`: + +```yaml +casino_tables: +- id: ultimate_x_multi + game: video_poker + max_players: 1 + video_poker: + cabinet: ultimate_x_multi + payout: full +``` + +`cabinet` accepts `classic`, `ultimate_x_single`, `ultimate_x_multi`, +`super_times_pay`, `hot_roll`. `payout` accepts `low`, `normal`, `full`. +Defaults are classic/full. Do not author variant/pay_table/feature/single_line; +strict YAML validation rejects those obsolete fields. Do not use table wager +limits to set coin denominations. Each cabinet has one occupant, but multiple +cabinets may share a room and are addressed by ID. + +## Games and quality tiers + +Each row below lists **low / normal / full** schedules. Tier changes apply to +all games on that cabinet; selecting a different game preserves the tier. +These are published schedules, not interpolated payouts. The tier names rank +the offered schedules within a game/cabinet; they do not imply equal RTP across +games. Some classic full schedules exceed 100% with optimal play. + +| Game | Classic | UX Single | UX Multi | STP | Hot Roll | +|---|---|---|---|---|---| +| Jacks or Better | 7/5, 8/5, 9/6 | 8/5, 8/6, 9/6 | 7/5, 8/5, 8/6 | 7/5, 8/5, 9/6 | 8/5, 9/5, 9/6 | +| Bonus Poker | 6/5, 7/5, 8/5 | 6/5, 7/5, 8/5 | omitted | 6/5, 7/5, 8/5 | omitted | +| Bonus Poker Deluxe | 8/5, 9/5, 9/6 | 8/5, 8/6, 9/6 | 7/5, 8/5, 8/6 | omitted | omitted | +| Double Bonus | 9/6/4, 9/7, 10/7 | 9/6/4, 9/6/5, 9/7 | 9/5/4, 9/6/4, 9/6/5 | 9/6/4, 9/6/5, 9/7 | omitted | +| Double Double Bonus | 8/5, 9/6, 10/6 | 8/5, 9/5, 9/6 | 8/5, 9/5, 9/6 | 8/5, 8/6, 9/6 | 8/5, 9/5, 9/6 | +| Deuces Wild | C | U | U | C | omitted | +| Joker Poker (Kings or Better) | J | J | J | omitted | omitted | + +- C: 20/12/10/4/4/3, 25/15/9/4/4/3, 25/15/9/5/3/2. +- U: 25/16/13/4/3/2, 20/12/10/4/4/3, 25/15/9/4/4/3. +- J: 50/15/7/5/3, 50/16/7/5/3, 50/17/7/5/3. +- Double Bonus 9/7 and 10/7 pay five for straights; three-part names explicitly + identify full-house/flush/straight rates. +- Bonus Poker has only two sourced UX Multi schedules; it is omitted rather + than inventing a third tier. Other omissions are deliberately conservative, + not claims that no physical edition exists. + +The runtime tier selection persists across occupants of the live cabinet. +A restart or authored configuration replacement restores the authored tier. +Multiplier banks for *all* tiers remain durable and separate. + +| Edition | Supported hands | +|---|---| +| Classic Jacks or Better | 1, 3, 5, 10, 25, 50, 100 | +| Other classic games | 1 | +| Ultimate X Single | 1 | +| Ultimate X Multi | 3, 5, 10 | +| Super Times Pay | 1, 3, 5, 10 | +| Hot Roll | 3, 5, 10 | + +## Controls and presentation + +- `play <id>` seats you; `game` lists games; `game jacks` (or a full game name) + selects one. `payout low|normal|full` selects cabinet-wide quality. +- `denom 1|10|50` / `denomination` sets credits per coin. +- `hands` lists counts; `hands <count>` selects one. +- `bet 1-4` **adds** coins, capped at five per hand. `bet max` loads five. + Bare `bet` / `rebet` repeats the previous selection, initially five. +- Loading neither charges nor deals. Explicit bets show the pay table; bare + bet does not. `deal` charges the full wager and reveals five cards. +- `531`, `1 3 5`, or `hold 531` **toggles** holds. Repeating the command releases + those cards; duplicate digits within one command toggle only once. + `hold none` is not supported. `draw` or seated `d` finishes the hand. + Away from video poker, `d` retains down movement. +- `feature on|off` toggles the cabinet's bonus between rounds. +- `paytable`, `look`, `table rules` redisplay; `rules` opens the cabinet guide. + `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 +(10 total); STP adds one (6 total). Fees never increase the base payout. + +- **Ultimate X:** each line's current multiplier applies to its payout. Enabled + wins earn next-hand multipliers using the published single/3/5/10-play award + schedules. Awards never compound. Feature OFF collects existing multipliers + without earning new ones. `NEXT X` shows awards for the selected hand count. +- **Super Times Pay:** a pre-deal 1/15 trigger awards a shared multiplier. + Conditional 2x/3x/4x/5x/8x/10x probabilities are 17/33/16/24/6/4 percent. +- **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). + +## Sources + +Published schedules, award tables and feature rules: + +- https://wizardofodds.com/games/video-poker/tables/ultimate-x-single-line/ +- https://wizardofodds.com/games/video-poker/tables/ultimate-x/ +- https://wizardofodds.com/games/video-poker/tables/super-times-pay/ +- https://wizardofodds.com/games/video-poker/tables/hot-roll/ +- https://wizardofodds.com/games/video-poker/tables/jacks-or-better/ +- https://wizardofodds.com/games/video-poker/tables/bonus-poker/ +- https://wizardofodds.com/games/video-poker/tables/bonus-poker-deluxe/ +- https://wizardofodds.com/games/video-poker/tables/deuces-wild/ +- 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) diff --git a/data/help/bonus_poker.yaml b/data/help/bonus_poker.yaml new file mode 100644 index 0000000..65d949b --- /dev/null +++ b/data/help/bonus_poker.yaml @@ -0,0 +1,14 @@ +name: bonus poker +category: Interaction +related: [video poker, casino] +description: | + Bonus Poker + + A pair of jacks or better is the lowest win. Two pair pays 2 per coin. + Four aces pay 80, four 2s-4s pay 40, and four 5s-Ks pay 25 per coin. + + Select this game inside a compatible video poker cabinet with game bonus_poker. + payout low|normal|full selects a published pay schedule, not a different game. + paytable shows exact returns and any next-hand multiplier awards. + Type 531 or 1 3 5 to toggle holds, then draw or d. See help video poker + for denominations, loading coins, supported hands and bonus cabinets. diff --git a/data/help/bonus_poker_deluxe.yaml b/data/help/bonus_poker_deluxe.yaml new file mode 100644 index 0000000..40f5234 --- /dev/null +++ b/data/help/bonus_poker_deluxe.yaml @@ -0,0 +1,14 @@ +name: bonus poker deluxe +category: Interaction +related: [video poker, casino] +description: | + Bonus Poker Deluxe + + A pair of jacks or better is the lowest win. All four-of-a-kind hands + pay 80 per coin; two pair pays only 1 per coin. + + Select this game inside a compatible video poker cabinet with game bonus_poker_deluxe. + payout low|normal|full selects a published pay schedule, not a different game. + paytable shows exact returns and any next-hand multiplier awards. + Type 531 or 1 3 5 to toggle holds, then draw or d. See help video poker + for denominations, loading coins, supported hands and bonus cabinets. diff --git a/data/help/casino.yaml b/data/help/casino.yaml index b9756cb..f401c12 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] +related: [roulette, racetrack, blackjack, baccarat, craps, slots, video poker] description: | Casino quick guide @@ -18,7 +18,7 @@ description: | Profit odds exclude your returned stake: a 10-credit win at 2:1 returns 30 total (20 profit + 10 stake). A push returns the stake; - a loss returns nothing. Slots report total returns, not profit odds. + a loss returns nothing. Slots and video poker report total returns, not profit odds. Fractional table-game returns round down to whole credits, except craps which requires exact payout units (commission rounds up). @@ -28,5 +28,6 @@ description: | help baccarat Bet which hand finishes closest to 9. 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. Rules and limits vary by table. No bet guarantees a profit. diff --git a/data/help/deuces_wild.yaml b/data/help/deuces_wild.yaml new file mode 100644 index 0000000..39725cb --- /dev/null +++ b/data/help/deuces_wild.yaml @@ -0,0 +1,15 @@ +name: deuces wild +category: Interaction +related: [video poker, casino] +description: | + Deuces Wild + + Every 2 is wild. Three of a kind is the lowest win; two pair does not + pay. Four deuces pay 200 per coin. Natural and wild royals are separate + categories. Wild cards form the highest-paying hand on the selected table. + + Select this game inside a compatible video poker cabinet with game deuces_wild. + payout low|normal|full selects a published pay schedule, not a different game. + paytable shows exact returns and any next-hand multiplier awards. + Type 531 or 1 3 5 to toggle holds, then draw or d. See help video poker + for denominations, loading coins, supported hands and bonus cabinets. diff --git a/data/help/double_bonus.yaml b/data/help/double_bonus.yaml new file mode 100644 index 0000000..d09b373 --- /dev/null +++ b/data/help/double_bonus.yaml @@ -0,0 +1,15 @@ +name: double bonus +category: Interaction +related: [video poker, casino] +description: | + Double Bonus + + A pair of jacks or better is the lowest win. Four aces pay 160, four + 2s-4s pay 80, and four 5s-Ks pay 50 per coin. Two pair pays 1. + Straight, full-house and flush rates depend on the selected schedule. + + Select this game inside a compatible video poker cabinet with game double_bonus. + payout low|normal|full selects a published pay schedule, not a different game. + paytable shows exact returns and any next-hand multiplier awards. + Type 531 or 1 3 5 to toggle holds, then draw or d. See help video poker + for denominations, loading coins, supported hands and bonus cabinets. diff --git a/data/help/double_double_bonus.yaml b/data/help/double_double_bonus.yaml new file mode 100644 index 0000000..4365694 --- /dev/null +++ b/data/help/double_double_bonus.yaml @@ -0,0 +1,15 @@ +name: double double bonus +category: Interaction +related: [video poker, casino] +description: | + Double Double Bonus + + A pair of jacks or better is the lowest win. Four aces with a 2-4 kicker + pay 400 per coin; otherwise 160. Four 2s-4s with an A-4 kicker pay 160, + otherwise 80. Four 5s-Ks pay 50; two pair pays 1 per coin. + + Select this game inside a compatible video poker cabinet with game double_double_bonus. + payout low|normal|full selects a published pay schedule, not a different game. + paytable shows exact returns and any next-hand multiplier awards. + Type 531 or 1 3 5 to toggle holds, then draw or d. See help video poker + for denominations, loading coins, supported hands and bonus cabinets. diff --git a/data/help/hot_roll.yaml b/data/help/hot_roll.yaml new file mode 100644 index 0000000..0e6f483 --- /dev/null +++ b/data/help/hot_roll.yaml @@ -0,0 +1,19 @@ +name: hot roll +category: Interaction +related: [video poker, casino] +description: | + Hot Roll + + Choose hands 3, 5 or 10. Games: jacks, ddb. + game lists games; game <name> selects one. payout low|normal|full sets + the same quality tier across games. paytable shows the exact schedule. + + Feature ON adds five coins per hand to a five-coin base wager (10 total). + Before dealing, a 1-in-6 trigger rolls two fair dice. Their sum multiplies + every line this round. There is no next-hand multiplier bank. + + feature on|off is available between rounds. denom 1|10|50 selects credits + per coin. bet 1-4 adds coins; bet max loads five; bare bet repeats silently. + deal charges and deals. 531 or 1 3 5 toggles holds; draw or d finishes. + stop leaves between rounds. rules opens this guide; help video poker + explains all controls and base game help pages explain winning hands. diff --git a/data/help/jacks_or_better.yaml b/data/help/jacks_or_better.yaml new file mode 100644 index 0000000..4ece68d --- /dev/null +++ b/data/help/jacks_or_better.yaml @@ -0,0 +1,14 @@ +name: jacks or better +category: Interaction +related: [video poker, casino] +description: | + Jacks or Better + + A pair of jacks, queens, kings or aces is the lowest win. Two pair pays + 2 per coin; four of a kind pays 25. Natural royals pay 4000 at five coins. + + Select this game inside a compatible video poker cabinet with game jacks_or_better. + payout low|normal|full selects a published pay schedule, not a different game. + paytable shows exact returns and any next-hand multiplier awards. + Type 531 or 1 3 5 to toggle holds, then draw or d. See help video poker + for denominations, loading coins, supported hands and bonus cabinets. diff --git a/data/help/joker_poker.yaml b/data/help/joker_poker.yaml new file mode 100644 index 0000000..8783507 --- /dev/null +++ b/data/help/joker_poker.yaml @@ -0,0 +1,15 @@ +name: joker poker +category: Interaction +related: [video poker, casino] +description: | + Joker Poker (Kings or Better) + + A 53-card deck includes one wild joker. Kings or aces are the lowest + paying pair. Natural and wild royals are separate; five of a kind is + possible. This is Kings or Better, not the Two Pair version. + + Select this game inside a compatible video poker cabinet with game joker_poker. + payout low|normal|full selects a published pay schedule, not a different game. + paytable shows exact returns and any next-hand multiplier awards. + Type 531 or 1 3 5 to toggle holds, then draw or d. See help video poker + for denominations, loading coins, supported hands and bonus cabinets. diff --git a/data/help/super_times_pay.yaml b/data/help/super_times_pay.yaml new file mode 100644 index 0000000..0d9f92b --- /dev/null +++ b/data/help/super_times_pay.yaml @@ -0,0 +1,20 @@ +name: super times pay +category: Interaction +related: [video poker, casino] +description: | + Super Times Pay + + Choose hands 1, 3, 5 or 10. Games: jacks, bonus, double, ddb, deuces. + game lists games; game <name> selects one. payout low|normal|full sets + the same quality tier across games. paytable shows the exact schedule. + + Feature ON adds one coin per hand to a five-coin base wager (6 total). + Before dealing, a 1-in-15 trigger awards one multiplier for ALL lines. + Conditional chances for 2x/3x/4x/5x/8x/10x are 17/33/16/24/6/4 percent. + There is no next-hand multiplier bank. + + feature on|off is available between rounds. denom 1|10|50 selects credits + per coin. bet 1-4 adds coins; bet max loads five; bare bet repeats silently. + deal charges and deals. 531 or 1 3 5 toggles holds; draw or d finishes. + stop leaves between rounds. rules opens this guide; help video poker + explains all controls and base game help pages explain winning hands. diff --git a/data/help/ultimate_x_multi_hand.yaml b/data/help/ultimate_x_multi_hand.yaml new file mode 100644 index 0000000..cffdc33 --- /dev/null +++ b/data/help/ultimate_x_multi_hand.yaml @@ -0,0 +1,23 @@ +name: ultimate x multi hand +category: Interaction +related: [video poker, casino] +description: | + Ultimate X Multi-Hand + + Choose hands 3, 5 or 10. Games: jacks, deluxe, double, ddb, deuces, joker. + game lists games; game <name> selects one. payout low|normal|full sets + the same quality tier across games. paytable shows the exact schedule. + + Feature ON adds five coins per hand to a five-coin base wager. Each winning + line earns its own NEXT-hand multiplier. The NEXT X column changes with + hand count. A current multiplier never multiplies another multiplier. + + Multipliers belong to the machine, not you. Another player inherits them. + Banks are separate for each game, tier, denomination and hand count, and + survive restarts. Feature OFF can collect a bank without earning new awards. + + feature on|off is available between rounds. denom 1|10|50 selects credits + per coin. bet 1-4 adds coins; bet max loads five; bare bet repeats silently. + deal charges and deals. 531 or 1 3 5 toggles holds; draw or d finishes. + stop leaves between rounds. rules opens this guide; help video poker + explains all controls and base game help pages explain winning hands. diff --git a/data/help/ultimate_x_single_line.yaml b/data/help/ultimate_x_single_line.yaml new file mode 100644 index 0000000..139fe2a --- /dev/null +++ b/data/help/ultimate_x_single_line.yaml @@ -0,0 +1,23 @@ +name: ultimate x single line +category: Interaction +related: [video poker, casino] +description: | + Ultimate X Single Line + + One hand. Games: jacks, bonus, deluxe, double, ddb, deuces, joker. + game lists games; game <name> selects one. payout low|normal|full sets + the same quality tier across games. paytable shows the exact schedule. + + Feature ON adds five coins to a five-coin wager. Winning hands earn the NEXT + hand's multiplier; current multipliers never multiply new awards. The NEXT X + column shows awards. Single-line awards differ from multi-hand awards. + + Multipliers belong to the machine, not you. Another player inherits them. + Banks are separate for each game, tier, denomination and hand count, and + survive restarts. Feature OFF can collect a bank without earning new awards. + + feature on|off is available between rounds. denom 1|10|50 selects credits + per coin. bet 1-4 adds coins; bet max loads five; bare bet repeats silently. + deal charges and deals. 531 or 1 3 5 toggles holds; draw or d finishes. + stop leaves between rounds. rules opens this guide; help video poker + explains all controls and base game help pages explain winning hands. diff --git a/data/help/video_poker.yaml b/data/help/video_poker.yaml new file mode 100644 index 0000000..5cd8c77 --- /dev/null +++ b/data/help/video_poker.yaml @@ -0,0 +1,48 @@ +name: video poker +aliases: [video_poker, vp, poker, denom, denomination, paytable] +category: Interaction +related: [casino, ultimate x single line, ultimate x multi hand, super times pay, hot roll, jacks or better, bonus poker, bonus poker deluxe, double bonus, double double bonus, deuces wild, joker poker] +description: | + Video Poker — five cabinet types + + The lounge east of Penny Reels has Classic Video Poker, Ultimate X Single + Line, Ultimate X Multi-Hand, Super Times Pay and Hot Roll. Each has one seat. + play lists cabinets; play <id> seats you. game lists its supported games. + game jacks selects Jacks or Better. Other short names: bonus, deluxe, + double, ddb, deuces, joker. Full game names and underscore names also work. + Not every feature supports every game. Joker Poker is Kings or Better. + + payout low|normal|full sets the cabinet-wide pay quality for ALL its games. + These select published schedules, not different games or guaranteed RTPs. + paytable shows the selected game, schedule and exact coin payouts. + hands lists supported counts; hands <count> selects one between rounds. + Classic Jacks supports 1/3/5/10/25/50/100 hands; other classic games use 1. + Ultimate X Single uses 1, Ultimate X Multi and Hot Roll 3/5/10, + and Super Times Pay 1/3/5/10. Larger games use smaller card art. + + denom 1|10|50 (or denomination) sets credits per coin. + bet 1-4 ADDS coins, capped at five per hand; bet max loads five. + Bare bet repeats your previous selection, or five initially, without + reprinting the pay table. Explicit bets reprint it with a red coin column. + Loading coins does not charge money or reveal cards. deal pays and deals. + + Type 531, 1 3 5, or hold 531 to TOGGLE those cards. Type a number again + to release it. Repeated digits in one command toggle only once. + There is no hold none command. draw or d replaces unheld cards once; + away from video poker, d still means down. HELD labels are bold yellow. + Each line draws from its own remaining deck, excluding all original cards. + Wins appear below cards in bold yellow; no win appears in bold white. + + feature on|off switches bonuses between rounds. ON requires five base + coins per hand. Ultimate X and Hot Roll add five coins (10 total); + Super Times Pay adds one (6 total). Fees do not increase base payouts. + Ultimate X multipliers belong to the MACHINE: the next player inherits + them at the same game, payout tier, denomination and hand count. Feature + OFF can collect them but earns no new ones. Other bonuses have no carryover. + + Payouts are total coins including stake, not profit. Multiply by denomination + for credits. Natural royals pay 250/500/750/1000 coins for 1-4 base coins, + or 4000 at five. Only the highest-paying category wins on each line. + look redisplays your hand; rules opens the cabinet guide; table rules shows + the pay table. stop leaves between hands. Disconnect finishes current holds. + If payment fails, draw retries the SAME final hands, never a fresh draw. diff --git a/data/rooms/casino/6100.yaml b/data/rooms/casino/6100.yaml index 6e77bc6..cf85c90 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, one salon each, going east. Each salon keeps its own house + baccarat, roulette, craps, slots, then video 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/6118.yaml b/data/rooms/casino/6118.yaml index 5ec251b..ec06b43 100644 --- a/data/rooms/casino/6118.yaml +++ b/data/rooms/casino/6118.yaml @@ -4,6 +4,8 @@ description: A wall of chiming reels opens the machine gallery; limits are 1–2 exits: west: room: 6185 + east: + room: 6192 north: room: 6120 south: diff --git a/data/rooms/casino/6192.yaml b/data/rooms/casino/6192.yaml new file mode 100644 index 0000000..76635eb --- /dev/null +++ b/data/rooms/casino/6192.yaml @@ -0,0 +1,40 @@ +name: Video Poker Lounge +description: Five single-seat poker cabinets glow blue beneath yellow pay-table borders. Each offers a choice of poker games; the selected coin column shines red. Classic poker, two Ultimate X editions, Super Times Pay and Hot Roll stand side by side. +exits: + west: + room: 6118 +casino_tables: +- id: poker + game: video_poker + max_players: 1 + video_poker: + cabinet: classic + payout: full +- id: ultimate_x_single + game: video_poker + max_players: 1 + video_poker: + cabinet: ultimate_x_single + payout: full +- id: ultimate_x_multi + game: video_poker + max_players: 1 + video_poker: + cabinet: ultimate_x_multi + payout: full +- id: super_times_pay + game: video_poker + max_players: 1 + video_poker: + cabinet: super_times_pay + payout: full +- id: hot_roll + game: video_poker + max_players: 1 + video_poker: + cabinet: hot_roll + payout: full +objects: +- name: poker plaque + description: 'Use play to list cabinets, then play <id>. game lists the games inside a cabinet; game jacks selects Jacks or Better. payout low|normal|full sets its pay quality across games. denom 1|10|50 sets credits per coin. bet max loads five coins; deal pays and deals. 531 toggles holds 1, 3 and 5; draw or d finishes. Ultimate X multipliers stay on the machine for the next player. rules explains your cabinet; paytable shows exact payouts.' + inroom_description: A brass poker plaque explains the five cabinets. diff --git a/internal/casino/config_validate.go b/internal/casino/config_validate.go index a35f039..74dc0a4 100644 --- a/internal/casino/config_validate.go +++ b/internal/casino/config_validate.go @@ -358,6 +358,9 @@ func ValidateRoomConfigs(tables []TableConfig, machines []MachineConfig) error { } for _, c := range tables { check("table", c.ID, c.Validate()) + if c.Game == GameVideoPoker { + continue + } // Cabinets are addressed by unique ID, not game name. checkAddressable(strings.ToLower(string(c.Game)), c.ID) } // A machine addresses by its variant when one is declared; a bare game diff --git a/internal/casino/registry.go b/internal/casino/registry.go index 049d9f1..dfce704 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{ + GameVideoPoker: { + validate: func(c TableConfig) error { + if c.MaxPlayers > 1 { + return fmt.Errorf("video poker cabinets have one seat") + } + return c.VideoPoker.Validate() + }, + normalize: func(c TableConfig) TableConfig { c.VideoPoker = c.VideoPoker.Normalize(); c.MaxPlayers = 1; return c }, + ruleBlockName: "video_poker", + hasRuleBlock: func(c TableConfig) bool { return c.VideoPoker != (VideoPokerConfig{}) }, + new: newVideoPokerTable, + ruleText: func(c TableConfig) string { return pokerRules(c.VideoPoker) }, + helpTopic: func(c TableConfig) string { return pokerHelpTopic(c.VideoPoker) }, + }, GameCraps: { validate: validateCraps, normalize: func(c TableConfig) TableConfig { c.Craps = c.Craps.Normalize(); return c }, diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go index 768c041..7c3ab36 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 == GameVideoPoker { + return c.VideoPoker.CabinetLabel() + } if c.Game == GameRoulette { label := "Roulette / Single zero" if c.Roulette.DoubleZero { diff --git a/internal/casino/types.go b/internal/casino/types.go index ecded85..b863ee7 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -14,11 +14,12 @@ func betRangeText(minBet, maxBet int) string { type GameName string const ( - GameSlots GameName = "slots" - GameBlackjack GameName = "blackjack" - GameBaccarat GameName = "baccarat" - GameRoulette GameName = "roulette" - GameCraps GameName = "craps" + GameVideoPoker GameName = "video_poker" + GameSlots GameName = "slots" + GameBlackjack GameName = "blackjack" + GameBaccarat GameName = "baccarat" + GameRoulette GameName = "roulette" + GameCraps GameName = "craps" ) type BlackjackConfig struct { @@ -121,16 +122,17 @@ func (c BaccaratConfig) Normalize() BaccaratConfig { } type TableConfig struct { - ID string `yaml:"id"` - Game GameName `yaml:"game"` - MaxPlayers int `yaml:"max_players,omitempty"` - BettingTicks int `yaml:"betting_ticks,omitempty"` - MinBet int `yaml:"min_bet,omitempty"` - MaxBet int `yaml:"max_bet,omitempty"` - Blackjack BlackjackConfig `yaml:"blackjack,omitempty"` - Baccarat BaccaratConfig `yaml:"baccarat,omitempty"` - Roulette RouletteConfig `yaml:"roulette,omitempty"` - Craps CrapsConfig `yaml:"craps,omitempty"` + ID string `yaml:"id"` + Game GameName `yaml:"game"` + MaxPlayers int `yaml:"max_players,omitempty"` + BettingTicks int `yaml:"betting_ticks,omitempty"` + MinBet int `yaml:"min_bet,omitempty"` + MaxBet int `yaml:"max_bet,omitempty"` + Blackjack BlackjackConfig `yaml:"blackjack,omitempty"` + Baccarat BaccaratConfig `yaml:"baccarat,omitempty"` + Roulette RouletteConfig `yaml:"roulette,omitempty"` + Craps CrapsConfig `yaml:"craps,omitempty"` + VideoPoker VideoPokerConfig `yaml:"video_poker,omitempty"` } type MachineConfig struct { diff --git a/internal/casino/video_poker.go b/internal/casino/video_poker.go new file mode 100644 index 0000000..485d849 --- /dev/null +++ b/internal/casino/video_poker.go @@ -0,0 +1,545 @@ +package casino + +import ( + "fmt" + "math/rand" + "strconv" + "strings" + "sync" +) + +// PokerWallet atomically settles the character and shared machine state. +// Ordinary Wallet implementations remain usable for in-memory simulations. +type PokerWallet interface { + Wallet + LoadPokerBanks(roomID int, cabinetID string) (map[string][]int, error) + PayPoker(amount int, stakeID string, roomID int, cabinetID string, banks map[string][]int) error +} + +type PokerLine struct { + Cards []CardView + Category string + Multiplier, Coins int +} + +type VideoPokerSnapshot struct { + Config VideoPokerConfig + Variant string + ShowPayTable bool + Cards []CardView + Held [5]bool + Drawing bool + Result string + NoWin bool + Coins int + Denomination int + Hands int + FeatureOn bool + Multiplier int + Dice [2]int + Current, Next []int + Lines []PokerLine +} + +func (*VideoPokerSnapshot) snapshot() {} + +type pokerPlayer struct { + config VideoPokerConfig + variant string + cards [5]card + remaining []card + held [5]bool + wager Wager + wallet Wallet + active, drawn, disconnected bool + loaded, lastBet, denomination int + hands int + multiplier int + featureOn bool + dice [2]int + current, next []int + lines []PokerLine + payout int + result, bankKey string +} + +// cost is the full currency price of dealing the loaded wager. +func (p *pokerPlayer) cost() int { + coins := p.loaded + if p.featureOn && coins == 5 { + coins += p.config.FeatureFeeCoins() + } + return coins * p.denomination * p.hands +} + +// Video poker uses the shared table lifecycle adapter (wallet, room routing, +// disconnects, eviction) but is a single-occupant cabinet, never a table. +type videoPokerTable struct { + *tableBase + mu sync.Mutex + rng *rand.Rand + banks map[string][]int + banksLoaded bool + settings VideoPokerConfig + players map[string]*pokerPlayer +} + +func newVideoPokerTable(room int, c TableConfig, rng *rand.Rand) tableGame { + t := &videoPokerTable{tableBase: &tableBase{RoomID: room, Config: c}, rng: rng, banks: map[string][]int{}, players: map[string]*pokerPlayer{}} + t.settings = c.VideoPoker.Normalize() + return t +} + +func (t *videoPokerTable) base() *tableBase { return t.tableBase } +func (t *videoPokerTable) idle() bool { + t.mu.Lock() + defer t.mu.Unlock() + return len(t.players) == 0 +} +func (t *videoPokerTable) hasParticipant(name string) bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.players[name] != nil +} +func (t *videoPokerTable) overview() string { + t.mu.Lock() + defer t.mu.Unlock() + return t.overviewLocked() +} +func (t *videoPokerTable) overviewLocked() string { + return t.settings.CabinetLabel() + " | denom 1/10/50 | one player | 1-5 coins per hand" +} +func (t *videoPokerTable) wagers(string) []WagerRow { return nil } +func (t *videoPokerTable) hints(name string) []MenuCommand { + t.mu.Lock() + defer t.mu.Unlock() + return t.hintsLocked(name) +} +func (t *videoPokerTable) hintsLocked(name string) []MenuCommand { + if t.players[name] == nil { + return nil + } + commands := []MenuCommand{{Command: "bet [1-4|max]", Desc: "Load coins (up to five per hand)"}, {Command: "deal", Desc: "Pay and deal the loaded wager"}} + if p := t.players[name]; p != nil && p.active { + commands = []MenuCommand{{Command: "1 3 5", Desc: "Toggle held cards (531 also works)"}, {Command: "draw", Desc: "Replace unheld cards on every line"}} + if p.drawn { + commands = []MenuCommand{{Command: "draw", Desc: "Retry payment of the same final hands"}} + } + } else { + commands = append(commands, + MenuCommand{Command: "game <name>", Desc: t.settings.GameChoices()}, + MenuCommand{Command: "payout low|normal|full", Desc: "Set the cabinet-wide pay quality"}, + MenuCommand{Command: "denom <1|10|50>", Desc: "Credits per coin"}, + MenuCommand{Command: "hands <count>", Desc: "Supported hand counts are announced by hands"}, + ) + if cabinetFeature(t.settings.Cabinet) != "" { + commands = append(commands, MenuCommand{Command: "feature on|off", Desc: "Toggle the extra-cost bonus"}) + } + commands = append(commands, MenuCommand{Command: "paytable", Desc: "Show the pay table"}) + } + return append(commands, MenuCommand{Command: "rules", Desc: "This cabinet's guide"}, MenuCommand{Command: "stop", Desc: "Leave the cabinet"}) +} + +// gameConfig builds the player's resolved config for a chosen game under the +// cabinet's current payout tier. +func (t *videoPokerTable) gameConfig(variant string) (VideoPokerConfig, bool) { + for _, allowed := range t.settings.VariantOptions() { + if allowed != variant { + continue + } + cfg := VideoPokerConfig{Cabinet: t.settings.Cabinet, Payout: t.settings.Payout, Variant: variant} + cfg = cfg.Normalize() + if _, ok := pokerPayTable(cfg); ok { + return cfg, true + } + } + return VideoPokerConfig{}, false +} + +func (t *videoPokerTable) status(name string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + return t.statusLocked(name, true) +} +func (t *videoPokerTable) statusLocked(name string, showTable bool) []Event { + p := t.players[name] + if p == nil { + return []Event{t.private(name, t.overviewLocked())} + } + coins := p.loaded + if p.active || len(p.lines) > 0 { + coins = p.lastBet + } + var current, next []int + if len(p.current) > 0 { + current = append([]int(nil), p.current...) + } + if len(p.next) > 0 { + next = append([]int(nil), p.next...) + } else if !p.active && p.config.Feature == PokerUltimateX { + next = append([]int(nil), t.banks[t.multiplierKey(p)]...) + } + snap := &VideoPokerSnapshot{Config: p.config, Variant: p.variant, ShowPayTable: showTable, Held: p.held, Drawing: p.active && !p.drawn, Result: p.result, NoWin: p.payout == 0 && p.drawn, Coins: coins, Denomination: p.denomination, Hands: p.hands, FeatureOn: p.featureOn, Multiplier: p.multiplier, Dice: p.dice, Current: current, Next: next} + if p.remaining != nil { + for _, c := range p.cards { + snap.Cards = append(snap.Cards, CardView{Rank: c.rank, Suit: c.suit}) + } + } + for _, line := range p.lines { + line.Cards = append([]CardView(nil), line.Cards...) + snap.Lines = append(snap.Lines, line) + } + e := t.private(name, t.settings.CabinetLabel()+" — "+p.config.GameLabel()) + e.Color = "casino_menu" + e.Snapshot = snap + if !p.active && p.loaded > 0 { + e.Message += fmt.Sprintf(" — %d loaded; deal costs %d credits", p.loaded, p.cost()) + } + e.Menu = &MenuView{Heading: e.Message, ShowBalance: true, Commands: t.hintsLocked(name)} + return []Event{e} +} + +func (t *videoPokerTable) join(name string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if t.players[name] != nil { + return t.statusLocked(name, true) + } + if len(t.players) > 0 { + return []Event{t.private(name, "This video poker cabinet is occupied.")} + } + cfg := t.settings + cfg.PayTable = "" + cfg = cfg.Normalize() + t.players[name] = &pokerPlayer{config: cfg, variant: cfg.Variant, denomination: 1, hands: CabinetHandCounts(cfg.Cabinet, cfg.Variant)[0], featureOn: cfg.Feature != "", multiplier: 1} + return t.statusLocked(name, true) +} +func (t *videoPokerTable) leave(name string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if p := t.players[name]; p != nil && p.active { + return []Event{t.private(name, "Finish your hand with draw before leaving.")} + } + delete(t.players, name) + return []Event{t.private(name, "You leave video poker. Showing multipliers stay with the machine.")} +} + +func (t *videoPokerTable) multiplierKey(p *pokerPlayer) string { + return fmt.Sprintf("%s/%s/%d/%d", p.variant, t.settings.Payout, p.denomination, p.hands) +} + +func (t *videoPokerTable) bet(name, spot string, amount int, w Wallet) []Event { + t.mu.Lock() + defer t.mu.Unlock() + p := t.players[name] + if p == nil { + return nil + } + if p.config.Feature == PokerUltimateX && !t.banksLoaded { + if wallet, ok := w.(PokerWallet); ok { + banks, err := wallet.LoadPokerBanks(t.RoomID, t.Config.ID) + if err != nil { + return []Event{t.private(name, "Machine bank unavailable; try again later: "+err.Error())} + } + if banks != nil { + t.banks = banks + } + } + t.banksLoaded = true + } + if spot == "view" && !p.active { + p.wallet = w + return t.statusLocked(name, true) + } + if spot != "deal" { + return []Event{t.private(name, "Load coins with bet, then deal.")} + } + if p.active { + return []Event{t.private(name, "Finish this hand with draw first.")} + } + if p.loaded == 0 { + return []Event{t.private(name, "Load coins first: bet [1-4|max].")} + } + if p.featureOn && p.loaded != 5 { + return []Event{t.private(name, "The feature requires five wager coins per hand. Use bet max or feature off.")} + } + if w == nil { + return []Event{t.private(name, "Wallet unavailable.")} + } + stake, err := w.Wager(p.cost()) + if err != nil { + return []Event{t.private(name, "The wager could not be placed: "+err.Error())} + } + p.wallet, p.wager, p.lastBet = w, stake, p.loaded + p.bankKey = t.multiplierKey(p) + p.loaded = 0 + current := make([]int, p.hands) + for i := range current { + current[i] = 1 + } + if p.config.Feature == PokerUltimateX { + bank := t.banks[p.bankKey] + for i := range current { + if i < len(bank) && bank[i] >= 1 && bank[i] <= 12 { + current[i] = bank[i] + } + } + } + p.current = current + p.next = nil + p.lines = nil + p.payout = 0 + p.multiplier, p.dice = 1, [2]int{} + if p.featureOn { + p.multiplier, p.dice = pokerRandomMultiplier(p.config.Feature, t.rng) + } + deck := newShoe(1, 0, t.rng) + if p.variant == "joker_poker" { + deck.cards = append(deck.cards, card{rank: "JK", suit: "joker"}) + t.rng.Shuffle(len(deck.cards), func(i, j int) { deck.cards[i], deck.cards[j] = deck.cards[j], deck.cards[i] }) + } + for i := range p.cards { + p.cards[i] = deck.draw() + } + p.remaining = append([]card(nil), deck.cards...) + p.active, p.drawn, p.held, p.result = true, false, [5]bool{}, "" + return t.statusLocked(name, false) +} +func (t *videoPokerTable) rebet(name string) (string, int) { + return "", 0 // Coin loading is session state handled by the cabinet's own commands. +} +func (t *videoPokerTable) action(name, action string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + p := t.players[name] + if p == nil { + return nil + } + fields := strings.Fields(action) + if len(fields) == 0 { + return nil + } + cmd := fields[0] + if cmd == "paytable" && len(fields) == 1 { + return t.statusLocked(name, true) + } + if p.active { + if action == "draw" { + return t.drawLocked(name, p) + } + if p.drawn { + return []Event{t.private(name, "Settlement is pending; draw retries payment without drawing again.")} + } + digits, ok := ParsePokerDigits(strings.TrimPrefix(action, "hold ")) + if !ok { + return []Event{t.private(name, "Finish this hand first. Toggle holds with 531, then draw.")} + } + for _, d := range digits { + p.held[d] = !p.held[d] + } + return t.statusLocked(name, false) + } + switch cmd { + case "coins": + if len(fields) > 2 { + break + } + n := 0 + switch { + case len(fields) == 1: + // Bare repeat: the previous wager, or five initially. + n = p.lastBet + if n == 0 { + n = 5 + } + case fields[1] == "max": + n = 5 + default: + add, err := strconv.Atoi(fields[1]) + if err != nil || add < 1 || add > 4 { + return []Event{t.private(name, "Use bet [1-4|max].")} + } + n = min(5, p.loaded+add) + } + p.loaded, p.lastBet = n, n + p.lines, p.remaining, p.result, p.current, p.next = nil, nil, "", nil, nil + p.multiplier, p.dice = 1, [2]int{} + return t.statusLocked(name, len(fields) > 1) + case "denom", "denomination": + if len(fields) != 2 { + break + } + n, err := strconv.Atoi(fields[1]) + if err != nil || n != 1 && n != 10 && n != 50 { + break + } + p.denomination = n + p.next, p.current, p.lines, p.remaining, p.result = nil, nil, nil, nil, "" + return t.statusLocked(name, true) + case "hands": + if len(fields) != 2 { + return []Event{t.private(name, fmt.Sprintf("Supported hands: %v", CabinetHandCounts(p.config.Cabinet, p.variant)))} + } + n, err := strconv.Atoi(fields[1]) + if err != nil { + break + } + for _, allowed := range CabinetHandCounts(t.settings.Cabinet, p.variant) { + if n == allowed { + p.hands = n + p.next, p.current, p.lines, p.remaining, p.result = nil, nil, nil, nil, "" + return t.statusLocked(name, true) + } + } + case "game": + if len(fields) < 2 { + return []Event{t.private(name, "Games: "+p.config.GameChoices())} + } + variant, ok := cabinetVariantAliases[strings.ToLower(strings.Join(fields[1:], " "))] + if !ok { + break + } + cfg, ok := t.gameConfig(variant) + if !ok { + return []Event{t.private(name, "That game is not available on this cabinet. Games: "+t.settings.GameChoices())} + } + p.config, p.variant, p.hands = cfg, variant, CabinetHandCounts(cfg.Cabinet, variant)[0] + p.next, p.current, p.lines, p.remaining, p.result = nil, nil, nil, nil, "" + return t.statusLocked(name, true) + case "payout": + if len(fields) != 2 { + break + } + tier := strings.ToLower(fields[1]) + if tier != "low" && tier != "normal" && tier != "full" { + break + } + cfg := (VideoPokerConfig{Cabinet: p.config.Cabinet, Payout: tier, Variant: p.variant}).Normalize() + if err := cfg.Validate(); err != nil { + return []Event{t.private(name, err.Error())} + } + t.settings.Payout = tier + t.settings.PayTable = "" + t.settings = t.settings.Normalize() + p.config = cfg + p.next, p.current, p.lines, p.remaining, p.result = nil, nil, nil, nil, "" + return t.statusLocked(name, true) + case "feature": + if len(fields) != 2 || cabinetFeature(t.settings.Cabinet) == "" || fields[1] != "on" && fields[1] != "off" { + break + } + p.featureOn = fields[1] == "on" + return t.statusLocked(name, false) + } + return []Event{t.private(name, "Use bet [1-4|max], deal, game <name>, payout low|normal|full, denom 1|10|50, hands <count>, paytable, or feature on|off on a bonus cabinet.")} +} + +func (t *videoPokerTable) drawLocked(name string, p *pokerPlayer) []Event { + if !p.drawn { + p.next = make([]int, p.hands) + for line := 0; line < p.hands; line++ { + // Each line draws independently from its OWN copy of the undealt + // deck; all original cards, including discards, are excluded. + deck := append([]card(nil), p.remaining...) + t.rng.Shuffle(len(deck), func(i, j int) { deck[i], deck[j] = deck[j], deck[i] }) + cards := p.cards + draw := 0 + for i := range cards { + if !p.held[i] { + cards[i] = deck[draw] + draw++ + } + } + category, rate := evaluatePoker(cards, p.config) + mult := p.multiplier * p.current[line] + coins := pokerCoinPay(category, rate, p.lastBet) * mult + view := PokerLine{Category: category, Multiplier: mult, Coins: coins} + for _, c := range cards { + view.Cards = append(view.Cards, CardView{Rank: c.rank, Suit: c.suit}) + } + p.lines = append(p.lines, view) + p.payout += coins * p.denomination + p.next[line] = 1 + if p.featureOn && p.config.Feature == PokerUltimateX { + p.next[line] = p.config.NextAward(p.hands, category) + } + } + p.drawn = true + } + // Prepare a new bank without mutating the visible machine. Failed writes + // leave both entitlement and the frozen final hands available for retry. + banks := make(map[string][]int, len(t.banks)) + for key, values := range t.banks { + banks[key] = append([]int(nil), values...) + } + if p.config.Feature == PokerUltimateX { + delete(banks, p.bankKey) + for _, n := range p.next { + if n > 1 { + banks[p.bankKey] = append([]int(nil), p.next...) + break + } + } + } + var err error + if wallet, ok := p.wallet.(PokerWallet); ok && p.config.Feature == PokerUltimateX { + err = wallet.PayPoker(p.payout, p.wager.ID, t.RoomID, t.Config.ID, banks) + } else { + err = p.wallet.PayCredits(p.payout, p.wager.ID) + } + if err != nil { + p.result = "Payment pending; draw retries the SAME final hands" + return t.statusLocked(name, false) + } + t.banks = banks + p.active = false + p.result = fmt.Sprintf("Total return %d coins = %d credits", p.payout/p.denomination, p.payout) + events := t.statusLocked(name, false) + if p.disconnected { + delete(t.players, name) + } + return events +} +func (t *videoPokerTable) markDisconnected(name string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + p := t.players[name] + if p == nil { + return nil + } + p.disconnected = true + if p.active { + return t.drawLocked(name, p) + } + delete(t.players, name) + return nil +} +func (t *videoPokerTable) tick() []Event { + t.mu.Lock() + defer t.mu.Unlock() + var events []Event + for name, p := range t.players { + if p.disconnected && p.active { + events = append(events, t.drawLocked(name, p)...) + } + } + return events +} +func (t *videoPokerTable) refundAll() []Event { + t.mu.Lock() + defer t.mu.Unlock() + var events []Event + for name, p := range t.players { + if p.wallet != nil { + _, _, err := p.wallet.RefundOutstanding() + if err != nil { + events = append(events, t.private(name, "Shutdown refund remains pending for startup recovery.")) + } + } + p.active = false + p.remaining = nil + p.lines = nil + p.loaded = 0 + } + return events +} diff --git a/internal/casino/video_poker_bonus_test.go b/internal/casino/video_poker_bonus_test.go new file mode 100644 index 0000000..6fd06b2 --- /dev/null +++ b/internal/casino/video_poker_bonus_test.go @@ -0,0 +1,127 @@ +package casino + +import "testing" + +func TestPokerUltimateXPerLineAndMachineBanks(t *testing.T) { + table, w, bank := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetUXMulti}) + save := bank + table.action("alice", "hands 3") + p := table.players["alice"] + key := table.multiplierKey(p) + table.banks[key] = []int{2, 3, 4} + // A bank at denomination 1 does not carry into denomination 10. + table.action("alice", "denom 10") + pokerDeal(t, table, w) + for _, m := range p.current { + if m != 1 { + t.Fatal("denomination imported a saved multiplier") + } + } + table.action("alice", "draw") + // Restore denomination 1: the saved bank applies again. + table.action("alice", "denom 1") + pokerDeal(t, table, w) + for i, m := range []int{2, 3, 4} { + if p.current[i] != m { + t.Fatal("same-denomination bank lost") + } + } + p.cards = pokerHand("Ah 3h 6h 9h Jh") + table.action("alice", "12345") + before := w.paid + table.action("alice", "draw") + if w.paid-before != 270 { + t.Fatalf("expected 5 coins * 6 flush * (2+3+4), got %d", w.paid-before) + } + for i, line := range p.lines { + if line.Multiplier != i+2 || p.next[i] != 11 || table.banks[key][i] != 11 { + t.Fatal("current multiplier compounded into the next award") + } + } + if save.writes == 0 { + t.Fatal("the engine bank sink was never invoked") + } + // Feature off: no fee, no new multipliers, saved ones still pay. + table.action("alice", "feature off") + pokerDeal(t, table, w) + if p.wager.Total != 15 { + t.Fatal("cashout charged the feature") + } + p.cards = pokerHand("Ah Kh Qh Jh 10h") + table.action("alice", "12345") + before = w.paid + table.action("alice", "draw") + if w.paid-before != 132000 { + t.Fatalf("cashout returned %d", w.paid-before) + } + // A five-coin hand consumes the multipliers it paid with: the new bank + // is 1x because the feature is off. + for _, m := range table.banks[key] { + if m != 1 { + t.Fatal("spent bank was not cleared") + } + } + // The next five-coin hand plays at plain rates. + pokerDeal(t, table, w) + for _, m := range p.current { + if m != 1 { + t.Fatal("consumed bank applied twice") + } + } + p.cards = pokerHand("Ah Kh Qh Jh 10h") + table.action("alice", "12345") + before = w.paid + table.action("alice", "draw") + if w.paid-before != 12000 { + t.Fatalf("plain royal returned %d", w.paid-before) + } +} +func TestPokerRandomFeaturePayoutExcludesFee(t *testing.T) { + for _, feature := range []string{PokerSuperTimesPay, PokerHotRoll} { + cabinet := CabinetSuperTimesPay + if feature == PokerHotRoll { + cabinet = CabinetHotRoll + } + table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: cabinet}) + table.action("alice", "hands 3") + table.action("alice", "denom 10") + pokerDeal(t, table, w) + p := table.players["alice"] + p.multiplier = 8 // pin the rolled bonus + p.cards = pokerHand("Ah Kh Qh Jh 10h") + table.action("alice", "12345") + table.action("alice", "draw") + if w.paid != 3*4000*8*10 { + t.Fatalf("%s multiplied the feature fee into the payout: %d", feature, w.paid) + } + for _, line := range p.lines { + if line.Multiplier != 8 || line.Coins != 32000 { + t.Fatal("the bonus was not shared across all lines") + } + } + } +} +func TestPokerDisconnectedFailedSettlementRetriesWithoutRedraw(t *testing.T) { + table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) + pokerDeal(t, table, w) + p := table.players["alice"] + w.fail = true + table.markDisconnected("alice") + if !table.hasParticipant("alice") || !p.drawn || !p.active { + t.Fatal("failed disconnected stake was dropped") + } + final := p.lines[0].Cards + table.tick() + for i, c := range final { + if p.lines[0].Cards[i] != c { + t.Fatal("retry redrew cards") + } + } + w.fail = false + table.tick() + paid := w.paid + table.tick() + if !table.idle() || len(w.open) != 0 || w.paid != paid { + t.Fatal("retry did not settle exactly once") + } +} diff --git a/internal/casino/video_poker_config.go b/internal/casino/video_poker_config.go new file mode 100644 index 0000000..918fed3 --- /dev/null +++ b/internal/casino/video_poker_config.go @@ -0,0 +1,218 @@ +package casino + +import ( + "fmt" + "strings" +) + +// Cabinet and Payout are authored settings. The remaining fields identify the +// selected game and its resolved schedule; schedules are never separate games. +type VideoPokerConfig struct { + Cabinet string `yaml:"cabinet,omitempty"` + Payout string `yaml:"payout,omitempty"` + Variant string `yaml:"-"` + PayTable string `yaml:"-"` + Feature string `yaml:"-"` +} + +const ( + CabinetClassic = "classic" + CabinetUXSingle = "ultimate_x_single" + CabinetUXMulti = "ultimate_x_multi" + CabinetSuperTimesPay = "super_times_pay" + CabinetHotRoll = "hot_roll" +) + +func cabinetFeature(c string) string { + switch c { + case CabinetUXSingle, CabinetUXMulti: + return PokerUltimateX + case CabinetSuperTimesPay: + return PokerSuperTimesPay + case CabinetHotRoll: + return PokerHotRoll + } + return "" +} + +var cabinetVariantAliases = map[string]string{ + "jacks": "jacks_or_better", "jacks_or_better": "jacks_or_better", "jacks or better": "jacks_or_better", + "bonus": "bonus_poker", "bonus_poker": "bonus_poker", "bonus poker": "bonus_poker", + "deluxe": "bonus_poker_deluxe", "bonus_poker_deluxe": "bonus_poker_deluxe", "bonus poker deluxe": "bonus_poker_deluxe", + "double": "double_bonus", "double_bonus": "double_bonus", "double bonus": "double_bonus", + "ddb": "double_double_bonus", "double_double_bonus": "double_double_bonus", "double double bonus": "double_double_bonus", + "deuces": "deuces_wild", "deuces_wild": "deuces_wild", "deuces wild": "deuces_wild", + "joker": "joker_poker", "joker_poker": "joker_poker", "joker poker": "joker_poker", +} +var cabinetGames = map[string][]string{ + CabinetClassic: {"jacks_or_better", "bonus_poker", "bonus_poker_deluxe", "double_bonus", "double_double_bonus", "deuces_wild", "joker_poker"}, + CabinetUXSingle: {"jacks_or_better", "bonus_poker", "bonus_poker_deluxe", "double_bonus", "double_double_bonus", "deuces_wild", "joker_poker"}, + // Bonus Poker has only two verified multi-line schedules; omit rather than + // inventing a third quality tier. + CabinetUXMulti: {"jacks_or_better", "bonus_poker_deluxe", "double_bonus", "double_double_bonus", "deuces_wild", "joker_poker"}, + CabinetSuperTimesPay: {"jacks_or_better", "bonus_poker", "double_bonus", "double_double_bonus", "deuces_wild"}, + CabinetHotRoll: {"jacks_or_better", "double_double_bonus"}, +} + +// 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. +var cabinetSchedules = map[string]map[string][3]string{ + CabinetClassic: { + "jacks_or_better": {"7/5", "8/5", "9/6"}, + "bonus_poker": {"6/5", "7/5", "8/5"}, + "bonus_poker_deluxe": {"8/5", "9/5", "9/6"}, + "double_bonus": {"9/6/4", "9/7", "10/7"}, + "double_double_bonus": {"8/5", "9/6", "10/6"}, + "deuces_wild": {"20/12/10/4/4/3", "25/15/9/4/4/3", "25/15/9/5/3/2"}, + "joker_poker": {"50/15/7/5/3", "50/16/7/5/3", "50/17/7/5/3"}, + }, + CabinetUXSingle: { + "jacks_or_better": {"8/5", "8/6", "9/6"}, + "bonus_poker": {"6/5", "7/5", "8/5"}, + "bonus_poker_deluxe": {"8/5", "8/6", "9/6"}, + "double_bonus": {"9/6/4", "9/6/5", "9/7"}, + "double_double_bonus": {"8/5", "9/5", "9/6"}, + "deuces_wild": {"25/16/13/4/3/2", "20/12/10/4/4/3", "25/15/9/4/4/3"}, + "joker_poker": {"50/15/7/5/3", "50/16/7/5/3", "50/17/7/5/3"}, + }, + CabinetUXMulti: { + "jacks_or_better": {"7/5", "8/5", "8/6"}, + "bonus_poker_deluxe": {"7/5", "8/5", "8/6"}, + "double_bonus": {"9/5/4", "9/6/4", "9/6/5"}, + "double_double_bonus": {"8/5", "9/5", "9/6"}, + "deuces_wild": {"25/16/13/4/3/2", "20/12/10/4/4/3", "25/15/9/4/4/3"}, + "joker_poker": {"50/15/7/5/3", "50/16/7/5/3", "50/17/7/5/3"}, + }, + CabinetSuperTimesPay: { + "jacks_or_better": {"7/5", "8/5", "9/6"}, + "bonus_poker": {"6/5", "7/5", "8/5"}, + "double_bonus": {"9/6/4", "9/6/5", "9/7"}, + "double_double_bonus": {"8/5", "8/6", "9/6"}, + "deuces_wild": {"20/12/10/4/4/3", "25/15/9/4/4/3", "25/15/9/5/3/2"}, + }, + CabinetHotRoll: { + "jacks_or_better": {"8/5", "9/5", "9/6"}, + "double_double_bonus": {"8/5", "9/5", "9/6"}, + }, +} + +func CabinetHandCounts(cabinet, variant string) []int { + switch cabinet { + case CabinetUXSingle: + return []int{1} + case CabinetUXMulti, CabinetHotRoll: + return []int{3, 5, 10} + case CabinetSuperTimesPay: + return []int{1, 3, 5, 10} + } + if variant == "jacks_or_better" { + return []int{1, 3, 5, 10, 25, 50, 100} + } + return []int{1} +} +func variantTitle(v string) string { + switch v { + case "jacks_or_better": + return "Jacks or Better" + case "bonus_poker": + return "Bonus Poker" + case "bonus_poker_deluxe": + return "Bonus Poker Deluxe" + case "double_bonus": + return "Double Bonus" + case "double_double_bonus": + return "Double Double Bonus" + case "deuces_wild": + return "Deuces Wild" + case "joker_poker": + return "Joker Poker (Kings or Better)" + } + return v +} +func (c VideoPokerConfig) CabinetLabel() string { + c = c.Normalize() + label := "Classic Video Poker" + switch c.Cabinet { + case CabinetUXSingle: + label = "Ultimate X Single Line" + case CabinetUXMulti: + label = "Ultimate X Multi-Hand" + case CabinetSuperTimesPay: + label = "Super Times Pay" + case CabinetHotRoll: + label = "Hot Roll" + } + return label + " / " + c.Payout + " pay" +} +func (c VideoPokerConfig) Normalize() VideoPokerConfig { + if c.Cabinet == "" { + c.Cabinet = CabinetClassic + } + if c.Payout == "" { + c.Payout = "full" + } + if c.Variant == "" { + c.Variant = "jacks_or_better" + } + if alias, ok := cabinetVariantAliases[c.Variant]; ok { + c.Variant = alias + } + c.Feature = cabinetFeature(c.Cabinet) + ix := -1 + switch c.Payout { + case "low": + ix = 0 + case "normal": + ix = 1 + case "full": + ix = 2 + } + // Explicit schedules are used by evaluator tests. Authored configurations + // and game selection always resolve from the cabinet-wide tier. + if c.PayTable == "" && ix >= 0 { + if schedules, ok := cabinetSchedules[c.Cabinet][c.Variant]; ok { + c.PayTable = schedules[ix] + } + } + return c +} +func (c VideoPokerConfig) Validate() error { + rawFeature := c.Feature + c = c.Normalize() + if _, ok := cabinetGames[c.Cabinet]; !ok { + return fmt.Errorf("unknown video poker cabinet %q", c.Cabinet) + } + if rawFeature != "" && rawFeature != c.Feature { + return fmt.Errorf("video poker feature does not match cabinet") + } + if c.Payout != "low" && c.Payout != "normal" && c.Payout != "full" { + return fmt.Errorf("video poker payout must be low, normal or full") + } + schedules, ok := cabinetSchedules[c.Cabinet][c.Variant] + if !ok { + return fmt.Errorf("unsupported game %q on %s", c.Variant, c.Cabinet) + } + expected := (VideoPokerConfig{Cabinet: c.Cabinet, Payout: c.Payout, Variant: c.Variant}).Normalize() + if c.PayTable != expected.PayTable { + return fmt.Errorf("schedule %q does not match cabinet tier (%v)", c.PayTable, schedules) + } + if _, ok := pokerPayTable(c); !ok { + return fmt.Errorf("unsupported schedule %q", c.PayTable) + } + return nil +} +func (c VideoPokerConfig) VariantOptions() []string { + return append([]string(nil), cabinetGames[c.Normalize().Cabinet]...) +} +func (c VideoPokerConfig) GameLabel() string { + c = c.Normalize() + return variantTitle(c.Variant) + " / " + c.PayTable +} +func (c VideoPokerConfig) GameChoices() string { return strings.Join(c.VariantOptions(), ", ") } + +type PokerPayRow struct { + Name string + Coins [5]int + NextAward int +} diff --git a/internal/casino/video_poker_lifecycle_test.go b/internal/casino/video_poker_lifecycle_test.go new file mode 100644 index 0000000..3107a93 --- /dev/null +++ b/internal/casino/video_poker_lifecycle_test.go @@ -0,0 +1,327 @@ +package casino + +import ( + "math/rand" + "reflect" + "strings" + "testing" +) + +// pokerTestWallet models atomic settlement failure without filesystem I/O. +type pokerTestWallet struct { + testWallet + fail bool + bank *pokerTestBank +} + +func (w *pokerTestWallet) LoadPokerBanks(int, string) (map[string][]int, error) { return nil, nil } +func (w *pokerTestWallet) PayPoker(n int, id string, room int, cabinet string, banks map[string][]int) error { + if err := w.PayCredits(n, id); err != nil { + return err + } + return w.bank.save(room, cabinet, banks) +} + +func (w *pokerTestWallet) PayCredits(n int, ids ...string) error { + if w.fail { + return ErrWalletPersistence + } + return w.testWallet.PayCredits(n, ids...) +} + +func (e Event) MessageStarts(prefix string) bool { return strings.HasPrefix(e.Message, prefix) } + +type pokerTestBank struct{ writes int } + +func (b *pokerTestBank) save(int, string, map[string][]int) error { b.writes++; return nil } + +func pokerTestCabinet(t *testing.T, cfg VideoPokerConfig) (*videoPokerTable, *pokerTestWallet, *pokerTestBank) { + t.Helper() + tableCfg := TableConfig{ID: "vp", Game: GameVideoPoker, VideoPoker: cfg} + if err := tableCfg.Validate(); err != nil { + t.Fatal(err) + } + table := newVideoPokerTable(1, tableCfg.Normalize(), rand.New(rand.NewSource(1))).(*videoPokerTable) + w := &pokerTestWallet{testWallet: testWallet{credits: 10000000}} + bank := &pokerTestBank{} + w.bank = bank + table.join("alice") + return table, w, bank +} +func pokerDeal(t *testing.T, table *videoPokerTable, w Wallet) { + t.Helper() + table.action("alice", "coins max") + table.bet("alice", "deal", 0, w) + if !table.players["alice"].active { + t.Fatal("deal failed") + } +} +func TestPokerCabinetOccupancyAndHints(t *testing.T) { + table, _, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) + table.join("bob") + if table.hasParticipant("bob") { + t.Fatal("cabinet had two occupants") + } + if table.hints("bob") != nil { + t.Fatal("non-occupant saw hints") + } + for _, h := range table.hints("alice") { + if h.Command == "list" { + t.Fatal("irrelevant list hint") + } + } + if table.overview() == "" { + t.Fatal("empty overview") + } +} +func TestPokerCoinsDenominationAndRoyal(t *testing.T) { + table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) + p := table.players["alice"] + for want := 1; want <= 2; want++ { + events := table.action("alice", "coins 1") + snap := events[0].Snapshot.(*VideoPokerSnapshot) + if p.loaded != want || !snap.ShowPayTable || snap.Coins != want { + t.Fatalf("load: coins=%d %+v", p.loaded, snap) + } + } + if len(w.open) != 0 || p.remaining != nil { + t.Fatal("coin loading charged or dealt") + } + table.action("alice", "denom 10") + table.bet("alice", "deal", 0, w) + if p.wager.Total != 20 { + t.Fatalf("cost %d", p.wager.Total) + } + p.cards = pokerHand("Ah Kh Qh Jh 10h") + table.action("alice", "12345") + table.action("alice", "draw") + if w.paid != 5000 { + t.Fatalf("two-coin royal: %d", w.paid) + } + events := table.action("alice", "coins") + if p.loaded != 2 || events[0].Snapshot.(*VideoPokerSnapshot).ShowPayTable { + t.Fatal("bare bet did not repeat silently") + } + // A second coin adds to the loaded wager and reprints the pay table. + events = table.action("alice", "coins 1") + if p.loaded != 3 || !events[0].Snapshot.(*VideoPokerSnapshot).ShowPayTable { + t.Fatal("second coin did not reprint the pay table") + } + table.action("alice", "coins max") + table.bet("alice", "deal", 0, w) + p.cards = pokerHand("Ah Kh Qh Jh 10h") + table.action("alice", "12345") + table.action("alice", "draw") + if w.paid != 45000 { + t.Fatalf("max royal total %d", w.paid) + } + rows := p.config.PayRows(1) + if rows[0].Coins != [5]int{250, 500, 750, 1000, 4000} { + t.Fatalf("royal columns %+v", rows[0]) + } + // An uninitialized cabinet repeats the max wager on the first bare bet. + table2, _, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) + table2.players["alice"].loaded = 0 + table2.action("alice", "coins") + if table2.players["alice"].loaded != 5 { + t.Fatal("initial bare bet not max") + } +} +func TestPokerHoldToggleAndMultiHand(t *testing.T) { + table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) + table.action("alice", "hands 100") + pokerDeal(t, table, w) + p := table.players["alice"] + original := p.cards + table.action("alice", "531") + if !p.held[0] || p.held[1] || !p.held[2] || p.held[3] || !p.held[4] { + t.Fatalf("first toggle: %+v", p.held) + } + table.action("alice", "1") + if p.held[0] { + t.Fatal("digit did not toggle back off") + } + table.action("alice", "1") + table.status("alice") + table.action("alice", "paytable") + if p.remaining == nil || !p.active { + t.Fatal("read commands changed deal") + } + table.action("alice", "denom 50") + table.action("alice", "game jacks") + if p.denomination != 1 || p.variant != "jacks_or_better" { + t.Fatal("settings changed during a hand") + } + table.action("alice", "draw") + if len(p.lines) != 100 || len(w.open) != 0 || p.active { + t.Fatal("bad multi-hand settlement") + } + unique := map[string]bool{} + for _, line := range p.lines { + for _, i := range []int{0, 2, 4} { + if line.Cards[i].Rank != original[i].rank || line.Cards[i].Suit != original[i].suit { + t.Fatal("hold lost") + } + } + for _, i := range []int{1, 3} { + for _, c := range original { + if line.Cards[i].Rank == c.rank && line.Cards[i].Suit == c.suit { + t.Fatal("original card returned") + } + } + } + if line.Cards[1] == line.Cards[3] { + t.Fatal("duplicate within one hand") + } + unique[line.Cards[1].Rank+line.Cards[1].Suit] = true + } + if len(unique) < 10 { + t.Fatal("lines did not draw independently") + } + paid := w.paid + table.action("alice", "draw") + if w.paid != paid { + t.Fatal("second draw paid again") + } + for _, e := range table.status("alice") { + if e.Public || e.PrivateTo != "alice" { + t.Fatal("private cards leaked") + } + } + // Settings change freely between rounds. + table.action("alice", "denom 50") + table.action("alice", "hands 1") + if p.denomination != 50 || p.hands != 1 { + t.Fatal("settings refused between rounds") + } + table.leave("alice") + if !table.idle() { + t.Fatal("seat not released") + } +} +func TestPokerGameAndPayoutSwitching(t *testing.T) { + table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) + p := table.players["alice"] + if !table.action("alice", "game jacks")[0].Snapshot.(*VideoPokerSnapshot).ShowPayTable { + t.Fatal("explicit switch did not show the pay table") + } + table.action("alice", "game ddb") + if p.config.Variant != "double_double_bonus" || p.config.PayTable != "10/6" { + t.Fatalf("alias switch: %+v", p.config) + } + table.action("alice", "payout low") + if p.config.PayTable != "8/5" { + t.Fatalf("tier not applied to the current game: %+v", p.config) + } + table.action("alice", "game joker") + if p.config.PayTable != "50/15/7/5/3" { + t.Fatalf("tier lost switching to joker: %+v", p.config) + } + events := table.action("alice", "game unknown") + if !events[0].MessageStarts("Use bet") || p.variant != "joker_poker" { + t.Fatal("unknown game accepted") + } + table.action("alice", "coins max") + pokerDeal(t, table, w) + if !table.action("alice", "game jacks")[0].MessageStarts("Finish this hand first.") { + t.Fatal("game switched during a hand") + } + table.action("alice", "draw") + // A hundred-hand classic Jacks or Better edition is reachable. + table.action("alice", "game jacks") + table.action("alice", "hands 100") + if p.hands != 100 { + t.Fatal("hundred-play unavailable") + } + table.action("alice", "hands 3") + if p.hands != 3 { + t.Fatal("multi-hand unavailable") + } +} +func TestPokerFeatureFeesAndHandCounts(t *testing.T) { + for _, tt := range []struct { + cabinet string + payTable string + feature string + hands, max int + }{{CabinetUXSingle, "9/6", PokerUltimateX, 1, 1}, {CabinetUXMulti, "8/6", PokerUltimateX, 3, 10}, {CabinetSuperTimesPay, "9/6", PokerSuperTimesPay, 1, 10}, {CabinetHotRoll, "9/6", PokerHotRoll, 3, 10}} { + table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: tt.cabinet}) + p := table.players["alice"] + table.action("alice", "hands 100") + if p.hands != tt.hands { + t.Fatal("unsupported hand count accepted") + } + table.action("alice", "coins 1") + table.bet("alice", "deal", 0, w) + if p.active || len(w.open) != 0 { + t.Fatal("feature allowed a partial bet") + } + pokerDeal(t, table, w) + fee := 5 + if tt.feature == PokerSuperTimesPay { + fee = 1 + } + if p.wager.Total != (5+fee)*tt.hands { + t.Fatalf("%s fee %d", tt.feature, p.wager.Total) + } + if p.config.Feature != tt.feature || p.config.PayTable != tt.payTable { + t.Fatalf("%s cabinet: %+v", tt.cabinet, p.config) + } + table.action("alice", "draw") + table.action("alice", "feature off") + pokerDeal(t, table, w) + if p.wager.Total != 5*tt.hands { + t.Fatal("feature off still charged a fee") + } + } +} +func TestPokerSettlementRetry(t *testing.T) { + table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) + pokerDeal(t, table, w) + p := table.players["alice"] + p.cards = pokerHand("Ah Kh Qh Jh 10h") + table.action("alice", "12345") + w.fail = true + table.action("alice", "draw") + if !p.active || !p.drawn || len(w.open) != 1 { + t.Fatal("failed payout lost stake") + } + final := append([]PokerLine(nil), p.lines...) + table.action("alice", "draw") + if !reflect.DeepEqual(final, p.lines) || w.paid != 0 { + t.Fatal("failed final hands changed or paid") + } + w.fail = false + table.action("alice", "draw") + table.action("alice", "draw") + if w.paid != 4000 || len(w.open) != 0 || p.active { + t.Fatalf("retry: paid=%d", w.paid) + } +} +func TestPokerDisconnectAndRefund(t *testing.T) { + table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) + pokerDeal(t, table, w) + table.markDisconnected("alice") + if table.hasParticipant("alice") || len(w.open) != 0 { + t.Fatal("disconnect failed to settle") + } + table, w, _ = pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) + pokerDeal(t, table, w) + table.refundAll() + table.refundAll() + if w.paid != 5 || len(w.open) != 0 { + t.Fatal("refund not exactly once") + } +} +func TestPokerJokerDeck(t *testing.T) { + table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Variant: "joker_poker"}) + pokerDeal(t, table, w) + p := table.players["alice"] + seen := map[card]bool{} + for _, c := range append(append([]card(nil), p.cards[:]...), p.remaining...) { + seen[c] = true + } + if len(seen) != 53 || !seen[card{rank: "JK", suit: "joker"}] { + t.Fatal("not a 53-card deck") + } +} diff --git a/internal/casino/video_poker_profiles.go b/internal/casino/video_poker_profiles.go new file mode 100644 index 0000000..9471ee6 --- /dev/null +++ b/internal/casino/video_poker_profiles.go @@ -0,0 +1,171 @@ +package casino + +import "math/rand" + +// Bonus feature identifiers (cabinet editions). +const ( + PokerUltimateX = "ultimate_x" + PokerSuperTimesPay = "super_times_pay" + PokerHotRoll = "hot_roll" +) + +// NextAward exposes the published Ultimate X multiplier the named hand earns +// for the selected hand count; 1 means the line earned nothing. +func (c VideoPokerConfig) NextAward(hands int, category string) int { + if cabinetFeature(c.Normalize().Cabinet) != PokerUltimateX { + return 1 + } + return pokerNextMultiplier(c.Cabinet, c.Variant, hands, category) +} + +// Each tuple is [single-line, 3-play, 5-play, 10-play]. These are the +// published Ultimate X award schedules, NOT an interpolation. +func pokerNextMultiplier(cabinet, variant string, hands int, category string) int { + ix := 0 + switch { + case cabinet == CabinetUXSingle: + ix = 0 + case hands == 3: + ix = 1 + case hands == 5: + ix = 2 + case hands == 10: + ix = 3 + } + var m map[string][4]int + switch variant { + case "deuces_wild": + m = map[string][4]int{"Natural royal flush": {2, 2, 2, 4}, "Four deuces": {2, 2, 2, 4}, "Wild royal flush": {2, 2, 2, 4}, "Five of a kind": {2, 2, 3, 3}, "Straight flush": {11, 12, 12, 12}, "Four of a kind": {7, 7, 7, 7}, "Full house": {5, 5, 5, 5}, "Flush": {5, 5, 5, 5}, "Straight": {3, 3, 3, 3}, "Three of a kind": {2, 2, 2, 2}} + case "joker_poker": + m = map[string][4]int{"Natural royal flush": {2, 2, 3, 4}, "Five of a kind": {2, 2, 3, 4}, "Wild royal flush": {2, 2, 3, 4}, "Straight flush": {2, 2, 3, 4}, "Four of a kind": {2, 2, 2, 2}, "Full house": {6, 7, 7, 7}, "Flush": {6, 6, 6, 6}, "Straight": {5, 5, 5, 5}, "Three of a kind": {4, 4, 4, 4}, "Two pair": {3, 3, 3, 3}, "Kings or better": {2, 2, 2, 2}} + case "double_bonus", "double_double_bonus": + m = map[string][4]int{"Royal flush": {2, 2, 2, 4}, "Straight flush": {2, 2, 2, 4}, "Four aces": {2, 2, 2, 4}, "Four 2s-4s": {2, 2, 2, 4}, "Four aces with 2-4 kicker": {2, 2, 2, 4}, "Four 2s-4s with A-4 kicker": {2, 2, 2, 4}, "Four 5s-Ks": {2, 2, 3, 3}, "Full house": {12, 12, 12, 12}, "Flush": {10, 10, 10, 10}, "Straight": {7, 8, 8, 8}, "Three of a kind": {4, 4, 4, 4}, "Two pair": {3, 3, 3, 3}, "Jacks or better": {2, 2, 2, 2}} + if variant == "double_double_bonus" { + for _, k := range []string{"Four aces", "Four 2s-4s", "Four aces with 2-4 kicker", "Four 2s-4s with A-4 kicker"} { + row := m[k] + row[0] = 4 + m[k] = row + } + m["Royal flush"] = [4]int{4, 2, 2, 4} + m["Straight flush"] = [4]int{4, 2, 2, 4} + m["Four 5s-Ks"] = [4]int{4, 2, 3, 3} + } + default: + m = map[string][4]int{"Royal flush": {2, 2, 2, 7}, "Straight flush": {2, 2, 2, 7}, "Four of a kind": {2, 2, 3, 3}, "Full house": {12, 12, 12, 12}, "Flush": {10, 11, 11, 11}, "Straight": {7, 7, 7, 7}, "Three of a kind": {4, 4, 4, 4}, "Two pair": {3, 3, 3, 3}, "Jacks or better": {2, 2, 2, 2}} + } + if variant == "bonus_poker" { + for _, k := range []string{"Royal flush", "Straight flush", "Four aces", "Four 2s-4s", "Four 5s-Ks"} { + m[k] = [4]int{3, 2, 2, 4} + } + m["Four 5s-Ks"] = [4]int{3, 2, 3, 3} + m["Straight"] = [4]int{7, 8, 8, 8} + } + if row, ok := m[category]; ok { + return row[ix] + } + return 1 +} + +// FeatureRules names each feature's published cost and trigger behavior. +func (c VideoPokerConfig) FeatureRules() string { + switch cabinetFeature(c.Normalize().Cabinet) { + case PokerUltimateX: + return "Ultimate X: feature on costs five extra coins per hand. Each winning line earns the NEXT hand's multiplier for that line; a current multiplier never multiplies another multiplier. The feature is not required to use a multiplier that is already showing. Multipliers stay with the machine until they are used." + case PokerSuperTimesPay: + return "Super Times Pay: feature on costs one extra coin per hand. Before the deal a 1-in-15 trigger awards one multiplier shared by every line: 2x/3x/4x/5x/8x/10x with probabilities 17/33/16/24/6/4 percent." + case PokerHotRoll: + return "Hot Roll: feature on costs five extra coins per hand. Before the deal a 1-in-6 trigger rolls two fair dice; their sum multiplies every line this round." + } + return "This cabinet carries no bonus feature." +} + +// FeatureFeeCoins is the extra coin cost per hand while the feature is on. +func (c VideoPokerConfig) FeatureFeeCoins() int { + switch cabinetFeature(c.Normalize().Cabinet) { + case PokerUltimateX, PokerHotRoll: + return 5 + case PokerSuperTimesPay: + return 1 + } + return 0 +} + +// pokerCoinPay is the total coin return of one line: rates are per coin, and +// the 52-card natural royal pays the flat 250-per-coin table below max bet. +func pokerCoinPay(category string, perCoin, coins int) int { + if coins < 1 || coins > 5 { + return 0 + } + if (category == "Royal flush" || category == "Natural royal flush") && coins < 5 { + return 250 * coins + } + return perCoin * coins +} + +// PayRows renders the pay table for the selected game and hand count. +func (c VideoPokerConfig) PayRows(hands int) []PokerPayRow { + c = c.Normalize() + schedule, ok := pokerPayTable(c) + if !ok { + return nil + } + var rows []PokerPayRow + for _, k := range pokerCategories { + if n := schedule.pays[k]; n > 0 { + row := PokerPayRow{Name: k, NextAward: c.NextAward(hands, k)} + for i := range row.Coins { + row.Coins[i] = pokerCoinPay(k, n, i+1) + } + rows = append(rows, row) + } + } + return rows +} + +// pokerRandomMultiplier rolls the deal-time bonus of Super Times Pay and +// Hot Roll; 1 means no multiplier. Hot Roll returns the dice that produced it. +func pokerRandomMultiplier(feature string, rng *rand.Rand) (int, [2]int) { + if feature == PokerHotRoll && rng.Intn(6) == 0 { + dice := [2]int{rng.Intn(6) + 1, rng.Intn(6) + 1} + return dice[0] + dice[1], dice + } + if feature == PokerSuperTimesPay && rng.Intn(15) == 0 { + n := rng.Intn(100) + for _, v := range []struct{ n, m int }{{17, 2}, {33, 3}, {16, 4}, {24, 5}, {6, 8}, {4, 10}} { + if n < v.n { + return v.m, [2]int{} + } + n -= v.n + } + } + return 1, [2]int{} +} + +// pokerHelpTopic names the help page the "rules" command opens while seated +// at this cabinet; every shipped cabinet type has its own page. +func pokerHelpTopic(c VideoPokerConfig) string { + switch c.Normalize().Cabinet { + case CabinetUXSingle: + return "ultimate x single line" + case CabinetUXMulti: + return "ultimate x multi hand" + case CabinetSuperTimesPay: + return "super times pay" + case CabinetHotRoll: + return "hot roll" + } + return "video poker" +} + +// FeatureLabel is the feature's display name for badges and menus. +func (c VideoPokerConfig) FeatureLabel() string { + switch cabinetFeature(c.Normalize().Cabinet) { + case PokerUltimateX: + return "Ultimate X" + case PokerSuperTimesPay: + return "Super Times Pay" + case PokerHotRoll: + return "Hot Roll" + } + return "Classic" +} diff --git a/internal/casino/video_poker_regression_test.go b/internal/casino/video_poker_regression_test.go new file mode 100644 index 0000000..634ebc9 --- /dev/null +++ b/internal/casino/video_poker_regression_test.go @@ -0,0 +1,135 @@ +package casino + +import ( + "reflect" + "strconv" + "testing" +) + +func TestPokerEveryCabinetTierGameAndAward(t *testing.T) { + for cabinet, games := range cabinetGames { + table, _, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: cabinet}) + for _, tier := range []string{"low", "normal", "full"} { + table.action("alice", "payout "+tier) + for _, variant := range games { + events := table.action("alice", "game "+variant) + p := table.players["alice"] + if p.variant != variant || p.config.Payout != tier || len(events) != 1 || events[0].Snapshot == nil { + t.Fatalf("%s/%s/%s failed selection: %+v", cabinet, tier, variant, p.config) + } + if err := p.config.Validate(); err != nil { + t.Fatal(err) + } + for _, hands := range CabinetHandCounts(cabinet, variant) { + table.action("alice", "hands "+strconv.Itoa(hands)) + for _, row := range p.config.PayRows(hands) { + if row.Coins[4] <= 0 { + t.Fatalf("zero payout: %s", row.Name) + } + if p.config.Feature == PokerUltimateX && (row.NextAward < 2 || row.NextAward > 12) { + t.Fatalf("missing UX award: %s/%s/%d/%s = %d", cabinet, variant, hands, row.Name, row.NextAward) + } + } + } + } + } + table.action("alice", "payout low") + table.leave("alice") + table.join("bob") + if table.players["bob"].config.Payout != "low" { + t.Fatal("machine tier changed with occupant") + } + } +} + +func TestPokerInvalidLoadPreservesPreviousCoins(t *testing.T) { + table, _, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) + table.action("alice", "coins 2") + for _, input := range []string{"coins nope", "coins 0", "coins 5", "coins -1", "coins 1 2"} { + table.action("alice", input) + if table.players["alice"].loaded != 2 || table.players["alice"].lastBet != 2 { + t.Fatalf("invalid load %q changed wager", input) + } + } +} + +func TestPokerDuplicateHoldDigitsToggleOnce(t *testing.T) { + table, w, _ := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetClassic}) + pokerDeal(t, table, w) + table.action("alice", "5 3 1 1 3 5") + want := [5]bool{true, false, true, false, true} + if table.players["alice"].held != want { + t.Fatal("duplicates did not toggle once") + } + table.action("alice", "hold none") + if table.players["alice"].held != want { + t.Fatal("hold none changed cards") + } + table.action("alice", "hold 531") + if table.players["alice"].held != ([5]bool{}) { + t.Fatal("second hold command did not release cards") + } +} + +func TestPokerFailedUXSettlementPreservesBankAndFinalHands(t *testing.T) { + table, w, bank := pokerTestCabinet(t, VideoPokerConfig{Cabinet: CabinetUXSingle}) + p := table.players["alice"] + key := table.multiplierKey(p) + table.banks[key] = []int{3} + pokerDeal(t, table, w) + p.cards = pokerHand("Ah 3h 6h 9h Jh") + table.action("alice", "12345") + w.fail = true + table.action("alice", "draw") + frozen := append([]PokerLine(nil), p.lines...) + if bank.writes != 0 || !reflect.DeepEqual(table.banks[key], []int{3}) || !p.active { + t.Fatal("failed payout consumed old bank") + } + table.action("alice", "draw") + if !reflect.DeepEqual(frozen, p.lines) { + t.Fatal("retry changed final cards") + } + w.fail = false + table.action("alice", "draw") + if w.paid != 90 || bank.writes != 1 || !reflect.DeepEqual(table.banks[key], []int{10}) { + t.Fatalf("retry: paid=%d bank=%v writes=%d", w.paid, table.banks[key], bank.writes) + } + table.action("alice", "draw") + if bank.writes != 1 || w.paid != 90 { + t.Fatal("settlement replayed") + } + table.leave("alice") + table.join("bob") + snap := table.status("bob")[0].Snapshot.(*VideoPokerSnapshot) + if !reflect.DeepEqual(snap.Next, []int{10}) { + t.Fatal("new occupant did not inherit earned bank") + } +} + +func TestPokerKnownFeatureScheduleValues(t *testing.T) { + cases := []struct{ cabinet, tier, variant, table string }{ + {CabinetHotRoll, "normal", "jacks_or_better", "9/5"}, + {CabinetHotRoll, "normal", "double_double_bonus", "9/5"}, + {CabinetSuperTimesPay, "full", "double_double_bonus", "9/6"}, + {CabinetSuperTimesPay, "full", "double_bonus", "9/7"}, + {CabinetUXMulti, "full", "double_bonus", "9/6/5"}, + } + for _, tt := range cases { + c := (VideoPokerConfig{Cabinet: tt.cabinet, Payout: tt.tier, Variant: tt.variant}).Normalize() + if c.PayTable != tt.table { + t.Fatalf("%+v => %s", tt, c.PayTable) + } + } + for _, variant := range []string{"bonus_poker", "double_double_bonus"} { + cfg := (VideoPokerConfig{Cabinet: CabinetUXSingle, Variant: variant}).Normalize() + want := 3 + if variant == "double_double_bonus" { + want = 4 + } + for _, category := range []string{"Royal flush", "Straight flush", "Four aces", "Four 2s-4s", "Four 5s-Ks"} { + if got := cfg.NextAward(1, category); got != want { + t.Fatalf("%s %s: %dx != %dx", variant, category, got, want) + } + } + } +} diff --git a/internal/casino/video_poker_rules.go b/internal/casino/video_poker_rules.go new file mode 100644 index 0000000..340815d --- /dev/null +++ b/internal/casino/video_poker_rules.go @@ -0,0 +1,307 @@ +package casino + +import ( + "strings" + "unicode" +) + +type pokerSchedule struct { + name string + pays map[string]int +} + +// pokerCategories is the fixed pay-table row order, top payout first. +var pokerCategories = []string{"Natural royal flush", "Royal flush", "Four deuces", "Five of a kind", "Wild royal flush", "Straight flush", "Four aces with 2-4 kicker", "Four 2s-4s with A-4 kicker", "Four aces", "Four 2s-4s", "Four 5s-Ks", "Four of a kind", "Full house", "Flush", "Straight", "Three of a kind", "Two pair", "Jacks or better", "Kings or better"} + +// 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. +func pokerPayTable(c VideoPokerConfig) (pokerSchedule, bool) { + c = c.Normalize() + if c.Variant == "" { + return pokerSchedule{}, false + } + p := map[string]int{"Royal flush": 800, "Straight flush": 50, "Four of a kind": 25, "Straight": 4, "Three of a kind": 3, "Two pair": 2, "Jacks or better": 1} + name := variantTitle(c.Variant) + switch c.Variant { + case "jacks_or_better": + if !switchTwo(&p, c.PayTable, map[string][2]int{"9/6": {9, 6}, "9/5": {9, 5}, "8/6": {8, 6}, "8/5": {8, 5}, "7/5": {7, 5}}) { + return pokerSchedule{}, false + } + case "bonus_poker": + if !switchTwo(&p, c.PayTable, map[string][2]int{"8/5": {8, 5}, "7/5": {7, 5}, "6/5": {6, 5}}) { + return pokerSchedule{}, false + } + p["Four aces"], p["Four 2s-4s"], p["Four 5s-Ks"] = 80, 40, 25 + delete(p, "Four of a kind") + case "bonus_poker_deluxe": + if !switchTwo(&p, c.PayTable, map[string][2]int{"9/6": {9, 6}, "9/5": {9, 5}, "8/6": {8, 6}, "8/5": {8, 5}, "7/5": {7, 5}}) { + return pokerSchedule{}, false + } + p["Four of a kind"], p["Two pair"] = 80, 1 + case "double_bonus", "double_double_bonus": + p["Two pair"] = 1 + delete(p, "Four of a kind") + p["Four aces"], p["Four 2s-4s"], p["Four 5s-Ks"] = 160, 80, 50 + if c.Variant == "double_bonus" { + p["Straight"] = 5 + switch c.PayTable { + case "10/7": + p["Full house"], p["Flush"] = 10, 7 + case "9/7": + p["Full house"], p["Flush"] = 9, 7 + case "9/6/5": + p["Full house"], p["Flush"] = 9, 6 + case "9/6/4": + p["Full house"], p["Flush"], p["Straight"] = 9, 6, 4 + case "9/5/4": + p["Full house"], p["Flush"], p["Straight"] = 9, 5, 4 + case "8/5": + p["Full house"], p["Flush"], p["Straight"] = 8, 5, 4 + default: + return pokerSchedule{}, false + } + } else { + name = "Double Double Bonus" + p["Four aces with 2-4 kicker"], p["Four 2s-4s with A-4 kicker"] = 400, 160 + switch c.PayTable { + case "10/6": + p["Full house"], p["Flush"] = 10, 6 + case "9/6": + p["Full house"], p["Flush"] = 9, 6 + case "9/5": + p["Full house"], p["Flush"] = 9, 5 + case "8/6": + p["Full house"], p["Flush"] = 8, 6 + case "8/5": + p["Full house"], p["Flush"] = 8, 5 + default: + return pokerSchedule{}, false + } + } + case "deuces_wild": + name = "Deuces Wild" + p = map[string]int{"Natural royal flush": 800, "Four deuces": 200, "Wild royal flush": 25, "Five of a kind": 15, "Straight flush": 9, "Four of a kind": 5, "Full house": 3, "Flush": 2, "Straight": 2, "Three of a kind": 1} + switch c.PayTable { + case "25/15/9/5/3/2": + case "25/15/9/4/4/3": + p["Four of a kind"], p["Full house"], p["Flush"] = 4, 4, 3 + case "25/16/13/4/3/2": + p["Five of a kind"], p["Straight flush"], p["Four of a kind"] = 16, 13, 4 + case "20/12/10/4/4/3": + p["Wild royal flush"], p["Five of a kind"], p["Straight flush"], p["Four of a kind"], p["Full house"], p["Flush"] = 20, 12, 10, 4, 4, 3 + default: + return pokerSchedule{}, false + } + case "joker_poker": + if c.PayTable != "50/17/7/5/3" && c.PayTable != "50/16/7/5/3" && c.PayTable != "50/15/7/5/3" { + return pokerSchedule{}, false + } + name = "Joker Poker (Kings or Better)" + p = map[string]int{"Natural royal flush": 800, "Five of a kind": 200, "Wild royal flush": 100, "Straight flush": 50, "Four of a kind": 17, "Full house": 7, "Flush": 5, "Straight": 3, "Three of a kind": 2, "Two pair": 1, "Kings or better": 1} + if c.PayTable == "50/16/7/5/3" { + p["Four of a kind"] = 16 + } + if c.PayTable == "50/15/7/5/3" { + p["Four of a kind"] = 15 + } + default: + return pokerSchedule{}, false + } + return pokerSchedule{name + " / " + c.PayTable, p}, true +} + +// switchTwo applies a full-house/flush pair to the schedule map and reports +// whether the pay-table name was one of the listed editions. +func switchTwo(p *map[string]int, payTable string, editions map[string][2]int) bool { + if edition, ok := editions[payTable]; ok { + (*p)["Full house"], (*p)["Flush"] = edition[0], edition[1] + return true + } + return false +} + +func pokerRules(c VideoPokerConfig) string { + c = c.Normalize() + schedule, ok := pokerPayTable(c) + if !ok { + return "This cabinet is not configured with a playable game." + } + lines := []string{c.CabinetLabel() + " / " + schedule.name, + "One fresh deck per deal. bet 1-4 loads coins (up to five per hand);", + "bet max loads five. Bare bet repeats the previous selection. deal", + "charges the full wager and deals; numbers toggle holds; d draws.", + "denom 1|10|50 sets credits per coin. paytable shows this box again.", + "Payouts are total coins per hand (stake included), not profit odds.", + c.PairRule(), + } + if c.Feature != "" { + lines = append(lines, c.FeatureRules()) + } + lines = append(lines, "Only the highest-paying category pays on each line.") + return strings.Join(lines, "\n") +} + +// PairRule names the lowest paying hand family of the selected game. +func (c VideoPokerConfig) PairRule() string { + c = c.Normalize() + switch c.Variant { + case "deuces_wild": + return "Every 2 is wild. Three of a kind is the lowest win; only the highest category pays." + case "joker_poker": + return "One joker is wild. A pair of kings or aces is the lowest win; a wild royal is not a natural royal." + case "bonus_poker", "bonus_poker_deluxe", "double_bonus", "double_double_bonus": + return "A pair of jacks or better is the lowest win; four of a kind carries bonus rates by rank." + } + return "A pair of jacks or better is the lowest win." +} + +// ParsePokerDigits accepts the unordered digits of a hold selection: every +// distinct digit toggles that card once per command; spacing and order do not matter. +func ParsePokerDigits(input string) ([]int, bool) { + var digits []int + var seen [5]bool + for _, r := range input { + if unicode.IsSpace(r) { + continue + } + if r < '1' || r > '5' { + return nil, false + } + i := int(r - '1') + if !seen[i] { + digits = append(digits, i) + seen[i] = true + } + } + return digits, len(digits) > 0 +} + +func pokerRank(c card) int { + switch c.rank { + case "A": + return 14 + case "K": + return 13 + case "Q": + return 12 + case "J": + return 11 + } + return c.blackjackValue() +} + +// evaluatePoker scores one five-card line against the resolved schedule. Wild +// evaluation uses rank deficits instead of enumerating wild-card assignments. +func evaluatePoker(cards [5]card, cfg VideoPokerConfig) (string, int) { + cfg = cfg.Normalize() + schedule, ok := pokerPayTable(cfg) + if !ok { + return "No win", 0 + } + counts := [15]int{} + wild := 0 + suit := "" + flush := true + for _, c := range cards { + r := pokerRank(c) + if cfg.Variant == "deuces_wild" && r == 2 || cfg.Variant == "joker_poker" && c.rank == "JK" { + wild++ + continue + } + counts[r]++ + if suit == "" { + suit = c.suit + } else if suit != c.suit { + flush = false + } + } + straight, royal := false, false + for high := 5; high <= 14; high++ { + fits := true + for r := 2; r <= 14; r++ { + if counts[r] == 0 { + continue + } + in := r >= high-4 && r <= high + if high == 5 && r == 14 { + in = true + } + if !in || counts[r] > 1 { + fits = false + break + } + } + if fits { + straight = true + if high == 14 { + royal = true + } + } + } + maxCount, quad, kicker, pairs := 0, 0, 0, 0 + for r := 2; r <= 14; r++ { + if counts[r] > maxCount { + maxCount = counts[r] + } + if counts[r] == 4 { + quad = r + } + if counts[r] == 1 { + kicker = r + } + if counts[r] == 2 { + pairs++ + } + } + var candidates []string + add := func(ok bool, k string) { + if ok { + candidates = append(candidates, k) + } + } + if cfg.Variant == "deuces_wild" || cfg.Variant == "joker_poker" { + add(flush && royal && wild == 0, "Natural royal flush") + add(cfg.Variant == "deuces_wild" && wild == 4, "Four deuces") + add(flush && royal && wild > 0, "Wild royal flush") + add(maxCount+wild >= 5, "Five of a kind") + add(maxCount+wild >= 4, "Four of a kind") + if cfg.Variant == "joker_poker" { + add(pairs == 2 || pairs == 1 && wild == 1, "Two pair") + add(counts[13]+wild >= 2 || counts[14]+wild >= 2, "Kings or better") + } + } else { + add(flush && royal, "Royal flush") + add(quad > 0, "Four of a kind") + add(quad == 14, "Four aces") + add(quad >= 2 && quad <= 4, "Four 2s-4s") + add(quad >= 5 && quad <= 13, "Four 5s-Ks") + add(quad == 14 && kicker >= 2 && kicker <= 4, "Four aces with 2-4 kicker") + add(quad >= 2 && quad <= 4 && (kicker == 14 || kicker >= 2 && kicker <= 4), "Four 2s-4s with A-4 kicker") + add(pairs == 2, "Two pair") + add(counts[11] == 2 || counts[12] == 2 || counts[13] == 2 || counts[14] == 2, "Jacks or better") + } + full := false + for a := 2; a <= 14; a++ { + for b := 2; b <= 14; b++ { + if a == b || counts[a] > 3 || counts[b] > 2 { + continue + } + if counts[a]+counts[b]+wild == 5 { + full = true + } + } + } + add(flush && straight, "Straight flush") + add(full, "Full house") + add(flush, "Flush") + add(straight, "Straight") + add(maxCount+wild >= 3, "Three of a kind") + best, pay := "No win", 0 + for _, k := range candidates { + if schedule.pays[k] > pay { + best, pay = k, schedule.pays[k] + } + } + return best, pay +} diff --git a/internal/casino/video_poker_test.go b/internal/casino/video_poker_test.go new file mode 100644 index 0000000..bd9e38d --- /dev/null +++ b/internal/casino/video_poker_test.go @@ -0,0 +1,141 @@ +package casino + +import ( + "strings" + "testing" +) + +func pokerHand(text string) [5]card { + var cards [5]card + suits := map[byte]string{'c': "clubs", 'd': "diamonds", 'h': "hearts", 's': "spades"} + for i, s := range strings.Fields(text) { + if s == "JK" { + cards[i] = card{rank: "JK", suit: "joker"} + continue + } + cards[i] = card{rank: s[:len(s)-1], suit: suits[s[len(s)-1]]} + } + return cards +} +func TestPokerEvaluation(t *testing.T) { + tests := []struct { + variant, table, hand, category string + pay int + }{ + {"jacks_or_better", "9/6", "Ah Kh Qh Jh 10h", "Royal flush", 800}, + {"jacks_or_better", "9/6", "Ah Ac As Ad 10h", "Four of a kind", 25}, + {"jacks_or_better", "9/6", "Ah 2h 3h 4h 5h", "Straight flush", 50}, + {"jacks_or_better", "9/6", "Ah 2c 3h 4h 5h", "Straight", 4}, + {"jacks_or_better", "9/6", "Qh Kh Ac 2h 3h", "No win", 0}, + {"jacks_or_better", "9/6", "10h 10c 4s 6h 8c", "No win", 0}, + {"jacks_or_better", "9/6", "Jh Jc 4s 6h 8c", "Jacks or better", 1}, + {"jacks_or_better", "9/6", "Jh Jc Js 6h 6c", "Full house", 9}, + {"jacks_or_better", "8/5", "Jh Jc Js 6h 6c", "Full house", 8}, + {"jacks_or_better", "8/5", "Jh 3h 8h 6h 9h", "Flush", 5}, + {"jacks_or_better", "9/6", "Jh Jc 4s 4h 8c", "Two pair", 2}, + {"bonus_poker", "8/5", "Ah Ac As Ad 2c", "Four aces", 80}, + {"bonus_poker", "8/5", "3h 3c 3s 3d Ac", "Four 2s-4s", 40}, + {"bonus_poker", "8/5", "Kh Kc Ks Kd Ac", "Four 5s-Ks", 25}, + {"double_bonus", "10/7", "Ah Ac As Ad 2c", "Four aces", 160}, + {"double_bonus", "10/7", "3h 3c 3s 3d Ac", "Four 2s-4s", 80}, + {"double_bonus", "10/7", "Kh Kc Ks Kd Ac", "Four 5s-Ks", 50}, + {"double_bonus", "10/7", "Ah 2c 3h 4h 5h", "Straight", 5}, + {"double_bonus", "9/6/4", "Ah 2c 3h 4h 5h", "Straight", 4}, + {"double_double_bonus", "10/6", "Ah Ac As Ad 2c", "Four aces with 2-4 kicker", 400}, + {"double_double_bonus", "10/6", "Ah Ac As Ad 5c", "Four aces", 160}, + {"double_double_bonus", "9/6", "4h 4c 4s 4d Ac", "Four 2s-4s with A-4 kicker", 160}, + {"double_double_bonus", "8/5", "2h 2c 2s 2d 4c", "Four 2s-4s with A-4 kicker", 160}, + {"double_double_bonus", "10/6", "4h 4c 4s 4d 5c", "Four 2s-4s", 80}, + {"double_double_bonus", "10/6", "Jh Jc Js 6h 6c", "Full house", 10}, + {"deuces_wild", "25/15/9/5/3/2", "Ah Kh Qh Jh 10h", "Natural royal flush", 800}, + {"deuces_wild", "25/15/9/5/3/2", "2h 2c 2s 2d Ac", "Four deuces", 200}, + {"deuces_wild", "25/15/9/5/3/2", "Ah Kh Qh 2c 10h", "Wild royal flush", 25}, + {"deuces_wild", "25/15/9/5/3/2", "Ah Kh Qc 2c 10h", "Straight", 2}, + {"deuces_wild", "25/15/9/5/3/2", "Ah Ac As 2c 2h", "Five of a kind", 15}, + {"deuces_wild", "25/15/9/5/3/2", "3h 4h 5h 2c 2h", "Straight flush", 9}, + {"deuces_wild", "25/15/9/5/3/2", "3h 3c 3s 2c 7h", "Four of a kind", 5}, + {"deuces_wild", "25/15/9/5/3/2", "3h 3c 7s 2c 7h", "Full house", 3}, + {"deuces_wild", "25/15/9/5/3/2", "3h 4h 8h 2c Jh", "Flush", 2}, + {"deuces_wild", "20/12/10/4/4/3", "3h 4h 8h 2c Jh", "Flush", 3}, + {"deuces_wild", "20/12/10/4/4/3", "Ah Kh Qh 2c 10h", "Wild royal flush", 20}, + {"deuces_wild", "25/15/9/5/3/2", "3h 3c 8s 2c Jh", "Three of a kind", 1}, + {"deuces_wild", "25/15/9/5/3/2", "3h 3c 8s 8c Jh", "No win", 0}, + {"deuces_wild", "25/15/9/5/3/2", "Ah Ac 8s 5c Jh", "No win", 0}, + {"joker_poker", "50/17/7/5/3", "Ah Kh Qh Jh 10h", "Natural royal flush", 800}, + {"joker_poker", "50/17/7/5/3", "Ah Kh Qh JK 10h", "Wild royal flush", 100}, + {"joker_poker", "50/17/7/5/3", "Ah Ac As Ad JK", "Five of a kind", 200}, + {"joker_poker", "50/17/7/5/3", "Kh 3c 5s 8d JK", "Kings or better", 1}, + {"joker_poker", "50/17/7/5/3", "Qh Qc 5s 8d 9h", "No win", 0}, + {"joker_poker", "50/17/7/5/3", "Qh Qc 5s 5d 9h", "Two pair", 1}, + {"joker_poker", "50/17/7/5/3", "Kh Kc 5s 5d JK", "Full house", 7}, + {"bonus_poker_deluxe", "9/6", "2h 2c 2s 2d Ac", "Four of a kind", 80}, + {"bonus_poker_deluxe", "9/6", "Jh Jc 4s 4h 8c", "Two pair", 1}, + } + for _, tt := range tests { + t.Run(tt.variant+"/"+tt.hand, func(t *testing.T) { + got, pay := evaluatePoker(pokerHand(tt.hand), VideoPokerConfig{Variant: tt.variant, PayTable: tt.table}) + if got != tt.category || pay != tt.pay { + t.Fatalf("got %s/%d, want %s/%d", got, pay, tt.category, tt.pay) + } + }) + } +} +func TestPokerHoldDigits(t *testing.T) { + for _, input := range []string{"531", "5 3 1", " 5\t3\n1 "} { + digits, ok := ParsePokerDigits(input) + if !ok || len(digits) != 3 { + t.Fatalf("%q: %v %v", input, digits, ok) + } + } + for _, input := range []string{"", " ", "0", "6", "123456", "1,3", "-1", "1x3"} { + if _, ok := ParsePokerDigits(input); ok { + t.Fatalf("accepted %q", input) + } + } +} +func TestPokerCabinets(t *testing.T) { + // Every cabinet type normalizes to its fixed feature and default game. + cfg := TableConfig{ID: "vp", Game: GameVideoPoker, VideoPoker: VideoPokerConfig{Cabinet: CabinetUXMulti}}.Normalize() + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + if cfg.VideoPoker.Feature != PokerUltimateX || cfg.VideoPoker.Payout != "full" || cfg.VideoPoker.Variant != "jacks_or_better" { + t.Fatalf("UX normalize: %+v", cfg.VideoPoker) + } + if cfg.VideoPoker.PayTable != "8/6" { + t.Fatalf("UX multi schedule %q", cfg.VideoPoker.PayTable) + } + single := TableConfig{ID: "vp", Game: GameVideoPoker, VideoPoker: VideoPokerConfig{Cabinet: CabinetUXSingle}}.Normalize() + if single.VideoPoker.PayTable != "9/6" { + t.Fatalf("UX single schedule %q", single.VideoPoker.PayTable) + } + hr := TableConfig{ID: "vp", Game: GameVideoPoker, VideoPoker: VideoPokerConfig{Cabinet: CabinetHotRoll}}.Normalize() + if hr.VideoPoker.PayTable != "9/6" || hr.VideoPoker.Feature != PokerHotRoll { + t.Fatalf("hot roll: %+v", hr.VideoPoker) + } + // Authored overrides are rejected; tiers must be known values. + for _, c := range []VideoPokerConfig{{Cabinet: "unknown"}, {Payout: "high"}, {Feature: "ux_stp"}} { + if (TableConfig{ID: "vp", Game: GameVideoPoker, VideoPoker: c}).Validate() == nil { + t.Fatalf("accepted %+v", c) + } + } + // Low/normal tiers resolve verified short-pay schedules on classic cabinets. + tiers := map[string]string{"full": "9/6", "normal": "8/5", "low": "7/5"} + for tier, want := range tiers { + cfg := TableConfig{ID: "vp", Game: GameVideoPoker, VideoPoker: VideoPokerConfig{Payout: tier}}.Normalize() + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + if cfg.VideoPoker.PayTable != want { + t.Fatalf("%s: %q", tier, cfg.VideoPoker.PayTable) + } + } + // The pay table carries across games: tier survives a game switch. + mid := TableConfig{ID: "vp", Game: GameVideoPoker, VideoPoker: VideoPokerConfig{Payout: "normal", Variant: "deuces_wild"}}.Normalize() + if mid.VideoPoker.PayTable != "25/15/9/4/4/3" || mid.VideoPoker.Feature != "" { + t.Fatalf("mid deuces: %+v", mid.VideoPoker) + } + if (TableConfig{ID: "bj", Game: GameBlackjack, VideoPoker: VideoPokerConfig{Cabinet: CabinetClassic, Payout: "full", Variant: "jacks_or_better"}}).Validate() == nil { + t.Fatal("foreign block accepted") + } +} diff --git a/internal/game/blackjack_rules.go b/internal/game/blackjack_rules.go index afd8e4a..045626c 100644 --- a/internal/game/blackjack_rules.go +++ b/internal/game/blackjack_rules.go @@ -24,6 +24,39 @@ func (g *Game) lookRouletteTable(sess *net.Session, target string) bool { return g.lookCardTable(sess, target, casino.GameRoulette) } +// A cabinet's published pay table is public; another player's cards are not. +func (g *Game) lookVideoPokerCabinet(sess *net.Session, target string) bool { + if g.Casino == nil || sess.Player == nil { + return false + } + room, err := g.World.LoadRoom(sess.Player.RoomID) + if err != nil { + return false + } + target = strings.TrimSpace(strings.ToLower(target)) + for _, cfg := range room.CasinoTables { + if cfg.Game != casino.GameVideoPoker { + continue + } + id := strings.ToLower(cfg.ID) + if target != id && target != id+" machine" && target != id+" cabinet" { + continue + } + table, ok := g.casinoTable(room.ID, cfg.ID) + if !ok { + return false + } + if seated, playing := g.Casino.Participation(sess.Player.Name); playing && seated == table { + g.emitCasinoEvents(table.Status(sess.Player.Name)) + return true + } + c := table.Config.VideoPoker.Normalize() + sess.WriteLine(table.Config.Label() + g.renderCasinoSnapshot(sess, &casino.VideoPokerSnapshot{Config: c, ShowPayTable: true, Denomination: 1, Hands: casino.CabinetHandCounts(c.Cabinet, c.Variant)[0], FeatureOn: c.Feature != "", Multiplier: 1})) + return true + } + return false +} + func (g *Game) lookCardTable(sess *net.Session, target string, game casino.GameName) bool { room, err := g.World.LoadRoom(sess.Player.RoomID) if err != nil { diff --git a/internal/game/casino.go b/internal/game/casino.go index f29efd6..8d6e2a5 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -263,7 +263,7 @@ func (g *Game) casinoTable(roomID int, gameName string) (*casino.Table, bool) { return nil, false } for _, cfg := range room.CasinoTables { - if strings.EqualFold(string(cfg.Game), gameName) { + if (cfg.Game == casino.GameVideoPoker && strings.EqualFold(cfg.ID, gameName)) || (cfg.Game != casino.GameVideoPoker && strings.EqualFold(string(cfg.Game), gameName)) { table := g.Casino.EnsureTable(roomID, cfg) if table == nil { return nil, false @@ -372,7 +372,13 @@ func (g *Game) startCasinoGame(sess *net.Session, p *player.Player, gameName str sess.WriteLine(fmt.Sprintf("There is no %s table here.", gameName)) return } - g.emitCasinoEvents(g.Casino.Join(table.RoomID, table.Config, p.Name)) + events := g.Casino.Join(table.RoomID, table.Config, p.Name) + if table.Config.Game == casino.GameVideoPoker { + if joined, playing := g.Casino.Participation(p.Name); playing && joined == table { + events = g.Casino.Bet(table.RoomID, table.Config.ID, p.Name, "view", 0, casinoWallet{g: g, p: p}) + } + } + g.emitCasinoEvents(events) } func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { @@ -409,6 +415,10 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { sess.WriteLine("You're not playing a game.") return } + if table.Config.Game == casino.GameVideoPoker { + g.emitCasinoEvents(g.Casino.Action(table.RoomID, table.Config.ID, p.Name, strings.TrimSpace("coins "+strings.Join(args, " ")))) + return + } // Table bet syntax: bet | bet <amount> [on] <target> | bet min|max [on] <target>. // BEFORE an "on" keyword, a token that parses as an amount (or min/max) // sets the wager and any other token is the bet target; AFTER "on", the @@ -517,6 +527,10 @@ func (g *Game) executeTableAction(sess *net.Session, action string) { sess.WriteLine("You're not playing a multiplayer casino game.") return } + if table.Config.Game == casino.GameVideoPoker && action == "deal" { + g.emitCasinoEvents(g.Casino.Bet(table.RoomID, table.Config.ID, p.Name, "deal", 0, casinoWallet{g: g, p: p})) + return + } g.emitCasinoEvents(g.Casino.Action(table.RoomID, table.Config.ID, p.Name, action)) } @@ -626,11 +640,12 @@ func (g *Game) casinoBoardReminder(sess *net.Session, category string, menu *cas return "", "", false } _, isCraps := snapshot.(*casino.CrapsSnapshot) + _, isPoker := snapshot.(*casino.VideoPokerSnapshot) s, isRoulette := snapshot.(*casino.RouletteSnapshot) - if !isCraps && (!isRoulette || s.Phase == "playing" || s.Phase == "result") { + if !isPoker && !isCraps && (!isRoulette || s.Phase == "playing" || s.Phase == "result") { return "", "", false } - if !isCraps && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) { + if !isPoker && !isCraps && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) { // The compact reminder is already a short block; leave it intact. return "", "", false } @@ -711,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.VideoPokerSnapshot: + return renderVideoPoker(s, mode, unicode, size, sess.Player.OptionString("card_style"), g.CardArt) case *casino.CrapsSnapshot: return renderCrapsSnapshot(s, mode, unicode, sess.Player.OptionString("card_style")) case *casino.RouletteSnapshot: diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go index 2881a49..750d05d 100644 --- a/internal/game/casino_ux.go +++ b/internal/game/casino_ux.go @@ -68,6 +68,10 @@ func (g *Game) casinoChoices(roomID int) []casinoChoice { } var choices []casinoChoice for _, c := range room.CasinoTables { + if c.Game == casino.GameVideoPoker { + choices = append(choices, casinoChoice{address: c.ID, names: []string{c.ID, "video_poker", "vp", c.VideoPoker.Normalize().Variant}, table: true}) + continue + } names := []string{string(c.Game)} switch c.Game { case casino.GameBlackjack: @@ -121,7 +125,11 @@ func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) { if len(args) > 0 && args[0] == "rules" { // The felt and the exact, per-table wording stay under table rules; // bare rules opens the game's own help page. - sess.WriteLine(g.renderCasinoRules(sess, t.Config)) + if t.Config.Game == casino.GameVideoPoker { + g.executeTableAction(sess, "paytable") + } else { + sess.WriteLine(g.renderCasinoRules(sess, t.Config)) + } return } if len(args) > 0 && args[0] == "help" { @@ -138,6 +146,9 @@ func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) { // Reuse the game's current-state menu instead of advertising actions // (insurance, split, rebet, etc.) that may not be legal right now. g.emitCasinoEvents(t.Status(sess.Player.Name)) + if t.Config.Game == casino.GameVideoPoker { + return + } commands := []casino.MenuCommand{ {Command: "rules", Desc: "Help page for this game variant"}, {Command: "list", Desc: "Every bet available right now, with payouts"}, @@ -181,6 +192,10 @@ func (g *Game) executeWatch(sess *net.Session, args []string, _ string) { if !ok { return } + if t.Config.Game == casino.GameVideoPoker { + sess.WriteLine("Poker hands are private. Use look " + t.Config.ID + " to inspect this cabinet.") + return + } sess.CasinoWatchRoom = sess.Player.RoomID sess.CasinoWatchTable = t.Config.ID sess.WriteLine(g.colorize(sess, "casino_action", "Watching "+t.Config.Label()+". Detailed public cards are visible; watch off stops watching.")) @@ -347,12 +362,41 @@ func (g *Game) executeList(sess *net.Session, args []string, rawInput string) { // become a brief table; a single row is one line; an empty list means the // only bet is the ordinary amount-only wager. func (g *Game) showCasinoWagerList(sess *net.Session, t *casino.Table) { + if t.Config.Game == casino.GameVideoPoker { + for _, h := range t.Hints(sess.Player.Name) { + if h.Command == "draw" { + sess.WriteLine("No wagers are available during this hand; finish with draw.") + return + } + } + sess.WriteLine("This cabinet has one wager: load coins with bet [1-4|max], then deal. Use paytable for payouts.") + return + } rows := t.Wagers(sess.Player.Name) + // Blackjack rows are actions, not targets for a new bet. + if t.Config.Game == casino.GameBlackjack && len(rows) > 0 { + commands := make([]casino.MenuCommand, 0, len(rows)) + for _, r := range rows { + commands = append(commands, casino.MenuCommand{Command: r.Bet, Desc: strings.Trim(r.Payout+" | "+r.Win, " |")}) + } + sess.WriteLine(g.renderCasinoCommands(sess, g.casinoBalance(sess), "Available actions", commands)) + return + } switch { case len(rows) == 1: r := rows[0] sess.WriteLine(g.colorize(sess, "casino_action", "Your only wager: bet <amount> on "+r.Bet+" — "+r.Payout+" — "+r.Win+".")) case len(rows) == 0: + canBet := false + for _, h := range t.Hints(sess.Player.Name) { + if strings.HasPrefix(h.Command, "bet ") { + canBet = true + } + } + if !canBet { + sess.WriteLine(g.colorize(sess, "casino_action", "No new wagers are available right now.")) + return + } sess.WriteLine(g.colorize(sess, "casino_action", "The only wager here is bet <amount>") + " (" + betRangeWords(t.Config.MinBet, t.Config.MaxBet) + ").") default: commands := make([]casino.MenuCommand, 0, len(rows)) diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index fa3518a..d7b808b 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -5,6 +5,7 @@ import ( "strings" "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/casino" "thehouseoficarus/internal/net" ) @@ -16,16 +17,46 @@ type commandDef struct { } var commandRegistry = map[string]commandDef{ - "get": {(*Game).executeGet, ClassActive}, - "take": {(*Game).executeGet, ClassActive}, - "grab": {(*Game).executeGet, ClassActive}, - "pick": {(*Game).executeGet, ClassActive}, - "drop": {(*Game).executeDrop, ClassActive}, - "attack": {(*Game).executeAttack, ClassActive}, - "kill": {(*Game).executeAttack, ClassActive}, - "work": {(*Game).executeWork, ClassActive}, - "play": {(*Game).executePlay, ClassActive}, - "rebet": {(*Game).executeBet, ClassActive}, + "get": {(*Game).executeGet, ClassActive}, + "take": {(*Game).executeGet, ClassActive}, + "grab": {(*Game).executeGet, ClassActive}, + "pick": {(*Game).executeGet, ClassActive}, + "drop": {(*Game).executeDrop, ClassActive}, + "attack": {(*Game).executeAttack, ClassActive}, + "kill": {(*Game).executeAttack, ClassActive}, + "work": {(*Game).executeWork, ClassActive}, + "play": {(*Game).executePlay, ClassActive}, + "rebet": {(*Game).executeBet, ClassActive}, + "draw": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "draw") }, ClassActive}, + "hold": {func(g *Game, s *net.Session, args []string, _ string) { + g.executeTableAction(s, "hold "+strings.Join(args, " ")) + }, ClassInstant}, + "deal": {func(g *Game, s *net.Session, args []string, _ string) { + if len(args) > 0 { + s.WriteLine("Use deal.") + return + } + g.executeTableAction(s, "deal") + }, ClassActive}, + "paytable": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "paytable") }, ClassInstant}, + "denom": {func(g *Game, s *net.Session, args []string, _ string) { + g.executeTableAction(s, "denom "+strings.Join(args, " ")) + }, ClassInstant}, + "denomination": {func(g *Game, s *net.Session, args []string, _ string) { + g.executeTableAction(s, "denom "+strings.Join(args, " ")) + }, ClassInstant}, + "hands": {func(g *Game, s *net.Session, args []string, _ string) { + g.executeTableAction(s, "hands "+strings.Join(args, " ")) + }, ClassInstant}, + "feature": {func(g *Game, s *net.Session, args []string, _ string) { + g.executeTableAction(s, "feature "+strings.Join(args, " ")) + }, ClassInstant}, + "game": {func(g *Game, s *net.Session, args []string, _ string) { + g.executeTableAction(s, "game "+strings.Join(args, " ")) + }, ClassInstant}, + "payout": {func(g *Game, s *net.Session, args []string, _ string) { + g.executeTableAction(s, "payout "+strings.Join(args, " ")) + }, ClassInstant}, "roll": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "roll") }, ClassActive}, "autoroll": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "autoroll") }, ClassInstant}, "unbet": {func(g *Game, s *net.Session, args []string, _ string) { @@ -192,6 +223,9 @@ var commandRegistry = map[string]commandDef{ } func classifyCommand(cmd string) CommandClass { + if _, ok := casino.ParsePokerDigits(cmd); ok { + return ClassInstant + } if def, ok := commandRegistry[cmd]; ok { return def.class } @@ -226,6 +260,23 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI } } + // 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) + if p != nil && g.Casino != nil && (cmd == "d" || pokerDigits) { + if t, playing := g.Casino.Participation(p.Name); playing && t != nil && t.Config.Game == casino.GameVideoPoker { + if cmd == "d" { + if len(args) != 0 { + sess.WriteLine("Use d or draw.") + return + } + g.executeTableAction(sess, "draw") + return + } + g.executeTableAction(sess, strings.Join(append([]string{cmd}, args...), " ")) + return + } + } if def, ok := commandRegistry[cmd]; ok { def.handler(g, sess, args, rawInput) return diff --git a/internal/game/look_entities.go b/internal/game/look_entities.go index e120c9c..c38b6e7 100644 --- a/internal/game/look_entities.go +++ b/internal/game/look_entities.go @@ -231,6 +231,10 @@ func (g *Game) showRoomCasinos(sess *net.Session, room *world.Room) []string { var lines []string for _, table := range room.CasinoTables { cfg := table.Normalize() + if cfg.Game == casino.GameVideoPoker { + lines = append(lines, g.colorize(sess, "casino_action", cfg.Label()+" cabinet | denom 1/10/50 | one player"), " "+g.colorize(sess, "command", "play "+cfg.ID)) + continue + } label := fmt.Sprintf("%s | Limits %d-%d | 0/%d seated", cfg.Label(), cfg.MinBet, cfg.MaxBet, cfg.MaxPlayers) if g.Casino != nil { if live := g.Casino.Table(room.ID, table.ID); live != nil { diff --git a/internal/game/look_target.go b/internal/game/look_target.go index 2311710..3f8cd15 100644 --- a/internal/game/look_target.go +++ b/internal/game/look_target.go @@ -47,7 +47,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { return } - if g.lookBlackjackTable(sess, lower) || g.lookBaccaratTable(sess, lower) || g.lookRouletteTable(sess, lower) || g.lookCrapsTable(sess, lower) { + if g.lookBlackjackTable(sess, lower) || g.lookBaccaratTable(sess, lower) || g.lookRouletteTable(sess, lower) || g.lookCrapsTable(sess, lower) || g.lookVideoPokerCabinet(sess, lower) { return } diff --git a/internal/game/render_video_poker.go b/internal/game/render_video_poker.go new file mode 100644 index 0000000..d7b1a57 --- /dev/null +++ b/internal/game/render_video_poker.go @@ -0,0 +1,216 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/cardart" + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" +) + +var ( + pokerBlue = color.NearestXterm256(0, 2, 105) + pokerRed = color.NearestXterm256(254, 0, 0) + pokerYellow = color.NearestXterm256(247, 231, 14) +) + +func pokerInk(mode, text string, active bool) string { + bg := pokerBlue + if active { + bg = pokerRed + } + return color.Render(mode, color.ColorSpec{Fg: pokerYellow, Bg: bg, Bold: true}, text) +} +func renderPokerPayTable(s *casino.VideoPokerSnapshot, mode string) string { + rows := s.Config.PayRows(s.Hands) + if len(rows) == 0 { + return "" + } + nameWidth, coinWidth := len("COINS PER HAND")+3, 6 + for _, row := range rows { + nameWidth = max(nameWidth, color.VisibleLen(row.Name)+3) + for _, n := range row.Coins { + coinWidth = max(coinWidth, len(fmt.Sprint(n))+2) + } + } + hasNext := s.Config.Feature == casino.PokerUltimateX + widths := []int{nameWidth, coinWidth, coinWidth, coinWidth, coinWidth, coinWidth} + if hasNext { + widths = append(widths, 8) + } + border := func(left, middle, right string) string { + parts := make([]string, len(widths)) + for i, w := range widths { + parts[i] = strings.Repeat("─", w) + } + return pokerInk(mode, left+strings.Join(parts, middle)+right, false) + } + row := func(cells []string, leaders bool) string { + var b strings.Builder + b.WriteString(pokerInk(mode, "│", false)) + for i, text := range cells { + if i == 0 && leaders { + text = " " + text + " " + strings.Repeat(".", widths[0]-color.VisibleLen(text)-2) + } else { + pad := max(0, widths[i]-color.VisibleLen(text)) + text = strings.Repeat(" ", pad/2) + text + strings.Repeat(" ", pad-pad/2) + } + b.WriteString(pokerInk(mode, text, i > 0 && i <= 5 && s.Coins == i)) + b.WriteString(pokerInk(mode, "│", false)) + } + return b.String() + } + headers := []string{"COINS PER HAND", "1", "2", "3", "4", "5"} + if hasNext { + headers = append(headers, "NEXT X") + } + lines := []string{s.Config.GameLabel() + " — " + s.Config.Payout + " pay", border("┌", "┬", "┐"), row(headers, false), border("├", "┼", "┤")} + for _, pay := range rows { + cells := []string{pay.Name} + for _, n := range pay.Coins { + cells = append(cells, fmt.Sprint(n)) + } + if hasNext { + cells = append(cells, fmt.Sprintf("%dx", pay.NextAward)) + } + lines = append(lines, row(cells, true)) + } + lines = append(lines, border("└", "┴", "┘"), fmt.Sprintf("1 coin = %d credits | %d hand(s) | selected coin column in red", s.Denomination, s.Hands)) + if s.Config.Feature != "" { + lines = append(lines, fmt.Sprintf("Feature ON adds %d coin(s) per hand; requires five base coins.", s.Config.FeatureFeeCoins())) + } + return strings.Join(lines, "\n") +} + +func renderPokerCards(cards []casino.CardView, held *[5]bool, mode string, unicode bool, size int, style string, store *cardart.Store) []string { + if len(cards) == 0 { + return nil + } + art := make([][]string, len(cards)) + widths := make([]int, len(cards)) + height := 0 + for i, c := range cards { + art[i] = cardsLine(mode, []casino.CardView{c}, size, unicode, style, store) + height = max(height, len(art[i])) + for _, line := range art[i] { + widths[i] = max(widths[i], color.VisibleLen(line)) + } + if held != nil { + widths[i] = max(widths[i], 6) + } + } + var lines []string + for r := 0; r < height; r++ { + parts := make([]string, len(cards)) + for i := range cards { + text := "" + if r < len(art[i]) { + text = art[i][r] + } + parts[i] = rouletteCenter(text, widths[i]) + } + lines = append(lines, strings.Join(parts, " ")) + } + if held != nil { + labels := make([]string, len(cards)) + for i := range cards { + text := fmt.Sprint(i + 1) + if held[i] { + text = color.Render(mode, color.ColorSpec{Fg: pokerYellow, Bg: -1, Bold: true}, text+" HELD") + } + labels[i] = rouletteCenter(text, widths[i]) + } + lines = append(lines, strings.Join(labels, " ")) + } + return lines +} + +func renderVideoPoker(s *casino.VideoPokerSnapshot, mode string, unicode bool, size int, style string, store *cardart.Store) string { + if s.Hands > 1 { + size = min(size, 5) + } + if s.Hands >= 25 { + size = 1 + } + if s.Hands >= 100 { + unicode = true + } + var lines []string + if s.ShowPayTable { + lines = append(lines, renderPokerPayTable(s, mode), "") + } + feature := "CLASSIC" + if s.Config.Feature != "" { + feature = s.Config.FeatureLabel() + " OFF" + if s.FeatureOn { + feature = s.Config.FeatureLabel() + " ON" + } + } + badge := pokerInk(mode, " ["+feature+"] ", false) + bonus := "" + mult := s.Multiplier + if s.Config.Feature == casino.PokerUltimateX { + for _, n := range s.Current { + mult = max(mult, n) + } + } + if mult > 1 { + text := fmt.Sprintf(" %dX! ", mult) + if s.Config.Feature == casino.PokerUltimateX && s.Hands > 1 { + text = fmt.Sprintf(" Up to %dX! ", mult) + } + bonus = pokerInk(mode, text, true) + } + if s.Dice[0] > 0 { + bonus += fmt.Sprintf(" Dice [%d] + [%d]", s.Dice[0], s.Dice[1]) + } + if len(s.Cards) > 0 && len(s.Lines) == 0 { + art := renderPokerCards(s.Cards, &s.Held, mode, unicode, size, style, store) + if len(art) > 0 { + art[0] += " " + badge + bonus + } + lines = append(lines, art...) + } else if len(s.Lines) == 0 { + lines = append(lines, badge+bonus) + } + for i, line := range s.Lines { + art := renderPokerCards(line.Cards, nil, mode, unicode, size, style, store) + if i == 0 && len(art) > 0 { + art[0] += " " + badge + bonus + } + if len(s.Lines) > 1 && len(art) > 0 { + art[0] = fmt.Sprintf("%3d: ", i+1) + art[0] + } + lines = append(lines, art...) + text := "No win" + fg := 15 + if line.Coins > 0 { + text = fmt.Sprintf("WIN: %s — %d coins", line.Category, line.Coins) + fg = pokerYellow + } + if line.Multiplier > 1 { + text += fmt.Sprintf(" (%dx)", line.Multiplier) + } + if len(s.Next) > i && s.Next[i] > 1 { + text += fmt.Sprintf(" | earns %dx", s.Next[i]) + } + // Results always follow the cards, including compact hundred-play. + lines = append(lines, color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1, Bold: true}, text)) + } + if s.Result != "" { + fg := pokerYellow + if s.NoWin { + fg = 15 + } + lines = append(lines, color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1, Bold: true}, s.Result)) + } + if !s.Drawing && len(s.Lines) == 0 && s.Config.Feature == casino.PokerUltimateX && len(s.Next) > 0 { + var parts []string + for i, n := range s.Next { + parts = append(parts, fmt.Sprintf("%d:%dx", i+1, n)) + } + lines = append(lines, "Multipliers on the machine: "+strings.Join(parts, " ")) + } + return "\n" + strings.Join(lines, "\n") + "\n" +} diff --git a/internal/game/vesta_world_test.go b/internal/game/vesta_world_test.go index 1a26bf6..b719e24 100644 --- a/internal/game/vesta_world_test.go +++ b/internal/game/vesta_world_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "thehouseoficarus/internal/casino" "thehouseoficarus/internal/item" "thehouseoficarus/internal/object" "thehouseoficarus/internal/validate" @@ -292,6 +293,16 @@ func TestVestaCasinoFloorsAndVariants(t *testing.T) { future++ } count := len(r.CasinoTables) + len(r.CasinoMachines) + // Individually addressed video-poker cabinets share a lounge. + // Multiplayer games still require their own salons. + for _, cfg := range r.CasinoTables { + if cfg.Game == casino.GameVideoPoker { + count-- + if cfg.Normalize().MaxPlayers != 1 { + t.Errorf("room %d: poker cabinet is not single-seat", id) + } + } + } if count > 1 { t.Errorf("room %d: games must have individual salons", id) } @@ -309,7 +320,7 @@ func TestVestaCasinoFloorsAndVariants(t *testing.T) { classic = classic || machine.Variant == "" } } - if games["blackjack"] != 5 || games["baccarat"] != 3 || games["roulette"] != 3 || games["craps"] != 7 || games["slots"] != 5 || !soft17 || !surrender || !singleDeck || !huff || !classic { + if games["blackjack"] != 5 || games["baccarat"] != 3 || games["roulette"] != 3 || games["craps"] != 7 || games["slots"] != 5 || games["video_poker"] != 5 || !soft17 || !surrender || !singleDeck || !huff || !classic { t.Errorf("casino variant coverage lost: %v", games) } if future != 0 { @@ -317,7 +328,7 @@ func TestVestaCasinoFloorsAndVariants(t *testing.T) { } // One salon per game sits on the gaming street; each keeps its editions // within a couple of steps north or south, so no variant is ever far away. - street := map[int]string{6101: "blackjack", 6127: "baccarat", 6182: "roulette", 6185: "craps", 6118: "slots"} + street := map[int]string{6101: "blackjack", 6127: "baccarat", 6182: "roulette", 6185: "craps", 6118: "slots", 6192: "video_poker"} for id, game := range street { r := rooms[id] found := false @@ -339,7 +350,8 @@ func TestVestaCasinoFloorsAndVariants(t *testing.T) { 6127: {world.West, world.East}, 6182: {world.West, world.East}, 6185: {world.West, world.East}, - 6118: {world.West}, + 6118: {world.West, world.East}, + 6192: {world.West}, } { r := rooms[id] for _, dir := range want { diff --git a/internal/game/video_poker_banks.go b/internal/game/video_poker_banks.go new file mode 100644 index 0000000..bb18837 --- /dev/null +++ b/internal/game/video_poker_banks.go @@ -0,0 +1,145 @@ +package game + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/player" +) + +var _ casino.PokerWallet = casinoWallet{} + +type pokerBankFile struct { + Banks map[string][]int `yaml:"banks"` +} + +func (g *Game) pokerBankPath(roomID int, cabinetID string) (string, error) { + if cabinetID == "" { + return "", fmt.Errorf("empty cabinet id") + } + for _, r := range cabinetID { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-') { + return "", fmt.Errorf("invalid cabinet id %q", cabinetID) + } + } + return filepath.Join(g.DataDir, "casino", "video_poker_banks", fmt.Sprintf("%d_%s.yaml", roomID, cabinetID)), nil +} + +// Missing is a new cabinet. Corrupt/unreadable state is NOT an empty bank: +// refuse deals instead of silently destroying or replaying entitlements. +func (w casinoWallet) LoadPokerBanks(roomID int, cabinetID string) (map[string][]int, error) { + path, err := w.g.pokerBankPath(roomID, cabinetID) + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return map[string][]int{}, nil + } + if err != nil { + return nil, err + } + var file pokerBankFile + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + if err := decoder.Decode(&file); err != nil { + return nil, err + } + for key, values := range file.Banks { + if len(strings.Split(key, "/")) != 4 || len(values) == 0 || len(values) > 10 { + return nil, fmt.Errorf("invalid multiplier bank %q", key) + } + for _, n := range values { + if n < 1 || n > 12 { + return nil, fmt.Errorf("invalid multiplier %d", n) + } + } + } + return file.Banks, nil +} + +// PayPoker replaces the character payout/stake ledger AND the whole cabinet +// bank in one undo-journal transaction, on the game owner goroutine. A crash +// before commit restores both; startup then refunds the still-pending stake. +// A committed payout cannot leave an old bank that another player can collect. +func (w casinoWallet) PayPoker(amount int, stakeID string, roomID int, cabinetID string, banks map[string][]int) error { + candidate := *w.p + candidate.PendingWagers = append([]player.PendingWager(nil), w.p.PendingWagers...) + if stakeID == "" || len(takePendingWagers(&candidate, []string{stakeID})) != 1 { + return casino.ErrWalletPersistence + } + sum, err := addCreditsChecked(candidate.Credits, amount) + if err != nil { + return err + } + candidate.Credits = sum + path, err := w.g.pokerBankPath(roomID, cabinetID) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + for _, directory := range []string{filepath.Dir(path), filepath.Dir(filepath.Dir(path)), w.g.DataDir} { + d, err := os.Open(directory) + if err != nil { + return err + } + err = d.Sync() + d.Close() + if err != nil { + return err + } + } + bankData, err := yaml.Marshal(pokerBankFile{Banks: banks}) + if err != nil { + return err + } + charData, err := w.g.AccountStore.MarshalCharacter(&candidate) + if err != nil { + return err + } + charPath := w.g.AccountStore.CharPath(w.p.Name) + if err := os.MkdirAll(w.g.journalDir(), 0700); err != nil { + return err + } + txn := w.g.newTxn() + if err := txn.Replace(charPath, charData, 0600); err != nil { + return err + } + if err := txn.Replace(path, bankData, 0600); err != nil { + return err + } + if err := txn.Commit(); err != nil { + // A commit-point/unlink error is ambiguous. Finish recovery before any + // retry or later character save, then reconcile the actual pair of files. + // If recovery/directory sync fails, fail closed, as startup recovery does. + w.g.recoverJournals() + dir, syncErr := os.Open(w.g.journalDir()) + if syncErr == nil { + syncErr = dir.Sync() + dir.Close() + } + if syncErr != nil { + panic(fmt.Sprintf("poker journal durability: %v", syncErr)) + } + actualChar, cerr := os.ReadFile(charPath) + actualBank, berr := os.ReadFile(path) + charApplied := cerr == nil && bytes.Equal(actualChar, charData) + bankApplied := berr == nil && bytes.Equal(actualBank, bankData) + if !charApplied { + return err + } // recovered before-images; no live mutation + if !bankApplied { + panic("poker settlement recovery left inconsistent files") + } + } + w.p.Credits = candidate.Credits + w.p.PendingWagers = candidate.PendingWagers + return nil +} diff --git a/internal/game/video_poker_cabinet_test.go b/internal/game/video_poker_cabinet_test.go new file mode 100644 index 0000000..a327bce --- /dev/null +++ b/internal/game/video_poker_cabinet_test.go @@ -0,0 +1,177 @@ +package game + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "gopkg.in/yaml.v3" + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/persistence" +) + +const uxTestRoom = "name: Cabinets\ncasino_tables:\n - id: ux_multi\n game: video_poker\n video_poker:\n cabinet: ultimate_x_multi\n" + +func TestPokerBankAtomicSettlementAndRetry(t *testing.T) { + g, sess, _ := casinoCrapsGame(t, uxTestRoom) + g.DataDir = t.TempDir() + w := casinoWallet{g: g, p: sess.Player} + stake, err := w.Wager(10) + if err != nil { + t.Fatal(err) + } + banks := map[string][]int{"jacks_or_better/full/1/3": {2, 3, 4}} + // A failure before journal creation cannot apply either half. + if err := os.WriteFile(g.journalDir(), []byte("blocked"), 0600); err != nil { + t.Fatal(err) + } + if err := w.PayPoker(50, stake.ID, 1, "ux_multi", banks); err == nil { + t.Fatal("accepted failed persistence") + } + if sess.Player.Credits != 90 || len(sess.Player.PendingWagers) != 1 { + t.Fatal("failure mutated character") + } + loaded, err := w.LoadPokerBanks(1, "ux_multi") + if err != nil || len(loaded) != 0 { + t.Fatalf("bank changed: %v %v", loaded, err) + } + if err := os.Remove(g.journalDir()); err != nil { + t.Fatal(err) + } + if err := w.PayPoker(50, stake.ID, 1, "ux_multi", banks); err != nil { + t.Fatal(err) + } + disk, err := g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + loaded, err = w.LoadPokerBanks(1, "ux_multi") + if err != nil || disk.Credits != 140 || sess.Player.Credits != 140 || len(disk.PendingWagers) != 0 || !reflect.DeepEqual(loaded, banks) { + t.Fatalf("settlement: %v %+v", err, disk) + } + if err := w.PayPoker(50, stake.ID, 1, "ux_multi", banks); err == nil { + t.Fatal("duplicate stake paid") + } +} + +func TestPokerSeatsPassMachineBankAcrossRestart(t *testing.T) { + g, sess, _ := casinoCrapsGame(t, uxTestRoom) + g.DataDir = t.TempDir() + w := casinoWallet{g: g, p: sess.Player} + stake, err := w.Wager(1) + if err != nil { + t.Fatal(err) + } + banks := map[string][]int{"jacks_or_better/full/1/3": {2, 3, 4}} + if err := w.PayPoker(0, stake.ID, 1, "ux_multi", banks); err != nil { + t.Fatal(err) + } + // Restart the casino manager: the bank comes from disk, not a player map. + g.Casino = casino.NewManager(2) + g.executePlay(sess, []string{"ux_multi"}, "play ux_multi") + table, ok := g.Casino.Participation("alice") + if !ok || table == nil { + t.Fatal("not seated") + } + g.Casino.Join(1, table.Config, "bob") + if _, playing := g.Casino.Participation("bob"); playing { + t.Fatal("second occupant admitted") + } + snap := table.Status("alice")[0].Snapshot.(*casino.VideoPokerSnapshot) + if !reflect.DeepEqual(snap.Next, []int{2, 3, 4}) { + t.Fatalf("bank not displayed on join: %v", snap.Next) + } + if !g.leaveCasinoGames(sess.Player) { + t.Fatal("could not leave") + } + g.Casino.Join(1, table.Config, "bob") + snap = table.Status("bob")[0].Snapshot.(*casino.VideoPokerSnapshot) + if !reflect.DeepEqual(snap.Next, []int{2, 3, 4}) { + t.Fatalf("next occupant did not inherit: %v", snap.Next) + } + // Settings keep separate banks; merely viewing them never consumes them. + g.Casino.Action(1, table.Config.ID, "bob", "denom 10") + if got := table.Status("bob")[0].Snapshot.(*casino.VideoPokerSnapshot).Next; len(got) != 0 { + t.Fatalf("wrong denomination bank: %v", got) + } + g.Casino.Action(1, table.Config.ID, "bob", "denom 1") + if got := table.Status("bob")[0].Snapshot.(*casino.VideoPokerSnapshot).Next; !reflect.DeepEqual(got, []int{2, 3, 4}) { + t.Fatal("bank lost on switch") + } +} + +func TestPokerCorruptBankRefusesDeal(t *testing.T) { + g, sess, conn := casinoCrapsGame(t, uxTestRoom) + g.DataDir = t.TempDir() + path, _ := g.pokerBankPath(1, "ux_multi") + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("banks: [broken"), 0600); err != nil { + t.Fatal(err) + } + g.executePlay(sess, []string{"ux_multi"}, "play ux_multi") + g.executeBet(sess, []string{"max"}, "bet max") + g.executeTableAction(sess, "deal") + if sess.Player.Credits != 100 || len(sess.Player.PendingWagers) != 0 || !strings.Contains(string(conn.data), "Machine bank unavailable") { + t.Fatal("corrupt bank did not block deal") + } +} + +func TestPokerInterruptedTransactionRecovery(t *testing.T) { + g, sess, _ := casinoCrapsGame(t, uxTestRoom) + g.DataDir = t.TempDir() + w := casinoWallet{g: g, p: sess.Player} + stake, err := w.Wager(10) + if err != nil { + t.Fatal(err) + } + path, _ := g.pokerBankPath(1, "ux_multi") + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + before, _ := yaml.Marshal(pokerBankFile{Banks: map[string][]int{"jacks_or_better/full/1/3": {2, 3, 4}}}) + if err := os.WriteFile(path, before, 0600); err != nil { + t.Fatal(err) + } + next := *sess.Player + next.Credits += 50 + next.PendingWagers = nil + charData, _ := g.AccountStore.MarshalCharacter(&next) + bankData, _ := yaml.Marshal(pokerBankFile{}) + txn := g.newTxn() + if err := txn.Replace(g.AccountStore.CharPath("alice"), charData, 0600); err != nil { + t.Fatal(err) + } + if err := txn.Replace(path, bankData, 0600); err != nil { + t.Fatal(err) + } + journal, err := txn.MarshalJournal() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(g.journalDir(), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(g.journalDir(), "interrupted.json"), journal, 0600); err != nil { + t.Fatal(err) + } + // Process died after applying just the character replacement. + if err := persistence.WriteFile(g.AccountStore.CharPath("alice"), charData, 0600); err != nil { + t.Fatal(err) + } + g.recoverJournals() + restored, err := g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if restored.Credits != 90 || len(restored.PendingWagers) != 1 || restored.PendingWagers[0].ID != stake.ID { + t.Fatal("character before-image not restored") + } + restoredBank, err := w.LoadPokerBanks(1, "ux_multi") + if err != nil || !reflect.DeepEqual(restoredBank["jacks_or_better/full/1/3"], []int{2, 3, 4}) { + t.Fatal("bank before-image not restored") + } +} diff --git a/internal/game/video_poker_render_test.go b/internal/game/video_poker_render_test.go new file mode 100644 index 0000000..d618c36 --- /dev/null +++ b/internal/game/video_poker_render_test.go @@ -0,0 +1,112 @@ +package game + +import ( + "fmt" + "strings" + "testing" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" +) + +func TestPokerPayTableBordersPaletteAndColumns(t *testing.T) { + for _, cabinet := range []string{casino.CabinetClassic, casino.CabinetUXSingle, casino.CabinetUXMulti} { + cfg := (casino.VideoPokerConfig{Cabinet: cabinet, Variant: "double_double_bonus"}).Normalize() + snap := &casino.VideoPokerSnapshot{Config: cfg, Coins: 3, Hands: 3, Denomination: 10} + panel := renderPokerPayTable(snap, "xterm256") + for _, want := range []string{"┌", "┬", "┐", "│", "└", "┴", "┘", "....", "4000", "COINS PER HAND"} { + if !strings.Contains(panel, want) { + t.Fatalf("panel missing %q: %s", want, panel) + } + } + for _, seq := range []string{fmt.Sprintf("38;5;%d", pokerYellow), fmt.Sprintf("48;5;%d", pokerBlue), fmt.Sprintf("48;5;%d", pokerRed)} { + if !strings.Contains(panel, seq) { + t.Fatalf("missing palette %s", seq) + } + } + width := -1 + for _, line := range strings.Split(stripANSI(panel), "\n") { + if !strings.HasPrefix(line, "│") && !strings.HasPrefix(line, "┌") && !strings.HasPrefix(line, "├") && !strings.HasPrefix(line, "└") { + continue + } + if width < 0 { + width = color.VisibleLen(line) + } + if color.VisibleLen(line) != width { + t.Fatalf("unaligned pay table: %q (%d != %d)", line, color.VisibleLen(line), width) + } + if strings.HasPrefix(line, "│") { + cells := strings.Split(line, "│") + want := 8 + if cabinet != casino.CabinetClassic { + want = 9 + } + if len(cells) != want { + t.Fatalf("bad column count %d: %s", len(cells), line) + } + } + } + if cabinet != casino.CabinetClassic && strings.Count(stripANSI(panel), "NEXT X") != 1 { + t.Fatal("missing/duplicate next award heading") + } + plain := renderPokerPayTable(snap, "none") + if strings.Contains(plain, "\x1b") { + t.Fatal("plain display contains escapes") + } + } +} + +func TestPokerResultsBelowCardsAndHoldStyle(t *testing.T) { + cards := []casino.CardView{{Rank: "A", Suit: "hearts"}, {Rank: "K", Suit: "hearts"}, {Rank: "Q", Suit: "hearts"}, {Rank: "J", Suit: "hearts"}, {Rank: "10", Suit: "hearts"}} + cfg := (casino.VideoPokerConfig{Cabinet: casino.CabinetClassic}).Normalize() + for _, hands := range []int{1, 3, 100} { + for _, win := range []bool{false, true} { + line := casino.PokerLine{Cards: cards, Category: "No win", Multiplier: 1} + fg, text := 15, "No win" + if win { + line.Category = "Royal flush" + line.Coins = 4000 + fg = pokerYellow + text = "WIN: Royal flush — 4000 coins" + } + snap := &casino.VideoPokerSnapshot{Config: cfg, Hands: hands, Lines: []casino.PokerLine{line}, Multiplier: 1} + out := renderVideoPoker(snap, "xterm256", true, 7, "", nil) + styled := color.Render("xterm256", color.ColorSpec{Fg: fg, Bg: -1, Bold: true}, text) + if !strings.Contains(out, styled) { + t.Fatalf("missing result styling: %q", out) + } + plain := stripANSI(out) + result := strings.Index(plain, text) + // The result starts its own line and follows the card/badge line. + if result < 0 || result == 0 || plain[result-1] != '\n' || strings.Index(plain, "CLASSIC") > result { + t.Fatalf("result not below cards: %s", plain) + } + if hands == 100 && !strings.Contains(plain, "♥") { + t.Fatal("hundred-play missing compact Unicode suits") + } + } + } + held := [5]bool{true, false, true, false, true} + art := strings.Join(renderPokerCards(cards, &held, "xterm256", true, 5, "", nil), "\n") + for _, n := range []int{1, 3, 5} { + if !strings.Contains(art, color.Render("xterm256", color.ColorSpec{Fg: pokerYellow, Bg: -1, Bold: true}, fmt.Sprintf("%d HELD", n))) { + t.Fatal("HELD not bold yellow") + } + } +} + +func TestPokerDrawShortcutLeavesDownAlone(t *testing.T) { + g, sess, conn := casinoCrapsGame(t, "name: Poker\ncasino_tables:\n - id: vp\n game: video_poker\n") + g.executeCommand(sess, "d", nil, "d") + if !strings.Contains(string(conn.data), "You can't go that way.") { + t.Fatalf("d did not route down outside poker: %s", conn.data) + } + g.executePlay(sess, []string{"vp"}, "play vp") + g.executeBet(sess, nil, "bet") + g.executeTableAction(sess, "deal") + conn.data = nil + g.executeCommand(sess, "d", nil, "d") + if len(sess.Player.PendingWagers) != 0 || strings.Contains(string(conn.data), "You can't go that way.") { + t.Fatal("seated d did not settle the hand") + } +} diff --git a/internal/game/video_poker_test.go b/internal/game/video_poker_test.go new file mode 100644 index 0000000..f2ea43f --- /dev/null +++ b/internal/game/video_poker_test.go @@ -0,0 +1,79 @@ +package game + +import ( + "strings" + "testing" + + "thehouseoficarus/internal/casino" +) + +func TestVideoPokerCommandsAndLook(t *testing.T) { + g, sess, conn := casinoCrapsGame(t, "name: Poker\ncasino_tables:\n - id: vp\n game: video_poker\n") + g.executePlay(sess, []string{"vp"}, "play vp") + conn.data = nil + g.executeRules(sess, nil, "rules") + if text := stripANSI(string(conn.data)); !strings.Contains(text, "Jacks or Better") { + t.Fatal(text) + } + g.executeBet(sess, []string{"max"}, "bet max") + g.executeTableAction(sess, "deal") + if classifyCommand("531") != ClassInstant { + t.Fatal("numeric holds rejected by dispatch") + } + g.executeCommand(sess, "531", nil, "531") + table, _ := g.Casino.Participation(sess.Player.Name) + snap := table.Status(sess.Player.Name)[0].Snapshot.(*casino.VideoPokerSnapshot) + if snap.Held != ([5]bool{true, false, true, false, true}) { + t.Fatalf("numeric holds: %+v", snap.Held) + } + conn.data = nil + room, _ := g.World.LoadRoom(1) + g.showCasinoGameLook(sess, sess.Player, room) + text := stripANSI(string(conn.data)) + for _, want := range []string{"1 HELD", "3 HELD", "5 HELD", "draw"} { + if !strings.Contains(text, want) { + t.Fatalf("look missing %q: %s", want, text) + } + } + after := table.Status(sess.Player.Name)[0].Snapshot.(*casino.VideoPokerSnapshot) + if after.Held != snap.Held { + t.Fatal("look altered holds") + } + conn.data = nil + g.executeList(sess, nil, "list") + if text := stripANSI(string(conn.data)); !strings.Contains(text, "No wagers are available") || strings.Contains(text, "COINS PER HAND") { + t.Fatal(text) + } + g.executeCommand(sess, "1", []string{"3", "5"}, "1 3 5") + if got := table.Status(sess.Player.Name)[0].Snapshot.(*casino.VideoPokerSnapshot).Held; got != ([5]bool{}) { + t.Fatalf("holds did not toggle off: %v", got) + } + g.executeCommand(sess, "d", nil, "d") + if table.Status(sess.Player.Name)[0].Snapshot.(*casino.VideoPokerSnapshot).Drawing || len(sess.Player.PendingWagers) != 0 { + t.Fatal("d did not draw") + } + g.executeCommand(sess, "game", []string{"deuces", "wild"}, "game deuces wild") + conn.data = nil + g.executeTableStatus(sess, []string{"rules"}, "table rules") + if text := stripANSI(string(conn.data)); !strings.Contains(text, "Deuces Wild / 25/15/9/5/3/2") || !strings.Contains(text, "4000") || !strings.Contains(text, "COINS PER HAND") { + t.Fatal(text) + } +} +func TestBlackjackListUsesRealActionCommands(t *testing.T) { + g, sess, conn := casinoCrapsGame(t, "name: BJ\ncasino_tables:\n - id: bj\n game: blackjack\n") + g.executePlay(sess, []string{"bj"}, "play bj") + // Deal until a hand has available action rows (a natural can settle immediately). + table, _ := g.Casino.Participation(sess.Player.Name) + for i := 0; i < 10 && len(table.Wagers(sess.Player.Name)) == 0; i++ { + g.executeBet(sess, []string{"1"}, "bet 1") + } + if len(table.Wagers(sess.Player.Name)) == 0 { + t.Fatal("no action rows") + } + conn.data = nil + g.executeList(sess, nil, "list") + text := stripANSI(string(conn.data)) + if strings.Contains(text, "bet <amount> on") || !strings.Contains(text, "Available actions") { + t.Fatal(text) + } +} |
