plugins/hushle/src/state.ts
Rama main · réplica de lectura del repositorio original.
/**
* Hushle game state and action model.
*
* The plugin is a server-authoritative reducer: the web app's room page
* sends every action through the activity dispatch route, the host
* `handleAction` is the only thing that produces the next state, and the
* persisted state is the source of truth for every other participant's view.
*
* Phases:
* - lobby : no game yet. The host picks a pack and the settings.
* - team_setup : the host is building the teams.
* - playing : a turn runs while `timer.startedAt` is set; between two
* turns the phase stays `playing` with the timer cleared.
* - ended : the game is over; only scores are valid.
*
* State versioning:
*
* Every persisted `HushleState` carries a `version` integer. When the
* state shape changes (new fields, renamed fields, removed fields),
* bump `HUSHLE_STATE_VERSION` and add a step to `migrateHushleState`
* below that upgrades the previous version. The host runs the
* migrator on every read so a server upgrade doesn't crash on
* sessions persisted by an older build. The reducer itself only
* ever produces `HUSHLE_STATE_VERSION`; older versions only exist
* in the database.
*/
import { cursorAfter } from './rotation';
export type HushlePhase = 'lobby' | 'team_setup' | 'playing' | 'ended';
/**
* Card language. The two built-in packs ship `en` and `tr`, but hosts
* can create packs in any language via the admin panel (M20b). The
* `(string & {})` intersection keeps IDE autocomplete for the known
* codes while accepting arbitrary BCP-47-ish tags (e.g. `de`, `pt-BR`).
*/
export type HushleLanguage = 'en' | 'tr' | (string & {});
/**
* Difficulty tiers for a Hushle card. The visual treatment (color +
* top-right pips) is plugin-owned; the reducer only knows the label.
*/
export type HushleDifficulty = 'easy' | 'medium' | 'hard';
export interface HushleCard {
id: string;
language: HushleLanguage;
word: string;
forbiddenWords: string[];
difficulty: HushleDifficulty;
category?: string;
}
export interface HushleTeam {
id: string;
name: string;
playerIds: string[];
score: number;
correctCount: number;
passCount: number;
penaltyCount: number;
/**
* v3 — where this team's explainer rotation stands: the slot in its
* rotation (see `rotation.ts`) that the team's NEXT turn goes to. It
* moves on by one each time the team's turn starts; an explainer the
* host picks by hand moves it to the slot after theirs.
*/
nextExplainerSlot: number;
}
export interface HushleSettings {
turnDurationSeconds: number;
cardsPerTurn: number;
language: HushleLanguage;
packId: string | null;
/**
* Target number of players per team. `set-teams` trims every team to
* it. Hushle defaults to 2 (the 2v2 format); an odd player count
* carries the extra player as `floaterPlayerId`.
*/
teamSize: number;
/**
* Weighted draw distribution. Keys are HushleDifficulty; values are
* fractions in [0, 1] that sum to 1. Default is
* `{ easy: 0.6, medium: 0.3, hard: 0.1 }`. The reducer samples a tier,
* then draws an unused card from that tier's bucket.
*/
difficultyDistribution: Record<HushleDifficulty, number>;
}
/**
* The turn timer. A turn has ONE clock: it starts with the turn and does
* not restart per card, so the explaining team gets the whole duration for
* as many cards as they manage. `endsAt` is the deadline every client
* counts down to; after it, the turn's scoring is over.
*/
export interface HushleTimer {
startedAt: string | null;
durationSeconds: number;
paused: boolean;
/** v3 — when the running turn's time is up (ISO). Null between turns. */
endsAt: string | null;
}
export interface HushleState {
version: number;
phase: HushlePhase;
teams: HushleTeam[];
/**
* The extra player of an odd player count. They sit on no team; they
* have a slot in EVERY team's explainer rotation instead, so they
* explain once for each team per round.
*/
floaterPlayerId: string | null;
/** v3 — the current turn's number, 1 for the first; 0 before play starts. */
turnNumber: number;
currentTeamId: string | null;
currentExplainerId: string | null;
currentCard: HushleCard | null;
/**
* v4 — which deal the card in play is: goes up by one at every draw
* (the start of a turn, a card scored, skipped or busted, `next-card`)
* and never goes down, not even for a new game. Public: it says how
* many cards were dealt, never which. Got it / Skip / Penalty / Next
* card carry the number the host's button was drawn for, so a press
* that lands after the card changed (a BUST got there first, or a
* second moderator pressed too) is ignored instead of scoring a card
* nobody explained. The card's id cannot do this job: the host and the
* explaining team never receive it.
*/
cardSeq: number;
deck: HushleCard[];
deckIndex: number;
/**
* Card IDs already drawn this game. The reducer consults this set when
* sampling from a difficulty tier to avoid repeating the same card.
* Resets to `[]` on `start-game`.
*
* beta-review: SERVER ONLY. These are stable DB card ids and the last
* entry is the current card, so the canonical projector
* (@lobbyforge/core) replaces them with `usedCardCount` for every
* viewer — the client must not rely on this field.
*/
usedCardIds: string[];
settings: HushleSettings;
timer: HushleTimer;
cardsPlayedThisTurn: number;
totalCardsPlayed: number;
createdBy: string | null;
createdAt: string | null;
}
export type HushleAction =
| {
type: 'start-game';
packId: string;
language?: HushleLanguage;
turnDurationSeconds?: number;
createdBy: string;
/** Optional difficulty weights and team size; default 60/30/10 and 2. */
difficultyDistribution?: Partial<Record<HushleDifficulty, number>>;
teamSize?: number;
/**
* Cards per turn before the turn ends. Defaults to
* `HUSHLE_DEFAULT_CARDS_PER_TURN` (15).
*/
cardsPerTurn?: number;
/** Host-injected DB deck. Client input is overwritten at the API boundary. */
deck?: HushleCard[];
}
| {
type: 'set-teams';
teams: Array<{ name: string; playerIds: string[] }>;
/** The single extra player of an odd count, or null (the default). */
floaterPlayerId?: string | null;
}
/**
* `turnNumber` (on `start-turn` and `end-turn`) is the `state.turnNumber`
* the host's button was drawn for: when present and no longer current,
* the action is ignored, so a double tap — or the host and a moderator
* pressing together — starts or ends ONE turn. Absent (or null), it acts
* on the turn as it stands, as older panels sent it.
*/
| { type: 'start-turn'; teamId: string; explainerId: string | null; turnNumber?: number | null }
| { type: 'set-explainer'; explainerId: string | null; turnNumber?: number | null }
/**
* The host's card moves. `cardSeq` is the `state.cardSeq` the button was
* drawn for: when present and no longer current, the action is ignored
* (the card it meant is gone). Absent (or null), it applies to whatever
* card is in play — the shape older panels send.
*/
| { type: 'next-card'; cardSeq?: number | null }
| { type: 'correct-guess'; cardSeq?: number | null }
| { type: 'pass'; cardSeq?: number | null }
| { type: 'penalty'; cardSeq?: number | null }
/**
* Classic-Taboo buzzer: a player on an OPPOSING team catches the
* explainer saying a forbidden word and presses BUST. Server injects
* the actor id via actionPolicies `actorFields` — the reducer then
* verifies the caller really is on another team (teammates and the
* floater cannot bust their own explainer). Applies the standard
* penalty (-1 to the explaining team) and draws the next card.
*
* `cardId` is the card the buster saw: a BUST for any card but the one
* on screen is ignored, so two opponents pressing at once (or one
* double-tap) cost ONE penalty and never burn the next card.
*/
| { type: 'bust-forbidden'; bustedBy?: string; cardId?: string }
| { type: 'end-turn'; turnNumber?: number | null }
| { type: 'end-game' };
export const HUSHLE_DEFAULT_TURN_DURATION_SECONDS = 60;
export const HUSHLE_DEFAULT_CARDS_PER_TURN = 15;
export const HUSHLE_DEFAULT_TEAM_SIZE = 2;
export const HUSHLE_DEFAULT_DIFFICULTY_DISTRIBUTION: Record<HushleDifficulty, number> = {
easy: 0.6,
medium: 0.3,
hard: 0.1,
};
/**
* How late a card may still be scored after the turn's deadline: the time
* a host's last-second tap takes to reach the server. Past it, the turn's
* scoring is over.
*/
export const HUSHLE_TIME_UP_GRACE_MS = 2000;
/**
* The current Hushle state schema version. Bump this and add a step
* to `migrateHushleState` below whenever the state shape changes in
* a backwards-incompatible way.
*
* v2 (M20a) added the floater, the difficulty tiers and the weighted draw.
* v3 gives every team its own explainer rotation (`teams[].nextExplainerSlot`,
* replacing the single `currentExplainerIndex`), counts turns
* (`turnNumber`) and keeps the turn's deadline (`timer.endsAt`).
* v4 numbers the deals (`cardSeq`) so a host's press names its card.
*/
export const HUSHLE_STATE_VERSION = 4;
export function createHushleInitialState(): HushleState {
return {
version: HUSHLE_STATE_VERSION,
phase: 'lobby',
teams: [],
floaterPlayerId: null,
turnNumber: 0,
currentTeamId: null,
currentExplainerId: null,
currentCard: null,
cardSeq: 0,
deck: [],
deckIndex: 0,
usedCardIds: [],
settings: {
turnDurationSeconds: HUSHLE_DEFAULT_TURN_DURATION_SECONDS,
cardsPerTurn: HUSHLE_DEFAULT_CARDS_PER_TURN,
language: 'en',
packId: null,
teamSize: HUSHLE_DEFAULT_TEAM_SIZE,
difficultyDistribution: { ...HUSHLE_DEFAULT_DIFFICULTY_DISTRIBUTION },
},
timer: {
startedAt: null,
durationSeconds: HUSHLE_DEFAULT_TURN_DURATION_SECONDS,
paused: true,
endsAt: null,
},
cardsPlayedThisTurn: 0,
totalCardsPlayed: 0,
createdBy: null,
createdAt: null,
};
}
// ---------------------------------------------------------------------------
// Migrations. Older shapes are handled as loose records: each step takes the
// previous version's shape and returns the next one's.
// ---------------------------------------------------------------------------
type Loose = Record<string, unknown>;
const asRecord = (value: unknown): Loose =>
value && typeof value === 'object' && !Array.isArray(value) ? (value as Loose) : {};
/** The v2 defaults — the shape v0 and v1 rows are brought up to first. */
const HUSHLE_V2_DEFAULTS: Loose = {
phase: 'lobby',
teams: [],
floaterPlayerId: null,
currentExplainerIndex: 0,
currentTeamId: null,
currentExplainerId: null,
currentCard: null,
deck: [],
deckIndex: 0,
usedCardIds: [],
settings: {
turnDurationSeconds: HUSHLE_DEFAULT_TURN_DURATION_SECONDS,
cardsPerTurn: HUSHLE_DEFAULT_CARDS_PER_TURN,
language: 'en',
packId: null,
teamSize: HUSHLE_DEFAULT_TEAM_SIZE,
difficultyDistribution: { ...HUSHLE_DEFAULT_DIFFICULTY_DISTRIBUTION },
},
timer: {
startedAt: null,
durationSeconds: HUSHLE_DEFAULT_TURN_DURATION_SECONDS,
paused: true,
},
cardsPlayedThisTurn: 0,
totalCardsPlayed: 0,
createdBy: null,
createdAt: null,
};
function migrateV0ToV1(raw: unknown): Loose {
// Pre-versioned state had no `version` field. Promote it to v1 with
// defaults for any fields added in v1. The reducer's view of the
// data is unchanged — `phase` / `teams` / `deck` already existed.
const base = asRecord(raw);
return {
...HUSHLE_V2_DEFAULTS,
...base,
version: 1,
// settings was already in v0 but the schema may have drifted; merge
// defaults so a partially-shaped row still loads.
settings: { ...asRecord(HUSHLE_V2_DEFAULTS.settings), ...asRecord(base.settings) },
timer: { ...asRecord(HUSHLE_V2_DEFAULTS.timer), ...asRecord(base.timer) },
};
}
function migrateV1ToV2(raw: unknown): Loose {
// v1 had no `difficulty` on cards, no floater support, no
// `teamSize` / `difficultyDistribution` on settings, no `usedCardIds`.
// Promote every card to `easy` (the safest default).
const base = asRecord(raw);
const toCard = (value: unknown) => {
const c = asRecord(value);
return {
id: typeof c.id === 'string' ? c.id : 'card-migrated',
language: c.language === 'tr' ? 'tr' : 'en',
word: typeof c.word === 'string' ? c.word : '',
forbiddenWords: Array.isArray(c.forbiddenWords)
? (c.forbiddenWords as unknown[]).filter((w): w is string => typeof w === 'string')
: [],
difficulty: 'easy',
};
};
const deck = Array.isArray(base.deck) ? (base.deck as unknown[]).map(toCard) : [];
const currentCard = base.currentCard && typeof base.currentCard === 'object' ? toCard(base.currentCard) : null;
return {
...HUSHLE_V2_DEFAULTS,
...base,
version: 2,
floaterPlayerId: null,
currentExplainerIndex: 0,
usedCardIds: [],
deck,
currentCard,
settings: {
...asRecord(HUSHLE_V2_DEFAULTS.settings),
...asRecord(base.settings),
teamSize: HUSHLE_DEFAULT_TEAM_SIZE,
difficultyDistribution: { ...HUSHLE_DEFAULT_DIFFICULTY_DISTRIBUTION },
},
timer: { ...asRecord(HUSHLE_V2_DEFAULTS.timer), ...asRecord(base.timer) },
};
}
function migrateV2ToV3(raw: unknown): HushleState {
// v2 had one rotation index for all teams (`currentExplainerIndex`), a
// timer that restarted with every card, and no turn counter. v3 gives
// every team its own rotation cursor, numbers the turns, and keeps the
// turn's deadline.
const { currentExplainerIndex, ...base } = asRecord(raw);
const oldIndex = typeof currentExplainerIndex === 'number' ? Math.max(0, Math.floor(currentExplainerIndex)) : 0;
const teams = (Array.isArray(base.teams) ? (base.teams as unknown[]) : []).map((value) => {
const team = asRecord(value);
return {
...team,
nextExplainerSlot: typeof team.nextExplainerSlot === 'number' ? team.nextExplainerSlot : 0,
} as unknown as HushleTeam;
});
const timer = asRecord(base.timer);
const startedAt = typeof timer.startedAt === 'string' ? timer.startedAt : null;
const durationSeconds =
typeof timer.durationSeconds === 'number' ? timer.durationSeconds : HUSHLE_DEFAULT_TURN_DURATION_SECONDS;
const paused = timer.paused === true;
const start = startedAt ? Date.parse(startedAt) : Number.NaN;
const endsAt =
startedAt && !paused && Number.isFinite(start) ? new Date(start + durationSeconds * 1000).toISOString() : null;
const inPlay = (base.phase === 'playing' || base.phase === 'ended') && typeof base.currentTeamId === 'string';
const state = {
...(base as unknown as HushleState),
version: 3,
teams,
turnNumber: inPlay ? oldIndex + 1 : 0,
timer: { startedAt, durationSeconds, paused, endsAt },
};
// A game in progress: the explaining team's rotation continues after
// whoever explains now. The other teams start their rotation afresh.
if (inPlay) {
const index = teams.findIndex((team) => team.id === base.currentTeamId);
const explainer = typeof base.currentExplainerId === 'string' ? base.currentExplainerId : null;
const cursor = index === -1 ? null : cursorAfter(state, index, explainer);
if (cursor !== null) {
state.teams = teams.map((team, i) => (i === index ? { ...team, nextExplainerSlot: cursor } : team));
}
}
return state;
}
function migrateV3ToV4(raw: unknown): HushleState {
// v4 numbers the deals. A game in progress starts counting at 0: every
// read of the same v3 row migrates to the same number, so a panel that
// read the migrated state names the card the reducer sees. Panels that
// read the raw row send no number, and that is accepted too.
const base = asRecord(raw);
const cardSeq = base.cardSeq;
return {
...(base as unknown as HushleState),
version: HUSHLE_STATE_VERSION,
cardSeq: typeof cardSeq === 'number' && Number.isSafeInteger(cardSeq) && cardSeq >= 0 ? cardSeq : 0,
};
}
/**
* Migrate an arbitrary JSONB blob (the persisted `state` column on
* `game_sessions`) to the current `HushleState`. Idempotent — running
* twice produces the same result. Defensive: an invalid blob (null,
* missing phase, etc.) falls back to `createHushleInitialState()` so
* the host never crashes on a bad row.
*/
export function migrateHushleState(raw: unknown): HushleState {
if (!raw || typeof raw !== 'object') {
return createHushleInitialState();
}
const obj = raw as Record<string, unknown>;
const version = typeof obj.version === 'number' ? obj.version : 0;
if (version === HUSHLE_STATE_VERSION) {
return raw as HushleState;
}
// Walk the chain forward from the row's version to current. Each
// step is a pure transform; `state` is small enough that the cost
// is negligible compared to the read round-trip.
let state: unknown = raw;
if (version < 1) state = migrateV0ToV1(state);
if (version < 2) state = migrateV1ToV2(state);
if (version < 3) state = migrateV2ToV3(state);
if (version < 4) state = migrateV3ToV4(state);
// if (version < 5) state = migrateV4ToV5(state); ← add when v5 lands
return state as HushleState;
}