package casino import "fmt" // Every craps deadline is a tick count; the engine tick length decides the // wall-clock duration. const ( crapsBetTicks = 10 crapsDealerTicks = 60 crapsAutoTicks = 3 // Simplified craps has no manual roll: one shared automatic countdown // starts with the first live bet and resolves every wager. crapsSimplifiedTicks = 3 // A requested manual roll first shows the shake, then the outcome chart, // then resolves the dice three ticks later. crapsManualRollTicks = 3 // Last-call warning fires this many ticks before the shooter may roll. crapsLastCallTicks = 3 ) func (t *crapsTable) shooter() *crapsPlayer { if len(t.queue) == 0 { return nil } return t.players.get(t.queue[0]) } // busy reports whether the named player's dice are still in the air. The // command loop uses it to hold the prompt until the roll resolves, so the // shake message is not split from its result by a stray prompt. Other players // may still bet (and be prompted) during the same window. func (t *crapsTable) busy(name string) bool { t.mu.Lock() defer t.mu.Unlock() s := t.shooter() return t.pendingRollTicks > 0 && s != nil && s.Name == name } func (t *crapsTable) hasLiveBets() bool { for _, p := range t.players.ordered() { if p.HasBet { return true } } return false } func (t *crapsTable) activePlayers() int { n := 0 for _, p := range t.players.ordered() { if p.Connected && !p.SittingOut { n++ } } return n } // Called under the table lock whenever seats or wagers change. Only entering // multiplayer or starting a new round opens a window; adding bets never // extends it. A solo player has no forced deadline at all. func (t *crapsTable) syncRoundClock() []Event { // Cancel immediately, not on the next tick: a new bet must not inherit // a roll requested for wagers that have since been taken down. if !t.hasLiveBets() { t.pendingRollTicks = 0 } multi := t.activePlayers() > 1 if t.Config.Craps.variant() == "simplified" { // Simplified craps never opens a manual shooting window; the first // live bet starts one shared automatic countdown for everyone. if !t.hasLiveBets() { t.phase = phaseWaiting t.roundTicks = 0 t.countdown = 0 t.lastCall = false t.multiplayer = multi return nil } fresh := t.phase == phaseWaiting t.multiplayer = multi if fresh { t.roundTicks = 0 t.countdown = 0 } t.phase = phasePlaying if fresh && multi { return []Event{t.event("{casino_emphasis}Bets open{/} — the dice roll in 3 ticks.", true)} } return nil } if !t.hasLiveBets() { t.phase = phaseWaiting t.roundTicks = 0 t.lastCall = false t.multiplayer = multi return nil } fresh := t.phase == phaseWaiting || (multi && !t.multiplayer) t.multiplayer = multi if fresh { t.roundTicks = 0 t.lastCall = false } t.phase = phasePlaying if multi && t.roundTicks < crapsBetTicks { t.phase = phaseBetting } if fresh && multi { return []Event{t.event("{casino_emphasis}Bets open{/} — 10 ticks before the shooter can roll. Betting stays open until the dice roll.", true)} } return nil } func (t *crapsTable) advance(ticks int) []Event { t.mu.Lock() defer t.mu.Unlock() events := t.syncRoundClock() if ticks <= 0 { return events } if t.Config.Craps.variant() == "simplified" { if !t.hasLiveBets() { return events } t.roundTicks += ticks shooter := t.shooter() if shooter == nil { return events } for t.countdown < len(crapsCountdownFlavor) && t.roundTicks >= t.countdown+1 { events = append(events, crapsCountdownEvent(t, shooter.Name, t.countdown)) t.countdown++ } if t.roundTicks >= crapsSimplifiedTicks { events = append(events, t.roll()...) } return events } if t.pendingRollTicks > 0 { // A manual roll is in the air: no other deadline may fire first. Only // the shake is announced now; the result follows when the dice land. if !t.hasLiveBets() { t.pendingRollTicks = 0 return events } t.pendingRollTicks -= ticks if t.pendingRollTicks <= 0 { t.pendingRollTicks = 0 return append(events, t.roll()...) } return events } if !t.hasLiveBets() { return events } t.roundTicks += ticks p := t.shooter() if p == nil { return events } if p.autoRoll && p.HasBet { p.autoTicks += ticks } if t.multiplayer && !t.lastCall && t.roundTicks >= crapsBetTicks-crapsLastCallTicks { t.lastCall = true remaining := max(1, crapsBetTicks-t.roundTicks) events = append(events, t.event(fmt.Sprintf("{casino_warning}Last call for bets! %d ticks until %s can shoot!{/}", remaining, p.Name), true)) } if t.multiplayer && t.roundTicks < crapsBetTicks { return events } if t.phase == phaseBetting { events = append(events, t.event(fmt.Sprintf("{casino_emphasis}%s can shoot!{/} Bets stay open until the roll. Dealer rolls in %d ticks if needed.", p.Name, crapsDealerTicks), true), t.reminder(p.Name)) } t.phase = phasePlaying if (p.autoRoll && p.HasBet && p.autoTicks >= crapsAutoTicks) || (t.multiplayer && t.roundTicks >= crapsBetTicks+crapsDealerTicks) { return append(events, t.roll()...) } return events } // crapsCountdownFlavor broadcasts one line per tick before the automatic // simplified roll. Each step carries a few variants: the public line names // the shooter, the private line speaks to them. var crapsCountdownFlavor = [][3][2]string{ { {"{casino_emphasis}%s grabs the dice...{/}", "{casino_emphasis}You grab the dice...{/}"}, {"{casino_emphasis}%s scoops up the dice...{/}", "{casino_emphasis}You scoop up the dice...{/}"}, {"{casino_emphasis}%s weighs the dice in one hand...{/}", "{casino_emphasis}You weigh the dice in one hand...{/}"}, }, { {"{casino_emphasis}%s blows on them for luck...{/}", "{casino_emphasis}You blow on them for luck...{/}"}, {"{casino_emphasis}%s blows on the dice for luck...{/}", "{casino_emphasis}You blow on the dice for luck...{/}"}, {"{casino_emphasis}%s rubs them for luck...{/}", "{casino_emphasis}You rub them for luck...{/}"}, }, { {"{casino_emphasis}%s shakes, shakes, shakes the dice...{/}", "{casino_emphasis}Shake, shake, shake...{/}"}, {"{casino_emphasis}%s rattles the dice... shake, shake...{/}", "{casino_emphasis}Shake, shake... here they come!{/}"}, {"{casino_emphasis}%s gives the dice one last shake...{/}", "{casino_emphasis}You shake them hard... here they come!{/}"}, }, } func crapsCountdownEvent(t *crapsTable, shooter string, step int) Event { line := crapsCountdownFlavor[step][t.rng.Intn(3)] return t.playerAction(shooter, fmt.Sprintf(line[0], shooter), line[1]) } // StopAutoRoll is atomic with ticking and does not queue a departure or touch // any wager. A second stop, in manual mode, retains normal casino leave behavior. func (t *Table) StopAutoRoll(name string) ([]Event, bool) { game, ok := t.game.(*crapsTable) if !ok { return nil, false } game.mu.Lock() defer game.mu.Unlock() p := game.players.get(name) if p == nil || !p.autoRoll { return nil, false } p.autoRoll = false p.autoTicks = 0 return []Event{game.private(name, "{casino_emphasis}Autoroll OFF{/} — manual play resumed. Your bets stay on the table."), game.reminder(name)}, true }