You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

257 lines
7.6 KiB

import { randomInt } from 'node:crypto';
import { parchisColorOrder } from '../../shared/player-colors.mjs';
import { nextPlayingSeat, withdrawParchis } from './turns.mjs';
/** @typedef {{starts?:number[],pieces:number[][],turn:number,die:number|null,rolled:number|null,bonus:boolean,extraTurn:boolean,sixes:number,lastMoved:number|null,arrivals:number[][],clock:number}} ParchisState */
export const starts = [4, 21, 38, 55];
const OUTER = 65,
GOAL = 72;
const safe = new Set([
0, 4, 11, 16, 17, 21, 28, 33, 34, 38, 45, 50, 51, 55, 62, 67,
]);
const square = (state, seat, progress) =>
((state.starts ?? starts)[seat] + progress) % 68;
const occupants = (state, cell) =>
state.pieces.flatMap((pieces, seat) =>
pieces.flatMap((p, piece) =>
p >= 0 && p < OUTER && square(state, seat, p) === cell
? [{ seat, piece }]
: [],
),
);
const barrier = (group) =>
group.length === 2 && group[0].seat === group[1].seat;
function canMove(state, seat, piece) {
const p = state.pieces[seat][piece],
distance = state.die;
if (
!distance ||
p === GOAL ||
(p === -1 && (distance !== 5 || state.bonus)) ||
p + distance > GOAL
)
return false;
const target = p === -1 ? 0 : p + distance;
if (p !== -1)
for (let step = p + 1; step <= target; step++) {
if (step < OUTER && barrier(occupants(state, square(state, seat, step))))
return false;
if (
step >= OUTER &&
step < GOAL &&
state.pieces[seat].filter((v) => v === step).length >= 2
)
return false;
}
if (target < OUTER) {
const group = occupants(state, square(state, seat, target));
if (p === -1 && group.length === 2 && group.some((v) => v.seat !== seat))
return true;
if (group.length >= 2) return false;
} else if (
target < GOAL &&
state.pieces[seat].filter((v) => v === target).length >= 2
)
return false;
return true;
}
function moves(state, seat) {
let choices = state.pieces[seat].flatMap((_, piece) =>
canMove(state, seat, piece) ? [{ type: 'move', piece }] : [],
);
if (
!state.bonus &&
state.die === 5 &&
choices.some((a) => state.pieces[seat][a.piece] === -1)
)
return choices.filter((a) => state.pieces[seat][a.piece] === -1);
if (!state.bonus && state.rolled === 6) {
const opening = choices.filter((a) => {
const p = state.pieces[seat][a.piece];
return (
p >= 0 &&
p < GOAL &&
(p < OUTER
? barrier(occupants(state, square(state, seat, p)))
: state.pieces[seat].filter((v) => v === p).length === 2)
);
});
if (opening.length) choices = opening;
}
return choices;
}
function endTurn(state, seat) {
state.die = null;
state.bonus = false;
if (!state.extraTurn) {
state.turn = nextPlayingSeat(state, seat);
state.sixes = 0;
state.lastMoved = null;
}
state.extraTurn = false;
}
function settle(state, seat) {
if (state.die !== null && !moves(state, seat).length) {
const bonus = state.bonus;
endTurn(state, seat);
return bonus ? ' · premio sin movimiento posible' : ' · sin movimientos';
}
return '';
}
export const parchis = {
withdraw: withdrawParchis,
id: 'parchis',
version: '2.0.0',
minPlayers: 2,
maxPlayers: 4,
/** @returns {ParchisState} */
create(players = 4, colors = []) {
const selected = Array.from(
{ length: players },
(_, seat) =>
starts[
parchisColorOrder.indexOf(colors[seat] ?? parchisColorOrder[seat])
],
);
if (
selected.some((s) => s === undefined) ||
new Set(selected).size !== players
)
throw new Error('Los colores del parchís deben ser distintos.');
return {
starts: selected,
pieces: Array.from({ length: players }, () => [-1, -1, -1, -1]),
turn: 0,
die: null,
rolled: null,
bonus: false,
extraTurn: false,
sixes: 0,
lastMoved: null,
arrivals: Array.from({ length: players }, () => [0, 0, 0, 0]),
clock: 0,
};
},
/** @returns {{type:string,piece?:number}[]} */
legal(state, seat) {
if (seat !== state.turn) return [];
if (state.die === null) return [{ type: 'roll' }];
const choices = moves(state, seat);
return choices.length ? choices : [{ type: 'pass' }];
},
apply(state, action, seat, rng = () => randomInt(1, 7)) {
if (
!this.legal(state, seat).some(
(a) =>
a.type === action.type &&
(a.type !== 'move' || a.piece === action.piece),
)
)
throw new Error('Esa acción no está disponible.');
const next = structuredClone(state);
if (action.type === 'roll') {
const die = rng();
if (!Number.isInteger(die) || die < 1 || die > 6)
throw new Error('Dado no válido.');
next.rolled = die;
next.sixes = die === 6 ? (state.sixes ?? 0) + 1 : 0;
if (next.sixes === 3) {
const last = next.lastMoved;
if (
last !== null &&
last !== undefined &&
next.pieces[seat][last] >= 0 &&
next.pieces[seat][last] < OUTER
)
next.pieces[seat][last] = -1;
next.extraTurn = false;
endTurn(next, seat);
return {
state: next,
label:
'Tercer seis: termina el turno; la última ficha del recorrido vuelve a casa',
result: null,
sound: 'pass',
};
}
next.die = die === 6 && !next.pieces[seat].includes(-1) ? 7 : die;
next.extraTurn = die === 6;
const skipped = settle(next, seat);
return {
state: next,
label: `Dado: ${die}${next.die === 7 ? ' · avanza 7' : ''}${skipped}`,
result: null,
sound: 'roll',
};
}
if (action.type === 'pass') {
endTurn(next, seat);
return {
state: next,
label: 'Sin movimientos: turno resuelto',
result: null,
sound: 'pass',
};
}
const piece = action.piece,
p = state.pieces[seat][piece],
target = p === -1 ? 0 : p + state.die;
let reward = null,
label = `Ficha ${piece + 1}: ${p === -1 ? 'sale de casa' : `avanza ${state.die}`}`;
const group =
target < OUTER ? occupants(state, square(state, seat, target)) : [];
let victims = group.filter((v) => v.seat !== seat);
if (p === -1 && group.length === 2)
victims = victims
.sort(
(a, b) =>
(state.arrivals?.[b.seat]?.[b.piece] ?? 0) -
(state.arrivals?.[a.seat]?.[a.piece] ?? 0),
)
.slice(0, 1);
else if (target >= OUTER || safe.has(square(state, seat, target)))
victims = [];
for (const victim of victims) {
next.pieces[victim.seat][victim.piece] = -1;
reward = 20;
label += ' · captura';
}
next.pieces[seat][piece] = target;
next.lastMoved = piece;
next.clock = (next.clock ?? 0) + 1;
next.arrivals[seat][piece] = next.clock;
if (target === GOAL) {
reward = 10;
label += ' · llega a meta';
}
if (next.pieces[seat].every((v) => v === GOAL))
return {
state: next,
label,
result: { winner: seat, reason: 'cuatro fichas en meta' },
sound: 'finish',
};
if (reward !== null) {
next.die = reward;
next.bonus = true;
label += ` · premio de ${reward}`;
label += settle(next, seat);
} else endTurn(next, seat);
return {
state: next,
label,
result: null,
sound: reward === 20 ? 'capture' : reward === 10 ? 'goal' : 'move',
};
},
view(state) {
return {
...state,
starts: state.starts ?? starts,
safe: [...safe],
goal: GOAL,
outerLength: OUTER,
};
},
};

Powered by TurnKey Linux.