Ctrl K
ComunidadGuías…

plugins/hushle/src/state.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * 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;
}