import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; import { hushlePlugin, type HushleState } from '../index'; import { migrateHushleState, HUSHLE_STATE_VERSION, HUSHLE_DEFAULT_DIFFICULTY_DISTRIBUTION, HUSHLE_TIME_UP_GRACE_MS, } from '../state'; import { hushleExplainerQueue, hushleNextExplainerForTeam } from '../actions'; import { createTestHarness } from '@lobbyforge/plugin-sdk/testing'; describe('@lobbyforge/hushle', () => { it('walks through a full game flow: start, set teams, play a turn, end', async () => { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2', 'p3', 'p4'], }); await harness.startGame(); // Initial state — lobby, no teams, no cards drawn. expect(harness.getState().phase).toBe('lobby'); expect(harness.getState().teams).toHaveLength(0); expect(harness.getState().deck).toHaveLength(0); // Host starts a game in Turkish. await harness.performAction('p1', { type: 'start-game', packId: 'hushle-tr-basic', language: 'tr', turnDurationSeconds: 30, createdBy: 'p1', }); expect(harness.getState().phase).toBe('team_setup'); expect(harness.getState().settings.language).toBe('tr'); expect(harness.getState().settings.packId).toBe('hushle-tr-basic'); expect(harness.getState().settings.turnDurationSeconds).toBe(30); expect(harness.getState().settings.teamSize).toBe(2); expect(harness.getState().settings.difficultyDistribution).toEqual( HUSHLE_DEFAULT_DIFFICULTY_DISTRIBUTION ); expect(harness.getState().deck.length).toBeGreaterThan(0); expect(harness.getState().usedCardIds).toEqual([]); // Host configures two teams. await harness.performAction('p1', { type: 'set-teams', teams: [ { name: 'Takım A', playerIds: ['p1', 'p2'] }, { name: 'Takım B', playerIds: ['p3', 'p4'] }, ], }); expect(harness.getState().teams).toHaveLength(2); expect(harness.getState().teams[0]?.name).toBe('Takım A'); expect(harness.getState().phase).toBe('team_setup'); expect(harness.getState().floaterPlayerId).toBeNull(); // v3: no turn played yet; every team's rotation starts at its first player. expect(harness.getState().turnNumber).toBe(0); expect(harness.getState().teams.map((t) => t.nextExplainerSlot)).toEqual([0, 0]); // Host starts the first turn for Takım A with p1 as explainer. const firstTeam = harness.getState().teams[0]!; await harness.performAction('p1', { type: 'start-turn', teamId: firstTeam.id, explainerId: 'p1', }); expect(harness.getState().phase).toBe('playing'); expect(harness.getState().currentTeamId).toBe(firstTeam.id); expect(harness.getState().currentExplainerId).toBe('p1'); expect(harness.getState().currentCard).not.toBeNull(); expect(harness.getState().currentCard?.language).toBe('tr'); // M20a — every card carries a difficulty tier. expect(['easy', 'medium', 'hard']).toContain(harness.getState().currentCard?.difficulty); expect(harness.getState().timer.paused).toBe(false); expect(harness.getState().usedCardIds.length).toBe(1); expect(harness.getState().turnNumber).toBe(1); // The turn's deadline is in state: start + the 30 s picked above. const { startedAt, endsAt } = harness.getState().timer; expect(Date.parse(endsAt!) - Date.parse(startedAt!)).toBe(30_000); // Host scores a correct guess. await harness.performAction('p1', { type: 'correct-guess' }); const afterCorrect = harness.getState(); // One clock per turn: scoring a card does not restart it. expect(afterCorrect.timer.startedAt).toBe(startedAt); expect(afterCorrect.timer.endsAt).toBe(endsAt); expect(afterCorrect.teams[0]?.score).toBe(1); expect(afterCorrect.teams[0]?.correctCount).toBe(1); // A new card is drawn automatically after a correct guess. expect(afterCorrect.currentCard).not.toBeNull(); expect(afterCorrect.totalCardsPlayed).toBe(1); expect(afterCorrect.usedCardIds.length).toBe(2); // No card should ever be drawn twice in a session. expect(new Set(afterCorrect.usedCardIds).size).toBe(afterCorrect.usedCardIds.length); // Host passes a card. await harness.performAction('p1', { type: 'pass' }); expect(harness.getState().teams[0]?.passCount).toBe(1); expect(harness.getState().teams[0]?.score).toBe(1); expect(harness.getState().totalCardsPlayed).toBe(2); // Host hands a penalty to the team. await harness.performAction('p1', { type: 'penalty' }); expect(harness.getState().teams[0]?.penaltyCount).toBe(1); expect(harness.getState().teams[0]?.score).toBe(0); expect(harness.getState().totalCardsPlayed).toBe(3); // Host ends the game. await harness.performAction('p1', { type: 'end-game' }); expect(harness.getState().phase).toBe('ended'); expect(harness.getState().currentCard).toBeNull(); }); it('rejects non-host actions in playing phase', async () => { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2'], }); await harness.startGame(); await harness.performAction('p1', { type: 'start-game', packId: 'hushle-en-basic', createdBy: 'p1', }); await harness.performAction('p1', { type: 'set-teams', teams: [{ name: 'A', playerIds: ['p1', 'p2'] }], }); const team = harness.getState().teams[0]!; await harness.performAction('p1', { type: 'start-turn', teamId: team.id, explainerId: 'p1' }); const stateBefore = harness.getState(); // All Hushle actions are host-only — a non-host action leaves the // state machine untouched (the plugin-sdk test harness is permissive // by design, so the state machine enforces the constraint in the // reducer itself). The host-only check is enforced by the route // layer's `authorizePluginAction` against `actionPolicies`. expect(stateBefore.currentCard).not.toBeNull(); }); it('refuses to seat one player on two teams', async () => { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2', 'p3', 'p4'], }); await harness.startGame(); await harness.performAction('p1', { type: 'start-game', packId: 'hushle-en-basic', createdBy: 'p1' }); const before = harness.getState(); // p2 would guess for A while watching B's cards. await harness.performAction('p1', { type: 'set-teams', teams: [ { name: 'A', playerIds: ['p1', 'p2'] }, { name: 'B', playerIds: ['p3', 'p2'] }, ], }); expect(harness.getState().teams).toEqual(before.teams); }); it('rotates to the next team on end-turn', async () => { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2', 'p3', 'p4'], }); await harness.startGame(); await harness.performAction('p1', { type: 'start-game', packId: 'hushle-en-basic', createdBy: 'p1', }); await harness.performAction('p1', { type: 'set-teams', teams: [ { name: 'A', playerIds: ['p1', 'p2'] }, { name: 'B', playerIds: ['p3', 'p4'] }, ], }); const teamA = harness.getState().teams[0]!; const teamB = harness.getState().teams[1]!; await harness.performAction('p1', { type: 'start-turn', teamId: teamA.id, explainerId: 'p1' }); expect(harness.getState().currentTeamId).toBe(teamA.id); await harness.performAction('p1', { type: 'end-turn' }); expect(harness.getState().currentTeamId).toBe(teamB.id); // Team B's own rotation starts at its first player. (The M20a reducer // picked p4 here: one shared index, taken modulo the team size, so // team B only ever got odd indexes and p3 never explained.) expect(harness.getState().currentExplainerId).toBe('p3'); expect(harness.getState().turnNumber).toBe(2); }); it('gives every player of two teams of two a turn in four turns', async () => { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2', 'p3', 'p4'], }); await harness.startGame(); await harness.performAction('p1', { type: 'start-game', packId: 'hushle-en-basic', createdBy: 'p1' }); await harness.performAction('p1', { type: 'set-teams', teams: [ { name: 'A', playerIds: ['p1', 'p2'] }, { name: 'B', playerIds: ['p3', 'p4'] }, ], }); const [teamA, teamB] = harness.getState().teams; await harness.performAction('p1', { type: 'start-turn', teamId: teamA!.id, explainerId: 'p1' }); const turns = [[harness.getState().currentTeamId, harness.getState().currentExplainerId]]; for (let i = 0; i < 5; i += 1) { await harness.performAction('p1', { type: 'end-turn' }); turns.push([harness.getState().currentTeamId, harness.getState().currentExplainerId]); } // Teams alternate; within each team the explainer rotates. expect(turns).toEqual([ [teamA!.id, 'p1'], [teamB!.id, 'p3'], [teamA!.id, 'p2'], [teamB!.id, 'p4'], [teamA!.id, 'p1'], [teamB!.id, 'p3'], ]); expect(harness.getState().turnNumber).toBe(6); }); it('continues a team\'s rotation after an explainer the host picked by hand', async () => { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2', 'p3', 'p4', 'p5', 'p6'], }); await harness.startGame(); await harness.performAction('p1', { type: 'start-game', packId: 'hushle-en-basic', teamSize: 3, createdBy: 'p1' }); await harness.performAction('p1', { type: 'set-teams', teams: [ { name: 'A', playerIds: ['p1', 'p2', 'p3'] }, { name: 'B', playerIds: ['p4', 'p5', 'p6'] }, ], }); const teamA = harness.getState().teams[0]!; // The host opens with p2 instead of p1… await harness.performAction('p1', { type: 'start-turn', teamId: teamA.id, explainerId: 'p2' }); expect(harness.getState().currentExplainerId).toBe('p2'); // …and in B's turn hands it from p4 to p6 (say p4 stepped away). await harness.performAction('p1', { type: 'end-turn' }); expect(harness.getState().currentExplainerId).toBe('p4'); await harness.performAction('p1', { type: 'set-explainer', explainerId: 'p6' }); // Each team's rotation continues after whoever actually explained. await harness.performAction('p1', { type: 'end-turn' }); expect(harness.getState().currentExplainerId).toBe('p3'); await harness.performAction('p1', { type: 'end-turn' }); expect(harness.getState().currentExplainerId).toBe('p4'); // A late pick names the turn it was meant for: once that turn is over // it changes nothing; the current turn's number still works. const turn = harness.getState().turnNumber; const before = harness.getState(); await harness.performAction('p1', { type: 'set-explainer', explainerId: 'p5', turnNumber: turn - 1 }); expect(harness.getState()).toBe(before); await harness.performAction('p1', { type: 'set-explainer', explainerId: 'p5', turnNumber: turn }); expect(harness.getState().currentExplainerId).toBe('p5'); }); it('end-game blocks new turns but preserves scores', async () => { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2'], }); await harness.startGame(); await harness.performAction('p1', { type: 'start-game', packId: 'hushle-en-basic', createdBy: 'p1', }); await harness.performAction('p1', { type: 'set-teams', teams: [{ name: 'A', playerIds: ['p1', 'p2'] }], }); const team = harness.getState().teams[0]!; await harness.performAction('p1', { type: 'start-turn', teamId: team.id, explainerId: 'p1' }); await harness.performAction('p1', { type: 'correct-guess' }); await harness.performAction('p1', { type: 'end-game' }); expect(harness.getState().phase).toBe('ended'); expect(harness.getState().teams[0]?.score).toBe(1); }); it('start-game resolves language from the packId slug', async () => { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2'], }); await harness.startGame(); await harness.performAction('p1', { type: 'start-game', packId: 'hushle-tr-basic', createdBy: 'p1', }); const state = harness.getState(); expect(state.settings.packId).toBe('hushle-tr-basic'); expect(state.settings.language).toBe('tr'); // The deck is loaded from the bundled tr deck (24 cards). expect(state.deck.length).toBe(24); expect(state.deck.every((c) => c.language === 'tr')).toBe(true); }); it('start-game honors language override when packId is not a known slug', async () => { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2'], }); await harness.startGame(); await harness.performAction('p1', { type: 'start-game', packId: 'hushle-community-spicy-words', language: 'en', createdBy: 'p1', }); const state = harness.getState(); expect(state.settings.packId).toBe('hushle-community-spicy-words'); // The reducer falls back to the explicit `language` when the slug // isn't recognized — a custom pack's deck will land via the M19 work // (DB-backed deck loading); the MVP keeps the legacy `getDefaultDeck`. expect(state.settings.language).toBe('en'); expect(state.deck.every((c) => c.language === 'en')).toBe(true); }); it('built-in packs include both en and tr decks with a 60/30/10 difficulty distribution', async () => { const { HUSHLE_BUILTIN_PACKS } = await import('../decks.js'); expect(HUSHLE_BUILTIN_PACKS).toHaveLength(2); const slugs = HUSHLE_BUILTIN_PACKS.map((p) => p.slug).sort(); expect(slugs).toEqual(['hushle-en-basic', 'hushle-tr-basic']); const languages = HUSHLE_BUILTIN_PACKS.map((p) => p.language).sort(); expect(languages).toEqual(['en', 'tr']); for (const pack of HUSHLE_BUILTIN_PACKS) { expect(pack.cards.length).toBe(24); const tiers = pack.cards.map((c) => c.difficulty ?? 'easy'); const easy = tiers.filter((t) => t === 'easy').length; const medium = tiers.filter((t) => t === 'medium').length; const hard = tiers.filter((t) => t === 'hard').length; // 60/30/10 of 24 = 14 / 7 / 3. Permitting a +-1 swing so a // future pack edit doesn't break the test. expect(easy).toBeGreaterThanOrEqual(13); expect(easy).toBeLessThanOrEqual(15); expect(medium).toBeGreaterThanOrEqual(6); expect(medium).toBeLessThanOrEqual(8); expect(hard).toBeGreaterThanOrEqual(2); expect(hard).toBeLessThanOrEqual(4); for (const card of pack.cards) { expect(typeof card.word).toBe('string'); expect(card.word.length).toBeGreaterThan(0); expect(Array.isArray(card.forbiddenWords)).toBe(true); expect(card.forbiddenWords.length).toBeGreaterThan(0); expect(['easy', 'medium', 'hard']).toContain(card.difficulty); } } }); it('initial state carries the current version', () => { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1'], }); return harness.startGame().then(() => { const state = harness.getState(); expect(state.version).toBe(HUSHLE_STATE_VERSION); expect(state.phase).toBe('lobby'); expect(state.floaterPlayerId).toBeNull(); expect(state.turnNumber).toBe(0); expect(state.timer.endsAt).toBeNull(); expect(state.usedCardIds).toEqual([]); expect(state.settings.teamSize).toBe(2); expect(state.settings.difficultyDistribution).toEqual( HUSHLE_DEFAULT_DIFFICULTY_DISTRIBUTION ); }); }); it('migrator upgrades a pre-versioned v0 state to the current version', () => { // Simulate a row persisted by a build that pre-dated state versioning. const v0 = { phase: 'playing', teams: [ { id: 'team-a', name: 'A', playerIds: ['p1', 'p2'], score: 2, correctCount: 2, passCount: 0, penaltyCount: 0, }, ], currentTeamId: 'team-a', currentExplainerId: 'p1', currentCard: { id: 'card-1', language: 'en', word: 'apple', forbiddenWords: ['fruit'] }, deck: [ { id: 'card-1', language: 'en', word: 'apple', forbiddenWords: ['fruit'] }, { id: 'card-2', language: 'en', word: 'book', forbiddenWords: ['read'] }, ], deckIndex: 0, settings: { turnDurationSeconds: 60, cardsPerTurn: 15, language: 'en', packId: 'hushle-en-basic', }, timer: { startedAt: null, durationSeconds: 60, paused: true }, cardsPlayedThisTurn: 0, totalCardsPlayed: 2, createdBy: 'p1', createdAt: '2026-06-01T00:00:00.000Z', // NB: no `version` field. }; const migrated = migrateHushleState(v0); expect(migrated.version).toBe(HUSHLE_STATE_VERSION); // The data we cared about carries through. expect(migrated.phase).toBe('playing'); expect(migrated.teams[0]?.score).toBe(2); expect(migrated.settings.packId).toBe('hushle-en-basic'); // M20a migration: every pre-versioned card picks up `difficulty: easy`. expect(migrated.deck[0]?.difficulty).toBe('easy'); expect(migrated.currentCard?.difficulty).toBe('easy'); expect(migrated.floaterPlayerId).toBeNull(); expect(migrated.usedCardIds).toEqual([]); expect(migrated.settings.teamSize).toBe(2); expect(migrated.settings.difficultyDistribution).toEqual( HUSHLE_DEFAULT_DIFFICULTY_DISTRIBUTION ); }); it('migrator upgrades a v1 (no difficulty) state to the current version', () => { const v1 = { version: 1, phase: 'lobby', teams: [], currentTeamId: null, currentExplainerId: null, currentCard: null, deck: [ { id: 'card-x', language: 'en', word: 'apple', forbiddenWords: ['fruit'] }, ], deckIndex: 0, settings: { turnDurationSeconds: 60, cardsPerTurn: 15, language: 'en', packId: 'hushle-en-basic', }, timer: { startedAt: null, durationSeconds: 60, paused: true }, cardsPlayedThisTurn: 0, totalCardsPlayed: 0, createdBy: null, createdAt: null, }; const migrated = migrateHushleState(v1); expect(migrated.version).toBe(HUSHLE_STATE_VERSION); expect(migrated.deck[0]?.difficulty).toBe('easy'); expect(migrated.settings.teamSize).toBe(2); expect(migrated.settings.difficultyDistribution.easy).toBeCloseTo(0.6, 5); expect(migrated.floaterPlayerId).toBeNull(); expect(migrated.usedCardIds).toEqual([]); }); it('migrator is idempotent on already-current state', () => { const current = { 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: 60, cardsPerTurn: 15, language: 'en', packId: null, teamSize: 2, difficultyDistribution: { easy: 0.6, medium: 0.3, hard: 0.1 }, }, timer: { startedAt: null, durationSeconds: 60, paused: true, endsAt: null }, cardsPlayedThisTurn: 0, totalCardsPlayed: 0, createdBy: null, createdAt: null, }; const first = migrateHushleState(current); const second = migrateHushleState(first); expect(second).toEqual(first); expect(first).toEqual(current); }); it('migrator numbers the deals of a v3 game in progress from 0, the same on every read', () => { // A v3 row mid-turn: the panel and the reducer each migrate it on their // own read, so both must arrive at the same deal number. const v3 = { version: 3, phase: 'playing', teams: [ { id: 'team-a', name: 'A', playerIds: ['p1', 'p2'], score: 1, correctCount: 1, passCount: 0, penaltyCount: 0, nextExplainerSlot: 1 }, { id: 'team-b', name: 'B', playerIds: ['p3', 'p4'], score: 0, correctCount: 0, passCount: 0, penaltyCount: 0, nextExplainerSlot: 0 }, ], floaterPlayerId: null, turnNumber: 1, currentTeamId: 'team-a', currentExplainerId: 'p1', currentCard: { id: 'c2', language: 'en', word: 'apple', forbiddenWords: ['fruit'], difficulty: 'easy' }, deck: [], deckIndex: 0, usedCardIds: ['c1', 'c2'], settings: { turnDurationSeconds: 60, cardsPerTurn: 15, language: 'en', packId: 'hushle-en-basic', teamSize: 2, difficultyDistribution: { easy: 0.6, medium: 0.3, hard: 0.1 }, }, timer: { startedAt: '2026-09-01T10:00:00.000Z', durationSeconds: 60, paused: false, endsAt: '2026-09-01T10:01:00.000Z' }, cardsPlayedThisTurn: 1, totalCardsPlayed: 1, createdBy: 'p1', createdAt: '2026-09-01T09:55:00.000Z', }; const migrated = migrateHushleState(v3); expect(migrated.version).toBe(HUSHLE_STATE_VERSION); expect(migrated.cardSeq).toBe(0); expect(migrateHushleState(v3)).toEqual(migrated); expect(migrateHushleState(migrated)).toBe(migrated); // Everything else carries through untouched. const { version: _v, cardSeq: _seq, ...rest } = migrated; const { version: _v3, ...restV3 } = v3; expect(rest).toEqual(restV3); // Older rows reach v4 through every step. expect(migrateHushleState({ version: 2, phase: 'lobby', teams: [] }).cardSeq).toBe(0); expect(migrateHushleState({ phase: 'lobby', teams: [] }).cardSeq).toBe(0); }); it('migrator upgrades a v2 game in progress: per-team rotation, turn number, deadline', () => { // A v2 row mid-game: team A's second turn overall is running (shared // index 2), p2 explaining, with a floater — the old single-index shape. const v2 = { version: 2, phase: 'playing', teams: [ { id: 'team-a', name: 'A', playerIds: ['p1', 'p2'], score: 1, correctCount: 1, passCount: 0, penaltyCount: 0 }, { id: 'team-b', name: 'B', playerIds: ['p3', 'p4'], score: 0, correctCount: 0, passCount: 0, penaltyCount: 0 }, ], floaterPlayerId: 'p5', currentExplainerIndex: 2, currentTeamId: 'team-a', currentExplainerId: 'p2', currentCard: { id: 'c1', language: 'en', word: 'apple', forbiddenWords: ['fruit'], difficulty: 'easy' }, deck: [], deckIndex: 0, usedCardIds: ['c1'], settings: { turnDurationSeconds: 60, cardsPerTurn: 15, language: 'en', packId: 'hushle-en-basic', teamSize: 2, difficultyDistribution: { easy: 0.6, medium: 0.3, hard: 0.1 }, }, timer: { startedAt: '2026-09-01T10:00:00.000Z', durationSeconds: 60, paused: false }, cardsPlayedThisTurn: 3, totalCardsPlayed: 5, createdBy: 'p1', createdAt: '2026-09-01T09:55:00.000Z', }; const migrated = migrateHushleState(v2); expect(migrated.version).toBe(HUSHLE_STATE_VERSION); expect('currentExplainerIndex' in migrated).toBe(false); expect(migrated.turnNumber).toBe(3); // Team A's rotation (p1, floater, p2) continues after p2; B starts afresh. expect(hushleExplainerQueue(migrated, 'team-a')).toEqual(['p1', 'p5', 'p2']); expect(hushleNextExplainerForTeam(migrated, 'team-a')).toBe('p1'); expect(hushleNextExplainerForTeam(migrated, 'team-b')).toBe('p3'); expect(migrated.timer.endsAt).toBe('2026-09-01T10:01:00.000Z'); // Everything else carries through. expect(migrated.teams[0]!.score).toBe(1); expect(migrated.currentCard?.word).toBe('apple'); expect(migrateHushleState(migrated)).toEqual(migrated); }); it('migrator gives a v2 lobby or stopped clock no deadline', () => { const migrated = migrateHushleState({ version: 2, phase: 'team_setup', teams: [{ id: 't', name: 'A', playerIds: ['p1'], score: 0, correctCount: 0, passCount: 0, penaltyCount: 0 }], floaterPlayerId: null, currentExplainerIndex: 0, currentTeamId: null, settings: { turnDurationSeconds: 45 }, timer: { startedAt: null, durationSeconds: 45, paused: true }, }); expect(migrated.turnNumber).toBe(0); expect(migrated.timer).toEqual({ startedAt: null, durationSeconds: 45, paused: true, endsAt: null }); expect(migrated.teams[0]!.nextExplainerSlot).toBe(0); }); it('migrator falls back to initial state on garbage', () => { expect(migrateHushleState(null).version).toBe(HUSHLE_STATE_VERSION); expect(migrateHushleState(undefined).version).toBe(HUSHLE_STATE_VERSION); expect(migrateHushleState('not an object').version).toBe(HUSHLE_STATE_VERSION); expect(migrateHushleState(42).version).toBe(HUSHLE_STATE_VERSION); }); it('plugin exposes migrateState through the registry adapter', async () => { // The host reads `getPlugin(id).migrateState` on the registered // wrapper, so verify the adapter preserves it. const { registerGamePlugin } = await import('@lobbyforge/plugin-sdk'); const registered = registerGamePlugin(hushlePlugin); expect(typeof registered.migrateState).toBe('function'); const migrated = registered.migrateState!({ phase: 'lobby', teams: [], settings: {}, timer: {}, }); expect((migrated as HushleState).version).toBe(HUSHLE_STATE_VERSION); }); // ──────────────────────────────────────────────────────────────────── // M20a — 2v2 + odd-player (floater) rotation // ──────────────────────────────────────────────────────────────────── it('start-game accepts a custom teamSize and difficultyDistribution', async () => { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2', 'p3', 'p4'], }); await harness.startGame(); await harness.performAction('p1', { type: 'start-game', packId: 'hushle-en-basic', teamSize: 3, difficultyDistribution: { easy: 0.5, medium: 0.3, hard: 0.2 }, createdBy: 'p1', }); const state = harness.getState(); expect(state.settings.teamSize).toBe(3); // The reducer renormalizes the distribution to sum to 1. const sum = state.settings.difficultyDistribution.easy + state.settings.difficultyDistribution.medium + state.settings.difficultyDistribution.hard; expect(sum).toBeCloseTo(1, 5); expect(state.settings.difficultyDistribution.hard).toBeCloseTo(0.2, 5); }); it('start-game falls back to defaults when difficultyDistribution sums to zero', async () => { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2'], }); await harness.startGame(); await harness.performAction('p1', { type: 'start-game', packId: 'hushle-en-basic', difficultyDistribution: { easy: 0, medium: 0, hard: 0 }, createdBy: 'p1', }); expect(harness.getState().settings.difficultyDistribution).toEqual( HUSHLE_DEFAULT_DIFFICULTY_DISTRIBUTION ); }); it('start-game rejects negative distribution weights (clamps to zero)', async () => { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2'], }); await harness.startGame(); await harness.performAction('p1', { type: 'start-game', packId: 'hushle-en-basic', difficultyDistribution: { easy: -0.5, medium: 1, hard: 0 }, createdBy: 'p1', }); const dist = harness.getState().settings.difficultyDistribution; // Negative clamped to 0; only `medium` carries weight; renormalized to 1. expect(dist.easy).toBe(0); expect(dist.medium).toBe(1); expect(dist.hard).toBe(0); }); it('set-teams accepts a floater for odd-player games and validates it is not on a team', async () => { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2', 'p3', 'p4', 'p5'], }); await harness.startGame(); await harness.performAction('p1', { type: 'start-game', packId: 'hushle-en-basic', createdBy: 'p1', }); await harness.performAction('p1', { type: 'set-teams', teams: [ { name: 'A', playerIds: ['p1', 'p2'] }, { name: 'B', playerIds: ['p3', 'p4'] }, ], floaterPlayerId: 'p5', }); expect(harness.getState().floaterPlayerId).toBe('p5'); expect(harness.getState().teams[0]?.playerIds).toEqual(['p1', 'p2']); }); it('set-teams drops the floater when they are already on a team', async () => { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2', 'p3', 'p4', 'p5'], }); await harness.startGame(); await harness.performAction('p1', { type: 'start-game', packId: 'hushle-en-basic', createdBy: 'p1', }); // p5 is also on team A — the reducer should drop the floater. await harness.performAction('p1', { type: 'set-teams', teams: [ { name: 'A', playerIds: ['p1', 'p2', 'p5'] }, { name: 'B', playerIds: ['p3', 'p4'] }, ], floaterPlayerId: 'p5', }); expect(harness.getState().floaterPlayerId).toBeNull(); }); it('set-teams trims each team to settings.teamSize', async () => { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2', 'p3', 'p4', 'p5', 'p6'], }); await harness.startGame(); await harness.performAction('p1', { type: 'start-game', packId: 'hushle-en-basic', teamSize: 2, createdBy: 'p1', }); await harness.performAction('p1', { type: 'set-teams', teams: [ { name: 'A', playerIds: ['p1', 'p2', 'p3', 'p4'] }, // 4 players, trim to 2 { name: 'B', playerIds: ['p5', 'p6'] }, ], }); expect(harness.getState().teams[0]?.playerIds).toEqual(['p1', 'p2']); expect(harness.getState().teams[1]?.playerIds).toEqual(['p5', 'p6']); }); it('end-turn rotates to the floater when the next team is empty', async () => { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2', 'p3'], }); await harness.startGame(); await harness.performAction('p1', { type: 'start-game', packId: 'hushle-en-basic', createdBy: 'p1', }); // 3 players + 1 floater = 4 slots across 2 teams. Team B's playerIds // is intentionally empty; the reducer picks the floater instead. await harness.performAction('p1', { type: 'set-teams', teams: [ { name: 'A', playerIds: ['p1'] }, { name: 'B', playerIds: [] }, ], floaterPlayerId: 'p3', }); const teamA = harness.getState().teams[0]!; await harness.performAction('p1', { type: 'start-turn', teamId: teamA.id, explainerId: 'p1' }); expect(harness.getState().currentExplainerId).toBe('p1'); await harness.performAction('p1', { type: 'end-turn' }); // Team B is empty — floater explains. expect(harness.getState().currentExplainerId).toBe('p3'); }); it('end-turn alternates the floater across teams across multiple turns', async () => { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2', 'p3', 'p4', 'p5'], }); await harness.startGame(); await harness.performAction('p1', { type: 'start-game', packId: 'hushle-en-basic', createdBy: 'p1', }); // 5 players: A=[p1,p2], B=[p3,p4], floater=p5. The floater has a slot // in BOTH teams' rotations — (p1, p5, p2) and (p3, p4, p5) — so a round // of six turns gives everyone one turn and the floater one for each // team, three turns apart. (The M20a reducer went p1, p4, p1: the // floater never explained and p2 and p3 never did either.) await harness.performAction('p1', { type: 'set-teams', teams: [ { name: 'A', playerIds: ['p1', 'p2'] }, { name: 'B', playerIds: ['p3', 'p4'] }, ], floaterPlayerId: 'p5', }); const teamA = harness.getState().teams[0]!; const teamB = harness.getState().teams[1]!; await harness.performAction('p1', { type: 'start-turn', teamId: teamA.id, explainerId: 'p1' }); const turns = [[harness.getState().currentTeamId, harness.getState().currentExplainerId]]; for (let i = 0; i < 6; i += 1) { await harness.performAction('p1', { type: 'end-turn' }); turns.push([harness.getState().currentTeamId, harness.getState().currentExplainerId]); } expect(turns).toEqual([ [teamA.id, 'p1'], [teamB.id, 'p3'], [teamA.id, 'p5'], [teamB.id, 'p4'], [teamA.id, 'p2'], [teamB.id, 'p5'], [teamA.id, 'p1'], ]); }); it('spreads the floater through larger teams too', () => { // 7 players: two teams of three and a floater. Each rotation holds the // floater once; their two turns never come back to back. const state = { floaterPlayerId: 'f', teams: [ { id: 'a', playerIds: ['a1', 'a2', 'a3'] }, { id: 'b', playerIds: ['b1', 'b2', 'b3'] }, ], } as unknown as HushleState; const a = hushleExplainerQueue(state, 'a'); const b = hushleExplainerQueue(state, 'b'); expect(a).toEqual(['a1', 'f', 'a2', 'a3']); expect(b).toEqual(['b1', 'b2', 'b3', 'f']); // Teams alternate: the round is a1 b1 f b2 a2 b3 a3 f. const round = a.flatMap((id, i) => [id, b[i]!]); const floaterTurns = round.flatMap((id, i) => (id === 'f' ? [i] : [])); expect(floaterTurns).toHaveLength(2); expect(floaterTurns[1]! - floaterTurns[0]!).toBeGreaterThan(1); expect(round.length - floaterTurns[1]! + floaterTurns[0]!).toBeGreaterThan(1); }); it('hushleNextExplainerForTeam reads the team\'s own rotation, floater included', () => { const team = (id: string, playerIds: string[], nextExplainerSlot: number) => ({ id, name: id, playerIds, score: 0, correctCount: 0, passCount: 0, penaltyCount: 0, nextExplainerSlot }); // A team with no players of its own: the floater explains every time. const empty = { floaterPlayerId: 'p5', teams: [team('a', ['p1'], 0), team('b', [], 3)] }; expect(hushleExplainerQueue(empty, 'b')).toEqual(['p5']); expect(hushleNextExplainerForTeam(empty, 'b')).toBe('p5'); // The cursor picks the slot; it wraps round the rotation. const game = { floaterPlayerId: 'p5', teams: [team('a', ['p1', 'p2'], 1), team('b', ['p3', 'p4'], 4)] }; expect(hushleNextExplainerForTeam(game, 'a')).toBe('p5'); expect(hushleNextExplainerForTeam(game, 'b')).toBe('p4'); // Without a floater, the rotation is the team's players. const plain = { floaterPlayerId: null, teams: [team('a', ['p1', 'p2'], 0)] }; expect(hushleExplainerQueue(plain, 'a')).toEqual(['p1', 'p2']); expect(hushleNextExplainerForTeam(plain, 'a')).toBe('p1'); // A team nobody can explain for, and an unknown team. expect(hushleNextExplainerForTeam({ floaterPlayerId: null, teams: [team('a', [], 0)] }, 'a')).toBeNull(); expect(hushleNextExplainerForTeam(plain, 'nope')).toBeNull(); }); // ──────────────────────────────────────────────────────────────────── // v3 — one clock per turn // ──────────────────────────────────────────────────────────────────── describe('the turn timer', () => { const start = Date.parse('2026-09-29T12:00:00.000Z'); beforeEach(() => { vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(start); }); afterEach(() => { vi.useRealTimers(); }); async function runningTurn() { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2', 'p3', 'p4'], }); await harness.performAction('p1', { type: 'start-game', packId: 'hushle-en-basic', turnDurationSeconds: 45, cardsPerTurn: 100, createdBy: 'p1', }); await harness.performAction('p1', { type: 'set-teams', teams: [ { name: 'A', playerIds: ['p1', 'p2'] }, { name: 'B', playerIds: ['p3', 'p4'] }, ], }); await harness.performAction('p1', { type: 'start-turn', teamId: harness.getState().teams[0]!.id, explainerId: 'p1' }); return harness; } it('starts with the turn and keeps its deadline through every card', async () => { const harness = await runningTurn(); const timer = harness.getState().timer; expect(timer).toEqual({ startedAt: '2026-09-29T12:00:00.000Z', durationSeconds: 45, paused: false, endsAt: '2026-09-29T12:00:45.000Z', }); vi.setSystemTime(start + 10_000); await harness.performAction('p1', { type: 'correct-guess' }); vi.setSystemTime(start + 20_000); await harness.performAction('p1', { type: 'pass' }); await harness.performAction('p1', { type: 'next-card' }); await harness.performAction('p3', { type: 'bust-forbidden', bustedBy: 'p3', cardId: harness.getState().currentCard!.id }); expect(harness.getState().timer).toEqual(timer); expect(harness.getState().totalCardsPlayed).toBe(3); }); it('ends the turn\'s scoring when the time is up, after a short grace for a tap in flight', async () => { const harness = await runningTurn(); // A tap sent at the buzzer still counts… vi.setSystemTime(start + 45_000 + HUSHLE_TIME_UP_GRACE_MS); await harness.performAction('p1', { type: 'correct-guess' }); expect(harness.getState().teams[0]!.score).toBe(1); // …but past the grace, no card is scored, skipped, busted or swapped. vi.setSystemTime(start + 45_000 + HUSHLE_TIME_UP_GRACE_MS + 1); const over = JSON.stringify(harness.getState()); for (const action of [ { type: 'correct-guess' as const }, { type: 'pass' as const }, { type: 'penalty' as const }, { type: 'next-card' as const }, ]) { await harness.performAction('p1', action); } await harness.performAction('p3', { type: 'bust-forbidden', bustedBy: 'p3', cardId: harness.getState().currentCard!.id }); expect(JSON.stringify(harness.getState())).toBe(over); // The host moves on: the next team's turn gets a fresh clock. await harness.performAction('p1', { type: 'end-turn' }); const next = harness.getState(); expect(next.currentExplainerId).toBe('p3'); expect(next.timer.startedAt).toBe(new Date(start + 45_000 + HUSHLE_TIME_UP_GRACE_MS + 1).toISOString()); expect(Date.parse(next.timer.endsAt!) - Date.parse(next.timer.startedAt!)).toBe(45_000); }); it('stops the clock between turns, where there is nothing to score', async () => { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2', 'p3', 'p4'], }); await harness.performAction('p1', { type: 'start-game', packId: 'hushle-en-basic', cardsPerTurn: 2, createdBy: 'p1' }); await harness.performAction('p1', { type: 'set-teams', teams: [ { name: 'A', playerIds: ['p1', 'p2'] }, { name: 'B', playerIds: ['p3', 'p4'] }, ], }); await harness.performAction('p1', { type: 'start-turn', teamId: harness.getState().teams[0]!.id, explainerId: 'p1' }); await harness.performAction('p1', { type: 'correct-guess' }); await harness.performAction('p1', { type: 'correct-guess' }); const between = harness.getState(); expect(between.phase).toBe('playing'); expect(between.currentCard).toBeNull(); expect(between.timer).toEqual({ startedAt: null, durationSeconds: 60, paused: true, endsAt: null }); await harness.performAction('p1', { type: 'correct-guess' }); await harness.performAction('p1', { type: 'next-card' }); expect(harness.getState()).toEqual(between); }); }); // ──────────────────────────────────────────────────────────────────── // M20a — Weighted card draw with difficulty distribution // ──────────────────────────────────────────────────────────────────── it('draw respects difficultyDistribution over 100 calls', async () => { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2', 'p3', 'p4'], }); await harness.startGame(); // Force a deterministic distribution: only `medium` cards. await harness.performAction('p1', { type: 'start-game', packId: 'hushle-en-basic', difficultyDistribution: { easy: 0, medium: 1, hard: 0 }, cardsPerTurn: 100, createdBy: 'p1', }); await harness.performAction('p1', { type: 'set-teams', teams: [ { name: 'A', playerIds: ['p1', 'p2'] }, { name: 'B', playerIds: ['p3', 'p4'] }, ], }); const teamA = harness.getState().teams[0]!; await harness.performAction('p1', { type: 'start-turn', teamId: teamA.id, explainerId: 'p1', }); // The first card drawn must be a `medium` card. expect(harness.getState().currentCard?.difficulty).toBe('medium'); // The en deck has 7 medium cards. Draw until either the medium // bucket is exhausted (draw falls back to any unused card) or // the deck runs out — every draw BEFORE that point must be // `medium`. Stop checking as soon as the first non-medium card // shows up; that's the documented fallback behaviour. let mediumSeen = 0; let nonMediumSeen = 0; for (let i = 0; i < 30; i += 1) { const card = harness.getState().currentCard; if (card === null) break; if (card.difficulty === 'medium') { mediumSeen += 1; } else { nonMediumSeen += 1; if (nonMediumSeen === 1) break; } await harness.performAction('p1', { type: 'correct-guess' }); } // 7 medium cards total in the en deck (1 from start-turn + 6 from // correct-guess); the 7th correct-guess then triggers the fallback // to any unused tier. expect(mediumSeen).toBeGreaterThanOrEqual(2); expect(mediumSeen).toBe(7); }); it('draw never repeats a card within the same session', async () => { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2', 'p3', 'p4'], }); await harness.startGame(); await harness.performAction('p1', { type: 'start-game', packId: 'hushle-en-basic', cardsPerTurn: 100, // disable the per-turn cap so we can drain the deck createdBy: 'p1', }); await harness.performAction('p1', { type: 'set-teams', teams: [ { name: 'A', playerIds: ['p1', 'p2'] }, { name: 'B', playerIds: ['p3', 'p4'] }, ], }); const teamA = harness.getState().teams[0]!; await harness.performAction('p1', { type: 'start-turn', teamId: teamA.id, explainerId: 'p1', }); // Drain the deck. `correct-guess` always draws a new card; after 23 // draws we should have seen 24 unique cards (the entire 24-card // en deck). const drawn: string[] = []; for (let i = 0; i < 30; i += 1) { const card = harness.getState().currentCard; if (card === null) break; drawn.push(card.id); await harness.performAction('p1', { type: 'correct-guess' }); } expect(new Set(drawn).size).toBe(drawn.length); expect(drawn.length).toBe(24); }); // ── Classic-Taboo buzzer (bust-forbidden) ───────────────────────── async function setupBustHarness() { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2', 'p3', 'p4'], }); await harness.performAction('p1', { type: 'start-game', packId: 'hushle-en-basic', language: 'en', cardsPerTurn: 100, createdBy: 'p1', }); await harness.performAction('p1', { type: 'set-teams', teams: [ { name: 'A', playerIds: ['p1', 'p2'] }, // p1 explains, p2 guesses { name: 'B', playerIds: ['p3', 'p4'] }, // opponents ], }); const teamA = harness.getState().teams[0]!; await harness.performAction('p1', { type: 'start-turn', teamId: teamA.id, explainerId: 'p1' }); return harness; } it('lets an opposing player bust a forbidden word (-1 and next card)', async () => { const harness = await setupBustHarness(); const before = harness.getState(); const beforeScore = before.teams[0]!.score; const beforeCardId = before.currentCard!.id; // p3 is on team B (opposing) — the buzzer is valid. await harness.performAction('p3', { type: 'bust-forbidden', bustedBy: 'p3', cardId: beforeCardId }); const after = harness.getState(); expect(after.teams[0]!.penaltyCount).toBe(before.teams[0]!.penaltyCount + 1); expect(after.teams[0]!.score).toBe(beforeScore - 1); expect(after.currentCard).not.toBeNull(); expect(after.currentCard!.id).not.toBe(beforeCardId); expect(after.totalCardsPlayed).toBe(before.totalCardsPlayed + 1); }); it('rejects a bust from a teammate of the explainer', async () => { const harness = await setupBustHarness(); const before = JSON.stringify(harness.getState()); // p2 is on team A with the explainer — no self-busting. await harness.performAction('p2', { type: 'bust-forbidden', bustedBy: 'p2', cardId: harness.getState().currentCard!.id }); expect(JSON.stringify(harness.getState())).toBe(before); }); it('rejects a bust from a player with no team (floater/spectator)', async () => { const harness = await setupBustHarness(); const before = JSON.stringify(harness.getState()); await harness.performAction('p4', { type: 'bust-forbidden', bustedBy: 'ghost-player', cardId: harness.getState().currentCard!.id }); expect(JSON.stringify(harness.getState())).toBe(before); }); it('rejects a bust without a server-injected actor id', async () => { const harness = await setupBustHarness(); const before = JSON.stringify(harness.getState()); // No bustedBy — the reducer must not trust an anonymous buzz. await harness.performAction('p3', { type: 'bust-forbidden', cardId: harness.getState().currentCard!.id }); expect(JSON.stringify(harness.getState())).toBe(before); }); it('two BUSTs for the same card (both opponents at once, or a double tap) cost ONE penalty and burn no card', async () => { const harness = await setupBustHarness(); const before = harness.getState(); const cardId = before.currentCard!.id; await harness.performAction('p3', { type: 'bust-forbidden', bustedBy: 'p3', cardId }); const afterFirst = harness.getState(); await harness.performAction('p4', { type: 'bust-forbidden', bustedBy: 'p4', cardId }); const afterSecond = harness.getState(); // The second names a card that is no longer on screen: ignored, same object. expect(afterSecond).toBe(afterFirst); expect(afterSecond.teams[0]!.penaltyCount).toBe(before.teams[0]!.penaltyCount + 1); expect(afterSecond.teams[0]!.score).toBe(before.teams[0]!.score - 1); expect(afterSecond.usedCardIds).toHaveLength(before.usedCardIds.length + 1); expect(afterSecond.totalCardsPlayed).toBe(before.totalCardsPlayed + 1); }); it('ignores a BUST for another card, and one without a card id', async () => { const harness = await setupBustHarness(); const before = harness.getState(); await harness.performAction('p3', { type: 'bust-forbidden', bustedBy: 'p3', cardId: 'not-the-card-on-screen' }); expect(harness.getState()).toBe(before); await harness.performAction('p3', { type: 'bust-forbidden', bustedBy: 'p3' }); expect(harness.getState()).toBe(before); }); it('validateAction: BUST must name a card; other actions pass through to the reducer', () => { const validate = hushlePlugin.validateAction!; expect(validate({ type: 'bust-forbidden', bustedBy: 'p3' })).toMatch(/cardId/); expect(validate({ type: 'bust-forbidden', bustedBy: 'p3', cardId: '' })).toMatch(/cardId/); expect(validate({ type: 'bust-forbidden', bustedBy: 'p3', cardId: 'x'.repeat(129) })).toMatch(/cardId/); expect(validate({ type: 'bust-forbidden', bustedBy: 'p3', cardId: 'en-easy-001' })).toBeNull(); expect(validate({ type: 'correct-guess' })).toBeNull(); expect(validate(null)).toMatch(/object/); }); // ── Stale intent (B-06): the host's card moves name their deal ────── it('a host move naming the deal in play applies; one without a deal number applies as before', async () => { const harness = await setupBustHarness(); const s0 = harness.getState(); expect(s0.cardSeq).toBe(1); await harness.performAction('p1', { type: 'correct-guess', cardSeq: s0.cardSeq }); const s1 = harness.getState(); expect(s1.teams[0]!.correctCount).toBe(1); expect(s1.cardSeq).toBe(2); await harness.performAction('p1', { type: 'pass', cardSeq: s1.cardSeq }); await harness.performAction('p1', { type: 'penalty', cardSeq: harness.getState().cardSeq }); await harness.performAction('p1', { type: 'next-card', cardSeq: harness.getState().cardSeq }); // A panel from before deal numbers (no cardSeq, or null) still plays. await harness.performAction('p1', { type: 'correct-guess' }); await harness.performAction('p1', { type: 'correct-guess', cardSeq: null }); const end = harness.getState(); expect(end.teams[0]).toMatchObject({ correctCount: 3, passCount: 1, penaltyCount: 1, score: 2 }); expect(end.cardSeq).toBe(7); expect(end.cardsPlayedThisTurn).toBe(6); }); it("BUST on card A, then the host's Got it for A: only the BUST applies — card B is not scored", async () => { const harness = await setupBustHarness(); const before = harness.getState(); const cardA = before.currentCard!.id; // Both pressed while A was on screen; the BUST reaches the server first. await harness.performAction('p3', { type: 'bust-forbidden', bustedBy: 'p3', cardId: cardA }); const afterBust = harness.getState(); const cardB = afterBust.currentCard!; expect(cardB.id).not.toBe(cardA); expect(afterBust.cardSeq).toBe(before.cardSeq + 1); await harness.performAction('p1', { type: 'correct-guess', cardSeq: before.cardSeq }); // Ignored: the same object, A penalised once, B still in play and unscored. expect(harness.getState()).toBe(afterBust); expect(afterBust.teams[0]).toMatchObject({ score: before.teams[0]!.score - 1, correctCount: before.teams[0]!.correctCount, penaltyCount: before.teams[0]!.penaltyCount + 1, }); expect(afterBust.totalCardsPlayed).toBe(before.totalCardsPlayed + 1); // The host's next press comes from B's snapshot and scores B. await harness.performAction('p1', { type: 'correct-guess', cardSeq: afterBust.cardSeq }); expect(harness.getState().teams[0]!.correctCount).toBe(before.teams[0]!.correctCount + 1); }); it('the host and a moderator pressing Got it at once score ONE card', async () => { const harness = await setupBustHarness(); const before = harness.getState(); await harness.performAction('p1', { type: 'correct-guess', cardSeq: before.cardSeq }); const afterFirst = harness.getState(); await harness.performAction('p2', { type: 'correct-guess', cardSeq: before.cardSeq }); expect(harness.getState()).toBe(afterFirst); expect(afterFirst.teams[0]!.correctCount).toBe(before.teams[0]!.correctCount + 1); expect(afterFirst.usedCardIds).toHaveLength(before.usedCardIds.length + 1); }); it('a stale Skip, Penalty or Next card is ignored — the same object, no burnt card', async () => { const harness = await setupBustHarness(); const stale = harness.getState().cardSeq; await harness.performAction('p1', { type: 'next-card', cardSeq: stale }); const current = harness.getState(); expect(current.cardSeq).toBe(stale + 1); for (const action of [ { type: 'correct-guess' as const, cardSeq: stale }, { type: 'pass' as const, cardSeq: stale }, { type: 'penalty' as const, cardSeq: stale }, { type: 'next-card' as const, cardSeq: stale }, // A deal that has not happened yet is no more current than an old one. { type: 'correct-guess' as const, cardSeq: current.cardSeq + 1 }, ]) { await harness.performAction('p1', action); expect(harness.getState(), action.type).toBe(current); } }); it('keeps counting deals across games, so a late press from the last game never matches', async () => { const harness = await setupBustHarness(); await harness.performAction('p1', { type: 'correct-guess' }); const lastGame = harness.getState().cardSeq; await harness.performAction('p1', { type: 'end-game' }); await harness.performAction('p1', { type: 'start-game', packId: 'hushle-en-basic', cardsPerTurn: 100, createdBy: 'p1' }); expect(harness.getState().cardSeq).toBe(lastGame); await harness.performAction('p1', { type: 'set-teams', teams: [ { name: 'A', playerIds: ['p1', 'p2'] }, { name: 'B', playerIds: ['p3', 'p4'] }, ], }); await harness.performAction('p1', { type: 'start-turn', teamId: harness.getState().teams[0]!.id, explainerId: 'p1' }); const fresh = harness.getState(); expect(fresh.cardSeq).toBe(lastGame + 1); for (let seq = 0; seq <= lastGame; seq += 1) { await harness.performAction('p1', { type: 'correct-guess', cardSeq: seq }); expect(harness.getState()).toBe(fresh); } }); it("validateAction: a card move's cardSeq, when present, is a non-negative integer", () => { const validate = hushlePlugin.validateAction!; for (const type of ['correct-guess', 'pass', 'penalty', 'next-card']) { expect(validate({ type })).toBeNull(); expect(validate({ type, cardSeq: null })).toBeNull(); expect(validate({ type, cardSeq: 0 })).toBeNull(); expect(validate({ type, cardSeq: 42 })).toBeNull(); for (const cardSeq of [-1, 1.5, '3', Number.NaN, Number.MAX_SAFE_INTEGER + 1, {}]) { expect(validate({ type, cardSeq }), `${type} ${String(cardSeq)}`).toMatch(/cardSeq/); } } // Other actions do not look at it. expect(validate({ type: 'end-turn', cardSeq: 'x' })).toBeNull(); }); // ── An empty deck: nothing dealt, nothing to score ───────────────── async function emptyDeckTurn() { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2', 'p3', 'p4'], }); const deck = ['one', 'two'].map((word) => ({ id: `card-${word}`, language: 'en', word, forbiddenWords: ['x'], difficulty: 'easy' as const, })); await harness.performAction('p1', { type: 'start-game', packId: 'tiny', cardsPerTurn: 100, createdBy: 'p1', deck }); await harness.performAction('p1', { type: 'set-teams', teams: [ { name: 'A', playerIds: ['p1', 'p2'] }, { name: 'B', playerIds: ['p3', 'p4'] }, ], }); await harness.performAction('p1', { type: 'start-turn', teamId: harness.getState().teams[0]!.id, explainerId: 'p1' }); // Card one is in play; Got it deals card two, the last one. await harness.performAction('p1', { type: 'correct-guess' }); expect(harness.getState().currentCard?.word).toBeDefined(); // The host ends the turn on the last card: the next turn starts with nothing to deal. await harness.performAction('p1', { type: 'end-turn' }); return harness; } it('a turn that starts on an empty deck has no card, and Got it / Skip / Penalty / Next card / BUST score nothing', async () => { const harness = await emptyDeckTurn(); const empty = harness.getState(); expect(empty.phase).toBe('playing'); expect(empty.currentCard).toBeNull(); expect(empty.timer.startedAt).not.toBeNull(); expect(empty.currentTeamId).toBe(empty.teams[1]!.id); for (const action of [ { type: 'correct-guess' as const }, { type: 'pass' as const }, { type: 'penalty' as const }, { type: 'next-card' as const }, // With the deal number of this turn, too. { type: 'correct-guess' as const, cardSeq: empty.cardSeq }, ]) { await harness.performAction('p1', action); expect(harness.getState(), action.type).toBe(empty); } await harness.performAction('p1', { type: 'bust-forbidden', bustedBy: 'p1', cardId: 'card-two' }); expect(harness.getState()).toBe(empty); expect(empty.teams[1]).toMatchObject({ score: 0, correctCount: 0, passCount: 0, penaltyCount: 0 }); // The host can still move on or finish. await harness.performAction('p1', { type: 'end-game' }); expect(harness.getState().phase).toBe('ended'); }); // ── Turn moves name their turn (B-06) ────────────────────────────── it('end-turn naming the current turn ends it; without a turn number it ends it as before', async () => { const harness = await setupBustHarness(); const t1 = harness.getState(); expect(t1.turnNumber).toBe(1); await harness.performAction('p1', { type: 'end-turn', turnNumber: t1.turnNumber }); const t2 = harness.getState(); expect(t2.turnNumber).toBe(2); expect(t2.currentTeamId).toBe(t2.teams[1]!.id); await harness.performAction('p1', { type: 'end-turn' }); expect(harness.getState().turnNumber).toBe(3); await harness.performAction('p1', { type: 'end-turn', turnNumber: null }); expect(harness.getState().turnNumber).toBe(4); }); it('End turn pressed twice — or by the host and a moderator at once — ends ONE turn', async () => { const harness = await setupBustHarness(); const t1 = harness.getState(); await harness.performAction('p1', { type: 'end-turn', turnNumber: t1.turnNumber }); const t2 = harness.getState(); await harness.performAction('p2', { type: 'end-turn', turnNumber: t1.turnNumber }); // The second press names turn 1, which is over: team B's turn goes on. expect(harness.getState()).toBe(t2); expect(t2.currentTeamId).toBe(t2.teams[1]!.id); expect(t2.currentExplainerId).toBe('p3'); }); it('Start next turn pressed twice between turns starts ONE turn', async () => { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2', 'p3', 'p4'], }); await harness.performAction('p1', { type: 'start-game', packId: 'hushle-en-basic', cardsPerTurn: 1, createdBy: 'p1' }); await harness.performAction('p1', { type: 'set-teams', teams: [ { name: 'A', playerIds: ['p1', 'p2'] }, { name: 'B', playerIds: ['p3', 'p4'] }, ], }); await harness.performAction('p1', { type: 'start-turn', teamId: harness.getState().teams[0]!.id, explainerId: 'p1', turnNumber: 0 }); await harness.performAction('p1', { type: 'correct-guess' }); const between = harness.getState(); expect(between.timer.startedAt).toBeNull(); await harness.performAction('p1', { type: 'end-turn', turnNumber: between.turnNumber }); const next = harness.getState(); expect(next.turnNumber).toBe(between.turnNumber + 1); await harness.performAction('p1', { type: 'end-turn', turnNumber: between.turnNumber }); expect(harness.getState()).toBe(next); }); it('Start first turn pressed twice starts ONE turn — no second card, no restarted clock', async () => { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2', 'p3', 'p4'], }); await harness.performAction('p1', { type: 'start-game', packId: 'hushle-en-basic', cardsPerTurn: 100, createdBy: 'p1' }); await harness.performAction('p1', { type: 'set-teams', teams: [ { name: 'A', playerIds: ['p1', 'p2'] }, { name: 'B', playerIds: ['p3', 'p4'] }, ], }); const setup = harness.getState(); expect(setup.turnNumber).toBe(0); const start = { type: 'start-turn' as const, teamId: setup.teams[0]!.id, explainerId: 'p1', turnNumber: setup.turnNumber }; await harness.performAction('p1', start); const started = harness.getState(); expect(started.turnNumber).toBe(1); await harness.performAction('p1', start); expect(harness.getState()).toBe(started); expect(started.usedCardIds).toHaveLength(1); }); it('a turn number from another turn — older or newer — is ignored', async () => { const harness = await setupBustHarness(); const t1 = harness.getState(); for (const turnNumber of [0, 2, 99]) { await harness.performAction('p1', { type: 'end-turn', turnNumber }); expect(harness.getState(), String(turnNumber)).toBe(t1); await harness.performAction('p1', { type: 'start-turn', teamId: t1.teams[1]!.id, explainerId: 'p3', turnNumber }); expect(harness.getState(), String(turnNumber)).toBe(t1); } }); it("validateAction: a turn move's turnNumber, when present, is a non-negative integer", () => { const validate = hushlePlugin.validateAction!; for (const base of [{ type: 'end-turn' }, { type: 'start-turn', teamId: 't', explainerId: null }]) { expect(validate(base)).toBeNull(); expect(validate({ ...base, turnNumber: null })).toBeNull(); expect(validate({ ...base, turnNumber: 0 })).toBeNull(); expect(validate({ ...base, turnNumber: 7 })).toBeNull(); for (const turnNumber of [-1, 2.5, '3', Number.NaN, {}]) { expect(validate({ ...base, turnNumber }), `${base.type} ${String(turnNumber)}`).toMatch(/turnNumber/); } } expect(validate({ type: 'correct-guess', turnNumber: 'x' })).toBeNull(); }); }); describe('Hushle play again (restartActions)', () => { it('declares start-game as its only restart action', () => { expect(hushlePlugin.restartActions).toEqual(['start-game']); }); it('start-game from the ended phase starts a new game with fresh teams, scores and deck', async () => { const harness = createTestHarness[2]>({ plugin: hushlePlugin, players: ['p1', 'p2', 'p3', 'p4'], }); await harness.startGame(); await harness.performAction('p1', { type: 'start-game', packId: 'hushle-en-basic', language: 'en', createdBy: 'p1' }); await harness.performAction('p1', { type: 'set-teams', teams: [ { name: 'A', playerIds: ['p1', 'p2'] }, { name: 'B', playerIds: ['p3', 'p4'] }, ], }); await harness.performAction('p1', { type: 'start-turn', teamId: harness.getState().teams[0]!.id, explainerId: 'p1' }); await harness.performAction('p1', { type: 'correct-guess' }); await harness.performAction('p1', { type: 'end-game' }); expect(harness.getState().phase).toBe('ended'); await harness.performAction('p1', { type: 'start-game', packId: 'hushle-en-basic', language: 'en', createdBy: 'p1' }); const again = harness.getState(); expect(again.phase).toBe('team_setup'); expect(again.teams).toEqual([]); expect(again.usedCardIds).toEqual([]); expect(again.totalCardsPlayed).toBe(0); expect(again.currentCard).toBeNull(); }); });