fertig-classic-games/src/games/nerts/NertsLogic.js

363 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 = [];
}
}