363 lines
14 KiB
JavaScript
363 lines
14 KiB
JavaScript
// 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 = [];
|
||
}
|
||
}
|