diff options
| -rw-r--r-- | building_guide/rooms.md | 23 | ||||
| -rw-r--r-- | data/help/casino.yaml | 3 | ||||
| -rw-r--r-- | data/help/coin_flip.yaml | 34 | ||||
| -rw-r--r-- | data/rooms/casino/6202.yaml | 4 | ||||
| -rw-r--r-- | data/rooms/casino/6203.yaml | 10 | ||||
| -rw-r--r-- | internal/casino/coin_flip.go | 652 | ||||
| -rw-r--r-- | internal/casino/coin_flip_test.go | 296 | ||||
| -rw-r--r-- | internal/casino/core.go | 3 | ||||
| -rw-r--r-- | internal/casino/registry.go | 9 | ||||
| -rw-r--r-- | internal/casino/table.go | 14 | ||||
| -rw-r--r-- | internal/casino/types.go | 1 | ||||
| -rw-r--r-- | internal/game/casino.go | 7 | ||||
| -rw-r--r-- | internal/game/casino_ux.go | 8 | ||||
| -rw-r--r-- | internal/game/cmd_registry.go | 13 | ||||
| -rw-r--r-- | internal/game/coin_flip_test.go | 162 | ||||
| -rw-r--r-- | internal/game/look_casino.go | 2 | ||||
| -rw-r--r-- | internal/game/look_target.go | 2 | ||||
| -rw-r--r-- | internal/game/render_coin_flip.go | 238 |
18 files changed, 1475 insertions, 6 deletions
diff --git a/building_guide/rooms.md b/building_guide/rooms.md index 608dcf5..c61e710 100644 --- a/building_guide/rooms.md +++ b/building_guide/rooms.md @@ -246,7 +246,7 @@ Multiplayer table fields (`casino_tables`): | Field | Type | Default | Notes | |---|---|---|---| | `id` | string | — | Required. Nonempty, no surrounding whitespace, unique among the room's tables and machines. | -| `game` | string | — | Required. `blackjack`, `baccarat`, `roulette`, `craps`, `paigow`, `video_poker`, `plinko`, `pachinko`, or `cash_hunt`. | +| `game` | string | — | Required. `blackjack`, `baccarat`, `roulette`, `craps`, `paigow`, `video_poker`, `plinko`, `pachinko`, `cash_hunt`, or `coin_flip`. | | `max_players` | int | 8 | 0–64. Video poker is forced to 1; Pai Gow caps at 6. | | `betting_ticks` | int | 20 | 0–3600. | | `min_bet` | int | 1 | Must not exceed `max_bet`. | @@ -311,6 +311,27 @@ round and the best-slot return is exactly 100%. `max_players` must be omitted, space can chain an arbitrarily large payout; any return beyond the economy's credit cap is clamped rather than rejected. +#### Coin Flip + +Coin Flip is a single-seat fixture under `casino_tables`, addressed with +`play coin flip`: + +```yaml +casino_tables: + - id: coin_flip + game: coin_flip + min_bet: 1 + max_bet: 10000 +``` + +No game-specific rule block is needed. The coin is a fair 50/50: red lands a +negative net multiplier (0 to -100), blue a positive one (0 to +100 with a +removed middle band so its mean stays +46), and the two faces average a -2 +swing for exactly 98% RTP. Because the swing can reach 100x the stake, a bet +of `N` reserves `100 * N` from the wallet, so the player must hold 100x their +stake. `max_players` must be omitted, `0`, or `1`, and `max_bet` cannot exceed +`MaxCredits / 100`. + #### Blackjack ```yaml diff --git a/data/help/casino.yaml b/data/help/casino.yaml index fe81bad..74b1250 100644 --- a/data/help/casino.yaml +++ b/data/help/casino.yaml @@ -1,7 +1,7 @@ name: casino aliases: [play, bet, rebet, table, watch, rules] category: Interaction -related: [big6, roulette, racetrack, blackjack, baccarat, craps, slots, video poker, pai gow poker, plinko, pachinko, cash hunt, predictions, crypto] +related: [big6, roulette, racetrack, blackjack, baccarat, craps, slots, video poker, pai gow poker, plinko, pachinko, cash hunt, coin flip, predictions, crypto] description: | Casino quick guide @@ -55,6 +55,7 @@ description: | help plinko Animated pyramid, four risk levels, up to ten balls. help pachinko Rectangle of pins; populated board; pick a top slot; DOUBLE chains prizes. help cash hunt Reveal 100 prizes, mix their positions, pick one square. + help coin flip Fair 50/50 coin; red 0 to -100x, blue 0 to +100x; reserves 100x the stake. help video poker Five cabinet types, selectable games, toggle holds and bonuses. help pai gow poker Seven cards, two hands; Classic, EZ, No Push and Fortune/Envy. diff --git a/data/help/coin_flip.yaml b/data/help/coin_flip.yaml new file mode 100644 index 0000000..56d9dd0 --- /dev/null +++ b/data/help/coin_flip.yaml @@ -0,0 +1,34 @@ +name: coin flip +aliases: [coinflip, coin_flip, coin-flip, coin] +category: Interaction +related: [casino, cash hunt, pachinko] +description: | + COIN FLIP — a fair 50/50 coin with two spinning faces. + + play coin flip Take the single seat. + bet 10 Stake 10 (the house reserves 1000); the faces spin. + bet / rebet Repeat your last stake. + stop Leave after the coin has landed. + + Each round the RED face spins like a wheel and lands a negative multiplier + from 0 to -100. The BLUE face then spins and lands a positive multiplier + from 0 to +100, with a middle band (63-77 and 90) removed so the two faces + still average a -2 swing. The landed multipliers print, then the fair + 50/50 coin flips. A settled coin is filled in solid red or blue. + + The multiplier is a NET swing on your stake, so red -50 loses half your + stake, blue +50 wins half your stake, red -100 loses it all, and blue +100 + doubles it. Multipliers include the stake; +0x is a push. + + Because the swing can reach 100x your stake, the house requires you to + hold 100x your stake and reserves exactly that when you bet. Your payout + is (100 + swing) x stake, so it is never negative: a -100 red simply + returns zero. The reserve and the payout are one settled transaction. + + The coin is exactly fair. The 2% house edge comes from the two faces: + red is uniform over 0..100 and blue drops a middle band to a mean of +46, + so the two distributions average a -2 swing per stake — exactly 98% RTP. + + You cannot leave mid-round. Shutdown refunds an unsettled round; a failed + payout keeps your stake pending for recovery and settle retries the same + landed face. diff --git a/data/rooms/casino/6202.yaml b/data/rooms/casino/6202.yaml index 0ae636b..4a3ce45 100644 --- a/data/rooms/casino/6202.yaml +++ b/data/rooms/casino/6202.yaml @@ -1,8 +1,10 @@ name: Fallen Star Pachinko Gallery -description: A broad cabinet of polished obsidian frames a tall rectangle of silver pins. Numbered brass slots crown the top, a golden DOUBLE burns at the base, and jewel-bright prize lamps ring the pockets. Balls rain down in ribbons of light, bouncing pin to pin. The Kaleidoscope Vault glitters to the south. +description: A broad cabinet of polished obsidian frames a tall rectangle of silver pins. Numbered brass slots crown the top, a golden DOUBLE burns at the base, and jewel-bright prize lamps ring the pockets. Balls rain down in ribbons of light, bouncing pin to pin. The Kaleidoscope Vault glitters to the south, and two turning coins glow to the north. exits: south: room: 6201 + north: + room: 6203 casino_tables: - id: pachinko game: pachinko diff --git a/data/rooms/casino/6203.yaml b/data/rooms/casino/6203.yaml new file mode 100644 index 0000000..4e73141 --- /dev/null +++ b/data/rooms/casino/6203.yaml @@ -0,0 +1,10 @@ +name: Two Moons Flip +description: Two enormous coins hang from the ceiling, one veined in crimson and one washed in deep blue, turning slowly above a felt table. A brass rail reads, "One coin. One swing. The red takes, the blue gives." The Fallen Star Pachinko Gallery glitters to the south. +exits: + south: + room: 6202 +casino_tables: + - id: coin_flip + game: coin_flip + min_bet: 1 + max_bet: 10000 diff --git a/internal/casino/coin_flip.go b/internal/casino/coin_flip.go new file mode 100644 index 0000000..29247fe --- /dev/null +++ b/internal/casino/coin_flip.go @@ -0,0 +1,652 @@ +package casino + +import ( + "fmt" + "math/rand" + "strings" + "sync" +) + +// Coin Flip is Crazy Time's always-positive bonus reimagined as a standalone +// wager. A fair coin picks one of two spinning faces: the red face carries a +// negative multiplier (0 to -100) and the blue face a positive one (0 to +// +100). The multiplier is a net swing on the player's stake, so a -100 red +// busts the whole wager and a +100 blue doubles it. Because the swing can +// reach 100x the stake, the house requires the player to hold 100x the stake +// before the bet is accepted and reserves exactly that much up front; the +// round then pays back (100 + swing) x stake, which is never negative. +// +// The coin is fair 50/50. The 2% house edge comes entirely from the two +// multiplier distributions. Red is uniform over 0..100 (mean magnitude 50). +// Blue keeps the full 0..100 range, including the 100x, but drops a middle +// band (63..77 and 90) so its mean magnitude is exactly 46. Averaged over the +// fair coin that is a net swing of -2 per stake, i.e. exactly 98% RTP. +const ( + coinFlipReserve = 100 // stake multiplier the wallet reserves up front + coinFlipRedMax = 100 // red magnitude ceiling + coinFlipBlueMax = 100 // blue magnitude ceiling +) + +// coinFlipBlueValues is the blue face's value set: every integer 0..100 except +// a removed middle band. Removing 63..77 (15 values) and 90 (1 value) drops 16 +// values summing to 1140, leaving 85 values summing to 3910: a mean of exactly +// 46 while 100x stays reachable. +var coinFlipBlueValues = func() []int { + removed := make(map[int]bool, 16) + for v := 63; v <= 77; v++ { + removed[v] = true + } + removed[90] = true + values := make([]int, 0, 85) + for v := 0; v <= coinFlipBlueMax; v++ { + if !removed[v] { + values = append(values, v) + } + } + return values +}() + +// Coin Flip lifecycle phases. The table itself is only idle or playing; the +// snapshot's Phase carries the finer red-spin / blue-spin / results / flip +// stage the renderer needs. +const ( + coinFlipIdle = "idle" + coinFlipPlaying = "playing" +) + +const ( + coinFlipRed = "red" + coinFlipBlue = "blue" +) + +// Exported phase and face names for the presentation layer. +const ( + CoinFlipPhaseIdle = "idle" + CoinFlipPhaseRedSpin = "redspin" + CoinFlipPhaseBlueSpin = "bluespin" + CoinFlipPhaseResults = "results" + CoinFlipPhaseFlipping = "flipping" + CoinFlipPhaseResult = "result" + CoinFlipSideRed = coinFlipRed + CoinFlipSideBlue = coinFlipBlue +) + +// Wheel geometry, shared by the engine (which picks the landing offset) and +// the renderer (which slices the same strip). A cell is a centred label plus +// one separator; the strip scrolls by two characters a frame, so the pointer +// can sit anywhere inside the landed label, not just its centre. +const ( + CoinFlipCellWidth = 6 + CoinFlipPitch = CoinFlipCellWidth + 1 + CoinFlipViewCells = 5 + CoinFlipViewWidth = CoinFlipViewCells * CoinFlipPitch + CoinFlipPointerCol = CoinFlipViewWidth / 2 +) + +// coinFlipSpinStep is how many characters the strip moves each frame. +const coinFlipSpinStep = 2 + +// coinFlipExpectedSwing is the mean net swing of the two distributions over +// the fair coin. It is exact: (blueMean - redMean) / 2. +func coinFlipExpectedSwing() float64 { + redSum := 0 + for v := 0; v <= coinFlipRedMax; v++ { + redSum += v + } + redMean := float64(redSum) / float64(coinFlipRedMax+1) + blueSum := 0 + for _, v := range coinFlipBlueValues { + blueSum += v + } + blueMean := float64(blueSum) / float64(len(coinFlipBlueValues)) + return 0.5 * (blueMean - redMean) +} + +// coinFlipRTP is the return per reserved credit: (reserve + swing) / reserve. +func coinFlipRTP() float64 { + return (float64(coinFlipReserve) + coinFlipExpectedSwing()) / float64(coinFlipReserve) +} + +func coinFlipHouseEdge(TableConfig, string) HouseEdge { + return edge(1 - coinFlipRTP()) +} + +func validateCoinFlip(c TableConfig) error { + if c.MaxPlayers > 1 { + return fmt.Errorf("coin flip has one seat") + } + // The reserve is coinFlipReserve times the stated stake; reject a stake + // whose reserve cannot be represented. + if c.MaxBet > MaxCredits/coinFlipReserve { + return fmt.Errorf("coin flip max_bet must not exceed %d", MaxCredits/coinFlipReserve) + } + return nil +} + +func coinFlipRules(TableConfig) string { + return fmt.Sprintf("Coin Flip: a fair 50/50 coin with two spinning faces.\n"+ + "The RED face lands a negative multiplier from 0 to -%d; the BLUE face lands a positive multiplier from 0 to +%d.\n"+ + "The multiplier is a NET swing on your stake: red -50 loses half your stake, blue +50 wins half your stake, red -%d loses it all, blue +%d doubles it.\n"+ + "Because the swing can reach %dx your stake, you must hold %dx your stake to bet and the house reserves exactly that. Your payout is (100 + swing) x stake and is never negative.\n"+ + "RTP is exactly %.0f%%: the coin is fair, and the blue face drops a middle band so its mean is +46 against red's -50.\n"+ + "bet N places a stake of N (reserving %dN). You cannot leave mid-flip. Shutdown refunds an unsettled flip; a failed payout keeps its stake pending for recovery.", + coinFlipRedMax, coinFlipBlueMax, coinFlipRedMax, coinFlipBlueMax, + coinFlipRedMax, coinFlipReserve, 100*coinFlipRTP(), coinFlipReserve) +} + +type CoinFlipSnapshot struct { + Phase string + // SpinSide, SpinValues and SpinOffset describe the scrolling multiplier + // strip: SpinValues are the wheel cells, SpinOffset the character offset + // of the visible window, and Set marks the landed frame that prints SET. + SpinSide string + SpinValues []int + SpinOffset int + Set bool + // Red and Blue are the two faces drawn for this flip. + Red int + Blue int + // Side, Multiplier, Result and FlipAlt describe the coin itself. + // Multiplier is the signed net swing on the landed face; Result marks the + // settled coin; FlipAlt swaps the whoosh/arrow marks on alternating frames. + Side string + Multiplier int + Result bool + FlipAlt bool + // Stake is the stated stake, Wager the reserved amount debited from the + // wallet (Stake x 100). + Stake int + Wager int +} + +func (*CoinFlipSnapshot) snapshot() {} + +// coinFlipFrame is one printed coin in the flip animation. alt flips the +// whoosh/arrow marks from one side to the other on alternating frames. +type coinFlipFrame struct { + side string + multiplier int + result bool + alt bool +} + +// coinFlipPrint is one already-planned animation frame: either a structured +// snapshot (a spin, the results panel, or a coin) or a plain message (a blank +// spacer line). +type coinFlipPrint struct { + delay int + msg string + snap *CoinFlipSnapshot + last bool +} + +type coinFlipTable struct { + *tableBase + mu sync.Mutex + rng *rand.Rand + name string + betSize int + wager int + wallet Wallet + stake Wager + phase string + pending bool + red int + blue int + result string + swing int + prints []coinFlipPrint + print int + wait int +} + +func newCoinFlipTable(room int, c TableConfig, rng *rand.Rand) tableGame { + return &coinFlipTable{tableBase: &tableBase{RoomID: room, Config: c}, rng: rng, phase: coinFlipIdle} +} + +func (t *coinFlipTable) base() *tableBase { return t.tableBase } +func (t *coinFlipTable) idle() bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.name == "" +} +func (t *coinFlipTable) hasParticipant(n string) bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.name != "" && t.name == n +} +func (t *coinFlipTable) busy(n string) bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.name == n && t.phase == coinFlipPlaying +} +func (t *coinFlipTable) overview() string { return "Coin Flip / red vs blue swing" } +func (t *coinFlipTable) seats() []string { + t.mu.Lock() + defer t.mu.Unlock() + if t.name == "" { + return nil + } + return []string{t.name} +} +func (t *coinFlipTable) state() string { + t.mu.Lock() + defer t.mu.Unlock() + if t.pending { + return "payout pending" + } + if t.phase == coinFlipPlaying { + return "spinning" + } + return "waiting" +} +func (t *coinFlipTable) wagers(string) []WagerRow { return nil } + +// stakeMultiple is the optional interface Table uses to size "bet max" and +// the affordability preview. Coin Flip reserves coinFlipReserve credits per +// stated stake. +func (t *coinFlipTable) stakeMultiple() int { return coinFlipReserve } + +func (t *coinFlipTable) hintsLocked() []MenuCommand { + if t.phase == coinFlipPlaying { + return nil + } + if t.pending { + return []MenuCommand{ + {Command: "settle", Desc: "Retry the pending payout"}, + {Command: "rules", Desc: "Coin Flip rules"}, + } + } + return []MenuCommand{ + {Command: "bet N", Desc: fmt.Sprintf("Stake N; the house reserves %dN", coinFlipReserve)}, + {Command: "rules", Desc: "Coin Flip rules"}, + {Command: "stop", Desc: "Leave Coin Flip"}, + } +} +func (t *coinFlipTable) hints(n string) []MenuCommand { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + return t.hintsLocked() +} + +func (t *coinFlipTable) statusLocked(n string) []Event { + e := t.private(n, "") + e.Color = "casino_menu" + // The sitting-down menu carries no coin: only an in-flight spin or flip + // has a graphic worth printing. + if t.phase == coinFlipPlaying && t.print >= 0 && t.print < len(t.prints) { + e.Snapshot = t.prints[t.print].snap + } + if n == t.name { + e.Menu = &MenuView{ShowBalance: true, Commands: t.hintsLocked()} + } + return []Event{e} +} +func (t *coinFlipTable) status(n string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + return t.statusLocked(n) +} +func (t *coinFlipTable) join(n string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if t.name != "" { + return []Event{t.private(n, "Coin Flip is occupied.")} + } + t.clearLocked() + t.name = n + e := t.private(n, "") + e.Type, e.Player = EventJoined, n + return append([]Event{e}, t.statusLocked(n)...) +} +func (t *coinFlipTable) clearLocked() { + t.name, t.betSize, t.wager, t.phase, t.pending = "", 0, 0, coinFlipIdle, false + t.wallet, t.stake = nil, Wager{} + t.red, t.blue, t.result, t.swing = 0, 0, "", 0 + t.prints, t.print, t.wait = nil, 0, 0 +} +func (t *coinFlipTable) leave(n string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + if t.phase == coinFlipPlaying { + return []Event{t.private(n, "The coin is in the air. Wait for it to land.")} + } + if t.pending { + return []Event{t.private(n, "A payout is still pending. Use settle before leaving.")} + } + t.clearLocked() + e := t.playerAction(n, n+" leaves Coin Flip.", "You leave Coin Flip.") + e.Type, e.Player = EventLeft, n + return []Event{e} +} +func (t *coinFlipTable) rebet(n string) (string, int) { + t.mu.Lock() + defer t.mu.Unlock() + if n == t.name && t.phase != coinFlipPlaying && !t.pending { + return "", t.betSize + } + return "", 0 +} + +// faceValueLocked draws one face's value: red is negative 0..coinFlipRedMax, +// blue is drawn from coinFlipBlueValues (0..coinFlipBlueMax with a middle band +// removed). +func (t *coinFlipTable) faceValueLocked(side string) int { + if side == coinFlipRed { + return -t.rng.Intn(coinFlipRedMax + 1) + } + return coinFlipBlueValues[t.rng.Intn(len(coinFlipBlueValues))] +} + +// planSpinLocked builds one face's scrolling wheel. It draws the frame count +// (three guaranteed, then a 60% chance each further tick), picks the landing +// cell so the fixed pointer sits inside the result's label, and pads the wheel +// with random neighbours. If the pointer would land on a separator it ticks one +// more frame, as required. +func (t *coinFlipTable) planSpinLocked(side string, result int) []*CoinFlipSnapshot { + frames := 3 + for frames < 12 && t.rng.Intn(100) < 75 { + frames++ + } + finalOffset := coinFlipSpinStep * (frames - 1) + cellIdx := (finalOffset + CoinFlipPointerCol) / CoinFlipPitch + for (finalOffset+CoinFlipPointerCol)%CoinFlipPitch == 0 { + // The pointer is on a separator; one more frame moves it two + // characters into the label. + frames++ + finalOffset += coinFlipSpinStep + cellIdx = (finalOffset + CoinFlipPointerCol) / CoinFlipPitch + } + values := make([]int, cellIdx+CoinFlipViewCells+2) + for i := range values { + values[i] = t.faceValueLocked(side) + } + values[cellIdx] = result + phase := CoinFlipPhaseRedSpin + if side == coinFlipBlue { + phase = CoinFlipPhaseBlueSpin + } + snaps := make([]*CoinFlipSnapshot, 0, frames) + for f := 0; f < frames; f++ { + snaps = append(snaps, &CoinFlipSnapshot{ + Phase: phase, + SpinSide: side, + SpinValues: values, + SpinOffset: coinFlipSpinStep * f, + Set: f == frames-1, + Red: t.red, + Blue: t.blue, + Stake: t.betSize, + Wager: t.wager, + }) + } + return snaps +} + +// planCoinFramesLocked builds the coin flip animation. It opens with three +// printed coins (red, blue, red) and then keeps flipping with a 50% chance +// each step. The coin never settles on the colour it is already showing: if +// the last flip already matches the landed face it settles in place, otherwise +// one final flip to the landed colour is appended. +func (t *coinFlipTable) planCoinFramesLocked() []coinFlipFrame { + frames := []coinFlipFrame{ + {side: coinFlipRed, multiplier: t.red}, + {side: coinFlipBlue, multiplier: t.blue, alt: true}, + {side: coinFlipRed, multiplier: t.red}, + } + for len(frames) < 16 && t.rng.Intn(2) != 0 { + last := frames[len(frames)-1].side + next := coinFlipBlue + if last == coinFlipBlue { + next = coinFlipRed + } + multiplier := t.red + if next == coinFlipBlue { + multiplier = t.blue + } + frames = append(frames, coinFlipFrame{side: next, multiplier: multiplier, alt: len(frames)%2 == 0}) + } + if frames[len(frames)-1].side == t.result { + // Already showing the landed colour: settle this very frame, so the + // result never repeats the previous colour. + frames[len(frames)-1].result = true + frames[len(frames)-1].multiplier = t.swing + return frames + } + frames = append(frames, coinFlipFrame{side: t.result, multiplier: t.swing, result: true, alt: len(frames)%2 == 0}) + return frames +} + +func (t *coinFlipTable) coinSnapLocked(f coinFlipFrame) *CoinFlipSnapshot { + phase := CoinFlipPhaseFlipping + if f.result { + phase = CoinFlipPhaseResult + } + return &CoinFlipSnapshot{ + Phase: phase, + Red: t.red, + Blue: t.blue, + Side: f.side, + Multiplier: f.multiplier, + Result: f.result, + FlipAlt: f.alt, + Stake: t.betSize, + Wager: t.wager, + } +} + +// planPrintsLocked lays out the whole round: the red wheel, a spacer, the blue +// wheel, the landed multipliers, then the coin flip. The final coin is marked +// last so the tick that prints it also settles. +func (t *coinFlipTable) planPrintsLocked() { + redSpin := t.planSpinLocked(coinFlipRed, t.red) + blueSpin := t.planSpinLocked(coinFlipBlue, t.blue) + prints := make([]coinFlipPrint, 0, len(redSpin)+len(blueSpin)+8) + for _, snap := range redSpin { + prints = append(prints, coinFlipPrint{delay: 1, snap: snap}) + } + prints = append(prints, coinFlipPrint{delay: 1, msg: " "}) + for _, snap := range blueSpin { + prints = append(prints, coinFlipPrint{delay: 1, snap: snap}) + } + prints = append(prints, coinFlipPrint{ + delay: 2, + snap: &CoinFlipSnapshot{Phase: CoinFlipPhaseResults, Red: t.red, Blue: t.blue, Stake: t.betSize, Wager: t.wager}, + }) + frames := t.planCoinFramesLocked() + for i, f := range frames { + prints = append(prints, coinFlipPrint{delay: 2, snap: t.coinSnapLocked(f), last: i == len(frames)-1}) + } + t.prints = prints +} + +func (t *coinFlipTable) printEventLocked(p coinFlipPrint) Event { + if p.snap != nil { + e := t.private(t.name, "") + e.Color = "casino_action" + e.Snapshot = p.snap + return e + } + return t.private(t.name, p.msg) +} + +func (t *coinFlipTable) bet(n, spot string, amount int, w Wallet) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + if t.phase == coinFlipPlaying { + return []Event{t.private(n, "The coin is already in the air.")} + } + if t.pending { + return []Event{t.private(n, "A payout is still pending. Use settle first.")} + } + if spot != "" && spot != "main" { + return []Event{t.private(n, "Use bet N; the coin is the only wager.")} + } + if amount < t.Config.MinBet || amount > t.Config.MaxBet { + return []Event{t.private(n, "Bets must be "+betRangeText(t.Config.MinBet, t.Config.MaxBet)+".")} + } + if amount > MaxCredits/coinFlipReserve { + return []Event{t.private(n, "That stake is too large.")} + } + if w == nil { + return []Event{t.private(n, "No wallet is available.")} + } + reserved := amount * coinFlipReserve + stake, err := w.Wager(reserved) + if err != nil { + // A wager this large is a normal rejected bet: the player did not hold + // the 100x reserve the swing can consume. + if err == ErrInsufficientFunds { + return []Event{t.private(n, fmt.Sprintf("Coin Flip needs %dx your stake (%d) on hand. You don't have enough chips and credits.", coinFlipReserve, reserved))} + } + return []Event{t.private(n, walletErrorMessage(err, "Not enough chips and credits."))} + } + t.wallet, t.stake, t.betSize, t.wager = w, stake, amount, reserved + t.red = t.faceValueLocked(coinFlipRed) + t.blue = t.faceValueLocked(coinFlipBlue) + t.result, t.swing = coinFlipRed, t.red + if t.rng.Intn(2) == 0 { + t.result, t.swing = coinFlipBlue, t.blue + } + t.planPrintsLocked() + t.phase = coinFlipPlaying + // The first frame is deliberately held back one tick so the stake line and + // the opening spin do not land in the same output burst. + t.print = -1 + t.wait = 1 + open := t.private(n, fmt.Sprintf("Stake %d. The house reserves %d.", amount, reserved)) + open.Color = "casino_menu" + return []Event{open} +} + +func (t *coinFlipTable) action(n, action string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + if t.phase == coinFlipPlaying { + return []Event{t.private(n, "The coin is in the air. Wait for it to land.")} + } + f := strings.Fields(strings.ToLower(action)) + if len(f) == 0 { + return t.statusLocked(n) + } + if f[0] == "settle" { + // A failed payout leaves the round un-settled; the outcome is already + // known, so settle re-pays the same landed face. Only a persistence + // failure can reach this state. + if t.pending { + return t.settleLocked() + } + } + return []Event{t.private(n, "Use bet N to flip the coin.")} +} + +func (t *coinFlipTable) tick() []Event { + t.mu.Lock() + defer t.mu.Unlock() + if t.phase != coinFlipPlaying { + return nil + } + t.wait-- + if t.wait > 0 { + return nil + } + t.print++ + if t.print >= len(t.prints) { + return t.settleLocked() + } + p := t.prints[t.print] + e := t.printEventLocked(p) + if p.last { + // The settled coin was the previous frame; settle without reprinting it. + return append([]Event{e}, t.settleLocked()...) + } + t.wait = p.delay + return []Event{e} +} + +// settleLocked pays the landed face. The outcome is fixed before the flip, so +// a failed bank save leaves the same face and stake for an idempotent retry. +// It never reprints the coin: the last flip frame already carried the settled +// fill. +func (t *coinFlipTable) settleLocked() []Event { + side, swing := t.result, t.swing + payout := (coinFlipReserve + swing) * t.betSize + if err := t.wallet.PayCredits(payout, t.stake.ID); err != nil { + t.phase = coinFlipIdle + t.pending = true + return []Event{t.private(t.name, payoutFailedMessage()+" The same face is locked; settle retries it.")} + } + t.phase = coinFlipIdle + t.pending = false + t.stake = Wager{} + label := fmt.Sprintf("Blue +%d", swing) + if side == coinFlipRed { + label = fmt.Sprintf("Red %d", swing) + } + receipt := t.private(t.name, "") + receipt.Settlement = []SettlementLine{{ + Label: label, + Odds: "Varies", + Stake: t.wager, + Returned: payout, + Result: returnResult(t.wager, payout), + }} + receipt.Color = "casino_payout" + if swing >= 50 { + receipt.Color = "casino_big_win" + } + // A short public line names the landed face; the receipt stays private. A + // leading blank line separates it from the final coin frame. + announce := t.event(fmt.Sprintf("\n%s flips %s.", t.name, side), true) + return []Event{announce, receipt, t.betMenuEvent(t.name, t.hintsLocked())} +} + +func (t *coinFlipTable) markDisconnected(n string) []Event { + t.mu.Lock() + defer t.mu.Unlock() + if n != t.name { + return nil + } + var events []Event + if (t.phase == coinFlipPlaying || t.pending) && t.wallet != nil { + // The coin was already flipped and the player has seen the faces; + // match the exposed-game convention and settle the landed face. + events = t.settleLocked() + } + t.clearLocked() + e := t.event(n+" leaves Coin Flip.", true) + e.Type, e.Player = EventLeft, n + return append(events, e) +} +func (t *coinFlipTable) refundAll() []Event { + t.mu.Lock() + defer t.mu.Unlock() + var events []Event + switch { + case t.pending && t.wallet != nil: + // A known face is waiting on a failed payout; retry it so the shutdown + // pays the same result rather than refunding a decided round. + events = t.settleLocked() + case t.phase == coinFlipPlaying && t.wallet != nil: + chips, credits, err := t.wallet.RefundOutstanding() + message := fmt.Sprintf("Coin Flip closes; %d credits/chips refunded.", chips+credits) + if err != nil { + message = "Coin Flip refund could not be persisted; your stake remains pending for recovery." + } + events = []Event{t.private(t.name, message)} + } + t.clearLocked() + return events +} diff --git a/internal/casino/coin_flip_test.go b/internal/casino/coin_flip_test.go new file mode 100644 index 0000000..41430a5 --- /dev/null +++ b/internal/casino/coin_flip_test.go @@ -0,0 +1,296 @@ +package casino + +import ( + "math" + "math/rand" + "strings" + "testing" +) + +func newTestCoinFlip() *coinFlipTable { + return newTable(1, TableConfig{ID: "coin_flip", Game: GameCoinFlip}, rand.New(rand.NewSource(42))).game.(*coinFlipTable) +} + +func TestCoinFlipRTP(t *testing.T) { + if rtp := coinFlipRTP(); math.Abs(rtp-0.98) > 1e-12 { + t.Fatalf("coin flip RTP %.12f, want exactly 0.98", rtp) + } + if swing := coinFlipExpectedSwing(); math.Abs(swing-(-2)) > 1e-12 { + t.Fatalf("expected swing %.12f, want -2", swing) + } + e := coinFlipHouseEdge(TableConfig{}, "") + if len(e.Parts) != 1 || math.Abs(e.Parts[0].Loss-0.02) > 1e-12 { + t.Fatalf("house edge does not match 98%% RTP: %+v", e) + } +} + +func TestCoinFlipBlueValues(t *testing.T) { + // The blue face must reach 100x, and the removed middle band must never + // appear, or the mean (and therefore the RTP) drifts. + seen := map[int]bool{} + sum := 0 + for _, v := range coinFlipBlueValues { + if v < 0 || v > coinFlipBlueMax { + t.Fatalf("blue value %d out of range", v) + } + if (v >= 63 && v <= 77) || v == 90 { + t.Fatalf("removed blue value %d is still reachable", v) + } + seen[v] = true + sum += v + } + if !seen[100] { + t.Fatal("100x is not reachable on the blue face") + } + if len(coinFlipBlueValues) != 85 || sum != 3910 { + t.Fatalf("blue set size %d sum %d, want 85 and 3910", len(coinFlipBlueValues), sum) + } +} + +func TestCoinFlipConfig(t *testing.T) { + if (TableConfig{ID: "c", Game: GameCoinFlip, MaxPlayers: 2}).Validate() == nil { + t.Fatal("accepted a two-seat coin flip table") + } + if err := (TableConfig{ID: "c", Game: GameCoinFlip}).Validate(); err != nil { + t.Fatal(err) + } + if got := (TableConfig{ID: "c", Game: GameCoinFlip}).Normalize(); got.MaxPlayers != 1 { + t.Fatalf("normalized max players = %d, want 1", got.MaxPlayers) + } + if (TableConfig{ID: "c", Game: GameCoinFlip, MaxBet: MaxCredits}).Validate() == nil { + t.Fatal("accepted a stake whose 100x reserve overflows the economy") + } + def, ok := tableGameDefFor(GameCoinFlip) + if !ok || def.ruleText == nil || def.houseEdge == nil || def.hasRuleBlock == nil || def.hasRuleBlock(TableConfig{}) { + t.Fatalf("coin flip registration incomplete: %+v", def.ruleBlockName) + } + if topic := (TableConfig{ID: "c", Game: GameCoinFlip}).HelpTopic(); topic != "coin flip" { + t.Fatalf("help topic %q, want coin flip", topic) + } +} + +func TestCoinFlipReserveAndLifecycle(t *testing.T) { + c := newTestCoinFlip() + // Sitting down must not print a coin graphic. + for _, e := range c.join("alice") { + if e.Snapshot != nil { + t.Fatalf("sitting down printed a coin: %+v", e.Snapshot) + } + } + if events := c.join("bob"); len(events) != 1 || !strings.Contains(events[0].Message, "occupied") { + t.Fatalf("unexpected second-seat reply: %+v", events) + } + w := &testWallet{credits: 1000} + events := c.bet("alice", "", 10, w) + if c.phase != coinFlipPlaying || len(events) != 1 { + t.Fatalf("bet did not start the round: phase %q events %d", c.phase, len(events)) + } + if w.credits != 0 { + t.Fatalf("reserve was not debited: credits %d", w.credits) + } + if len(w.open) != 1 || w.open[0].Total != 1000 { + t.Fatalf("reserved stake = %+v, want 1000", w.open) + } + swing := c.swing + want := (coinFlipReserve + swing) * 10 + // The first frame is held back a tick, then prints the red spin strip. + first := c.tick() + if len(first) == 0 || first[0].Snapshot == nil || first[0].Snapshot.(*CoinFlipSnapshot).Phase != CoinFlipPhaseRedSpin { + t.Fatalf("first frame is not the red spin: %+v", first) + } + for i := 0; i < 128 && c.phase == coinFlipPlaying; i++ { + c.tick() + } + if c.phase != coinFlipIdle { + t.Fatalf("round did not settle: phase %q", c.phase) + } + if w.paid != want { + t.Fatalf("paid %d, want %d for swing %d", w.paid, want, swing) + } + if len(w.open) != 0 { + t.Fatalf("stake not resolved: %+v", w.open) + } +} + +func TestCoinFlipPrintsEndOnSettledCoin(t *testing.T) { + c := newTestCoinFlip() + c.join("alice") + w := &testWallet{credits: 1000} + c.bet("alice", "", 10, w) + // The plan must contain a red spin, a blue spin, a results line, and a + // final coin frame marked last with the settled background. + var sawRed, sawBlue, sawResults, sawSettled, sawSpacer bool + for _, p := range c.prints { + switch { + case p.snap != nil && p.snap.Phase == CoinFlipPhaseRedSpin: + sawRed = true + case p.snap != nil && p.snap.Phase == CoinFlipPhaseBlueSpin: + sawBlue = true + case p.snap != nil && p.snap.Phase == CoinFlipPhaseResults: + sawResults = true + case p.snap != nil && p.snap.Result: + sawSettled = true + if !p.last { + t.Fatal("the settled coin is not the last frame") + } + case p.msg != "": + sawSpacer = true + } + } + if !sawRed || !sawBlue || !sawResults || !sawSettled || !sawSpacer { + t.Fatalf("print plan incomplete: red=%v blue=%v results=%v settled=%v spacer=%v", sawRed, sawBlue, sawResults, sawSettled, sawSpacer) + } +} + +func TestCoinFlipNeverRepeatsColour(t *testing.T) { + // The settled coin must never repeat the colour of the frame before it. + c := newTestCoinFlip() + for i := 0; i < 5000; i++ { + c.red = c.faceValueLocked(CoinFlipSideRed) + c.blue = c.faceValueLocked(CoinFlipSideBlue) + c.result, c.swing = CoinFlipSideRed, c.red + if c.rng.Intn(2) == 0 { + c.result, c.swing = CoinFlipSideBlue, c.blue + } + frames := c.planCoinFramesLocked() + if len(frames) < 3 { + t.Fatalf("coin planned only %d frames", len(frames)) + } + if !frames[len(frames)-1].result { + t.Fatal("last frame is not the settled coin") + } + if frames[len(frames)-1].multiplier != c.swing { + t.Fatalf("settled multiplier %d, want %d", frames[len(frames)-1].multiplier, c.swing) + } + for j := 1; j < len(frames); j++ { + if frames[j].side == frames[j-1].side { + t.Fatalf("frame %d repeats colour %s: %+v", j, frames[j].side, frames) + } + } + } +} + +func TestCoinFlipInsufficientReserve(t *testing.T) { + c := newTestCoinFlip() + c.join("alice") + w := &testWallet{credits: 999} + events := c.bet("alice", "", 10, w) + if c.phase != coinFlipIdle || len(w.open) != 0 || w.credits != 999 { + t.Fatalf("under-reserved bet changed state: phase %q open %d credits %d", c.phase, len(w.open), w.credits) + } + if len(events) == 0 || !strings.Contains(events[0].Message, "100x") { + t.Fatalf("missing reserve explanation: %+v", events) + } +} + +func TestCoinFlipFacesAndSwing(t *testing.T) { + c := newTestCoinFlip() + seenHundred := false + for i := 0; i < 200000; i++ { + red := c.faceValueLocked(CoinFlipSideRed) + blue := c.faceValueLocked(CoinFlipSideBlue) + if red > 0 || red < -coinFlipRedMax { + t.Fatalf("red face %d out of range", red) + } + if blue < 0 || blue > coinFlipBlueMax { + t.Fatalf("blue face %d out of range", blue) + } + if blue == 100 { + seenHundred = true + } + } + if !seenHundred { + t.Fatal("blue 100x never appeared") + } +} + +func TestCoinFlipFairCoin(t *testing.T) { + // The landing side is an independent fair coin: over many flips the two + // faces must be within a few percent of even. + c := newTestCoinFlip() + reds := 0 + const n = 200000 + for i := 0; i < n; i++ { + if c.rng.Intn(2) == 0 { + reds++ + } + } + share := float64(reds) / float64(n) + if math.Abs(share-0.5) > 0.01 { + t.Fatalf("red landed %.4f of the time, want ~0.5", share) + } +} + +func TestCoinFlipSimulatedRTP(t *testing.T) { + // End-to-end: the two face draws, the fair landing coin, and the payout + // formula must reproduce the 98% design return. + c := newTestCoinFlip() + const rounds = 200000 + staked, returned := 0, 0 + for i := 0; i < rounds; i++ { + red := c.faceValueLocked(CoinFlipSideRed) + blue := c.faceValueLocked(CoinFlipSideBlue) + swing := red + if c.rng.Intn(2) == 0 { + swing = blue + } + staked += coinFlipReserve + returned += coinFlipReserve + swing + } + rtp := float64(returned) / float64(staked) + if math.Abs(rtp-0.98) > 0.005 { + t.Fatalf("simulated RTP %.4f is not within 0.5%% of 0.98", rtp) + } +} + +func TestCoinFlipRefundAndDisconnect(t *testing.T) { + c := newTestCoinFlip() + c.join("alice") + w := &testWallet{credits: 1000} + c.bet("alice", "", 10, w) + events := c.refundAll() + if len(w.open) != 0 || w.paid != 1000 { + t.Fatalf("refund did not restore the reserve: paid %d open %+v", w.paid, w.open) + } + if len(events) == 0 { + t.Fatal("refund produced no event") + } + if !c.idle() { + t.Fatal("refund did not clear the table") + } + + // A disconnect mid-round settles the already-drawn face, never refunds it. + c.join("bob") + w = &testWallet{credits: 1000} + c.bet("bob", "", 10, w) + swing := c.swing + want := (coinFlipReserve + swing) * 10 + c.markDisconnected("bob") + if w.paid != want || len(w.open) != 0 { + t.Fatalf("disconnect paid %d want %d open %+v", w.paid, want, w.open) + } +} + +func TestCoinFlipStakeMultiple(t *testing.T) { + c := newTestCoinFlip() + if c.stakeMultiple() != coinFlipReserve { + t.Fatalf("stake multiple %d, want %d", c.stakeMultiple(), coinFlipReserve) + } +} + +func TestCoinFlipMidRoundCommands(t *testing.T) { + c := newTestCoinFlip() + c.join("alice") + w := &testWallet{credits: 100000} + c.bet("alice", "", 10, w) + // A second bet while the coin is airborne is refused. + if events := c.bet("alice", "", 10, w); len(events) == 0 || !strings.Contains(events[0].Message, "air") { + t.Fatalf("accepted a mid-round bet: %+v", events) + } + for i := 0; i < 128 && c.phase == coinFlipPlaying; i++ { + c.tick() + } + if events := c.action("alice", "settle"); len(events) == 0 { + t.Fatal("settle after a clean round produced no reply") + } +} diff --git a/internal/casino/core.go b/internal/casino/core.go index 6bcd4f9..3ba9765 100644 --- a/internal/casino/core.go +++ b/internal/casino/core.go @@ -193,6 +193,9 @@ func gameTitle(game GameName) string { if game == GameCashHunt { return "Cash Hunt" } + if game == GameCoinFlip { + return "Coin Flip" + } if game == GameBigSix { return "Big 6" } diff --git a/internal/casino/registry.go b/internal/casino/registry.go index 988da07..e87b45a 100644 --- a/internal/casino/registry.go +++ b/internal/casino/registry.go @@ -53,6 +53,15 @@ func init() { helpTopic: func(TableConfig) string { return "cash hunt" }, houseEdge: func(TableConfig, string) HouseEdge { return edge(0.02) }, }, + GameCoinFlip: { + validate: validateCoinFlip, + normalize: func(c TableConfig) TableConfig { c.MaxPlayers = 1; return c }, + hasRuleBlock: func(TableConfig) bool { return false }, + new: newCoinFlipTable, + ruleText: coinFlipRules, + helpTopic: func(TableConfig) string { return "coin flip" }, + houseEdge: coinFlipHouseEdge, + }, GamePlinko: { validate: validatePlinko, normalize: func(c TableConfig) TableConfig { c.Plinko = c.Plinko.Normalize(); c.MaxPlayers = 1; return c }, diff --git a/internal/casino/table.go b/internal/casino/table.go index d9d3525..4ab4e3a 100644 --- a/internal/casino/table.go +++ b/internal/casino/table.go @@ -153,6 +153,20 @@ func (t *Table) Busy(name string) bool { return false } +// stakeMultiple is the optional interface for a game whose wallet debit is a +// multiple of the player's stated stake. Coin Flip reserves 100x so a -100 +// swing is fully covered; every other game debits the stake itself. +type stakeMultiple interface{ stakeMultiple() int } + +// StakeMultiple reports how many credits the game debits per unit of the +// player's stated stake. It sizes "bet max" and affordability previews. +func (t *Table) StakeMultiple() int { + if m, ok := t.game.(stakeMultiple); ok { + return m.stakeMultiple() + } + return 1 +} + // Seats lists the seated players in seat order for the look command. func (t *Table) Seats() []string { return t.game.seats() } diff --git a/internal/casino/types.go b/internal/casino/types.go index 2b3a0a0..411b58d 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -20,6 +20,7 @@ const ( GamePlinko GameName = "plinko" GamePachinko GameName = "pachinko" GameCashHunt GameName = "cash_hunt" + GameCoinFlip GameName = "coin_flip" GameBlackjack GameName = "blackjack" GameBaccarat GameName = "baccarat" GameRoulette GameName = "roulette" diff --git a/internal/game/casino.go b/internal/game/casino.go index fa78c2a..2d8024c 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -277,7 +277,7 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) { continue } if strings.EqualFold(arg, "max") { - amount = min(table.Config.MaxBet, casinoAffordable(p)) + amount = min(table.Config.MaxBet, casinoAffordable(p)/table.StakeMultiple()) if amount < 1 { sess.WriteLine("You don't have any chips or credits to bet.") return @@ -519,11 +519,12 @@ func (g *Game) casinoBoardReminder(sess *net.Session, category string, menu *cas _, isPlinko := snapshot.(*casino.PlinkoSnapshot) _, isPachinko := snapshot.(*casino.PachinkoSnapshot) _, isCashHunt := snapshot.(*casino.CashHuntSnapshot) + _, isCoinFlip := snapshot.(*casino.CoinFlipSnapshot) _, isPaiGow := snapshot.(*casino.PaiGowSnapshot) b, isBigSix := snapshot.(*casino.BigSixSnapshot) isBigSix = isBigSix && b.Phase != "playing" && b.Phase != "result" s, isRoulette := snapshot.(*casino.RouletteSnapshot) - if !isCashHunt && !isPlinko && !isPachinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && (!isRoulette || s.Phase == "playing" || s.Phase == "result") { + if !isCashHunt && !isCoinFlip && !isPlinko && !isPachinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && (!isRoulette || s.Phase == "playing" || s.Phase == "result") { return "", false } if isRoulette && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) { @@ -618,6 +619,8 @@ func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot) rtp = casinoRTP(s.Edge, precision) } return renderCashHunt(s, mode, unicode, rtp) + case *casino.CoinFlipSnapshot: + return renderCoinFlip(s, mode, unicode) case *casino.PlinkoSnapshot: return renderPlinko(s, mode, unicode) case *casino.PachinkoSnapshot: diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go index 7eb10dd..bdc3add 100644 --- a/internal/game/casino_ux.go +++ b/internal/game/casino_ux.go @@ -68,6 +68,8 @@ func casinoTableAliases(cfg casino.TableConfig) []string { switch cfg.Game { case casino.GameCashHunt: names = append(names, "cash hunt", "cashhunt", "cash-hunt") + case casino.GameCoinFlip: + names = append(names, "coin flip", "coinflip", "coin-flip", "coin") case casino.GamePachinko: names = append(names, "pachinko") case casino.GameBigSix: @@ -174,6 +176,12 @@ func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) { } return } + // Coin Flip has no felt or board: the spin and the coin are the whole + // game, so the table command answers with a one-liner instead of nothing. + if t.Config.Game == casino.GameCoinFlip { + sess.WriteLine("Coin Flip has no table. Use bet N to spin and flip the coin.") + return + } // The table command shows only the table: the live board for roulette, // craps and video poker, or the printed felt for blackjack, baccarat and // Pai Gow. No balance, seat state, command menu, or reminder rides along. diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go index ef17925..c23f89b 100644 --- a/internal/game/cmd_registry.go +++ b/internal/game/cmd_registry.go @@ -254,6 +254,8 @@ func (g *Game) casinoOwnedInput(sess *net.Session, cmd string) bool { return isAllDigits(cmd) || cmd == "settle" case casino.GamePachinko: return isAllDigits(cmd) + case casino.GameCoinFlip: + return cmd == "settle" case casino.GamePaiGow: return isAllDigits(cmd) || cmd == "set" || cmd == "house" || cmd == "autoset" || cmd == "settle" } @@ -292,6 +294,7 @@ var casinoCommonVerbs = []string{"watch", "game", "table", "list", "stop", "rule // because "split" is not a Big 6 verb and "spin" is not a blackjack verb. var casinoVerbsByGame = map[casino.GameName][]string{ casino.GameCashHunt: {"bet", "rebet", "difficulty", "paytable", "pick", "settle"}, + casino.GameCoinFlip: {"bet", "rebet", "settle"}, casino.GamePlinko: {"bet", "rebet", "difficulty", "balls", "paytable"}, casino.GamePachinko: {"bet", "rebet", "table", "drop"}, casino.GameBlackjack: {"bet", "rebet", "unbet", "hit", "stand", "double", "split", "insurance", "surrender", "decline"}, @@ -420,6 +423,16 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI } } + // Coin Flip owns the retry verb for a payout that could not be persisted. + if p != nil && g.Casino != nil { + if t, playing := g.Casino.Participation(p.Name); playing && t != nil && t.Config.Game == casino.GameCoinFlip { + if cmd == "settle" { + g.executeTableAction(sess, strings.Join(append([]string{cmd}, args...), " ")) + return + } + } + } + // Pachinko owns bare slot numbers and "drop N" while seated; a "drop" // with any other argument still drops an item from the world. if p != nil && g.Casino != nil { diff --git a/internal/game/coin_flip_test.go b/internal/game/coin_flip_test.go new file mode 100644 index 0000000..c8a5372 --- /dev/null +++ b/internal/game/coin_flip_test.go @@ -0,0 +1,162 @@ +package game + +import ( + "strings" + "testing" + + "thehouseoficarus/internal/casino" +) + +func coinFlipRoom() string { + return "name: Two Moons Flip\ncasino_tables:\n - id: coin_flip\n game: coin_flip\n min_bet: 1\n max_bet: 100\n" +} + +func TestCoinFlipPlayAndFlip(t *testing.T) { + g, s, conn := casinoCrapsGame(t, coinFlipRoom()) + s.Player.Options = map[string]any{"color": "none", "casino_hints": "on"} + g.executePlay(s, []string{"coin", "flip"}, "play coin flip") + table, ok := g.Casino.Participation(s.Player.Name) + if !ok || table == nil || table.Config.Game != casino.GameCoinFlip { + t.Fatal("Coin Flip alias did not seat the player") + } + if table.StakeMultiple() != 100 { + t.Fatalf("stake multiple %d, want 100", table.StakeMultiple()) + } + // Sitting down must not print a coin or a multiplier. + if text := string(conn.data); strings.Contains(text, "▐") || strings.Contains(text, "BLUE") { + t.Fatalf("join printed a coin: %s", text) + } + // The player only holds 100 credits, so a stake of 1 reserves 100. + conn.data = nil + g.executeBet(s, []string{"1"}, "bet 1") + if len(s.Player.PendingWagers) != 1 || s.Player.PendingWagers[0].Credits != 100 { + t.Fatalf("bet did not reserve 100x: %+v", s.Player.PendingWagers) + } + if !strings.Contains(string(conn.data), "Stake 1") { + t.Fatalf("bet did not print the stake line: %s", conn.data) + } + // The first spin frame is held back one tick. + conn.data = nil + g.CasinoTick() + if !strings.Contains(string(conn.data), "▼") { + t.Fatalf("first tick did not start the red spin: %s", conn.data) + } + // Advance until the round lands (the spin + flip plan is at most ~40 frames). + for i := 0; i < 200 && len(s.Player.PendingWagers) > 0; i++ { + conn.data = nil + g.CasinoTick() + } + if len(s.Player.PendingWagers) != 0 { + t.Fatalf("round never settled: %+v", s.Player.PendingWagers) + } + if !strings.Contains(stripANSI(string(conn.data)), "Returned:") { + t.Fatalf("settlement receipt missing: %s", conn.data) + } +} + +func TestCoinFlipStakeLimit(t *testing.T) { + g, s, _ := casinoCrapsGame(t, coinFlipRoom()) + s.Player.Options = map[string]any{"color": "none"} + g.executePlay(s, []string{"coin flip"}, "play coin flip") + // 100 credits cannot cover a stake of 2 (200 reserved). + g.executeBet(s, []string{"2"}, "bet 2") + if len(s.Player.PendingWagers) != 0 { + t.Fatalf("accepted a stake the reserve cannot cover: %+v", s.Player.PendingWagers) + } +} + +func TestCoinFlipBetMaxUsesReserve(t *testing.T) { + g, s, _ := casinoCrapsGame(t, coinFlipRoom()) + s.Player.Options = map[string]any{"color": "none"} + g.executePlay(s, []string{"coin flip"}, "play coin flip") + // 100 credits with a 100x reserve means the largest stake is 1. + g.executeBet(s, []string{"max"}, "bet max") + if len(s.Player.PendingWagers) != 1 || s.Player.PendingWagers[0].Credits != 100 { + t.Fatalf("bet max did not reserve 100x the affordable stake: %+v", s.Player.PendingWagers) + } +} + +func TestCoinFlipSettleReachesGame(t *testing.T) { + g, s, conn := casinoCrapsGame(t, coinFlipRoom()) + s.Player.Options = map[string]any{"color": "none"} + g.executePlay(s, []string{"coin flip"}, "play coin flip") + conn.data = nil + g.handleGameCommand(s, "settle") + if strings.Contains(string(conn.data), "Unknown command") { + t.Fatalf("settle was rejected before reaching Coin Flip: %s", conn.data) + } +} + +func TestCoinFlipWheelInk(t *testing.T) { + if ink, bold := coinFlipWheelInk(30, casino.CoinFlipSideRed); ink != -1 || bold { + t.Fatalf("30x = %d,%v; want plain", ink, bold) + } + if ink, bold := coinFlipWheelInk(60, casino.CoinFlipSideRed); ink != coinFlipRedInk || bold { + t.Fatalf("60x = %d,%v; want red not bold", ink, bold) + } + if ink, bold := coinFlipWheelInk(80, casino.CoinFlipSideBlue); ink != coinFlipBlueInk || !bold { + t.Fatalf("80x = %d,%v; want blue bold", ink, bold) + } + if ink, bold := coinFlipWheelInk(-90, casino.CoinFlipSideRed); ink != coinFlipRedInk || !bold { + t.Fatalf("-90x = %d,%v; want red bold", ink, bold) + } +} + +func TestCoinFlipRendering(t *testing.T) { + for _, mode := range []string{"none", "ansi", "xterm256"} { + for _, unicode := range []bool{false, true} { + spin := &casino.CoinFlipSnapshot{ + Phase: casino.CoinFlipPhaseRedSpin, + SpinSide: casino.CoinFlipSideRed, + SpinValues: []int{-10, -20, -30, -40, -50, -60, -70}, + } + rendered := renderCoinFlip(spin, mode, unicode) + if mode == "none" && strings.Contains(rendered, "\x1b") { + t.Fatalf("plain spin has ANSI escapes: %q", rendered) + } + if !strings.Contains(rendered, "▼") && unicode { + t.Fatalf("spin strip missing its pointer: %q", rendered) + } + + set := &casino.CoinFlipSnapshot{ + Phase: casino.CoinFlipPhaseRedSpin, + SpinSide: casino.CoinFlipSideRed, + SpinValues: []int{-10, -20, -30, -40, -50, -60, -70}, + Set: true, + } + if !strings.Contains(renderCoinFlip(set, mode, unicode), "SET") { + t.Fatalf("landed spin missing SET: %q", renderCoinFlip(set, mode, unicode)) + } + + results := &casino.CoinFlipSnapshot{Phase: casino.CoinFlipPhaseResults, Red: -61, Blue: 85} + rendered = renderCoinFlip(results, mode, unicode) + if plain := stripANSI(rendered); !strings.Contains(plain, "-61x") || !strings.Contains(plain, "+85x") { + t.Fatalf("results panel missing a face: %q", plain) + } + + coin := &casino.CoinFlipSnapshot{Phase: casino.CoinFlipPhaseFlipping, Side: casino.CoinFlipSideBlue, Multiplier: 42} + rendered = renderCoinFlip(coin, mode, unicode) + if plain := stripANSI(rendered); !strings.Contains(plain, "+42x") { + t.Fatalf("coin missing its multiplier: %q", plain) + } + if strings.Contains(rendered, "COIN FLIP") || strings.Contains(rendered, "stake") { + t.Fatalf("flip printed chrome instead of just the coin: %q", rendered) + } + + settled := &casino.CoinFlipSnapshot{Phase: casino.CoinFlipPhaseResult, Side: casino.CoinFlipSideRed, Multiplier: -50, Result: true} + rendered = renderCoinFlip(settled, mode, unicode) + if plain := stripANSI(rendered); !strings.Contains(plain, "-50x") { + t.Fatalf("settled coin missing its multiplier: %q", plain) + } + if mode == "xterm256" && !strings.Contains(rendered, "48;5;196") { + t.Fatalf("settled red coin has no red fill: %q", rendered) + } + if mode == "ansi" && !strings.Contains(rendered, "\x1b[41m") { + t.Fatalf("settled red coin has no red fill: %q", rendered) + } + } + } + if got := renderCoinFlip(&casino.CoinFlipSnapshot{Phase: casino.CoinFlipPhaseIdle}, "none", true); got != "" { + t.Fatalf("idle coin rendered %q", got) + } +} diff --git a/internal/game/look_casino.go b/internal/game/look_casino.go index e349386..500bbfb 100644 --- a/internal/game/look_casino.go +++ b/internal/game/look_casino.go @@ -105,6 +105,8 @@ func casinoTableSummary(cfg casino.TableConfig) string { switch cfg.Game { case casino.GameCashHunt: return "100 squares; three difficulties; 98% RTP; prizes up to 50000x" + case casino.GameCoinFlip: + return "fair 50/50; red 0 to -100x, blue 0 to +100x; 98% RTP; reserves 100x the stake" case casino.GamePlinko: return fmt.Sprintf("16 rows; four risks; 1-10 balls; near %.4g%% target RTP", cfg.Plinko.Normalize().RTP) case casino.GamePachinko: diff --git a/internal/game/look_target.go b/internal/game/look_target.go index 97d7ecb..b467ae9 100644 --- a/internal/game/look_target.go +++ b/internal/game/look_target.go @@ -50,7 +50,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { // Felt tables resolve through one data-driven loop; the video-poker cabinet // has its own cabinet-id/pay-table handling. - for _, game := range []casino.GameName{casino.GameCashHunt, casino.GamePlinko, casino.GamePachinko, casino.GameBigSix, casino.GameBlackjack, casino.GameBaccarat, casino.GameRoulette, casino.GameCraps, casino.GamePaiGow} { + for _, game := range []casino.GameName{casino.GameCashHunt, casino.GameCoinFlip, casino.GamePlinko, casino.GamePachinko, casino.GameBigSix, casino.GameBlackjack, casino.GameBaccarat, casino.GameRoulette, casino.GameCraps, casino.GamePaiGow} { if g.lookCasinoTable(sess, lower, game) { return } diff --git a/internal/game/render_coin_flip.go b/internal/game/render_coin_flip.go new file mode 100644 index 0000000..6408b26 --- /dev/null +++ b/internal/game/render_coin_flip.go @@ -0,0 +1,238 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/color" +) + +// Coin Flip inks. The two faces are deliberately far apart on the xterm +// wheel: bright red for the loss face and a deep sky blue for the win face. +const ( + coinFlipRedInk = 196 + coinFlipBlueInk = 45 + coinFlipDimInk = 245 +) + +// coinFlipLabel renders a signed net multiplier, e.g. "+50x" or "-100x". +func coinFlipLabel(multiplier int) string { + return fmt.Sprintf("%+dx", multiplier) +} + +// coinFlipWheelInk tiers a wheel value: under 50x is plain, 50-74x takes the +// face colour, and 75x and up is bold as well. +func coinFlipWheelInk(value int, side string) (int, bool) { + magnitude := value + if magnitude < 0 { + magnitude = -magnitude + } + if magnitude < 50 { + return -1, false + } + ink := coinFlipRedInk + if side == casino.CoinFlipSideBlue { + ink = coinFlipBlueInk + } + return ink, magnitude >= 75 +} + +// coinFlipRow is one scanline of the coin: padding, an outline run, the +// interior (which alone is filled when the coin settles), the other outline +// run, and trailing padding. +type coinFlipRow struct { + pre, left, interior, right, post string +} + +// coinFlipArt draws one medium coin: a nine-column, five-row circle with the +// signed multiplier centered inside. A settled coin fills only the interior +// cells, so the partial outline blocks keep the circle's round edge instead of +// growing into a square. A spinning coin gets whoosh marks on either side. +func coinFlipArt(multiplier int, side string, settled, alt bool, mode string, unicode bool) []string { + const inner = 7 + label := coinFlipLabel(multiplier) + if len(label) > inner { + label = label[:inner] + } + content := rouletteCenter(label, inner) + ink := coinFlipRedInk + if side == casino.CoinFlipSideBlue { + ink = coinFlipBlueInk + } + plain := func(text string) string { return color.Render(mode, color.ColorSpec{Fg: -1, Bg: -1}, text) } + outline := func(text string) string { + if text == "" { + return "" + } + return color.Render(mode, color.ColorSpec{Fg: ink, Bg: -1, Bold: true}, text) + } + interior := func(text string) string { + if text == "" { + return "" + } + bg, fg := -1, ink + if settled { + // Fill only the interior: the outline's partial cells stay open. + bg, fg = ink, 16 + } + var b strings.Builder + for _, r := range text { + if r == ' ' { + b.WriteString(color.Render(mode, color.ColorSpec{Fg: -1, Bg: bg}, " ")) + } else { + b.WriteString(color.Render(mode, color.ColorSpec{Fg: fg, Bg: bg, Bold: true}, string(r))) + } + } + return b.String() + } + var rows []coinFlipRow + if unicode { + rows = []coinFlipRow{ + {" ", strings.Repeat("▄", inner-2), "", "", " "}, + {" ", "▟", strings.Repeat(" ", inner-2), "▙", " "}, + {"", "▐", content, "▌", ""}, + {" ", "▜", strings.Repeat(" ", inner-2), "▛", " "}, + {" ", strings.Repeat("▀", inner-2), "", "", " "}, + } + } else { + rows = []coinFlipRow{ + {" ", strings.Repeat("_", inner-2), "", "", " "}, + {" ", "/", strings.Repeat(" ", inner-2), "\\", " "}, + {"", "|", content, "|", ""}, + {" ", "\\", strings.Repeat(" ", inner-2), "/", " "}, + {" ", strings.Repeat("-", inner-2), "", "", " "}, + } + } + lines := make([]string, 0, len(rows)) + for _, r := range rows { + lines = append(lines, plain(r.pre)+outline(r.left)+interior(r.interior)+outline(r.right)+plain(r.post)) + } + if !settled { + // Only the two flank rows carry motion marks, and they swap the whoosh + // and arrow each frame so the coin reads as tumbling. + arrow := color.Render(mode, color.ColorSpec{Fg: ink, Bold: true}, "↺") + whoosh := color.Render(mode, color.ColorSpec{Fg: ink, Bold: true}, "~") + for i := range lines { + var left, right string + switch i { + case 1: + if alt { + left, right = arrow, whoosh + } else { + left, right = whoosh, arrow + } + case 3: + if alt { + left, right = whoosh, arrow + } else { + left, right = arrow, whoosh + } + default: + continue + } + lines[i] = left + " " + lines[i] + " " + right + } + } + return lines +} + +// coinFlipWheelChar is one character of the scrolling multiplier strip. +type coinFlipWheelChar struct { + ch rune + value int + sep bool + label bool +} + +// coinFlipWheel builds the full strip: a separator, then each value centred in +// a fixed cell. The engine and renderer share the geometry constants, so the +// landing offset and the drawn pointer always agree. +func coinFlipWheel(values []int, unicode bool) []coinFlipWheelChar { + sep := '│' + if !unicode { + sep = '|' + } + chars := make([]coinFlipWheelChar, 0, len(values)*casino.CoinFlipPitch+1) + for _, v := range values { + chars = append(chars, coinFlipWheelChar{ch: sep, sep: true}) + for _, r := range rouletteCenter(coinFlipLabel(v), casino.CoinFlipCellWidth) { + chars = append(chars, coinFlipWheelChar{ch: r, value: v, label: r != ' '}) + } + } + chars = append(chars, coinFlipWheelChar{ch: sep, sep: true}) + return chars +} + +// renderCoinFlipSpin draws the scrolling multiplier strip, a fixed pointer, and +// a bold SET under the landed value. +func renderCoinFlipSpin(s *casino.CoinFlipSnapshot, mode string, unicode bool) string { + pointer := "▼" + if !unicode { + pointer = "V" + } + wheel := coinFlipWheel(s.SpinValues, unicode) + var out strings.Builder + out.WriteString(strings.Repeat(" ", casino.CoinFlipPointerCol)) + out.WriteString(color.Render(mode, color.ColorSpec{Fg: -1, Bold: true}, pointer)) + out.WriteByte('\n') + for i := 0; i < casino.CoinFlipViewWidth; i++ { + idx := s.SpinOffset + i + cell := coinFlipWheelChar{ch: ' '} + if idx >= 0 && idx < len(wheel) { + cell = wheel[idx] + } + spec := color.ColorSpec{Fg: coinFlipDimInk} + switch { + case cell.label: + ink, bold := coinFlipWheelInk(cell.value, s.SpinSide) + spec = color.ColorSpec{Fg: ink, Bold: bold} + case cell.sep: + spec = color.ColorSpec{Fg: coinFlipDimInk} + } + out.WriteString(color.Render(mode, spec, string(cell.ch))) + } + out.WriteByte('\n') + if s.Set { + out.WriteString(strings.Repeat(" ", casino.CoinFlipPointerCol-1)) + out.WriteString(color.Render(mode, color.ColorSpec{Fg: -1, Bold: true}, "SET")) + } + return strings.TrimRight(out.String(), " \n") +} + +// coinFlipResultCell centers a coloured label/value pair inside one panel cell. +func coinFlipResultCell(label, value string, ink, width int, mode string) string { + text := label + " " + value + pad := max(0, width-len(text)) + left := pad / 2 + right := pad - left + return strings.Repeat(" ", left) + + color.Render(mode, color.ColorSpec{Fg: ink, Bold: true}, label) + " " + + color.Render(mode, color.ColorSpec{Fg: 250, Bold: true}, value) + + strings.Repeat(" ", right) +} + +// renderCoinFlipResults draws the two landed faces as a compact coloured panel. +func renderCoinFlipResults(s *casino.CoinFlipSnapshot, mode string, unicode bool) string { + const cell = 15 + b := rouletteBorders(unicode) + dim := func(text string) string { return color.Render(mode, color.ColorSpec{Fg: coinFlipDimInk}, text) } + top := dim(b.tl + strings.Repeat(b.h, cell) + b.teeTop + strings.Repeat(b.h, cell) + b.tr) + mid := dim(b.v) + + coinFlipResultCell("RED", coinFlipLabel(s.Red), coinFlipRedInk, cell, mode) + dim(b.v) + + coinFlipResultCell("BLUE", coinFlipLabel(s.Blue), coinFlipBlueInk, cell, mode) + dim(b.v) + bottom := dim(b.bl + strings.Repeat(b.h, cell) + b.teeBottom + strings.Repeat(b.h, cell) + b.br) + return top + "\n" + mid + "\n" + bottom +} + +func renderCoinFlip(s *casino.CoinFlipSnapshot, mode string, unicode bool) string { + switch s.Phase { + case casino.CoinFlipPhaseRedSpin, casino.CoinFlipPhaseBlueSpin: + return renderCoinFlipSpin(s, mode, unicode) + case casino.CoinFlipPhaseResults: + return renderCoinFlipResults(s, mode, unicode) + case casino.CoinFlipPhaseFlipping, casino.CoinFlipPhaseResult: + return strings.Join(coinFlipArt(s.Multiplier, s.Side, s.Result, s.FlipAlt, mode, unicode), "\n") + } + return "" +} |
