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
257 lines
7.6 KiB
|
4 days ago
|
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,
|
||
|
|
};
|
||
|
|
},
|
||
|
|
};
|