aboutsummaryrefslogtreecommitdiff
path: root/internal/game/render_pachinko.go
blob: e973f04f7f99c670d718162241a6dc3359c9c204 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
package game

import (
	"fmt"
	"math"
	"strconv"
	"strings"

	"thehouseoficarus/internal/casino"
	"thehouseoficarus/internal/color"
	"thehouseoficarus/internal/ui"
)

// The rectangle is drawn on a fixed pitch: eight lanes at x = 3+6k, pegs on
// the boundaries at x = 6+6k, and walls at both ends. Every scanline has the
// same width, so a drop prints one row per tick and the ball's path reads as a
// diagonal column of frames. The right wall sits one half-lane past the last
// lane, mirroring the left wall, so the outer slots have the same clearance on
// both sides.
const (
	pachinkoLanes  = 8
	pachinkoRows   = 20
	pachinkoPitch  = 6
	pachinkoWidth  = pachinkoLanes*pachinkoPitch + 1
	pachinkoBallX  = 3
	pachinkoPegGap = 6
)

// pachinkoLaneX is the fixed column of a lane on an even scanline; the prize
// row and the top labels use it so a settled ball lines up with its pocket.
func pachinkoLaneX(lane int) int { return pachinkoBallX + pachinkoPitch*lane }

// pachinkoDropX staggers the ball's column on odd rows so a ball that holds its
// lane visibly weaves between the pins instead of sliding down a static grid.
func pachinkoDropX(row, lane int) int {
	if row%2 == 0 {
		return pachinkoLaneX(lane)
	}
	return pachinkoPitch * (lane + 1)
}

// pachinkoGradientRGB gives the board a warm jewel ramp: deep violet walls,
// through blue and teal, to a golden-red right edge.
func pachinkoGradientRGB(x int) [3]int {
	stops := [5][3]int{{150, 80, 255}, {60, 170, 255}, {40, 230, 190}, {255, 210, 60}, {255, 90, 70}}
	distance := float64(x) / float64(pachinkoWidth-1)
	segment := min(3, int(distance*4))
	blend := distance*4 - 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 pachinkoColorAt(x int, scale float64) int {
	rgb := pachinkoGradientRGB(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))))
}

func pachinkoPegColor(x int) int    { return pachinkoColorAt(x, 0.45) }
func pachinkoWallColor(x int) int   { return pachinkoColorAt(x, 0.85) }
func pachinkoHeaderColor(x int) int { return pachinkoColorAt(x, 1.0) }

// pachinkoPocketColor maps a prize to a readable color; doubling shifts an
// already-valuable pocket hotter.
func pachinkoPocketColor(p casino.PachinkoPocket, doubling int) int {
	switch {
	case p.Double:
		if doubling > 0 {
			return 201 // bright magenta
		}
		return 198 // magenta
	case p.Value >= 100:
		return 51 // neon cyan
	case p.Value >= 10:
		if doubling > 0 {
			return 226
		}
		return 220 // gold
	case p.Value >= 5:
		return 208 // orange
	case p.Value >= 2:
		return 46 // green
	case p.Value >= 1:
		return 49 // teal: break-even and small fractions
	case p.Value > 0:
		return 245 // dim fraction
	default:
		return 240 // dim slate for 0x
	}
}

func pachinkoPocketBold(p casino.PachinkoPocket, doubling int) bool {
	return !p.Double && (p.Value >= 2 || doubling > 0)
}

type pachinkoCell struct {
	glyph rune
	fg    int
	bold  bool
}

type pachinkoLine [pachinkoWidth]pachinkoCell

func newPachinkoLine() pachinkoLine {
	var line pachinkoLine
	for x := range line {
		line[x] = pachinkoCell{glyph: ' ', fg: -1}
	}
	return line
}

func (line *pachinkoLine) put(x int, glyph rune, fg int) {
	if x >= 0 && x < len(line) {
		line[x] = pachinkoCell{glyph: glyph, fg: fg}
	}
}

func (line *pachinkoLine) putBold(x int, glyph rune, fg int) {
	if x >= 0 && x < len(line) {
		line[x] = pachinkoCell{glyph: glyph, fg: fg, bold: true}
	}
}

// centeredStart returns the first cell of a text of length n centered in the
// 6-wide lane k cell.
func pachinkoCenteredStart(lane, n int) int {
	if n > pachinkoPitch {
		n = pachinkoPitch
	}
	return pachinkoPitch*lane + (pachinkoPitch-n+1)/2
}

func (line pachinkoLine) render(mode string) string {
	var b strings.Builder
	for x := 0; x < len(line); {
		cell := line[x]
		end := x + 1
		for end < len(line) && line[end].fg == cell.fg && line[end].bold == cell.bold {
			end++
		}
		var text strings.Builder
		for _, c := range line[x:end] {
			text.WriteRune(c.glyph)
		}
		b.WriteString(color.Render(mode, color.ColorSpec{Fg: cell.fg, Bg: -1, Bold: cell.bold}, text.String()))
		x = end
	}
	return b.String()
}

