// Nerts (Pounce / Racing Demon) — pure rules + state. // // IMPORTANT — state model: unlike the other games in this engine (which return // a brand-new immutable state per action), Nerts is REAL-TIME. Every player — // the human and every AI — fires many small actions per second with no turns. // Deep-cloning the whole state on each action would be wasteful, so this engine // uses a MUTABLE state with pure query helpers and in-place mutators. Mutators // return a small log entry the scene uses to drive animation. // // Rules implemented: // - Each player owns a full 52-card deck. Deal: 13 → Nerts pile (top face-up), // 1 each → 4 work piles (face-up), remaining 34 → stock (face-down draw). // - Work piles build DOWN, alternating color; movable sequences; empty work // pile accepts any card. // - Stock flips 3 at a time to a face-up waste; top of waste is playable; when // the draw empties, the waste recycles (no shuffle) back into the draw. // - Foundations (shared center): start on any Ace, build UP by suit to King. // Any player may play on any foundation. // - A round ends the instant a player empties their Nerts pile. Safeguard: if // every player is genuinely stuck, the round ends and is scored as-is. // - Scoring: +1 per card you put on foundations, -2 per card left in your Nerts // pile. Accumulate across rounds until a player reaches targetScore. import { Deck } from '../cards/Deck.js'; export const NERTS_PILE_SIZE = 13; // Suit required to start each foundation slot (cycles every 4 slots). export const FOUNDATION_SUITS = ['s', 'h', 'd', 'c']; export const WORK_PILE_COUNT = 4; export const STOCK_FLIP = 3; export const DEFAULT_TARGET_SCORE = 100; // Ace-low ranking for Nerts (A=1 … K=13). Note the shared Deck Card uses A=14, // which is wrong for both foundation build-up and work-pile build-down here. const NERTS_RANK = { A: 1, '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9, T: 10, J: 11, Q: 12, K: 13, }; /** Ace-low rank value (1..13) for a card. */ export function rv(card) { return NERTS_RANK[card.rank]; } function isRed(card) { return card.suit === 'h' || card.suit === 'd'; } // ── State construction ──────────────────────────────────────────────────────── export function createInitialState({ playerCount, targetScore = DEFAULT_TARGET_SCORE, totals = null } = {}) { if (playerCount < 2 || playerCount > 4) { throw new Error(`Nerts supports 2..4 players, got ${playerCount}`); } let cardId = 0; const players = []; for (let seat = 0; seat < playerCount; seat++) { const deck = new Deck(); deck.shuffle(); // Tag every card with a unique id (decks repeat rank+suit) and its owner. for (const c of deck.cards) { c.id = cardId++; c.owner = seat; } const nerts = deck.deal(NERTS_PILE_SIZE); const work = []; for (let i = 0; i < WORK_PILE_COUNT; i++) work.push(deck.deal(1)); const stockDraw = deck.cards.splice(0); // remaining 35 (52 - 13 - 4) players.push({ seat, nerts, // last element = face-up top work, // 4 piles, build down alt-color; last = top stockDraw, // face-down; draw from the end stockWaste: [], // face-up; last = playable top roundScore: 0, totalScore: totals ? (totals[seat] ?? 0) : 0, }); } return { phase: 'playing', // 'playing' | 'roundover' | 'matchover' targetScore, // Fixed-length slot array. Each slot is null (empty — accepts an Ace) or a // foundation { suit, cards: [...] }. Max foundations = 4 suits × playerCount. foundations: new Array(4 * playerCount).fill(null), players, nertsCaller: null, // seat that emptied its Nerts pile winner: null, // round/match winner seat, or -1 for draw matchWinner: null, // set when phase === 'matchover' }; } // ── Source helpers ────────────────────────────────────────────────────────── // A `source` is { type:'nerts'|'waste'|'work', idx?, count? }. export function nertsTop(state, seat) { const n = state.players[seat].nerts; return n.length > 0 ? n[n.length - 1] : null; } export function wasteTop(state, seat) { const w = state.players[seat].stockWaste; return w.length > 0 ? w[w.length - 1] : null; } export function workTop(state, seat, wIdx) { const p = state.players[seat].work[wIdx]; return p.length > 0 ? p[p.length - 1] : null; } /** The single card referenced by a source's top (ignores count). */ function sourceTopCard(state, seat, source) { if (source.type === 'nerts') return nertsTop(state, seat); if (source.type === 'waste') return wasteTop(state, seat); if (source.type === 'work') { const pile = state.players[seat].work[source.idx]; const k = source.count ? pile.length - source.count : pile.length - 1; return pile[k] ?? null; // bottom card of the moved run } return null; } // ── Work-pile sequence validation ───────────────────────────────────────────── /** True if pile[k..end] is a descending, alternating-color run. */ export function validRunFromIndex(pile, k) { for (let i = k; i < pile.length - 1; i++) { const a = pile[i], b = pile[i + 1]; if (rv(b) !== rv(a) - 1) return false; if (isRed(a) === isRed(b)) return false; } return true; } /** Smallest index whose tail forms a valid movable run (top single always qualifies). */ export function maxRunStart(pile) { if (pile.length === 0) return 0; let k = pile.length - 1; while (k > 0) { const a = pile[k - 1], b = pile[k]; if (rv(b) === rv(a) - 1 && isRed(a) !== isRed(b)) k--; else break; } return k; } // ── Legality ────────────────────────────────────────────────────────────────── export function foundationTopRank(slot) { return slot.cards.length > 0 ? rv(slot.cards[slot.cards.length - 1]) : 0; } /** Can `card` (a single card) go onto foundation slot `fIdx`? */ export function canPlayOnFoundation(state, card, fIdx) { if (!card) return false; const slot = state.foundations[fIdx]; if (!slot) return rv(card) === 1 && card.suit === FOUNDATION_SUITS[fIdx % 4]; // Ace of required suit if (card.suit !== slot.suit) return false; const top = foundationTopRank(slot); if (top >= 13) return false; // completed return rv(card) === top + 1; } /** Can `card` (the bottom of a moved run) land on work pile `wIdx`? */ export function canPlayOnWork(state, seat, card, wIdx) { if (!card) return false; const pile = state.players[seat].work[wIdx]; if (pile.length === 0) return true; // empty accepts anything const top = pile[pile.length - 1]; return rv(card) === rv(top) - 1 && isRed(card) !== isRed(top); } /** * Enumerate legal moves for `seat`. * Returns { kind:'foundation'|'work', source, dest, card } entries. * source: { type, idx?, count? } * dest: foundation index (kind 'foundation') or work index (kind 'work') */ export function getValidPlays(state, seat) { if (state.phase !== 'playing') return []; const plays = []; const p = state.players[seat]; // Single-card sources that can hit foundations or work piles. const singleSources = []; const nt = nertsTop(state, seat); if (nt) singleSources.push({ source: { type: 'nerts' }, card: nt }); const wt = wasteTop(state, seat); if (wt) singleSources.push({ source: { type: 'waste' }, card: wt }); for (let w = 0; w < WORK_PILE_COUNT; w++) { const t = workTop(state, seat, w); if (t) singleSources.push({ source: { type: 'work', idx: w, count: 1 }, card: t }); } for (const { source, card } of singleSources) { for (let f = 0; f < state.foundations.length; f++) { if (canPlayOnFoundation(state, card, f)) { plays.push({ kind: 'foundation', source, dest: f, card }); } } for (let w = 0; w < WORK_PILE_COUNT; w++) { if (source.type === 'work' && source.idx === w) continue; if (canPlayOnWork(state, seat, card, w)) { plays.push({ kind: 'work', source, dest: w, card }); } } } // Work → work sequence moves (more than the single top card). for (let s = 0; s < WORK_PILE_COUNT; s++) { const pile = p.work[s]; const start = maxRunStart(pile); for (let k = start; k < pile.length - 1; k++) { // k = end is the single, handled above const bottom = pile[k]; const count = pile.length - k; for (let d = 0; d < WORK_PILE_COUNT; d++) { if (d === s) continue; if (canPlayOnWork(state, seat, bottom, d)) { plays.push({ kind: 'work', source: { type: 'work', idx: s, count }, dest: d, card: bottom }); } } } } return plays; } export function canFlipStock(state, seat) { const p = state.players[seat]; return p.stockDraw.length > 0 || p.stockWaste.length > 0; } /** A seat is stuck only when it has no legal play AND cannot flip its stock. */ export function isSeatStuck(state, seat) { if (canFlipStock(state, seat)) return false; return getValidPlays(state, seat).length === 0; } export function allStuck(state) { return state.players.every((_, seat) => isSeatStuck(state, seat)); } // ── Mutators (mutate in place, return a log entry) ────────────────────────────── function removeSourceCards(state, seat, source) { const p = state.players[seat]; if (source.type === 'nerts') return [p.nerts.pop()]; if (source.type === 'waste') return [p.stockWaste.pop()]; if (source.type === 'work') { const pile = p.work[source.idx]; const count = source.count ?? 1; return pile.splice(pile.length - count, count); } return []; } /** Play the single top card of `source` onto foundation `fIdx`. */ export function playToFoundation(state, seat, source, fIdx) { const card = sourceTopCard(state, seat, source); if (!canPlayOnFoundation(state, card, fIdx)) return null; const [moved] = removeSourceCards(state, seat, source); if (!state.foundations[fIdx]) { state.foundations[fIdx] = { suit: moved.suit, cards: [moved] }; } else { state.foundations[fIdx].cards.push(moved); } const log = { type: 'foundation', seat, source, fIdx, card: moved }; checkNertsCall(state, seat); return log; } /** Move `count` cards (a valid run, or a single) from `source` onto work pile `dstIdx`. */ export function playToWork(state, seat, source, dstIdx) { const bottom = sourceTopCard(state, seat, source); if (!canPlayOnWork(state, seat, bottom, dstIdx)) return null; if (source.type === 'work' && source.idx === dstIdx) return null; const moved = removeSourceCards(state, seat, source); state.players[seat].work[dstIdx].push(...moved); const log = { type: 'work', seat, source, dstIdx, cards: moved }; checkNertsCall(state, seat); return log; } /** Flip up to STOCK_FLIP cards from draw to waste. * If draw is empty, recycles waste back to draw and returns without dealing — * the player must click again to actually draw the next three cards. */ export function flipStock(state, seat) { const p = state.players[seat]; if (p.stockDraw.length === 0) { if (p.stockWaste.length === 0) return null; p.stockDraw = p.stockWaste.reverse(); p.stockWaste = []; return { type: 'recycle', seat }; } const n = Math.min(STOCK_FLIP, p.stockDraw.length); for (let i = 0; i < n; i++) p.stockWaste.push(p.stockDraw.pop()); return { type: 'flip', seat, n }; } function checkNertsCall(state, seat) { if (state.players[seat].nerts.length === 0 && state.nertsCaller === null) { state.nertsCaller = seat; } } // ── Scoring / round end ───────────────────────────────────────────────────────── /** * Score the round: +1 per foundation card by owner, -2 per remaining Nerts card. * Updates each player's roundScore/totalScore, sets winner + phase. Returns a * per-seat summary array. */ export function endRound(state) { const foundationByOwner = new Array(state.players.length).fill(0); for (const slot of state.foundations) { if (!slot) continue; for (const card of slot.cards) foundationByOwner[card.owner] += 1; } const summary = state.players.map((p) => { const founded = foundationByOwner[p.seat]; const nertsLeft = p.nerts.length; p.roundScore = founded - 2 * nertsLeft; p.totalScore += p.roundScore; return { seat: p.seat, founded, nertsLeft, roundScore: p.roundScore, totalScore: p.totalScore }; }); // Round winner: the Nerts caller, else highest round score (deadlock case). if (state.nertsCaller !== null) { state.winner = state.nertsCaller; } else { let best = -1, bestScore = -Infinity; for (const p of state.players) { if (p.roundScore > bestScore) { bestScore = p.roundScore; best = p.seat; } } state.winner = best; } // Match over if anyone has reached the target. const maxTotal = Math.max(...state.players.map((p) => p.totalScore)); if (maxTotal >= state.targetScore) { state.phase = 'matchover'; const leaders = state.players.filter((p) => p.totalScore === maxTotal); state.matchWinner = leaders.length === 1 ? leaders[0].seat : -1; } else { state.phase = 'roundover'; } return summary; } export function reshuffleAllStocks(state) { for (const p of state.players) { const combined = [...p.stockDraw, ...p.stockWaste]; for (let i = combined.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [combined[i], combined[j]] = [combined[j], combined[i]]; } p.stockDraw = combined; p.stockWaste = []; } }