diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/casino/roulette.go | 376 | ||||
| -rw-r--r-- | internal/casino/roulette_test.go | 202 | ||||
| -rw-r--r-- | internal/casino/table_ux.go | 15 | ||||
| -rw-r--r-- | internal/game/casino.go | 17 | ||||
| -rw-r--r-- | internal/game/look_entities.go | 4 | ||||
| -rw-r--r-- | internal/game/render_roulette.go | 25 | ||||
| -rw-r--r-- | internal/game/render_roulette_test.go | 33 | ||||
| -rw-r--r-- | internal/game/roulette_test.go | 52 |
8 files changed, 697 insertions, 27 deletions
diff --git a/internal/casino/roulette.go b/internal/casino/roulette.go index 5784ac9..cae9d0d 100644 --- a/internal/casino/roulette.go +++ b/internal/casino/roulette.go @@ -56,9 +56,24 @@ func normalizeRouletteSpot(spot string) string { } // Returns the covered pockets and total-return multiplier (stake included). -// Zero never belongs to an outside bet. +// Zero never belongs to an outside bet. Combination bets (splits, streets, +// corners, six-lines, trios and the basket) are parsed from a compact number +// list: the leading keyword is optional because the covered set uniquely +// identifies the bet. func rouletteBet(spot string, double bool) ([]string, int) { spot = normalizeRouletteSpot(spot) + if pockets, mult := rouletteStraightBet(spot, double); mult > 0 { + return pockets, mult + } + if pockets, mult := rouletteOutsideBet(spot); mult > 0 { + return pockets, mult + } + return rouletteCombinationBet(spot, double) +} + +// rouletteStraightBet covers a single pocket: any number, 0, or 00 (American +// tables only). It pays 35:1, a 36x total return. +func rouletteStraightBet(spot string, double bool) ([]string, int) { if spot == "00" { if double { return []string{spot}, 36 @@ -68,6 +83,12 @@ func rouletteBet(spot string, double bool) ([]string, int) { if n, err := strconv.Atoi(spot); err == nil && n >= 0 && n <= 36 && strconv.Itoa(n) == spot { return []string{spot}, 36 } + return nil, 0 +} + +// rouletteOutsideBet covers the even-money, dozen, and row wagers, whose +// pocket counts all divide 36 exactly, so the return is 36/len. +func rouletteOutsideBet(spot string) ([]string, int) { var pockets []string for n := 1; n <= 36; n++ { hit := false @@ -107,6 +128,359 @@ func rouletteBet(spot string, double bool) ([]string, int) { return pockets, 36 / len(pockets) } +// rouletteComboKeywords maps the accepted names for a combination bet onto +// its canonical kind. Several names share a kind ("square" is a corner, +// "sixline"/"double street" is a line). +var rouletteComboKeywords = map[string]string{ + "split": "split", + "street": "street", + "corner": "corner", + "square": "corner", + "line": "line", + "sixline": "line", + "six-line": "line", + "doublestreet": "line", + "double-street": "line", + "trio": "trio", + "basket": "basket", +} + +// Payout returns are 36/covered for every combination except the five-number +// American basket, which pays a deliberately short 6:1 (7x total). +func rouletteCombinationBet(spot string, double bool) ([]string, int) { + kind, nums, ok := parseRouletteCombination(spot, double) + if !ok { + return nil, 0 + } + if kind == "" { + kind = inferRouletteCombination(nums) + } + switch kind { + case "split": + if len(nums) == 2 && rouletteAdjacent(nums[0], nums[1], double) { + return nums, 18 + } + case "street": + if rouletteIsStreet(nums) { + return nums, 12 + } + case "corner": + if rouletteIsCorner(nums, double) { + return nums, 9 + } + case "line": + if rouletteIsLine(nums) { + return nums, 6 + } + case "trio": + if rouletteIsTrio(nums, double) { + return nums, 12 + } + case "basket": + if pockets, mult, valid := rouletteBasket(nums, double); valid { + return pockets, mult + } + } + return nil, 0 +} + +// parseRouletteCombination strips an optional leading keyword and converts the +// remaining number tokens (separated by spaces, dashes, slashes, commas or +// backslashes) into canonical pocket names. Duplicate numbers are rejected. +func parseRouletteCombination(spot string, double bool) (string, []string, bool) { + fields := strings.Fields(strings.ToLower(spot)) + if len(fields) == 0 { + return "", nil, false + } + kind := "" + switch fields[0] { + case "split", "street", "corner", "square", "line", "sixline", "six-line", "doublestreet", "double-street", "trio", "basket": + kind = fields[0] + fields = fields[1:] + case "six": + if len(fields) > 1 && fields[1] == "line" { + kind, fields = "sixline", fields[2:] + } + case "double": + if len(fields) > 1 && fields[1] == "street" { + kind, fields = "doublestreet", fields[2:] + } + } + if canonical, ok := rouletteComboKeywords[kind]; ok { + kind = canonical + } else if kind != "" { + return "", nil, false + } + if kind == "basket" && len(fields) == 0 { + return "basket", nil, true + } + if len(fields) == 0 { + return "", nil, false + } + raw := strings.NewReplacer("-", " ", "/", " ", ",", " ", "\\", " ").Replace(strings.Join(fields, " ")) + seen := map[string]bool{} + var nums []string + for _, tok := range strings.Fields(raw) { + pocket, ok := roulettePocketToken(tok, double) + if !ok || seen[pocket] { + return "", nil, false + } + seen[pocket] = true + nums = append(nums, pocket) + } + if len(nums) == 0 { + return "", nil, false + } + return kind, nums, true +} + +// roulettePocketToken canonicalizes one number token: a plain 0-36 value or +// 00, rejecting non-canonical spellings such as "01", "+1" or "000". +func roulettePocketToken(tok string, double bool) (string, bool) { + if tok == "00" { + return "00", double + } + n, err := strconv.Atoi(tok) + if err != nil || n < 0 || n > 36 || strconv.Itoa(n) != tok { + return "", false + } + return strconv.Itoa(n), true +} + +// inferRouletteCombination identifies a bare number list by its size. Adjacency +// and geometry are validated after this, so an ambiguous count still fails. +func inferRouletteCombination(nums []string) string { + switch len(nums) { + case 2: + return "split" + case 3: + if rouletteIsStreet(nums) { + return "street" + } + return "trio" + case 4: + return "corner" + case 5: + return "basket" + case 6: + return "line" + } + return "" +} + +// rouletteNumberCell maps 1-36 onto its column and row on the felt: column +// 0-11 left to right, row 0-2 bottom to top (row 0 holds 1,4,7,...). +func rouletteNumberCell(n string) (col, row int, ok bool) { + v, err := strconv.Atoi(n) + if err != nil || v < 1 || v > 36 { + return 0, 0, false + } + return (v - 1) / 3, (v - 1) % 3, true +} + +// rouletteAdjacent reports whether two pockets share an edge: horizontally +// (same row, neighboring columns) or vertically (same column, neighboring +// rows). Zero and double zero use the felt's special zero edge. +func rouletteAdjacent(a, b string, double bool) bool { + if a == "0" || a == "00" || b == "0" || b == "00" { + return rouletteZeroAdjacent(a, b, double) + } + ac, ar, ok := rouletteNumberCell(a) + if !ok { + return false + } + bc, br, ok := rouletteNumberCell(b) + if !ok { + return false + } + dc, dr := ac-bc, ar-br + if dc < 0 { + dc = -dc + } + if dr < 0 { + dr = -dr + } + 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. +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 a == "0" || a == "00" { + return neighbors(b) + } + return neighbors(a) +} + +func rouletteIsStreet(nums []string) bool { + if len(nums) != 3 { + return false + } + set := map[int]bool{} + for _, s := range nums { + v, err := strconv.Atoi(s) + if err != nil { + return false + } + set[v] = true + } + if len(set) != 3 { + return false + } + for n := 1; n <= 34; n += 3 { + if set[n] && set[n+1] && set[n+2] { + return true + } + } + return false +} + +func rouletteIsCorner(nums []string, double bool) bool { + if len(nums) != 4 { + return false + } + // The European "first four" (0,1,2,3) is the only zero corner. + if !double && rouletteIsFirstFour(nums) { + return true + } + cells := map[[2]int]bool{} + for _, s := range nums { + c, r, ok := rouletteNumberCell(s) + if !ok { + return false + } + cells[[2]int{c, r}] = true + } + 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 +} + +func rouletteIsFirstFour(nums []string) bool { + if len(nums) != 4 { + return false + } + set := map[string]bool{} + for _, s := range nums { + set[s] = true + } + return set["0"] && set["1"] && set["2"] && set["3"] +} + +// A six-line is two neighboring columns, i.e. six numbers across all three +// rows of two adjacent streets. +func rouletteIsLine(nums []string) bool { + if len(nums) != 6 { + return false + } + cols := map[int]bool{} + set := map[int]bool{} + for _, s := range nums { + c, _, ok := rouletteNumberCell(s) + if !ok { + return false + } + v, _ := strconv.Atoi(s) + cols[c] = true + set[v] = true + } + if len(cols) != 2 || len(set) != 6 { + return false + } + var c1, c2 int + first := true + for c := range cols { + if first { + c1, first = c, false + } else { + c2 = c + } + } + if c1 > c2 { + c1, c2 = c2, c1 + } + 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. +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 + } + } + } + return hasZero +} + +// The basket is the American five-number 0/00/1/2/3 bet, which pays a short +// 6:1. On a single-zero felt the name covers the European first four instead. +// An empty list means the bare "basket" keyword. +func rouletteBasket(nums []string, double bool) ([]string, int, bool) { + if len(nums) == 0 { + if double { + return []string{"0", "00", "1", "2", "3"}, 7, true + } + return []string{"0", "1", "2", "3"}, 9, true + } + if double { + set := map[string]bool{} + for _, s := range nums { + set[s] = true + } + if len(nums) != 5 { + return nil, 0, false + } + for _, need := range []string{"0", "00", "1", "2", "3"} { + if !set[need] { + return nil, 0, false + } + } + return []string{"0", "00", "1", "2", "3"}, 7, true + } + if rouletteIsFirstFour(nums) { + return []string{"0", "1", "2", "3"}, 9, true + } + return nil, 0, false +} + type roulettePlayer struct { Participant lastSpot string diff --git a/internal/casino/roulette_test.go b/internal/casino/roulette_test.go index a20b17c..160ff37 100644 --- a/internal/casino/roulette_test.go +++ b/internal/casino/roulette_test.go @@ -1,6 +1,7 @@ package casino import ( + "fmt" "math/rand" "strconv" "strings" @@ -66,6 +67,207 @@ func TestRouletteRowBetMapping(t *testing.T) { } } +// TestRouletteCombinationBets covers splits, streets, corners, six-lines, +// trios and the basket, including the geometry that must be rejected and the +// bare-number-list inference that lets the leading keyword be omitted. +func TestRouletteCombinationBets(t *testing.T) { + same := func(got []string, want ...string) bool { + if len(got) != len(want) { + return false + } + set := map[string]bool{} + for _, g := range got { + set[g] = true + } + for _, w := range want { + if !set[w] { + return false + } + } + return true + } + cases := []struct { + spot string + double bool + mult int + want []string + }{ + // Splits cover edge-neighbours only, horizontally or vertically. + {"split 1 2", false, 18, []string{"1", "2"}}, + {"1-2", false, 18, []string{"1", "2"}}, + {"1/4", false, 18, []string{"1", "4"}}, + {"split 1 5", false, 0, nil}, + {"split 3 4", false, 0, nil}, + {"split 0 3", false, 18, []string{"0", "3"}}, + // Streets are one three-row column. + {"street 1 2 3", false, 12, []string{"1", "2", "3"}}, + {"1-2-3", false, 12, []string{"1", "2", "3"}}, + {"street 2 3 4", false, 0, nil}, + // Corners are a two-by-two block. + {"corner 1 2 4 5", false, 9, []string{"1", "2", "4", "5"}}, + {"1-2-4-5", false, 9, []string{"1", "2", "4", "5"}}, + {"corner 1 2 3 4", false, 0, nil}, + // Six-lines are two adjacent streets. + {"line 1 2 3 4 5 6", false, 6, []string{"1", "2", "3", "4", "5", "6"}}, + {"1-2-3-4-5-6", false, 6, []string{"1", "2", "3", "4", "5", "6"}}, + {"line 1 2 3 4 5 7", false, 0, nil}, + // Trios pair a zero with the first street. + {"trio 0 1 2", false, 12, []string{"0", "1", "2"}}, + {"0-1-2", false, 12, []string{"0", "1", "2"}}, + {"trio 0 00 2", true, 12, []string{"0", "00", "2"}}, + {"trio 0 00 2", false, 0, nil}, + // The American basket is five numbers at a deliberately short 6:1. + {"basket", true, 7, []string{"0", "00", "1", "2", "3"}}, + {"basket 0 00 1 2 3", true, 7, []string{"0", "00", "1", "2", "3"}}, + {"basket 0 00 1 2", true, 0, nil}, + // Single zero falls back to the European first four. + {"basket", false, 9, []string{"0", "1", "2", "3"}}, + {"corner 0 1 2 3", false, 9, []string{"0", "1", "2", "3"}}, + // Zero-to-zero is an American split only. + {"split 0 00", true, 18, []string{"0", "00"}}, + {"split 0 00", false, 0, nil}, + } + for _, tc := range cases { + got, mult := rouletteBet(tc.spot, tc.double) + if mult != tc.mult { + t.Fatalf("%q double=%v: mult=%d, want %d", tc.spot, tc.double, mult, tc.mult) + } + if tc.mult == 0 { + continue + } + if !same(got, tc.want...) { + t.Fatalf("%q double=%v: pockets=%v, want %v", tc.spot, tc.double, got, tc.want) + } + // Every fair combination covers a divisor of 36; the American basket + // is the one intentional exception (5 x 7 = 35, not 36). + if tc.mult != 7 && len(got)*mult != 36 { + t.Fatalf("%q: coverage/payout %d/%d", tc.spot, len(got), mult) + } + } + // A bare list and its named form must reach the same bet. + for _, pair := range [][2]string{{"1-2", "split 1 2"}, {"1-2-3", "street 1 2 3"}, {"1-2-4-5", "corner 1 2 4 5"}, {"1-2-3-4-5-6", "line 1 2 3 4 5 6"}} { + bare, bareMult := rouletteBet(pair[0], false) + named, namedMult := rouletteBet(pair[1], false) + if bareMult != namedMult || !same(bare, named...) { + t.Fatalf("inference %q != %q: %v/%d vs %v/%d", pair[0], pair[1], bare, bareMult, named, namedMult) + } + } +} + +// TestRouletteCombinationOrderIndependent checks that every permutation of a +// combination's numbers selects the same bet. Street 3-1-2 must be the 1-2-3 +// street, and so on for splits, corners, lines, trios and the basket. The +// payout multiplier and the covered pockets must not depend on input order. +func TestRouletteCombinationOrderIndependent(t *testing.T) { + same := func(a, b []string) bool { + if len(a) != len(b) { + return false + } + set := map[string]bool{} + for _, x := range a { + set[x] = true + } + for _, x := range b { + if !set[x] { + return false + } + } + return true + } + var permute func([]string, func([]string)) + permute = func(items []string, visit func([]string)) { + var rec func(int) + rec = func(i int) { + if i == len(items) { + visit(append([]string(nil), items...)) + return + } + for j := i; j < len(items); j++ { + items[i], items[j] = items[j], items[i] + rec(i + 1) + items[i], items[j] = items[j], items[i] + } + } + rec(0) + } + cases := []struct { + kind string + nums []string + double bool + }{ + {"split", []string{"1", "2"}, false}, + {"split", []string{"0", "00"}, true}, + {"street", []string{"1", "2", "3"}, false}, + {"corner", []string{"1", "2", "4", "5"}, false}, + {"line", []string{"1", "2", "3", "4", "5", "6"}, false}, + {"trio", []string{"0", "1", "2"}, false}, + {"trio", []string{"0", "00", "2"}, true}, + {"basket", []string{"0", "00", "1", "2", "3"}, true}, + } + for _, tc := range cases { + canonical := tc.kind + " " + strings.Join(tc.nums, " ") + wantPockets, wantMult := rouletteBet(canonical, tc.double) + if wantMult == 0 { + t.Fatalf("canonical %q is invalid", canonical) + } + permute(append([]string(nil), tc.nums...), func(p []string) { + spot := tc.kind + " " + strings.Join(p, " ") + got, mult := rouletteBet(spot, tc.double) + if mult != wantMult || !same(got, wantPockets) { + t.Fatalf("%q: got %v/%d, want %v/%d", spot, got, mult, wantPockets, wantMult) + } + }) + } +} + +// TestRouletteCombinationSettlement drives a split through the real table +// phases, so coverage and payout are exercised through settle, not only by +// calling rouletteBet directly. +func TestRouletteCombinationSettlement(t *testing.T) { + table := rouletteFixture() + wheel := rouletteWheel(false) + index := rand.New(rand.NewSource(7)).Intn(len(wheel)) + pocket := wheel[index] + // Pick a real split covering the sampled pocket. + split := "" + for n := 1; n <= 36 && split == ""; n++ { + for _, m := range []int{n + 1, n + 3} { + if m > 36 { + continue + } + spot := fmt.Sprintf("split %d %d", n, m) + covered, mult := rouletteBet(spot, false) + if mult == 0 { + continue + } + for _, p := range covered { + if p == pocket { + split = spot + break + } + } + if split != "" { + break + } + } + } + if split == "" { + split = "split 0 1" + if _, mult := rouletteBet(split, false); mult == 0 { + t.Fatal("no split covers pocket ", pocket) + } + } + table.join("a") + w := &testWallet{chips: 100} + table.bet("a", split, 10, w) + for i := 0; i < 4; i++ { + table.tick() + } + if w.paid != 180 || len(w.open) != 0 { + t.Fatalf("split %q pocket %s: wallet %+v", split, pocket, w) + } +} + func rouletteFixture() *rouletteTable { return newRouletteTable(1, TableConfig{ID: "r", Game: GameRoulette, BettingTicks: 2, Roulette: RouletteConfig{SpinTicks: 2}}, rand.New(rand.NewSource(7))) } diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go index db700d6..00009bf 100644 --- a/internal/casino/table_ux.go +++ b/internal/casino/table_ux.go @@ -13,7 +13,11 @@ func (c TableConfig) RulesText() string { case GameBaccarat: return c.BaccaratRulesText() case GameRoulette: - return c.Label() + "\nStraight numbers pay 35:1; 1to12/13to24/25to36 and rows 2:1; other outside bets 1:1.\nZero pockets lose all outside bets. One wager per spin. Use help roulette." + return 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." default: return c.Label() } @@ -48,14 +52,7 @@ func (c *tableCore[P]) overview() string { func (c *tableCore[P]) overviewLocked() string { // Concise enough to sit beside a look minimap without wrapping into it. // The betting countdown lives in the menu heading and warning lines. - state := "Betting open" - switch c.phase { - case phasePlaying: - state = "In progress" - case phaseInsurance: - state = "Insurance" - } - return fmt.Sprintf("%s | Limits %s | %d/%d seated | %s", c.Config.Label(), compactBetRange(c.Config.MinBet, c.Config.MaxBet), c.players.count(), c.Config.MaxPlayers, state) + return fmt.Sprintf("%s | Limits %s | %d/%d seated", c.Config.Label(), compactBetRange(c.Config.MinBet, c.Config.MaxBet), c.players.count(), c.Config.MaxPlayers) } // compactBetRange renders the wager range in the short form used by the diff --git a/internal/game/casino.go b/internal/game/casino.go index 8c10dfd..9282653 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -431,6 +431,10 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { // sets the wager and any other token is the bet target; AFTER "on", the // next token is always the target. That makes numeric targets reachable // ("bet 5 on 17"), which roulette, craps, and keno need. + // Roulette combination bets need a multi-token target ("split 1 2", + // "corner 1 2 4 5"), so a roulette table joins every extra target token. + // Every other game keeps the stricter one-token target and its usage error. + joinTarget := table.Config.Game == casino.GameRoulette amount, spot, hasAmount, sawOn := 0, "", false, false for i, arg := range args { if strings.EqualFold(arg, "on") { @@ -472,11 +476,16 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { continue } } - if spot != "" { - sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.") - return + if spot == "" { + spot = strings.ToLower(arg) + continue } - spot = strings.ToLower(arg) + if joinTarget { + spot += " " + strings.ToLower(arg) + continue + } + sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.") + return } if table.Config.Game == casino.GameBaccarat { switch spot { diff --git a/internal/game/look_entities.go b/internal/game/look_entities.go index 2507073..e120c9c 100644 --- a/internal/game/look_entities.go +++ b/internal/game/look_entities.go @@ -231,7 +231,7 @@ func (g *Game) showRoomCasinos(sess *net.Session, room *world.Room) []string { var lines []string for _, table := range room.CasinoTables { cfg := table.Normalize() - label := fmt.Sprintf("%s | Limits %d-%d | 0/%d seated | Betting open", cfg.Label(), cfg.MinBet, cfg.MaxBet, cfg.MaxPlayers) + label := fmt.Sprintf("%s | Limits %d-%d | 0/%d seated", cfg.Label(), cfg.MinBet, cfg.MaxBet, cfg.MaxPlayers) if g.Casino != nil { if live := g.Casino.Table(room.ID, table.ID); live != nil { label = g.casinoTimeText(live.Overview()) @@ -244,7 +244,7 @@ func (g *Game) showRoomCasinos(sess *net.Session, room *world.Room) []string { if table.Game == casino.GameBaccarat { alias = "bac" } - lines = append(lines, g.colorize(sess, "casino_action", label), " "+g.colorize(sess, "command", "play "+alias)+" Join | "+g.colorize(sess, "command", "watch "+alias)+" Spectate") + lines = append(lines, g.colorize(sess, "casino_action", label), " "+g.colorize(sess, "command", "play "+alias)+" | "+g.colorize(sess, "command", "watch "+alias)) } for _, machine := range room.CasinoMachines { label := casinoGameLabel(machine.Game) + " machine" diff --git a/internal/game/render_roulette.go b/internal/game/render_roulette.go index cfded31..47ba5c6 100644 --- a/internal/game/render_roulette.go +++ b/internal/game/render_roulette.go @@ -53,13 +53,19 @@ func rouletteCenter(text string, width int) string { return strings.Repeat(" ", pad/2) + text + strings.Repeat(" ", pad-pad/2) } -type rouletteLines struct{ h, v, tl, tr, bl, br, teeTop, teeBottom, teeLeft, teeRight, cross string } +type rouletteLines struct { + h, v, tl, tr, bl, br, teeTop, teeBottom, teeLeft, teeRight, cross string + // zeroTL/zeroBL are the zero lane's outer corners. They are rounded while + // the rest of the board keeps square joints, so the tall green zero panel + // reads as a soft-edged block without disturbing the grid's connections. + zeroTL, zeroBL string +} func rouletteBorders(unicode bool) rouletteLines { if unicode { - return rouletteLines{"─", "│", "┌", "┐", "└", "┘", "┬", "┴", "├", "┤", "┼"} + return rouletteLines{"─", "│", "┌", "┐", "└", "┘", "┬", "┴", "├", "┤", "┼", "╭", "╰"} } - return rouletteLines{"-", "|", "+", "+", "+", "+", "+", "+", "+", "+", "+"} + return rouletteLines{"-", "|", "+", "+", "+", "+", "+", "+", "+", "+", "+", "+", "+"} } func repeatRoulette(s string, n int) []string { @@ -97,7 +103,7 @@ func renderRouletteLayout(double bool, mode string, unicode bool) string { // everywhere else. gridBottom := func() string { var sb strings.Builder - sb.WriteString(b.bl) + sb.WriteString(b.zeroBL) for col := 6; col <= 66; col += 5 { if col == 6 { sb.WriteString(strings.Repeat(b.h, 5)) @@ -115,7 +121,7 @@ func renderRouletteLayout(double bool, mode string, unicode bool) string { sb.WriteString(b.br) return sb.String() } - lines = append(lines, rouletteInk(mode, gridRule(b.tl, b.teeTop, b.tr))) + lines = append(lines, rouletteInk(mode, gridRule(b.zeroTL, b.teeTop, b.tr))) for row := 3; row >= 1; row-- { zero := "" switch { @@ -144,7 +150,9 @@ func renderRouletteLayout(double bool, mode string, unicode bool) string { line += rouletteInk(mode, rouletteCenter(fmt.Sprintf("row%d", 4-row), rowCell)+b.v) lines = append(lines, line) if row > 1 { - line = rouletteInk(mode, b.v+" "+b.teeLeft+ + // The zero lane is one green rectangle, so the two separator rows + // are painted to match the cells above and below them. + line = rouletteInk(mode, b.v) + roulettePaint(mode, " ", 2) + rouletteInk(mode, b.teeLeft+ strings.Join(repeatRoulette(strings.Repeat(b.h, 4), 12), b.cross)+ b.cross+strings.Repeat(b.h, rowCell)+b.teeRight) lines = append(lines, line) @@ -428,9 +436,10 @@ 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 { - text := "Numbers 35:1 | 1to12 / 13to24 / 25to36 and rows 2:1 | Other outside bets 1:1\n" + + 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" + - "bet 10 on 17 / bet 10 on red / bet 10 on 13to24" + "bet 10 on 17 / bet 10 on red / bet 10 on split 1 2" return g.colorize(sess, "casino_action", text) } diff --git a/internal/game/render_roulette_test.go b/internal/game/render_roulette_test.go index b1a6005..1cb75ce 100644 --- a/internal/game/render_roulette_test.go +++ b/internal/game/render_roulette_test.go @@ -86,9 +86,9 @@ func TestRouletteArtModes(t *testing.T) { } } if mode != "none" { - // The whole 0/00 lane is green: blank cells above and below - // the zeros fill the rectangle. - wantGreen := 3 + // The whole 0/00 lane is green: the three numbered rows plus + // the two separator rows that run through the lane. + wantGreen := 5 if got := strings.Count(layoutArt, color.BgCode(mode, 2)); got != wantGreen { t.Fatalf("green cells on board = %d, want %d", got, wantGreen) } @@ -199,6 +199,33 @@ func TestRouletteLayoutConnections(t *testing.T) { } } +// TestRouletteZeroPanel checks the two zero-panel fixes: the whole five-row +// lane carries the green background, and its outer corners are rounded in +// Unicode mode while ASCII mode stays square. +func TestRouletteZeroPanel(t *testing.T) { + for _, double := range []bool{false, true} { + colored := renderRouletteLayout(double, "xterm256", true) + rows := strings.Split(colored, "\n") + // Interior rows 2-6: top numbered row, separator, middle, separator, + // bottom numbered row. Each must contain the green background. + for row := 2; row <= 6; 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], "╰") { + t.Fatalf("double=%v: zero panel corners are not rounded:\n%s", double, plainRoulette(colored)) + } + ascii := plainRoulette(renderRouletteLayout(double, "none", false)) + for _, r := range ascii { + if r > 127 { + t.Fatalf("double=%v: non-ASCII rune %q in ASCII mode", double, r) + } + } + } +} + 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 a963913..7a22d9b 100644 --- a/internal/game/roulette_test.go +++ b/internal/game/roulette_test.go @@ -43,6 +43,58 @@ func TestRouletteNumericTargetsAndRendering(t *testing.T) { } } +func TestRouletteCombinationTargetParsing(t *testing.T) { + for _, double := range []bool{false, true} { + g := &Game{Casino: casino.NewManager(1)} + cfg := casino.TableConfig{ID: "roulette", Game: casino.GameRoulette, Roulette: casino.RouletteConfig{DoubleZero: double}} + 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} + // Multi-token targets are joined for roulette, with or without "on". + valid := [][]string{ + {"10", "on", "split", "1", "2"}, + {"10", "on", "split", "1-2"}, + {"10", "split", "1-2"}, + {"10", "on", "street", "1", "2", "3"}, + {"10", "on", "street", "3", "1", "2"}, + {"10", "on", "corner", "1", "2", "4", "5"}, + {"10", "on", "corner", "5", "1", "4", "2"}, + {"10", "on", "line", "1", "2", "3", "4", "5", "6"}, + {"10", "on", "trio", "0", "1", "2"}, + {"10", "on", "basket"}, + } + if double { + valid = append(valid, []string{"10", "on", "split", "0", "00"}) + } + 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("double=%v args=%v: expected wallet failure, got %q", double, args, conn.data) + } + } + // Non-adjacent or wrong-shaped combinations must not reach the wallet. + invalid := [][]string{ + {"10", "on", "split", "1", "5"}, + {"10", "on", "street", "2", "3", "4"}, + {"10", "on", "corner", "1", "2", "3", "4"}, + {"10", "on", "line", "1", "2", "3", "4", "5", "7"}, + {"10", "on", "trio", "0", "1", "5"}, + } + if !double { + invalid = append(invalid, []string{"10", "on", "split", "0", "00"}) + } + for _, args := range invalid { + conn.data = nil + g.executeBet(sess, args, "bet "+strings.Join(args, " ")) + if !strings.Contains(string(conn.data), "Unknown roulette target") { + t.Fatalf("double=%v args=%v: expected target rejection, got %q", double, args, conn.data) + } + } + } +} + func TestCasinoTestPortalAuthoredContract(t *testing.T) { g, rooms, _ := loadVesta(t) found := false |