func pachinkoGlyphs(unicode bool) (ball, peg rune) {
	if unicode {
		return '●', '·'
	}
	return 'o', '.'
}

// pachinkoScanline draws one staggered peg row, optionally with the ball in a
// lane.
func pachinkoScanline(row, ballLane int, mode string, unicode bool) string {
	ball, peg := pachinkoGlyphs(unicode)
	line := newPachinkoLine()
	line.put(0, '|', pachinkoWallColor(0))
	line.put(pachinkoWidth-1, '|', pachinkoWallColor(pachinkoWidth-1))
	if row%2 == 0 {
		for k := 0; k < pachinkoLanes-1; k++ {
			x := pachinkoPegGap + pachinkoPitch*k
			line.put(x, peg, pachinkoPegColor(x))
		}
	} else {
		for k := 0; k < pachinkoLanes; k++ {
			x := pachinkoBallX + pachinkoPitch*k
			line.put(x, peg, pachinkoPegColor(x))
		}
	}
	if ballLane >= 0 && ballLane < pachinkoLanes {
		line.putBold(pachinkoDropX(row, ballLane), ball, 231)
	}
	return line.render(mode)
}

// pachinkoPocketLabel is the text shown for a landed value after doublings.
func pachinkoPocketLabel(p casino.PachinkoPocket, doubling int) string {
	if p.Double {
		return "DBL"
	}
	value := p.Value
	for i := 0; i < doubling && i < 62; i++ {
		value *= 2
		if value > casino.MaxCredits {
			return "MAX"
		}
	}
	if value > casino.MaxCredits {
		return "MAX"
	}
	return strconv.FormatFloat(value, 'f', -1, 64) + "x"
}

// pachinkoPocketRow prints the prize row, marking the landed pocket.
func pachinkoPocketRow(s *casino.PachinkoSnapshot, mode string) string {
	var b strings.Builder
	for lane := 0; lane < pachinkoLanes; lane++ {
		pocket := s.Pockets[lane]
		label := pachinkoPocketLabel(pocket, s.Doubling)
		landed := s.Landing == lane && (s.Phase == "result" || s.Phase == "dropping")
		text := label
		if landed && len([]rune("["+label+"]")) <= pachinkoPitch {
			text = "[" + label + "]"
		}
		fg := pachinkoPocketColor(pocket, s.Doubling)
		bold := pachinkoPocketBold(pocket, s.Doubling)
		if landed {
			bold = true
			switch {
			case pocket.Double:
				fg = 201
			case pocket.Value >= 100:
				fg = 51
			default:
				fg = 226
			}
		}
		runes := []rune(text)
		if len(runes) > pachinkoPitch {
			runes = runes[:pachinkoPitch]
		}
		lead := pachinkoCenteredStart(lane, len(runes)) - pachinkoPitch*lane
		if lead < 0 {
			lead = 0
		}
		trail := pachinkoPitch - lead - len(runes)
		if trail < 0 {
			trail = 0
		}
		b.WriteString(strings.Repeat(" ", lead))
		b.WriteString(color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1, Bold: bold}, string(runes)))
		b.WriteString(strings.Repeat(" ", trail))
	}
	// The cells span pachinkoLanes*pachinkoPitch; pad to the board width.
	b.WriteString(strings.Repeat(" ", pachinkoWidth-pachinkoLanes*pachinkoPitch))
	return b.String()
}

// renderPachinkoBoard draws the whole rectangle: the numbered top slots, the
// peg rows, and the prize row. The labels sit in the gaps of the first peg row
// (not on its pins) so they read as slots to drop the ball into. The first
// drawn row is the even entry row, whose pins bracket each slot number.
func renderPachinkoBoard(s *casino.PachinkoSnapshot, mode string, unicode bool) string {
	lines := make([]string, 0, pachinkoRows+3)
	header := newPachinkoLine()
	for lane := 0; lane < pachinkoLanes; lane++ {
		x := pachinkoLaneX(lane)
		header.put(x, rune('1'+lane), pachinkoHeaderColor(x))
	}
	lines = append(lines, header.render(mode))
	for row := 0; row <= pachinkoRows; row++ {
		lines = append(lines, pachinkoScanline(row, -1, mode, unicode))
	}
	lines = append(lines, pachinkoPocketRow(s, mode))
	return strings.Join(lines, "\n")
}

// pachinkoBlankPocketRow prints a prize row of question marks for the
// reference view, where no board has been dealt yet.
func pachinkoBlankPocketRow(mode string) string {
	var b strings.Builder
	for lane := 0; lane < pachinkoLanes; lane++ {
		x := pachinkoLaneX(lane)
		fg := pachinkoColorAt(x, 0.6)
		lead := pachinkoCenteredStart(lane, 1) - pachinkoPitch*lane
		if lead < 0 {
			lead = 0
		}
		trail := pachinkoPitch - lead - 1
		if trail < 0 {
			trail = 0
		}
		b.WriteString(strings.Repeat(" ", lead))
		b.WriteString(color.Render(mode, color.ColorSpec{Fg: fg, Bg: -1, Bold: true}, "?"))
		b.WriteString(strings.Repeat(" ", trail))
	}
	b.WriteString(strings.Repeat(" ", pachinkoWidth-pachinkoLanes*pachinkoPitch))
	return b.String()
}

