From 3a39a020e4449bc4d6f728ef837cffb9f7bd0c56 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sat, 3 Oct 2026 19:46:54 -0400 Subject: feat: new casino game: plinko --- internal/casino/house_edge.go | 7 + internal/casino/plinko.go | 395 +++++++++++++++++++++++++++++++++++++++ internal/casino/plinko_test.go | 218 +++++++++++++++++++++ internal/casino/registry.go | 10 + internal/casino/types.go | 2 + internal/game/blackjack_rules.go | 4 + internal/game/casino.go | 10 +- internal/game/cmd_registry.go | 7 + internal/game/look_casino.go | 4 +- internal/game/look_target.go | 2 +- internal/game/plinko_test.go | 261 ++++++++++++++++++++++++++ internal/game/render_plinko.go | 213 +++++++++++++++++++++ 12 files changed, 1129 insertions(+), 4 deletions(-) create mode 100644 internal/casino/plinko.go create mode 100644 internal/casino/plinko_test.go create mode 100644 internal/game/plinko_test.go create mode 100644 internal/game/render_plinko.go (limited to 'internal') diff --git a/internal/casino/house_edge.go b/internal/casino/house_edge.go index 01fe7aa..e175e24 100644 --- a/internal/casino/house_edge.go +++ b/internal/casino/house_edge.go @@ -59,6 +59,13 @@ func (c TableConfig) HouseEdge(bet string) HouseEdge { // HouseEdge reads seat-specific settings under the game's lock. It does not // alter game state, advance RNGs, or reveal cards from the current shoe. func (t *Table) HouseEdge(name, bet string) HouseEdge { + if p, ok := t.game.(*plinkoTable); ok { + p.mu.Lock() + defer p.mu.Unlock() + if p.name == name { + return edge(1 - plinkoReturn(p.risk, p.Config.Plinko.Normalize().RTP)) + } + } if vp, ok := t.game.(*videoPokerTable); ok { vp.mu.Lock() defer vp.mu.Unlock() diff --git a/internal/casino/plinko.go b/internal/casino/plinko.go new file mode 100644 index 0000000..d73a0a4 --- /dev/null +++ b/internal/casino/plinko.go @@ -0,0 +1,395 @@ +package casino + +import ( + "fmt" + "math" + "math/rand" + "strconv" + "strings" + "sync" +) + +const plinkoRows = 16 + +const plinkoFirstRow = 0 + +// RTP is the base percentage before pocket multipliers round up to tenths. +// Fixed expert edge prizes alone return 30.517578125%. +type PlinkoConfig struct { + RTP float64 `yaml:"rtp,omitempty"` +} + +func (c PlinkoConfig) Normalize() PlinkoConfig { + if c.RTP == 0 { + c.RTP = 98 + } + return c +} +func validatePlinko(c TableConfig) error { + r := c.Plinko.Normalize().RTP + if math.IsNaN(r) || math.IsInf(r, 0) || r < 31 || r > 100 { + return fmt.Errorf("plinko rtp must be between 31 and 100 percent") + } + if c.MaxPlayers > 1 { + return fmt.Errorf("plinko has one seat") + } + if c.MaxBet > MaxCredits/100000 { + return fmt.Errorf("plinko max_bet must not exceed %d", MaxCredits/100000) + } + return nil +} + +var plinkoRisks = []string{"easy", "medium", "hard", "expert"} + +// Every path has probability 2^-16. Normalize interior weights against the +// binomial distribution, keeping both edge jackpots fixed at every RTP. +// Round the played multipliers UP to tenths, intentionally raising actual RTP. +func plinkoPockets(risk int, rtp float64) [17]float64 { + var out [17]float64 + outer := []float64{16, 100, 1000, 10000}[risk] + powers := []float64{2, 4, 7, 12} + out[0], out[16] = outer, outer + sum, choose := 0.0, 1.0 + for k := 1; k < 16; k++ { + choose *= float64(17-k) / float64(k) + out[k] = math.Pow(float64(absPlinko(k-8)+1), powers[risk]) + sum += choose * out[k] / 65536 + } + scale := (rtp/100 - 2*outer/65536) / sum + for k := 1; k < 16; k++ { + out[k] = math.Ceil(out[k]*scale*10) / 10 + } + return out +} +func absPlinko(n int) int { + if n < 0 { + return -n + } + return n +} +func plinkoReturn(risk int, rtp float64) float64 { + pockets := plinkoPockets(risk, rtp) + choose, result := 1.0, 0.0 + for k, multiplier := range pockets { + if k > 0 { + choose *= float64(17-k) / float64(k) + } + result += choose * multiplier / 65536 + } + return result +} + +func plinkoHouseEdge(c TableConfig, _ string) HouseEdge { + var e HouseEdge + for risk, name := range plinkoRisks { + e.Parts = append(e.Parts, EdgePart{Label: name, Loss: 1 - plinkoReturn(risk, c.Plinko.Normalize().RTP)}) + } + e.Note = "easy / medium / hard / expert; includes upward rounding to 0.1x" + return e +} + +func plinkoRules(c TableConfig) string { + return fmt.Sprintf("Plinko: 16 fair left/right bounces; 17 pockets. Base RTP %.4g%%. Multipliers round UP to 0.1x, increasing actual RTP by risk.\nUse difficulty easy|medium|hard|expert, balls 1-10, then bet N (per ball).\nPocket multipliers INCLUDE your stake. Fractional total returns round randomly, without bias.\nEasy edges pay 16x; expert edges pay 10000x. Higher risk means higher variance.\nOne ball is white; overlapping balls shade toward red (all balls together).\nLeaving is blocked during a drop; disconnect forfeits an unfinished drop.", c.Plinko.Normalize().RTP) +} + +type PlinkoSnapshot struct { + // Row 0 is the apex (preceded by the payout legend); 16 is the pockets. + Row, Total int + Counts [17]int + Pockets [17]float64 + Paytable bool + Risk string +} + +func (*PlinkoSnapshot) snapshot() {} + +// PlinkoPocketLabels gives every distinct played multiplier one letter, in +// outside-to-center order. Mirrored pockets and rounded ties share a label. +// Settlement, the legend and the board must use the same mapping. +func PlinkoPocketLabels(pockets [17]float64) [17]rune { + var labels [17]rune + seen := make(map[float64]rune) + for k, value := range pockets { + label, ok := seen[value] + if !ok { + label = 'A' + rune(len(seen)) + seen[value] = label + } + labels[k] = label + } + return labels +} + +type plinkoTable struct { + *tableBase + mu sync.Mutex + rng *rand.Rand + name string + risk, balls, betSize, row int + active bool + positions []int + wallet Wallet + stake Wager +} + +func newPlinkoTable(room int, c TableConfig, rng *rand.Rand) tableGame { + return &plinkoTable{tableBase: &tableBase{RoomID: room, Config: c}, rng: rng, balls: 1} +} +func (t *plinkoTable) base() *tableBase { return t.tableBase } +func (t *plinkoTable) idle() bool { t.mu.Lock(); defer t.mu.Unlock(); return t.name == "" } +func (t *plinkoTable) hasParticipant(n string) bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.name != "" && t.name == n +} +func (t *plinkoTable) busy(n string) bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.name == n && t.active +} +func (t *plinkoTable) overview() string { return "Plinko / 16-row pyramid" } +func (t *plinkoTable) seats() []string { + t.mu.Lock() + defer t.mu.Unlock() + if t.name == "" { + return nil + } + return []string{t.name} +} +func (t *plinkoTable) state() string { + t.mu.Lock() + defer t.mu.Unlock() + if t.active { + return "dropping" + } + return "waiting" +} +func (t *plinkoTable) wagers(string) []WagerRow { return nil } +func (t *plinkoTable) hintsLocked() []MenuCommand { + if t.active { + return nil + } + return []MenuCommand{{Command: "bet N", Desc: "Drop balls; N credits PER ball"}, {Command: "balls N", Desc: "Drop 1-10 balls together"}, {Command: "difficulty easy|medium|hard|expert", Desc: "Choose payout variance"}, {Command: "paytable", Desc: "Show all pocket multipliers"}, {Command: "rules", Desc: "Plinko rules"}, {Command: "stop", Desc: "Leave Plinko"}} +} +func (t *plinkoTable) hints(n string) []MenuCommand { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + return t.hintsLocked() +} +func (t *plinkoTable) snap(paytable bool) *PlinkoSnapshot { + s := &PlinkoSnapshot{Row: t.row, Total: t.balls, Pockets: plinkoPockets(t.risk, t.Config.Plinko.Normalize().RTP), Risk: plinkoRisks[t.risk], Paytable: paytable} + for _, p := range t.positions { + s.Counts[p]++ + } + return s +} +func (t *plinkoTable) statusLocked(n string) []Event { + e := t.private(n, fmt.Sprintf("PLINKO / %s / %d balls / %.2f%% RTP (base %.4g%%)", plinkoRisks[t.risk], t.balls, 100*plinkoReturn(t.risk, t.Config.Plinko.Normalize().RTP), t.Config.Plinko.Normalize().RTP)) + e.Color = "casino_menu" + e.Snapshot = t.snap(true) + if n == t.name { + e.Menu = &MenuView{ShowBalance: true, Commands: t.hintsLocked()} + } + return []Event{e} +} +func (t *plinkoTable) status(n string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + return t.statusLocked(n) +} +func (t *plinkoTable) board(string) Snapshot { t.mu.Lock(); defer t.mu.Unlock(); return t.snap(true) } +func (t *plinkoTable) join(n string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if t.name != "" { + return []Event{t.private(n, "Plinko is occupied.")} + } + t.name = n + t.risk = 0 + t.balls = 1 + e := t.playerAction(n, n+" steps up to Plinko.", "Welcome to PLINKO! Choose your risk and let it rain.") + e.Type = EventJoined + e.Player = n + return append([]Event{e}, t.statusLocked(n)...) +} +func (t *plinkoTable) clear() { + t.name = "" + t.active = false + t.positions = nil + t.wallet = nil + t.stake = Wager{} + t.betSize = 0 + t.row = 0 +} +func (t *plinkoTable) leave(n string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + if t.active { + return []Event{t.private(n, "The balls are still falling!")} + } + t.clear() + e := t.playerAction(n, n+" leaves Plinko.", "You leave Plinko.") + e.Type = EventLeft + e.Player = n + return []Event{e} +} +func (t *plinkoTable) rebet(n string) (string, int) { + t.mu.Lock() + defer t.mu.Unlock() + if n == t.name { + return "", t.betSize + } + return "", 0 +} +func (t *plinkoTable) action(n, a string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + if t.active { + return []Event{t.private(n, "Wait for the balls to land.")} + } + f := strings.Fields(strings.ToLower(a)) + if len(f) == 1 && f[0] == "paytable" { + return t.statusLocked(n) + } + if len(f) == 2 { + switch f[0] { + case "balls": + v, err := strconv.Atoi(f[1]) + if err == nil && v >= 1 && v <= 10 { + t.balls = v + return t.statusLocked(n) + } + case "difficulty": + for i, r := range plinkoRisks { + if f[1] == r { + t.risk = i + return t.statusLocked(n) + } + } + } + } + return []Event{t.private(n, "Use difficulty easy|medium|hard|expert, balls 1-10, or paytable.")} +} +func (t *plinkoTable) bet(n, spot string, amount int, w Wallet) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + if t.active { + return []Event{t.private(n, "The balls are still falling!")} + } + if spot != "" && spot != "main" { + return []Event{t.private(n, "Use bet N; N is the stake per ball.")} + } + if amount < t.Config.MinBet || amount > t.Config.MaxBet || amount > MaxCredits/100000 { + return []Event{t.private(n, "Per-ball bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")} + } + if w == nil { + return []Event{t.private(n, "No wallet is available.")} + } + stake, err := w.Wager(amount * t.balls) + if err != nil { + return []Event{t.private(n, walletErrorMessage(err, "Not enough chips and credits for all balls."))} + } + t.wallet = w + t.stake = stake + t.betSize = amount + t.row = plinkoFirstRow + t.active = true + t.positions = make([]int, t.balls) + e := t.private(n, fmt.Sprintf("*** PLINKO / %s *** %d balls x %d = %d credits. Let it rain!", strings.ToUpper(plinkoRisks[t.risk]), t.balls, amount, stake.Total)) + e.Snapshot = t.snap(false) + return []Event{e} +} +func (t *plinkoTable) tick() []Event { + t.mu.Lock() + defer t.mu.Unlock() + if !t.active { + return nil + } + t.row++ + if t.row > 0 { + for i := range t.positions { + t.positions[i] += t.rng.Intn(2) + } + } + e := t.private(t.name, "") + e.Snapshot = t.snap(false) + events := []Event{e} + if t.row < plinkoRows { + return events + } + t.active = false + pockets := plinkoPockets(t.risk, t.Config.Plinko.Normalize().RTP) + labels := PlinkoPocketLabels(pockets) + total := 0.0 + var hits []string + for i, p := range t.positions { + v := pockets[p] * float64(t.betSize) + total += v + hits = append(hits, fmt.Sprintf("Ball %d -> [%c] %.1fx", i+1, labels[p], pockets[p])) + } + paid := int(math.Floor(total)) + if t.rng.Float64() < total-float64(paid) { + paid++ + } + if err := t.wallet.PayCredits(paid, t.stake.ID); err != nil { + return append(events, t.private(t.name, payoutFailedMessage())) + } + t.stake = Wager{} + result := t.private(t.name, "") + outcome := "loss" + if paid == t.betSize*t.balls { + outcome = "push" + } else if paid > t.betSize*t.balls { + outcome = "win" + } + result.Settlement = []SettlementLine{{Label: fmt.Sprintf("%d Plinko balls", t.balls), Odds: "Varies", Stake: t.betSize * t.balls, Returned: paid, Result: outcome}} + result.Color = "casino_payout" + if paid > t.betSize*t.balls { + result.Color = "casino_big_win" + } + // The shared receipt replaces Message, so keep pocket reveals in their own event. + events = append(events, t.private(t.name, strings.Join(hits, "\n")), result, t.betMenuEvent(t.name, t.hintsLocked())) + return events +} +func (t *plinkoTable) markDisconnected(n string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + if t.active && t.wallet != nil { + t.wallet.ResolveStake(t.stake.ID) + } + t.clear() + e := t.event(n+" leaves Plinko.", true) + e.Type = EventLeft + e.Player = n + return []Event{e} +} +func (t *plinkoTable) refundAll() []Event { + t.mu.Lock() + defer t.mu.Unlock() + var events []Event + if t.wallet != nil { + chips, credits, err := t.wallet.RefundOutstanding() + msg := fmt.Sprintf("Plinko closes; %d credits/chips refunded.", chips+credits) + if err != nil { + msg = "Plinko refund could not be persisted; your stake remains pending for recovery." + } + events = append(events, t.private(t.name, msg)) + } + t.clear() + return events +} diff --git a/internal/casino/plinko_test.go b/internal/casino/plinko_test.go new file mode 100644 index 0000000..2b79853 --- /dev/null +++ b/internal/casino/plinko_test.go @@ -0,0 +1,218 @@ +package casino + +import ( + "fmt" + "math" + "math/rand" + "strings" + "testing" +) + +func TestPlinkoRoundedRTPAndVariance(t *testing.T) { + for _, rtp := range []float64{31, 90, 98, 100} { + lastVariance := -1.0 + for risk := 0; risk < 4; risk++ { + p := plinkoPockets(risk, rtp) + choose, mean, second := 1.0, 0.0, 0.0 + for k, v := range p { + if k > 0 { + choose *= float64(17-k) / float64(k) + } + mean += choose * v / 65536 + second += choose * v * v / 65536 + if v < 0 || v != p[16-k] { + t.Fatalf("invalid symmetric pockets %v", p) + } + if k < 8 && v < p[k+1] { + t.Fatalf("pockets must grow toward edges: %v", p) + } + } + if mean < rtp/100 || mean >= rtp/100+0.1 { + t.Fatalf("risk %d rounded RTP %g not within [base, base+0.1)", risk, mean) + } + if math.Abs(mean-plinkoReturn(risk, rtp)) > 1e-12 { + t.Fatal("reported RTP differs from played pockets") + } + for _, v := range p { + if math.Abs(v*10-math.Round(v*10)) > 1e-9 { + t.Fatalf("not a tenth: %g", v) + } + } + variance := second - mean*mean + if variance <= lastVariance { + t.Fatalf("variance did not rise: %g <= %g", variance, lastVariance) + } + lastVariance = variance + if risk == 0 && p[0] != 16 || risk == 3 && p[0] != 10000 { + t.Fatal("wrong jackpot") + } + } + } +} +func newTestPlinko() *plinkoTable { + return newTable(1, TableConfig{ID: "plinko", Game: GamePlinko}, rand.New(rand.NewSource(42))).game.(*plinkoTable) +} +func TestPlinkoDropLifecycle(t *testing.T) { + p := newTestPlinko() + p.join("alice") + p.action("alice", "balls 10") + p.action("alice", "difficulty expert") + w := &testWallet{credits: 1000} + p.bet("alice", "", 10, w) + if len(w.open) != 1 || w.open[0].Total != 100 { + t.Fatal("batch must wager once") + } + p.bet("alice", "", 10, w) + p.action("alice", "balls 2") + p.leave("alice") + if !p.active || p.balls != 10 || len(w.open) != 1 { + t.Fatal("active drop changed") + } + for row := plinkoFirstRow + 1; row <= 16; row++ { + old := append([]int(nil), p.positions...) + events := p.tick() + s := events[0].Snapshot.(*PlinkoSnapshot) + sum := 0 + for _, n := range s.Counts { + sum += n + } + if s.Row != row || sum != 10 { + t.Fatal("wrong animation") + } + for i, pos := range p.positions { + if row <= 0 && pos != old[i] { + t.Fatal("ball moved during label prelude") + } + if pos-old[i] < 0 || pos-old[i] > 1 { + t.Fatal("not a left/right bounce") + } + } + } + if p.active || len(w.open) != 0 { + t.Fatal("unsettled drop") + } + paid := w.paid + p.tick() + p.refundAll() + if w.paid != paid { + t.Fatal("duplicate payout") + } +} +func TestPlinkoRefundAndDisconnect(t *testing.T) { + for _, disconnect := range []bool{false, true} { + p := newTestPlinko() + p.join("alice") + w := &testWallet{credits: 100} + p.bet("alice", "", 10, w) + p.tick() + if disconnect { + p.markDisconnected("alice") + } else { + p.refundAll() + } + if len(w.open) != 0 || !p.idle() { + t.Fatal("stake or seat left open") + } + if !disconnect && w.credits+w.paid != 100 { + t.Fatal("shutdown failed to refund") + } + if disconnect && w.credits != 90 { + t.Fatal("disconnect must forfeit") + } + if len(p.tick()) != 0 { + t.Fatal("discarded drop continued") + } + } +} +func TestPlinkoRoundedPocketsArePaidAndAnalyzed(t *testing.T) { + p := newTestPlinko() + p.join("alice") + p.action("alice", "difficulty expert") + w := &testWallet{credits: 100} + p.bet("alice", "", 10, w) + // Both possible final center pockets round up to 0.1x, not just in the UI. + p.row, p.positions = 15, []int{7} + events := p.tick() + if w.paid != 1 || len(w.open) != 0 { + t.Fatalf("rounded pocket not paid: %+v", w) + } + for _, e := range events { + if strings.Contains(e.Message, "credits returned") { + t.Fatal("duplicate payout prose") + } + } + table := &Table{tableBase: p.tableBase, game: p} + want := 1 - plinkoReturn(3, 98) + if got := table.HouseEdge("alice", "main"); len(got.Parts) != 1 || math.Abs(got.Parts[0].Loss-want) > 1e-12 { + t.Fatalf("seat edge ignores rounded payouts: %+v", got) + } + if got := p.Config.HouseEdge("main"); len(got.Parts) != 4 || math.Abs(got.Parts[3].Loss-want) > 1e-12 { + t.Fatalf("config edge ignores risk: %+v", got) + } +} + +func TestPlinkoSharedPocketLabels(t *testing.T) { + for risk := 0; risk < 4; risk++ { + pockets := plinkoPockets(risk, 98) + labels := PlinkoPocketLabels(pockets) + for i := range pockets { + for j := range pockets { + if (pockets[i] == pockets[j]) != (labels[i] == labels[j]) { + t.Fatalf("risk %d: payout/label mismatch at %d,%d", risk, i, j) + } + } + } + if labels[0] != 'A' || labels[16] != 'A' { + t.Fatal("both edges must be A") + } + } + p := newTestPlinko() + p.join("alice") + w := &testWallet{credits: 100} + p.bet("alice", "", 1, w) + p.row, p.positions = 15, []int{15} + events := p.tick() + labels := PlinkoPocketLabels(plinkoPockets(0, 98)) + want := fmt.Sprintf("Ball 1 -> [%c]", labels[p.positions[0]]) + found := false + for _, e := range events { + found = found || strings.Contains(e.Message, want) + } + if !found { + t.Fatalf("settlement did not use mirrored label %s", want) + } +} + +func TestPlinkoConfig(t *testing.T) { + for _, rtp := range []float64{-1, 30, 101, math.NaN(), math.Inf(1)} { + if (TableConfig{ID: "p", Game: GamePlinko, Plinko: PlinkoConfig{RTP: rtp}}).Validate() == nil { + t.Fatalf("accepted RTP %g", rtp) + } + } + c := TableConfig{ID: "p", Game: GamePlinko} + if err := c.Validate(); err != nil { + t.Fatal(err) + } + if c.Normalize().Plinko.RTP != 98 { + t.Fatal("wrong default") + } + if (TableConfig{ID: "p", Game: GameRoulette, Plinko: PlinkoConfig{RTP: 98}}).Validate() == nil { + t.Fatal("wrong rule block accepted") + } +} +func TestPlinkoFailedPayoutRetainsStake(t *testing.T) { + p := newTestPlinko() + p.join("alice") + w := &failingPayoutWallet{testWallet: testWallet{credits: 100}} + p.bet("alice", "", 10, w) + for i := plinkoFirstRow; i < 16; i++ { + p.tick() + } + if len(w.open) != 1 { + t.Fatal("failed settlement consumed stake") + } + p.leave("alice") + if len(w.open) != 1 { + t.Fatal("leave consumed failed stake") + } +} diff --git a/internal/casino/registry.go b/internal/casino/registry.go index 4366bc6..fdaecec 100644 --- a/internal/casino/registry.go +++ b/internal/casino/registry.go @@ -44,6 +44,16 @@ var tableGames map[GameName]tableGameDef func init() { tableGames = map[GameName]tableGameDef{ + GamePlinko: { + validate: validatePlinko, + normalize: func(c TableConfig) TableConfig { c.Plinko = c.Plinko.Normalize(); c.MaxPlayers = 1; return c }, + ruleBlockName: "plinko", + hasRuleBlock: func(c TableConfig) bool { return c.Plinko != (PlinkoConfig{}) }, + new: newPlinkoTable, + ruleText: plinkoRules, + helpTopic: func(TableConfig) string { return "plinko" }, + houseEdge: plinkoHouseEdge, + }, GamePaiGow: { houseEdge: paiGowHouseEdge, validate: validatePaiGow, diff --git a/internal/casino/types.go b/internal/casino/types.go index 8986dc1..5f4d4f8 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -17,6 +17,7 @@ const ( GamePaiGow GameName = "paigow" GameVideoPoker GameName = "video_poker" GameSlots GameName = "slots" + GamePlinko GameName = "plinko" GameBlackjack GameName = "blackjack" GameBaccarat GameName = "baccarat" GameRoulette GameName = "roulette" @@ -124,6 +125,7 @@ func (c BaccaratConfig) Normalize() BaccaratConfig { } type TableConfig struct { + Plinko PlinkoConfig `yaml:"plinko,omitempty"` ID string `yaml:"id"` Game GameName `yaml:"game"` MaxPlayers int `yaml:"max_players,omitempty"` diff --git a/internal/game/blackjack_rules.go b/internal/game/blackjack_rules.go index 9e36466..f163fb8 100644 --- a/internal/game/blackjack_rules.go +++ b/internal/game/blackjack_rules.go @@ -30,6 +30,10 @@ func (g *Game) lookRouletteTable(sess *net.Session, target string) bool { return g.lookCasinoTable(sess, target, casino.GameRoulette) } +func (g *Game) lookPlinkoTable(sess *net.Session, target string) bool { + return g.lookCasinoTable(sess, target, casino.GamePlinko) +} + func (g *Game) lookPaiGowTable(sess *net.Session, target string) bool { return g.lookCasinoTable(sess, target, casino.GamePaiGow) } diff --git a/internal/game/casino.go b/internal/game/casino.go index 6dce0bd..15e3b6a 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -707,14 +707,15 @@ func (g *Game) casinoBoardReminder(sess *net.Session, category string, menu *cas } _, isCraps := snapshot.(*casino.CrapsSnapshot) _, isPoker := snapshot.(*casino.VideoPokerSnapshot) + _, isPlinko := snapshot.(*casino.PlinkoSnapshot) _, isPaiGow := snapshot.(*casino.PaiGowSnapshot) b, isBigSix := snapshot.(*casino.BigSixSnapshot) isBigSix = isBigSix && b.Phase != "playing" && b.Phase != "result" s, isRoulette := snapshot.(*casino.RouletteSnapshot) - if !isPoker && !isCraps && !isPaiGow && !isBigSix && (!isRoulette || s.Phase == "playing" || s.Phase == "result") { + if !isPlinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && (!isRoulette || s.Phase == "playing" || s.Phase == "result") { return "", false } - if !isPoker && !isCraps && !isPaiGow && !isBigSix && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) { + if !isPlinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) { // The compact reminder is already a short block; leave it intact. return "", false } @@ -741,6 +742,9 @@ func composeTableThenReminder(table, reminder string) string { // renderCasinoMessage colorizes a casino message unless it is an // already-composed structured menu. func (g *Game) renderCasinoMessage(sess *net.Session, category string, menu *casino.MenuView, message string) string { + if message == "" { + return "" + } if category == "casino_menu" && menu != nil { return message } @@ -797,6 +801,8 @@ func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot) // how verbose the command menus are. size := sess.Player.OptionInt("card_size") switch s := snapshot.(type) { + case *casino.PlinkoSnapshot: + return renderPlinko(s, mode, unicode) case *casino.PaiGowSnapshot: return renderPaiGow(s, mode, unicode, size, sess.Player.OptionString("card_style"), g.CardArt) case *casino.VideoPokerSnapshot: diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index 5b60118..d8cc18f 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -38,6 +38,12 @@ var commandRegistry = map[string]commandDef{ } g.executeTableAction(s, "deal") }, ClassActive}, + "difficulty": {func(g *Game, s *net.Session, args []string, _ string) { + g.executeTableAction(s, "difficulty "+strings.Join(args, " ")) + }, ClassInstant}, + "balls": {func(g *Game, s *net.Session, args []string, _ string) { + g.executeTableAction(s, "balls "+strings.Join(args, " ")) + }, ClassInstant}, "paytable": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "paytable") }, ClassInstant}, "denom": {func(g *Game, s *net.Session, args []string, _ string) { g.executeTableAction(s, "denom "+strings.Join(args, " ")) @@ -249,6 +255,7 @@ var casinoCommonVerbs = []string{"watch", "game", "table", "list", "stop", "rule // command: "sp" is split at a blackjack table, but spin at a Big 6 wheel, // because "split" is not a Big 6 verb and "spin" is not a blackjack verb. var casinoVerbsByGame = map[casino.GameName][]string{ + casino.GamePlinko: {"bet", "rebet", "difficulty", "balls", "paytable"}, casino.GameBlackjack: {"bet", "rebet", "unbet", "hit", "stand", "double", "split", "insurance", "surrender", "decline"}, casino.GameBaccarat: {"bet", "rebet", "unbet"}, casino.GameRoulette: {"bet", "rebet", "unbet"}, diff --git a/internal/game/look_casino.go b/internal/game/look_casino.go index ec9ab61..df6a714 100644 --- a/internal/game/look_casino.go +++ b/internal/game/look_casino.go @@ -83,7 +83,7 @@ func (g *Game) renderCasinoTableDetails(sess *net.Session, cfg casino.TableConfi fmt.Sprintf(" Seated %s", seated), } switch cfg.Game { - case casino.GameRoulette, casino.GameBigSix: + case casino.GameRoulette, casino.GameBigSix, casino.GamePlinko: // Look, like sitting down and the table command, shows the live // wheel: tracker, racetrack, and betting layout. if rendered := strings.TrimRight(g.renderCasinoSnapshot(sess, table.Board(sess.Player.Name)), "\n"); rendered != "" { @@ -103,6 +103,8 @@ func (g *Game) renderCasinoTableDetails(sess *net.Session, cfg casino.TableConfi // look details block. The full wording lives in the rules help page. func casinoTableSummary(cfg casino.TableConfig) string { switch cfg.Game { + case casino.GamePlinko: + return fmt.Sprintf("16 rows; four risks; 1-10 balls; %.4g%% base RTP + upward rounding", cfg.Plinko.Normalize().RTP) case casino.GameBlackjack: b := cfg.Blackjack.Normalize() dealer := "dealer stands on soft 17" diff --git a/internal/game/look_target.go b/internal/game/look_target.go index 9065ccc..cfa6009 100644 --- a/internal/game/look_target.go +++ b/internal/game/look_target.go @@ -47,7 +47,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { return } - if g.lookBigSixTable(sess, lower) || g.lookBlackjackTable(sess, lower) || g.lookBaccaratTable(sess, lower) || g.lookRouletteTable(sess, lower) || g.lookCrapsTable(sess, lower) || g.lookPaiGowTable(sess, lower) || g.lookVideoPokerCabinet(sess, lower) { + if g.lookPlinkoTable(sess, lower) || g.lookBigSixTable(sess, lower) || g.lookBlackjackTable(sess, lower) || g.lookBaccaratTable(sess, lower) || g.lookRouletteTable(sess, lower) || g.lookCrapsTable(sess, lower) || g.lookPaiGowTable(sess, lower) || g.lookVideoPokerCabinet(sess, lower) { return } diff --git a/internal/game/plinko_test.go b/internal/game/plinko_test.go new file mode 100644 index 0000000..711525f --- /dev/null +++ b/internal/game/plinko_test.go @@ -0,0 +1,261 @@ +package game + +import ( + "fmt" + "strings" + "testing" + "unicode/utf8" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" +) + +func TestPlinkoCommands(t *testing.T) { + g, s, conn := casinoCrapsGame(t, "name: Plinko\ncasino_tables:\n - id: plinko\n game: plinko\n") + g.executePlay(s, []string{"plinko"}, "play plinko") + g.executeCommand(s, "difficulty", []string{"expert"}, "difficulty expert") + g.executeCommand(s, "balls", []string{"10"}, "balls 10") + table, ok := g.Casino.Participation(s.Player.Name) + if !ok || table == nil { + t.Fatal("not seated") + } + snap := table.Status(s.Player.Name)[0].Snapshot.(*casino.PlinkoSnapshot) + if snap.Total != 10 || snap.Risk != "expert" || snap.Pockets[0] != 10000 { + t.Fatalf("commands failed: %+v", snap) + } + g.executeBet(s, []string{"1"}, "bet 1") + if !table.Busy(s.Player.Name) || len(s.Player.PendingWagers) != 1 { + t.Fatal("drop did not start") + } + conn.data = nil + for i := 0; i < 32 && table.Busy(s.Player.Name); i++ { + g.emitCasinoEvents(g.Casino.Tick()) + } + if table.Busy(s.Player.Name) || len(s.Player.PendingWagers) != 0 { + t.Fatal("drop did not settle") + } + text := stripANSI(string(conn.data)) + if !strings.Contains(text, "Ball 10 ->") || strings.Count(text, "Returned:") != 1 || strings.Contains(text, "credits returned") || strings.Contains(text, "net +") { + t.Fatal(text) + } + conn.data = nil + g.doLookTarget(s, "plinko") + if !strings.Contains(stripANSI(string(conn.data)), "P L I N K O") { + t.Fatal("look missing paytable") + } +} +func TestPlinkoBallShades(t *testing.T) { + if plinkoBallColor(1, 1) != color.NearestXterm256(255, 255, 255) { + t.Fatal("single must be white") + } + for total := 2; total <= 10; total++ { + last := 256 + for count := 1; count <= total; count++ { + r, g, b := color.Xterm256ToRGB(plinkoBallColor(count, total)) + if r != 255 || g != b || g > last { + t.Fatal("invalid white-red gradient") + } + last = g + } + if last != 0 { + t.Fatal("all balls must be pure red") + } + } +} +func TestPlinkoRenderRows(t *testing.T) { + for _, mode := range []string{"none", "ansi", "xterm256"} { + for _, unicode := range []bool{false, true} { + for row := 0; row <= 16; row++ { + s := &casino.PlinkoSnapshot{Row: row, Total: 10} + s.Counts[0] = 10 + out := renderPlinko(s, mode, unicode) + for _, line := range strings.Split(stripANSI(out), "\n") { + if utf8.RuneCountInString(line) != plinkoWidth { + t.Fatal("board must keep its full width, including exterior spaces") + } + } + wantLines := 1 + if row == plinkoRows/2 || row == plinkoRows { + wantLines = 2 // midpoint hint ruler, base rail + } + if got := len(strings.Split(out, "\n")); got != wantLines { + t.Fatalf("row %d printed %d lines, want %d", row, got, wantLines) + } + if strings.Contains(out, "\x1b[48;") { + t.Fatal("the board must not paint a background") + } + if mode == "none" && (!strings.Contains(out, "*") || strings.Contains(out, "\x1b")) { + t.Fatal("plain overlap fallback missing") + } + } + } + } +} + +func TestPlinkoUsesSharedHints(t *testing.T) { + for _, mode := range []string{"on", "once", "off"} { + t.Run(mode, func(t *testing.T) { + g, s, conn := casinoCrapsGame(t, "name: Plinko\ncasino_tables:\n - id: plinko\n game: plinko\n") + s.Player.Options = map[string]any{"color": "none", "unicode": true, "casino_hints": mode} + g.executePlay(s, []string{"plinko"}, "play plinko") + text := string(conn.data) + if strings.Contains(text, "difficulty easy|medium|hard|expert") != (mode != "off") { + t.Fatalf("join hints (%s): %s", mode, text) + } + if mode != "off" && strings.Index(text, "P L I N K O") > strings.Index(text, "bet N") { + t.Fatal("hints must follow the paytable") + } + conn.data = nil + g.executeTableAction(s, "balls 2") + if strings.Contains(string(conn.data), "bet N") != (mode == "on") { + t.Fatalf("settings hints (%s): %s", mode, conn.data) + } + g.executeBet(s, []string{"1"}, "bet 1") + table, _ := g.Casino.Participation(s.Player.Name) + for i := 0; i < 32 && table.Busy(s.Player.Name); i++ { + conn.data = nil + g.emitCasinoEvents(g.Casino.Tick()) + } + if strings.Contains(string(conn.data), "bet N") != (mode == "on") { + t.Fatalf("settlement hints (%s): %s", mode, conn.data) + } + conn.data = nil + g.executeVerbs(s, nil, "what") + if !strings.Contains(string(conn.data), "balls N") { + t.Fatal("explicit hints missing") + } + }) + } +} + +func plinkoTestBoardRow(s *casino.PlinkoSnapshot) []rune { + lines := strings.Split(renderPlinko(s, "none", true), "\n") + return []rune(lines[0]) +} + +func TestPlinkoPegsNeverMove(t *testing.T) { + for row := 0; row <= 16; row++ { + s := &casino.PlinkoSnapshot{Row: row, Total: 1} + empty := plinkoTestBoardRow(s) + left, right := 32-2*row, 36+2*row + pegs := map[int]bool{} + for k := 1; k <= row; k++ { + pegs[left+4*k] = true + if empty[left+4*k] != '·' { + t.Fatalf("row %d: peg missing at its true position %d", row, left+4*k) + } + } + if right < plinkoWidth && empty[right] != '\\' || empty[left] != '/' { + t.Fatalf("row %d: border moved", row) + } + for lane := 0; lane <= row; lane++ { + s.Counts = [17]int{} + s.Counts[lane] = 1 + filled := plinkoTestBoardRow(s) + x := 34 - 2*row + 4*lane + if filled[x] != '●' { + t.Fatalf("ball not in its own lane: row %d lane %d", row, lane) + } + for px := range pegs { + if filled[px] != '·' { + t.Fatalf("row %d lane %d overwrote peg at %d", row, lane, px) + } + } + } + } +} + +func TestPlinkoHintsPrintOnceAtMidpoint(t *testing.T) { + pockets := [17]float64{16, 7.8, 5.9, 4.4, 3.1, 2, 1.1, 0.5, 0.2, 0.5, 1.1, 2, 3.1, 4.4, 5.9, 7.8, 16} + labels := casino.PlinkoPocketLabels(pockets) + isRuler := func(line string) bool { + r := []rune(line) + if len(r) != plinkoWidth || r[1] != ' ' { + return false + } + for k, label := range labels { + if r[2+4*k] != label { + return false + } + } + return true + } + for row := 0; row <= 16; row++ { + s := &casino.PlinkoSnapshot{Row: row, Total: 1, Pockets: pockets} + hasRuler := false + for _, line := range strings.Split(stripANSI(renderPlinko(s, "none", true)), "\n") { + hasRuler = hasRuler || isRuler(line) + } + if hasRuler != (row == plinkoRows/2) { + t.Fatalf("row %d: hint ruler present=%v", row, hasRuler) + } + // The pyramid row itself never carries pocket letters any more. + board := plinkoTestBoardRow(s) + for k, label := range labels { + if board[2+4*k] == label { + t.Fatalf("row %d: pocket hint leaked into the board", row) + } + } + } +} + +func TestPlinkoLegendDownTheLeft(t *testing.T) { + s := &casino.PlinkoSnapshot{Pockets: [17]float64{16, 7.8, 5.9, 4.4, 3.1, 2, 1.1, 0.5, 0.2, 0.5, 1.1, 2, 3.1, 4.4, 5.9, 7.8, 16}, Total: 1} + labels := casino.PlinkoPocketLabels(s.Pockets) + if string(labels[:]) != "ABCDEFGHIHGFEDCBA" { + t.Fatalf("wrong labels: %s", string(labels[:])) + } + entries := plinkoLegendEntries(s) + if len(entries) != 9 { + t.Fatalf("want 9 distinct legend entries, got %d", len(entries)) + } + for row := 0; row < len(entries); row++ { + s.Row = row + line := string(plinkoTestBoardRow(s)) + want := fmt.Sprintf("%c %s", entries[row].label, entries[row].text) + if !strings.HasPrefix(line[1:], want) { + t.Fatalf("row %d: legend entry not on the left: %q", row, line) + } + if entries[row].label == 'A' && !strings.Contains(line, "16x") { + t.Fatal("legend missing the 16x edge payout") + } + } + // The legend stays clear of the pyramid as it widens. + s.Row = len(entries) - 1 + if left := 32 - 2*s.Row; left <= 10 { + t.Fatalf("legend would collide with the pyramid: left border %d", left) + } + // The paytable prints the same entries, one per line, not a wide block. + s.Row = 0 + paytable := stripANSI(renderPlinko(&casino.PlinkoSnapshot{Pockets: s.Pockets, Risk: "easy", Paytable: true}, "none", true)) + for _, e := range entries { + if strings.Count(paytable, fmt.Sprintf("%c %s", e.label, e.text)) != 1 { + t.Fatalf("paytable legend missing or duplicated %c", e.label) + } + } + // Rounded ties, not just mirrored positions, share one legend entry. + s.Row = 16 + s.Counts = [17]int{} + rail := strings.Split(renderPlinko(s, "none", true), "\n")[1] + if !strings.Contains(rail, "[A] [B] [C] [D] [E] [F] [G] [H] [I] [H] [G] [F] [E] [D] [C] [B] [A]") { + t.Fatal(rail) + } + s.Pockets[7], s.Pockets[8], s.Pockets[9] = 0.1, 0.1, 0.1 + if got := plinkoLegendEntries(s); len(got) != 8 { + t.Fatalf("rounded ties duplicated: %d entries", len(got)) + } +} + +func TestPlinkoHintsAreDimWhite(t *testing.T) { + r, g, b := color.Xterm256ToRGB(plinkoHintColor()) + if r != g || g != b { + t.Fatalf("hint is not white: %d,%d,%d", r, g, b) + } + if r > 95 { + t.Fatalf("hint is too bright: %d", r) + } + br, bg, bb := color.Xterm256ToRGB(plinkoBallColor(1, 1)) + if r >= br || g >= bg || b >= bb { + t.Fatal("hint must be dimmer than a ball") + } +} diff --git a/internal/game/render_plinko.go b/internal/game/render_plinko.go new file mode 100644 index 0000000..17f533a --- /dev/null +++ b/internal/game/render_plinko.go @@ -0,0 +1,213 @@ +package game + +import ( + "fmt" + "math" + "strings" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" +) + +const plinkoWidth = 69 + +// plinkoRows is the board height (16 bounces, 17 pockets). +const plinkoRows = 16 + +func plinkoBallColor(count, total int) int { + shade := 255 + if total > 1 && count > 1 { + shade = 255 * (total - count) / (total - 1) + } + return color.NearestXterm256(255, shade, shade) +} + +// plinkoGradientRGB is the board's shared hue: icy white at the center, +// deepening through sky blue and violet to a saturated rose at the edges. +func plinkoGradientRGB(x int) [3]int { + stops := [4][3]int{{215, 255, 255}, {85, 195, 255}, {145, 85, 245}, {255, 0, 95}} + distance := math.Abs(float64(x-34)) / 34 + segment := min(2, int(distance*3)) + blend := distance*3 - float64(segment) + var rgb [3]int + for i := range rgb { + rgb[i] = int(math.Round(float64(stops[segment][i])*(1-blend) + float64(stops[segment+1][i])*blend)) + } + return rgb +} + +func plinkoColorAt(x int, scale float64) int { + rgb := plinkoGradientRGB(x) + return color.NearestXterm256( + max(0, min(255, int(float64(rgb[0])*scale))), + max(0, min(255, int(float64(rgb[1])*scale))), + max(0, min(255, int(float64(rgb[2])*scale)))) +} + +// plinkoHintColor is the dim white used by the one-time midpoint pocket ruler. +func plinkoHintColor() int { return 238 } + +// Foregrounds are deliberately faint so the balls stay the brightest thing +// on the board. +func plinkoPegColor(x int) int { return plinkoColorAt(x, 0.68) } +func plinkoBorderColor(x int) int { return plinkoColorAt(x, 0.78) } +func plinkoPocketColor(x int) int { return plinkoColorAt(x, 0.90) } + +func plinkoMultiplier(multiplier float64) string { + return strings.TrimSuffix(fmt.Sprintf("%.1f", multiplier), ".0") + "x" +} + +type plinkoCell struct { + glyph rune + fg int + guide bool +} +type plinkoLine [plinkoWidth]plinkoCell + +func newPlinkoLine() plinkoLine { + var line plinkoLine + for x := range line { + line[x] = plinkoCell{glyph: ' ', fg: -1} + } + return line +} +func (line *plinkoLine) text(x int, text string, fg int) { + for _, c := range text { + if x >= len(line) { + break + } + line[x] = plinkoCell{glyph: c, fg: fg} + x++ + } +} +func (line plinkoLine) render(mode string) string { + var b strings.Builder + for _, cell := range line { + fg := cell.fg + // Basic ANSI has no dim white; the dim attribute plus bright black is + // its closest readable stand-in for a faint white hint. + dim := false + if cell.guide && mode == "ansi" { + fg, dim = 7, true + } + b.WriteString(color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1, Dim: dim}, string(cell.glyph))) + } + return b.String() +} + +type plinkoLegendEntry struct { + label rune + text string + // pocket is the left-most pocket index that carries this label, used to + // tint the entry with that column's board hue. + pocket int +} + +// plinkoLegendEntries is one entry per distinct payout, outside-to-center. +// Mirrored and rounded-tie pockets share a letter and therefore one entry. +func plinkoLegendEntries(s *casino.PlinkoSnapshot) []plinkoLegendEntry { + labels := casino.PlinkoPocketLabels(s.Pockets) + var entries []plinkoLegendEntry + seen := map[rune]bool{} + for k, label := range labels { + if seen[label] { + continue + } + seen[label] = true + entries = append(entries, plinkoLegendEntry{label: label, text: plinkoMultiplier(s.Pockets[k]), pocket: k}) + } + return entries +} + +// plinkoLegendText pads an entry to a fixed width so a guide letter in the +// next column never butts directly against the multiplier. +func plinkoLegendText(e plinkoLegendEntry) string { + return fmt.Sprintf("%c %-7s", e.label, e.text) +} + +// The legend is written down the left, outside the pyramid, one entry per +// row, so every value is known well before the ball reaches the pockets. +func renderPlinkoLegend(s *casino.PlinkoSnapshot, mode string) []string { + var lines []string + for _, e := range plinkoLegendEntries(s) { + line := newPlinkoLine() + line.text(1, plinkoLegendText(e), plinkoPocketColor(2+4*e.pocket)) + lines = append(lines, line.render(mode)) + } + return lines +} + +// plinkoHintLine is the one-time pocket ruler printed at the board's midpoint, +// so players learn the pocket columns without a letter on every row. +func plinkoHintLine(labels [17]rune, mode string) string { + hint := newPlinkoLine() + for k, label := range labels { + hint[2+4*k] = plinkoCell{glyph: label, fg: plinkoHintColor(), guide: true} + } + return hint.render(mode) +} + +// The legend appears down the left, then each snapshot appends one scanline. +// The pocket hints print once, as a ruler at the board's midpoint. +func renderPlinko(s *casino.PlinkoSnapshot, mode string, unicode bool) string { + labels := casino.PlinkoPocketLabels(s.Pockets) + if s.Paytable { + title := "=== P L I N K O / " + strings.ToUpper(s.Risk) + " ===" + heading := newPlinkoLine() + heading.text((plinkoWidth-len(title))/2, title, plinkoPocketColor(34)) + note := newPlinkoLine() + note.text(1, "Matching letters share a payout; returns include the stake.", plinkoPocketColor(34)) + lines := []string{heading.render(mode), note.render(mode)} + lines = append(lines, renderPlinkoLegend(s, mode)...) + tail := newPlinkoLine() + tail.text(1, "White = one ball; shades to red = all balls overlapping.", plinkoPocketColor(34)) + return strings.Join(append(lines, tail.render(mode)), "\n") + } + ball, peg := 'o', '.' + if unicode { + ball, peg = '●', '·' + } + line := newPlinkoLine() + left, right := 32-2*s.Row, 36+2*s.Row + line[left] = plinkoCell{glyph: '/', fg: plinkoBorderColor(left)} + line[right] = plinkoCell{glyph: '\\', fg: plinkoBorderColor(right)} + // Pegs sit at their true between-lane positions; they are never moved to + // make room for a guide letter. + for k := 1; k <= s.Row; k++ { + x := left + 4*k + line[x] = plinkoCell{glyph: peg, fg: plinkoPegColor(x)} + } + for k := 0; k <= s.Row; k++ { + count := s.Counts[k] + if count == 0 { + continue + } + glyph := ball + if mode == "none" && count > 1 { + glyph = rune('0' + count) + if count == 10 { + glyph = '*' + } + } + line[left+2+4*k] = plinkoCell{glyph: glyph, fg: plinkoBallColor(count, s.Total)} + } + if entries := plinkoLegendEntries(s); s.Row < len(entries) { + e := entries[s.Row] + line.text(1, plinkoLegendText(e), plinkoPocketColor(2+4*e.pocket)) + } + lines := []string{line.render(mode)} + if s.Row == plinkoRows/2 { + lines = append(lines, plinkoHintLine(labels, mode)) + } + if s.Row == 16 { + rail := newPlinkoLine() + for k, label := range labels { + x := 1 + 4*k + rail[x] = plinkoCell{glyph: '[', fg: plinkoPegColor(x)} + rail[x+1] = plinkoCell{glyph: label, fg: plinkoPocketColor(x + 1)} + rail[x+2] = plinkoCell{glyph: ']', fg: plinkoPegColor(x + 2)} + } + lines = append(lines, rail.render(mode)) + } + return strings.Join(lines, "\n") +} -- cgit v1.2.3