aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/casino/house_edge.go7
-rw-r--r--internal/casino/plinko.go395
-rw-r--r--internal/casino/plinko_test.go218
-rw-r--r--internal/casino/registry.go10
-rw-r--r--internal/casino/types.go2
-rw-r--r--internal/game/blackjack_rules.go4
-rw-r--r--internal/game/casino.go10
-rw-r--r--internal/game/cmd_registry.go7
-rw-r--r--internal/game/look_casino.go4
-rw-r--r--internal/game/look_target.go2
-rw-r--r--internal/game/plinko_test.go261
-rw-r--r--internal/game/render_plinko.go213
12 files changed, 1129 insertions, 4 deletions
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")
+}