diff options
| author | historia <[not public]> | 2026-09-29 18:58:28 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-29 18:58:28 -0400 |
| commit | 624626b9a10ae47cf7d5f6e91765570ed759bd74 (patch) | |
| tree | 9812f5d254b7d9cfe1d2fcb6da53362e974541b0 | |
| parent | ff9002b96e35ad0808945e7c35791460c9ca8776 (diff) | |
| download | thehouseoficarus-624626b9a10ae47cf7d5f6e91765570ed759bd74.tar.gz | |
feat: better roulette racetrack graphics and payout table
| -rw-r--r-- | building_guide/roulette.md | 22 | ||||
| -rw-r--r-- | data/help/baccarat.yaml | 29 | ||||
| -rw-r--r-- | data/help/blackjack.yaml | 26 | ||||
| -rw-r--r-- | data/help/casino.yaml | 28 | ||||
| -rw-r--r-- | data/help/racetrack.yaml | 38 | ||||
| -rw-r--r-- | data/help/roulette.yaml | 22 | ||||
| -rw-r--r-- | data/help/slots.yaml | 28 | ||||
| -rw-r--r-- | internal/casino/roulette.go | 71 | ||||
| -rw-r--r-- | internal/casino/roulette_geometry_test.go | 109 | ||||
| -rw-r--r-- | internal/casino/roulette_racetrack.go | 18 | ||||
| -rw-r--r-- | internal/casino/roulette_racetrack_test.go | 34 | ||||
| -rw-r--r-- | internal/casino/table_ux.go | 4 | ||||
| -rw-r--r-- | internal/game/casino_ux.go | 37 | ||||
| -rw-r--r-- | internal/game/render_casino_help.go | 85 | ||||
| -rw-r--r-- | internal/game/render_casino_help_test.go | 122 | ||||
| -rw-r--r-- | internal/game/render_help.go | 1 | ||||
| -rw-r--r-- | internal/game/render_roulette.go | 136 | ||||
| -rw-r--r-- | internal/game/render_roulette_test.go | 143 | ||||
| -rw-r--r-- | internal/game/roulette_test.go | 5 |
19 files changed, 830 insertions, 128 deletions
diff --git a/building_guide/roulette.md b/building_guide/roulette.md index e257b31..3960e2b 100644 --- a/building_guide/roulette.md +++ b/building_guide/roulette.md @@ -35,8 +35,12 @@ ANSI). Three views share one renderer: pockets, and outside bets. The whole five-row zero lane (three numbered rows plus the two separators between them) carries the green background, and its outer corners are rounded in Unicode mode. A racetrack table draws - `renderRouletteRacetrack` above the board: a rounded panel listing each call - bet's chips and wheel-order number series, plus the Neighbors syntax. + `renderRouletteRacetrack` above the board: a numbered oval in physical + clockwise wheel order, with 26 at the right tip. Interior dividers meet + each call bet's pockets, left to right: Tiers du Cylindre, Orphelins, + Voisins du Zero, Zero. Connected box-drawing steps separate Tiers from + Orphelins; Zero has a curved boundary within Voisins. Neighbors is not + drawn on the board, but remains in the payout reference and help. - **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 @@ -51,8 +55,14 @@ ANSI). Three views share one renderer: The smallest (five-row) cards drop the rule so the ball can sit directly under the number. -The odds reminder (`Numbers 35:1 ...`) is not part of the ambient board. It is -shown only by the `table` command (and `table rules`). Each table's one-line +The colored payout tables in `internal/game/render_casino_help.go` use the +shared ANSI-aware `ui.Table` renderer, respecting ASCII/Unicode and wrap width. +They show wheel-specific win chances and net profit odds, including call-bet +exceptions. They appear only with `table` and `table rules`, not ambient art. +`table help` opens the current game's guide; `help casino` indexes the brief +casino guides in `data/help/`. `help racetrack` explains chip placements and +rounding, including `zero`/`jeu zero` versus the straight-number target `0`. +Each table's one-line overview is kept short (`Label | Limits 1-10000 | n/m seated | state`) so it fits beside a look minimap; `writeLookSideBySide` also wraps any content line that would otherwise run into the map column. @@ -69,12 +79,12 @@ 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) +French call bets (Voisins du Zéro, Tiers du Cylindre, Orphelins, Jeu Zero) and the Neighbors bet (a chosen number plus four on each side in wheel order). The racetrack is a single-zero layout only: the validator rejects `racetrack: true` together with `double_zero: true`. A call bet is **one wager** whose amount is the total stake spread evenly over the bet's chips -(Voisins 9, Tiers 6, Orphelins 5, Zero Spiel 4, Neighbors 9). Because the +(Voisins 9, Tiers 6, Orphelins 5, Jeu Zero 4, Neighbors 9). Because the components are ordinary inside bets, each returns `36/covered` when its number hits, so a winning pocket can pay a different multiple of the stake than another (for example 0 on Voisins pays 24/9, while 4 pays 18/9). The diff --git a/data/help/baccarat.yaml b/data/help/baccarat.yaml new file mode 100644 index 0000000..b8d0e56 --- /dev/null +++ b/data/help/baccarat.yaml @@ -0,0 +1,29 @@ +name: baccarat +aliases: [bac] +category: Interaction +related: [casino, blackjack, roulette, slots] +description: | + Baccarat quick guide + + play baccarat; bet 10 on banker (or player / tie). + You bet on a hand, not on your own cards. Highest last digit of the + total wins: aces = 1, 2-9 = face value, 10/J/Q/K = 0. Drawing is + automatic; there are no hit/stand decisions. One wager per round. + + Player Pays 1:1 profit; ties push. + Banker Normally pays 0.95:1 after 5% commission; ties push. + Tie Normally pays 8:1; loses unless both totals are equal. + + Super 6 tables: Banker pays 1:1, except winning with 6 pays 1:2. + EZ tables: Banker pays 1:1, except winning with a three-card 7 pushes. + Player bets still lose on those Banker wins. Check table rules. + + Optional bets (only where offered; payouts are profit odds): + player_pair / banker_pair First two cards have the same rank: 11:1. + dragon_7 Banker wins with a three-card 7: 40:1. + panda_8 Player wins with a three-card 8: 25:1. + super_6 Banker wins with 6: 12:1. + + Side bets settle independently; a pair can win even on a tie. + Higher payouts do not mean better value: rare events win less often. + table rules shows this table's exact payouts and available bets. diff --git a/data/help/blackjack.yaml b/data/help/blackjack.yaml new file mode 100644 index 0000000..3e4c2c8 --- /dev/null +++ b/data/help/blackjack.yaml @@ -0,0 +1,26 @@ +name: blackjack +aliases: [bj] +category: Interaction +related: [casino, baccarat, roulette, slots] +description: | + Blackjack quick guide + + play blackjack; bet 10. Beat the dealer's total without exceeding 21. + Aces count as 1 or 11; face cards count as 10. Busting loses even if + the dealer later busts. Equal totals push (return your stake). + + hit / h Take a card. stand / st Keep your total. + double / dd Add an equal stake, take exactly one card, then stand. + split / sp Add an equal stake and play a pair as separate hands. + surrender If offered, give up the initial hand for half your stake. + insurance Against a dealer ace, stake half your original bet on + dealer blackjack; usually pays 2:1. Or type decline. + + Ordinary wins pay 1:1 profit. An original ace + ten-value card is + blackjack: normally 3:2, but some tables pay 6:5. Split-hand 21 is + normally an ordinary win. For a 10-credit bet, 3:2 returns 25 total. + Win chances depend on cards, rules and your decisions, not fixed odds. + + table rules lists the actual blackjack payout, insurance payout, + dealer soft-17 rule, splitting restrictions and surrender availability. + table help shows controls; help casino explains wagers and returns. diff --git a/data/help/casino.yaml b/data/help/casino.yaml new file mode 100644 index 0000000..e5ec558 --- /dev/null +++ b/data/help/casino.yaml @@ -0,0 +1,28 @@ +name: casino +aliases: [play, bet, rebet, table, watch] +category: Interaction +related: [roulette, racetrack, blackjack, baccarat, slots] +description: | + Casino quick guide + + play <game> Join a table; use play to list nearby games. + bet <amount> Wager chips first, then credits. + bet 10 on <target> Choose a roulette or baccarat bet. + bet / rebet Repeat your last table wager. + table Show the table; roulette also shows payout tables. + table help Learn the current game and its controls. + table rules Check this table's exact rules and payouts. + stop Leave (after a committed table round). + + 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. + Fractional table-game returns round down to whole credits. + + help roulette Number, color and combination bets. + help racetrack Voisins, Tiers, Orphelins, Zero and Neighbors. + help blackjack Beat the dealer without going over 21. + help baccarat Bet which hand finishes closest to 9. + help slots Matching symbols, ways and free spins. + + Rules and limits vary by table. No bet guarantees a profit. diff --git a/data/help/racetrack.yaml b/data/help/racetrack.yaml new file mode 100644 index 0000000..535285e --- /dev/null +++ b/data/help/racetrack.yaml @@ -0,0 +1,38 @@ +name: racetrack +aliases: [voisins, tiers, orphelins, zero, jeu zero, jeuzero, neighbors, neighbours] +category: Interaction +related: [roulette, casino] +description: | + Racetrack bets (single-zero racetrack tables only) + + The oval follows physical wheel order, not numerical order. Named + bets place several chips for you; the amount is the TOTAL stake. + For example, bet 9 on voisins places nine 1-credit chips. + Profit odds below are on the total stake; wins also return that stake. + + voisins - Voisins du Zero: 17 pockets around zero, using 9 chips. + Two on trio 0/2/3, two on corner 25/26/28/29; one each on splits + 4/7, 12/15, 18/21, 19/22 and 32/35. + Profit: 5:3 on 0/2/3, otherwise 1:1. Chance: 17/37. + + tiers - Tiers du Cylindre: the 12 pockets opposite zero, 6 chips. + One each on splits 5/8, 10/11, 13/16, 23/24, 27/30 and 33/36. + Profit: 2:1. Chance: 12/37. + + orphelins - the remaining 8 pockets, 5 chips. + One on 1; one each on splits 6/9, 14/17, 17/20 and 31/34. + Profit: 31:5 on 1 or 17 (two splits win on 17), otherwise 13:5. + Chance: 8/37. + + zero / jeu zero - 7 pockets within Voisins, 4 chips. + One on 26; one each on splits 0/3, 12/15 and 32/35. + Profit: 8:1 on 26, otherwise 7:2. Chance: 7/37. + bet 10 on zero (or jeu zero / jeuzero) means Jeu Zero; + bet 10 on 0 means just pocket 0. + + neighbors <n> - the chosen pocket plus four on either side, 9 chips. + One straight-number chip per pocket. Profit: 3:1. Chance: 9/37. + + Fractions are rounded down after adding winning chip returns. + Use a stake divisible by the chip count to avoid rounding. + One named bet per spin; table shows a compact payout reference. diff --git a/data/help/roulette.yaml b/data/help/roulette.yaml index 5982fb3..790313f 100644 --- a/data/help/roulette.yaml +++ b/data/help/roulette.yaml @@ -1,5 +1,6 @@ name: roulette category: Interaction +related: [racetrack, casino, blackjack, baccarat, slots] description: | Play roulette in the Domita Fortuna casino. From the vestibule: north, up, north reaches single zero; @@ -31,22 +32,35 @@ description: | bet 10 on street 1 2 3 A three-number column (11:1) bet 10 on corner 1 2 4 5 A 2x2 square (8:1) bet 10 on line 1 2 3 4 5 6 Two adjacent streets (5:1) - bet 10 on trio 0 1 2 A zero plus the first street (11:1) + bet 10 on trio 0 1 2 Three touching zero-edge pockets (11:1) bet 10 on basket 0,00,1,2,3 on double zero (6:1); 0,1,2,3 on single zero (8:1) - 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 + On single-zero tables with the racetrack option, the oval board shows + every pocket in physical wheel order; 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 zero Jeu Zero: the numbers nearest 0 (4 chips) + 'jeu zero' or 'jeuzero' also works; '0' bets pocket 0 only. bet 10 on neighbors 17 Number 17 plus four numbers each side of it on the wheel (9 chips) + Use help racetrack for the chip placements and each call bet's payouts. + Use table for a formatted payout reference for your current wheel. + + Call-bet returns depend on which chip placements win; overlapping + wins are added together. Fractional credit returns are rounded down. + Use a multiple of the chip count to avoid rounding. Neighbors pays 3:1 + on the total stake (9 of the 37 pockets win). + + Every pocket is equally likely: 1/37 on single zero, 1/38 on double + zero. The house edge is 2.70% or 5.26%, except the American basket + (7.89%). Call-bet rounding can increase the edge for other stake sizes. + 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; diff --git a/data/help/slots.yaml b/data/help/slots.yaml new file mode 100644 index 0000000..91b753b --- /dev/null +++ b/data/help/slots.yaml @@ -0,0 +1,28 @@ +name: slots +aliases: [spin, autospin] +category: Interaction +related: [casino, blackjack, baccarat, roulette] +description: | + Slots quick guide + + play <machine> Join a cabinet; play lists nearby machine names. + bet 10 Set your total wager and start a spin. + spin Spin again at that wager. + autospin Keep spinning automatically; stop cancels autoplay. + stop When not autoplaying, leave the machine. + + Match a symbol on at least three consecutive reels starting from + the left. Any row counts. Multiply the matching symbol counts on + each reel to get the number of winning ways (up to 243). + + Return = total bet x symbol multiplier x winning ways / 243. + Returns from different winning symbols add together. The wager is + not returned separately. A small win can return less than you spent. + Symbol weights and payout multipliers depend on the cabinet; there + are no universal win odds. Each spin is independent, not 'due' to win. + + Three or more scatter symbols trigger free spins (normally eight). + Free spins use the triggering wager without charging again; they do + not retrigger. Feature winnings are banked and paid when it finishes. + RTP is a long-run average before credit rounding, not a promised return + for any particular spin or session. diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go index 252ac4c..109306a 100644 --- a/internal/casino/roulette.go +++ b/internal/casino/roulette.go @@ -306,19 +306,17 @@ func rouletteAdjacent(a, b string, double bool) bool { return (dc == 1 && dr == 0) || (dc == 0 && dr == 1) } -// The zero lane spans all three numbered rows, so 0 splits with 1, 2 and 3. -// On an American felt the two zeros are adjacent to each other as well. +// European zero touches 1, 2 and 3. On the American layout, 0 +// touches 1 and 2; 00 touches 2 and 3. The zeros also touch each other. func rouletteZeroAdjacent(a, b string, double bool) bool { - neighbors := func(other string) bool { - if other == "0" || other == "00" { - return double - } - return other == "1" || other == "2" || other == "3" + if b == "0" || b == "00" { + a, b = b, a } - if a == "0" || a == "00" { - return neighbors(b) + if !double { + return a == "0" && (b == "1" || b == "2" || b == "3") } - return neighbors(a) + return (a == "0" && (b == "00" || b == "1" || b == "2")) || + (a == "00" && (b == "0" || b == "2" || b == "3")) } func rouletteIsStreet(nums []string) bool { @@ -352,6 +350,7 @@ func rouletteIsCorner(nums []string, double bool) bool { if !double && rouletteIsFirstFour(nums) { return true } + minCol, maxCol, minRow, maxRow := 12, -1, 3, -1 cells := map[[2]int]bool{} for _, s := range nums { c, r, ok := rouletteNumberCell(s) @@ -359,26 +358,10 @@ func rouletteIsCorner(nums []string, double bool) bool { return false } cells[[2]int{c, r}] = true + minCol, maxCol = min(minCol, c), max(maxCol, c) + minRow, maxRow = min(minRow, r), max(maxRow, r) } - if len(cells) != 4 { - return false - } - cols, rows := map[int]bool{}, map[int]bool{} - for cell := range cells { - cols[cell[0]] = true - rows[cell[1]] = true - } - if len(cols) != 2 || len(rows) != 2 { - return false - } - for c := range cols { - for r := range rows { - if !cells[[2]int{c, r}] { - return false - } - } - } - return true + return len(cells) == 4 && maxCol-minCol == 1 && maxRow-minRow == 1 } func rouletteIsFirstFour(nums []string) bool { @@ -427,30 +410,22 @@ func rouletteIsLine(nums []string) bool { return c2-c1 == 1 } -// A trio is any three distinct pockets drawn from 0, 00 and the first street -// (1,2,3) that includes at least one zero. It pays the same 11:1 as a street. +// Trios cover touching zero-edge intersections, not arbitrary selections +// from the zeros and the first street. func rouletteIsTrio(nums []string, double bool) bool { if len(nums) != 3 { return false } - hasZero := false - for _, s := range nums { - switch s { - case "0": - hasZero = true - case "00": - if !double { - return false - } - hasZero = true - default: - v, err := strconv.Atoi(s) - if err != nil || v < 1 || v > 3 { - return false - } - } + set := map[string]bool{} + for _, n := range nums { + set[n] = true + } + if double { + return (set["0"] && set["1"] && set["2"]) || + (set["0"] && set["00"] && set["2"]) || + (set["00"] && set["2"] && set["3"]) } - return hasZero + return set["0"] && set["2"] && (set["1"] || set["3"]) } // The basket is the American five-number 0/00/1/2/3 bet, which pays a short diff --git a/internal/casino/roulette_geometry_test.go b/internal/casino/roulette_geometry_test.go new file mode 100644 index 0000000..37fec74 --- /dev/null +++ b/internal/casino/roulette_geometry_test.go @@ -0,0 +1,109 @@ +package casino + +import ( + "fmt" + "testing" +) + +func TestRouletteRejectsNonTouchingCombinations(t *testing.T) { + for _, tc := range []struct { + target string + double bool + }{ + {"corner 1 3 4 6", false}, {"corner 1 2 7 8", false}, + {"corner 1 3 34 36", true}, {"trio 0 1 3", false}, + {"trio 0 1 3", true}, {"trio 0 2 3", true}, + {"trio 00 1 2", true}, {"trio 0 00 1", true}, + {"trio 0 00 3", true}, {"split 0 3", true}, + {"split 00 1", true}, + } { + if _, mult := rouletteBet(tc.target, tc.double); mult != 0 { + t.Errorf("accepted %q (double=%v)", tc.target, tc.double) + } + } + for _, double := range []bool{false, true} { + for c := 0; c < 11; c++ { + for r := 1; r <= 2; r++ { + n := 3*c + r + target := fmt.Sprintf("corner %d %d %d %d", n, n+1, n+3, n+4) + if _, mult := rouletteBet(target, double); mult != 9 { + t.Errorf("rejected %s", target) + } + } + } + for _, target := range []string{"split 0 1", "split 0 2", "trio 0 1 2"} { + if _, mult := rouletteBet(target, double); mult == 0 { + t.Errorf("rejected %s", target) + } + } + } + for _, target := range []string{"split 00 2", "split 00 3", "trio 00 2 3", "trio 0 00 2"} { + if _, mult := rouletteBet(target, true); mult == 0 { + t.Errorf("rejected %s", target) + } + } +} + +// Sum the return over every equally likely outcome. Ordinary bets return +// 36 stakes per wheel; the American five-number basket returns only 35. +func TestRouletteWheelAndExpectedReturns(t *testing.T) { + for _, double := range []bool{false, true} { + wheel := WheelOrder(double) + want := 37 + if double { + want = 38 + } + seen := map[string]bool{} + for _, p := range wheel { + if _, valid := roulettePocketToken(p, double); !valid || seen[p] { + t.Fatalf("invalid wheel pocket %s", p) + } + seen[p] = true + } + if len(seen) != want { + t.Fatalf("wheel has %d pockets, want %d", len(seen), want) + } + targets := append(WheelOrder(double), "red", "black", "odd", "even", "low", "high", "1to12", "13to24", "25to36", "row1", "row2", "row3", "split 1 2", "street 1 2 3", "corner 1 2 4 5", "line 1 2 3 4 5 6", "trio 0 1 2", "basket") + for _, target := range targets { + wager, ok := rouletteWagerFor(target, double, false) + if !ok { + t.Fatalf("invalid target %s", target) + } + sum := 0 + for _, p := range wheel { + share, _ := wager.covers(p) + payout, err := share.payout(1) + if err != nil { + t.Fatal(err) + } + sum += payout + } + wantReturn := 36 + if double && target == "basket" { + wantReturn = 35 + } + if sum != wantReturn { + t.Errorf("%s: total return %d, want %d", target, sum, wantReturn) + } + } + } +} + +func TestRouletteSharePayoutCap(t *testing.T) { + shares := []rouletteShare{{36, 1}, {7, 1}, {36, 9}} + for _, bet := range rouletteCallBets { + for _, share := range bet.wager().shares { + shares = append(shares, share) + } + } + for _, share := range shares { + limit := ((MaxCredits+1)*share.den - 1) / share.num + got, err := share.payout(limit) + if err != nil || got > MaxCredits { + t.Fatalf("%+v at limit: %d, %v", share, got, err) + } + if _, err := share.payout(limit + 1); err == nil { + t.Fatalf("%+v accepted overflow", share) + } + } +} diff --git a/internal/casino/roulette_racetrack.go b/internal/casino/roulette_racetrack.go index 624940b..9784d9a 100644 --- a/internal/casino/roulette_racetrack.go +++ b/internal/casino/roulette_racetrack.go @@ -23,7 +23,7 @@ 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 { + if limit := ((MaxCredits+1)*s.den - 1) / 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 @@ -96,8 +96,8 @@ func RacetrackBets() []RacetrackBetView { // 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. +// Jeu Zero (4). Every inside component returns 36/covered, so the combined +// wager keeps the wheel's normal house edge before whole-credit rounding. var rouletteCallBets = []rouletteCallBet{ { canonical: "voisins", @@ -142,8 +142,8 @@ var rouletteCallBets = []rouletteCallBet{ }, }, { - canonical: "zerospiel", - name: "Zero Spiel", + canonical: "jeuzero", + name: "Jeu Zero", chips: 4, series: []string{"12", "35", "3", "26", "0", "32", "15"}, parts: []rouletteComponent{ @@ -171,11 +171,9 @@ var rouletteCallAliases = map[string]string{ "orphelins": "orphelins", "orphans": "orphelins", "les orphelins": "orphelins", - "zero spiel": "zerospiel", - "zerospiel": "zerospiel", - "spiel": "zerospiel", - "jeu zero": "zerospiel", - "zero game": "zerospiel", + "zero": "jeuzero", + "jeu zero": "jeuzero", + "jeuzero": "jeuzero", } // neighborsRadius is how many pockets on each side of the chosen number the diff --git a/internal/casino/roulette_racetrack_test.go b/internal/casino/roulette_racetrack_test.go index 7c465b8..29e2809 100644 --- a/internal/casino/roulette_racetrack_test.go +++ b/internal/casino/roulette_racetrack_test.go @@ -33,6 +33,9 @@ func TestRouletteCallBetMath(t *testing.T) { chips := 0 for _, part := range bet.parts { chips += part.chips + if _, mult := rouletteBet(strings.Join(part.pockets, " "), false); mult != part.mult { + t.Fatalf("%s: invalid component %v or multiplier %d (want %d)", bet.name, part.pockets, part.mult, mult) + } } if chips != bet.chips { t.Fatalf("%s: parts total %d chips, want %d", bet.name, chips, bet.chips) @@ -68,7 +71,7 @@ func TestRouletteCallBetAliases(t *testing.T) { {[]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"}, + {[]string{"zero", "Zero", "jeu zero", "Jeu Zero", "jeuzero", "JeuZero", "jeu zéro"}, "jeuzero"}, } { var want rouletteWager for _, bet := range rouletteCallBets { @@ -152,13 +155,13 @@ func TestRouletteCallBetPayouts(t *testing.T) { 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) + // Jeu Zero: 26 is the straight-up. + jeuZero := wager("jeu zero") + if got := payout(jeuZero, "26", 4); got != 36 { + t.Fatalf("jeu zero 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) + if got := payout(jeuZero, "0", 4); got != 18 { + t.Fatalf("jeu zero 4 on 0 = %d, want 18", got) } // Tiers is a plain six-way split. tiers := wager("tiers") @@ -263,3 +266,20 @@ func TestRouletteRacetrackConfig(t *testing.T) { t.Fatalf("double-zero racetrack accepted: %v", err) } } + +func TestRouletteZeroNameIsNotPocketZero(t *testing.T) { + call, ok := rouletteWagerFor("Zero", false, true) + if !ok || len(call.shares) != 7 { + t.Fatalf("Zero did not resolve to Jeu Zero: %+v", call) + } + straight, ok := rouletteWagerFor("0", false, true) + if !ok || len(straight.shares) != 1 { + t.Fatalf("0 did not resolve to a straight bet: %+v", straight) + } + if got, err := straight.shares["0"].payout(10); err != nil || got != 360 { + t.Fatalf("straight zero return %d, %v", got, err) + } + if got, err := call.shares["0"].payout(10); err != nil || got != 45 { + t.Fatalf("Jeu Zero return %d, %v", got, err) + } +} diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go index 187bb6d..002d9f0 100644 --- a/internal/casino/table_ux.go +++ b/internal/casino/table_ux.go @@ -16,9 +16,9 @@ func (c TableConfig) RulesText() string { 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" + "bet 10 on line 1 2 3 4 5 6 | bet 10 on trio 0 1 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" + rules += "Racetrack (single zero): bet 10 on voisins | tiers | orphelins | jeu zero | neighbors 17.\n" } return rules + "Zero pockets lose all outside bets. One wager per spin. Use help roulette." default: diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go index de62797..6e146d0 100644 --- a/internal/game/casino_ux.go +++ b/internal/game/casino_ux.go @@ -103,28 +103,47 @@ func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) { } t, playing := g.Casino.Participation(sess.Player.Name) if !playing || t == nil { - g.showCasinoChoices(sess, g.casinoChoices(sess.Player.RoomID)) + if len(args) > 0 && args[0] == "help" { + topic := "casino" + if playing { + topic = "slots" + } + g.doHelp(sess, topic) + } else { + g.showCasinoChoices(sess, g.casinoChoices(sess.Player.RoomID)) + } return } if len(args) > 0 && args[0] == "rules" { sess.WriteLine(g.renderCasinoRules(sess, t.Config)) return } - g.emitCasinoEvents(t.Status(sess.Player.Name)) - if t.Config.Game == casino.GameRoulette { - sess.WriteLine(g.rouletteReminder(sess, t.Config)) + if len(args) > 0 && args[0] == "help" { + g.doHelp(sess, string(t.Config.Game)) + } else { + g.emitCasinoEvents(t.Status(sess.Player.Name)) + if t.Config.Game == casino.GameRoulette { + sess.WriteLine(g.rouletteReminder(sess, t.Config)) + } else { + sess.WriteLine(g.colorize(sess, "command", "Learn to play: table help / help casino. Exact payouts: table rules.")) + } } if len(args) > 0 { - sess.WriteLine(g.renderCasinoCommands(sess, "", "Table controls", []casino.MenuCommand{ - {Command: "bet / rebet", Desc: "Repeat the displayed wager"}, + commands := []casino.MenuCommand{ + {Command: "bet / rebet", Desc: "Repeat your previous wager"}, {Command: "bet min / bet max", Desc: "Use the table minimum / affordable maximum"}, - {Command: "h / st / dd / sp", Desc: "Blackjack: hit / stand / double / split (s and d still move)"}, - {Command: "insurance / decline", Desc: "Answer the insurance offer explicitly"}, {Command: "sitout", Desc: "Skip future rounds; bet to rejoin"}, {Command: "stop", Desc: "Leave now, or after your committed round"}, {Command: "option casino_display compact", Desc: "Compact action hints (detailed for full command menus)"}, {Command: "table rules", Desc: "Review this table’s rules"}, - })) + {Command: "help casino", Desc: "Quick guides to all casino games"}, + } + if t.Config.Game == casino.GameBlackjack { + commands = append(commands, + casino.MenuCommand{Command: "h / st / dd / sp", Desc: "Hit / stand / double / split (s and d still move)"}, + casino.MenuCommand{Command: "insurance / decline", Desc: "Answer the insurance offer explicitly"}) + } + sess.WriteLine(g.renderCasinoCommands(sess, "", "Table controls", commands)) } } diff --git a/internal/game/render_casino_help.go b/internal/game/render_casino_help.go new file mode 100644 index 0000000..5a76ef0 --- /dev/null +++ b/internal/game/render_casino_help.go @@ -0,0 +1,85 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/ui" +) + +// casinoBetTable uses the shared ANSI-aware table renderer, rather than +// letting the session wrap a long, pipe-separated payout paragraph. +func (g *Game) casinoBetTable(sess *net.Session, title string, columns []string, rows [][]string) string { + t := ui.Table{Title: g.colorize(sess, "casino_action", title)} + for _, col := range columns { + t.Columns = append(t.Columns, g.colorize(sess, "casino_action", col)) + } + for _, row := range rows { + cells := append([]string(nil), row...) + for i, cell := range cells { + category := "casino_action" + if i == 0 { + category = "command" + } + if i == len(cells)-1 { + category = "casino_payout" + } + cells[i] = g.colorize(sess, category, cell) + } + t.Rows = append(t.Rows, cells) + } + unicode, width := true, 78 + if p := sess.Player; p != nil { + unicode = p.OptionBool("unicode") + if wrap := p.OptionInt("wrap_width"); wrap > 0 { + width = min(width, wrap) + } + } + return strings.Join(t.Render(unicode, width), "\n") +} + +// rouletteReminder is shown by table/table rules, not on ambient boards. +// Hit chances count equally likely pockets; all ratios are NET profit odds. +func (g *Game) rouletteReminder(sess *net.Session, cfg casino.TableConfig) string { + pockets, basketCount, basketOdds := 37, 4, "8:1" + if cfg.Roulette.DoubleZero { + pockets, basketCount, basketOdds = 38, 5, "6:1" + } + chance := func(n int) string { return fmt.Sprintf("%d/%d", n, pockets) } + rows := [][]string{ + {"Number", "17 (or 0)", chance(1), "35:1"}, + {"Split", "split 1 2", chance(2), "17:1"}, + {"Street", "street 1 2 3", chance(3), "11:1"}, + {"Trio", "trio 0 1 2", chance(3), "11:1"}, + {"Corner", "corner 1 2 4 5", chance(4), "8:1"}, + {"Six line", "line 1 2 3 4 5 6", chance(6), "5:1"}, + {"Dozen", "1to12/13to24/25to36", chance(12), "2:1"}, + {"Row", "row1/row2/row3", chance(12), "2:1"}, + {"Color", "red / black", chance(18), "1:1"}, + {"Parity", "odd / even", chance(18), "1:1"}, + {"Low / high", "low / high", chance(18), "1:1"}, + {"Basket", "basket", chance(basketCount), basketOdds}, + } + if cfg.Roulette.DoubleZero { + rows[0][1] = "17 (or 0, 00)" + } + blocks := []string{g.casinoBetTable(sess, "Roulette payouts", []string{"Bet", "Target / example", "Chance", "Profit"}, rows)} + if cfg.Roulette.Racetrack && !cfg.Roulette.DoubleZero { + blocks = append(blocks, g.casinoBetTable(sess, "Racetrack payouts", []string{"Target", "Chance", "Profit on total stake"}, [][]string{ + {"voisins", chance(17), "1:1; 0/2/3 pay 5:3"}, + {"tiers", chance(12), "2:1"}, + {"orphelins", chance(8), "13:5; 1/17 pay 31:5"}, + {"zero / jeu zero", chance(7), "7:2; 26 pays 8:1"}, + {"neighbors <n>", chance(9), "3:1"}, + })) + blocks = append(blocks, "Call bets split your total stake; fractional returns round down.\nUse help racetrack for the chip placements.") + } + blocks = append(blocks, + "Profit excludes your returned stake: 1:1 on 10 returns 20.\n"+ + "Low: 1-18; high: 19-36. Zero pockets lose outside bets.\n"+ + "Use bet <amount> on <target>, e.g. bet 10 on split 1 2.\n"+ + "Learn more: help roulette / help casino; table rules for this table.") + return strings.Join(blocks, "\n") +} diff --git a/internal/game/render_casino_help_test.go b/internal/game/render_casino_help_test.go new file mode 100644 index 0000000..246880f --- /dev/null +++ b/internal/game/render_casino_help_test.go @@ -0,0 +1,122 @@ +package game + +import ( + "strings" + "testing" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func TestRoulettePayoutTables(t *testing.T) { + for _, mode := range []string{"none", "ansi", "xterm256"} { + for _, unicode := range []bool{false, true} { + for _, double := range []bool{false, true} { + for _, width := range []int{60, 78} { + g := &Game{} + p := player.New("alice") + p.Options = map[string]any{"color": mode, "unicode": unicode, "wrap_width": width} + sess := &net.Session{Player: p} + cfg := casino.TableConfig{Game: casino.GameRoulette, Roulette: casino.RouletteConfig{DoubleZero: double, Racetrack: !double}} + art := g.rouletteReminder(sess, cfg) + plain := stripANSI(art) + for _, want := range []string{"Roulette payouts", "Target / example", "Chance", "Profit", "35:1", "17:1", "11:1", "8:1", "5:1", "2:1", "1:1", "help roulette"} { + if !strings.Contains(plain, want) { + t.Fatalf("missing %q:\n%s", want, plain) + } + } + if double { + for _, want := range []string{"1/38", "5/38", "6:1", "0, 00"} { + if !strings.Contains(plain, want) { + t.Fatalf("missing %q", want) + } + } + if strings.Contains(plain, "Racetrack payouts") { + t.Fatal("American wheel has call bets") + } + } else { + for _, want := range []string{"1/37", "4/37", "17/37", "12/37", "8/37", "7/37", "9/37", "Racetrack payouts", "5:3", "13:5", "31:5", "7:2", "zero / jeu zero"} { + if !strings.Contains(plain, want) { + t.Fatalf("missing %q:\n%s", want, plain) + } + } + } + if (mode == "none") == strings.Contains(art, "\x1b[") { + t.Fatalf("wrong color mode %s", mode) + } + tableWidth := 0 + for _, line := range strings.Split(art, "\n") { + plainLine := stripANSI(line) + if plainLine != "" && strings.ContainsRune("╔╟╚+", []rune(plainLine)[0]) { + tableWidth = color.VisibleLen(line) + } + if plainLine != "" && strings.ContainsRune("╔║╟╚+|", []rune(plainLine)[0]) { + if got := color.VisibleLen(line); got != tableWidth || got > width { + t.Errorf("uneven table width %d (want %d, max %d): %q", got, tableWidth, width, plainLine) + } + } + if !unicode { + for _, r := range plainLine { + if r > 127 { + t.Errorf("non-ASCII %q", r) + } + } + } + } + } + } + } + } +} + +func TestCasinoHelpTopics(t *testing.T) { + g := &Game{Deps: Deps{DataDir: "../../data"}} + conn := &casinoTestConn{} + sess := &net.Session{Player: player.New("alice"), Conn: conn} + for topic, want := range map[string]string{ + "casino": "Profit odds", "play": "Casino quick guide", + "blackjack": "3:2", "baccarat": "0.95:1", "slots": "243", + "roulette": "35:1", "racetrack": "17/37", "zero": "Jeu Zero", + } { + conn.data = nil + g.doHelp(sess, topic) + if !strings.Contains(string(conn.data), want) { + t.Errorf("help %s: %s", topic, conn.data) + } + } +} + +func TestTableHelpTeachesCurrentGame(t *testing.T) { + for _, game := range []casino.GameName{casino.GameBlackjack, casino.GameBaccarat, casino.GameRoulette} { + g, sess, conn := uxCasinoGame(t) + g.DataDir = "../../data" + g.Casino.Join(1, casino.TableConfig{ID: "test", Game: game}, "alice") + conn.data = nil + g.executeTableStatus(sess, []string{"help"}, "table help") + text := stripANSI(string(conn.data)) + if strings.Contains(text, "No help") || !strings.Contains(text, "Table controls") || !strings.Contains(text, "pays") && !strings.Contains(text, "35:1") { + t.Fatalf("help %s: %s", game, text) + } + if game != casino.GameBlackjack && strings.Contains(text, "insurance / decline") { + t.Fatalf("blackjack-only controls shown for %s", game) + } + } + g, sess, conn := uxCasinoGame(t) + g.DataDir = "../../data" + g.executeTableStatus(sess, []string{"help"}, "table help") + if !strings.Contains(string(conn.data), "Casino quick guide") { + t.Fatal("unseated players cannot learn the games") + } +} + +func TestTableHelpAtSlotMachine(t *testing.T) { + g, sess, conn := uxCasinoGame(t) + g.DataDir = "../../data" + g.Casino.JoinMachine(1, casino.MachineConfig{ID: "slots", Game: casino.GameSlots}, "alice") + g.executeTableStatus(sess, []string{"help"}, "table help") + if !strings.Contains(string(conn.data), "Slots quick guide") { + t.Fatal("machine player did not get the slots guide") + } +} diff --git a/internal/game/render_help.go b/internal/game/render_help.go index b41af99..6066e9a 100644 --- a/internal/game/render_help.go +++ b/internal/game/render_help.go @@ -182,6 +182,7 @@ func (g *Game) doHelp(sess *net.Session, topic string) { sess.WriteLine("Active (replaces current action, queued per tick).") sess.WriteLine("") sess.WriteLine("Use 'help <command>' for detailed usage of a specific command.") + sess.WriteLine("Use 'help casino' for quick guides to casino bets, odds and payouts.") sess.WriteLine("Use 'option' to view and change settings, 'color' to customize colors.") return } diff --git a/internal/game/render_roulette.go b/internal/game/render_roulette.go index 159fadc..f6c6bb7 100644 --- a/internal/game/render_roulette.go +++ b/internal/game/render_roulette.go @@ -76,33 +76,111 @@ 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. +// renderRouletteRacetrack draws the European wheel clockwise with 26 at +// the right-hand tip. Interior boundaries meet the pockets covered by each +// call bet; the bowed Zero boundary denotes its overlap with Voisins. func renderRouletteRacetrack(mode string, unicode bool) string { b := rouletteBorders(unicode) - tl, tr, bl, br := b.tl, b.tr, b.bl, b.br + tl, tr, bl, 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) + const width, height = 73, 11 + grid := make([][]rouletteCell, height) + for y := range grid { + grid[y] = make([]rouletteCell, width) + for x := range grid[y] { + grid[y][x] = rouletteCell{' ', -1} + } + } + put := func(x, y int, text string, bg int) { + for _, r := range text { + grid[y][x] = rouletteCell{r, bg} + x++ + } + } + ink := func(x, y int, text string) { put(x, y, text, -1) } + wheel := casino.WheelOrder(false) + pocket := func(x, y, index, width int) { + n := wheel[index%len(wheel)] + bg, name := roulettePocketColor(n) + if mode == "none" || mode == "" { + n += name[:1] + } + put(x, y, rouletteCenter(n, width), bg) + } + // Equal-width bands, but slightly wider Orphelins cells below: five + // pockets (1,20,14,31,9) above face three (6,34,17) below. + band := func(y, start, step int, widths []int) { + x := 4 + ink(x, y, b.v) + for i, w := range widths { + ink(x, y-1, b.teeTop+strings.Repeat(b.h, w-1)) + ink(x, y+1, b.teeBottom+strings.Repeat(b.h, w-1)) + pocket(x+1, y, start+i*step, w-1) + x += w + ink(x, y, b.v) } - lines = append(lines, rouletteInk(mode, b.v+row+b.v)) } - lines = append(lines, rouletteInk(mode, bl+strings.Repeat(b.h, width)+br)) + band(1, 19, 1, []int{4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4}) + band(9, 15, -1, []int{4, 4, 4, 4, 4, 5, 5, 6, 4, 4, 4, 4, 4, 4, 4}) + ink(4, 0, tl) + ink(68, 0, tr) + ink(4, 10, bl) + ink(68, 10, br) + // Connected, stepped curves join the long bands to three end pockets. + ink(1, 1, tl+strings.Repeat(b.h, 2)+b.teeRight) + ink(68, 1, b.teeLeft+strings.Repeat(b.h, 2)+tr) + ink(0, 2, tl+b.teeBottom+strings.Repeat(b.h, 2)+b.cross) + ink(68, 2, b.cross+strings.Repeat(b.h, 2)+b.teeBottom+tr) + ink(0, 8, bl+b.teeTop+strings.Repeat(b.h, 2)+b.cross) + ink(68, 8, b.cross+strings.Repeat(b.h, 2)+b.teeTop+br) + ink(1, 9, bl+strings.Repeat(b.h, 2)+b.teeRight) + ink(68, 9, b.teeLeft+strings.Repeat(b.h, 2)+br) + for i := 0; i < 3; i++ { + y := 3 + 2*i + for _, x := range []int{0, 4, 68, 72} { + ink(x, y, b.v) + } + pocket(1, y, 18-i, 3) // 10,23,8 + pocket(69, y, 35+i, 3) // 3,26,0 + if i < 2 { + ink(0, y+1, b.teeLeft+strings.Repeat(b.h, 3)+b.teeRight) + ink(68, y+1, b.teeLeft+strings.Repeat(b.h, 3)+b.teeRight) + } + } + // Connected steps meet 33/1 above and 27/6 below. Diagonal glyphs + // only touch at cell corners and leave visible gaps in many fonts. + ink(20, 3, b.v) + ink(20, 4, b.bl+b.h+b.tr) + ink(22, 5, b.v) + ink(22, 6, b.bl+b.h+b.tr) + ink(24, 7, b.v) + for y := 3; y <= 7; y++ { + ink(40, y, b.v) + } + for _, p := range [][2]int{{20, 2}, {24, 8}, {40, 2}, {40, 8}, {60, 2}, {60, 8}} { + ink(p[0], p[1], b.cross) + } + // Zero includes 12,35,3,26,0,32,15, entirely within Voisins. + ink(59, 3, tl+br) + ink(58, 4, tl+br) + ink(58, 5, b.v) + ink(58, 6, bl+tr) + ink(59, 7, bl+tr) + for _, label := range []struct { + left, right int + text string + }{ + {4, 22, "Tiers du Cylindre"}, {22, 40, "Orphelins"}, + {40, 58, "Voisins du Zero"}, {58, 68, "Jeu Zero"}, + } { + ink(label.left+1, 5, rouletteCenter(label.text, label.right-label.left-1)) + } + lines := make([]string, height) + for y, row := range grid { + lines[y] = renderRouletteRow(row, mode) + } return strings.Join(lines, "\n") } @@ -157,9 +235,9 @@ func renderRouletteLayout(double, racetrack bool, mode string, unicode bool) str zero := "" switch { case double && row == 3: - zero = "0" - case double && row == 1: zero = "00" + case double && row == 1: + zero = "0" case !double && row == 2: zero = "0" } @@ -464,20 +542,6 @@ func renderRouletteResult(pockets [3]string, mode string, unicode bool, size int return strings.Join(lines, "\n") } -// rouletteReminder is the odds/cheat-sheet block, shown only by "table" (and -// "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" - 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) -} - func (g *Game) renderCasinoRules(sess *net.Session, cfg casino.TableConfig) string { rules := g.colorize(sess, "casino_action", cfg.RulesText()) if cfg.Game == casino.GameRoulette { diff --git a/internal/game/render_roulette_test.go b/internal/game/render_roulette_test.go index d992abf..8d9c141 100644 --- a/internal/game/render_roulette_test.go +++ b/internal/game/render_roulette_test.go @@ -68,7 +68,7 @@ func TestRouletteArtModes(t *testing.T) { layout := plainRoulette(layoutArt) rows := strings.Split(layout, "\n") if double { - if !strings.Contains(string([]rune(rows[1])[:7]), "0") || !strings.Contains(string([]rune(rows[5])[:7]), "00") { + if !strings.Contains(string([]rune(rows[1])[:7]), "00") || !strings.Contains(string([]rune(rows[5])[:7]), "0") { t.Fatal("double zero not left of grid", layout) } } else if !strings.Contains(string([]rune(rows[3])[:7]), "0") { @@ -227,14 +227,14 @@ 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 +// panel above the board, names the four call-bet areas (not Neighbors), 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)"} { + for _, want := range []string{"Tiers du Cylindre", "Orphelins", "Voisins du Zero", "Zero"} { if !strings.Contains(plain, want) { t.Fatalf("mode=%s unicode=%v: missing %q:\n%s", mode, unicode, want, plain) } @@ -255,11 +255,11 @@ func TestRouletteRacetrackPanel(t *testing.T) { } // 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") { + if !strings.Contains(plainRoulette(renderRouletteSnapshot(snap, "none", true, 7)), "Voisins") { t.Fatal("racetrack snapshot did not draw the panel") } snap.Racetrack = false - if strings.Contains(plainRoulette(renderRouletteSnapshot(snap, "none", true, 7)), "Voisins du Zero") { + if strings.Contains(plainRoulette(renderRouletteSnapshot(snap, "none", true, 7)), "Voisins") { t.Fatal("plain snapshot drew the racetrack panel") } } @@ -404,3 +404,136 @@ func TestRouletteSettlementShowsBalanceOnce(t *testing.T) { } } } + +// Read the perimeter clockwise, not in terminal row order. Every displayed +// pocket must match the actual European wheel, including the wraparound. +func TestRouletteRacetrackPhysicalOrder(t *testing.T) { + for _, unicode := range []bool{false, true} { + art := renderRouletteRacetrack("none", unicode) + rows := strings.Split(art, "\n") + top, bottom := racetrackNumbers(rows[1]), racetrackNumbers(rows[9]) + if len(top) != 16 || len(bottom) != 15 { + t.Fatalf("missing track pockets:\n%s", art) + } + order := append([]string(nil), top...) + for _, i := range []int{3, 5, 7} { + side := racetrackNumbers(rows[i]) + if len(side) != 2 { + t.Fatalf("missing curved-end pockets:\n%s", art) + } + order = append(order, side[1]) + } + for i := len(bottom) - 1; i >= 0; i-- { + order = append(order, bottom[i]) + } + for _, i := range []int{7, 5, 3} { + order = append(order, racetrackNumbers(rows[i])[0]) + } + wheel := casino.WheelOrder(false) + if len(order) != len(wheel) { + t.Fatalf("got %d pockets", len(order)) + } + for i, p := range order { + if p != wheel[(19+i)%len(wheel)] { + t.Errorf("pocket %d: %s", i, p) + } + } + if got := racetrackNumbers(rows[5]); got[1] != "26" { + t.Fatalf("right tip is %s, want 26", got[1]) + } + } +} + +func racetrackNumbers(row string) []string { + var out []string + for _, match := range regexp.MustCompile(`([0-9]+)[RBG]`).FindAllStringSubmatch(row, -1) { + out = append(out, match[1]) + } + return out +} + +// The sections must touch their actual bet coverage, not merely contain +// labels. Zero is a nested part of Voisins, not a fourth disjoint sector. +func TestRouletteRacetrackSections(t *testing.T) { + for _, unicode := range []bool{false, true} { + rows := strings.Split(renderRouletteRacetrack("none", unicode), "\n") + segment := func(y, left, right int) string { return string([]rune(rows[y])[left:right]) } + nums := func(y, left, right int) []string { return racetrackNumbers(segment(y, left, right)) } + sections := map[string][]string{ + "tiers": append(nums(1, 4, 20), nums(9, 4, 24)...), + "orphelins": append(nums(1, 20, 40), nums(9, 24, 40)...), + "voisins": append(nums(1, 40, 60), nums(9, 40, 60)...), + "jeuzero": append(nums(1, 60, 68), nums(9, 60, 68)...), + } + for _, y := range []int{3, 5, 7} { + sections["tiers"] = append(sections["tiers"], nums(y, 0, 4)...) + sections["jeuzero"] = append(sections["jeuzero"], nums(y, 68, 73)...) + } + sections["voisins"] = append(sections["voisins"], sections["jeuzero"]...) + for _, bet := range casino.RacetrackBets() { + got := sections[bet.Syntax] + if len(got) != len(bet.Series) { + t.Fatalf("%s: coverage %v, want %v", bet.Syntax, got, bet.Series) + } + seen := map[string]bool{} + for _, n := range got { + seen[n] = true + } + for _, n := range bet.Series { + if !seen[n] { + t.Errorf("%s does not touch %s", bet.Syntax, n) + } + } + } + for _, label := range []struct { + left, right int + text string + }{ + {4, 22, "Tiers du Cylindre"}, {22, 40, "Orphelins"}, {40, 58, "Voisins du Zero"}, {58, 68, "Jeu Zero"}, + } { + if strings.TrimSpace(segment(5, label.left+1, label.right)) != label.text { + t.Errorf("misplaced %s label", label.text) + } + } + // Every step joins orthogonally; no font-dependent diagonal gaps. + b := rouletteBorders(unicode) + for _, step := range []struct { + x, y int + text string + }{ + {20, 3, b.v}, {20, 4, b.bl + b.h + b.tr}, {22, 5, b.v}, + {22, 6, b.bl + b.h + b.tr}, {24, 7, b.v}, + } { + if got := segment(step.y, step.x, step.x+len([]rune(step.text))); got != step.text { + t.Errorf("disconnected step at %d,%d: %q", step.x, step.y, got) + } + } + // The Zero boundary bows into Voisins then returns to the same column. + for _, p := range [][2]int{{60, 2}, {59, 3}, {58, 4}, {58, 5}, {58, 6}, {59, 7}, {60, 8}} { + if []rune(rows[p[1]])[p[0]] == ' ' { + t.Errorf("gap in Zero curve at %v", p) + } + } + } + for _, mode := range []string{"none", "ansi", "xterm256"} { + for _, unicode := range []bool{false, true} { + art := renderRouletteRacetrack(mode, unicode) + if strings.Contains(strings.ToLower(art), "neighbor") { + t.Fatal("Neighbors belongs in help, not on the racetrack") + } + rows := strings.Split(art, "\n") + for y, row := range rows { + wantWidth := 73 + if y == 0 || y == 10 { + wantWidth = 69 + } + if y == 1 || y == 9 { + wantWidth = 72 + } + if color.VisibleLen(row) != wantWidth { + t.Errorf("mode=%s unicode=%v: row %d width=%d, want %d", mode, unicode, y, color.VisibleLen(row), wantWidth) + } + } + } + } +} diff --git a/internal/game/roulette_test.go b/internal/game/roulette_test.go index 333e6a9..e581435 100644 --- a/internal/game/roulette_test.go +++ b/internal/game/roulette_test.go @@ -108,7 +108,10 @@ func TestRouletteRacetrackTargetParsing(t *testing.T) { {"10", "on", "voisins", "du", "zero"}, {"10", "on", "tiers"}, {"10", "on", "orphelins"}, - {"10", "on", "zero", "spiel"}, + {"10", "on", "zero"}, + {"10", "on", "jeu", "zero"}, + {"10", "on", "jeuzero"}, + {"10", "on", "0"}, {"10", "on", "neighbors", "17"}, {"10", "on", "neighbors", "of", "17"}, } |
