178 lines
7.3 KiB
JavaScript
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 };
|
|
}
|