package game import ( "fmt" "regexp" "strconv" "strings" "thehouseoficarus/internal/casino" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" ) type casinoChoice struct { address string names []string table bool } // Exact addresses beat aliases; prefixes must identify exactly one game. func resolveCasinoChoice(choices []casinoChoice, input string) (casinoChoice, bool) { input = strings.ToLower(input) for _, c := range choices { if input != "" && strings.EqualFold(c.address, input) { return c, true } } if input != "" { var exact []casinoChoice for _, c := range choices { for _, name := range c.names { if strings.EqualFold(name, input) { exact = append(exact, c) break } } } if len(exact) == 1 { return exact[0], true } if len(exact) > 1 { return casinoChoice{}, false } } matches := []casinoChoice{} for _, c := range choices { matched := input == "" for _, name := range c.names { if strings.EqualFold(name, input) || (input != "" && strings.HasPrefix(strings.ToLower(name), input)) { matched = true } } if matched { matches = append(matches, c) } } if len(matches) == 1 { return matches[0], true } return casinoChoice{}, false } // casinoTableAliases lists the names the play, list and look commands accept // for a multiplayer table, beyond its raw game name and fixture id. func casinoTableAliases(cfg casino.TableConfig) []string { names := []string{string(cfg.Game)} switch cfg.Game { case casino.GameBlackjack: names = append(names, "bj") case casino.GameBaccarat: names = append(names, "bac") case casino.GamePaiGow: names = append(names, "pai", "pai gow", "pai gow poker") } return names } func (g *Game) casinoChoices(roomID int) []casinoChoice { room, err := g.World.LoadRoom(roomID) if err != nil { return nil } var choices []casinoChoice for _, c := range room.CasinoTables { if c.Game == casino.GameVideoPoker { names := append([]string{c.ID}, c.VideoPoker.CabinetAliases()...) choices = append(choices, casinoChoice{address: c.ID, names: names, table: true}) continue } names := casinoTableAliases(c) choices = append(choices, casinoChoice{address: string(c.Game), names: names, table: true}) } for _, c := range room.CasinoMachines { choices = append(choices, casinoChoice{address: c.ID, names: []string{c.ID, string(c.Game), c.Variant}}) } return choices } func (g *Game) showCasinoChoices(sess *net.Session, choices []casinoChoice) { if len(choices) == 0 { sess.WriteLine("There are no casino games here.") return } commands := []casino.MenuCommand{} for _, c := range choices { commands = append(commands, casino.MenuCommand{Command: "play " + c.address, Desc: "Join this game"}) } sess.WriteLine(g.renderCasinoCommands(sess, "", "Choose a game (use an unambiguous name or abbreviation):", commands)) } // renderCasinoFelt draws a table's felt (layout, payouts, board) without the // prose rule text. The table command shows this; rules opens the help page. // Craps and roulette already render their felt through the live status // snapshot, so they return nothing here. func (g *Game) renderCasinoFelt(sess *net.Session, cfg casino.TableConfig) string { switch cfg.Game { case casino.GameBlackjack: return g.renderCasinoSnapshot(sess, &casino.BlackjackRulesView{Config: cfg}) case casino.GameBaccarat: return g.renderCasinoSnapshot(sess, &casino.BaccaratRulesView{Config: cfg}) case casino.GamePaiGow: return renderPaiGowFelt(cfg, g.colorMode(sess), sess.Player.OptionBool("unicode")) } return "" } func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) { if sess.Player == nil { return } t, playing := g.Casino.Participation(sess.Player.Name) if !playing || t == nil { if len(args) > 0 && args[0] == "help" { // Machine players get their cabinet's guide; everyone else the // shared casino one. topic := "casino" if playing { topic = "slots" } g.doHelp(sess, topic) } else { g.showCasinoChoices(sess, g.casinoChoices(sess.Player.RoomID)) } return } // table help remains a shortcut to the seated game's help page; the old // "table rules" subcommand is gone, so bare table always shows the felt. if len(args) > 0 && args[0] == "help" { g.doHelp(sess, t.HelpTopic()) if t.Config.Game != casino.GameVideoPoker { commands := []casino.MenuCommand{ {Command: "rules", Desc: "Help page for this game variant"}, {Command: "list", Desc: "Every bet available right now, with payouts"}, {Command: "option casino_display compact", Desc: "Compact action hints (detailed for full command menus)"}, {Command: "help casino", Desc: "Quick guides to all casino games"}, } sess.WriteLine(g.renderCasinoCommands(sess, "", "Table controls", commands)) } return } // The table command always shows the live felt. The rules command opens // the game's help page; there is no separate "table rules" command. if t.Config.Game == casino.GameVideoPoker { // A video poker cabinet's felt is its paytable. g.executeTableAction(sess, "paytable") return } g.emitCasinoEvents(t.Status(sess.Player.Name)) if felt := g.renderCasinoFelt(sess, t.Config); felt != "" { sess.WriteLine(felt) } if t.Config.Game == casino.GameRoulette { sess.WriteLine(g.rouletteReminder(sess, t.Config)) } else { sess.WriteLine(g.colorize(sess, "command", "Learn to play: rules.")) } } func (g *Game) executeWatch(sess *net.Session, args []string, _ string) { if sess.Player == nil { return } if len(args) == 1 && args[0] == "off" { sess.CasinoWatchTable = "" sess.WriteLine(g.colorize(sess, "casino_action", "You stop watching table details.")) return } if len(args) > 1 { sess.WriteLine("Use watch [game] or watch off.") return } choices := []casinoChoice{} for _, c := range g.casinoChoices(sess.Player.RoomID) { if c.table { choices = append(choices, c) } } input := "" if len(args) == 1 { input = args[0] } c, ok := resolveCasinoChoice(choices, input) if !ok { sess.WriteLine("Choose a table to watch: watch blackjack, watch baccarat, watch roulette, or watch craps.") return } t, ok := g.casinoTable(sess.Player.RoomID, c.address) if !ok { return } if t.Config.Game == casino.GameVideoPoker { sess.WriteLine("Poker hands are private. Use look " + t.Config.ID + " to inspect this cabinet.") return } sess.CasinoWatchRoom = sess.Player.RoomID sess.CasinoWatchTable = t.Config.ID sess.WriteLine(g.colorize(sess, "casino_action", "Watching "+t.Config.Label()+". Detailed public cards are visible; watch off stops watching.")) g.emitCasinoEvents(t.Status(sess.Player.Name)) } func (g *Game) seesCasinoDetails(sess *net.Session, event casino.Event) bool { if sess.Player == nil { return false } // Slot output is unchanged; watch controls multiplayer tables only. if g.Casino.Table(event.RoomID, event.TableID) == nil { return true } if sess.CasinoWatchRoom == event.RoomID && sess.CasinoWatchTable == event.TableID { return true } // Settlement may have removed a queued departure before these events // are delivered. Their own final cards must still be visible. switch s := event.Snapshot.(type) { case *casino.CrapsSnapshot: for _, name := range s.Queue { if name == sess.Player.Name { return true } } case *casino.RouletteSnapshot: for _, name := range s.Players { if name == sess.Player.Name { return true } } case *casino.BlackjackSnapshot: for _, p := range s.Players { if p.Name == sess.Player.Name { return true } } case *casino.BaccaratSnapshot: for _, p := range s.Bets { if p.Name == sess.Player.Name { return true } } } t, ok := g.Casino.Participation(sess.Player.Name) return ok && t != nil && t.RoomID == event.RoomID && t.Config.ID == event.TableID } var casinoTicksPattern = regexp.MustCompile(`([0-9]+) ticks`) // Casino logic counts engine ticks; present deadlines in actual configured seconds. func (g *Game) casinoTimeText(text string) string { ms := 600 if g.Ticks != nil { ms = g.Ticks.TickLengthMilliseconds() } return casinoTicksPattern.ReplaceAllStringFunc(text, func(s string) string { n, _ := strconv.Atoi(strings.Fields(s)[0]) return fmt.Sprintf("%g seconds", float64(n)*float64(ms)/1000) }) } // betRangeWords renders the wager range in words for one-line hints. func betRangeWords(minBet, maxBet int) string { if maxBet <= 0 || maxBet >= casino.DefaultMaxBet { return fmt.Sprintf("%d or more", minBet) } return fmt.Sprintf("%d-%d", minBet, maxBet) } func (g *Game) casinoBalance(sess *net.Session) string { if sess.Player == nil { return "" } p := sess.Player return fmt.Sprintf("Available %s (%s chips + %s credits)", g.colorize(sess, "casino_chips", strconv.Itoa(casinoAffordable(p))), g.colorize(sess, "casino_chips", strconv.Itoa(countChips(p))), g.colorize(sess, "casino_credits", strconv.Itoa(p.Credits))) } // executeRules is the seated table's own help entry point: a variant's page // (Simplified Craps gets its own short page, not a section of the craps // manual), slots help at a machine, and the room's own game while merely // standing in a casino room. Explicit topics pass straight through to help. func (g *Game) executeRules(sess *net.Session, args []string, rawInput string) { if sess.Player == nil { return } if len(args) > 0 { g.doHelp(sess, strings.Join(args, " ")) return } if g.Casino == nil { g.doHelp(sess, "casino") return } if topic := g.casinoRulesTopic(sess.Player.RoomID, sess.Player.Name); topic != "" { g.doHelp(sess, topic) return } // Not in a casino game or room: the shared guide still answers. g.doHelp(sess, "casino") } // casinoRulesTopic picks the help page "rules" should open: the seated // table's variant page first, then a machine's game, then the room's own // casino games (so standing in the craps pit answers about craps). Mixed // games, or no casino content at all, fall back to the shared guide. func (g *Game) casinoRulesTopic(roomID int, name string) string { if table, playing := g.Casino.Participation(name); playing && table != nil { return table.HelpTopic() } if machine, ok := g.Casino.MachineFor(name); ok { return string(machine.Config.Game) } room, err := g.World.LoadRoom(roomID) if err != nil { return "" } topic := "" for _, cfg := range room.CasinoTables { page := cfg.HelpTopic() if topic == "" { topic = page } else if topic != page { return "casino" } } for _, m := range room.CasinoMachines { page := string(m.Game) if topic == "" { topic = page } else if topic != page { return "casino" } } return topic } // executeList shows the bets a casino game accepts right now — the game's // own state-driven list, from the come-out pass line to the travelling come // bets and one-roll props. Everywhere else (shops, plain rooms) "list" // keeps its shop meaning. func (g *Game) executeList(sess *net.Session, args []string, rawInput string) { p := sess.Player if p == nil { return } if len(args) > 0 || g.Casino == nil { g.executeShopList(sess, args, rawInput) return } if machine, ok := g.Casino.MachineFor(p.Name); ok { sess.WriteLine(g.colorize(sess, "casino_action", "A "+casinoGameLabel(machine.Config.Game)+" takes one wager: bet (or bet max), then spin or autospin.")) return } if table, playing := g.Casino.Participation(p.Name); playing && table != nil { g.showCasinoWagerList(sess, table) return } g.executeShopList(sess, args, rawInput) } // showCasinoWagerList renders the game's live wager list. Two or more rows // become a brief table; a single row is one line; an empty list means the // only bet is the ordinary amount-only wager. func (g *Game) showCasinoWagerList(sess *net.Session, t *casino.Table) { if t.Config.Game == casino.GameVideoPoker { for _, h := range t.Hints(sess.Player.Name) { if h.Command == "draw" { sess.WriteLine("No wagers are available during this hand; finish with draw.") return } } sess.WriteLine("This cabinet has one wager: load coins with bet [1-4|max], then deal. Use paytable for payouts.") return } rows := t.Wagers(sess.Player.Name) // Blackjack rows are actions, not targets for a new bet. if t.Config.Game == casino.GameBlackjack && len(rows) > 0 { commands := make([]casino.MenuCommand, 0, len(rows)) for _, r := range rows { commands = append(commands, casino.MenuCommand{Command: r.Bet, Desc: strings.Trim(r.Payout+" | "+r.Win, " |")}) } sess.WriteLine(g.renderCasinoCommands(sess, g.casinoBalance(sess), "Available actions", commands)) return } switch { case len(rows) == 1: r := rows[0] sess.WriteLine(g.colorize(sess, "casino_action", "Your only wager: bet on "+r.Bet+" — "+r.Payout+" — "+r.Win+".")) case len(rows) == 0: canBet := false for _, h := range t.Hints(sess.Player.Name) { if strings.HasPrefix(h.Command, "bet ") { canBet = true } } if !canBet { sess.WriteLine(g.colorize(sess, "casino_action", "No new wagers are available right now.")) return } sess.WriteLine(g.colorize(sess, "casino_action", "The only wager here is bet ") + " (" + betRangeWords(t.Config.MinBet, t.Config.MaxBet) + ").") default: commands := make([]casino.MenuCommand, 0, len(rows)) for _, r := range rows { commands = append(commands, casino.MenuCommand{Command: "bet on " + r.Bet, Desc: r.Payout + " | " + r.Win}) } sess.WriteLine(g.renderCasinoCommands(sess, g.casinoBalance(sess), t.Config.Label()+" — available bets", commands)) } } // showCasinoGameLook appends the live game state and hint menu for the // table the player is seated at, so look reads room first, then the table, // then the commands that matter now. Machines are skipped: their menus // arrive with every spin. func (g *Game) showCasinoGameLook(sess *net.Session, p *player.Player, room *world.Room) { if g.Casino == nil || p == nil { return } table, playing := g.Casino.Participation(p.Name) if !playing || table == nil || table.RoomID != room.ID { return } sess.WriteLine("") g.emitCasinoEvents(table.Status(p.Name)) }