fertig-classic-games/public/src/games/phase10/PhaseSpec.js

178 lines
7.3 KiB
JavaScript

// Phase 10 — declarative spec for the 10 phases plus a laydown validator.
//
// A "group" is one of:
// { kind: 'set', count: N } N cards of the same value
// { kind: 'run', count: N } N consecutive numbers (no wrap)
// { kind: 'color', count: N } N cards of one color
//
// Wilds substitute for any card in a group, but the entire laydown must
// contain at least one natural (non-wild) card to be legal.
export const PHASES = [
/* phase 1 */ { num: 1, short: '2 sets of 3', groups: [{ kind: 'set', count: 3 }, { kind: 'set', count: 3 }] },
/* phase 2 */ { num: 2, short: '1 set of 3 + 1 run of 4', groups: [{ kind: 'set', count: 3 }, { kind: 'run', count: 4 }] },
/* phase 3 */ { num: 3, short: '1 set of 4 + 1 run of 4', groups: [{ kind: 'set', count: 4 }, { kind: 'run', count: 4 }] },
/* phase 4 */ { num: 4, short: '1 run of 7', groups: [{ kind: 'run', count: 7 }] },
/* phase 5 */ { num: 5, short: '1 run of 8', groups: [{ kind: 'run', count: 8 }] },
/* phase 6 */ { num: 6, short: '1 run of 9', groups: [{ kind: 'run', count: 9 }] },
/* phase 7 */ { num: 7, short: '2 sets of 4', groups: [{ kind: 'set', count: 4 }, { kind: 'set', count: 4 }] },
/* phase 8 */ { num: 8, short: '7 cards of one color', groups: [{ kind: 'color', count: 7 }] },
/* phase 9 */ { num: 9, short: '1 set of 5 + 1 set of 2', groups: [{ kind: 'set', count: 5 }, { kind: 'set', count: 2 }] },
/* phase 10 */ { num: 10, short: '1 set of 5 + 1 set of 3', groups: [{ kind: 'set', count: 5 }, { kind: 'set', count: 3 }] },
];
export const COLORS_LIST = ['red', 'blue', 'yellow', 'green'];
export function getPhase(num) {
return PHASES[num - 1] ?? null;
}
// ── Validation ──────────────────────────────────────────────────────────────
/**
* Validate a laydown attempt for a given phase.
* phaseNum: 1..10
* groups: array of { kind, cards: [card,...] } — must match phase spec order
* OR any order — we accept the proposed kind for each group and
* check the multiset matches the phase requirement.
*
* Returns { ok: true } or { ok: false, reason }.
*/
export function validateLaydown(phaseNum, groups) {
const spec = getPhase(phaseNum);
if (!spec) return { ok: false, reason: `Unknown phase ${phaseNum}` };
if (!Array.isArray(groups) || groups.length !== spec.groups.length) {
return { ok: false, reason: `Phase ${phaseNum} needs ${spec.groups.length} group(s)` };
}
// Match groups by kind+count multiset — order in the player's laydown
// doesn't matter. We greedily pair each player group with an unused spec.
const usedSpec = new Array(spec.groups.length).fill(false);
for (const g of groups) {
let matchedAt = -1;
for (let i = 0; i < spec.groups.length; i++) {
if (usedSpec[i]) continue;
const s = spec.groups[i];
if (s.kind === g.kind && s.count === g.cards.length) {
matchedAt = i;
break;
}
}
if (matchedAt === -1) {
return { ok: false, reason: `Phase ${phaseNum} doesn't accept a ${g.kind} of ${g.cards.length}` };
}
usedSpec[matchedAt] = true;
}
// Cards must be non-empty and only wilds/numbers/skips? Skips never count.
let anyNatural = false;
for (const g of groups) {
for (const c of g.cards) {
if (c.value === 'skip') {
return { ok: false, reason: 'Skip cards cannot be laid down' };
}
if (c.value !== 'wild') anyNatural = true;
}
}
if (!anyNatural) {
return { ok: false, reason: 'Laydown must contain at least one natural card' };
}
// Per-group structural rules
for (const g of groups) {
const r = validateGroup(g);
if (!r.ok) return r;
}
return { ok: true };
}
export function validateGroup(g) {
if (!g || !Array.isArray(g.cards) || g.cards.length === 0) {
return { ok: false, reason: 'Empty group' };
}
if (g.kind === 'set') return validateSet(g.cards);
if (g.kind === 'run') return validateRun(g.cards);
if (g.kind === 'color') return validateColor(g.cards);
return { ok: false, reason: `Unknown group kind: ${g.kind}` };
}
function validateSet(cards) {
// All non-wild cards must share the same value.
let v = null;
for (const c of cards) {
if (c.value === 'wild') continue;
if (c.value === 'skip') return { ok: false, reason: 'Set cannot contain skips' };
if (v === null) v = c.value;
else if (c.value !== v) return { ok: false, reason: 'Set values must match' };
}
return { ok: true };
}
function validateColor(cards) {
// All non-wild cards must share the same color.
let col = null;
for (const c of cards) {
if (c.value === 'wild') continue;
if (c.value === 'skip') return { ok: false, reason: 'Color group cannot contain skips' };
if (!c.color) return { ok: false, reason: 'Color group needs colored cards' };
if (col === null) col = c.color;
else if (c.color !== col) return { ok: false, reason: 'Color group colors must match' };
}
return { ok: true };
}
/**
* A run of length N must be assignable consecutive values such that each
* position is either a natural matching the assigned value or a wild.
* We greedily fit the natural cards: sort naturals ascending, then check
* that there's a starting value S where each natural's value fits in
* [S, S+N-1] without collision; gaps are filled by wilds.
*/
function validateRun(cards) {
const naturals = cards.filter((c) => c.value !== 'wild').map((c) => c.value);
for (const n of naturals) if (n === 'skip') return { ok: false, reason: 'Run cannot contain skips' };
const wildCount = cards.length - naturals.length;
const N = cards.length;
if (naturals.length === 0) {
// Validated elsewhere: at least one natural across the whole laydown.
// A single all-wild run by itself is structurally fine (only the
// global "needs one natural" rule catches all-wild laydowns).
return { ok: true };
}
naturals.sort((a, b) => a - b);
// Duplicates among naturals are illegal in a run.
for (let i = 1; i < naturals.length; i++) {
if (naturals[i] === naturals[i - 1]) {
return { ok: false, reason: 'Run cannot have duplicate values' };
}
}
// Spread between min and max must be < N (so they all fit in a window of N).
const span = naturals[naturals.length - 1] - naturals[0] + 1;
if (span > N) return { ok: false, reason: 'Run values too spread out' };
// All naturals must be in [1,12].
for (const n of naturals) {
if (typeof n !== 'number' || n < 1 || n > 12) {
return { ok: false, reason: 'Run values must be 1..12' };
}
}
// Determine if there's a starting value S with naturals[0] - k = S and
// S + N - 1 ≤ 12 and S ≥ 1.
// The window has length N starting at S; the leftmost natural can sit at
// any of positions 0..(N-span), so S can range from
// max(1, naturals[max]-N+1) to min(naturals[min], 13-N)
const minS = Math.max(1, naturals[naturals.length - 1] - N + 1);
const maxS = Math.min(naturals[0], 13 - N);
if (minS > maxS) return { ok: false, reason: 'Run does not fit within 1..12' };
// Wild count must equal (N - naturals.length) — already true by construction.
void wildCount;
return { ok: true };
}