import { createElement } from 'react'; import type { GamePlugin } from '@lobbyforge/plugin-sdk'; import { CATALOG_SUMMARY_KEY, PluginPermission, loadPluginLocale } from '@lobbyforge/plugin-sdk'; import { hushleReducer, validateHushleAction } from './actions'; import { LOCALE_TABLES, SHIPPED_LOCALES } from './locales.generated'; import { createHushleInitialState, migrateHushleState } from './state'; import type { HushleAction, HushleState } from './state'; import { HushlePanel, type HushlePanelClientProps } from './renderClient'; import { HUSHLE_PLUGIN_ID } from './plugin-id'; // Also registered by the panel, but that is a 'use client' module the // server never evaluates — the host reads `catalog.summary` server-side. loadPluginLocale(HUSHLE_PLUGIN_ID, LOCALE_TABLES); export type { HushleAction, HushleState, HushleTeam, HushleCard, HushleSettings, HushlePhase, HushleLanguage, HushleDifficulty, HushleTimer, } from './state'; export { createHushleInitialState, migrateHushleState, HUSHLE_STATE_VERSION, HUSHLE_DEFAULT_TURN_DURATION_SECONDS, HUSHLE_DEFAULT_CARDS_PER_TURN, HUSHLE_DEFAULT_TEAM_SIZE, HUSHLE_DEFAULT_DIFFICULTY_DISTRIBUTION, HUSHLE_TIME_UP_GRACE_MS, } from './state'; export { hushleExplainerQueue, hushleNextExplainerForTeam } from './actions'; export { HUSHLE_PLUGIN_ID } from './plugin-id'; export { HUSHLE_BUILTIN_PACKS, getDefaultPackSlugForLanguage, getLanguageForPackSlug, } from './decks'; // Server-only: the seeder imports @lobbyforge/db which transitively pulls // in `postgres` (Node.js-only). Import via the subpath // `@lobbyforge/hushle/builtInPacks` from server-side code only. // `export { seedBuiltinHushlePacks } from './builtInPacks';` would crash // the client bundle for the room page. export const hushlePlugin: GamePlugin = { manifest: { id: HUSHLE_PLUGIN_ID, name: 'Hushle', version: '0.3.0', type: 'game', minAppVersion: '0.1.0', permissions: [ PluginPermission.MANAGE_GAME_SESSION, PluginPermission.MANAGE_SCORES, PluginPermission.SEND_ROOM_MESSAGE, PluginPermission.MANAGE_TIMER, ], // Derived from locales/*.json, so the catalogue can never claim a // language the plugin does not actually ship. locales: SHIPPED_LOCALES, entryClient: './renderClient.js', catalog: { category: 'game', // Translated in locales/*.json; the host shows the viewer's language. summary: LOCALE_TABLES.en[CATALOG_SUMMARY_KEY], publisher: 'LobbyForge', trustLevel: 'official', playerConfig: { minPlayers: 4, maxPlayers: 12, defaultMaxPlayers: 8, supportsSpectators: true, supportsQueue: true, overflowPolicy: 'spectator', }, requiresVoiceRoom: true, externalAccountRequired: false, compatibleAppVersion: '>=0.2.0', tags: ['word-game', 'party', 'voice'], }, }, actionPolicies: { 'start-game': { role: 'host' }, 'set-teams': { role: 'host' }, 'start-turn': { role: 'host' }, 'set-explainer': { role: 'host' }, 'next-card': { role: 'host' }, 'correct-guess': { role: 'host' }, pass: { role: 'host' }, penalty: { role: 'host' }, /** * Classic-Taboo buzzer: any SERVER MEMBER may dispatch it (the * route has already verified server membership). The host injects * the authenticated actor id as `bustedBy` (actorFields) and the * reducer verifies the caller sits on an OPPOSING team per the * host-configured rosters — teammates and floaters cannot bust. * ('player' role is unusable today: game_session_players is only * populated for the creator, so a 'player' check would 403 real * opponents.) */ 'bust-forbidden': { role: 'member', actorFields: ['bustedBy'] }, 'end-turn': { role: 'host' }, 'end-game': { role: 'host' }, }, // "Start new game" on the final screen: the host accepts `start-game` from // the ended phase (and nothing else — every other action is refused with // `session_ended`). The reducer sends everyone back to team setup. restartActions: ['start-game'], validateAction: validateHushleAction, createInitialState: () => createHushleInitialState(), handleAction: (_ctx, state, action) => hushleReducer(state, action), migrateState: (raw: unknown) => migrateHushleState(raw), // beta-review: this used to CALL HushlePanel as a plain function, so // the panel's hooks (useState/useEffect/useMemo) were appended to the // hook list of whatever component invoked renderClient. The host's // panel is rendered conditionally, so the hook count changed between // renders and React threw #310 ("rendered more hooks than during the // previous render") — the whole voice room crashed to its error // boundary the moment an activity loaded. Returning an ELEMENT gives // the panel its own component instance and its own hooks. renderClient: (props: unknown) => createElement(HushlePanel, props as HushlePanelClientProps), };