aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-09 17:05:54 -0400
committerhistoria <[not public]>2026-10-09 17:05:54 -0400
commitced77d7c89129da6ba4001ab8e3dad265aa41396 (patch)
tree2acfd429e5b4a83121565bf5f1bbc122228e9d8a /internal
parent6321c0a6b63061ac57a982c9ec5f93c1f731ae65 (diff)
downloadthehouseoficarus-ced77d7c89129da6ba4001ab8e3dad265aa41396.tar.gz
feat: new casino game: coin flip
Diffstat (limited to 'internal')
-rw-r--r--internal/casino/coin_flip.go652
-rw-r--r--internal/casino/coin_flip_test.go296
-rw-r--r--internal/casino/core.go3
-rw-r--r--internal/casino/registry.go9
-rw-r--r--internal/casino/table.go14
-rw-r--r--internal/casino/types.go1
-rw-r--r--internal/game/casino.go7
-rw-r--r--internal/game/casino_ux.go8
-rw-r--r--internal/game/cmd_registry.go13
-rw-r--r--internal/game/coin_flip_test.go162
-rw-r--r--internal/game/look_casino.go2
-rw-r--r--internal/game/look_target.go2
-rw-r--r--internal/game/render_coin_flip.go238
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 ""
+}