// 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 }; }