diff options
| author | historia <[not public]> | 2026-09-24 20:06:41 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-24 20:07:31 -0400 |
| commit | 13db3e50cb6e29eedbf25416e5284bf08b0adefb (patch) | |
| tree | d5e9b99030b25cc7fc102288b694c133f8832f84 | |
| parent | d114c747a7672c7e4daab3a5b5f1b2f066ebc912 (diff) | |
| download | thehouseoficarus-13db3e50cb6e29eedbf25416e5284bf08b0adefb.tar.gz | |
feat: casino table ux: aliases, rebet hints, deadline notifications, spectator mode
| -rw-r--r-- | config.yaml | 1 | ||||
| -rw-r--r-- | internal/casino/baccarat.go | 32 | ||||
| -rw-r--r-- | internal/casino/baccarat_test.go | 7 | ||||
| -rw-r--r-- | internal/casino/betting.go | 10 | ||||
| -rw-r--r-- | internal/casino/blackjack.go | 163 | ||||
| -rw-r--r-- | internal/casino/blackjack_test.go | 14 | ||||
| -rw-r--r-- | internal/casino/core.go | 10 | ||||
| -rw-r--r-- | internal/casino/table.go | 8 | ||||
| -rw-r--r-- | internal/casino/table_ux.go | 180 | ||||
| -rw-r--r-- | internal/casino/table_ux_test.go | 181 | ||||
| -rw-r--r-- | internal/casino/types.go | 32 | ||||
| -rw-r--r-- | internal/config/config.go | 73 | ||||
| -rw-r--r-- | internal/engine/tick.go | 28 | ||||
| -rw-r--r-- | internal/game/casino.go | 192 | ||||
| -rw-r--r-- | internal/game/casino_test.go | 15 | ||||
| -rw-r--r-- | internal/game/casino_ux.go | 221 | ||||
| -rw-r--r-- | internal/game/casino_ux_test.go | 153 | ||||
| -rw-r--r-- | internal/game/cmd_registry.go | 10 | ||||
| -rw-r--r-- | internal/game/cmd_stop.go | 12 | ||||
| -rw-r--r-- | internal/game/look_entities.go | 17 | ||||
| -rw-r--r-- | internal/game/quit_casino_test.go | 11 | ||||
| -rw-r--r-- | internal/game/render_help.go | 11 | ||||
| -rw-r--r-- | internal/net/server.go | 7 | ||||
| -rw-r--r-- | internal/player/player.go | 3 |
24 files changed, 1239 insertions, 152 deletions
diff --git a/config.yaml b/config.yaml index 0ebf142..95992cb 100644 --- a/config.yaml +++ b/config.yaml @@ -63,6 +63,7 @@ default_colors: global: "49" broadcast: "89" casino_action: "6E" + casino_warning: "C4 bold" # Red deadlines and unavailable actions casino_menu: "FA" casino_win: "FA" casino_payout: "bold FA" diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go index 68f363e..e34518f 100644 --- a/internal/casino/baccarat.go +++ b/internal/casino/baccarat.go @@ -47,11 +47,16 @@ func newBaccaratTable(roomID int, cfg TableConfig, rng *rand.Rand) *baccaratTabl } func (t *baccaratTable) menuEvent(name string) Event { - return t.betMenuEvent(name, []MenuCommand{ - {Command: "bet <amount>", Desc: "Place a wager (min/max accepted)"}, - {Command: "bet <amount> on " + t.Rules.betChoices(), Desc: "Wager on a specific outcome (one wager per round)"}, - {Command: "stop", Desc: "Leave the table"}, - }) + commands := []MenuCommand{} + if p := t.players.get(name); p != nil && p.lastBet > 0 { + commands = append(commands, MenuCommand{Command: "bet", Desc: fmt.Sprintf("Repeat %d on %s (also: rebet)", p.lastBet, p.lastSpot)}, MenuCommand{Command: "bet <amount>", Desc: "Change amount; keep " + p.lastSpot}) + } + commands = append(commands, MenuCommand{Command: fmt.Sprintf("bet %d banker", t.Config.MinBet), Desc: "Choose banker (b), player (p), or tie (t); min/max accepted"}, MenuCommand{Command: "bet <amount> on " + t.Rules.betChoices(), Desc: "One wager per round; 'on' is optional"}, MenuCommand{Command: "stop", Desc: "Leave; during a round, leave afterward"}, MenuCommand{Command: "table / sitout", Desc: "Check the table / skip betting until you rejoin"}) + e := t.betMenuEvent(name, commands) + if p := t.players.get(name); p != nil { + e.Menu.Repeat = p.lastBet > 0 + } + return e } func (t *baccaratTable) join(name string) []Event { @@ -111,6 +116,7 @@ func (t *baccaratTable) bet(name, spot string, amount int, wallet Wallet) []Even if err != nil { return []Event{t.private(name, walletErrorMessage(err))} } + p.SittingOut = false p.HasBet, p.Wager, p.Wallet, p.lastBet, p.lastSpot, p.stakeID = true, wager, wallet, amount, spot, wager.ID events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d credits on %s.", name, amount, spot), @@ -132,6 +138,14 @@ func (t *baccaratTable) tick() []Event { } func (t *baccaratTable) action(name, action string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if events, handled := t.controlLocked(name, action); handled { + if t.phase == baccaratBetting && allBet(t.players.participants()) { + return append(events, t.deal()...) + } + return events + } return []Event{t.private(name, "That game has no player actions.")} } @@ -171,11 +185,14 @@ func (t *baccaratTable) deal() []Event { // stake pending on any failure, so a refund can never be paid on top // of a payout that was not durable. payoutFailed := settleStakeOutcome(p.Wallet, payout, []string{p.stakeID}, settleErr) - publicResult, privateResult := baccaratResultMessages(p.Name, result, payout) + publicResult, _ := baccaratResultMessages(p.Name, result, payout) + privateResult := resultText(result, p.Wager.Total, payout) if payoutFailed { privateResult = payoutFailedMessage() + publicResult = p.Name + "’s settlement is pending recovery." } settlement := t.playerAction(p.Name, publicResult, privateResult) + settlement.ShowBalance = !payoutFailed if result == "win" { if p.lastSpot == baccaratSpotTie { settlement.Color = "casino_jackpot_win" @@ -190,8 +207,9 @@ func (t *baccaratTable) deal() []Event { // pending by an earlier failed payout, which must stay refundable. } t.resetRound() + events = append(events, t.finishControlsLocked()...) for _, p := range t.players.ordered() { - if p.Connected { + if p.Connected && !p.SittingOut { events = append(events, t.menuEvent(p.Name)) } } diff --git a/internal/casino/baccarat_test.go b/internal/casino/baccarat_test.go index 49e8fdd..12dc07c 100644 --- a/internal/casino/baccarat_test.go +++ b/internal/casino/baccarat_test.go @@ -1,6 +1,7 @@ package casino import ( + "strings" "testing" ) @@ -266,7 +267,7 @@ func TestBaccaratTiePushesAndPaysJackpotColor(t *testing.T) { t.Fatalf("payouts alice=%d bob=%d, want 100 (push)/900 (8:1)", wa.paid, wb.paid) } for _, event := range events { - if event.PrivateTo == "bob" && event.PrivateMessage == "You win! +900 credits" && event.Color != "casino_jackpot_win" { + if event.PrivateTo == "bob" && strings.Contains(event.PrivateMessage, "WIN | Net +800 credits | 900 returned") && event.Color != "casino_jackpot_win" { t.Fatalf("tie win color=%q, want casino_jackpot_win", event.Color) } } @@ -354,10 +355,10 @@ func TestBaccaratMenuAndActions(t *testing.T) { menu = event.Menu } } - if menu == nil || !menu.ShowBalance || menu.Heading != "New baccarat hand! Place your bet." { + if menu == nil || !menu.ShowBalance || !strings.Contains(menu.Heading, "Baccarat / Commission | Next hand | Limits") { t.Fatalf("join menu=%+v, want a baccarat betting menu", menu) } - if len(menu.Commands) != 3 || menu.Commands[0].Command != "bet <amount>" || menu.Commands[2].Command != "stop" { + if len(menu.Commands) != 4 || menu.Commands[0].Command != "bet 1 banker" || menu.Commands[2].Command != "stop" { t.Fatalf("baccarat menu commands=%+v", menu.Commands) } if events := table.action("alice", "hit"); !hasMessage(events, "no player actions") { diff --git a/internal/casino/betting.go b/internal/casino/betting.go index fa2d2dc..54d8c60 100644 --- a/internal/casino/betting.go +++ b/internal/casino/betting.go @@ -10,7 +10,11 @@ type countdown struct { func (c *countdown) start(ticks int) { c.clock, c.deadline = 0, ticks } func (c *countdown) stop() { c.clock, c.deadline = 0, 0 } func (c *countdown) tick() { c.clock++ } -func (c *countdown) expired() bool { return c.deadline > 0 && c.clock >= c.deadline } +func (c *countdown) remaining() int { return max(0, c.deadline-c.clock) } + +// One warning per window, at one quarter of its duration (at least one tick). +func (c *countdown) warn() bool { return c.deadline > 1 && c.remaining() == max(1, c.deadline/4) } +func (c *countdown) expired() bool { return c.deadline > 0 && c.clock >= c.deadline } // bettingWindow is the timed betting phase shared by multiplayer table games. // The owning table opens it when the first wager lands with multiple seated @@ -42,7 +46,7 @@ func (w *bettingWindow) advance(seats []*Participant) (sitOuts []string, resolve func allBet(seats []*Participant) bool { connected := false for _, p := range seats { - if !p.Connected { + if !p.Connected || p.SittingOut { continue } connected = true @@ -68,7 +72,7 @@ func anyBet(seats []*Participant) bool { func sitOutNames(seats []*Participant) []string { var names []string for _, p := range seats { - if !p.HasBet { + if !p.HasBet && !p.SittingOut { names = append(names, p.Name) } } diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go index a1cb112..2e7d7e9 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -102,11 +102,23 @@ func newBlackjackTable(roomID int, cfg TableConfig, rng *rand.Rand) *blackjackTa } func (t *blackjackTable) menuEvent(name string) Event { - return t.betMenuEvent(name, []MenuCommand{ - {Command: "bet", Desc: "Place a wager (min/max accepted)"}, - {Command: "bet <amount>", Desc: "Place a specific wager"}, - {Command: "stop", Desc: "Leave the table"}, + if t.phase == blackjackPlaying || t.phase == blackjackInsurance { + return t.private(name, "Round in progress. You’ll join the next hand; table shows the current action.") + } + amount, desc := t.Config.MinBet, "Table minimum" + if p := t.players.get(name); p != nil && p.lastBet > 0 { + amount, desc = p.lastBet, "Repeat wager" + } + e := t.betMenuEvent(name, []MenuCommand{ + {Command: "bet", Desc: fmt.Sprintf("%s: %d credits (also: rebet)", desc, amount)}, + {Command: "bet <amount>", Desc: "Change wager; min/max accepted"}, + {Command: "stop", Desc: "Leave; during a round, leave afterward"}, + {Command: "table / sitout", Desc: "Check the table / skip betting until you rejoin"}, }) + if p := t.players.get(name); p != nil { + e.Menu.Repeat = p.lastBet > 0 + } + return e } func (t *blackjackTable) join(name string) []Event { @@ -166,6 +178,7 @@ func (t *blackjackTable) bet(name, spot string, amount int, wallet Wallet) []Eve if err != nil { return []Event{t.private(name, walletErrorMessage(err))} } + p.SittingOut = false p.HasBet, p.Wager, p.Wallet, p.lastBet = true, wager, wallet, amount events := []Event{t.playerAction(name, fmt.Sprintf("%s bets %d credits.", name, amount), fmt.Sprintf("You bet %d credits.", amount))} events = t.openBettingLocked(events) @@ -187,7 +200,15 @@ func (t *blackjackTable) tick() []Event { } t.timer.tick() if !t.timer.expired() { - return nil + var events []Event + if t.timer.warn() { + for _, p := range t.players.ordered() { + if p.HasBet && p.insurance == 0 && !p.declinedInsurance && p.Connected { + events = append(events, t.warning(p.Name, fmt.Sprintf("Insurance closes in %d ticks. No response means decline.", t.timer.remaining()))) + } + } + } + return events } return t.finishInsurance() case blackjackPlaying: @@ -208,7 +229,7 @@ func (t *blackjackTable) tick() []Event { t.ceiling.tick() if t.ceiling.expired() { active.hands[t.activeHand].stood = true - events := []Event{t.private(name, "You've been idle too long; standing.")} + events := []Event{t.warning(name, "You've been idle too long; standing.")} // Restart the action timer so the next hand gets a full window. t.timer.start(t.Rules.ActionTicks) return append(events, t.finishHand()...) @@ -216,14 +237,20 @@ func (t *blackjackTable) tick() []Event { if active.Connected && t.bettorCount() <= 1 { // A lone connected player is never timed out. A disconnected // active player always is, so the table can never stall. + if t.ceiling.warn() { + return []Event{t.warning(name, fmt.Sprintf("%d ticks remaining. No response means stand.", t.ceiling.remaining()))} + } return nil } t.timer.tick() if !t.timer.expired() { + if t.timer.warn() { + return []Event{t.warning(name, fmt.Sprintf("%d ticks remaining. No response means stand.", t.timer.remaining()))} + } return nil } active.hands[t.activeHand].stood = true - events := []Event{t.private(name, "Your action timed out; standing.")} + events := []Event{t.warning(name, "Your action timed out; standing.")} // Restart the timer so the next active hand gets a full action window // instead of timing out on the following tick. t.timer.start(t.Rules.ActionTicks) @@ -237,12 +264,28 @@ func (t *blackjackTable) tick() []Event { func (t *blackjackTable) action(name, action string) []Event { t.mu.Lock() defer t.mu.Unlock() + if events, handled := t.controlLocked(name, action); handled { + if t.phase == blackjackBetting && allBet(t.players.participants()) { + return append(events, t.startRound()...) + } + return events + } if t.phase == blackjackInsurance { return t.insuranceAction(name, action) } if t.phase != blackjackPlaying || t.activeName() != name { return []Event{t.private(name, "It's not your turn.")} } + switch action { + case "h": + action = "hit" + case "st": + action = "stand" + case "dd": + action = "double" + case "sp": + action = "split" + } p := t.players.get(name) hand := p.hands[t.activeHand] // The action window only restarts when a new turn is actually prompted @@ -259,6 +302,7 @@ func (t *blackjackTable) action(name, action string) []Event { } e := t.playerAction(name, fmt.Sprintf("%s hits and receives %s.", name, hand.cards[len(hand.cards)-1]), fmt.Sprintf("You hit and receive %s.", hand.cards[len(hand.cards)-1])) e.Snapshot = t.snapshot(false) + e.Detail = true events := []Event{e} if total >= 21 { events = append(events, t.finishHand()...) @@ -287,6 +331,7 @@ func (t *blackjackTable) action(name, action string) []Event { } e := t.playerAction(name, fmt.Sprintf("%s doubles down.", name), "You double down.") e.Snapshot = t.snapshot(false) + e.Detail = true return append([]Event{e}, t.finishHand()...) case "split": return t.splitHand(name, p, hand) @@ -302,7 +347,7 @@ func (t *blackjackTable) action(name, action string) []Event { } // insuranceAction handles commands during the insurance offer window: -// "insurance" buys cover for half the base bet; any other action declines. +// "insurance" buys cover for half the base bet; "decline" explicitly declines. func (t *blackjackTable) insuranceAction(name, action string) []Event { p := t.players.get(name) if p == nil || !p.HasBet { @@ -312,6 +357,9 @@ func (t *blackjackTable) insuranceAction(name, action string) []Event { return []Event{t.private(name, "You have already decided about insurance.")} } var events []Event + if action != "insurance" && action != "decline" { + return []Event{t.private(name, "Insurance decision first: insurance to buy, decline to continue. No card action has been taken.")} + } if action == "insurance" { amount := p.hands[0].bet / 2 if amount <= 0 { @@ -417,7 +465,7 @@ func (t *blackjackTable) startRound() []Event { p.declinedInsurance = true } } - roundEvent := t.event("", true) + roundEvent := t.event("New blackjack hand!", true) roundEvent.Snapshot = t.snapshot(false) events := []Event{roundEvent} if t.dealer.cards[1].rank == "A" { @@ -428,7 +476,7 @@ func (t *blackjackTable) startRound() []Event { if !p.HasBet || !p.Connected || p.declinedInsurance { continue } - events = append(events, t.private(p.Name, fmt.Sprintf("Insure your %d-credit bet for %d credits? Type \"insurance\" to buy, or any other action to decline.", p.hands[0].bet, p.hands[0].bet/2))) + events = append(events, t.insuranceMenuLocked(p)) } if t.insuranceSettled() { events = append(events, t.finishInsurance()...) @@ -440,6 +488,8 @@ func (t *blackjackTable) startRound() []Event { return events } t.beginPlay() + t.skipCompletedHands() + events[0].Snapshot = t.snapshot(false) return append(events, t.finishHand()...) } @@ -473,6 +523,7 @@ func (t *blackjackTable) prompt() []Event { if name == "" { return t.settle() } + changed := name != t.turnName || t.activeHand != t.turnHand // The absolute turn ceiling belongs to the turn, not the prompt: restart // it only when the active (player, hand) changes. A seat event that // re-prompts the same turn must not reset a hung bettor's deadline. @@ -481,6 +532,23 @@ func (t *blackjackTable) prompt() []Event { t.turnName = name t.turnHand = t.activeHand } + events := []Event{t.turnMenuLocked(name)} + if changed { + for _, p := range t.players.ordered() { + if p.Name == name || !p.Connected { + continue + } + text := name + "’s turn." + if p.HasBet && t.players.indexOf(p.Name) > t.activePlayer { + text += " You’re waiting for your turn." + } + events = append(events, t.private(p.Name, text)) + } + } + return events +} + +func (t *blackjackTable) turnMenuLocked(name string) Event { p := t.players.get(name) h := p.hands[t.activeHand] ranks := make([]string, len(h.cards)) @@ -490,16 +558,27 @@ func (t *blackjackTable) prompt() []Event { actions := t.availableActions(p, h) e := t.private(name, fmt.Sprintf("Your turn (%s).\nActions: %s", strings.Join(ranks, ","), strings.Join(actions, ", "))) e.Color = "casino_menu" - e.Menu = &MenuView{Heading: fmt.Sprintf("Your turn (%s)", strings.Join(ranks, ",")), Commands: blackjackActionCommands(actions)} - return []Event{e} + deadline := t.timer.remaining() + if p.Connected && t.bettorCount() <= 1 { + deadline = t.ceiling.remaining() + } + e.Menu = &MenuView{Heading: fmt.Sprintf("Your turn (%s) | Hand %d/%d | %s | Wager %d | %d ticks remaining", strings.Join(ranks, ","), t.activeHand+1, len(p.hands), handScore(h), h.bet, deadline), Commands: blackjackActionCommands(actions)} + for i := range e.Menu.Commands { + c := &e.Menu.Commands[i] + if c.Command == "double" || c.Command == "split" { + c.Cost = h.bet + c.Desc += fmt.Sprintf(" (+%d credits)", h.bet) + } + } + return e } func blackjackActionCommands(actions []string) []MenuCommand { descriptions := map[string]string{ - "hit": "Take another card", - "stand": "Hold your total", - "double": "Double your bet and take one card", - "split": "Split your pair into two hands", + "hit": "[h] Take another card", + "stand": "[st] Hold your total", + "double": "[dd] Double your bet and take one card", + "split": "[sp] Split your pair into two hands", "surrender": "Forfeit half your bet", } commands := make([]MenuCommand, 0, len(actions)) @@ -592,19 +671,25 @@ func (t *blackjackTable) settle() []Event { if !p.HasBet { continue } - for _, hand := range p.hands { + roundNet, roundFailed := int64(0), false + for i, hand := range p.hands { result, payout, settleErr := settleHand(hand, dealerTotal, dealerBlackjack, t.Rules) // One shared settlement path for every game: pay before // announcing, and leave the stakes pending on any failure so a // refund can never be paid on top of a payout that was not durable. if settleStakeOutcome(p.Wallet, payout, hand.stakes, settleErr) { - publicResult, _ := blackjackResultMessages(p.Name, result, payout) - settlement := t.playerAction(p.Name, publicResult, payoutFailedMessage()) + roundFailed = true + settlement := t.playerAction(p.Name, p.Name+"’s settlement is pending recovery.", payoutFailedMessage()) colorizeBlackjackSettlement(&settlement, result) events = append(events, settlement) continue } - publicResult, privateResult := blackjackResultMessages(p.Name, result, payout) + publicResult, _ := blackjackResultMessages(p.Name, result, payout) + roundNet += int64(payout) - int64(hand.bet) + privateResult := resultText(result, hand.bet, payout) + if len(p.hands) > 1 { + privateResult = fmt.Sprintf("Hand %d/%d | %s", i+1, len(p.hands), privateResult) + } settlement := t.playerAction(p.Name, publicResult, privateResult) colorizeBlackjackSettlement(&settlement, result) events = append(events, settlement) @@ -616,13 +701,21 @@ func (t *blackjackTable) settle() []Event { insurancePayout, insuranceErr = MulPayout(p.insurance, 1+t.Rules.InsurancePayout) } if settleStakeOutcome(p.Wallet, insurancePayout, []string{p.insuranceStakeID}, insuranceErr) { + roundFailed = true events = append(events, t.private(p.Name, payoutFailedMessage())) - } else if insurancePayout > 0 { - events = append(events, t.private(p.Name, fmt.Sprintf("Insurance pays %d credits.", insurancePayout))) } else { - events = append(events, t.private(p.Name, "Insurance pays 0 credits.")) + roundNet += int64(insurancePayout) - int64(p.insurance) + events = append(events, t.private(p.Name, resultText("insurance", p.insurance, insurancePayout))) } } + if !roundFailed { + e := t.private(p.Name, fmt.Sprintf("Round complete | Net %+d credits (insurance included)", roundNet)) + e.ShowBalance = true + if roundNet > 0 { + e.Color = "casino_big_win" + } + events = append(events, e) + } // Each hand's stakes were already consumed per-handle by // settleStakeOutcome (a payout removes them in PayCredits; a loss // resolves them), and the insurance stake likewise. Deliberately do @@ -631,8 +724,9 @@ func (t *blackjackTable) settle() []Event { // refundable (see the Wallet contract). } t.resetRound() + events = append(events, t.finishControlsLocked()...) for _, p := range t.players.ordered() { - if p.Connected { + if p.Connected && !p.SittingOut { events = append(events, t.menuEvent(p.Name)) } } @@ -724,8 +818,23 @@ func (t *blackjackTable) snapshot(revealDealer bool) *BlackjackSnapshot { if !p.HasBet { continue } - for _, hand := range p.hands { - view := BlackjackPlayerView{Name: p.Name, Score: handScore(hand)} + for i, hand := range p.hands { + status := "Waiting" + switch { + case hand.bust: + status = "Busted" + case hand.surrendered: + status = "Surrendered" + case hand.stood: + status = "Standing" + case hand.blackjack(t.Rules): + status = "Blackjack" + } + active := t.phase == blackjackPlaying && t.activeName() == p.Name && t.activeHand == i && !revealDealer + if active { + status = "Your turn" + } + view := BlackjackPlayerView{Name: p.Name, Score: handScore(hand), Hand: i + 1, Hands: len(p.hands), Bet: hand.bet, Active: active, Status: status} for _, card := range hand.cards { view.Cards = append(view.Cards, CardView{Rank: card.rank, Suit: card.suit}) } @@ -745,7 +854,7 @@ func handScore(hand *blackjackHand) string { if !soft || total == 21 { return fmt.Sprintf("%d", total) } - return fmt.Sprintf("%d or %d", total-10, total) + return fmt.Sprintf("Soft %d", total) } func blackjackResultMessages(name, result string, payout int) (string, string) { diff --git a/internal/casino/blackjack_test.go b/internal/casino/blackjack_test.go index e3ffcaf..dd3d83a 100644 --- a/internal/casino/blackjack_test.go +++ b/internal/casino/blackjack_test.go @@ -23,8 +23,8 @@ func TestBlackjackHandScoreShowsBlackjackAs21(t *testing.T) { if got := handScore(&blackjackHand{cards: []card{{rank: "A"}, {rank: "K"}}}); got != "21" { t.Fatalf("blackjack score=%q, want 21", got) } - if got := handScore(&blackjackHand{cards: []card{{rank: "A"}, {rank: "6"}}}); got != "7 or 17" { - t.Fatalf("soft 17 score=%q, want 7 or 17", got) + if got := handScore(&blackjackHand{cards: []card{{rank: "A"}, {rank: "6"}}}); got != "Soft 17" { + t.Fatalf("soft 17 score=%q, want Soft 17", got) } if got := handScore(&blackjackHand{cards: []card{{rank: "10"}, {rank: "8"}}}); got != "18" { t.Fatalf("hard 18 score=%q, want 18", got) @@ -130,7 +130,7 @@ func TestBlackjackPromptNamesActiveHandAndOnlyOffersAvailableActions(t *testing. if event.Message != "Your turn (K,6).\nActions: hit, stand, double" { t.Fatalf("prompt=%q", event.Message) } - if event.Menu == nil || event.Menu.Heading != "Your turn (K,6)" || event.Menu.ShowBalance || + if event.Menu == nil || !strings.Contains(event.Menu.Heading, "Hand 1/1 | 16 | Wager 10") || event.Menu.ShowBalance || len(event.Menu.Commands) != 3 || event.Menu.Commands[0].Command != "hit" || event.Menu.Commands[1].Command != "stand" || event.Menu.Commands[2].Command != "double" { t.Fatalf("prompt menu=%+v", event.Menu) } @@ -142,7 +142,7 @@ func TestBlackjackMenuEventCarriesStructuredMenu(t *testing.T) { if event.Menu == nil || !event.Menu.ShowBalance { t.Fatalf("menu event=%+v", event.Menu) } - if len(event.Menu.Commands) != 3 || event.Menu.Commands[0].Command != "bet" || event.Menu.Commands[2].Command != "stop" { + if len(event.Menu.Commands) != 4 || event.Menu.Commands[0].Command != "bet" || event.Menu.Commands[2].Command != "stop" { t.Fatalf("bet menu commands=%+v", event.Menu.Commands) } } @@ -175,7 +175,7 @@ func TestBlackjackInsurancePaysOnDealerBlackjack(t *testing.T) { if w.paid != 15 { t.Fatalf("insurance payout=%d, want 15 (stake + 2x)", w.paid) } - if !hasMessage(events, "Insurance pays 15 credits.") { + if !hasMessage(events, "INSURANCE | Net +10 credits | 15 returned") { t.Fatalf("missing insurance payout message: %+v", events) } if table.phase != blackjackWaiting { @@ -192,7 +192,7 @@ func TestBlackjackInsuranceDeclineContinuesRound(t *testing.T) { }) table.dealer.cards = []card{{rank: "9"}, {rank: "A"}} table.phase = blackjackInsurance - events := table.action("alice", "stand") + events := table.action("alice", "decline") if !hasMessage(events, "You decline insurance.") { t.Fatalf("missing decline message: %+v", events) } @@ -285,7 +285,7 @@ func TestBlackjackDisconnectActivePassesPlay(t *testing.T) { } // Bob stands; round settles. settle := table.action("bob", "stand") - if !hasMessage(settle, "You lose!") { + if !hasMessage(settle, "LOSS | Net -10 credits | 0 returned") { t.Fatalf("bob 16 vs dealer 17: expected loss, got %+v", settle) } if wb.paid != 0 { diff --git a/internal/casino/core.go b/internal/casino/core.go index 7143841..a927ab1 100644 --- a/internal/casino/core.go +++ b/internal/casino/core.go @@ -89,7 +89,15 @@ func (c *tableCore[P]) openBettingLocked(events []Event) []Event { func (c *tableCore[P]) advanceBettingLocked(resolve func() []Event, reset func()) []Event { sitOuts, goRound, pending := c.window.advance(c.players.participants()) if pending { - return nil + var events []Event + if c.window.timer.warn() { + for _, p := range c.players.participants() { + if p.Connected && !p.HasBet && !p.SittingOut { + events = append(events, c.warning(p.Name, fmt.Sprintf("Betting closes in %d ticks. Bet now or sit out this hand.", c.window.timer.remaining()))) + } + } + } + return events } var events []Event for _, name := range sitOuts { diff --git a/internal/casino/table.go b/internal/casino/table.go index 408e5fe..17732e0 100644 --- a/internal/casino/table.go +++ b/internal/casino/table.go @@ -19,6 +19,8 @@ type tableGame interface { hasParticipant(name string) bool refundAll() []Event idle() bool + status(name string) []Event + overview() string // base returns the table identity shared with the Table handle. base() *tableBase } @@ -52,7 +54,7 @@ func (b *tableBase) playerAction(name, publicMessage, privateMessage string) Eve // betMenuEvent builds the shared "place your bet" menu heading for a table // game; only the command list differs per game. func (b *tableBase) betMenuEvent(name string, commands []MenuCommand) Event { - heading := fmt.Sprintf("New %s hand! Place your bet.", b.Config.Game) + heading := fmt.Sprintf("%s | Next hand | Limits %s", b.Config.Label(), betRangeText(b.Config.MinBet, b.Config.MaxBet)) e := b.private(name, heading) e.Color = "casino_menu" e.Menu = &MenuView{Heading: heading, ShowBalance: true, Commands: commands} @@ -105,3 +107,7 @@ func (t *Table) refundAll() []Event { return t.game.refundAll() } // idle on the Table wrapper delegates to the game. func (t *Table) idle() bool { return t.game.idle() } + +// Status is a read-only view: it never restarts a deadline or changes a wager. +func (t *Table) Status(name string) []Event { return t.game.status(name) } +func (t *Table) Overview() string { return t.game.overview() } diff --git a/internal/casino/table_ux.go b/internal/casino/table_ux.go new file mode 100644 index 0000000..9543d91 --- /dev/null +++ b/internal/casino/table_ux.go @@ -0,0 +1,180 @@ +package casino + +import ( + "fmt" + "strings" +) + +func (c TableConfig) Label() string { + if c.Game != GameBaccarat { + return gameTitle(c.Game) + } + switch c.Baccarat.Normalize().Variant { + case BaccaratSuper6: + return "Baccarat / Super 6" + case BaccaratEZ: + return "Baccarat / EZ" + default: + return "Baccarat / Commission" + } +} + +func (c *tableCore[P]) overview() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.overviewLocked() +} + +func (c *tableCore[P]) overviewLocked() string { + state := "Betting open" + switch c.phase { + case phaseBetting: + state = fmt.Sprintf("Betting closes in %d ticks", c.window.timer.remaining()) + case phasePlaying: + state = "Round in progress" + case phaseInsurance: + state = "Insurance decisions" + } + return fmt.Sprintf("%s | Limits %s | %d/%d seated | %s", c.Config.Label(), betRangeText(c.Config.MinBet, c.Config.MaxBet), c.players.count(), c.Config.MaxPlayers, state) +} + +func (c *tableCore[P]) warning(name, text string) Event { + e := c.private(name, text) + e.Color = "casino_warning" + return e +} + +// controlLocked queues departures without changing committed hands or stakes. +func (c *tableCore[P]) controlLocked(name, action string) ([]Event, bool) { + if action != "leave_after" && action != "sitout" { + return nil, false + } + for _, p := range c.players.participants() { + if p.Name != name { + continue + } + if action == "leave_after" { + p.LeaveAfter = true + return []Event{c.private(name, "You’ll leave after this round. Your current hand still needs your actions.")}, true + } + if p.HasBet { + p.SitOutAfter = true + return []Event{c.private(name, "You’ll sit out after this round. Your current wager and actions are unchanged.")}, true + } + p.SittingOut = true + return []Event{c.private(name, "You’re sitting out. Place a bet to rejoin; stop to leave.")}, true + } + return []Event{c.private(name, "You aren’t seated at this table.")}, true +} + +// Called only after resetRound, when seat removal cannot disturb active indices. +func (c *tableCore[P]) finishControlsLocked() []Event { + var events []Event + for _, p := range c.players.participants() { + if p.LeaveAfter { + c.removeSeatLocked(p.Name) + events = append(events, c.playerAction(p.Name, p.Name+" stands up.", "Round complete. You stand up.")) + } else if p.SitOutAfter { + p.SitOutAfter = false + p.SittingOut = true + events = append(events, c.private(p.Name, "You’re now sitting out. Place a bet to rejoin.")) + } + } + return c.closeIfEmptyLocked(events) +} + +func (c *tableCore[P]) seatStatusLocked(name string) string { + for _, p := range c.players.participants() { + if p.Name != name { + continue + } + if p.LeaveAfter { + return "Leaving after this round" + } + if p.SitOutAfter { + return "Sitting out after this round" + } + if p.SittingOut { + return "Sitting out | Bet to rejoin" + } + if p.HasBet { + return fmt.Sprintf("Wager committed: %d", p.Wager.Total) + } + } + var waiting []string + for _, p := range c.players.participants() { + if p.Connected && !p.SittingOut && !p.HasBet { + waiting = append(waiting, p.Name) + } + } + if len(waiting) > 0 { + return "Waiting for bets: " + strings.Join(waiting, ", ") + } + return "" +} + +func (t *blackjackTable) status(name string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + e := t.private(name, t.overviewLocked()) + if text := t.seatStatusLocked(name); text != "" { + e.Message += "\n" + text + } + events := []Event{e} + switch t.phase { + case blackjackPlaying: + events[0].Snapshot = t.snapshot(false) + if t.activeName() == name { + events = append(events, t.turnMenuLocked(name)) + } else { + events[0].Message += "\n" + t.activeName() + "’s turn | You are waiting" + } + case blackjackInsurance: + events[0].Snapshot = t.snapshot(false) + if p := t.players.get(name); p != nil && p.HasBet && !p.declinedInsurance && p.insurance == 0 { + events = append(events, t.insuranceMenuLocked(p)) + } + default: + if p := t.players.get(name); p != nil && !p.HasBet { + events = append(events, t.menuEvent(name)) + } + } + return events +} + +func (t *baccaratTable) status(name string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + e := t.private(name, t.overviewLocked()) + if text := t.seatStatusLocked(name); text != "" { + e.Message += "\n" + text + } + events := []Event{e} + if p := t.players.get(name); p != nil && !p.HasBet { + events = append(events, t.menuEvent(name)) + } + return events +} + +func (t *blackjackTable) insuranceMenuLocked(p *blackjackPlayer) Event { + cost := p.hands[0].bet / 2 + e := t.private(p.Name, "Insurance decision") + e.Color = "casino_menu" + e.Menu = &MenuView{Heading: fmt.Sprintf("Insurance | Costs %d | Pays %g:1 | %d ticks remaining", cost, t.Rules.InsurancePayout, t.timer.remaining()), Commands: []MenuCommand{{Command: "insurance", Desc: "Buy insurance", Cost: cost}, {Command: "decline", Desc: "Continue without insurance (also the timeout default)"}}} + if cost == 0 { + e.Menu.Commands = e.Menu.Commands[1:] + e.Menu.Heading = "Bet too small to insure | decline to continue" + } + return e +} + +func resultText(result string, stake, returned int) string { + label := strings.ToUpper(result) + switch result { + case "blackjack": + label = "BLACKJACK" + case "bust", "loss", "dealer blackjack": + label = "LOSS" + } + return fmt.Sprintf("%s | Net %+d credits | %d returned (stake included)", label, returned-stake, returned) +} diff --git a/internal/casino/table_ux_test.go b/internal/casino/table_ux_test.go new file mode 100644 index 0000000..7f93951 --- /dev/null +++ b/internal/casino/table_ux_test.go @@ -0,0 +1,181 @@ +package casino + +import ( + "strings" + "testing" +) + +func uxBlackjack() *blackjackTable { + t := newBlackjackTable(1, TableConfig{ID: "bj", Game: GameBlackjack, Blackjack: BlackjackConfig{ActionTicks: 8}}, newTestRand()) + t.phase = blackjackPlaying + t.dealer.cards = []card{{rank: "10", suit: "spades"}, {rank: "7", suit: "hearts"}} + t.players.add("alice", &blackjackPlayer{Participant: Participant{Name: "alice", Connected: true, HasBet: true, Wager: Wager{Total: 10}}, hands: []*blackjackHand{{bet: 10, cards: []card{{rank: "10"}, {rank: "6"}}}}}) + t.prompt() + return t +} + +func TestTableStatusDoesNotExtendAnyDeadlineOrRevealHoleCard(t *testing.T) { + table := uxBlackjack() + table.tick() + beforeTimer, beforeCeiling := table.timer, table.ceiling + for i := 0; i < 3; i++ { + events := table.status("alice") + snap := events[0].Snapshot.(*BlackjackSnapshot) + if !snap.Dealer[0].Hidden || snap.Dealer[0].Rank != "?" { + t.Fatal("status revealed hole card") + } + if !snap.Players[0].Active || snap.Players[0].Hand != 1 || snap.Players[0].Bet != 10 { + t.Fatalf("missing active hand: %+v", snap.Players[0]) + } + } + if table.timer != beforeTimer || table.ceiling != beforeCeiling { + t.Fatal("status extended deadline") + } +} + +func TestBlackjackWarnsOnceThenAutoStands(t *testing.T) { + for _, multi := range []bool{false, true} { + table := uxBlackjack() + if multi { + table.players.add("bob", &blackjackPlayer{Participant: Participant{Name: "bob", Connected: true, HasBet: true}, hands: []*blackjackHand{{bet: 10, stood: true, cards: []card{{rank: "10"}, {rank: "8"}}}}}) + } + warnings := 0 + timeout := false + for i := 0; i < 81; i++ { + for _, e := range table.tick() { + if e.PrivateTo == "alice" && strings.Contains(e.Message, "No response means stand") { + warnings++ + if e.Color != "casino_warning" { + t.Fatal("warning not colored") + } + } + if strings.Contains(e.Message, "standing.") { + timeout = true + if e.Color != "casino_warning" { + t.Fatal("timeout not colored") + } + } + } + } + if warnings != 1 || !timeout { + t.Fatalf("multi=%v warnings=%d timeout=%v", multi, warnings, timeout) + } + } +} + +func TestInsuranceRequiresExplicitDecisionAndWarns(t *testing.T) { + table := uxBlackjack() + table.phase = blackjackInsurance + table.timer.start(8) + before := len(table.players.get("alice").hands[0].cards) + events := table.action("alice", "hit") + if !hasMessage(events, "No card action") || table.players.get("alice").declinedInsurance || len(table.players.get("alice").hands[0].cards) != before { + t.Fatal("hit silently declined or took card") + } + warnings := 0 + for i := 0; i < 8; i++ { + for _, e := range table.tick() { + if strings.Contains(e.Message, "Insurance closes") { + warnings++ + if e.Color != "casino_warning" { + t.Fatal("insurance warning color") + } + } + } + } + if warnings != 1 || table.phase != blackjackPlaying { + t.Fatalf("warnings=%d phase=%s", warnings, table.phase) + } +} + +func TestBettingWarningOnlyTargetsPlayersWhoNeedToBet(t *testing.T) { + table := newBaccaratTestTable(TableConfig{ID: "bac", Game: GameBaccarat, BettingTicks: 8}) + for _, n := range []string{"alice", "bob", "carol"} { + table.join(n) + } + table.action("carol", "sitout") + table.bet("alice", "banker", 10, &testWallet{credits: 100}) + warnings := 0 + for i := 0; i < 8; i++ { + for _, e := range table.tick() { + if e.Color == "casino_warning" { + warnings++ + if e.PrivateTo != "bob" { + t.Fatalf("warning sent to %s", e.PrivateTo) + } + } + } + } + if warnings != 1 { + t.Fatalf("warnings=%d", warnings) + } +} + +func TestSitoutDoesNotBlockOthersAndBetRejoins(t *testing.T) { + table := newBaccaratTestTable(TableConfig{ID: "bac", Game: GameBaccarat}) + table.join("alice") + table.join("bob") + table.bet("alice", "banker", 10, &testWallet{credits: 100}) + events := table.action("bob", "sitout") + if !hasMessage(events, "New baccarat hand!") || !table.players.get("bob").SittingOut { + t.Fatal("sitout held up round") + } + table.action("alice", "sitout") + events = table.bet("bob", "player", 10, &testWallet{credits: 100}) + if !hasMessage(events, "New baccarat hand!") || table.players.get("bob").SittingOut { + t.Fatal("bet failed to rejoin") + } +} + +func TestQueuedLeaveAndSitoutPreserveCommittedBlackjackHands(t *testing.T) { + for _, action := range []string{"leave_after", "sitout"} { + table := uxBlackjack() + hand := table.players.get("alice").hands[0] + table.action("alice", action) + if !table.hasParticipant("alice") || hand.stood || hand.surrendered { + t.Fatalf("%s altered committed hand", action) + } + events := table.action("alice", "stand") + if !hasMessage(events, "Net -10 credits") { + t.Fatalf("missing settlement: %+v", events) + } + if action == "leave_after" && table.hasParticipant("alice") { + t.Fatal("queued leave retained seat") + } + if action == "sitout" && !table.players.get("alice").SittingOut { + t.Fatal("queued sitout not applied") + } + } +} + +func TestSplitAndInsuranceRoundNetIncludesEveryStake(t *testing.T) { + table := uxBlackjack() + p := table.players.get("alice") + p.hands = []*blackjackHand{{bet: 20, stood: true, cards: []card{{rank: "10"}, {rank: "10"}}}, {bet: 10, bust: true, cards: []card{{rank: "10"}, {rank: "10"}, {rank: "5"}}}} + p.insurance = 5 + events := table.settle() + for _, text := range []string{"Hand 1/2 | WIN | Net +20 credits | 40 returned", "Hand 2/2 | LOSS | Net -10 credits", "INSURANCE | Net -5 credits", "Round complete | Net +5 credits"} { + if !hasMessage(events, text) { + t.Fatalf("missing %q in %+v", text, events) + } + } +} + +func TestRebetMenusAndMidRoundJoin(t *testing.T) { + table := uxBlackjack() + events := table.join("bob") + if !hasMessage(events, "Round in progress") { + t.Fatal("mid-round join offered betting") + } + bac := newBaccaratTestTable(TableConfig{ID: "bac", Game: GameBaccarat}) + bac.join("alice") + first := bac.menuEvent("alice") + if first.Menu.Commands[0].Command == "bet <amount>" { + t.Fatal("first baccarat bet omitted target") + } + bac.bet("alice", "banker", 25, &testWallet{credits: 100}) + menu := bac.menuEvent("alice").Menu + if !menu.Repeat || !strings.Contains(menu.Commands[0].Desc, "Repeat 25 on banker") { + t.Fatalf("missing rebet details: %+v", menu) + } +} diff --git a/internal/casino/types.go b/internal/casino/types.go index 5efb43e..4bbc0a8 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -250,6 +250,7 @@ type MenuView struct { Heading string // prompt heading shown above the command list ShowBalance bool // prepend the player's chips/credits balance Commands []MenuCommand + Repeat bool // returning bettor: compact clients can show a short reminder } // MenuCommand is one selectable command shown in a casino menu: the typed @@ -257,6 +258,7 @@ type MenuView struct { type MenuCommand struct { Command string Desc string + Cost int // additional stake; presentation can mark unaffordable actions } type Event struct { @@ -275,8 +277,10 @@ type Event struct { // snapshot can never be assigned without implementing the interface, and // the event filter can test it with a single nil check instead of // enumerating every game's field. - Snapshot Snapshot - Menu *MenuView + Snapshot Snapshot + Menu *MenuView + ShowBalance bool // append the recipient's current available funds + Detail bool // action-by-action public output, only for participants/watchers } // Snapshot is the marker interface for a game's structured event view. The @@ -298,9 +302,14 @@ type CardView struct { } type BlackjackPlayerView struct { - Name string - Cards []CardView - Score string + Name string + Cards []CardView + Score string + Hand int + Hands int + Bet int + Active bool + Status string } type BlackjackSnapshot struct { @@ -331,9 +340,12 @@ type BaccaratSnapshot struct { func (*BaccaratSnapshot) snapshot() {} type Participant struct { - Name string - Connected bool - HasBet bool - Wager Wager - Wallet Wallet + Name string + Connected bool + HasBet bool + Wager Wager + Wallet Wallet + SittingOut bool + SitOutAfter bool + LeaveAfter bool } diff --git a/internal/config/config.go b/internal/config/config.go index 852a6d6..d765c5b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -50,39 +50,46 @@ func DefaultColors() ColorsConfig { "damage_dealt": "B4", "damage_taken": "A7", - "xp": "B3", - "level_up": "B4 bold", - "item": "6E", - "player_name": "74", - "death": "A7 bold", - "victory": "6C", - "miss": "F5", - "error": "A7", - "say": "FF", - "global": "49", - "dialog": "6E", - "dialog_options": "bold", - "command": "0A bold", - "broadcast": "89", - "sequence": "off", - "fire": "B4", - "eat_food": "6C", - "drop_message": "89", - "currency_pickup": "B3", - "visual_tick": "49 dim", - "visual_first_tick": "B4 bold", - "battery": "6C", - "tech_depleted": "A7", - "science_mod": "8B", - "assassin_task": "8B", - "dim": "F5 dim", - "warning": "B3", - "farm_grow": "6C", - "farm_disease": "A7", - "map_at": "B4 bold", - "map_blocked": "A7", - "map_agility": "43", - "safespot_alert": "A7 bold", + "xp": "B3", + "level_up": "B4 bold", + "item": "6E", + "player_name": "74", + "death": "A7 bold", + "victory": "6C", + "miss": "F5", + "error": "A7", + "say": "FF", + "global": "49", + "dialog": "6E", + "dialog_options": "bold", + "command": "0A bold", + "broadcast": "89", + "sequence": "off", + "fire": "B4", + "eat_food": "6C", + "drop_message": "89", + "currency_pickup": "B3", + "visual_tick": "49 dim", + "visual_first_tick": "B4 bold", + "battery": "6C", + "tech_depleted": "A7", + "science_mod": "8B", + "assassin_task": "8B", + "dim": "F5 dim", + "warning": "B3", + "casino_warning": "C4 bold", + "casino_action": "6E", + "casino_menu": "FA", + "casino_chips": "B3", + "casino_credits": "B3", + "casino_big_win": "0A bold", + "casino_jackpot_win": "E2 bold", + "farm_grow": "6C", + "farm_disease": "A7", + "map_at": "B4 bold", + "map_blocked": "A7", + "map_agility": "43", + "safespot_alert": "A7 bold", } } diff --git a/internal/engine/tick.go b/internal/engine/tick.go index 8c64cdd..e2e7343 100644 --- a/internal/engine/tick.go +++ b/internal/engine/tick.go @@ -20,14 +20,15 @@ type subscriber struct { } type Engine struct { - tickMu sync.Mutex // serializes manual ticks and successive ticker runs - mu sync.Mutex - subscribers map[uint64]*subscriber - nextID uint64 - ticker *time.Ticker - running bool - stopCh chan struct{} - loopDone chan struct{} + tickMu sync.Mutex // serializes manual ticks and successive ticker runs + mu sync.Mutex + subscribers map[uint64]*subscriber + nextID uint64 + ticker *time.Ticker + running bool + tickLengthMs int + stopCh chan struct{} + loopDone chan struct{} } func New() *Engine { @@ -36,6 +37,16 @@ func New() *Engine { } } +// TickLengthMilliseconds reports the configured cadence for countdown displays. +func (e *Engine) TickLengthMilliseconds() int { + e.mu.Lock() + defer e.mu.Unlock() + if e.tickLengthMs == 0 { + return 600 + } + return e.tickLengthMs +} + func (e *Engine) Subscribe(interval int, cb Callback) uint64 { e.mu.Lock() defer e.mu.Unlock() @@ -64,6 +75,7 @@ func (e *Engine) Start(tickLengthMs int) { if tickLengthMs < 50 { tickLengthMs = 50 } + e.tickLengthMs = tickLengthMs e.ticker = time.NewTicker(time.Duration(tickLengthMs) * time.Millisecond) e.stopCh = make(chan struct{}) // Capture this run's resources. A quick Stop/Start must not make the diff --git a/internal/game/casino.go b/internal/game/casino.go index 698cb78..579371c 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -340,15 +340,36 @@ func (g *Game) casinoMachine(roomID int, gameName string) (*casino.Machine, bool func (g *Game) executePlay(sess *net.Session, args []string, rawInput string) { p := sess.Player - if p == nil || len(args) == 0 { - sess.WriteLine("Play what?") + if p == nil { return } if _, playing := g.Casino.Participation(p.Name); playing { sess.WriteLine("You're already playing a game. Type \"stop\" between rounds to leave it.") return } - g.startCasinoGame(sess, p, args[0]) + choices := g.casinoChoices(p.RoomID) + input := "" + if len(args) > 0 { + input = args[0] + } + choice, ok := resolveCasinoChoice(choices, input) + if !ok { + g.showCasinoChoices(sess, choices) + return + } + if len(args) > 1 { + amount, err := strconv.Atoi(args[1]) + if !choice.table || len(args) > 3 || (args[1] != "min" && args[1] != "max" && (err != nil || amount <= 0)) { + sess.WriteLine("Use play <game> [amount|min|max] [target]. No wager was placed.") + return + } + } + g.startCasinoGame(sess, p, choice.address) + if len(args) > 1 { + if t, joined := g.Casino.Participation(p.Name); joined && t != nil { + g.executeBet(sess, args[1:], rawInput) + } + } } func (g *Game) startCasinoGame(sess *net.Session, p *player.Player, gameName string) { @@ -411,8 +432,12 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { // next token is always the target. That makes numeric targets reachable // ("bet 5 on 17"), which roulette, craps, and keno need. amount, spot, hasAmount, sawOn := 0, "", false, false - for _, arg := range args { + for i, arg := range args { if strings.EqualFold(arg, "on") { + if sawOn || spot != "" || i == len(args)-1 { + sess.WriteLine("Use bet <amount> on <target>. No wager was placed.") + return + } sawOn = true continue } @@ -453,6 +478,16 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { } spot = strings.ToLower(arg) } + if table.Config.Game == casino.GameBaccarat { + switch spot { + case "b": + spot = "banker" + case "p": + spot = "player" + case "t": + spot = "tie" + } + } if !hasAmount { var lastSpot string lastSpot, amount = g.Casino.Rebet(table.RoomID, table.Config.ID, p.Name) @@ -512,11 +547,19 @@ func (g *Game) emitCasinoEvents(events []casino.Event) { if event.PrivateTo != "" && sess.Player != nil && sess.Player.Name == event.PrivateTo { continue } - message := event.Message - if rendered := g.renderCasinoSnapshot(sess, event.Snapshot); rendered != "" { - message += "\n" + rendered + details := g.seesCasinoDetails(sess, event) + if event.Detail && !details { + continue + } + message := g.casinoTimeText(event.Message) + if details { + if rendered := g.renderCasinoSnapshot(sess, event.Snapshot); rendered != "" { + message += "\n" + rendered + } + } + if message != "" { + sess.WriteLine(g.colorize(sess, category, message)) } - sess.WriteLine(g.colorize(sess, category, message)) } } if event.PrivateTo == "" { @@ -526,7 +569,7 @@ func (g *Game) emitCasinoEvents(events []casino.Event) { if text == "" { text = event.Message } - if text == "" && event.Menu == nil { + if text == "" && event.Menu == nil && event.Snapshot == nil { continue } g.charsMu.Lock() @@ -535,10 +578,13 @@ func (g *Game) emitCasinoEvents(events []casino.Event) { if sess == nil { continue } - privateMessage := g.casinoPrivateMessage(sess, category, event.Menu, text) + privateMessage := g.casinoPrivateMessage(sess, category, event.Menu, g.casinoTimeText(text)) if rendered := g.renderCasinoSnapshot(sess, event.Snapshot); rendered != "" { privateMessage += "\n" + rendered } + if event.ShowBalance { + privateMessage += "\n" + g.casinoBalance(sess) + } sess.WriteLine(g.renderCasinoMessage(sess, category, event.Menu, privateMessage)) if category == "casino_menu" { g.writePrompt(sess) @@ -561,15 +607,55 @@ func (g *Game) casinoPrivateMessage(sess *net.Session, category string, menu *ca } balance := "" if menu.ShowBalance { - chips := g.colorize(sess, "casino_chips", fmt.Sprintf("%d", countChips(sess.Player))) - credits := g.colorize(sess, "casino_credits", fmt.Sprintf("%d", sess.Player.Credits)) - balance = fmt.Sprintf("You have %s chips and %s credits.", chips, credits) + balance = g.casinoBalance(sess) } - heading := menu.Heading + heading := g.casinoTimeText(menu.Heading) if heading == "" { heading = message } - return g.renderCasinoCommands(sess, balance, heading, menu.Commands) + commands := append([]casino.MenuCommand(nil), menu.Commands...) + for i := range commands { + if commands[i].Cost > casinoAffordable(sess.Player) { + commands[i].Desc += " [unavailable: insufficient funds]" + } + } + if sess.Player.OptionString("casino_display") == "compact" && menu.Repeat { + var repeat string + for _, c := range commands { + if c.Command == "bet" { + repeat = g.colorize(sess, "command", "bet / rebet") + " " + c.Desc + break + } + } + return g.colorize(sess, "casino_action", heading) + "\n" + balance + "\n " + repeat + "\n " + g.colorize(sess, "command", "bet <amount> [target] | bet min/max | table | sitout | stop") + } + if sess.Player.OptionString("casino_display") == "compact" && !menu.ShowBalance { + var hints []string + for _, c := range commands { + label := c.Command + switch c.Command { + case "hit": + label = "h:hit" + case "stand": + label = "st:stand" + case "double": + label = "dd:double" + case "split": + label = "sp:split" + } + if c.Cost > 0 { + label += fmt.Sprintf(" (+%d)", c.Cost) + } + category := "command" + if c.Cost > casinoAffordable(sess.Player) { + label += " [unavailable]" + category = "casino_warning" + } + hints = append(hints, g.colorize(sess, category, label)) + } + return g.colorize(sess, "casino_action", heading) + "\n " + strings.Join(hints, " ") + } + return g.renderCasinoCommands(sess, balance, heading, commands) } // renderCasinoSnapshot renders a game's structured view for one session. It is @@ -582,13 +668,17 @@ func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot) } mode := g.colorMode(sess) unicode := sess.Player.OptionBool("unicode") + size := sess.Player.OptionInt("card_size") + if sess.Player.OptionString("casino_display") == "compact" { + size = 1 + } switch s := snapshot.(type) { case *casino.SlotSnapshot: return renderSlotSnapshot(s, mode, unicode) case *casino.BlackjackSnapshot: - return renderBlackjackSnapshot(s, mode, unicode, sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt) + return renderBlackjackSnapshotFor(s, mode, unicode, size, sess.Player.OptionString("card_style"), g.CardArt, sess.Player.Name) case *casino.BaccaratSnapshot: - return renderBaccaratSnapshot(s, mode, unicode, sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt) + return renderBaccaratSnapshot(s, mode, unicode, size, sess.Player.OptionString("card_style"), g.CardArt) } return "" } @@ -603,7 +693,7 @@ func (g *Game) renderCasinoCommands(sess *net.Session, balance, heading string, sb.WriteString("\n\n") } if heading != "" { - sb.WriteString(heading) + sb.WriteString(g.colorize(sess, "casino_action", heading)) sb.WriteString("\n\n") } width := 0 @@ -614,7 +704,11 @@ func (g *Game) renderCasinoCommands(sess *net.Session, balance, heading string, } for _, c := range commands { padded := c.Command + strings.Repeat(" ", width-utf8.RuneCountInString(c.Command)) - cmd := g.colorize(sess, "command", padded) + category := "command" + if sess.Player != nil && c.Cost > casinoAffordable(sess.Player) { + category = "casino_warning" + } + cmd := g.colorize(sess, category, padded) if c.Desc != "" { fmt.Fprintf(&sb, " %s %s\n", cmd, c.Desc) } else { @@ -624,7 +718,7 @@ func (g *Game) renderCasinoCommands(sess *net.Session, balance, heading string, return strings.TrimRight(sb.String(), "\n") } -func renderBlackjackSnapshot(snapshot *casino.BlackjackSnapshot, mode string, unicode bool, size int, style string, art *cardart.Store) string { +func renderBlackjackSnapshotFor(snapshot *casino.BlackjackSnapshot, mode string, unicode bool, size int, style string, art *cardart.Store, viewer string) string { if snapshot == nil { return "" } @@ -636,11 +730,35 @@ func renderBlackjackSnapshot(snapshot *casino.BlackjackSnapshot, mode string, un } else if snapshot.DealerShownScore != "" { dealerTitle = "Dealer (" + snapshot.DealerShownScore + "):" } - lines = append(lines, cardLine(mode, dealerTitle)) - lines = append(lines, cardsLine(mode, snapshot.Dealer, size, unicode, style, art)...) + if size == 1 { + lines = append(lines, cardLine(mode, dealerTitle)+" "+strings.Join(cardsLine(mode, snapshot.Dealer, size, unicode, style, art), " ")) + } else { + lines = append(lines, cardLine(mode, dealerTitle)) + lines = append(lines, cardsLine(mode, snapshot.Dealer, size, unicode, style, art)...) + } for _, player := range snapshot.Players { - lines = append(lines, cardLine(mode, fmt.Sprintf("%s (%s):", player.Name, player.Score))) - lines = append(lines, cardsLine(mode, player.Cards, size, unicode, style, art)...) + name := player.Name + if name == viewer { + name = "You" + } + marker := " " + if player.Active { + marker = "> " + } + detail := "" + if player.Hand > 0 { + detail = fmt.Sprintf(" | Hand %d/%d | Wager %d | %s", player.Hand, player.Hands, player.Bet, player.Status) + } + if player.Active && player.Name != viewer { + detail = strings.ReplaceAll(detail, "Your turn", "Playing") + } + title := fmt.Sprintf("%s%s (%s)%s:", marker, name, player.Score, detail) + if size == 1 { + lines = append(lines, cardLine(mode, title)+" "+strings.Join(cardsLine(mode, player.Cards, size, unicode, style, art), " ")) + } else { + lines = append(lines, cardLine(mode, title)) + lines = append(lines, cardsLine(mode, player.Cards, size, unicode, style, art)...) + } } return strings.Join(lines, "\n") } @@ -653,10 +771,14 @@ func renderBaccaratSnapshot(snapshot *casino.BaccaratSnapshot, mode string, unic } size = cardRenderSize(unicode, size) var lines []string - lines = append(lines, cardLine(mode, "Player ("+snapshot.PlayerScore+"):")) - lines = append(lines, cardsLine(mode, snapshot.Player, size, unicode, style, art)...) - lines = append(lines, cardLine(mode, "Banker ("+snapshot.BankerScore+"):")) - lines = append(lines, cardsLine(mode, snapshot.Banker, size, unicode, style, art)...) + if size == 1 { + lines = append(lines, cardLine(mode, "Player ("+snapshot.PlayerScore+"):")+" "+strings.Join(cardsLine(mode, snapshot.Player, size, unicode, style, art), " "), cardLine(mode, "Banker ("+snapshot.BankerScore+"):")+" "+strings.Join(cardsLine(mode, snapshot.Banker, size, unicode, style, art), " ")) + } else { + lines = append(lines, cardLine(mode, "Player ("+snapshot.PlayerScore+"):")) + lines = append(lines, cardsLine(mode, snapshot.Player, size, unicode, style, art)...) + lines = append(lines, cardLine(mode, "Banker ("+snapshot.BankerScore+"):")) + lines = append(lines, cardsLine(mode, snapshot.Banker, size, unicode, style, art)...) + } for _, bet := range snapshot.Bets { lines = append(lines, cardLine(mode, fmt.Sprintf("%s bets %d on %s.", bet.Name, bet.Amount, bet.Spot))) } @@ -676,7 +798,16 @@ func cardsLine(mode string, cards []casino.CardView, size int, unicode bool, sty if size == 1 { parts := make([]string, len(cards)) for i, card := range cards { - parts[i] = colorizeCardSymbol(mode, card) + if !unicode { + if card.Hidden { + parts[i] = "[??]" + } else { + suit := map[string]string{"hearts": "H", "diamonds": "D", "spades": "S", "clubs": "C"}[card.Suit] + parts[i] = card.Rank + suit + } + } else { + parts[i] = colorizeCardSymbol(mode, card) + } } return []string{strings.Join(parts, " ")} } @@ -696,6 +827,9 @@ func cardsLine(mode string, cards []casino.CardView, size int, unicode bool, sty } func cardRenderSize(unicode bool, size int) int { + if size == 1 { + return 1 + } if !unicode { return 5 } diff --git a/internal/game/casino_test.go b/internal/game/casino_test.go index ffd9958..b3bfb9b 100644 --- a/internal/game/casino_test.go +++ b/internal/game/casino_test.go @@ -1,6 +1,7 @@ package game import ( + "regexp" "strings" "testing" "time" @@ -108,7 +109,7 @@ func TestCasinoMenuRendersAlignedColoredCommands(t *testing.T) { {Command: "spin", Desc: "Spin using your current wager"}, }} msg := g.casinoPrivateMessage(sess, "casino_menu", menu, "Your slot machine bet is set to 10.") - if !strings.Contains(msg, "You have 0 chips and 0 credits.") { + if !strings.Contains(regexp.MustCompile(`\x1b\[[0-9;]*m`).ReplaceAllString(msg, ""), "Available 0 (0 chips + 0 credits)") { t.Fatalf("missing balance line: %q", msg) } // Commands are painted with the "command" color (0A bold). @@ -176,11 +177,11 @@ func TestRenderBlackjackSnapshotUsesCompactASCIIArt(t *testing.T) { Dealer: []casino.CardView{{Rank: "?", Hidden: true}, {Rank: "K", Suit: "hearts"}}, Players: []casino.BlackjackPlayerView{{Name: "alice", Score: "20", Cards: []casino.CardView{{Rank: "5", Suit: "clubs"}, {Rank: "Q", Suit: "spades"}}}}, } - plain := renderBlackjackSnapshot(snapshot, "none", false, 5, "dark", nil) + plain := renderBlackjackSnapshotFor(snapshot, "none", false, 5, "dark", nil, "") if !strings.Contains(plain, "+---+") || !strings.Contains(plain, "Dealer:") || !strings.Contains(plain, "alice (20):") { t.Fatalf("unexpected blackjack art: %q", plain) } - output := renderBlackjackSnapshot(snapshot, "xterm256", false, 5, "dark", nil) + output := renderBlackjackSnapshotFor(snapshot, "xterm256", false, 5, "dark", nil, "") if !strings.Contains(output, "\033[38;5;") { t.Fatalf("expected colored blackjack art: %q", output) } @@ -192,15 +193,15 @@ func TestRenderBlackjackSnapshotUsesCompactASCIIArt(t *testing.T) { t.Fatalf("blackjack line exceeds console width: %d", color.VisibleLen(line)) } } - unicodeOutput := renderBlackjackSnapshot(snapshot, "none", true, 7, "dark", nil) + unicodeOutput := renderBlackjackSnapshotFor(snapshot, "none", true, 7, "dark", nil, "") if !strings.Contains(unicodeOutput, "│5 │") || !strings.Contains(unicodeOutput, "│ 5│") || !strings.Contains(unicodeOutput, "│ ♣ │") { t.Fatalf("unexpected Unicode card alignment: %q", unicodeOutput) } - coloredUnicode := renderBlackjackSnapshot(snapshot, "xterm256", true, 7, "dark", nil) + coloredUnicode := renderBlackjackSnapshotFor(snapshot, "xterm256", true, 7, "dark", nil, "") if !strings.Contains(coloredUnicode, "\033[38;5;15m") || !strings.Contains(coloredUnicode, "\033[38;5;27m") { t.Fatalf("expected white borders and blue card art: %q", coloredUnicode) } - light := renderBlackjackSnapshot(snapshot, "xterm256", true, 7, "light", nil) + light := renderBlackjackSnapshotFor(snapshot, "xterm256", true, 7, "light", nil, "") if !strings.Contains(light, "\033[48;5;15m") { t.Fatalf("expected white card background in light style: %q", light) } @@ -208,7 +209,7 @@ func TestRenderBlackjackSnapshotUsesCompactASCIIArt(t *testing.T) { if strings.Contains(lightLines[1], "\033[48;5;15m") || strings.Contains(lightLines[7], "\033[48;5;15m") { t.Fatalf("card edges should not have a background: %q", light) } - lightPlain := renderBlackjackSnapshot(snapshot, "none", true, 7, "light", nil) + lightPlain := renderBlackjackSnapshotFor(snapshot, "none", true, 7, "light", nil, "") if !strings.Contains(lightPlain, "▀▀▀▀▀▀▀") || !strings.Contains(lightPlain, "▄▄▄▄▄▄▄") { t.Fatalf("expected half-height light card edges: %q", lightPlain) } diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go new file mode 100644 index 0000000..fdd4533 --- /dev/null +++ b/internal/game/casino_ux.go @@ -0,0 +1,221 @@ +package game + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/net" +) + +type casinoChoice struct { + address string + names []string + table bool +} + +// Exact addresses beat aliases; prefixes must identify exactly one game. +func resolveCasinoChoice(choices []casinoChoice, input string) (casinoChoice, bool) { + input = strings.ToLower(input) + for _, c := range choices { + if input != "" && strings.EqualFold(c.address, input) { + return c, true + } + } + if input != "" { + var exact []casinoChoice + for _, c := range choices { + for _, name := range c.names { + if strings.EqualFold(name, input) { + exact = append(exact, c) + break + } + } + } + if len(exact) == 1 { + return exact[0], true + } + if len(exact) > 1 { + return casinoChoice{}, false + } + } + matches := []casinoChoice{} + for _, c := range choices { + matched := input == "" + for _, name := range c.names { + if strings.EqualFold(name, input) || (input != "" && strings.HasPrefix(strings.ToLower(name), input)) { + matched = true + } + } + if matched { + matches = append(matches, c) + } + } + if len(matches) == 1 { + return matches[0], true + } + return casinoChoice{}, false +} + +func (g *Game) casinoChoices(roomID int) []casinoChoice { + room, err := g.World.LoadRoom(roomID) + if err != nil { + return nil + } + var choices []casinoChoice + for _, c := range room.CasinoTables { + names := []string{string(c.Game)} + switch c.Game { + case casino.GameBlackjack: + names = append(names, "bj") + case casino.GameBaccarat: + names = append(names, "bac") + } + choices = append(choices, casinoChoice{address: string(c.Game), names: names, table: true}) + } + for _, c := range room.CasinoMachines { + choices = append(choices, casinoChoice{address: c.ID, names: []string{c.ID, string(c.Game), c.Variant}}) + } + return choices +} + +func (g *Game) showCasinoChoices(sess *net.Session, choices []casinoChoice) { + if len(choices) == 0 { + sess.WriteLine("There are no casino games here.") + return + } + commands := []casino.MenuCommand{} + for _, c := range choices { + commands = append(commands, casino.MenuCommand{Command: "play " + c.address, Desc: "Join this game"}) + } + sess.WriteLine(g.renderCasinoCommands(sess, "", "Choose a game (use an unambiguous name or abbreviation):", commands)) +} + +func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) { + if sess.Player == nil { + return + } + if len(args) > 0 && args[0] != "rules" && args[0] != "help" { + sess.WriteLine("Use table, table rules, or table help.") + return + } + t, playing := g.Casino.Participation(sess.Player.Name) + if !playing || t == nil { + g.showCasinoChoices(sess, g.casinoChoices(sess.Player.RoomID)) + return + } + if len(args) > 0 && args[0] == "rules" { + text := t.Config.BlackjackRulesText() + if t.Config.Game == casino.GameBaccarat { + text = t.Config.BaccaratRulesText() + } + sess.WriteLine(g.colorize(sess, "casino_action", text)) + return + } + g.emitCasinoEvents(t.Status(sess.Player.Name)) + if len(args) > 0 { + sess.WriteLine(g.renderCasinoCommands(sess, "", "Table controls", []casino.MenuCommand{ + {Command: "bet / rebet", Desc: "Repeat the displayed wager"}, + {Command: "bet min / bet max", Desc: "Use the table minimum / affordable maximum"}, + {Command: "h / st / dd / sp", Desc: "Blackjack: hit / stand / double / split (s and d still move)"}, + {Command: "insurance / decline", Desc: "Answer the insurance offer explicitly"}, + {Command: "sitout", Desc: "Skip future rounds; bet to rejoin"}, + {Command: "stop", Desc: "Leave now, or after your committed round"}, + {Command: "option casino_display compact", Desc: "Short cards and action hints (or detailed for card art)"}, + {Command: "table rules", Desc: "Review this table’s rules"}, + })) + } +} + +func (g *Game) executeWatch(sess *net.Session, args []string, _ string) { + if sess.Player == nil { + return + } + if len(args) == 1 && args[0] == "off" { + sess.CasinoWatchTable = "" + sess.WriteLine(g.colorize(sess, "casino_action", "You stop watching table details.")) + return + } + if len(args) > 1 { + sess.WriteLine("Use watch [game] or watch off.") + return + } + choices := []casinoChoice{} + for _, c := range g.casinoChoices(sess.Player.RoomID) { + if c.table { + choices = append(choices, c) + } + } + input := "" + if len(args) == 1 { + input = args[0] + } + c, ok := resolveCasinoChoice(choices, input) + if !ok { + sess.WriteLine("Choose a table to watch: watch blackjack or watch baccarat.") + return + } + t, ok := g.casinoTable(sess.Player.RoomID, c.address) + if !ok { + return + } + sess.CasinoWatchRoom = sess.Player.RoomID + sess.CasinoWatchTable = t.Config.ID + sess.WriteLine(g.colorize(sess, "casino_action", "Watching "+t.Config.Label()+". Detailed public cards are visible; watch off stops watching.")) + g.emitCasinoEvents(t.Status(sess.Player.Name)) +} + +func (g *Game) seesCasinoDetails(sess *net.Session, event casino.Event) bool { + if sess.Player == nil { + return false + } + // Slot output is unchanged; watch controls multiplayer tables only. + if g.Casino.Table(event.RoomID, event.TableID) == nil { + return true + } + if sess.CasinoWatchRoom == event.RoomID && sess.CasinoWatchTable == event.TableID { + return true + } + // Settlement may have removed a queued departure before these events + // are delivered. Their own final cards must still be visible. + switch s := event.Snapshot.(type) { + case *casino.BlackjackSnapshot: + for _, p := range s.Players { + if p.Name == sess.Player.Name { + return true + } + } + case *casino.BaccaratSnapshot: + for _, p := range s.Bets { + if p.Name == sess.Player.Name { + return true + } + } + } + t, ok := g.Casino.Participation(sess.Player.Name) + return ok && t != nil && t.RoomID == event.RoomID && t.Config.ID == event.TableID +} + +var casinoTicksPattern = regexp.MustCompile(`([0-9]+) ticks`) + +// Casino logic counts engine ticks; present deadlines in actual configured seconds. +func (g *Game) casinoTimeText(text string) string { + ms := 600 + if g.Ticks != nil { + ms = g.Ticks.TickLengthMilliseconds() + } + return casinoTicksPattern.ReplaceAllStringFunc(text, func(s string) string { + n, _ := strconv.Atoi(strings.Fields(s)[0]) + return fmt.Sprintf("%g seconds", float64(n)*float64(ms)/1000) + }) +} + +func (g *Game) casinoBalance(sess *net.Session) string { + if sess.Player == nil { + return "" + } + p := sess.Player + return fmt.Sprintf("Available %s (%s chips + %s credits)", g.colorize(sess, "casino_chips", strconv.Itoa(casinoAffordable(p))), g.colorize(sess, "casino_chips", strconv.Itoa(countChips(p))), g.colorize(sess, "casino_credits", strconv.Itoa(p.Credits))) +} diff --git a/internal/game/casino_ux_test.go b/internal/game/casino_ux_test.go new file mode 100644 index 0000000..046d7d3 --- /dev/null +++ b/internal/game/casino_ux_test.go @@ -0,0 +1,153 @@ +package game + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func stripANSI(s string) string { + return regexp.MustCompile(`\x1b\[[0-9;]*m`).ReplaceAllString(s, "") +} + +func uxCasinoGame(t *testing.T) (*Game, *net.Session, *casinoTestConn) { + t.Helper() + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "rooms"), 0755); err != nil { + t.Fatal(err) + } + room := "name: Casino\ncasino_tables:\n - id: bj1\n game: blackjack\n min_bet: 1\n max_bet: 100\n blackjack:\n action_ticks: 25\n - id: bac1\n game: baccarat\n min_bet: 5\n max_bet: 100\n" + if err := os.WriteFile(filepath.Join(dir, "rooms", "1.yaml"), []byte(room), 0644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "players", "characters"), 0o755); err != nil { + t.Fatal(err) + } + p := player.New("alice") + sess := &net.Session{Player: p, Conn: &casinoTestConn{}} + g := &Game{ + Deps: Deps{World: world.New(dir), AccountStore: player.NewAccountStore(dir), Ticks: engine.New()}, + Casino: casino.NewManager(1), + Combat: combat.NewTracker(), + safespot: NewSafespotManager(), + restTimers: make(map[string]uint64), + loggedInChars: map[string]*net.Session{"alice": sess}, + } + p.Credits = 100 + p.RoomID = 1 + return g, sess, sess.Conn.(*casinoTestConn) +} + +func TestPlayAliasesJoinGames(t *testing.T) { + for input, game := range map[string]string{"bj": "blackjack", "black": "blackjack", "bac": "baccarat", "baccarat": "baccarat"} { + g, sess, conn := uxCasinoGame(t) + g.executePlay(sess, strings.Fields(input), "play "+input) + table, ok := g.Casino.Participation("alice") + if !ok || table == nil || string(table.Config.Game) != game { + t.Fatalf("play %s: participation=%v game=%q", input, ok, game) + } + if !strings.Contains(stripANSI(string(conn.data)), "Next hand") { + t.Fatalf("play %s: missing betting menu, got %q", input, stripANSI(string(conn.data))) + } + } +} + +func TestPlayAmbiguousAndUnknownListChoices(t *testing.T) { + g, sess, conn := uxCasinoGame(t) + _ = g + for _, input := range []string{"", "b", "zzz"} { + conn.data = nil + args := []string{} + if input != "" { + args = strings.Fields("play " + input)[1:] + } + g.executePlay(sess, args, "play "+input) + text := stripANSI(string(conn.data)) + if !strings.Contains(text, "play blackjack") || !strings.Contains(text, "play baccarat") { + t.Fatalf("play %q: expected choice list, got %q", input, text) + } + } +} + +func TestPlayWithAmountJoinsAndBets(t *testing.T) { + g, sess, conn := uxCasinoGame(t) + g.executePlay(sess, []string{"bj", "25"}, "play bj 25") + if !strings.Contains(stripANSI(string(conn.data)), "You bet 25 credits.") { + t.Fatalf("missing wager confirmation: %q", stripANSI(string(conn.data))) + } +} + +func TestTableStatusShowsStateAndRules(t *testing.T) { + g, sess, conn := uxCasinoGame(t) + g.executePlay(sess, []string{"bj"}, "play bj") + g.executeTableStatus(sess, nil, "table") + text := stripANSI(string(conn.data)) + if !strings.Contains(text, "Limits") || !strings.Contains(text, "seated") { + t.Fatalf("status missing overview: %q", text) + } + conn.data = nil + g.executeTableStatus(sess, []string{"rules"}, "table rules") + if !strings.Contains(stripANSI(string(conn.data)), "payout") { + t.Fatalf("table rules missing: %q", stripANSI(string(conn.data))) + } +} + +func TestWatchAndSpectatorDetailFlow(t *testing.T) { + g, sess, conn := uxCasinoGame(t) + g.executePlay(sess, []string{"bj", "10"}, "play bj 10") + // Alice, seated, sees the deal; watch off is still acknowledged. + if _, seated := g.Casino.Participation("alice"); !seated { + t.Fatal("alice not seated") + } + g.executeWatch(sess, []string{"off"}, "watch off") + if !strings.Contains(stripANSI(string(conn.data)), "stop watching") { + t.Fatalf("watch off not confirmed: %q", stripANSI(string(conn.data))) + } +} + +func TestBlackjackShortcutsReachTheTable(t *testing.T) { + g, sess, conn := uxCasinoGame(t) + cfg := casino.TableConfig{ID: "blackjack", Game: casino.GameBlackjack, MinBet: 1, MaxBet: 100, Blackjack: casino.BlackjackConfig{ActionTicks: 3}} + g.Casino.Join(1, cfg, "alice") + // Rounds deal instantly with a lone bettor; dealer aces and naturals vary + // by seed, so repeat short rounds until a hit actually lands. + hit := false + for round := 0; round < 12 && !hit; round++ { + g.executeBet(sess, nil, "rebet") + g.executeTableAction(sess, "decline") + for attempt := 0; attempt < 3 && !hit; attempt++ { + g.executeTableAction(sess, "h") + hit = strings.Contains(stripANSI(string(conn.data)), "You hit") + } + g.executeTableAction(sess, "st") + } + if _, seated := g.Casino.Participation("alice"); !seated { + t.Fatal("shortcut actions should never remove the seat") + } + if !hit { + t.Fatalf("shortcuts did not hit: %q", stripANSI(string(conn.data))) + } +} + +func TestRebetRepeatsWager(t *testing.T) { + g, sess, conn := uxCasinoGame(t) + cfg := casino.TableConfig{ID: "bac", Game: casino.GameBaccarat, MinBet: 5, MaxBet: 100} + g.Casino.Join(1, cfg, "alice") + g.Casino.Bet(1, "bac", "alice", "banker", 20, casinoWallet{g: g, p: sess.Player}) + conn.data = nil + // The first round deals and settles instantly with a single bettor, so a + // fresh betting window opens; rebet should repeat 20 on banker. + g.executeBet(sess, nil, "rebet") + if !strings.Contains(stripANSI(string(conn.data)), "on banker") { + t.Fatalf("rebet did not target banker: %q", stripANSI(string(conn.data))) + } +} diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index ae4c895..d5676d5 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -25,6 +25,16 @@ var commandRegistry = map[string]commandDef{ "kill": {(*Game).executeAttack, ClassActive}, "work": {(*Game).executeWork, ClassActive}, "play": {(*Game).executePlay, ClassActive}, + "rebet": {(*Game).executeBet, ClassActive}, + "table": {(*Game).executeTableStatus, ClassInstant}, + "status": {(*Game).executeTableStatus, ClassInstant}, + "watch": {(*Game).executeWatch, ClassInstant}, + "sitout": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "sitout") }, ClassInstant}, + "decline": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "decline") }, ClassActive}, + "h": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "hit") }, ClassActive}, + "st": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "stand") }, ClassActive}, + "dd": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "double") }, ClassActive}, + "sp": {func(g *Game, s *net.Session, _ []string, _ string) { g.executeTableAction(s, "split") }, ClassActive}, "bet": {(*Game).executeBet, ClassActive}, "spin": {(*Game).executeBet, ClassActive}, "autobet": {(*Game).executeAutoBet, ClassActive}, diff --git a/internal/game/cmd_stop.go b/internal/game/cmd_stop.go index 4b287ce..2f2a7ee 100644 --- a/internal/game/cmd_stop.go +++ b/internal/game/cmd_stop.go @@ -10,7 +10,17 @@ func (g *Game) executeStop(sess *net.Session, args []string, rawInput string) { // queue; cancel a pending quit countdown explicitly. g.cancelRest(p.Name) if _, playing := g.Casino.Participation(p.Name); playing { - g.leaveCasinoGames(p) + if table, _ := g.Casino.Participation(p.Name); table != nil { + // Normal leave refuses committed wagers. Only an explicit stop + // queues a departure; movement/quit retain their existing policy. + events := g.Casino.Leave(table.RoomID, table.Config.ID, p.Name) + if _, stillSeated := g.Casino.Participation(p.Name); stillSeated { + events = g.Casino.Action(table.RoomID, table.Config.ID, p.Name, "leave_after") + } + g.emitCasinoEvents(events) + } else { + g.leaveCasinoGames(p) + } return } ss, _ := g.safespot.Get(p.Name) diff --git a/internal/game/look_entities.go b/internal/game/look_entities.go index 1239f31..8bf762d 100644 --- a/internal/game/look_entities.go +++ b/internal/game/look_entities.go @@ -230,8 +230,21 @@ func (g *Game) showRoomCasinos(sess *net.Session, room *world.Room) []string { } var lines []string for _, table := range room.CasinoTables { - name := casinoGameLabel(table.Game) + " table" - lines = append(lines, fmt.Sprintf("A %s is here.", g.colorize(sess, "casino_action", name))) + cfg := table.Normalize() + label := fmt.Sprintf("%s | Limits %d–%d | 0/%d seated | Betting open", cfg.Label(), cfg.MinBet, cfg.MaxBet, cfg.MaxPlayers) + if g.Casino != nil { + if live := g.Casino.Table(room.ID, table.ID); live != nil { + label = g.casinoTimeText(live.Overview()) + } + } + alias := string(table.Game) + if table.Game == casino.GameBlackjack { + alias = "bj" + } + if table.Game == casino.GameBaccarat { + alias = "bac" + } + lines = append(lines, g.colorize(sess, "casino_action", label), " "+g.colorize(sess, "command", "play "+alias)+" Join | "+g.colorize(sess, "command", "watch "+alias)+" Spectate") } for _, machine := range room.CasinoMachines { label := casinoGameLabel(machine.Game) + " machine" diff --git a/internal/game/quit_casino_test.go b/internal/game/quit_casino_test.go index 02dadb0..6ffe2d5 100644 --- a/internal/game/quit_casino_test.go +++ b/internal/game/quit_casino_test.go @@ -267,12 +267,11 @@ func TestQuitNowAtMachineHoldsUntilLogout(t *testing.T) { } } -// Stop and quit share one departure path: both refuse mid-round and both -// leave gracefully between rounds. -func TestStopMatchesQuitCasinoPolicy(t *testing.T) { +// Stop queues a departure; quit still refuses a committed round. +func TestStopQueuesDepartureWithoutForfeiting(t *testing.T) { cfg := casino.TableConfig{ID: "blackjack", Game: casino.GameBlackjack, MinBet: 1, MaxBet: 100} - // Mid-round: stop refuses just like quit. + // Mid-round: stop queues a departure without removing or forfeiting. g, sess := quitTestGame(t) g.Casino.Join(1, cfg, "alice") g.Casino.Join(1, cfg, "bob") @@ -281,8 +280,8 @@ func TestStopMatchesQuitCasinoPolicy(t *testing.T) { conn := sess.Conn.(*casinoTestConn) conn.data = nil g.executeStop(sess, nil, "stop") - if !strings.Contains(string(conn.data), "middle of a blackjack round") { - t.Fatalf("expected mid-round refusal, got %q", string(conn.data)) + if !strings.Contains(string(conn.data), "leave after this round") { + t.Fatalf("expected queued departure, got %q", string(conn.data)) } if _, playing := g.Casino.Participation("alice"); !playing { t.Fatal("stop mid-round removed the player from the table") diff --git a/internal/game/render_help.go b/internal/game/render_help.go index a11ac74..b41af99 100644 --- a/internal/game/render_help.go +++ b/internal/game/render_help.go @@ -63,12 +63,15 @@ var commandList = []cmdEntry{ {"jack / jackin", "Active", "Jack into a terminal (Hacking)"}, {"look / l", "Instant", "Look around or examine things"}, {"map", "Instant", "Display an ASCII map of the area"}, - {"play", "Active", "Join a casino game in the current room"}, - {"bet", "Active", "Place a casino wager (e.g. bet 100 on banker)"}, + {"play", "Active", "Join a game; play bj / play bac; optional amount and target"}, + {"table / status", "Instant", "Redisplay table state; table rules / table help for details"}, + {"watch", "Instant", "Watch detailed table action; watch off to stop"}, + {"sitout", "Instant", "Sit out future table rounds; bet to rejoin"}, + {"bet / rebet", "Active", "Repeat a wager, or bet <amount> [target]; min/max accepted"}, {"autobet / autospin", "Active", "Automatically repeat a casino wager"}, {"spin", "Active", "Spin the current casino wager"}, - {"hit / stand / double / split", "Active", "Play a blackjack hand"}, - {"insurance / surrender", "Active", "Use blackjack table options"}, + {"hit / stand / double / split", "Active", "Play blackjack (shortcuts: h / st / dd / sp)"}, + {"insurance / decline / surrender", "Active", "Answer an insurance offer or surrender a blackjack hand"}, {"mine", "Active", "Mine rocks (Mining)"}, {"mix", "Active", "Mix potions (Pharmacy)"}, {"mods / modlist", "Instant", "List available science modules"}, diff --git a/internal/net/server.go b/internal/net/server.go index 80710ad..c715074 100644 --- a/internal/net/server.go +++ b/internal/net/server.go @@ -65,8 +65,11 @@ type Session struct { PendingBackground bool PendingUndigDir string PendingUndigRoom int - promptVisible bool - writeMu sync.Mutex + // Casino spectator preferences are session-local, owner-goroutine only. + CasinoWatchRoom int + CasinoWatchTable string + promptVisible bool + writeMu sync.Mutex // Disconnect bookkeeping is written by the connection goroutine that // discovered the dead transport (Hub.Remove's deferred path) and read diff --git a/internal/player/player.go b/internal/player/player.go index 22ef04a..b296dee 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -140,7 +140,8 @@ var OptionDefs = []OptionDef{ {"show_queued_cmds", OptBool, false, nil, "Show confirmation messages for queued tick actions"}, {"wrap_width", OptInt, 80, nil, "Wrap all output to this many columns (minimum 80)"}, {"unicode", OptBool, true, nil, "Unicode box-drawing characters"}, - {"card_size", OptInt, 9, nil, "Casino card size (1, 5, 7, or 9)"}, + {"casino_display", OptString, "compact", []string{"compact", "detailed"}, "Table display: compact cards and hints, or detailed card art and menus"}, + {"card_size", OptInt, 9, nil, "Detailed casino card size (1, 5, 7, or 9)"}, {"card_style", OptString, "dark", []string{"dark", "light"}, "Casino card style (dark or light)"}, {"visual_ticks", OptBool, false, nil, "Display a tick marker every game tick"}, {"visual_tick_count", OptInt, 0, nil, "Cycle length for tick counter (0 = no counter)"}, |
