diff options
100 files changed, 881 insertions, 139 deletions
diff --git a/building_guide/roulette.md b/building_guide/roulette.md index 2d3fc53..e257b31 100644 --- a/building_guide/roulette.md +++ b/building_guide/roulette.md @@ -34,7 +34,9 @@ ANSI). Three views share one renderer: - **Betting board**: numbered grid with zeros in the left lane, red/black pockets, and outside bets. The whole five-row zero lane (three numbered rows plus the two separators between them) carries the green background, and its - outer corners are rounded in Unicode mode. + outer corners are rounded in Unicode mode. A racetrack table draws + `renderRouletteRacetrack` above the board: a rounded panel listing each call + bet's chips and wheel-order number series, plus the Neighbors syntax. - **Spinning**: a stylised 17x9 wheel (wider than tall so it reads as a circle in a terminal cell grid). Only the rim is colored, as solid red and black pockets alternating around the circumference; pockets are two cells wide @@ -65,6 +67,23 @@ because the geometry of the covered set identifies the bet. Non-adjacent or wrong-shaped sets are rejected rather than silently accepted. Multiple simultaneous wagers remain future work: `Participant.Wager` still holds a single stake. + +Single-zero tables may set `roulette.racetrack: true`. That adds the four +French call bets (Voisins du Zéro, Tiers du Cylindre, Orphelins, Zero Spiel) +and the Neighbors bet (a chosen number plus four on each side in wheel order). +The racetrack is a single-zero layout only: the validator rejects +`racetrack: true` together with `double_zero: true`. A call bet is **one +wager** whose amount is the total stake spread evenly over the bet's chips +(Voisins 9, Tiers 6, Orphelins 5, Zero Spiel 4, Neighbors 9). Because the +components are ordinary inside bets, each returns `36/covered` when its +number hits, so a winning pocket can pay a different multiple of the stake +than another (for example 0 on Voisins pays 24/9, while 4 pays 18/9). The +engine therefore resolves a wager to exact per-pocket `num/den` shares +(`roulette_racetrack.go`) instead of a single multiplier; `bet` validates +every possible return before committing and `settle` floors +`stake*num/den`. The rack panel is drawn by `renderRouletteRacetrack` above +the board and lists each bet's wheel-order series. + All returns are checked before debiting a wager. Both winning and losing stakes settle through `PayCredits(return, stakeID)`, including a zero return for losses. Failures leave the stake pending and do not claim that payment succeeded. diff --git a/data/help/roulette.yaml b/data/help/roulette.yaml index 5468995..5982fb3 100644 --- a/data/help/roulette.yaml +++ b/data/help/roulette.yaml @@ -1,8 +1,9 @@ name: roulette category: Interaction description: | - Play roulette in Domita's House of Icarus casino. + Play roulette in the Domita Fortuna casino. From the vestibule: north, up, north reaches single zero; + east from single zero reaches the racetrack; another north reaches double zero. play roulette Join the table @@ -34,7 +35,20 @@ description: | bet 10 on basket 0,00,1,2,3 on double zero (6:1); 0,1,2,3 on single zero (8:1) - One wager per player per spin; a combination is still one wager. + On single-zero tables with the racetrack option, the numbers below are + shown in wheel order for reference; the named bets are the wagers and + their chips are placed for you. The amount is the total stake, split + evenly across the bet's chips. + + bet 10 on voisins Voisins du Zero: 17 numbers around 0 (9 chips) + bet 10 on tiers Tiers du Cylindre: the far third (6 chips) + bet 10 on orphelins Orphelins: the two orphan arcs (5 chips) + bet 10 on zero spiel Zero Spiel: the numbers nearest 0 (4 chips) + bet 10 on neighbors 17 Number 17 plus four numbers each side of it + on the wheel (9 chips) + + One wager per player per spin; a combination or call bet is still one + wager. Zero and double zero lose all outside bets. Chips are spent first; winnings return as credits. Displayed returns include your stake; odds above are profit odds. diff --git a/data/mobs/vesta/casino_host.yaml b/data/mobs/vesta/casino_host.yaml index 8d3e4da..92a887d 100644 --- a/data/mobs/vesta/casino_host.yaml +++ b/data/mobs/vesta/casino_host.yaml @@ -9,7 +9,7 @@ talk: nodes: start: messages: - - Welcome to the House of Icarus. Every game has its own room and posted rules. The upper floors + - Welcome to Domita Fortuna. Every game has its own room and posted rules. The upper floors are open to visitors but their gaming halls are not operating yet. No membership or wager is needed to explore. options: diff --git a/data/objects/casino_test_portal.yaml b/data/objects/casino_test_portal.yaml index cf1b6cb..0132d32 100644 --- a/data/objects/casino_test_portal.yaml +++ b/data/objects/casino_test_portal.yaml @@ -2,7 +2,7 @@ name: casino test portal aliases: [casino, test portal] inroom_description: A casino test portal offers 1000 chips and a direct trip to Domita. description: - - text: 'TESTING ONLY: use casino to receive 1000 chips and teleport to the House of Icarus vestibule in Domita. Repeatable; remove this object before launch.' + - text: 'TESTING ONLY: use casino to receive 1000 chips and teleport to the Domita Fortuna vestibule in Domita. Repeatable; remove this object before launch.' on_use: - steps: - drop_table: diff --git a/data/rooms/casino/6100.yaml b/data/rooms/casino/6100.yaml index 63ee5bb..e822eda 100644 --- a/data/rooms/casino/6100.yaml +++ b/data/rooms/casino/6100.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Vestibule +name: Vestibule description: A ceiling of tiny brass suns hangs over polished stone. Icarus Leisure’s emblem repeats on every column. Stairs and galleries are open to all visitors. exits: @@ -12,6 +12,6 @@ objects: - name: wayboard description: 'Grand stair: ground gaming floor above the entrance podium. West: five blackjack salons, three baccarat salons. East: five slot rooms. Three upper galleries reserve fifteen future game halls. - Roulette salons: north, up, north for single zero; continue north for double zero. + Roulette salons: north, up, north for single zero; east for the racetrack; continue north for double zero. All floors are open without a wager.' inroom_description: A wayboard stands where travelers can read it. diff --git a/data/rooms/casino/6101.yaml b/data/rooms/casino/6101.yaml index cb6d6dc..7df2c07 100644 --- a/data/rooms/casino/6101.yaml +++ b/data/rooms/casino/6101.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Grand Atrium +name: Grand Atrium description: A ceiling of tiny brass suns hangs over polished stone. Icarus Leisure’s emblem repeats on every column. Stairs and galleries are open to all visitors. exits: diff --git a/data/rooms/casino/6102.yaml b/data/rooms/casino/6102.yaml index 264f3e8..26f80d4 100644 --- a/data/rooms/casino/6102.yaml +++ b/data/rooms/casino/6102.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Ground Gaming Floor Landing +name: Ground Gaming Floor Landing description: The entrance stair rises onto a broad podium, the casino’s ground gaming floor. A directory divides the floor into card salons west and machine galleries east. Higher stairs lead to future gaming floors. diff --git a/data/rooms/casino/6103.yaml b/data/rooms/casino/6103.yaml index 2a65f07..2632e65 100644 --- a/data/rooms/casino/6103.yaml +++ b/data/rooms/casino/6103.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — South Gaming Rotunda +name: South Gaming Rotunda description: Deep carpet quiets footsteps between individual gaming salons. Each doorway displays its own rules and wager limits. exits: diff --git a/data/rooms/casino/6104.yaml b/data/rooms/casino/6104.yaml index e356fb2..38ad8c5 100644 --- a/data/rooms/casino/6104.yaml +++ b/data/rooms/casino/6104.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Amber Concourse +name: Amber Concourse description: Deep carpet quiets footsteps between individual gaming salons. Each doorway displays its own rules and wager limits. exits: diff --git a/data/rooms/casino/6105.yaml b/data/rooms/casino/6105.yaml index 8f76703..0ebe609 100644 --- a/data/rooms/casino/6105.yaml +++ b/data/rooms/casino/6105.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Central Gaming Concourse +name: Central Gaming Concourse description: Deep carpet quiets footsteps between individual gaming salons. Each doorway displays its own rules and wager limits. exits: diff --git a/data/rooms/casino/6106.yaml b/data/rooms/casino/6106.yaml index bc4cd7a..2ba4faf 100644 --- a/data/rooms/casino/6106.yaml +++ b/data/rooms/casino/6106.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Marble Concourse +name: Marble Concourse description: Deep carpet quiets footsteps between individual gaming salons. Each doorway displays its own rules and wager limits. exits: diff --git a/data/rooms/casino/6107.yaml b/data/rooms/casino/6107.yaml index ed810a1..85fc600 100644 --- a/data/rooms/casino/6107.yaml +++ b/data/rooms/casino/6107.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Copper Blackjack Anteroom +name: Copper Blackjack Anteroom description: A quiet seating bay separates the concourse from Copper Blackjack. A brass plaque states the rules before anyone joins a game. exits: diff --git a/data/rooms/casino/6108.yaml b/data/rooms/casino/6108.yaml index 734308b..877e219 100644 --- a/data/rooms/casino/6108.yaml +++ b/data/rooms/casino/6108.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Copper Blackjack +name: Copper Blackjack description: An individual blackjack salon opens beyond the anteroom. Limits are 1–100 credits. No wager is required to watch or pass through. exits: diff --git a/data/rooms/casino/6109.yaml b/data/rooms/casino/6109.yaml index 6b026fe..4e2786c 100644 --- a/data/rooms/casino/6109.yaml +++ b/data/rooms/casino/6109.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Classic Blackjack Anteroom +name: Classic Blackjack Anteroom description: A quiet seating bay separates the concourse from Classic Blackjack. A brass plaque states the rules before anyone joins a game. exits: diff --git a/data/rooms/casino/6110.yaml b/data/rooms/casino/6110.yaml index 1ef79cd..5f77c60 100644 --- a/data/rooms/casino/6110.yaml +++ b/data/rooms/casino/6110.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Classic Blackjack +name: Classic Blackjack description: An individual blackjack salon opens beyond the anteroom. Limits are 10–1000 credits. No wager is required to watch or pass through. exits: diff --git a/data/rooms/casino/6111.yaml b/data/rooms/casino/6111.yaml index d1bb348..5f33706 100644 --- a/data/rooms/casino/6111.yaml +++ b/data/rooms/casino/6111.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Mercy Blackjack Anteroom +name: Mercy Blackjack Anteroom description: A quiet seating bay separates the concourse from Mercy Blackjack. A brass plaque states the rules before anyone joins a game. exits: diff --git a/data/rooms/casino/6112.yaml b/data/rooms/casino/6112.yaml index a3ce9e5..a77775d 100644 --- a/data/rooms/casino/6112.yaml +++ b/data/rooms/casino/6112.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Mercy Blackjack +name: Mercy Blackjack description: An individual blackjack salon opens beyond the anteroom. Limits are 2–500 credits. No wager is required to watch or pass through. exits: diff --git a/data/rooms/casino/6113.yaml b/data/rooms/casino/6113.yaml index 667ac21..e1fb79d 100644 --- a/data/rooms/casino/6113.yaml +++ b/data/rooms/casino/6113.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Single Deck Salon Anteroom +name: Single Deck Salon Anteroom description: A quiet seating bay separates the concourse from Single Deck Salon. A brass plaque states the rules before anyone joins a game. exits: diff --git a/data/rooms/casino/6114.yaml b/data/rooms/casino/6114.yaml index ee6ca12..0689249 100644 --- a/data/rooms/casino/6114.yaml +++ b/data/rooms/casino/6114.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Single Deck Salon +name: Single Deck Salon description: An individual blackjack salon opens beyond the anteroom. Limits are 25–2500 credits. No wager is required to watch or pass through. exits: diff --git a/data/rooms/casino/6115.yaml b/data/rooms/casino/6115.yaml index 2d834d7..b58fde3 100644 --- a/data/rooms/casino/6115.yaml +++ b/data/rooms/casino/6115.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Midnight Blackjack Anteroom +name: Midnight Blackjack Anteroom description: A quiet seating bay separates the concourse from Midnight Blackjack. A brass plaque states the rules before anyone joins a game. exits: diff --git a/data/rooms/casino/6116.yaml b/data/rooms/casino/6116.yaml index 23f7010..044b106 100644 --- a/data/rooms/casino/6116.yaml +++ b/data/rooms/casino/6116.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Midnight Blackjack +name: Midnight Blackjack description: An individual blackjack salon opens beyond the anteroom. Limits are 100–10000 credits. No wager is required to watch or pass through. exits: diff --git a/data/rooms/casino/6117.yaml b/data/rooms/casino/6117.yaml index d9eb7ad..ac51ab5 100644 --- a/data/rooms/casino/6117.yaml +++ b/data/rooms/casino/6117.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Penny Reels Anteroom +name: Penny Reels Anteroom description: A quiet seating bay separates the concourse from Penny Reels. A brass plaque states the rules before anyone joins a game. exits: diff --git a/data/rooms/casino/6118.yaml b/data/rooms/casino/6118.yaml index bf89d59..98e6d9d 100644 --- a/data/rooms/casino/6118.yaml +++ b/data/rooms/casino/6118.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Penny Reels +name: Penny Reels description: An individual slots salon opens beyond the anteroom. Limits are 1–25 credits. No wager is required to watch or pass through. exits: diff --git a/data/rooms/casino/6119.yaml b/data/rooms/casino/6119.yaml index b943467..cdf73e8 100644 --- a/data/rooms/casino/6119.yaml +++ b/data/rooms/casino/6119.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Silver Reels Anteroom +name: Silver Reels Anteroom description: A quiet seating bay separates the concourse from Silver Reels. A brass plaque states the rules before anyone joins a game. exits: diff --git a/data/rooms/casino/6120.yaml b/data/rooms/casino/6120.yaml index 7f0316b..63b12d3 100644 --- a/data/rooms/casino/6120.yaml +++ b/data/rooms/casino/6120.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Silver Reels +name: Silver Reels description: An individual slots salon opens beyond the anteroom. Limits are 10–500 credits. No wager is required to watch or pass through. exits: diff --git a/data/rooms/casino/6121.yaml b/data/rooms/casino/6121.yaml index 94d8101..511ceb8 100644 --- a/data/rooms/casino/6121.yaml +++ b/data/rooms/casino/6121.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Timber Reels Anteroom +name: Timber Reels Anteroom description: A quiet seating bay separates the concourse from Timber Reels. A brass plaque states the rules before anyone joins a game. exits: diff --git a/data/rooms/casino/6122.yaml b/data/rooms/casino/6122.yaml index c0ae041..486256b 100644 --- a/data/rooms/casino/6122.yaml +++ b/data/rooms/casino/6122.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Timber Reels +name: Timber Reels description: An individual slots salon opens beyond the anteroom. Limits are 1–100 credits. No wager is required to watch or pass through. exits: diff --git a/data/rooms/casino/6123.yaml b/data/rooms/casino/6123.yaml index 6e892e7..dc3bd2c 100644 --- a/data/rooms/casino/6123.yaml +++ b/data/rooms/casino/6123.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Golden Timber Reels Anteroom +name: Golden Timber Reels Anteroom description: A quiet seating bay separates the concourse from Golden Timber Reels. A brass plaque states the rules before anyone joins a game. exits: diff --git a/data/rooms/casino/6124.yaml b/data/rooms/casino/6124.yaml index 5edbd43..ac0319d 100644 --- a/data/rooms/casino/6124.yaml +++ b/data/rooms/casino/6124.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Golden Timber Reels +name: Golden Timber Reels description: An individual slots salon opens beyond the anteroom. Limits are 25–2500 credits. No wager is required to watch or pass through. exits: diff --git a/data/rooms/casino/6125.yaml b/data/rooms/casino/6125.yaml index 3dc7cce..6641421 100644 --- a/data/rooms/casino/6125.yaml +++ b/data/rooms/casino/6125.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Platinum Reels Anteroom +name: Platinum Reels Anteroom description: A quiet seating bay separates the concourse from Platinum Reels. A brass plaque states the rules before anyone joins a game. exits: diff --git a/data/rooms/casino/6126.yaml b/data/rooms/casino/6126.yaml index 9c6ee89..272b5ff 100644 --- a/data/rooms/casino/6126.yaml +++ b/data/rooms/casino/6126.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Platinum Reels +name: Platinum Reels description: An individual slots salon opens beyond the anteroom. Limits are 100–10000 credits. No wager is required to watch or pass through. exits: diff --git a/data/rooms/casino/6127.yaml b/data/rooms/casino/6127.yaml index b3f24cc..b0390b5 100644 --- a/data/rooms/casino/6127.yaml +++ b/data/rooms/casino/6127.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Harbor Baccarat +name: Harbor Baccarat description: Green baize and shaded lamps distinguish this quiet baccarat salon from the neighboring blackjack room. A traditional commission baccarat table offers pair bets; look at the table for its rules. exits: diff --git a/data/rooms/casino/6128.yaml b/data/rooms/casino/6128.yaml index b1d0edf..35226a2 100644 --- a/data/rooms/casino/6128.yaml +++ b/data/rooms/casino/6128.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Charter Baccarat +name: Charter Baccarat description: Green baize and shaded lamps distinguish this quiet baccarat salon from the neighboring blackjack room. A Super 6 no-commission baccarat table waits here; look at the table for its rules. exits: diff --git a/data/rooms/casino/6129.yaml b/data/rooms/casino/6129.yaml index a491f4f..50c40e4 100644 --- a/data/rooms/casino/6129.yaml +++ b/data/rooms/casino/6129.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Crown Baccarat +name: Crown Baccarat description: Green baize and shaded lamps distinguish this quiet baccarat salon from the neighboring blackjack room. An EZ Baccarat no-commission table offers Dragon 7 and Panda 8; look at the table for its rules. exits: diff --git a/data/rooms/casino/6130.yaml b/data/rooms/casino/6130.yaml index cdb960f..2a3008f 100644 --- a/data/rooms/casino/6130.yaml +++ b/data/rooms/casino/6130.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Cashier Gallery +name: Cashier Gallery description: A long brass grille protects the counting desk; the public bank booth remains outside it. exits: east: diff --git a/data/rooms/casino/6131.yaml b/data/rooms/casino/6131.yaml index 0f48d2f..645c9fb 100644 --- a/data/rooms/casino/6131.yaml +++ b/data/rooms/casino/6131.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Responsible Play Lounge +name: Responsible Play Lounge description: Quiet chairs face a clock with unusually large numerals. A sign reminds visitors that the next hand does not owe them a win. exits: diff --git a/data/rooms/casino/6132.yaml b/data/rooms/casino/6132.yaml index 2c2febb..34f8756 100644 --- a/data/rooms/casino/6132.yaml +++ b/data/rooms/casino/6132.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — South Buffet +name: South Buffet description: A public range lets travelers cook their own food alongside the dining tables. exits: west: diff --git a/data/rooms/casino/6133.yaml b/data/rooms/casino/6133.yaml index b025e0e..9e00c74 100644 --- a/data/rooms/casino/6133.yaml +++ b/data/rooms/casino/6133.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Cloakroom +name: Cloakroom description: Numbered hooks and clean benches serve visitors who only came to see the building. exits: west: diff --git a/data/rooms/casino/6134.yaml b/data/rooms/casino/6134.yaml index 9c24441..6bd9f53 100644 --- a/data/rooms/casino/6134.yaml +++ b/data/rooms/casino/6134.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Guest Services +name: Guest Services description: A charger sits beneath a directory of every floor. exits: west: diff --git a/data/rooms/casino/6135.yaml b/data/rooms/casino/6135.yaml index bee87bb..d1e9745 100644 --- a/data/rooms/casino/6135.yaml +++ b/data/rooms/casino/6135.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Musicians’ Alcove +name: Musicians’ Alcove description: A mechanical piano waits between performances. exits: west: diff --git a/data/rooms/casino/6136.yaml b/data/rooms/casino/6136.yaml index d7e8de8..ea7e05b 100644 --- a/data/rooms/casino/6136.yaml +++ b/data/rooms/casino/6136.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Observation Lounge +name: Observation Lounge description: Tall windows show Domita’s slate roofs far below the illuminated signs. exits: west: diff --git a/data/rooms/casino/6137.yaml b/data/rooms/casino/6137.yaml index b9db44f..e8809f7 100644 --- a/data/rooms/casino/6137.yaml +++ b/data/rooms/casino/6137.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — First Gallery South Rotunda +name: First Gallery South Rotunda description: The first gallery is freely open for exploration. Completed corridors face unfurnished gaming rooms; signs explicitly say that no games operate on this floor yet. exits: diff --git a/data/rooms/casino/6138.yaml b/data/rooms/casino/6138.yaml index a2ab602..2ccb2a8 100644 --- a/data/rooms/casino/6138.yaml +++ b/data/rooms/casino/6138.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — First Gallery Future Tournament Hall +name: First Gallery Future Tournament Hall description: Power sockets, table anchor points and service passages are installed, but there is no playable game here yet. This hall is reserved for a future casino expansion. exits: diff --git a/data/rooms/casino/6139.yaml b/data/rooms/casino/6139.yaml index 693249a..fb45596 100644 --- a/data/rooms/casino/6139.yaml +++ b/data/rooms/casino/6139.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — First Gallery Balcony Lounge +name: First Gallery Balcony Lounge description: A sheltered balcony overlooks the city without requiring a ticket. exits: west: diff --git a/data/rooms/casino/6140.yaml b/data/rooms/casino/6140.yaml index 63d0598..04bf1af 100644 --- a/data/rooms/casino/6140.yaml +++ b/data/rooms/casino/6140.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — First Gallery Promenade +name: First Gallery Promenade description: The first gallery is freely open for exploration. Completed corridors face unfurnished gaming rooms; signs explicitly say that no games operate on this floor yet. exits: diff --git a/data/rooms/casino/6141.yaml b/data/rooms/casino/6141.yaml index da13e24..7195e2c 100644 --- a/data/rooms/casino/6141.yaml +++ b/data/rooms/casino/6141.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — First Gallery Future Card Salon +name: First Gallery Future Card Salon description: Power sockets, table anchor points and service passages are installed, but there is no playable game here yet. This hall is reserved for a future casino expansion. exits: diff --git a/data/rooms/casino/6142.yaml b/data/rooms/casino/6142.yaml index af9b116..f49b1b5 100644 --- a/data/rooms/casino/6142.yaml +++ b/data/rooms/casino/6142.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — First Gallery Refreshment Room +name: First Gallery Refreshment Room description: Empty serving counters stand ready for the next expansion. exits: west: diff --git a/data/rooms/casino/6143.yaml b/data/rooms/casino/6143.yaml index 1431cd5..37f3a73 100644 --- a/data/rooms/casino/6143.yaml +++ b/data/rooms/casino/6143.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — First Gallery Central Landing +name: First Gallery Central Landing description: The first gallery is freely open for exploration. Completed corridors face unfurnished gaming rooms; signs explicitly say that no games operate on this floor yet. exits: diff --git a/data/rooms/casino/6144.yaml b/data/rooms/casino/6144.yaml index a918b34..cc30669 100644 --- a/data/rooms/casino/6144.yaml +++ b/data/rooms/casino/6144.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — First Gallery Future Wheel Gallery +name: First Gallery Future Wheel Gallery description: Power sockets, table anchor points and service passages are installed, but there is no playable game here yet. This hall is reserved for a future casino expansion. exits: diff --git a/data/rooms/casino/6145.yaml b/data/rooms/casino/6145.yaml index 4376b53..60f1e0f 100644 --- a/data/rooms/casino/6145.yaml +++ b/data/rooms/casino/6145.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — First Gallery Exhibition Room +name: First Gallery Exhibition Room description: Building models show the corporation’s increasingly extravagant plans. exits: west: diff --git a/data/rooms/casino/6146.yaml b/data/rooms/casino/6146.yaml index 0012476..04a9521 100644 --- a/data/rooms/casino/6146.yaml +++ b/data/rooms/casino/6146.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — First Gallery North Promenade +name: First Gallery North Promenade description: The first gallery is freely open for exploration. Completed corridors face unfurnished gaming rooms; signs explicitly say that no games operate on this floor yet. exits: diff --git a/data/rooms/casino/6147.yaml b/data/rooms/casino/6147.yaml index 83d277c..2b85741 100644 --- a/data/rooms/casino/6147.yaml +++ b/data/rooms/casino/6147.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — First Gallery Future Dice Salon +name: First Gallery Future Dice Salon description: Power sockets, table anchor points and service passages are installed, but there is no playable game here yet. This hall is reserved for a future casino expansion. exits: diff --git a/data/rooms/casino/6148.yaml b/data/rooms/casino/6148.yaml index 8c37c12..c9982b5 100644 --- a/data/rooms/casino/6148.yaml +++ b/data/rooms/casino/6148.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — First Gallery Staff History Gallery +name: First Gallery Staff History Gallery description: Photographs remember workers whose names do not appear on the marble dedication. exits: west: diff --git a/data/rooms/casino/6149.yaml b/data/rooms/casino/6149.yaml index bf959ef..e40a521 100644 --- a/data/rooms/casino/6149.yaml +++ b/data/rooms/casino/6149.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — First Gallery Grand Stair +name: First Gallery Grand Stair description: The first gallery is freely open for exploration. Completed corridors face unfurnished gaming rooms; signs explicitly say that no games operate on this floor yet. exits: diff --git a/data/rooms/casino/6150.yaml b/data/rooms/casino/6150.yaml index 015b219..8e627fd 100644 --- a/data/rooms/casino/6150.yaml +++ b/data/rooms/casino/6150.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — First Gallery Future Private Table Hall +name: First Gallery Future Private Table Hall description: Power sockets, table anchor points and service passages are installed, but there is no playable game here yet. This hall is reserved for a future casino expansion. exits: diff --git a/data/rooms/casino/6151.yaml b/data/rooms/casino/6151.yaml index 6eac0cd..f0d2e9f 100644 --- a/data/rooms/casino/6151.yaml +++ b/data/rooms/casino/6151.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — First Gallery Viewing Terrace +name: First Gallery Viewing Terrace description: A long window gives a clear view of the roads that belong to everyone. exits: west: diff --git a/data/rooms/casino/6152.yaml b/data/rooms/casino/6152.yaml index 62b3ecd..9433ce8 100644 --- a/data/rooms/casino/6152.yaml +++ b/data/rooms/casino/6152.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Second Gallery South Rotunda +name: Second Gallery South Rotunda description: The second gallery is freely open for exploration. Completed corridors face unfurnished gaming rooms; signs explicitly say that no games operate on this floor yet. exits: diff --git a/data/rooms/casino/6153.yaml b/data/rooms/casino/6153.yaml index 4c79b6d..34b51b4 100644 --- a/data/rooms/casino/6153.yaml +++ b/data/rooms/casino/6153.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Second Gallery Future Tournament Hall +name: Second Gallery Future Tournament Hall description: Power sockets, table anchor points and service passages are installed, but there is no playable game here yet. This hall is reserved for a future casino expansion. exits: diff --git a/data/rooms/casino/6154.yaml b/data/rooms/casino/6154.yaml index ea908ea..a05fd8a 100644 --- a/data/rooms/casino/6154.yaml +++ b/data/rooms/casino/6154.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Second Gallery Balcony Lounge +name: Second Gallery Balcony Lounge description: A sheltered balcony overlooks the city without requiring a ticket. exits: west: diff --git a/data/rooms/casino/6155.yaml b/data/rooms/casino/6155.yaml index 383209a..b0ddb4d 100644 --- a/data/rooms/casino/6155.yaml +++ b/data/rooms/casino/6155.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Second Gallery Promenade +name: Second Gallery Promenade description: The second gallery is freely open for exploration. Completed corridors face unfurnished gaming rooms; signs explicitly say that no games operate on this floor yet. exits: diff --git a/data/rooms/casino/6156.yaml b/data/rooms/casino/6156.yaml index ae8bd00..a879c4b 100644 --- a/data/rooms/casino/6156.yaml +++ b/data/rooms/casino/6156.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Second Gallery Future Card Salon +name: Second Gallery Future Card Salon description: Power sockets, table anchor points and service passages are installed, but there is no playable game here yet. This hall is reserved for a future casino expansion. exits: diff --git a/data/rooms/casino/6157.yaml b/data/rooms/casino/6157.yaml index c3aca67..83add4d 100644 --- a/data/rooms/casino/6157.yaml +++ b/data/rooms/casino/6157.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Second Gallery Refreshment Room +name: Second Gallery Refreshment Room description: Empty serving counters stand ready for the next expansion. exits: west: diff --git a/data/rooms/casino/6158.yaml b/data/rooms/casino/6158.yaml index f02f93b..fb598e4 100644 --- a/data/rooms/casino/6158.yaml +++ b/data/rooms/casino/6158.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Second Gallery Central Landing +name: Second Gallery Central Landing description: The second gallery is freely open for exploration. Completed corridors face unfurnished gaming rooms; signs explicitly say that no games operate on this floor yet. exits: diff --git a/data/rooms/casino/6159.yaml b/data/rooms/casino/6159.yaml index ea7309f..fff90ea 100644 --- a/data/rooms/casino/6159.yaml +++ b/data/rooms/casino/6159.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Second Gallery Future Wheel Gallery +name: Second Gallery Future Wheel Gallery description: Power sockets, table anchor points and service passages are installed, but there is no playable game here yet. This hall is reserved for a future casino expansion. exits: diff --git a/data/rooms/casino/6160.yaml b/data/rooms/casino/6160.yaml index 063211a..4c71e15 100644 --- a/data/rooms/casino/6160.yaml +++ b/data/rooms/casino/6160.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Second Gallery Exhibition Room +name: Second Gallery Exhibition Room description: Building models show the corporation’s increasingly extravagant plans. exits: west: diff --git a/data/rooms/casino/6161.yaml b/data/rooms/casino/6161.yaml index 38fa612..750c8ea 100644 --- a/data/rooms/casino/6161.yaml +++ b/data/rooms/casino/6161.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Second Gallery North Promenade +name: Second Gallery North Promenade description: The second gallery is freely open for exploration. Completed corridors face unfurnished gaming rooms; signs explicitly say that no games operate on this floor yet. exits: diff --git a/data/rooms/casino/6162.yaml b/data/rooms/casino/6162.yaml index 073ec10..c083708 100644 --- a/data/rooms/casino/6162.yaml +++ b/data/rooms/casino/6162.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Second Gallery Future Dice Salon +name: Second Gallery Future Dice Salon description: Power sockets, table anchor points and service passages are installed, but there is no playable game here yet. This hall is reserved for a future casino expansion. exits: diff --git a/data/rooms/casino/6163.yaml b/data/rooms/casino/6163.yaml index 9ca359e..58983a0 100644 --- a/data/rooms/casino/6163.yaml +++ b/data/rooms/casino/6163.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Second Gallery Staff History Gallery +name: Second Gallery Staff History Gallery description: Photographs remember workers whose names do not appear on the marble dedication. exits: west: diff --git a/data/rooms/casino/6164.yaml b/data/rooms/casino/6164.yaml index 126b647..2cf8ee1 100644 --- a/data/rooms/casino/6164.yaml +++ b/data/rooms/casino/6164.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Second Gallery Grand Stair +name: Second Gallery Grand Stair description: The second gallery is freely open for exploration. Completed corridors face unfurnished gaming rooms; signs explicitly say that no games operate on this floor yet. exits: diff --git a/data/rooms/casino/6165.yaml b/data/rooms/casino/6165.yaml index 72dd191..f588711 100644 --- a/data/rooms/casino/6165.yaml +++ b/data/rooms/casino/6165.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Second Gallery Future Private Table Hall +name: Second Gallery Future Private Table Hall description: Power sockets, table anchor points and service passages are installed, but there is no playable game here yet. This hall is reserved for a future casino expansion. exits: diff --git a/data/rooms/casino/6166.yaml b/data/rooms/casino/6166.yaml index e14493e..ce4c421 100644 --- a/data/rooms/casino/6166.yaml +++ b/data/rooms/casino/6166.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Second Gallery Viewing Terrace +name: Second Gallery Viewing Terrace description: A long window gives a clear view of the roads that belong to everyone. exits: west: diff --git a/data/rooms/casino/6167.yaml b/data/rooms/casino/6167.yaml index d6b3470..35dc3aa 100644 --- a/data/rooms/casino/6167.yaml +++ b/data/rooms/casino/6167.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Crown Floor South Rotunda +name: Crown Floor South Rotunda description: The crown floor is freely open for exploration. Completed corridors face unfurnished gaming rooms; signs explicitly say that no games operate on this floor yet. exits: diff --git a/data/rooms/casino/6168.yaml b/data/rooms/casino/6168.yaml index ba15c4e..5b5822e 100644 --- a/data/rooms/casino/6168.yaml +++ b/data/rooms/casino/6168.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Crown Floor Future Tournament Hall +name: Crown Floor Future Tournament Hall description: Power sockets, table anchor points and service passages are installed, but there is no playable game here yet. This hall is reserved for a future casino expansion. exits: diff --git a/data/rooms/casino/6169.yaml b/data/rooms/casino/6169.yaml index fe90e29..ae54bca 100644 --- a/data/rooms/casino/6169.yaml +++ b/data/rooms/casino/6169.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Crown Floor Balcony Lounge +name: Crown Floor Balcony Lounge description: A sheltered balcony overlooks the city without requiring a ticket. exits: west: diff --git a/data/rooms/casino/6170.yaml b/data/rooms/casino/6170.yaml index 4bd05af..d275920 100644 --- a/data/rooms/casino/6170.yaml +++ b/data/rooms/casino/6170.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Crown Floor Promenade +name: Crown Floor Promenade description: The crown floor is freely open for exploration. Completed corridors face unfurnished gaming rooms; signs explicitly say that no games operate on this floor yet. exits: diff --git a/data/rooms/casino/6171.yaml b/data/rooms/casino/6171.yaml index 0acac0f..41fbe05 100644 --- a/data/rooms/casino/6171.yaml +++ b/data/rooms/casino/6171.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Crown Floor Future Card Salon +name: Crown Floor Future Card Salon description: Power sockets, table anchor points and service passages are installed, but there is no playable game here yet. This hall is reserved for a future casino expansion. exits: diff --git a/data/rooms/casino/6172.yaml b/data/rooms/casino/6172.yaml index 595cf6b..44e9161 100644 --- a/data/rooms/casino/6172.yaml +++ b/data/rooms/casino/6172.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Crown Floor Refreshment Room +name: Crown Floor Refreshment Room description: Empty serving counters stand ready for the next expansion. exits: west: diff --git a/data/rooms/casino/6173.yaml b/data/rooms/casino/6173.yaml index 227a32e..1fd3e12 100644 --- a/data/rooms/casino/6173.yaml +++ b/data/rooms/casino/6173.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Crown Floor Central Landing +name: Crown Floor Central Landing description: The crown floor is freely open for exploration. Completed corridors face unfurnished gaming rooms; signs explicitly say that no games operate on this floor yet. exits: diff --git a/data/rooms/casino/6174.yaml b/data/rooms/casino/6174.yaml index ca27a14..dd31f28 100644 --- a/data/rooms/casino/6174.yaml +++ b/data/rooms/casino/6174.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Crown Floor Future Wheel Gallery +name: Crown Floor Future Wheel Gallery description: Power sockets, table anchor points and service passages are installed, but there is no playable game here yet. This hall is reserved for a future casino expansion. exits: diff --git a/data/rooms/casino/6175.yaml b/data/rooms/casino/6175.yaml index 5e2707e..ec79025 100644 --- a/data/rooms/casino/6175.yaml +++ b/data/rooms/casino/6175.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Crown Floor Exhibition Room +name: Crown Floor Exhibition Room description: Building models show the corporation’s increasingly extravagant plans. exits: west: diff --git a/data/rooms/casino/6176.yaml b/data/rooms/casino/6176.yaml index 5318ed6..d1a0ba6 100644 --- a/data/rooms/casino/6176.yaml +++ b/data/rooms/casino/6176.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Crown Floor North Promenade +name: Crown Floor North Promenade description: The crown floor is freely open for exploration. Completed corridors face unfurnished gaming rooms; signs explicitly say that no games operate on this floor yet. exits: diff --git a/data/rooms/casino/6177.yaml b/data/rooms/casino/6177.yaml index b950ff5..be5d0e9 100644 --- a/data/rooms/casino/6177.yaml +++ b/data/rooms/casino/6177.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Crown Floor Future Dice Salon +name: Crown Floor Future Dice Salon description: Power sockets, table anchor points and service passages are installed, but there is no playable game here yet. This hall is reserved for a future casino expansion. exits: diff --git a/data/rooms/casino/6178.yaml b/data/rooms/casino/6178.yaml index c99c4f1..4929fec 100644 --- a/data/rooms/casino/6178.yaml +++ b/data/rooms/casino/6178.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Crown Floor Staff History Gallery +name: Crown Floor Staff History Gallery description: Photographs remember workers whose names do not appear on the marble dedication. exits: west: diff --git a/data/rooms/casino/6179.yaml b/data/rooms/casino/6179.yaml index ab86719..7a9e652 100644 --- a/data/rooms/casino/6179.yaml +++ b/data/rooms/casino/6179.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Crown Floor Grand Stair +name: Crown Floor Grand Stair description: The crown floor is freely open for exploration. Completed corridors face unfurnished gaming rooms; signs explicitly say that no games operate on this floor yet. exits: diff --git a/data/rooms/casino/6180.yaml b/data/rooms/casino/6180.yaml index 9d67c12..2a83860 100644 --- a/data/rooms/casino/6180.yaml +++ b/data/rooms/casino/6180.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Crown Floor Future Private Table Hall +name: Crown Floor Future Private Table Hall description: Power sockets, table anchor points and service passages are installed, but there is no playable game here yet. This hall is reserved for a future casino expansion. exits: diff --git a/data/rooms/casino/6181.yaml b/data/rooms/casino/6181.yaml index ff1cf79..78927b6 100644 --- a/data/rooms/casino/6181.yaml +++ b/data/rooms/casino/6181.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Crown Floor Viewing Terrace +name: Crown Floor Viewing Terrace description: A long window gives a clear view of the roads that belong to everyone. exits: west: diff --git a/data/rooms/casino/6182.yaml b/data/rooms/casino/6182.yaml index c6bdcf5..398d8f2 100644 --- a/data/rooms/casino/6182.yaml +++ b/data/rooms/casino/6182.yaml @@ -1,10 +1,12 @@ -name: House of Icarus — Single Zero Roulette Salon +name: Single Zero Roulette Salon description: A polished European wheel turns beside green felt marked with a single zero. The dealer invites visitors to play roulette. exits: south: room: 6102 north: room: 6183 + east: + room: 6184 casino_tables: - id: roulette_single game: roulette diff --git a/data/rooms/casino/6183.yaml b/data/rooms/casino/6183.yaml index 0b00f36..68f12bc 100644 --- a/data/rooms/casino/6183.yaml +++ b/data/rooms/casino/6183.yaml @@ -1,4 +1,4 @@ -name: House of Icarus — Double Zero Roulette Salon +name: Double Zero Roulette Salon description: An American roulette wheel gleams under brass lamps. Its green zero and double-zero pockets face a neatly marked betting layout. exits: south: diff --git a/data/rooms/casino/6184.yaml b/data/rooms/casino/6184.yaml new file mode 100644 index 0000000..76c9cf1 --- /dev/null +++ b/data/rooms/casino/6184.yaml @@ -0,0 +1,15 @@ +name: Racetrack Roulette Salon +description: A single-zero wheel turns above a long oval racetrack painted on green felt. Brass plaques name the French call bets for players who prefer the wheel's own neighbours. +exits: + west: + room: 6182 +casino_tables: + - id: roulette_racetrack + game: roulette + betting_ticks: 15 + min_bet: 1 + max_bet: 10000 + roulette: + double_zero: false + racetrack: true + spin_ticks: 4 diff --git a/data/rooms/domita/5104.yaml b/data/rooms/domita/5104.yaml index feb9a94..1f06b7f 100644 --- a/data/rooms/domita/5104.yaml +++ b/data/rooms/domita/5104.yaml @@ -14,6 +14,6 @@ objects: - id: aps_tower - name: wayboard description: 'Domita: forest and Numisia southeast by the east gate. Quarry west; safe survey road beyond - the east gardens. House of Icarus north. The corporation owns the casino, not your right to travel. + the east gardens. Domita Fortuna north. The corporation owns the casino, not your right to travel. This is still Vesta’s settled starting district; the roads beyond await future expansion.' inroom_description: A wayboard stands where travelers can read it. diff --git a/internal/casino/registry.go b/internal/casino/registry.go index f27f7bd..7b5de83 100644 --- a/internal/casino/registry.go +++ b/internal/casino/registry.go @@ -39,6 +39,9 @@ func init() { if c.Roulette.SpinTicks < 0 || c.Roulette.SpinTicks > maxSpinTicks { return fmt.Errorf("roulette spin_ticks must be between 0 and %d", maxSpinTicks) } + if c.Roulette.Racetrack && c.Roulette.DoubleZero { + return fmt.Errorf("roulette racetrack is only available on single-zero tables; remove racetrack or set double_zero: false") + } return nil }, normalize: func(c TableConfig) TableConfig { diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go index cae9d0d..252ac4c 100644 --- a/internal/casino/roulette.go +++ b/internal/casino/roulette.go @@ -8,8 +8,11 @@ import ( ) // RouletteConfig selects European (single zero) or American (double zero) rules. +// Racetrack adds the French call bets and neighbours bets; it is a single-zero +// layout only. type RouletteConfig struct { DoubleZero bool `yaml:"double_zero,omitempty"` + Racetrack bool `yaml:"racetrack,omitempty"` SpinTicks int `yaml:"spin_ticks,omitempty"` } @@ -501,7 +504,7 @@ func newRouletteTable(room int, cfg TableConfig, rng *rand.Rand) *rouletteTable return &rouletteTable{tableCore: newTableCore[*roulettePlayer](&tableBase{RoomID: room, Config: cfg}), rng: rng} } func (t *rouletteTable) view() *RouletteSnapshot { - s := &RouletteSnapshot{DoubleZero: t.Config.Roulette.DoubleZero, Phase: string(t.phase), Wheel: rouletteWheel(t.Config.Roulette.DoubleZero)} + s := &RouletteSnapshot{DoubleZero: t.Config.Roulette.DoubleZero, Racetrack: t.Config.Roulette.Racetrack, Phase: string(t.phase), Wheel: rouletteWheel(t.Config.Roulette.DoubleZero)} for _, p := range t.players.ordered() { s.Players = append(s.Players, p.Name) } @@ -559,15 +562,16 @@ func (t *rouletteTable) bet(name, spot string, amount int, w Wallet) []Event { spot = p.lastSpot } spot = normalizeRouletteSpot(spot) - _, mult := rouletteBet(spot, t.Config.Roulette.DoubleZero) - if mult == 0 { + resolved, ok := rouletteWagerFor(spot, t.Config.Roulette.DoubleZero, t.Config.Roulette.Racetrack) + if !ok { return []Event{t.private(name, "Unknown roulette target. Use table to see all available bets.")} } if amount < t.Config.MinBet || amount > t.Config.MaxBet { return []Event{t.private(name, "Roulette bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")} } - // Check total returns before committing even an unlimited wager. - if _, err := MulPayout(amount, float64(mult)); err != nil { + // Check every possible winning return before committing even an unlimited + // wager: a call bet's components pay different multiples. + if err := resolved.validate(amount); err != nil { return []Event{t.private(name, "That wager's potential payout is too large.")} } wager, err := w.Wager(amount) @@ -633,15 +637,14 @@ func (t *rouletteTable) settle() []Event { if !p.HasBet { continue } - covered, mult := rouletteBet(p.lastSpot, t.Config.Roulette.DoubleZero) + wager, resolved := rouletteWagerFor(p.lastSpot, t.Config.Roulette.DoubleZero, t.Config.Roulette.Racetrack) payout := 0 result := "loss" var err error - for _, n := range covered { - if n == pocket { + if resolved { + if share, ok := wager.covers(pocket); ok { result = "win" - payout, err = MulPayout(p.Wager.Total, float64(mult)) - break + payout, err = share.payout(p.Wager.Total) } } // PayCredits supports a zero return and atomically consumes a losing @@ -746,9 +749,12 @@ func (t *rouletteTable) refundAll() []Event { // colors, Unicode preferences, or card-art settings. type RouletteSnapshot struct { DoubleZero bool - Phase string - Pockets [3]string // physical wheel neighbors, not numerical neighbors - Players []string + // Racetrack reports that the table also offers the French call bets and + // neighbours bets, so the renderer draws the racetrack panel. + Racetrack bool + Phase string + Pockets [3]string // physical wheel neighbors, not numerical neighbors + Players []string // Wheel is the pocket order clockwise from the top; the renderer colors // and places each pocket. It carries the zero count (one or two) so the // drawn wheel always matches the configured table. diff --git a/internal/casino/roulette_racetrack.go b/internal/casino/roulette_racetrack.go new file mode 100644 index 0000000..624940b --- /dev/null +++ b/internal/casino/roulette_racetrack.go @@ -0,0 +1,297 @@ +package casino + +import ( + "fmt" + "strconv" + "strings" +) + +// rouletteShare is a total-return fraction: a winning pocket pays +// stake*num/den credits, floor-rounded like a house paying in chips. +// Ordinary bets have den 1. Call bets split the stake across several chips, +// so their shares carry a denominator; a pocket covered by more than one +// component (for example 17 in Orphelins) sums those component returns into +// one share. +type rouletteShare struct { + num int + den int +} + +// payout returns floor(stake*num/den) and refuses a wager whose return would +// exceed the economy's credit cap, without overflowing stake*num. +func (s rouletteShare) payout(stake int) (int, error) { + if stake <= 0 || s.num <= 0 || s.den <= 0 { + return 0, nil + } + if limit := MaxCredits * s.den / s.num; stake > limit { + return MaxCredits, fmt.Errorf("payout overflow: %d * %d/%d exceeds %d", stake, s.num, s.den, MaxCredits) + } + return stake * s.num / s.den, nil +} + +// rouletteWager is a resolved wager: every covered pocket and its total-return +// share. A wager can cover one pocket (straight), many pockets at one rate +// (outside or inside combination), or several chip components at different +// rates (the racetrack call bets). +type rouletteWager struct { + shares map[string]rouletteShare +} + +func (w rouletteWager) covers(pocket string) (rouletteShare, bool) { + share, ok := w.shares[pocket] + return share, ok +} + +// validate verifies that every possible winning pocket pays a representable +// amount for this stake before the wager is committed. +func (w rouletteWager) validate(stake int) error { + for _, share := range w.shares { + if _, err := share.payout(stake); err != nil { + return err + } + } + return nil +} + +// rouletteComponent is one chip group inside a call bet: the pockets it covers +// and the total-return multiplier (stake included) paid when one of them hits. +type rouletteComponent struct { + pockets []string + chips int + mult int +} + +// rouletteCallBet is a fixed French/racetrack bet. The series is the display +// order (wheel order) and parts are the actual chip placement. +type rouletteCallBet struct { + canonical string + name string + chips int + series []string + parts []rouletteComponent +} + +// RacetrackBetView is one call bet for the presentation layer. +type RacetrackBetView struct { + Name string + Syntax string + Chips int + Series []string +} + +// RacetrackBets returns the fixed French call bets in display order. The +// numbers are shown for reference; the actual wagers are the named bets. +func RacetrackBets() []RacetrackBetView { + views := make([]RacetrackBetView, 0, len(rouletteCallBets)) + for _, bet := range rouletteCallBets { + views = append(views, RacetrackBetView{ + Name: bet.name, + Syntax: bet.canonical, + Chips: bet.chips, + Series: append([]string(nil), bet.series...), + }) + } + return views +} + +// The four standard single-zero call bets. Chip placements and payouts follow +// the traditional layout: Voisins (9 chips), Tiers (6), Orphelins (5) and +// Zero Spiel (4). Every inside component returns 36/covered, so the combined +// wager keeps the wheel's normal house edge. +var rouletteCallBets = []rouletteCallBet{ + { + canonical: "voisins", + name: "Voisins du Zero", + chips: 9, + series: []string{"22", "18", "29", "7", "28", "12", "35", "3", "26", "0", "32", "15", "19", "4", "21", "2", "25"}, + parts: []rouletteComponent{ + {pockets: []string{"0", "2", "3"}, chips: 2, mult: 12}, // trio + {pockets: []string{"4", "7"}, chips: 1, mult: 18}, + {pockets: []string{"12", "15"}, chips: 1, mult: 18}, + {pockets: []string{"18", "21"}, chips: 1, mult: 18}, + {pockets: []string{"19", "22"}, chips: 1, mult: 18}, + {pockets: []string{"25", "26", "28", "29"}, chips: 2, mult: 9}, // corner + {pockets: []string{"32", "35"}, chips: 1, mult: 18}, + }, + }, + { + canonical: "tiers", + name: "Tiers du Cylindre", + chips: 6, + series: []string{"27", "13", "36", "11", "30", "8", "23", "10", "5", "24", "16", "33"}, + parts: []rouletteComponent{ + {pockets: []string{"5", "8"}, chips: 1, mult: 18}, + {pockets: []string{"10", "11"}, chips: 1, mult: 18}, + {pockets: []string{"13", "16"}, chips: 1, mult: 18}, + {pockets: []string{"23", "24"}, chips: 1, mult: 18}, + {pockets: []string{"27", "30"}, chips: 1, mult: 18}, + {pockets: []string{"33", "36"}, chips: 1, mult: 18}, + }, + }, + { + canonical: "orphelins", + name: "Orphelins", + chips: 5, + series: []string{"17", "34", "6", "1", "20", "14", "31", "9"}, + parts: []rouletteComponent{ + {pockets: []string{"1"}, chips: 1, mult: 36}, + {pockets: []string{"6", "9"}, chips: 1, mult: 18}, + {pockets: []string{"14", "17"}, chips: 1, mult: 18}, + {pockets: []string{"17", "20"}, chips: 1, mult: 18}, + {pockets: []string{"31", "34"}, chips: 1, mult: 18}, + }, + }, + { + canonical: "zerospiel", + name: "Zero Spiel", + chips: 4, + series: []string{"12", "35", "3", "26", "0", "32", "15"}, + parts: []rouletteComponent{ + {pockets: []string{"0", "3"}, chips: 1, mult: 18}, + {pockets: []string{"12", "15"}, chips: 1, mult: 18}, + {pockets: []string{"32", "35"}, chips: 1, mult: 18}, + {pockets: []string{"26"}, chips: 1, mult: 36}, + }, + }, +} + +// rouletteCallAliases maps the many spellings players use onto a canonical +// call bet. Accents are stripped before lookup. +var rouletteCallAliases = map[string]string{ + "voisins": "voisins", + "voisins du zero": "voisins", + "grands voisins": "voisins", + "grands voisins du zero": "voisins", + "vdz": "voisins", + "tiers": "tiers", + "tiers du cylindre": "tiers", + "le tiers du cylindre": "tiers", + "tdc": "tiers", + "small series": "tiers", + "orphelins": "orphelins", + "orphans": "orphelins", + "les orphelins": "orphelins", + "zero spiel": "zerospiel", + "zerospiel": "zerospiel", + "spiel": "zerospiel", + "jeu zero": "zerospiel", + "zero game": "zerospiel", +} + +// neighborsRadius is how many pockets on each side of the chosen number the +// neighbours bet includes: the number plus four either side, nine in total. +const neighborsRadius = 4 + +// wager converts a call bet's chip placement into per-pocket return shares, +// summing shares where components overlap (17 in Orphelins). +func (b rouletteCallBet) wager() rouletteWager { + shares := map[string]rouletteShare{} + for _, part := range b.parts { + for _, pocket := range part.pockets { + share := shares[pocket] + shares[pocket] = rouletteShare{num: share.num + part.chips*part.mult, den: b.chips} + } + } + return rouletteWager{shares: shares} +} + +// normalizeRacetrackName lowercases, strips accents and collapses whitespace so +// "Voisins du Zéro" and "voisins du zero" resolve the same way. +func normalizeRacetrackName(spot string) string { + name := strings.ToLower(strings.TrimSpace(spot)) + name = strings.NewReplacer("é", "e", "è", "e", "ê", "e", "à", "a", "ô", "o", "û", "u").Replace(name) + return strings.Join(strings.Fields(name), " ") +} + +// rouletteRacetrackWager resolves a call bet or a neighbours bet for a +// single-zero table whose racetrack is enabled. +func rouletteRacetrackWager(spot string, double bool) (rouletteWager, bool) { + if double { + return rouletteWager{}, false + } + if canonical, ok := rouletteCallAliases[normalizeRacetrackName(spot)]; ok { + for _, bet := range rouletteCallBets { + if bet.canonical == canonical { + return bet.wager(), true + } + } + } + return rouletteNeighborsWager(spot, double) +} + +// rouletteNeighborsWager covers the chosen pocket and the four pockets on each +// side of it in wheel order, one chip each (nine chips, a uniform 3:1 return). +// It accepts "neighbors 17", "neighbors of 17", "17 neighbors" and the British +// "neighbours" spelling. +func rouletteNeighborsWager(spot string, double bool) (rouletteWager, bool) { + if double { + return rouletteWager{}, false + } + keyword := false + number := "" + numbers := 0 + for _, field := range strings.Fields(strings.ToLower(spot)) { + switch field { + case "neighbor", "neighbors", "neighbour", "neighbours": + keyword = true + continue + case "of", "the", "around": + continue + } + pocket, ok := roulettePocketToken(field, double) + if !ok { + return rouletteWager{}, false + } + number = pocket + numbers++ + } + if !keyword || numbers != 1 || number == "" { + return rouletteWager{}, false + } + wheel := rouletteWheel(false) + index := -1 + for i, pocket := range wheel { + if pocket == number { + index = i + break + } + } + if index < 0 { + return rouletteWager{}, false + } + shares := map[string]rouletteShare{} + for d := -neighborsRadius; d <= neighborsRadius; d++ { + pocket := wheel[(index+d+len(wheel))%len(wheel)] + shares[pocket] = rouletteShare{num: 36, den: 2*neighborsRadius + 1} + } + return rouletteWager{shares: shares}, true +} + +// rouletteWagerFor resolves any roulette target into a wager. Call bets and +// neighbours are reachable only on a single-zero table with the racetrack +// enabled; every other target uses the ordinary uniform bets. +func rouletteWagerFor(spot string, double, racetrack bool) (rouletteWager, bool) { + if racetrack && !double { + if wager, ok := rouletteRacetrackWager(spot, double); ok { + return wager, true + } + } + pockets, mult := rouletteBet(spot, double) + if mult == 0 { + return rouletteWager{}, false + } + shares := make(map[string]rouletteShare, len(pockets)) + for _, pocket := range pockets { + shares[pocket] = rouletteShare{num: mult, den: 1} + } + return rouletteWager{shares: shares}, true +} + +// formatShare renders a share for diagnostics and tests without floating +// point. +func formatShare(share rouletteShare) string { + if share.den == 1 { + return strconv.Itoa(share.num) + } + return fmt.Sprintf("%d/%d", share.num, share.den) +} diff --git a/internal/casino/roulette_racetrack_test.go b/internal/casino/roulette_racetrack_test.go new file mode 100644 index 0000000..7c465b8 --- /dev/null +++ b/internal/casino/roulette_racetrack_test.go @@ -0,0 +1,265 @@ +package casino + +import ( + "math/rand" + "strings" + "testing" +) + +// TestRouletteCallBetMath checks the four French call bets against the +// traditional chip placements: each component returns exactly 36/covered, so +// the whole wager keeps the wheel's normal house edge. +func TestRouletteCallBetMath(t *testing.T) { + if len(rouletteCallBets) != 4 { + t.Fatalf("want four call bets, got %d", len(rouletteCallBets)) + } + for _, bet := range rouletteCallBets { + // The display series must be exactly the pockets the chips cover. + covered := map[string]bool{} + for _, part := range bet.parts { + for _, p := range part.pockets { + covered[p] = true + } + } + if len(bet.series) != len(covered) { + t.Fatalf("%s: series %d pockets, chips cover %d", bet.name, len(bet.series), len(covered)) + } + for _, p := range bet.series { + if !covered[p] { + t.Fatalf("%s: series lists %s which no chip covers", bet.name, p) + } + } + // Chips and shares are internally consistent. + chips := 0 + for _, part := range bet.parts { + chips += part.chips + } + if chips != bet.chips { + t.Fatalf("%s: parts total %d chips, want %d", bet.name, chips, bet.chips) + } + wager := bet.wager() + den := 0 + sum := 0 + for _, share := range wager.shares { + if den == 0 { + den = share.den + } + if share.den != den { + t.Fatalf("%s: mixed share denominators %d and %d", bet.name, den, share.den) + } + sum += share.num + } + if sum != 36*den { + t.Fatalf("%s: returns sum to %d/%d, want 36", bet.name, sum, den) + } + if got := len(wager.shares); got != len(covered) { + t.Fatalf("%s: wager covers %d pockets, want %d", bet.name, got, len(covered)) + } + } +} + +// TestRouletteCallBetAliases checks that the shorthand and full names reach the +// same wager, and that they require a racetrack single-zero table. +func TestRouletteCallBetAliases(t *testing.T) { + for _, tc := range []struct { + syntax []string + name string + }{ + {[]string{"voisins", "voisins du zero", "Voisins du Zéro", "vdz", "grands voisins du zero"}, "voisins"}, + {[]string{"tiers", "tiers du cylindre", "le tiers du cylindre", "tdc"}, "tiers"}, + {[]string{"orphelins", "orphans", "les orphelins"}, "orphelins"}, + {[]string{"zero spiel", "zerospiel", "spiel", "jeu zero", "zero game"}, "zerospiel"}, + } { + var want rouletteWager + for _, bet := range rouletteCallBets { + if bet.canonical == tc.name { + want = bet.wager() + } + } + for _, syntax := range tc.syntax { + got, ok := rouletteWagerFor(syntax, false, true) + if !ok { + t.Fatalf("racetrack %q not resolved", syntax) + } + if len(got.shares) != len(want.shares) { + t.Fatalf("%q: %d pockets, want %d", syntax, len(got.shares), len(want.shares)) + } + for pocket, share := range want.shares { + if got.shares[pocket] != share { + t.Fatalf("%q pocket %s: %s, want %s", syntax, pocket, formatShare(got.shares[pocket]), formatShare(share)) + } + } + // The same name on a table without the racetrack option is unknown. + if _, ok := rouletteWagerFor(syntax, false, false); ok { + t.Fatalf("%q resolved with racetrack disabled", syntax) + } + // Double-zero tables never offer the call bets. + if _, ok := rouletteWagerFor(syntax, true, true); ok { + t.Fatalf("%q resolved on a double-zero table", syntax) + } + } + } +} + +// TestRouletteCallBetPayouts pins the per-pocket returns, including the +// Orphelins number covered by two splits and the floor on non-divisible +// stakes. +func TestRouletteCallBetPayouts(t *testing.T) { + wager := func(syntax string) rouletteWager { + w, ok := rouletteWagerFor(syntax, false, true) + if !ok { + t.Fatalf("cannot resolve %q", syntax) + } + return w + } + payout := func(w rouletteWager, pocket string, stake int) int { + share, ok := w.covers(pocket) + if !ok { + t.Fatalf("pocket %s not covered", pocket) + } + got, err := share.payout(stake) + if err != nil { + t.Fatal(err) + } + return got + } + // Voisins: the 0/2/3 trio returns 24/9 of the stake, splits 18/9. + voisins := wager("voisins") + if got := payout(voisins, "0", 9); got != 24 { + t.Fatalf("voisins 9 on 0 = %d, want 24", got) + } + if got := payout(voisins, "3", 9); got != 24 { + t.Fatalf("voisins 9 on 3 = %d, want 24", got) + } + if got := payout(voisins, "4", 9); got != 18 { + t.Fatalf("voisins 9 on 4 = %d, want 18", got) + } + if got := payout(voisins, "25", 9); got != 18 { + t.Fatalf("voisins 9 on 25 = %d, want 18", got) + } + // A stake the chip count does not divide floors, never rounds up. + if got := payout(voisins, "0", 10); got != 26 { + t.Fatalf("voisins 10 on 0 = %d, want 26", got) + } + // Orphelins: 1 and 17 (two winning splits) pay 36/5, the rest 18/5. + orphelins := wager("orphelins") + if got := payout(orphelins, "1", 5); got != 36 { + t.Fatalf("orphelins 5 on 1 = %d, want 36", got) + } + if got := payout(orphelins, "17", 5); got != 36 { + t.Fatalf("orphelins 5 on 17 = %d, want 36", got) + } + if got := payout(orphelins, "6", 5); got != 18 { + t.Fatalf("orphelins 5 on 6 = %d, want 18", got) + } + // Zero Spiel: 26 is the straight-up. + spiel := wager("zero spiel") + if got := payout(spiel, "26", 4); got != 36 { + t.Fatalf("zero spiel 4 on 26 = %d, want 36", got) + } + if got := payout(spiel, "0", 4); got != 18 { + t.Fatalf("zero spiel 4 on 0 = %d, want 18", got) + } + // Tiers is a plain six-way split. + tiers := wager("tiers") + if got := payout(tiers, "5", 6); got != 18 { + t.Fatalf("tiers 6 on 5 = %d, want 18", got) + } +} + +// TestRouletteNeighbors checks the neighbours bet covers the chosen number and +// four each side in wheel order, one chip on each, and rejects malformed +// targets. +func TestRouletteNeighbors(t *testing.T) { + wheel := rouletteWheel(false) + if len(wheel) != 37 { + t.Fatal("unexpected wheel size") + } + index := 0 + for i, p := range wheel { + if p == "17" { + index = i + } + } + for _, syntax := range []string{"neighbors 17", "neighbors of 17", "neighbours 17", "17 neighbors"} { + wager, ok := rouletteWagerFor(syntax, false, true) + if !ok { + t.Fatalf("%q not resolved", syntax) + } + if len(wager.shares) != 9 { + t.Fatalf("%q covers %d pockets, want 9", syntax, len(wager.shares)) + } + for d := -neighborsRadius; d <= neighborsRadius; d++ { + want := wheel[(index+d+len(wheel))%len(wheel)] + share, ok := wager.covers(want) + if !ok { + t.Fatalf("%q missing wheel neighbour %s", syntax, want) + } + if share != (rouletteShare{num: 36, den: 9}) { + t.Fatalf("%q pocket %s share %s, want 36/9", syntax, want, formatShare(share)) + } + } + } + for _, syntax := range []string{"neighbors", "neighbors 37", "neighbors 1 2", "neighbors 00"} { + if _, ok := rouletteWagerFor(syntax, false, true); ok { + t.Fatalf("%q should not resolve", syntax) + } + } + // Neighbours needs the racetrack option and a single-zero wheel. + if _, ok := rouletteWagerFor("neighbors 17", false, false); ok { + t.Fatal("neighbors resolved with racetrack disabled") + } + if _, ok := rouletteWagerFor("neighbors 17", true, true); ok { + t.Fatal("neighbors resolved on a double-zero table") + } + // A bare number is still a straight-up, not a neighbours bet. + if _, ok := rouletteWagerFor("17", false, true); !ok { + t.Fatal("straight 17 no longer resolves") + } +} + +// TestRouletteRacetrackSettlement drives a call bet through the real table +// phases and checks the exact per-pocket payout for the sampled winner. +func TestRouletteRacetrackSettlement(t *testing.T) { + table := newRouletteTable(1, TableConfig{ID: "r", Game: GameRoulette, BettingTicks: 2, Roulette: RouletteConfig{Racetrack: true, SpinTicks: 2}}, rand.New(rand.NewSource(7))) + wheel := rouletteWheel(false) + index := rand.New(rand.NewSource(7)).Intn(len(wheel)) + pocket := wheel[index] + // Voisins, Tiers and Orphelins partition the wheel, so one covers it. + syntax := "" + var share rouletteShare + for _, bet := range rouletteCallBets { + if s, ok := bet.wager().covers(pocket); ok { + syntax, share = bet.canonical, s + break + } + } + if syntax == "" { + t.Fatal("no call bet covers pocket ", pocket) + } + table.join("a") + w := &testWallet{chips: 100} + table.bet("a", syntax, 10, w) + for i := 0; i < 4; i++ { + table.tick() + } + want, err := share.payout(10) + if err != nil { + t.Fatal(err) + } + if w.paid != want || len(w.open) != 0 { + t.Fatalf("%q pocket %s: paid %d, want %d (%+v)", syntax, pocket, w.paid, want, w) + } +} + +// TestRouletteRacetrackConfig rejects a racetrack on the American wheel. +func TestRouletteRacetrackConfig(t *testing.T) { + cfg := TableConfig{ID: "r", Game: GameRoulette, Roulette: RouletteConfig{Racetrack: true}} + if err := cfg.Validate(); err != nil { + t.Fatalf("single-zero racetrack rejected: %v", err) + } + cfg.Roulette.DoubleZero = true + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "single-zero") { + t.Fatalf("double-zero racetrack accepted: %v", err) + } +} diff --git a/internal/casino/roulette_test.go b/internal/casino/roulette_test.go index 160ff37..0a43677 100644 --- a/internal/casino/roulette_test.go +++ b/internal/casino/roulette_test.go @@ -174,8 +174,7 @@ func TestRouletteCombinationOrderIndependent(t *testing.T) { } return true } - var permute func([]string, func([]string)) - permute = func(items []string, visit func([]string)) { + permute := func(items []string, visit func([]string)) { var rec func(int) rec = func(i int) { if i == len(items) { diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go index 00009bf..187bb6d 100644 --- a/internal/casino/table_ux.go +++ b/internal/casino/table_ux.go @@ -13,11 +13,14 @@ func (c TableConfig) RulesText() string { case GameBaccarat: return c.BaccaratRulesText() case GameRoulette: - return c.Label() + "\nStraight numbers pay 35:1; splits 17:1; streets and trios 11:1; corners 8:1; lines 5:1.\n" + + rules := c.Label() + "\nStraight numbers pay 35:1; splits 17:1; streets and trios 11:1; corners 8:1; lines 5:1.\n" + "1to12/13to24/25to36 and rows 2:1; other outside bets 1:1; the American basket pays 6:1.\n" + "Combos: bet 10 on split 1 2 | bet 10 on street 1 2 3 | bet 10 on corner 1 2 4 5 |\n" + - "bet 10 on line 1 2 3 4 5 6 | bet 10 on trio 0 00 2 | bet 10 on basket.\n" + - "Zero pockets lose all outside bets. One wager per spin. Use help roulette." + "bet 10 on line 1 2 3 4 5 6 | bet 10 on trio 0 00 2 | bet 10 on basket.\n" + if c.Roulette.Racetrack { + rules += "Racetrack (single zero): bet 10 on voisins | tiers | orphelins | zero spiel | neighbors 17.\n" + } + return rules + "Zero pockets lose all outside bets. One wager per spin. Use help roulette." default: return c.Label() } @@ -25,10 +28,14 @@ func (c TableConfig) RulesText() string { func (c TableConfig) Label() string { if c.Game == GameRoulette { + label := "Roulette / Single zero" if c.Roulette.DoubleZero { - return "Roulette / Double zero" + label = "Roulette / Double zero" + } + if c.Roulette.Racetrack { + label += " / Racetrack" } - return "Roulette / Single zero" + return label } if c.Game != GameBaccarat { return gameTitle(c.Game) diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go index e218653..de62797 100644 --- a/internal/game/casino_ux.go +++ b/internal/game/casino_ux.go @@ -112,7 +112,7 @@ func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) { } g.emitCasinoEvents(t.Status(sess.Player.Name)) if t.Config.Game == casino.GameRoulette { - sess.WriteLine(g.rouletteReminder(sess)) + sess.WriteLine(g.rouletteReminder(sess, t.Config)) } if len(args) > 0 { sess.WriteLine(g.renderCasinoCommands(sess, "", "Table controls", []casino.MenuCommand{ diff --git a/internal/game/render_roulette.go b/internal/game/render_roulette.go index 47ba5c6..159fadc 100644 --- a/internal/game/render_roulette.go +++ b/internal/game/render_roulette.go @@ -21,7 +21,7 @@ func renderRouletteSnapshot(s *casino.RouletteSnapshot, mode string, unicode boo case "result": return renderRouletteResult(s.Pockets, mode, unicode, size) default: - return renderRouletteLayout(s.DoubleZero, mode, unicode) + return renderRouletteLayout(s.DoubleZero, s.Racetrack, mode, unicode) } } @@ -76,18 +76,49 @@ func repeatRoulette(s string, n int) []string { return parts } +// renderRouletteRacetrack draws the French call-bet track above the board. The +// numbers are shown in wheel order for reference; the named bets are what the +// player places. The Neighbors line explains the one bet that takes a number. +func renderRouletteRacetrack(mode string, unicode bool) string { + b := rouletteBorders(unicode) + tl, tr, bl, br := b.tl, b.tr, b.bl, b.br + if unicode { + tl, tr, bl, br = "╭", "╮", "╰", "╯" + } + // The panel matches the betting board's 74-column footprint. + const width = 72 + bets := casino.RacetrackBets() + rows := make([]string, 0, len(bets)+1) + for _, bet := range bets { + label := fmt.Sprintf("%s (%d)", bet.Name, bet.Chips) + rows = append(rows, fmt.Sprintf("%-22s %s", label, strings.Join(bet.Series, " "))) + } + rows = append(rows, fmt.Sprintf("%-22s %s", "Neighbors (9)", "neighbors <n>: n plus four each side")) + lines := []string{rouletteInk(mode, tl+strings.Repeat(b.h, width)+tr)} + for _, row := range rows { + row = " " + row + if pad := width - color.VisibleLen(row); pad > 0 { + row += strings.Repeat(" ", pad) + } + lines = append(lines, rouletteInk(mode, b.v+row+b.v)) + } + lines = append(lines, rouletteInk(mode, bl+strings.Repeat(b.h, width)+br)) + return strings.Join(lines, "\n") +} + // renderRouletteLayout draws the betting board. Green is reserved for the // 0/00 lane: every other cell is red, black, or plain. The odds reminder is // not part of the board (it is shown by the "table" command), so the board // stays compact enough to sit in a betting menu. -func renderRouletteLayout(double bool, mode string, unicode bool) string { +func renderRouletteLayout(double, racetrack bool, mode string, unicode bool) string { + // The call bets are a single-zero layout; never draw the track on an + // American board even if a malformed snapshot asks for it. + racetrack = racetrack && !double b := rouletteBorders(unicode) var lines []string - title := "ROULETTE / SINGLE ZERO" - if double { - title = "ROULETTE / DOUBLE ZERO" + if racetrack { + lines = append(lines, renderRouletteRacetrack(mode, unicode)) } - lines = append(lines, rouletteInk(mode, title)) // Five columns for the zero lane, twelve four-column pockets, and a // six-column 2:1 lane on the right holding the row bets. Four adjacent // pockets line up exactly over each 19-column dozen. @@ -434,11 +465,15 @@ func renderRouletteResult(pockets [3]string, mode string, unicode bool, size int } // rouletteReminder is the odds/cheat-sheet block, shown only by "table" (and -// "table rules"), never with the ambient betting board. -func (g *Game) rouletteReminder(sess *net.Session) string { +// "table rules"), never with the ambient betting board. Racetrack tables also +// list their call-bet syntax. +func (g *Game) rouletteReminder(sess *net.Session, cfg casino.TableConfig) string { text := "Numbers 35:1 | Split 17:1 | Street/trio 11:1 | Corner 8:1 | Line 5:1 | Dozens & rows 2:1 | Outside 1:1\n" + - "Combos: split 1 2 | street 1 2 3 | corner 1 2 4 5 | line 1 2 3 4 5 6 | trio 0 1 2 | basket (double zero)\n" + - "Low: 1-18 | High: 19-36 | Zero loses all outside bets\n" + + "Combos: split 1 2 | street 1 2 3 | corner 1 2 4 5 | line 1 2 3 4 5 6 | trio 0 1 2 | basket (double zero)\n" + if cfg.Roulette.Racetrack { + text += "Racetrack: bet 10 on voisins | tiers | orphelins | zero spiel | neighbors 17\n" + } + text += "Low: 1-18 | High: 19-36 | Zero loses all outside bets\n" + "bet 10 on 17 / bet 10 on red / bet 10 on split 1 2" return g.colorize(sess, "casino_action", text) } @@ -446,8 +481,8 @@ func (g *Game) rouletteReminder(sess *net.Session) string { func (g *Game) renderCasinoRules(sess *net.Session, cfg casino.TableConfig) string { rules := g.colorize(sess, "casino_action", cfg.RulesText()) if cfg.Game == casino.GameRoulette { - snap := &casino.RouletteSnapshot{DoubleZero: cfg.Roulette.DoubleZero, Phase: "betting", Wheel: casino.WheelOrder(cfg.Roulette.DoubleZero)} - rules += "\n" + g.renderCasinoSnapshot(sess, snap) + "\n" + g.rouletteReminder(sess) + snap := &casino.RouletteSnapshot{DoubleZero: cfg.Roulette.DoubleZero, Racetrack: cfg.Roulette.Racetrack, Phase: "betting", Wheel: casino.WheelOrder(cfg.Roulette.DoubleZero)} + rules += "\n" + g.renderCasinoSnapshot(sess, snap) + "\n" + g.rouletteReminder(sess, cfg) } return rules } diff --git a/internal/game/render_roulette_test.go b/internal/game/render_roulette_test.go index 1cb75ce..d992abf 100644 --- a/internal/game/render_roulette_test.go +++ b/internal/game/render_roulette_test.go @@ -64,14 +64,14 @@ func TestRouletteArtModes(t *testing.T) { } } - layoutArt := renderRouletteLayout(double, mode, unicode) + layoutArt := renderRouletteLayout(double, false, mode, unicode) layout := plainRoulette(layoutArt) rows := strings.Split(layout, "\n") if double { - if !strings.Contains(string([]rune(rows[2])[:7]), "0") || !strings.Contains(string([]rune(rows[6])[:7]), "00") { + if !strings.Contains(string([]rune(rows[1])[:7]), "0") || !strings.Contains(string([]rune(rows[5])[:7]), "00") { t.Fatal("double zero not left of grid", layout) } - } else if !strings.Contains(string([]rune(rows[4])[:7]), "0") { + } else if !strings.Contains(string([]rune(rows[3])[:7]), "0") { t.Fatal("zero not left of grid", layout) } for _, target := range []string{"1to12", "13to24", "25to36", "row1", "row2", "row3", "red", "black", "odd", "even", "low", "high"} { @@ -157,12 +157,12 @@ func TestRouletteWheelGreenPockets(t *testing.T) { // each outside-bet divider. func TestRouletteLayoutConnections(t *testing.T) { for _, double := range []bool{false, true} { - art := plainRoulette(renderRouletteLayout(double, "xterm256", true)) + art := plainRoulette(renderRouletteLayout(double, false, "xterm256", true)) rows := strings.Split(art, "\n") - // rows[7] is the numbered grid's bottom edge, directly above the dozen + // rows[6] is the numbered grid's bottom edge, directly above the dozen // labels. The dozen boundaries and the grid/row-lane joint cross; the // row-bet lane closes with a bottom-right corner. - gridBottom := []rune(rows[7]) + gridBottom := []rune(rows[6]) for _, col := range []int{6, 26, 46, 66} { if gridBottom[col] != '┼' { t.Fatalf("double=%v: grid bottom col %d = %q, want ┼:\n%s", double, col, gridBottom[col], art) @@ -173,15 +173,15 @@ func TestRouletteLayoutConnections(t *testing.T) { } // The row-bet lane is boxed: its separators cross at the grid edge and // close on the right. - for _, idx := range []int{3, 5} { + for _, idx := range []int{2, 4} { sep := []rune(rows[idx]) if sep[66] != '┼' || sep[73] != '┤' { t.Fatalf("double=%v: row lane separator %q lacks box joints:\n%s", double, rows[idx], art) } } - // rows[9] is the dozen boxes' bottom edge: tees point down where the + // rows[8] is the dozen boxes' bottom edge: tees point down where the // even-money dividers sit and crosses pass the dozen dividers through. - dozenBottom := []rune(rows[9]) + dozenBottom := []rune(rows[8]) wantDozen := map[int]rune{6: '├', 16: '┬', 26: '┼', 36: '┬', 46: '┼', 56: '┬', 66: '┤'} for col, want := range wantDozen { if dozenBottom[col] != want { @@ -204,20 +204,20 @@ func TestRouletteLayoutConnections(t *testing.T) { // Unicode mode while ASCII mode stays square. func TestRouletteZeroPanel(t *testing.T) { for _, double := range []bool{false, true} { - colored := renderRouletteLayout(double, "xterm256", true) + colored := renderRouletteLayout(double, false, "xterm256", true) rows := strings.Split(colored, "\n") - // Interior rows 2-6: top numbered row, separator, middle, separator, + // Interior rows 1-5: top numbered row, separator, middle, separator, // bottom numbered row. Each must contain the green background. - for row := 2; row <= 6; row++ { + for row := 1; row <= 5; row++ { if !strings.Contains(rows[row], color.BgCode("xterm256", 2)) { t.Fatalf("double=%v: zero lane row %d is not green:\n%s", double, row, plainRoulette(colored)) } } plainRows := strings.Split(plainRoulette(colored), "\n") - if !strings.HasPrefix(plainRows[1], "╭") || !strings.HasPrefix(plainRows[7], "╰") { + if !strings.HasPrefix(plainRows[0], "╭") || !strings.HasPrefix(plainRows[6], "╰") { t.Fatalf("double=%v: zero panel corners are not rounded:\n%s", double, plainRoulette(colored)) } - ascii := plainRoulette(renderRouletteLayout(double, "none", false)) + ascii := plainRoulette(renderRouletteLayout(double, false, "none", false)) for _, r := range ascii { if r > 127 { t.Fatalf("double=%v: non-ASCII rune %q in ASCII mode", double, r) @@ -226,6 +226,44 @@ func TestRouletteZeroPanel(t *testing.T) { } } +// TestRouletteRacetrackPanel checks that an enabled racetrack adds the call-bet +// panel above the board, names all four call bets and the neighbours bet, and +// keeps the same width and ASCII/Unicode guarantees as the board. +func TestRouletteRacetrackPanel(t *testing.T) { + for _, mode := range []string{"none", "ansi", "xterm256"} { + for _, unicode := range []bool{false, true} { + art := renderRouletteLayout(false, true, mode, unicode) + plain := plainRoulette(art) + for _, want := range []string{"Voisins du Zero", "Tiers du Cylindre", "Orphelins", "Zero Spiel", "Neighbors (9)"} { + if !strings.Contains(plain, want) { + t.Fatalf("mode=%s unicode=%v: missing %q:\n%s", mode, unicode, want, plain) + } + } + for _, line := range strings.Split(art, "\n") { + if color.VisibleLen(line) > 78 { + t.Fatalf("mode=%s unicode=%v: line exceeds terminal width: %q", mode, unicode, plainRoulette(line)) + } + if !unicode { + for _, r := range plainRoulette(line) { + if r > 127 { + t.Fatalf("mode=%s: non-ASCII rune %q in ASCII mode", mode, r) + } + } + } + } + } + } + // The panel comes from the snapshot flag, and a plain board omits it. + snap := &casino.RouletteSnapshot{Phase: "betting", Wheel: casino.WheelOrder(false), Racetrack: true} + if !strings.Contains(plainRoulette(renderRouletteSnapshot(snap, "none", true, 7)), "Voisins du Zero") { + t.Fatal("racetrack snapshot did not draw the panel") + } + snap.Racetrack = false + if strings.Contains(plainRoulette(renderRouletteSnapshot(snap, "none", true, 7)), "Voisins du Zero") { + t.Fatal("plain snapshot drew the racetrack panel") + } +} + func TestRouletteResultSizesAndNeighbors(t *testing.T) { for _, unicode := range []bool{false, true} { for _, size := range []int{1, 5, 7, 9} { diff --git a/internal/game/roulette_test.go b/internal/game/roulette_test.go index 7a22d9b..333e6a9 100644 --- a/internal/game/roulette_test.go +++ b/internal/game/roulette_test.go @@ -95,6 +95,45 @@ func TestRouletteCombinationTargetParsing(t *testing.T) { } } +func TestRouletteRacetrackTargetParsing(t *testing.T) { + // A racetrack single-zero table accepts the call and neighbours syntaxes. + g := &Game{Casino: casino.NewManager(1)} + cfg := casino.TableConfig{ID: "roulette", Game: casino.GameRoulette, Roulette: casino.RouletteConfig{Racetrack: true}} + g.Casino.Join(1, cfg, "alice") + conn := &casinoTestConn{} + sess := &net.Session{Player: player.New("alice"), Conn: conn} + g.loggedInChars = map[string]*net.Session{"alice": sess} + valid := [][]string{ + {"10", "on", "voisins"}, + {"10", "on", "voisins", "du", "zero"}, + {"10", "on", "tiers"}, + {"10", "on", "orphelins"}, + {"10", "on", "zero", "spiel"}, + {"10", "on", "neighbors", "17"}, + {"10", "on", "neighbors", "of", "17"}, + } + for _, args := range valid { + conn.data = nil + g.executeBet(sess, args, "bet "+strings.Join(args, " ")) + if !strings.Contains(string(conn.data), "You don't have enough chips and credits") { + t.Fatalf("args=%v: expected wallet failure, got %q", args, conn.data) + } + } + // The same names are unknown on a single-zero table without the option. + plain := &Game{Casino: casino.NewManager(1)} + plain.Casino.Join(1, casino.TableConfig{ID: "roulette", Game: casino.GameRoulette}, "bob") + conn2 := &casinoTestConn{} + sess2 := &net.Session{Player: player.New("bob"), Conn: conn2} + plain.loggedInChars = map[string]*net.Session{"bob": sess2} + for _, args := range [][]string{{"10", "on", "voisins"}, {"10", "on", "neighbors", "17"}} { + conn2.data = nil + plain.executeBet(sess2, args, "bet "+strings.Join(args, " ")) + if !strings.Contains(string(conn2.data), "Unknown roulette target") { + t.Fatalf("non-racetrack args=%v: expected rejection, got %q", args, conn2.data) + } + } +} + func TestCasinoTestPortalAuthoredContract(t *testing.T) { g, rooms, _ := loadVesta(t) found := false @@ -118,7 +157,7 @@ func TestCasinoTestPortalAuthoredContract(t *testing.T) { if steps[1].Teleport != 6100 || rooms[6100] == nil { t.Fatal("wrong casino destination") } - for _, id := range []int{6182, 6183} { + for _, id := range []int{6182, 6183, 6184} { cfg := rooms[id].CasinoTables[0] if err := cfg.Validate(); err != nil { t.Fatal(err) @@ -126,5 +165,8 @@ func TestCasinoTestPortalAuthoredContract(t *testing.T) { if cfg.Game != casino.GameRoulette || cfg.Roulette.DoubleZero != (id == 6183) { t.Fatal("wrong roulette rules", id) } + if cfg.Roulette.Racetrack != (id == 6184) { + t.Fatal("wrong racetrack setting", id) + } } } |