// renderPachinkoBlankBoard draws the numbered top slots, the peg rows, and a
// row of question marks in place of the pockets.
func renderPachinkoBlankBoard(mode string, unicode bool) string {
	lines := make([]string, 0, pachinkoRows+3)
	header := newPachinkoLine()
	for lane := 0; lane < pachinkoLanes; lane++ {
		x := pachinkoLaneX(lane)
		header.put(x, rune('1'+lane), pachinkoHeaderColor(x))
	}
	lines = append(lines, header.render(mode))
	for row := 0; row <= pachinkoRows; row++ {
		lines = append(lines, pachinkoScanline(row, -1, mode, unicode))
	}
	lines = append(lines, pachinkoBlankPocketRow(mode))
	return strings.Join(lines, "\n")
}

// pachinkoOddsColor maps a prize tier to the color its label uses in the table.
func pachinkoOddsColor(label string) int {
	switch label {
	case "1.7x":
		return 49
	case "2x":
		return 46
	case "5x":
		return 208
	case "10x":
		return 220
	case "DOUBLE":
		return 198
	case "100x":
		return 51
	default:
		return 240
	}
}

// renderPachinkoOddsTable lays the board composition out as a bordered table.
// The chance column carries the exact percentages, so it is blanked unless the
// player carries a House Edge instrument.
func renderPachinkoOddsTable(odds []casino.PachinkoPrizeOdds, mode string, unicode, showOdds bool, precision int) string {
	rows := make([][]string, 0, len(odds))
	for _, odd := range odds {
		chance := "?"
		if showOdds {
			if odd.Chance > 0 {
				chance = strconv.FormatFloat(100*odd.Chance, 'f', precision, 64) + "%"
			} else {
				chance = "—"
			}
		}
		label := color.Render(mode, color.ColorSpec{Fg: pachinkoOddsColor(odd.Label), Bg: -1, Bold: odd.Label == "DOUBLE" || odd.Label == "100x"}, odd.Label)
		rows = append(rows, []string{label, chance, odd.Odds})
	}
	table := ui.Table{
		Title:   "Pachinko pay table",
		Columns: []string{"Prize", "Chance", "Odds"},
		Rows:    rows,
	}
	return strings.Join(table.Render(unicode, 0), "\n")
}

// pachinkoIntro is the framing text every player sees, whether they are just
// sitting down or reading the table reference. It is never gated by items.
func pachinkoIntro(mode string) string {
	return color.Render(mode, color.ColorSpec{Fg: 253, Bg: -1}, "You stand atop a giant peg board with 8 slots and 20 rows. Win multipliers are randomized as soon as you place a bet.")
}

// renderPachinkoReference is the table command's view: the framing text, a
// blank board, and the pay table. The RTP footer appears only with a House Edge
// instrument, matching the gated chance column.
func renderPachinkoReference(s *casino.PachinkoSnapshot, mode string, unicode, showOdds bool, precision int) string {
	parts := []string{
		pachinkoIntro(mode),
		"",
		renderPachinkoBlankBoard(mode, unicode),
		"",
		renderPachinkoOddsTable(s.Odds, mode, unicode, showOdds, precision),
	}
	if showOdds {
		format := func(v float64) string { return strconv.FormatFloat(v, 'f', precision, 64) + "%" }
		parts = append(parts, "", fmt.Sprintf("Best slot: %s RTP    Random slot: %s RTP", format(s.RTP), format(s.Uniform)))
	}
	return strings.Join(parts, "\n")
}

// renderPachinko renders the game view: two scanlines per tick while the ball
// falls, the full board while choosing, the prize row once it settles, and the
// blank board plus pay table for the table command. showOdds controls whether
// the exact percentages appear; precision is the House Edge instrument's
// rounding (0 or 2 decimal places).
func renderPachinko(s *casino.PachinkoSnapshot, mode string, unicode, showOdds bool, precision int) string {
	if s == nil {
		return ""
	}
	switch s.Phase {
	case "paytable":
		return renderPachinkoReference(s, mode, unicode, showOdds, precision)
	case "idle":
		// Sitting down shows the framing text and the menu, but not the blank
		// board or pay table; those wait for the table command.
		return pachinkoIntro(mode)
	case "dropping":
		return pachinkoScanline(s.Row, s.Lane, mode, unicode)
	case "result":
		// The ball path is already on screen; the settled view is just the
		// prize row so the multipliers land as the last line, with no repeated
		// board or number header.
		return pachinkoPocketRow(s, mode)
	default:
		return renderPachinkoBoard(s, mode, unicode)
	}
}