diff options
Diffstat (limited to 'internal')
| -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 |
13 files changed, 1404 insertions, 3 deletions
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 "" +} |
