diff --git a/examples/blackjack/commands.ts b/examples/blackjack/commands.ts new file mode 100644 index 0000000..84b257f --- /dev/null +++ b/examples/blackjack/commands.ts @@ -0,0 +1,16 @@ +import { defineComponent } from "../../src/component"; + +/** Player takes another card. */ +export const Hit = defineComponent("hit", {}); + +/** Player stands (ends their turn). */ +export const Stand = defineComponent("stand", {}); + +/** Start a new round. */ +export const NewRound = defineComponent("newRound", {}); + +/** Increase the bet. */ +export const BetMore = defineComponent("betMore", {}); + +/** Decrease the bet. */ +export const BetLess = defineComponent("betLess", {}); diff --git a/examples/blackjack/components.ts b/examples/blackjack/components.ts new file mode 100644 index 0000000..c4f7008 --- /dev/null +++ b/examples/blackjack/components.ts @@ -0,0 +1,38 @@ +import { defineComponent } from "../../src/component"; + +// ── Card ───────────────────────────────────────────── +/** Each card is its own entity. `order` tracks position within its collection. */ +export const Card = defineComponent("card", { + rank: "A", + suit: "♠", + order: 0, +}); + +// ── Tag components — mark which collection a card belongs to ─ +export const InDeck = defineComponent("inDeck", {}); +export const InPlayerHand = defineComponent("inPlayerHand", {}); +export const InDealerHand = defineComponent("inDealerHand", {}); + +// ── Dealer state ───────────────────────────────────── +/** When present (as singleton), the dealer's hole card is hidden. */ +export const HoleHidden = defineComponent("holeHidden", {}); + +// ── Score / state ──────────────────────────────────── +export const Score = defineComponent("score", { + wins: 0, + losses: 0, + pushes: 0, + chips: 100, +}); + +export const Bet = defineComponent("bet", { + amount: 10, +}); + +/** Current phase of the game. */ +export type Phase = "betting" | "playerTurn" | "dealerTurn" | "roundOver"; + +export const GamePhase = defineComponent("gamePhase", { + phase: "betting" as Phase, + message: "", +}); diff --git a/examples/blackjack/game.ts b/examples/blackjack/game.ts new file mode 100644 index 0000000..4ce06cb --- /dev/null +++ b/examples/blackjack/game.ts @@ -0,0 +1,104 @@ +// ── Blackjack game logic (pure functions, no ECS dependency) ── + +export const SUITS = ["♠", "♥", "♦", "♣"] as const; +export const RANKS = [ + "A", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "J", + "Q", + "K", +] as const; + +export interface CardData { + rank: string; + suit: string; +} + +// ── Hand evaluation ────────────────────────────────── +/** Numeric value of a single card rank. */ +export function rankValue(rank: string): number { + if (rank === "A") return 11; + if (rank === "K" || rank === "Q" || rank === "J") return 10; + return parseInt(rank, 10); +} + +/** Best total for a hand (aces count as 1 or 11). */ +export function handValue(cards: CardData[]): number { + let total = 0; + let aces = 0; + for (const c of cards) { + total += rankValue(c.rank); + if (c.rank === "A") aces++; + } + while (total > 21 && aces > 0) { + total -= 10; + aces--; + } + return total; +} + +export function isBust(cards: CardData[]): boolean { + return handValue(cards) > 21; +} + +export function isBlackjack(cards: CardData[]): boolean { + return cards.length === 2 && handValue(cards) === 21; +} + +export function isSoft(cards: CardData[]): boolean { + let total = 0; + let aces = 0; + for (const c of cards) { + total += rankValue(c.rank); + if (c.rank === "A") aces++; + } + return aces > 0 && total <= 21; +} + +// ── Dealer logic ───────────────────────────────────── +/** Dealer must hit on soft 17 in this variant. */ +export function dealerShouldHit(cards: CardData[]): boolean { + const val = handValue(cards); + if (val < 17) return true; + if (val === 17 && isSoft(cards)) return true; + return false; +} + +// ── Outcome ────────────────────────────────────────── +export type Outcome = "win" | "lose" | "push" | "blackjack"; + +export function determineOutcome( + playerCards: CardData[], + dealerCards: CardData[], +): Outcome { + if (isBust(playerCards)) return "lose"; + if (isBust(dealerCards)) return "win"; + if (isBlackjack(playerCards) && !isBlackjack(dealerCards)) return "blackjack"; + const pv = handValue(playerCards); + const dv = handValue(dealerCards); + if (pv > dv) return "win"; + if (pv < dv) return "lose"; + return "push"; +} + +/** Payout multiplier. Blackjack pays 3:2, win pays 1:1. */ +export function payout(outcome: Outcome, bet: number): number { + switch (outcome) { + case "blackjack": + return Math.floor(bet * 1.5); + case "win": + return bet; + case "push": + return 0; + case "lose": + return -bet; + } +} diff --git a/examples/blackjack/input.ts b/examples/blackjack/input.ts new file mode 100644 index 0000000..d6e9ef5 --- /dev/null +++ b/examples/blackjack/input.ts @@ -0,0 +1,27 @@ +// ── Keyboard input via blessed ──────────────────────── +import type blessed from "blessed"; + +export type Key = + | "h" + | "s" + | "n" + | "up" + | "down" + | "q"; + +/** Wire blessed screen key events to a callback. */ +export function startInput( + screen: blessed.Widgets.Screen, + onKey: (key: Key) => void, +): void { + screen.key( + ["h", "s", "n", "up", "down", "q", "C-c"], + (_ch, key) => { + if (key.name === "q" || key.name === "C-c") { + screen.destroy(); + process.exit(0); + } + onKey(key.name as Key); + }, + ); +} diff --git a/examples/blackjack/main.ts b/examples/blackjack/main.ts new file mode 100644 index 0000000..4242d9a --- /dev/null +++ b/examples/blackjack/main.ts @@ -0,0 +1,397 @@ +// ── 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); +}); diff --git a/examples/blackjack/render.ts b/examples/blackjack/render.ts new file mode 100644 index 0000000..5bfa598 --- /dev/null +++ b/examples/blackjack/render.ts @@ -0,0 +1,173 @@ +// ── Terminal rendering via blessed ──────────────────── +import blessed from "blessed"; +import type { World } from "../../src/index"; +import { query } from "../../src/query"; +import { + Card, + InPlayerHand, + InDealerHand, + HoleHidden, + Score, + Bet, + GamePhase, +} from "./components"; +import { handValue, isBust, isBlackjack } from "./game"; + +const SUIT_SYMBOLS: Record = { + "♠": "\x1b[37m♠\x1b[0m", + "♥": "\x1b[31m♥\x1b[0m", + "♦": "\x1b[31m♦\x1b[0m", + "♣": "\x1b[37m♣\x1b[0m", +}; + +export function createUI(): { + screen: blessed.Widgets.Screen; + dealerBox: blessed.Widgets.BoxElement; + playerBox: blessed.Widgets.BoxElement; + infoText: blessed.Widgets.TextElement; + controlsText: blessed.Widgets.TextElement; +} { + const screen = blessed.screen({ + smartCSR: true, + title: "Blackjack", + }); + + const dealerBox = blessed.box({ + parent: screen, + top: 2, + left: "center", + width: 50, + height: 8, + border: { type: "line" }, + label: " Dealer ", + style: { border: { fg: "white" } }, + }); + + const playerBox = blessed.box({ + parent: screen, + top: 12, + left: "center", + width: 50, + height: 8, + border: { type: "line" }, + label: " Player ", + style: { border: { fg: "white" } }, + }); + + const infoText = blessed.text({ + parent: screen, + top: 22, + left: "center", + width: 50, + height: 5, + style: { fg: "white" }, + }); + + const controlsText = blessed.text({ + parent: screen, + bottom: 0, + left: "center", + width: 60, + height: 1, + style: { fg: "gray" }, + }); + + return { screen, dealerBox, playerBox, infoText, controlsText }; +} + +function formatCard(rank: string, suit: string): string { + return `${rank}${SUIT_SYMBOLS[suit] ?? suit}`; +} + +/** Collect cards from a hand tag, sorted by order. */ +function collectHand( + world: World, + tag: typeof InPlayerHand, +): { rank: string; suit: string }[] { + 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 })); +} + +function formatHand( + cards: { rank: string; suit: string }[], + hideHole: boolean, +): string { + if (cards.length === 0) return " (empty)"; + + const lines: string[] = []; + + if (hideHole && cards.length >= 2) { + lines.push(` ${formatCard(cards[0].rank, cards[0].suit)} ██`); + lines.push(` Value: ${handValue([cards[0]])}`); + } else { + const cardStr = cards.map((c) => formatCard(c.rank, c.suit)).join(" "); + lines.push(` ${cardStr}`); + const val = handValue(cards); + const extra = isBlackjack(cards) + ? " — BLACKJACK!" + : isBust(cards) + ? " — BUST!" + : ""; + lines.push(` Value: ${val}${extra}`); + } + + return lines.join("\n"); +} + +/** Render the full game state into the blessed UI. */ +export function render(world: World, ui: ReturnType): void { + const score = world.tryGetSingleton(Score); + const bet = world.tryGetSingleton(Bet); + const phase = world.tryGetSingleton(GamePhase); + const holeHidden = world.hasSingleton(HoleHidden); + + // Dealer + const dealerCards = collectHand(world, InDealerHand); + ui.dealerBox.setContent(formatHand(dealerCards, holeHidden)); + + // Player + const playerCards = collectHand(world, InPlayerHand); + ui.playerBox.setContent(formatHand(playerCards, false)); + + // Info + let info = ""; + if (score) { + info += `Chips: ${score.chips} Wins: ${score.wins} Losses: ${score.losses} Pushes: ${score.pushes}\n`; + } + if (bet) { + info += `Bet: ${bet.amount}\n`; + } + if (phase) { + info += `\n${phase.message}`; + } + ui.infoText.setContent(info); + + // Dynamic controls based on phase + if (phase) { + switch (phase.phase) { + case "betting": + ui.controlsText.setContent( + "N : new round ↑↓ : adjust bet Q : quit", + ); + break; + case "playerTurn": + ui.controlsText.setContent("H : hit S : stand Q : quit"); + break; + case "dealerTurn": + ui.controlsText.setContent("Dealer is playing..."); + break; + case "roundOver": + ui.controlsText.setContent( + "N : new round ↑↓ : adjust bet Q : quit", + ); + break; + } + } + + ui.screen.render(); +}