plugins/hushle/src/actions.ts
Rama main · réplica de lectura del repositorio original.
/**
* Hushle action reducer.
*
* The host moderates the game (`role: 'host'` in `actionPolicies`): the
* host decides when a card is correct / skipped / penalised, when the turn
* ends, and when the game ends. The one player action is `bust-forbidden`,
* the other team's buzzer. The reducer is pure apart from reading the
* clock, and defensive: an action that does not fit the phase returns the
* state unchanged.
*
* Turns: teams play in seat order. Each team has its own explainer
* rotation (see `rotation.ts`) — its players in turn, plus the floater's
* slot when the player count is odd — so every player explains in turn.
*
* Timer model: a turn has ONE clock. The turn's start sets
* `timer.startedAt` and the deadline `timer.endsAt`; scoring a card does
* not restart it, so the explaining team has the whole duration for as
* many cards as they manage. Once the deadline (plus a short grace for a
* last-second tap in flight) has passed, the turn's scoring is over: the
* reducer refuses further cards until the host starts the next turn.
* There is no per-second tick — every client counts down to `endsAt`.
*
* Card draw model (M20a): the reducer samples a difficulty tier from
* `settings.difficultyDistribution`, then draws the next unused card
* from that tier's bucket. If that tier is exhausted, falls back to
* any unused card from any tier.
*
* Stale intent (v4): every draw bumps `cardSeq`. The host's card moves
* (Got it, Skip, Penalty, Next card) carry the `cardSeq` their button was
* drawn for; one that names an earlier deal is ignored — the same object
* comes back, like a BUST for a card that is gone. Without a `cardSeq`
* (a panel from before v4) the move applies to the card in play. The
* turn moves (`start-turn`, `end-turn`) carry `turnNumber` the same way,
* so a double tap starts or ends one turn, not two.
*/
import { getDefaultDeck, getLanguageForPackSlug } from './decks';
import { secureRandom } from './random';
import { cursorAfter, explainerQueue, nextExplainer } from './rotation';
import type {
HushleAction,
HushleCard,
HushleDifficulty,
HushleLanguage,
HushleSettings,
HushleState,
HushleTeam,
HushleTimer,
} from './state';
import {
HUSHLE_DEFAULT_DIFFICULTY_DISTRIBUTION,
HUSHLE_DEFAULT_TEAM_SIZE,
HUSHLE_DEFAULT_TURN_DURATION_SECONDS,
HUSHLE_TIME_UP_GRACE_MS,
} from './state';
function nowMs(): number {
return Date.now();
}
function makeTeamId(): string {
return `team-${Math.floor(secureRandom() * 36 ** 8).toString(36).padStart(8, '0')}`;
}
/** A stopped clock: between turns, before play, after the game. */
function stoppedTimer(settings: HushleSettings): HushleTimer {
return { startedAt: null, durationSeconds: settings.turnDurationSeconds, paused: true, endsAt: null };
}
/**
* True once the running turn's time is up: its deadline plus the grace
* for a tap already on its way has passed.
*/
function turnTimeIsUp(state: HushleState, now: number): boolean {
if (!state.timer.startedAt || !state.timer.endsAt) return false;
const deadline = Date.parse(state.timer.endsAt);
return Number.isFinite(deadline) && now > deadline + HUSHLE_TIME_UP_GRACE_MS;
}
/**
* M20a — pick a difficulty tier by sampling the configured
* distribution. Tiers with zero weight are skipped; if every tier has zero
* weight we fall back to `'easy'` so the reducer never fails to draw.
*/
function pickDifficultyTier(
distribution: Record<HushleDifficulty, number>,
rng: () => number = secureRandom
): HushleDifficulty {
const order: HushleDifficulty[] = ['easy', 'medium', 'hard'];
let total = 0;
for (const tier of order) {
const w = distribution[tier];
if (typeof w === 'number' && w > 0) total += w;
}
if (total <= 0) return 'easy';
const r = rng() * total;
let cursor = 0;
for (const tier of order) {
const w = distribution[tier];
if (typeof w !== 'number' || w <= 0) continue;
cursor += w;
if (r < cursor) return tier;
}
return 'easy';
}
/**
* M20a — sample a card respecting `usedCardIds` and the difficulty
* tier. Returns null when the deck is fully exhausted.
*/
function drawNextCardWeighted(
deck: HushleCard[],
usedCardIds: string[],
distribution: Record<HushleDifficulty, number>,
rng: () => number = secureRandom
): HushleCard | null {
if (deck.length === 0) return null;
if (usedCardIds.length >= deck.length) return null;
const used = new Set(usedCardIds);
const tier = pickDifficultyTier(distribution, rng);
const tierMatches = deck.filter((c) => c.difficulty === tier && !used.has(c.id));
if (tierMatches.length > 0) {
return tierMatches[Math.floor(rng() * tierMatches.length)] ?? null;
}
// Fallback: any unused card.
const unused = deck.filter((c) => !used.has(c.id));
if (unused.length > 0) {
return unused[Math.floor(rng() * unused.length)] ?? null;
}
return null;
}
/**
* The deal in play; a row from before v4 that skipped migration counts as 0
* (and so does no state at all: `start-game` may be the first action).
*/
function cardSeqOf(state: HushleState | undefined): number {
const seq = state?.cardSeq;
return typeof seq === 'number' && Number.isSafeInteger(seq) && seq >= 0 ? seq : 0;
}
/**
* True when a host's card move names a deal that is no longer in play. A
* move without a `cardSeq` names none, and is never stale.
*/
function isStaleCardMove(state: HushleState, action: { cardSeq?: number | null }): boolean {
return action.cardSeq != null && action.cardSeq !== cardSeqOf(state);
}
/**
* True when a host's turn move names a turn that is no longer current: the
* second of two presses of End turn (or Start next turn) arrives after the
* first already moved the game on. A move without a `turnNumber` is never
* stale.
*/
function isStaleTurnMove(state: HushleState, action: { turnNumber?: number | null }): boolean {
if (action.turnNumber == null) return false;
const current = state.turnNumber;
const turn = typeof current === 'number' && Number.isSafeInteger(current) && current >= 0 ? current : 0;
return action.turnNumber !== turn;
}
function findTeam(state: HushleState, teamId: string): HushleTeam | null {
return state.teams.find((t) => t.id === teamId) ?? null;
}
function nextTeamIndex(state: HushleState): number {
if (state.teams.length === 0) return -1;
const idx = state.teams.findIndex((t) => t.id === state.currentTeamId);
if (idx === -1) return 0;
return (idx + 1) % state.teams.length;
}
/** The teams with one team's rotation cursor moved, when there is somewhere to move it. */
function withCursor(teams: HushleTeam[], teamIndex: number, cursor: number | null): HushleTeam[] {
if (cursor === null) return teams;
return teams.map((team, i) => (i === teamIndex ? { ...team, nextExplainerSlot: cursor } : team));
}
/**
* Start a turn for `team`. The explainer is the one the host named, or
* the next in the team's rotation; either way the team's rotation then
* continues after them. The turn's clock starts now.
*/
function startTurn(state: HushleState, team: HushleTeam, requestedExplainer: string | null): HushleState {
const teamIndex = state.teams.findIndex((t) => t.id === team.id);
const explainer = requestedExplainer ?? nextExplainer(state, teamIndex).explainerId;
const teams = withCursor(state.teams, teamIndex, cursorAfter(state, teamIndex, explainer));
const card = drawNextCardWeighted(
state.deck,
state.usedCardIds,
state.settings.difficultyDistribution
);
const usedCardIds = card ? [...state.usedCardIds, card.id] : state.usedCardIds;
const start = nowMs();
const duration = state.settings.turnDurationSeconds;
return {
...state,
teams,
phase: 'playing',
turnNumber: state.turnNumber + 1,
currentTeamId: team.id,
currentExplainerId: explainer,
currentCard: card,
cardSeq: cardSeqOf(state) + 1,
usedCardIds,
cardsPlayedThisTurn: 0,
timer: {
startedAt: new Date(start).toISOString(),
durationSeconds: duration,
paused: false,
endsAt: new Date(start + duration * 1000).toISOString(),
},
};
}
function applyCorrectPassPenalty(
state: HushleState,
kind: 'correct' | 'pass' | 'penalty'
): HushleState {
if (state.phase !== 'playing') return state;
if (!state.currentTeamId) return state;
// Between turns there is no card to score.
if (!state.timer.startedAt) return state;
// Nor in a turn that started on an empty deck: nothing was dealt, so
// nothing can be guessed, skipped or penalised.
if (!state.currentCard) return state;
// Time's up: the turn's scoring is over.
if (turnTimeIsUp(state, nowMs())) return state;
const team = findTeam(state, state.currentTeamId);
if (!team) return state;
const scoreDelta = kind === 'correct' ? 1 : kind === 'penalty' ? -1 : 0;
const updatedTeams: HushleTeam[] = state.teams.map((t) => {
if (t.id !== team.id) return t;
return {
...t,
score: t.score + scoreDelta,
correctCount: kind === 'correct' ? t.correctCount + 1 : t.correctCount,
passCount: kind === 'pass' ? t.passCount + 1 : t.passCount,
penaltyCount: kind === 'penalty' ? t.penaltyCount + 1 : t.penaltyCount,
};
});
const card = drawNextCardWeighted(
state.deck,
state.usedCardIds,
state.settings.difficultyDistribution
);
const usedCardIds = card ? [...state.usedCardIds, card.id] : state.usedCardIds;
const cardsPlayedThisTurn = state.cardsPlayedThisTurn + 1;
// The card in play is settled: whatever comes next is a new deal.
const cardSeq = cardSeqOf(state) + 1;
// If we've hit the per-turn cap or the deck is exhausted, the turn
// ends and the next team takes over.
const deckExhausted = card === null;
const turnExhausted = cardsPlayedThisTurn >= state.settings.cardsPerTurn || deckExhausted;
if (turnExhausted) {
const nextIdx = nextTeamIndex({ ...state, teams: updatedTeams });
const nextTeam = nextIdx >= 0 ? updatedTeams[nextIdx] ?? null : null;
if (!nextTeam) {
return {
...state,
teams: updatedTeams,
currentCard: null,
cardSeq,
usedCardIds,
totalCardsPlayed: state.totalCardsPlayed + 1,
phase: 'ended',
timer: stoppedTimer(state.settings),
};
}
// Between turns: the card and the clock are cleared but the phase
// stays `playing`; `end-turn` starts the next team's turn.
return {
...state,
teams: updatedTeams,
currentCard: null,
cardSeq,
usedCardIds,
cardsPlayedThisTurn: 0,
totalCardsPlayed: state.totalCardsPlayed + 1,
timer: stoppedTimer(state.settings),
};
}
// The turn goes on with the next card — on the same clock.
return {
...state,
teams: updatedTeams,
currentCard: card,
cardSeq,
usedCardIds,
cardsPlayedThisTurn,
totalCardsPlayed: state.totalCardsPlayed + 1,
};
}
/** Longest card id accepted on the wire (built-in ids are short slugs; DB ids are UUIDs). */
const CARD_ID_MAX_LENGTH = 128;
/** The host's card moves, which may name the deal they are for (`cardSeq`). */
const CARD_MOVES = new Set(['next-card', 'correct-guess', 'pass', 'penalty']);
/** The host's turn moves, which may name the turn they are for (`turnNumber`). */
const TURN_MOVES = new Set(['start-turn', 'end-turn', 'set-explainer']);
/** A key a stale-intent check compares: absent (or null), or a non-negative integer. */
function isIntentKeyProblem(value: unknown): boolean {
return value != null && (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0);
}
/**
* The host's outer belt (it answers 400 with the message). Only the fields
* a client can get wrong in a way the reducer would not simply ignore are
* checked here; the reducer stays defensive on everything.
*/
export function validateHushleAction(action: unknown): string | null {
if (!action || typeof action !== 'object' || Array.isArray(action)) return 'Action must be an object.';
const record = action as Record<string, unknown>;
if (record.type === 'bust-forbidden') {
const cardId = record.cardId;
if (typeof cardId !== 'string' || cardId.length === 0 || cardId.length > CARD_ID_MAX_LENGTH) {
return 'bust-forbidden requires the cardId of the card on screen.';
}
}
if (typeof record.type === 'string' && CARD_MOVES.has(record.type) && isIntentKeyProblem(record.cardSeq)) {
return `${record.type}: cardSeq must be a non-negative integer when present.`;
}
if (typeof record.type === 'string' && TURN_MOVES.has(record.type) && isIntentKeyProblem(record.turnNumber)) {
return `${record.type}: turnNumber must be a non-negative integer when present.`;
}
return null;
}
export function hushleReducer(state: HushleState, action: HushleAction): HushleState {
switch (action.type) {
case 'start-game': {
// Resolve language from the packId slug: packId wins when its
// built-in language is known, otherwise we fall back to language.
const fromSlug = getLanguageForPackSlug(action.packId);
const language: HushleLanguage = fromSlug ?? action.language ?? 'en';
const requestedTeamSize =
typeof action.teamSize === 'number' && action.teamSize > 0
? Math.min(Math.floor(action.teamSize), 16)
: HUSHLE_DEFAULT_TEAM_SIZE;
// Normalize the difficulty distribution: reject negative weights,
// fill missing tiers with 0, renormalize so it sums to 1. If
// every tier is zero we keep the default distribution so the
// game still draws cards.
const requestedDistribution = action.difficultyDistribution ?? {};
const distribution: Record<HushleDifficulty, number> = {
easy: Math.max(0, requestedDistribution.easy ?? 0),
medium: Math.max(0, requestedDistribution.medium ?? 0),
hard: Math.max(0, requestedDistribution.hard ?? 0),
};
const sum = distribution.easy + distribution.medium + distribution.hard;
const normalizedDistribution =
sum > 0
? {
easy: distribution.easy / sum,
medium: distribution.medium / sum,
hard: distribution.hard / sum,
}
: { ...HUSHLE_DEFAULT_DIFFICULTY_DISTRIBUTION };
const settings: HushleSettings = {
turnDurationSeconds:
typeof action.turnDurationSeconds === 'number' && action.turnDurationSeconds > 0
? Math.min(action.turnDurationSeconds, 300)
: HUSHLE_DEFAULT_TURN_DURATION_SECONDS,
cardsPerTurn:
typeof action.cardsPerTurn === 'number' && action.cardsPerTurn > 0
? Math.min(Math.floor(action.cardsPerTurn), 100)
: state.settings.cardsPerTurn,
language,
packId: action.packId,
teamSize: requestedTeamSize,
difficultyDistribution: normalizedDistribution,
};
const deck = action.deck && action.deck.length > 0
? action.deck.map((card) => ({ ...card, forbiddenWords: [...card.forbiddenWords] }))
: getDefaultDeck(language);
return {
...state,
phase: 'team_setup',
teams: [],
floaterPlayerId: null,
turnNumber: 0,
currentTeamId: null,
currentExplainerId: null,
currentCard: null,
deck,
deckIndex: 0,
usedCardIds: [],
settings,
timer: stoppedTimer(settings),
cardsPlayedThisTurn: 0,
totalCardsPlayed: 0,
// Not reset: a late press from the last game must not match a deal
// of this one.
cardSeq: cardSeqOf(state),
createdBy: action.createdBy,
createdAt: new Date(nowMs()).toISOString(),
};
}
case 'set-teams': {
if (state.phase !== 'lobby' && state.phase !== 'team_setup') return state;
const teamSize = state.settings.teamSize;
// Validate the floater against the un-trimmed input. We trim
// teams to `teamSize` *after* the floater check, otherwise
// trimming drops the floater off a team first and we'd
// validate against a falsified player list.
const floater = action.floaterPlayerId ?? null;
const allRequestedPlayers = new Set<string>();
for (const t of action.teams) {
for (const id of t.playerIds) {
// A player on two teams would see every card while guessing:
// refuse the whole seating rather than guess which team was meant.
if (allRequestedPlayers.has(id)) return state;
allRequestedPlayers.add(id);
}
}
const validatedFloater = floater && !allRequestedPlayers.has(floater) ? floater : null;
const teams: HushleTeam[] = action.teams
.filter((t) => t.name.trim().length > 0)
.map((t) => ({
id: makeTeamId(),
name: t.name.trim().slice(0, 40),
// Trim each team to `teamSize` players. The host UI offers only
// teams with room; the reducer is the last line of defence.
playerIds: t.playerIds.slice(0, Math.max(1, teamSize)),
score: 0,
correctCount: 0,
passCount: 0,
penaltyCount: 0,
nextExplainerSlot: 0,
}));
return {
...state,
teams,
floaterPlayerId: validatedFloater,
turnNumber: 0,
phase: 'team_setup',
};
}
case 'start-turn': {
// A second press of "Start first turn" names turn 0, which is gone:
// ignored, instead of restarting the turn with a new card and clock.
if (isStaleTurnMove(state, action)) return state;
if (state.phase !== 'team_setup' && state.phase !== 'playing') return state;
const team = findTeam(state, action.teamId);
if (!team) return state;
return startTurn(state, team, action.explainerId);
}
case 'set-explainer': {
// A late pick (a double tap, host and moderator together) must not
// change the explainer of the turn after the one it was meant for.
if (isStaleTurnMove(state, action)) return state;
if (state.phase !== 'playing' && state.phase !== 'team_setup') return state;
// The host hands the turn to someone else; the team's rotation then
// continues after whoever explains now.
const teamIndex = state.teams.findIndex((t) => t.id === state.currentTeamId);
const teams =
state.phase === 'playing' && teamIndex !== -1
? withCursor(state.teams, teamIndex, cursorAfter(state, teamIndex, action.explainerId))
: state.teams;
return { ...state, teams, currentExplainerId: action.explainerId };
}
case 'next-card': {
if (isStaleCardMove(state, action)) return state;
if (state.phase !== 'playing') return state;
if (!state.timer.startedAt) return state;
if (turnTimeIsUp(state, nowMs())) return state;
const card = drawNextCardWeighted(
state.deck,
state.usedCardIds,
state.settings.difficultyDistribution
);
if (card === null) return state;
// A swapped card counts toward the turn's cards but not the score,
// and the turn's clock keeps running.
return {
...state,
currentCard: card,
cardSeq: cardSeqOf(state) + 1,
usedCardIds: [...state.usedCardIds, card.id],
cardsPlayedThisTurn: state.cardsPlayedThisTurn + 1,
};
}
// A host's press that names an earlier deal is ignored: a BUST (or a
// second moderator) moved the card on first, and the new card was
// never explained.
case 'correct-guess':
return isStaleCardMove(state, action) ? state : applyCorrectPassPenalty(state, 'correct');
case 'pass':
return isStaleCardMove(state, action) ? state : applyCorrectPassPenalty(state, 'pass');
case 'penalty':
return isStaleCardMove(state, action) ? state : applyCorrectPassPenalty(state, 'penalty');
case 'bust-forbidden': {
// Classic-Taboo buzzer. Server-authoritative actor check: the
// bustedBy id is injected by the host from the authenticated
// session (never trusted from the wire), and the reducer only
// accepts it when that player sits on a team OTHER than the
// explaining team. Teammates and the floater cannot bust.
if (state.phase !== 'playing') return state;
if (!state.currentCard) return state;
if (!state.currentTeamId) return state;
// Only a BUST for the card on screen counts. Two opponents pressing
// at once: the first applies the penalty and draws the next card, the
// second names the old card and is ignored (no double penalty, no
// burnt card). A BUST without a card id is refused too. The penalty
// is a new deal (`cardSeq`), so the host's Got it for the busted
// card, arriving after it, is ignored as well.
if (typeof action.cardId !== 'string' || action.cardId !== state.currentCard.id) return state;
const bustedBy = action.bustedBy;
if (typeof bustedBy !== 'string' || bustedBy.length === 0) return state;
const busterTeam = state.teams.find((t) => t.playerIds.includes(bustedBy));
if (!busterTeam) return state;
if (busterTeam.id === state.currentTeamId) return state;
return applyCorrectPassPenalty(state, 'penalty');
}
case 'end-turn': {
// Two presses (a double tap, or the host and a moderator) name the
// same turn: the first ends it, the second is ignored — otherwise it
// would end the next team's turn the moment it began.
if (isStaleTurnMove(state, action)) return state;
if (state.phase !== 'playing') return state;
const idx = nextTeamIndex(state);
const nextTeam = idx >= 0 ? state.teams[idx] : undefined;
if (!nextTeam) {
return { ...state, phase: 'ended', currentCard: null, timer: stoppedTimer(state.settings) };
}
// The next team's turn starts at once, with the next player in its
// own rotation explaining.
return startTurn(state, nextTeam, null);
}
case 'end-game':
return {
...state,
phase: 'ended',
currentCard: null,
currentExplainerId: null,
timer: stoppedTimer(state.settings),
};
default:
return state;
}
}
/**
* The explainer rotation of a team, in the order its turns go: its players,
* plus the floater's slot when there is one. Exported so the panel (and
* tests) read the same rotation the reducer uses.
*/
export function hushleExplainerQueue(
state: Pick<HushleState, 'teams' | 'floaterPlayerId'>,
teamId: string
): string[] {
return explainerQueue(
state,
state.teams.findIndex((team) => team.id === teamId)
);
}
/** Who explains the team's NEXT turn, if the host leaves it to the rotation. */
export function hushleNextExplainerForTeam(
state: Pick<HushleState, 'teams' | 'floaterPlayerId'>,
teamId: string
): string | null {
return nextExplainer(
state,
state.teams.findIndex((team) => team.id === teamId)
).explainerId;
}