aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/pai_gow.go
blob: 21abf4b73c2663be9f4acca1b5a3e88901dbd547 (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
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
package casino

import (
	"fmt"
	"math/rand"
	"strings"
	"sync"
)

type PaiGowSnapshot struct {
	Config                                  TableConfig
	Phase                                   string
	Cards, High, Low, DealerHigh, DealerLow []CardView
	Tiebreaker                              []CardView
	Result                                  string
}

func (*PaiGowSnapshot) snapshot() {}

type pgPlayer struct {
	name                      string
	wallet                    Wallet
	main, fortune             Wager
	pushace                   Wager
	last                      int
	cards                     []card
	split                     pgSplit
	ready, disconnected, paid bool
	payout                    int
	result                    string
}
type paiGowTable struct {
	*tableBase
	mu          sync.Mutex
	rng         *rand.Rand
	players     []*pgPlayer // stable seat/deal/settlement order
	phase       string
	ticks       int
	dealer      []card
	dealerSplit pgSplit
	tie         [2]card
}

func newPaiGowTable(room int, c TableConfig, rng *rand.Rand) tableGame {
	return &paiGowTable{tableBase: &tableBase{RoomID: room, Config: c}, rng: rng, phase: "waiting"}
}
func (t *paiGowTable) base() *tableBase { return t.tableBase }
func (t *paiGowTable) player(name string) *pgPlayer {
	for _, p := range t.players {
		if p.name == name {
			return p
		}
	}
	return nil
}
func (t *paiGowTable) remove(name string) {
	for i, p := range t.players {
		if p.name == name {
			t.players = append(t.players[:i], t.players[i+1:]...)
			return
		}
	}
}
func (t *paiGowTable) idle() bool { t.mu.Lock(); defer t.mu.Unlock(); return len(t.players) == 0 }
func (t *paiGowTable) hasParticipant(name string) bool {
	t.mu.Lock()
	defer t.mu.Unlock()
	return t.player(name) != nil
}
func (t *paiGowTable) overview() string {
	t.mu.Lock()
	defer t.mu.Unlock()
	return fmt.Sprintf("%s | %s | %d/%d seats", t.Config.Label(), t.phase, len(t.players), t.Config.MaxPlayers)
}
func (t *paiGowTable) hintsLocked(name string) []MenuCommand {
	p := t.player(name)
	if p == nil {
		return nil
	}
	commands := []MenuCommand{}
	switch t.phase {
	case "waiting", "betting":
		if p.main.Total == 0 {
			commands = append(commands, MenuCommand{Command: "bet <amount>", Desc: "Main wager; opens a timed betting window"})
		} else {
			if p.fortune.Total == 0 {
				commands = append(commands, MenuCommand{Command: "bet <amount> fortune", Desc: "Optional Fortune side bet before deal"})
			}
			if t.Config.PaiGow.Variant == PaiGowFaceUp && p.pushace.Total == 0 {
				commands = append(commands, MenuCommand{Command: "bet <amount> pushace", Desc: "Optional Push Ace High side bet (max half the main wager)"})
			}
			commands = append(commands, MenuCommand{Command: "deal", Desc: "Mark ready; deal when all bettors are ready"})
		}
	case "setting":
		if p.main.Total > 0 && !p.ready {
			commands = append(commands, MenuCommand{Command: "24 / 13456", Desc: "Choose two low cards OR five high cards; locks the hand"}, MenuCommand{Command: "house", Desc: "Set your hand the house way"})
		}
	case "settling":
		commands = append(commands, MenuCommand{Command: "settle", Desc: "Retry the same pending payment"})
	}
	return append(commands, MenuCommand{Command: "table rules", Desc: "Rules and Fortune felt"}, MenuCommand{Command: "rules", Desc: "Learn Pai Gow"}, MenuCommand{Command: "stop", Desc: "Leave after finishing your wager"})
}
func (t *paiGowTable) hints(name string) []MenuCommand {
	t.mu.Lock()
	defer t.mu.Unlock()
	return t.hintsLocked(name)
}
func (t *paiGowTable) wagers(name string) []WagerRow {
	t.mu.Lock()
	defer t.mu.Unlock()
	p := t.player(name)
	if p == nil || t.phase == "setting" || t.phase == "settling" {
		return nil
	}
	rows := []WagerRow{{Bet: "main", Payout: "See table rules", Win: "Beat both dealer hands"}, {Bet: "fortune", Payout: "See Fortune felt", Win: "Best seven-card bonus; independent of main result"}}
	if t.Config.PaiGow.Variant == PaiGowFaceUp {
		rows = append(rows, WagerRow{Bet: "pushace", Payout: "40/15/5:1", Win: "Dealer ace-high pai gow; both 40:1"})
	}
	return rows
}
func pgViews(cards []card) []CardView {
	var out []CardView
	for _, c := range cards {
		out = append(out, CardView{Rank: c.rank, Suit: c.suit})
	}
	return out
}
func (t *paiGowTable) statusLocked(name string) []Event {
	s := &PaiGowSnapshot{Config: t.Config, Phase: t.phase}
	if p := t.player(name); p != nil {
		s.Cards = pgViews(p.cards)
		s.High = pgViews(p.split.high)
		s.Low = pgViews(p.split.low)
		s.Result = p.result
		if t.phase == "settling" && !p.paid && p.main.Total > 0 {
			s.Result += "; PAYMENT PENDING (not yet credited)"
		}
	}
	if t.phase == "settling" || t.phase == "waiting" || t.phase == "setting" && t.Config.PaiGow.Variant == PaiGowFaceUp {
		s.DealerHigh = pgViews(t.dealerSplit.high)
		s.DealerLow = pgViews(t.dealerSplit.low)
		if len(t.dealer) > 0 && t.Config.PaiGow.Variant == PaiGowNoPush {
			s.Tiebreaker = pgViews(t.tie[:])
		}
	}
	e := t.private(name, t.Config.Label())
	e.Snapshot = s
	e.Menu = &MenuView{Heading: t.Config.Label() + " | " + t.phase, ShowBalance: true, Commands: t.hintsLocked(name)}
	return []Event{e}
}
func (t *paiGowTable) status(name string) []Event {
	t.mu.Lock()
	defer t.mu.Unlock()
	return t.statusLocked(name)
}
func (t *paiGowTable) join(name string) []Event {
	t.mu.Lock()
	defer t.mu.Unlock()
	if p := t.player(name); p != nil {
		p.disconnected = false
		return t.statusLocked(name)
	}
	if len(t.players) >= t.Config.MaxPlayers {
		return []Event{t.private(name, "This Pai Gow table is full.")}
	}
	t.players = append(t.players, &pgPlayer{name: name})
	return t.statusLocked(name)
}
func (t *paiGowTable) leave(name string) []Event {
	t.mu.Lock()
	defer t.mu.Unlock()
	if p := t.player(name); p != nil && p.main.Total > 0 {
		return []Event{t.private(name, "Finish your Pai Gow round before leaving; house sets your hand.")}
	}
	t.remove(name)
	return []Event{t.private(name, "You leave Pai Gow Poker.")}
}
func (t *paiGowTable) rebet(name string) (string, int) {
	t.mu.Lock()
	defer t.mu.Unlock()
	if p := t.player(name); p != nil && p.last > 0 {
		return "main", p.last
	}
	return "main", t.Config.MinBet
}
func (t *paiGowTable) bet(name, spot string, amount int, w Wallet) []Event {
	t.mu.Lock()
	defer t.mu.Unlock()
	p := t.player(name)
	if p == nil {
		return nil
	}
	fail := func(s string) []Event { return []Event{t.private(name, s)} }
	if t.phase != "waiting" && t.phase != "betting" {
		return fail("Betting is closed.")
	}
	if spot == "" {
		spot = "main"
	}
	if spot != "main" && spot != "fortune" && spot != "pushace" {
		return fail("Bet on main or fortune.")
	}
	if spot == "pushace" && t.Config.PaiGow.Variant != PaiGowFaceUp {
		return fail("Push Ace High is a Face Up side bet only.")
	}
	if spot == "main" && p.main.Total > 0 || spot == "fortune" && p.fortune.Total > 0 || spot == "pushace" && p.pushace.Total > 0 {
		return fail("That wager is already committed.")
	}
	if spot != "main" && (p.main.Total == 0 || p.ready) {
		return fail("Place a main bet first, then side bets before marking ready with deal.")
	}
	if spot == "pushace" && amount > p.main.Total/2 {
		return fail("Push Ace High is capped at half the main wager.")
	}
	if amount < t.Config.MinBet || amount > t.Config.MaxBet || amount > pgMaxWager {
		return fail("Wager must be " + betRangeText(t.Config.MinBet, min(t.Config.MaxBet, pgMaxWager)) + ".")
	}
	if w == nil {
		return fail("Wallet unavailable.")
	}
	stake, err := w.Wager(amount)
	if err != nil {
		return fail(walletErrorMessage(err))
	}
	p.wallet = w
	if spot == "main" {
		p.main = stake
		p.last = amount
		p.cards = nil
		p.split = pgSplit{}
		p.result = ""
		p.ready = false
		p.paid = false
		p.fortune = Wager{}
		p.pushace = Wager{}
	} else if spot == "pushace" {
		p.pushace = stake
	} else {
		p.fortune = stake
	}
	if t.phase == "waiting" {
		t.phase = "betting"
		t.ticks = t.Config.BettingTicks
		t.dealer = nil
		t.dealerSplit = pgSplit{}
	}
	return append(fail(fmt.Sprintf("Committed %d on %s. Betting closes in %d ticks; Fortune is optional, then deal.", amount, spot, t.ticks)), t.statusLocked(name)...)
}
func (t *paiGowTable) allReady() bool {
	found := false
	for _, p := range t.players {
		if p.main.Total > 0 {
			found = true
			if !p.ready {
				return false
			}
		}
	}
	return found
}
func (t *paiGowTable) dealLocked() []Event {
	deck := newShoe(1, 0, t.rng)
	deck.cards = append(deck.cards, card{"JK", "joker"})
	t.rng.Shuffle(len(deck.cards), func(i, j int) { deck.cards[i], deck.cards[j] = deck.cards[j], deck.cards[i] })
	// Seven packets, including unused seats, leave the published No Push
	// tiebreakers at cards 50 and 51. Empty packets never qualify for Envy.
	packets := [7][]card{}
	for i := range packets {
		for j := 0; j < 7; j++ {
			packets[i] = append(packets[i], deck.draw())
		}
	}
	t.dealer = packets[6]
	t.dealerSplit = pgHouse(t.dealer)
	t.tie = [2]card{deck.draw(), deck.draw()}
	t.phase = "setting"
	t.ticks = t.Config.PaiGow.ActionTicks
	// Face Up ace-high settles immediately: there is no decision to make.
	// Still assign every player's seven cards for independent Fortune/Envy.
	autoPush := t.Config.PaiGow.Variant == PaiGowFaceUp && pgDealerPush(t.dealer, PaiGowFaceUp) != ""
	var events []Event
	for i, p := range t.players {
		if p.main.Total == 0 {
			continue
		}
		p.cards = packets[i]
		p.ready = p.disconnected || autoPush
		if p.ready {
			p.split = pgHouse(p.cards)
		}
		if !autoPush {
			events = append(events, t.statusLocked(p.name)...)
		}
	}
	if t.allReady() {
		events = append(events, t.prepareSettlementLocked()...)
	}
	return events
}
func (t *paiGowTable) action(name, action string) []Event {
	t.mu.Lock()
	defer t.mu.Unlock()
	p := t.player(name)
	if p == nil {
		return nil
	}
	action = strings.TrimSpace(strings.ToLower(action))
	if action == "paytable" || action == "status" {
		return t.statusLocked(name)
	}
	if t.phase == "settling" && action == "settle" {
		return t.payLocked()
	}
	if action == "deal" && t.phase == "betting" && p.main.Total > 0 {
		p.ready = true
		if t.allReady() {
			return t.dealLocked()
		}
		return []Event{t.private(name, "Ready. Waiting for other bettors or the betting timer.")}
	}
	if t.phase != "setting" || p.main.Total == 0 {
		return []Event{t.private(name, "Place your bets, then deal; set cards only after they are dealt.")}
	}
	if p.ready {
		return []Event{t.private(name, "Your hand is locked; waiting for the other players.")}
	}
	var split pgSplit
	if action == "house" || action == "house way" || action == "set house" {
		split = pgHouse(p.cards)
	} else {
		digits, ok := ParsePaiGowDigits(strings.TrimPrefix(action, "set "))
		if !ok {
			return []Event{t.private(name, "Enter exactly two or five different numbers from 1-7 (24 or 13456), or house.")}
		}
		split = pgSet(p.cards, digits)
	}
	if !split.valid() {
		return []Event{t.private(name, "FOUL HAND: your five-card high hand must be higher than your two-card low hand. Nothing was set; choose again or use house.")}
	}
	p.split = split
	p.ready = true
	if t.allReady() {
		return t.prepareSettlementLocked()
	}
	return t.statusLocked(name)
}
func (t *paiGowTable) prepareSettlementLocked() []Event {
	t.phase = "settling"
	for _, p := range t.players {
		if p.main.Total == 0 {
			continue
		}
		result := pgMainResult(p.split, t.dealerSplit, t.dealer, t.Config.PaiGow.Variant, t.tie)
		p.payout = pgMainPay(p.main.Total, result, t.Config.PaiGow.Variant)
		p.result = "Main: " + result
		if t.Config.PaiGow.Variant == PaiGowFaceUp {
			rates, _ := t.Config.PaiGow.pushAceRates()
			if class := pgPushAceClass(t.dealer, p.cards, rates); class.klass != "none" {
				p.payout = p.pushace.Total * (class.rate + 1)
				p.result += fmt.Sprintf("; Push Ace High: %s (%d:1)", class.desc, class.rate)
			} else if p.pushace.Total > 0 {
				p.result += "; Push Ace High: lost (dealer not ace-high)"
			}
		}
		if p.fortune.Total > 0 {
			row, win := pgFortune(p.cards, t.Config.PaiGow.Envy)
			p.result += "; Fortune: " + row.Hand
			if win {
				p.payout += p.fortune.Total * (row.Odds + 1)
			}
			if t.Config.PaiGow.Envy && p.fortune.Total >= 5 {
				envy := 0
				for _, other := range t.players {
					if other != p && other.main.Total > 0 {
						row, _ := pgFortune(other.cards, true)
						envy += row.Envy
					}
				}
				p.payout += envy
				p.result += fmt.Sprintf("; Envy: %d", envy)
			}
		}
	}
	return t.payLocked()
}
func (t *paiGowTable) payLocked() []Event {
	var events []Event
	var newlyPaid []string
	pending := false
	for _, p := range t.players {
		if p.main.Total == 0 || p.paid {
			continue
		}
		ids := []string{p.main.ID}
		if p.fortune.Total > 0 {
			ids = append(ids, p.fortune.ID)
		}
		if p.pushace.Total > 0 {
			ids = append(ids, p.pushace.ID)
		}
		// One durable transaction settles both wagers and Envy, including zero
		// returns. Freeze the round on failure; retries never redeal or pay twice.
		if err := p.wallet.PayCredits(p.payout, ids...); err != nil {
			pending = true
			events = append(events, t.private(p.name, payoutFailedMessage()+" Use settle to retry the same result."))
			continue
		}
		p.paid = true
		p.result += fmt.Sprintf("; total returned %d credits", p.payout)
		newlyPaid = append(newlyPaid, p.name)
	}
	if pending {
		for _, name := range newlyPaid {
			events = append(events, t.statusLocked(name)...)
		}
		return events
	}
	t.phase = "waiting"
	kept := t.players[:0]
	for _, p := range t.players {
		p.main = Wager{}
		p.fortune = Wager{}
		p.ready = false
		if !p.disconnected {
			kept = append(kept, p)
		}
	}
	t.players = kept
	for _, p := range t.players {
		events = append(events, t.statusLocked(p.name)...)
	}
	return events
}
func (t *paiGowTable) tick() []Event {
	t.mu.Lock()
	defer t.mu.Unlock()
	switch t.phase {
	case "settling":
		return t.payLocked()
	case "betting", "setting":
		t.ticks--
		if t.ticks > 0 {
			return nil
		}
		if t.phase == "betting" {
			return t.dealLocked()
		}
		for _, p := range t.players {
			if p.main.Total > 0 && !p.ready {
				p.split = pgHouse(p.cards)
				p.ready = true
			}
		}
		return t.prepareSettlementLocked()
	}
	return nil
}
func (t *paiGowTable) markDisconnected(name string) []Event {
	t.mu.Lock()
	defer t.mu.Unlock()
	p := t.player(name)
	if p == nil {
		return nil
	}
	if p.main.Total == 0 {
		t.remove(name)
		return nil
	}
	p.disconnected = true
	p.ready = true
	if t.phase == "setting" {
		if p.split.high == nil {
			p.split = pgHouse(p.cards)
		}
		if t.allReady() {
			return t.prepareSettlementLocked()
		}
	}
	return nil
}
func (t *paiGowTable) refundAll() []Event {
	t.mu.Lock()
	defer t.mu.Unlock()
	var events []Event
	for _, p := range t.players {
		if p.wallet == nil || p.main.Total == 0 || p.paid {
			continue
		}
		if _, _, err := p.wallet.RefundOutstanding(); err != nil {
			events = append(events, t.private(p.name, "Shutdown refund remains pending for startup recovery."))
		} else {
			p.main = Wager{}
			p.fortune = Wager{}
			p.pushace = Wager{}
		}
	}
	return events
}