diff options
| author | historia <[not public]> | 2026-09-30 16:16:17 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-30 16:16:17 -0400 |
| commit | a4c09c2d38d1f3bc1d3ad4bdb0234bdea7c37e73 (patch) | |
| tree | 00c9567e620f331a59dd44f1f1fa779ecd94a845 /internal/game | |
| parent | 724079f8de1ae69965c6267dca3d7fc3cc86afa2 (diff) | |
| download | thehouseoficarus-a4c09c2d38d1f3bc1d3ad4bdb0234bdea7c37e73.tar.gz | |
feat: additional craps variants added, standardizard casino command hints
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/casino.go | 43 | ||||
| -rw-r--r-- | internal/game/casino_commands.go | 83 | ||||
| -rw-r--r-- | internal/game/casino_commands_test.go | 118 | ||||
| -rw-r--r-- | internal/game/casino_ux.go | 12 | ||||
| -rw-r--r-- | internal/game/craps_variants_test.go | 103 | ||||
| -rw-r--r-- | internal/game/render_craps.go | 55 | ||||
| -rw-r--r-- | internal/game/render_craps_cards.go | 44 |
7 files changed, 400 insertions, 58 deletions
diff --git a/internal/game/casino.go b/internal/game/casino.go index e9334fc..c2c4c4f 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -695,18 +695,8 @@ func (g *Game) casinoPrivateMessage(sess *net.Session, category string, menu *ca commands[i].Desc += " [unavailable: insufficient funds]" } } - if sess.Player.OptionString("casino_display") == "compact" && menu.Repeat { - var repeat string - for _, c := range commands { - if c.Command == "bet" { - repeat = g.colorize(sess, "command", "bet / rebet") + " " + c.Desc - break - } - } - return g.colorize(sess, "casino_action", heading) + "\n" + balance + "\n " + repeat + "\n " + g.colorize(sess, "command", "bet <amount> [target] | bet min/max | table | sitout | stop") - } if sess.Player.OptionString("casino_display") == "compact" && !menu.ShowBalance { - var hints []string + var hints []casino.MenuCommand for _, c := range commands { label := c.Command switch c.Command { @@ -722,14 +712,9 @@ func (g *Game) casinoPrivateMessage(sess *net.Session, category string, menu *ca if c.Cost > 0 { label += fmt.Sprintf(" (+%d)", c.Cost) } - category := "command" - if c.Cost > casinoAffordable(sess.Player) { - label += " [unavailable]" - category = "casino_warning" - } - hints = append(hints, g.colorize(sess, category, label)) + hints = append(hints, casino.MenuCommand{Command: label, Cost: c.Cost}) } - return g.colorize(sess, "casino_action", heading) + "\n " + strings.Join(hints, " ") + return g.renderCasinoCommands(sess, balance, heading, hints) } return g.renderCasinoCommands(sess, balance, heading, commands) } @@ -796,27 +781,7 @@ func (g *Game) casinoMenuHeader(sess *net.Session, balance, heading string) stri // casinoMenuCommands formats an aligned, colored command list. func (g *Game) casinoMenuCommands(sess *net.Session, commands []casino.MenuCommand) string { - width := 0 - for _, c := range commands { - if n := utf8.RuneCountInString(c.Command); n > width { - width = n - } - } - var sb strings.Builder - for _, c := range commands { - padded := c.Command + strings.Repeat(" ", width-utf8.RuneCountInString(c.Command)) - category := "command" - if sess.Player != nil && c.Cost > casinoAffordable(sess.Player) { - category = "casino_warning" - } - cmd := g.colorize(sess, category, padded) - if c.Desc != "" { - fmt.Fprintf(&sb, " %s %s\n", cmd, c.Desc) - } else { - fmt.Fprintf(&sb, " %s\n", cmd) - } - } - return strings.TrimRight(sb.String(), "\n") + return g.casinoCommandColumns(sess, commands) } func renderBlackjackSnapshotFor(snapshot *casino.BlackjackSnapshot, mode string, unicode bool, size int, style string, art *cardart.Store, viewer string) string { diff --git a/internal/game/casino_commands.go b/internal/game/casino_commands.go new file mode 100644 index 0000000..f4ca79f --- /dev/null +++ b/internal/game/casino_commands.go @@ -0,0 +1,83 @@ +package game + +import ( + "strings" + "unicode/utf8" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/net" +) + +// Wrap before Session.WriteLine: generic prose wrapping strips alignment from +// overlong lines. Every casino menu uses the same two-space command indent and +// hanging description column, capped at 80 even when prose wrapping is off. +func (g *Game) casinoCommandColumns(sess *net.Session, commands []casino.MenuCommand) string { + limit := 80 + if sess.Player != nil { + if wrap := sess.Player.OptionInt("wrap_width"); wrap > 0 { + limit = min(limit, wrap) + } + } + width := 0 + for _, c := range commands { + n := utf8.RuneCountInString(c.Command) + // Unusually long commands get their own lines instead of squeezing every + // other command's description into a tiny column. + if n <= limit/2 { + width = max(width, n) + } + } + var lines []string + for _, c := range commands { + if sess.Player != nil && c.Cost > casinoAffordable(sess.Player) { + continue + } + n := utf8.RuneCountInString(c.Command) + if n > limit/2 { + for _, line := range casinoWrapWords(c.Command, max(1, limit-2)) { + lines = append(lines, " "+g.colorize(sess, "command", line)) + } + for _, line := range casinoWrapWords(c.Desc, max(1, limit-4)) { + lines = append(lines, " "+line) + } + continue + } + if strings.TrimSpace(c.Desc) == "" { + lines = append(lines, " "+g.colorize(sess, "command", c.Command)) + continue + } + indent := width + 4 + desc := casinoWrapWords(c.Desc, max(1, limit-indent)) + cmd := c.Command + strings.Repeat(" ", width-n) + lines = append(lines, " "+g.colorize(sess, "command", cmd)+" "+desc[0]) + for _, line := range desc[1:] { + lines = append(lines, strings.Repeat(" ", indent)+line) + } + } + return strings.Join(lines, "\n") +} + +// Input is uncolored command/description text; paint only after measuring. +func casinoWrapWords(text string, width int) []string { + var lines []string + line := "" + for _, word := range strings.Fields(text) { + if line != "" && utf8.RuneCountInString(line)+1+utf8.RuneCountInString(word) > width { + lines = append(lines, line) + line = "" + } + runes := []rune(word) + for len(runes) > width { + lines = append(lines, string(runes[:width])) + runes = runes[width:] + } + if line != "" { + line += " " + } + line += string(runes) + } + if line != "" { + lines = append(lines, line) + } + return lines +} diff --git a/internal/game/casino_commands_test.go b/internal/game/casino_commands_test.go new file mode 100644 index 0000000..7820650 --- /dev/null +++ b/internal/game/casino_commands_test.go @@ -0,0 +1,118 @@ +package game + +import ( + "strings" + "testing" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func TestCasinoCommandsWrapBeforeSessionWithoutLosingIndent(t *testing.T) { + commands := []casino.MenuCommand{ + {Command: "bet <amount> on <target>", Desc: "Place bets until the dice roll; table rules lists targets"}, + {Command: "roll / autoroll / stop", Desc: "Shoot / repeat on your turn / return to manual play"}, + {Command: "unbet <target> / unbet all", Desc: "Remove one bet / every optional bet"}, + {Command: "table", Desc: "View the felt"}, + {Command: "insurance", Desc: "Buy insurance; a deliberately long description which requires a hanging continuation line"}, + {Command: "decline", Desc: "Continue without insurance (also the timeout default)"}, + {Command: "bet <amount> on player/banker/tie/player_pair/banker_pair", Desc: "One wager per round; 'on' is optional"}, + {Command: "option casino_display compact", Desc: "Compact action hints (detailed for full command menus)"}, + {Command: "spin", Desc: "Spin using your current wager"}, + {Command: "autospin", Desc: "Start automatic betting"}, + {Command: "unicode", Desc: strings.Repeat("é", 100)}, + } + for _, wrap := range []int{0, 40, 60, 80, 120} { + p := player.New("a") + p.Options = map[string]any{"wrap_width": wrap} + conn := &casinoTestConn{} + sess := &net.Session{Player: p, Conn: conn} + g := &Game{} + text := g.casinoMenuCommands(sess, commands) + sess.WriteLine(text) + limit := 80 + if wrap > 0 { + limit = min(limit, wrap) + } + for _, line := range strings.Split(strings.ReplaceAll(plainRoulette(string(conn.data)), "\r\n", "\n"), "\n") { + if line == "" { + continue + } + if color.VisibleLen(line) > limit { + t.Fatalf("wrap %d overlong %d: %q", wrap, color.VisibleLen(line), line) + } + if !strings.HasPrefix(line, " ") { + t.Fatalf("wrap %d lost indent: %q", wrap, line) + } + } + expected := strings.ReplaceAll(plainRoulette(text), "\r\n", "\n") + actual := strings.TrimRight(strings.ReplaceAll(plainRoulette(string(conn.data)), "\r\n", "\n"), "\n") + if expected != actual { + t.Fatalf("session reflowed prewrapped menu at %d", wrap) + } + } +} +func TestCasinoCommandsHangingIndentAndShortDescription(t *testing.T) { + sess := &net.Session{Player: player.New("a")} + g := &Game{} + commands := []casino.MenuCommand{ + {Command: "bet <amount> on <target>", Desc: "Place a wager; targets: table rules"}, + {Command: "table", Desc: "View the felt"}, + } + text := plainRoulette(g.casinoMenuCommands(sess, commands)) + lines := strings.Split(text, "\n") + if len(lines) != 2 { + t.Fatal("short commands should not wrap", text) + } + col := strings.Index(lines[0], "Place") + if col != strings.Index(lines[1], "View") { + t.Fatal("descriptions not aligned", text) + } + commands[0].Desc = strings.Repeat("long description ", 10) + text = plainRoulette(g.casinoMenuCommands(sess, commands)) + lines = strings.Split(text, "\n") + for _, line := range lines[1 : len(lines)-1] { + if !strings.HasPrefix(line, strings.Repeat(" ", col)) { + t.Fatal("missing hanging indent", text) + } + } +} +func TestCasinoCompactHintsUseOnlyAvailableCommands(t *testing.T) { + p := player.New("a") + p.Options = map[string]any{"casino_display": "compact"} + p.Credits = 10 + sess := &net.Session{Player: p} + g := &Game{} + menu := &casino.MenuView{Heading: "Your turn", Commands: []casino.MenuCommand{ + {Command: "hit", Desc: "Take a card"}, {Command: "stand", Desc: "Hold"}, {Command: "double", Cost: 20}, + }} + text := plainRoulette(g.casinoPrivateMessage(sess, "casino_menu", menu, "")) + if strings.Contains(text, "double") || !strings.Contains(text, " h:hit") || !strings.Contains(text, " st:stand") { + t.Fatal(text) + } + menu.ShowBalance = true + menu.Repeat = true + menu.Commands = []casino.MenuCommand{{Command: "table", Desc: "View the felt"}} + text = plainRoulette(g.casinoPrivateMessage(sess, "casino_menu", menu, "")) + if strings.Contains(text, "bet") || strings.Contains(text, "sitout") { + t.Fatal("compact mode invented actions", text) + } +} +func TestSimplifiedFeltSpacingAndNoIdlePrompt(t *testing.T) { + text := renderCrapsSnapshot(&casino.CrapsSnapshot{Variant: "simplified"}, "none", true) + if strings.Contains(text, "Place a bet, then roll when ready.") { + t.Fatal(text) + } + rows := strings.Split(text, "\n") + for i, row := range rows { + if strings.Contains(row, "PASS ONLY") { + if i+2 >= len(rows) || strings.Trim(rows[i+1], "│ ") != "" || !strings.HasPrefix(rows[i+2], "└") { + t.Fatal("expected exactly one blank felt row", text) + } + return + } + } + t.Fatal("missing pass label") +} diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go index 450480f..a2e7330 100644 --- a/internal/game/casino_ux.go +++ b/internal/game/casino_ux.go @@ -129,20 +129,14 @@ func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) { } } if len(args) > 0 { + // Reuse the game's current-state menu instead of advertising actions + // (insurance, split, rebet, etc.) that may not be legal right now. + g.emitCasinoEvents(t.Status(sess.Player.Name)) commands := []casino.MenuCommand{ - {Command: "bet / rebet", Desc: "Repeat your previous wager"}, - {Command: "bet min / bet max", Desc: "Use the table minimum / affordable maximum"}, - {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/craps_variants_test.go b/internal/game/craps_variants_test.go new file mode 100644 index 0000000..76d1424 --- /dev/null +++ b/internal/game/craps_variants_test.go @@ -0,0 +1,103 @@ +package game + +import ( + "path/filepath" + "strings" + "testing" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/world" +) + +func TestCrapsVariantRendering(t *testing.T) { + for _, v := range []string{"simplified", "high_point", "crapless", "new_york", "die_rich", "diceless"} { + for _, unicode := range []bool{false, true} { + for _, mode := range []string{"none", "xterm256"} { + for _, style := range []string{"dark", "light"} { + dice := [2]int{4, 2} + if v == "die_rich" { + dice[1] = 0 + } + text := renderCrapsSnapshot(&casino.CrapsSnapshot{Variant: v, Point: 4, Dice: dice}, mode, unicode, style) + plain := plainRoulette(text) + if !strings.Contains(plain, strings.ToUpper(strings.ReplaceAll(v, "_", " "))+" CRAPS") { + t.Fatal(v, "title") + } + if v == "diceless" { + for _, token := range []string{"PACK A", "PACK B", "Cards total: 6", "FIRE"} { + if !strings.Contains(plain, token) { + t.Fatal(v, token) + } + } + if strings.Contains(plain, "Total:") { + t.Fatal("dice rendered instead of cards") + } + } else { + if strings.Contains(plain, "FIRE") || strings.Contains(plain, "ALL SMALL") { + t.Fatal(v, "unsupported bonus display") + } + } + if v == "die_rich" && !strings.Contains(plain, "Total: 4") { + t.Fatal("single die absent") + } + if mode == "xterm256" && (v == "die_rich" || v == "diceless") { + bg := 0 + if style == "light" { + bg = 15 + } + if !strings.Contains(text, color.BgCode(mode, bg)) { + t.Fatal(v, "style") + } + } + } + } + } + } + for n := 1; n <= 6; n++ { + text := renderCrapsDice([2]int{n, 0}, "none", false, "dark") + if strings.Count(text, "+-------+") != 2 { + t.Fatal("must render exactly one die", text) + } + } +} + +func TestShippedCrapsGallery(t *testing.T) { + w := world.New(filepath.Join("..", "..", "data")) + variants := []string{"bank", "simplified", "high_point", "crapless", "new_york", "die_rich", "diceless"} + for i, v := range variants { + id := 6185 + i + room, err := w.LoadRoom(id) + if err != nil { + t.Fatal(err) + } + if len(room.CasinoTables) != 1 { + t.Fatal(id, "missing table") + } + cfg := room.CasinoTables[0] + if cfg.Game != casino.GameCraps || cfg.Craps.Variant != v { + t.Fatal(id, cfg) + } + if err := cfg.Validate(); err != nil { + t.Fatal(id, err) + } + if i > 0 && room.Exits["south"].Room != id-1 { + t.Fatal(id, "missing return exit") + } + if i < len(variants)-1 && room.Exits["north"].Room != id+1 { + t.Fatal(id, "unreachable next table") + } + m := casino.NewManager(1) + m.Join(id, cfg, "a") + if m.Table(id, cfg.ID) == nil { + t.Fatal(id, "table registration") + } + } + room, err := w.LoadRoom(6182) + if err != nil { + t.Fatal(err) + } + if room.Exits["west"].Room != 6185 { + t.Fatal("gallery unreachable from roulette") + } +} diff --git a/internal/game/render_craps.go b/internal/game/render_craps.go index 9f73b20..2270659 100644 --- a/internal/game/render_craps.go +++ b/internal/game/render_craps.go @@ -264,7 +264,7 @@ func renderCrapsFelt(s *casino.CrapsSnapshot, mode string, unicode bool) string return c.render(mode, unicode) } func renderCrapsDice(dice [2]int, mode string, unicode bool, style string) string { - if dice[0] < 1 || dice[0] > 6 || dice[1] < 1 || dice[1] > 6 { + if dice[0] < 1 || dice[0] > 6 || dice[1] < 0 || dice[1] > 6 { return "" } patterns := [][3]string{{}, {" ", " o ", " "}, {"o ", " ", " o"}, {"o ", " o ", " o"}, {"o o", " ", "o o"}, {"o o", " o ", "o o"}, {"o o", "o o", "o o"}} @@ -280,6 +280,9 @@ func renderCrapsDice(dice [2]int, mode string, unicode bool, style string) strin for row := 0; row < 5; row++ { out.WriteString(" ") for i, n := range dice { + if n == 0 { + continue + } line := top if row == 4 { line = bottom @@ -315,21 +318,51 @@ func renderCrapsSnapshot(s *casino.CrapsSnapshot, mode string, unicode bool, sty if s.Point != 0 { point = fmt.Sprintf("POINT %d", s.Point) } - out.WriteString("\n" + title(" BANK CRAPS") + subtle(" / "+point) + "\n") - out.WriteString(renderCrapsTrackers(s, mode, unicode)) - out.WriteByte('\n') - out.WriteString(renderCrapsFelt(s, mode, unicode)) + variant := s.Variant + if variant == "" { + variant = "bank" + } + out.WriteString("\n" + title(" "+strings.ToUpper(strings.ReplaceAll(variant, "_", " "))+" CRAPS") + subtle(" / "+point) + "\n") + if variant == "bank" || variant == "diceless" { + out.WriteString(renderCrapsTrackers(s, mode, unicode)) + out.WriteByte('\n') + out.WriteString(renderCrapsFelt(s, mode, unicode)) + } else { + height := 7 + if variant == "simplified" || variant == "high_point" || variant == "die_rich" { + height = 6 + } + c := newCrapsCanvas(77, height) + c.box(0, 0, 76, height-1) + c.center(1, 75, 1, "HOUSE EDITION — table rules for payouts", crapsGold) + switch variant { + case "crapless": + c.center(1, 75, 3, "POINTS: 2 3 4 5 6 8 9 10 11 12 | PASS / COME / ODDS", crapsInk) + c.center(1, 75, 4, "PLACE / BUY / LAY / FIELD / HARD / HOP / PROPOSITIONS", crapsInk) + c.center(1, 75, 5, "NO DON'T BETS OR BONUSES", crapsMuted) + case "new_york": + c.center(1, 75, 3, "PASS / DON'T PASS (BAR 12) / ODDS / BUY / LAY", crapsInk) + c.center(1, 75, 4, "BUY / LAY: 4 5 6 8 9 10 | 5% WIN-ONLY COMMISSION", crapsMuted) + default: + c.center(1, 75, 3, "PASS ONLY | WINS PAY 1:1 | NO ODDS OR BONUSES", crapsInk) + } + out.WriteString(c.render(mode, unicode)) + } out.WriteByte('\n') - out.WriteString(renderCrapsDice(s.Dice, mode, unicode, style)) + if variant == "diceless" { + out.WriteString(renderCrapsCards(s.Dice, mode, unicode, style)) + } else { + out.WriteString(renderCrapsDice(s.Dice, mode, unicode, style)) + } if len(s.Queue) > 0 { out.WriteString(title(" Shooter ") + subtle(s.Queue[0]) + "\n") if len(s.Queue) > 1 { out.WriteString(subtle(" On deck "+strings.Join(s.Queue[1:], " > ")) + "\n") } } - hint := "Place a bet, then roll when ready." + hint := "" if s.Phase != "waiting" && s.Phase != "" { - hint = "Your pace. Roll when ready." + hint = "" if s.ShootIn > 0 { hint = fmt.Sprintf("Bets open. Shooter ready in %ds.", s.ShootIn) } else if s.DealerIn > 0 { @@ -337,8 +370,10 @@ func renderCrapsSnapshot(s *casino.CrapsSnapshot, mode string, unicode bool, sty } } if s.AutoRoll { - hint = fmt.Sprintf("AUTOROLL / Next roll in %ds / stop for manual play", s.AutoIn) + hint = fmt.Sprintf("AUTOROLL / Shooter's next roll in %ds", s.AutoIn) + } + if hint != "" { + out.WriteString(subtle(" "+hint) + "\n") } - out.WriteString(subtle(" "+hint) + "\n") return out.String() } diff --git a/internal/game/render_craps_cards.go b/internal/game/render_craps_cards.go new file mode 100644 index 0000000..343f16f --- /dev/null +++ b/internal/game/render_craps_cards.go @@ -0,0 +1,44 @@ +package game + +import ( + "fmt" + "strings" + "thehouseoficarus/internal/color" +) + +// The two packs have ranks 1-6, no suits. Distinct pack labels make the +// replacement model visible without suggesting a conventional poker shoe. +func renderCrapsCards(cards [2]int, mode string, unicode bool, style string) string { + if cards[0] < 1 || cards[0] > 6 || cards[1] < 1 || cards[1] > 6 { + return "" + } + top, bottom, bar := "+-------+", "+-------+", "|" + if unicode { + top, bottom, bar = "╭───────╮", "╰───────╯", "│" + } + spec := color.ColorSpec{Fg: 15, Bg: 0, Bold: true} + if style == "light" { + spec = color.ColorSpec{Fg: 0, Bg: 15, Bold: true} + } + var out strings.Builder + for row := 0; row < 5; row++ { + out.WriteString(" ") + for i, n := range cards { + line := top + switch row { + case 1: + line = fmt.Sprintf("%s PACK %c%s", bar, 'A'+i, bar) + case 2: + line = fmt.Sprintf("%s %d %s", bar, n, bar) + case 3: + line = bar + " " + bar + case 4: + line = bottom + } + out.WriteString(color.Render(mode, spec, line) + " ") + } + out.WriteByte('\n') + } + fmt.Fprintf(&out, " Cards total: %d | Both packs replaced and reshuffled each draw.\n", cards[0]+cards[1]) + return out.String() +} |
