plugins/hushle/src/__tests__/hushle.test.ts
Rama main · réplica de lectura del repositorio original.
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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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<HushleState, Parameters<typeof hushlePlugin.handleAction>[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();
});
});