// ── Blackjack: BT-driven game loop with command-based input ── // // Architecture: // Behaviour Tree (buildTree) — controls game flow: // root (repeat) // └── seq (sequential) // ├── handleInput (leaf) — reads queued commands, mutates state // ├── dealerPlay (leaf) — auto-plays dealer hand on dealerTurn // └── render (leaf) — draws via blessed // // CommandQueue — processes input: // Keyboard → spawn command entities → CommandQueue.execute() // → handlers mutate game state // // Cards as entities with tag components: // Each card is an entity with a Card component ({ rank, suit, order }). // Tag components (InDeck, InPlayerHand, InDealerHand) mark which // collection the card belongs to. Queries find cards by tag. // Order is tracked via the `order` field on Card. // // Usage: // npx tsx examples/blackjack/main.ts import { World } from "../../src/index"; import { query } from "../../src/query"; import { buildTree } from "../../src/bt/index"; import { CommandQueue } from "../../src/commands/index"; import { Card, InDeck, InPlayerHand, InDealerHand, HoleHidden, Score, Bet, GamePhase, } from "./components"; import { Hit, Stand, NewRound, BetMore, BetLess } from "./commands"; import { SUITS, RANKS, handValue, isBust, isBlackjack, dealerShouldHit, determineOutcome, payout, type CardData, } from "./game"; import { createUI, render } from "./render"; import { startInput, type Key } from "./input"; // ── Setup ──────────────────────────────────────────── const world = new World(); // Singleton game state world.addSingleton(Score); world.addSingleton(Bet); world.addSingleton(GamePhase); // Create blessed UI const ui = createUI(); // ── Card helpers (tag-based) ───────────────────────── /** Create all 52 card entities with the InDeck tag. */ function buildDeck(): void { let order = 0; for (const suit of SUITS) { for (const rank of RANKS) { const e = world.spawn(); world.add(e, Card, { rank, suit, order }); world.add(e, InDeck); order++; } } } /** Shuffle the deck: collect all InDeck entities, shuffle, reassign order. */ function shuffleDeck(): void { const entities = [...world.query(query(Card, InDeck))]; // Fisher-Yates shuffle for (let i = entities.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [entities[i], entities[j]] = [entities[j], entities[i]]; } // Reassign order to match shuffled position for (let i = 0; i < entities.length; i++) { world.get(entities[i], Card).order = i; } } /** Draw the top card from the deck (highest order). Returns entity or null. */ function drawCard(): number | null { const cards = [...world.query(query(Card, InDeck))]; if (cards.length === 0) return null; // Find the one with the highest order let top = cards[0]; let topOrder = world.get(top, Card).order; for (let i = 1; i < cards.length; i++) { const o = world.get(cards[i], Card).order; if (o > topOrder) { top = cards[i]; topOrder = o; } } world.remove(top, InDeck); return top; } /** Move a card entity to a hand tag, assigning the next order. */ function dealTo(cardEntity: number, tag: typeof InPlayerHand): void { const existing = [...world.query(query(Card, tag))]; const nextOrder = existing.length === 0 ? 0 : Math.max(...existing.map((e) => world.get(e, Card).order)) + 1; world.add(cardEntity, tag); world.get(cardEntity, Card).order = nextOrder; } /** Collect CardData from a hand tag, sorted by order. */ function getHand(tag: typeof InPlayerHand): CardData[] { return [...world.query(query(Card, tag))] .map((e) => { const c = world.get(e, Card); return { rank: c.rank, suit: c.suit, order: c.order }; }) .sort((a, b) => a.order - b.order) .map(({ rank, suit }) => ({ rank, suit })); } /** Remove all cards from a hand tag (destroy the card entities). */ function clearHand(tag: typeof InPlayerHand): void { for (const e of world.query(query(Card, tag))) { world.destroy(e); } } /** Count cards remaining in the deck. */ function deckCount(): number { return [...world.query(query(Card, InDeck))].length; } // ── Game flow ──────────────────────────────────────── function dealInitial(): void { const c1 = drawCard(); const c2 = drawCard(); const c3 = drawCard(); const c4 = drawCard(); if (c1) dealTo(c1, InPlayerHand); if (c2) dealTo(c2, InDealerHand); if (c3) dealTo(c3, InPlayerHand); if (c4) dealTo(c4, InDealerHand); } function startRound(): void { const bet = world.getSingleton(Bet); const score = world.getSingleton(Score); // Deduct bet score.chips -= bet.amount; // Re-shuffle if deck is low if (deckCount() < 15) { // Move all remaining InDeck cards back, then shuffle const remaining = [...world.query(query(Card, InDeck))]; for (const e of remaining) { world.remove(e, InDeck); } for (const e of remaining) { world.add(e, InDeck); } shuffleDeck(); } // Clear old hands clearHand(InPlayerHand); clearHand(InDealerHand); world.removeSingleton(HoleHidden); dealInitial(); // Check for natural blackjack const playerCards = getHand(InPlayerHand); const dealerCards = getHand(InDealerHand); if (isBlackjack(playerCards)) { world.removeSingleton(HoleHidden); if (isBlackjack(dealerCards)) { score.chips += bet.amount; score.pushes++; world.setSingleton(GamePhase, { phase: "roundOver", message: "Both have Blackjack — Push! Press N for new round.", }); } else { const winnings = payout("blackjack", bet.amount); score.chips += bet.amount + winnings; score.wins++; world.setSingleton(GamePhase, { phase: "roundOver", message: "Blackjack! You win 3:2! Press N for new round.", }); } } else { world.addSingleton(HoleHidden); world.setSingleton(GamePhase, { phase: "playerTurn", message: "Your turn — H to hit, S to stand.", }); } } function resolveRound(): void { const playerCards = getHand(InPlayerHand); const dealerCards = getHand(InDealerHand); const score = world.getSingleton(Score); const bet = world.getSingleton(Bet); const outcome = determineOutcome(playerCards, dealerCards); const winnings = payout(outcome, bet.amount); score.chips += bet.amount + winnings; switch (outcome) { case "blackjack": case "win": score.wins++; break; case "lose": score.losses++; break; case "push": score.pushes++; break; } const messages: Record = { win: "You win!", lose: "Dealer wins.", push: "Push — tie!", blackjack: "Blackjack! You win 3:2!", }; world.setSingleton(GamePhase, { phase: "roundOver", message: `${messages[outcome]} Press N for new round.`, }); } // ── Command handlers ───────────────────────────────── const commands = new CommandQueue(world); commands.handle(Hit, () => { const phase = world.getSingleton(GamePhase); if (phase.phase !== "playerTurn") return; const cardEntity = drawCard(); if (cardEntity) { dealTo(cardEntity, InPlayerHand); } if (isBust(getHand(InPlayerHand))) { world.removeSingleton(HoleHidden); resolveRound(); } }); commands.handle(Stand, () => { const phase = world.getSingleton(GamePhase); if (phase.phase !== "playerTurn") return; world.removeSingleton(HoleHidden); world.setSingleton(GamePhase, { phase: "dealerTurn", message: "Dealer's turn...", }); }); commands.handle(NewRound, () => { const phase = world.getSingleton(GamePhase); if (phase.phase !== "roundOver" && phase.phase !== "betting") return; const score = world.getSingleton(Score); if (score.chips <= 0) { world.setSingleton(GamePhase, { phase: "roundOver", message: "You're out of chips! Restart the program to play again.", }); return; } startRound(); }); commands.handle(BetMore, () => { const phase = world.getSingleton(GamePhase); if (phase.phase !== "betting" && phase.phase !== "roundOver") return; const bet = world.getSingleton(Bet); const score = world.getSingleton(Score); bet.amount = Math.min(bet.amount + 10, score.chips); }); commands.handle(BetLess, () => { const phase = world.getSingleton(GamePhase); if (phase.phase !== "betting" && phase.phase !== "roundOver") return; const bet = world.getSingleton(Bet); bet.amount = Math.max(bet.amount - 10, 10); }); // ── Behaviour Tree ─────────────────────────────────── const runner = buildTree(world, { kind: "repeat", child: { kind: "sequential", children: [ { kind: "leaf", run: () => { commands.execute(); }, }, { kind: "leaf", *run() { while (true) { const dt: number = yield; const phase = world.getSingleton(GamePhase); if (phase.phase !== "dealerTurn") continue; if (dealerShouldHit(getHand(InDealerHand))) { const cardEntity = drawCard(); if (cardEntity) { dealTo(cardEntity, InDealerHand); } } else { resolveRound(); } } }, }, { kind: "leaf", run: () => { render(world, ui); }, }, ], }, }); // ── Input → Command mapping ────────────────────────── const keyToCommand: Partial> = { h: Hit, s: Stand, n: NewRound, up: BetMore, down: BetLess, }; startInput(ui.screen, (key) => { const cmd = keyToCommand[key]; if (cmd) { const cmdEntity = world.spawn(); world.add(cmdEntity, cmd); } }); // ── Game loop ──────────────────────────────────────── world.setSingleton(GamePhase, { phase: "betting", message: "Welcome to Blackjack! Press N to start.", }); buildDeck(); shuffleDeck(); runner.schedule((runner as any).root); const TICK_MS = 16; const interval = setInterval(() => { runner.tick(TICK_MS); }, TICK_MS); process.on("SIGINT", () => { clearInterval(interval); ui.screen.destroy(); process.exit(0); });