@joystyk/launcher 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +245 -2
- package/dist/browser/daily-prefetch.d.ts +8 -0
- package/dist/browser/daily-prefetch.js +32 -0
- package/dist/browser/daily-prefetch.js.map +1 -0
- package/dist/browser/index.d.ts +3 -0
- package/dist/browser/index.js +4 -0
- package/dist/browser/rooms.d.ts +2 -0
- package/dist/browser/rooms.js +49 -0
- package/dist/browser/rooms.js.map +1 -0
- package/dist/browser/services.d.ts +127 -0
- package/dist/browser/services.js +95 -0
- package/dist/browser/services.js.map +1 -0
- package/dist/browser/stage.d.ts +28 -0
- package/dist/browser/stage.js +1195 -0
- package/dist/browser/stage.js.map +1 -0
- package/dist/fixture/engine.d.ts +60 -0
- package/dist/fixture/engine.js +164 -0
- package/dist/fixture/engine.js.map +1 -0
- package/dist/fixture/index.d.ts +52 -0
- package/dist/fixture/index.js +63 -0
- package/dist/fixture/index.js.map +1 -0
- package/dist/fixture/module.d.ts +2 -0
- package/dist/fixture/module.js +135 -0
- package/dist/fixture/module.js.map +1 -0
- package/dist/fixture/probe.d.ts +10 -0
- package/dist/fixture/probe.js +19 -0
- package/dist/fixture/probe.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -0
- package/dist/launcher.d.ts +190 -0
- package/dist/launcher.js +4614 -0
- package/dist/launcher.js.map +1 -0
- package/dist/model.d.ts +431 -0
- package/dist/model.js +566 -0
- package/dist/model.js.map +1 -0
- package/dist/report-problem.d.ts +39 -0
- package/dist/report-problem.js +359 -0
- package/dist/report-problem.js.map +1 -0
- package/dist/report.d.ts +18 -0
- package/dist/report.js +32 -0
- package/dist/report.js.map +1 -0
- package/dist/room-drivers.d.ts +26 -0
- package/dist/room-drivers.js +92 -0
- package/dist/room-drivers.js.map +1 -0
- package/dist/safe-point.d.ts +33 -0
- package/dist/safe-point.js +44 -0
- package/dist/safe-point.js.map +1 -0
- package/dist/save-slots.d.ts +6 -0
- package/dist/save-slots.js +18 -0
- package/dist/save-slots.js.map +1 -0
- package/dist/theme.d.ts +35 -0
- package/dist/theme.js +111 -0
- package/dist/theme.js.map +1 -0
- package/package.json +67 -11
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"model.js","names":[],"sources":["../src/model.ts"],"sourcesContent":["// The launcher's pure parts: what a game page tells it, the modes it offers, the seats it\n// builds, what it tells the stage that runs the game and what the stage reports back, and\n// the words of the results screen. No DOM and no I/O, so the model tests run in Node.\nimport {\n ASSIST_KINDS,\n isGameOptions,\n optionsForMode,\n type GameOption,\n isGameId,\n isJson,\n type AssistKind,\n type GameManifest,\n type Json,\n type Mode,\n type Outcome,\n type OutcomeResult,\n type SeatInfo,\n type Validator,\n} from '@joystyk/contracts';\nimport { dailyWordStatsSchema, type DailyWordStats } from '@joystyk/daily';\nimport { checkDisplayName } from '@joystyk/identity';\nimport type { LocalMode } from '@joystyk/session';\nimport type { StoredPreferences } from '@joystyk/storage';\n\n// --- The game, as the page describes it ------------------------------------------------------\n\nexport type GameStatus = 'available' | 'coming-soon';\n\n/** What the launcher needs from the page: manifest facts, plus whether the game exists yet. */\nexport type LauncherGame = {\n readonly id: string;\n readonly name: string;\n readonly modes: readonly Mode[];\n /** `default`: the table size the player-count picker starts at against the computer. */\n readonly players: { readonly min: number; readonly max: number; readonly default?: number | undefined };\n readonly aiLevels: readonly string[];\n readonly status: GameStatus;\n /** The game plays AI-generated content: it stays locked until the player accepts the notice (D-44). */\n readonly aiContent?: boolean;\n readonly options?: readonly GameOption[];\n};\n\nexport const MODES: readonly Mode[] = ['solo', 'ai', 'pass-and-play', 'p2p', 'server', 'daily'];\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value);\nconst isStringArray = (value: unknown): value is string[] =>\n Array.isArray(value) && value.every((v) => typeof v === 'string');\nconst isMode = (value: unknown): value is Mode =>\n typeof value === 'string' && (MODES as readonly string[]).includes(value);\n\nexport const isLauncherGame = (value: unknown): value is LauncherGame => {\n if (!isRecord(value) || !isGameId(value['id']) || typeof value['name'] !== 'string') return false;\n const players = value['players'];\n return (\n Array.isArray(value['modes']) &&\n value['modes'].every(isMode) &&\n isRecord(players) &&\n Number.isInteger(players['min']) &&\n Number.isInteger(players['max']) &&\n (players['default'] === undefined || Number.isInteger(players['default'])) &&\n isStringArray(value['aiLevels']) &&\n (value['status'] === 'available' || value['status'] === 'coming-soon') &&\n (value['aiContent'] === undefined || typeof value['aiContent'] === 'boolean') &&\n (value['options'] === undefined || isGameOptions(value['options']))\n );\n};\n\n/** The launcher's view of a manifest, for pages that have the manifest itself. */\nexport const launcherGame = (manifest: GameManifest, status: GameStatus = 'available'): LauncherGame => ({\n id: manifest.id,\n name: manifest.name,\n modes: [...manifest.modes],\n players:\n manifest.players.default === undefined\n ? { min: manifest.players.min, max: manifest.players.max }\n : { min: manifest.players.min, max: manifest.players.max, default: manifest.players.default },\n aiLevels: manifest.ai === null ? [] : [...manifest.ai.levels],\n status,\n aiContent: manifest.aiContent === true,\n ...(manifest.options === undefined ? {} : { options: manifest.options }),\n});\n\n/** The AI-content notice (D-44; wording approved as D-52). */\nexport const AI_NOTICE = {\n title: 'This game uses AI-made content',\n body: 'Some puzzles and clues here are written by an AI model. Each one passes automatic safety checks before it appears, but no person reads every one first, so now and then something may be odd or wrong. If you spot a problem, use Report a problem.',\n scope: 'Accepting unlocks all AI-content games on this device. You can change this anytime in Preferences.',\n} as const;\n\n/** True when an AI-content game must show the notice before it can start. */\nexport const needsAiNotice = (game: LauncherGame, preferences: StoredPreferences): boolean =>\n game.aiContent === true && preferences.aiContentAccepted !== true;\n\n/**\n * The access gate (monetization.md): where premium games and modes will ask for an\n * entitlement in v2. Every game is free in v1, so it is always open.\n */\nexport const accessGate = (access: GameManifest['access']): 'open' => {\n switch (access) {\n case 'free':\n return 'open';\n }\n};\n\n// --- Modes --------------------------------------------------------------------------------\n\nexport const MODE_LABELS: Readonly<Record<Mode, string>> = {\n solo: 'Solo',\n ai: 'Against the computer',\n 'pass-and-play': 'Pass and play',\n p2p: 'Online, peer to peer',\n server: 'Online with friends',\n daily: 'Daily puzzle',\n};\n\n/** The modes the launcher runs on a local session. The daily puzzle is a local session on today's published config. */\nexport const toLocalMode = (mode: Mode): LocalMode | null =>\n mode === 'solo' || mode === 'ai' || mode === 'pass-and-play' || mode === 'daily' ? mode : null;\n\n/** What the stage runs: a local session, or a server room (D-48). P2P rooms arrive later. */\nexport type StageMode = LocalMode | 'server';\n\nexport const toStageMode = (mode: Mode): StageMode | null => (mode === 'server' ? 'server' : toLocalMode(mode));\n\nexport type ModeOption = {\n readonly mode: Mode;\n readonly label: string;\n readonly playable: boolean;\n /** Why it can't be picked, or null. */\n readonly note: string | null;\n};\n\n/**\n * What the launcher knows about the room server (`GET /api/rooms/games`): still asking, the\n * games it hosts, or no usable answer. A manifest may list `server` before the room server\n * hosts the game; only a hosted game offers Online with friends.\n */\nexport type OnlineRooms =\n | { readonly _tag: 'Checking' }\n | { readonly _tag: 'Hosted'; readonly games: readonly string[]; readonly protocol: number }\n | { readonly _tag: 'Unreachable' };\n\nexport const ONLINE_CHECKING: OnlineRooms = { _tag: 'Checking' };\nexport const ONLINE_UNREACHABLE: OnlineRooms = { _tag: 'Unreachable' };\n\n/** The room server's answer, checked at the boundary; anything else (an error page, a proxy's 502 body) is undefined. */\nexport const parseHostedGames = (value: unknown): OnlineRooms | undefined => {\n if (!isRecord(value)) return undefined;\n const { games, protocol } = value;\n if (!Array.isArray(games) || !games.every(isGameId) || !Number.isInteger(protocol) || (protocol as number) < 1)\n return undefined;\n return { _tag: 'Hosted', games: [...games], protocol: protocol as number };\n};\n\n/** True when the room server opens rooms for this game. */\nexport const hostedOnline = (game: LauncherGame, online: OnlineRooms): boolean =>\n online._tag === 'Hosted' && online.games.includes(game.id);\n\n/** Why Online with friends can't be picked yet: unknown while asking, coming soon (D-53) when not hosted. */\nconst onlineNote = (game: LauncherGame, online: OnlineRooms): string | null => {\n switch (online._tag) {\n case 'Checking':\n return null;\n case 'Unreachable':\n return 'Unavailable right now';\n case 'Hosted':\n return online.games.includes(game.id) ? null : 'Coming soon';\n }\n};\n\n/**\n * One option per mode in the manifest, in manifest order. Online with friends is playable only\n * once the room server says it hosts the game; until it answers, and when it can't be reached,\n * the local modes are unaffected.\n */\nexport const modeOptions = (game: LauncherGame, online: OnlineRooms = ONLINE_CHECKING): readonly ModeOption[] =>\n game.modes.map((mode) => {\n const run = toStageMode(mode);\n const fits =\n run === 'solo' || run === 'daily'\n ? game.players.min <= 1\n : run === 'ai'\n ? game.aiLevels.length > 0\n : run === 'pass-and-play' || run === 'server'\n ? game.players.max >= 2\n : false;\n const reachable = run !== 'server' || hostedOnline(game, online);\n const playable = game.status === 'available' && run !== null && fits && reachable;\n const note =\n run === null\n ? 'Coming soon'\n : run === 'server' && game.status === 'available' && fits\n ? onlineNote(game, online)\n : null;\n return { mode, label: MODE_LABELS[mode], playable, note };\n });\n\n/**\n * The player counts a mode allows: solo is one; local multiplayer takes two or more; a room\n * sizes itself. In pass-and-play the count is people: a game with AI and more seats than\n * people (Hearts, Spades) lets two or more share the device while the computer fills the rest.\n */\nexport const playerCounts = (game: LauncherGame, mode: Mode | null): readonly number[] => {\n if ((mode !== 'ai' && mode !== 'pass-and-play') || game.players.max === 1) return [1];\n const counts: number[] = [];\n const fewest = mode === 'pass-and-play' && game.aiLevels.length > 0 ? 2 : Math.max(2, game.players.min);\n for (let n = fewest; n <= game.players.max; n += 1) counts.push(n);\n return counts;\n};\n\n/**\n * The count the picker starts at for a mode: against the computer, the game's declared\n * default when the mode offers it (Shift 23 seats five, D-58); otherwise the fewest. Other\n * modes count people, and a room sizes itself, so the default never applies to them.\n */\nexport const startingCount = (game: LauncherGame, mode: Mode | null): number => {\n const counts = playerCounts(game, mode);\n const preset = game.players.default;\n return mode === 'ai' && preset !== undefined && counts.includes(preset) ? preset : (counts[0] ?? 1);\n};\n\n/** Pass-and-play with fewer people than the game's seats: the computer plays the rest. */\nexport const computerSeatsFor = (game: LauncherGame, setup: Setup): number =>\n setup.mode === 'pass-and-play' ? Math.max(0, game.players.min - setup.players) : 0;\n\n// --- Setup and seats ------------------------------------------------------------------------\n\nexport type Setup = {\n readonly mode: Mode | null;\n readonly aiLevel: string | null;\n readonly players: number;\n /** Pass-and-play names as typed, by seat. */\n readonly names: readonly string[];\n /** A problem per name, by seat, or null. */\n readonly errors: readonly (string | null)[];\n /** Online with friends: the invite code (or link) as typed into the join form. */\n readonly code: string;\n /** Join to watch, even if a seat is free (D-33). */\n readonly watch: boolean;\n /** Why the code can't be used, or null. */\n readonly codeError: string | null;\n readonly options?: Readonly<Record<string, string | number | boolean>>;\n};\n\nexport const initialSetup = (game: LauncherGame, online: OnlineRooms = ONLINE_CHECKING): Setup => {\n const mode = modeOptions(game, online).find((option) => option.playable)?.mode ?? null;\n return {\n mode,\n aiLevel: game.aiLevels[0] ?? null,\n players: startingCount(game, mode),\n names: [],\n errors: [],\n code: '',\n watch: false,\n codeError: null,\n options: Object.fromEntries(optionsForMode(game.options ?? [], mode ?? 'solo').map((o) => [o.key, o.default])),\n };\n};\n\n// --- Rooms ------------------------------------------------------------------------------------\n\n/** Invite codes: six characters without look-alikes (no 0/O or 1/I), as the room server issues them. */\nconst ROOM_CODE = /^[A-HJ-NP-Z2-9]{6}$/;\n\nexport const isRoomCode = (value: unknown): value is string => typeof value === 'string' && ROOM_CODE.test(value);\n\n/**\n * The room code in what a player typed or pasted: a code in any case, with spaces or dashes\n * (\"k7m-2qx\"), or a whole invite link (`…?room=K7M2QX`). Null when there is none.\n */\nexport const parseRoomCode = (input: string): string | null => {\n const trimmed = input.trim();\n const fromLink = /[?&]room=([A-Za-z0-9-]+)/.exec(trimmed)?.[1] ?? trimmed;\n const code = fromLink.toUpperCase().replace(/[\\s-]/g, '');\n return isRoomCode(code) ? code : null;\n};\n\n/** \"K7M 2QX\": a code grouped for reading aloud and copying by eye. */\nexport const spacedCode = (code: string): string => `${code.slice(0, 3)} ${code.slice(3)}`;\n\n/** How the stage opens its room. */\nexport type RoomPlan =\n { readonly action: 'create' } | { readonly action: 'join'; readonly code: string; readonly watch: boolean };\n\n/** A clock choice in the lobby (D-34, D-42): a per-move clock, a time bank, or Off. */\nexport type ClockSetting =\n | { readonly kind: 'move'; readonly ms: number }\n | { readonly kind: 'bank'; readonly initialMs: number; readonly incrementMs: number }\n | { readonly kind: 'off' };\n\n/** A select option for a clock setting; `value` round-trips through `parseClockValue`. */\nexport type ClockOption = { readonly value: string; readonly label: string };\n\nexport const clockValue = (setting: ClockSetting): string =>\n setting.kind === 'off'\n ? 'off'\n : setting.kind === 'move'\n ? `move:${String(setting.ms)}`\n : `bank:${String(setting.initialMs)}:${String(setting.incrementMs)}`;\n\nexport const parseClockValue = (value: string | undefined): ClockSetting | undefined => {\n if (value === 'off') return { kind: 'off' };\n const move = /^move:(\\d{1,9})$/.exec(value ?? '');\n if (move?.[1] !== undefined) return { kind: 'move', ms: Number(move[1]) };\n const bank = /^bank:(\\d{1,9}):(\\d{1,9})$/.exec(value ?? '');\n if (bank?.[1] !== undefined && bank[2] !== undefined)\n return { kind: 'bank', initialMs: Number(bank[1]), incrementMs: Number(bank[2]) };\n return undefined;\n};\n\nconst duration = (ms: number): string => {\n const s = Math.round(ms / 1000);\n if (s >= 60 && s % 60 === 0) return s === 60 ? '1 minute' : `${String(s / 60)} minutes`;\n return s === 1 ? '1 second' : `${String(s)} seconds`;\n};\n\n/** \"30 seconds a move\", \"5 minutes, plus 2 seconds a move\", \"Off\". */\nexport const clockLabel = (setting: ClockSetting): string => {\n switch (setting.kind) {\n case 'off':\n return 'Off';\n case 'move':\n return `${duration(setting.ms)} a move`;\n case 'bank':\n return setting.incrementMs > 0\n ? `${duration(setting.initialMs)}, plus ${duration(setting.incrementMs)} a move`\n : `${duration(setting.initialMs)} each`;\n }\n};\n\n/**\n * The lobby's clock choices for a manifest clock: its presets with the default marked, then\n * Off, which private rooms always offer and never default to (D-42). None for a game without\n * a clock.\n */\nexport const clockOptions = (clock: GameManifest['clock']): readonly ClockOption[] => {\n if (clock === null) return [];\n const presets: ClockSetting[] =\n clock.mode === 'bank'\n ? clock.presets.map((p) => ({ kind: 'bank', initialMs: p.initialMs, incrementMs: p.incrementMs }))\n : clock.presets.map((s) => ({ kind: 'move', ms: s * 1000 }));\n const usual: ClockSetting =\n clock.mode === 'bank'\n ? { kind: 'bank', initialMs: clock.default.initialMs, incrementMs: clock.default.incrementMs }\n : { kind: 'move', ms: clock.default * 1000 };\n const all = presets.some((p) => clockValue(p) === clockValue(usual)) ? presets : [usual, ...presets];\n return [\n ...all.map((p) => ({\n value: clockValue(p),\n label: clockValue(p) === clockValue(usual) ? `${clockLabel(p)} (usual)` : clockLabel(p),\n })),\n { value: 'off', label: 'Off: no clock' },\n ];\n};\n\n/** A seat in the lobby, as the stage reports it. */\nexport type RoomSeat = {\n readonly id: number;\n readonly name: string;\n readonly controller: 'human' | 'ai';\n readonly aiLevel: string | null;\n /** A player holds the seat (also while the computer covers for them). */\n readonly claimed: boolean;\n readonly ready: boolean;\n readonly connected: boolean;\n};\n\n/** The current decision clock, for the bar; `paused` while nobody is at the table (D-58). */\nexport type RoomTimer = {\n readonly seat: number;\n readonly name: string;\n readonly seconds: number;\n readonly paused: boolean;\n};\n\n/** A rematch or end-match offer as the results and the bar show it (D-61). */\nexport type RoomOfferFrame = {\n /** Names the offer in answers. */\n readonly id: number;\n readonly kind: 'rematch' | 'end';\n readonly by: number;\n readonly byName: string;\n readonly status: 'open' | 'declined' | 'withdrawn';\n /** Seats that have accepted (the proposer, and computer seats for a rematch). */\n readonly accepted: readonly number[];\n /** The seat that declined, if one did, and its player's name. */\n readonly declinedBy: number | null;\n readonly declinedName: string | null;\n /** Players whose answer an open offer still waits for. */\n readonly waiting: readonly string[];\n /** An end offer: whole seconds until the match ends unless someone declines. */\n readonly seconds: number | null;\n};\n\n/** What the stage reports about its room after every change: plain data for the lobby and the bar. */\nexport type RoomFrame = {\n readonly code: string;\n /** The invite link: this game page with `?room=CODE`. */\n readonly link: string;\n /** `connecting` until the first snapshot arrives. */\n readonly phase: 'connecting' | 'lobby' | 'playing' | 'finished' | 'closed';\n readonly connection: 'connecting' | 'online' | 'reconnecting' | 'closed';\n /** The viewer's seat; null for a spectator. */\n readonly seat: number | null;\n readonly host: number | null;\n readonly seats: readonly RoomSeat[];\n readonly minSeats: number;\n readonly clock: ClockSetting;\n readonly clockOptions: readonly ClockOption[];\n readonly aiLevel: string | null;\n readonly aiLevels: readonly string[];\n readonly spectators: number;\n readonly timer: RoomTimer | null;\n /** The latest rejection or connection problem, worded for players, and its code. */\n readonly notice: string | null;\n readonly noticeCode: string | null;\n /** Counts matches in this room; a rematch raises it. */\n readonly match: number;\n /** The latest rematch or end-match offer. */\n readonly offer: RoomOfferFrame | null;\n};\n\n/** What the lobby asks of the room. The stage turns each into a room control. */\nexport type RoomRequest =\n | { readonly kind: 'ready'; readonly ready: boolean }\n | { readonly kind: 'clock'; readonly setting: ClockSetting }\n | { readonly kind: 'ai-level'; readonly level: string }\n | { readonly kind: 'fill-with-ai'; readonly seat: number }\n | { readonly kind: 'remove-seat'; readonly seat: number }\n | { readonly kind: 'start' }\n /** Offer a rematch, or to end the match early (D-61); offering what is already offered accepts it. */\n | { readonly kind: 'offer'; readonly offer: 'rematch' | 'end' }\n /** Answer the offer with this id. */\n | { readonly kind: 'answer'; readonly offer: number; readonly answer: 'accept' | 'decline' | 'withdraw' }\n | { readonly kind: 'leave' }\n /** Take the seat back after another tab replaced this one. */\n | { readonly kind: 'reconnect' };\n\n/** \"Ana\", \"Ana and Ben\", \"Ana, Ben and Cy\". */\nconst nameList = (names: readonly string[]): string =>\n names.length <= 1 ? (names[0] ?? '') : `${names.slice(0, -1).join(', ')} and ${names.at(-1) ?? ''}`;\n\n/**\n * What an offer's state means to the viewer (`me`, null for a spectator), in words. An open\n * end offer says how long is left unless `countdown` is false (live-region announcements).\n */\nexport const offerText = (offer: RoomOfferFrame, me: number | null, countdown = true): string => {\n const mine = offer.by === me;\n const waiting = offer.waiting.length === 0 ? '' : ` Waiting for ${nameList(offer.waiting)}.`;\n if (offer.kind === 'rematch') {\n switch (offer.status) {\n case 'open':\n if (mine) return `You offered a rematch.${waiting}`;\n if (me !== null && offer.accepted.includes(me)) return `You accepted ${offer.byName}'s rematch.${waiting}`;\n return `${offer.byName} offers a rematch.`;\n case 'declined':\n return `${offer.declinedBy === me ? 'You' : (offer.declinedName ?? 'Someone')} declined the rematch.`;\n case 'withdrawn':\n return mine ? 'You withdrew your rematch offer.' : `${offer.byName} withdrew the rematch offer.`;\n }\n }\n switch (offer.status) {\n case 'open': {\n const left =\n countdown && offer.seconds !== null\n ? ` It ends in ${formatElapsed(offer.seconds * 1000)} unless someone declines.`\n : '';\n if (mine) return `You asked to end the match.${left}`;\n if (me !== null && offer.accepted.includes(me)) return `You agreed to end the match.${left}`;\n return `${offer.byName} asks to end the match with no result.${left}`;\n }\n case 'declined':\n return `${offer.declinedBy === me ? 'You' : (offer.declinedName ?? 'Someone')} declined to end the match.`;\n case 'withdrawn':\n return mine\n ? 'You withdrew your offer to end the match.'\n : `${offer.byName} withdrew the offer to end the match.`;\n }\n};\n\n/**\n * What to announce (politely) when the room's offer changed: a new offer, an answer, or\n * nothing. `seats` names the players who accepted.\n */\nexport const offerAnnouncement = (\n before: RoomOfferFrame | null,\n after: RoomOfferFrame | null,\n me: number | null,\n seats: readonly RoomSeat[],\n): string | null => {\n if (after === null) return null;\n if (before === null || before.id !== after.id || before.status !== after.status) return offerText(after, me, false);\n const joined = after.accepted.filter((seat) => !before.accepted.includes(seat) && seat !== me);\n const names = joined.flatMap((seat) =>\n seats.filter((s) => s.id === seat && s.controller === 'human').map((s) => s.name),\n );\n return names.length === 0 ? null : `${nameList(names)} accepted.`;\n};\n\n/** Seats a player or the computer fills. */\nexport const filledSeats = (room: RoomFrame): readonly RoomSeat[] =>\n room.seats.filter((s) => s.claimed || s.controller === 'ai');\n\n/** Why the host can't start yet, or null when the match can start (the room checks again). */\nexport const startProblem = (room: RoomFrame): string | null => {\n const filled = filledSeats(room);\n if (filled.length < room.minSeats) {\n return `Waiting for ${room.minSeats - filled.length === 1 ? 'another player' : `${String(room.minSeats - filled.length)} more players`}. Share the link, or add the computer.`;\n }\n const waiting = filled.filter((s) => s.controller === 'human' && (!s.ready || !s.connected));\n if (waiting.length > 0) return `Waiting for ${waiting.map((s) => s.name).join(' and ')} to be ready.`;\n return null;\n};\n\n/**\n * A new mode keeps the AI level, and fits the player count to the mode: a count the player\n * chose carries over when the new mode allows it. In a game that declares a default, a count\n * still at the old mode's starting count was never chosen, so the new mode starts at its own.\n */\nexport const pickMode = (game: LauncherGame, setup: Setup, mode: Mode): Setup => {\n const counts = playerCounts(game, mode);\n const untouched = game.players.default !== undefined && setup.players === startingCount(game, setup.mode);\n const players = counts.includes(setup.players) && !untouched ? setup.players : startingCount(game, mode);\n return { ...setup, mode, players, errors: [] };\n};\n\n/** The name a seat gets when its field is left empty. */\nexport const defaultName = (seat: number): string => `Player ${String(seat + 1)}`;\n\n/** An empty name on a human seat means \"the guest's own display name\"; the stage fills it in. */\nexport const GUEST_NAME = '';\n\n/**\n * The seats for a setup, numbered from 0, or a problem per pass-and-play name. Solo and\n * vs-AI play seat the guest first; AI seats are named for the computer.\n */\nexport const seatsFor = (\n game: LauncherGame,\n setup: Setup,\n):\n | { readonly ok: true; readonly seats: readonly SeatInfo[] }\n | { readonly ok: false; readonly errors: readonly (string | null)[] } => {\n switch (setup.mode) {\n case 'solo':\n case 'daily':\n return { ok: true, seats: [{ id: 0, name: GUEST_NAME, controller: 'human' }] };\n case 'ai': {\n const level = setup.aiLevel ?? game.aiLevels[0] ?? 'default';\n const opponents = setup.players - 1;\n const seats: SeatInfo[] = [{ id: 0, name: GUEST_NAME, controller: 'human' }];\n for (let n = 1; n <= opponents; n += 1) {\n seats.push({\n id: n,\n name: opponents === 1 ? 'Computer' : `Computer ${String(n)}`,\n controller: 'ai',\n aiLevel: level,\n });\n }\n return { ok: true, seats };\n }\n case 'pass-and-play': {\n const names: string[] = [];\n const errors: (string | null)[] = [];\n for (let seat = 0; seat < setup.players; seat += 1) {\n const typed = setup.names[seat]?.trim() ?? '';\n if (typed === '') {\n names.push(defaultName(seat));\n errors.push(null);\n continue;\n }\n const checked = checkDisplayName(typed);\n names.push(checked.ok ? checked.value : typed);\n errors.push(checked.ok ? null : checked.error.message);\n }\n names.forEach((name, seat) => {\n if (errors[seat] === null && names.findIndex((other) => other.toLowerCase() === name.toLowerCase()) !== seat) {\n errors[seat] = 'Each player needs a different name.';\n }\n });\n if (errors.some((e) => e !== null)) return { ok: false, errors };\n const people: SeatInfo[] = names.map((name, id) => ({ id, name, controller: 'human' }));\n const level = setup.aiLevel ?? game.aiLevels[0] ?? 'default';\n const computers = computerSeatsFor(game, setup);\n for (let n = 1; n <= computers; n += 1) {\n people.push({\n id: people.length,\n name: computers === 1 ? 'Computer' : `Computer ${String(n)}`,\n controller: 'ai',\n aiLevel: level,\n });\n }\n return { ok: true, seats: people };\n }\n default:\n return { ok: false, errors: [] };\n }\n};\n\n// --- The stage: what the launcher asks of it, and what it reports -----------------------------\n\n/** How to start: a new game, the saved one, or not yet (only load and look for a save). */\nexport type Begin = 'new' | 'resume' | null;\n\n/**\n * What the launcher's view hands the stage element that runs the game. Plain data: the\n * stage acts on changes (begin, pause, a new reveal, new preferences).\n */\nexport type StageControl = {\n readonly gameId: string;\n readonly mode: StageMode;\n readonly seats: readonly SeatInfo[];\n readonly begin: Begin;\n /** True while the platform overlay is open. */\n readonly paused: boolean;\n /** How many hand-offs the players have revealed. Each increase reveals one. */\n readonly reveals: number;\n /** The player's choices, or null until the stage has read them. */\n readonly preferences: StoredPreferences | null;\n /** A rematch's config (the engine's `rematch`), or null for the game's own setup. */\n readonly config: Json | null;\n /** Online with friends: how to open the room. Null for local play. */\n readonly room: RoomPlan | null;\n /** A validated choice from the resume offer, when this game keeps several saves. */\n readonly resumeSlot?: string;\n readonly setup?: Json;\n};\n\n/** A saved game, as the resume offer describes it. */\nexport type SaveSummary = {\n readonly savedAt: number;\n readonly elapsedMs: number;\n readonly players: readonly string[];\n readonly slot?: string;\n readonly choices?: readonly {\n readonly slot: string;\n readonly label: string;\n readonly savedAt: number;\n readonly elapsedMs: number;\n readonly players: readonly string[];\n }[];\n};\n\n/** What the frame needs from each snapshot; the stage reports it only when it changes. */\nexport type FrameState = {\n readonly seats: readonly SeatInfo[];\n /** Pass-and-play, D-31: the seat the device goes to next, behind the hand-off screen. */\n readonly handoff: { readonly seat: number; readonly name: string } | null;\n readonly outcome: Outcome | null;\n readonly safePoint: boolean;\n /** The latest rejection or problem, worded for players. */\n readonly notice: string | null;\n};\n\n/**\n * The daily puzzle's results, as the stage reports them after recording the result\n * (content-pipeline.md, \"Results and streaks\" and \"Share text\"). Plain data.\n */\nexport type DailyFrame = {\n readonly statistics?: DailyWordStats;\n /** The puzzle's UTC date, `yyyy-mm-dd`. */\n readonly date: string;\n /** The share text in both forms: emoji rows, and the rows in words for *Copy as text*. */\n readonly share: string;\n readonly shareWords: string;\n /** Days finished in a row, and the best run. */\n readonly streak: number;\n readonly bestStreak: number;\n /** Days won in a row, for a daily that can be lost; null otherwise. */\n readonly winStreak: number | null;\n readonly bestWinStreak: number | null;\n /** \"New puzzle at 8:00 PM (in 6 hours)\", in the player's own time. */\n readonly next: string;\n /** Set when today's puzzle was already finished before this visit: its result and summary. */\n readonly earlier: { readonly result: OutcomeResult; readonly summary: string } | null;\n};\n\nexport type StageOutput =\n | { readonly _tag: 'Prepared'; readonly save: SaveSummary | null; readonly preferences: StoredPreferences }\n /** The player changed a preference in another tab of the site (gp-d4v). */\n | { readonly _tag: 'Preferences'; readonly preferences: StoredPreferences }\n | { readonly _tag: 'Frame'; readonly frame: FrameState }\n | { readonly _tag: 'ExitRequested' }\n | { readonly _tag: 'Announce'; readonly message: string; readonly politeness: 'polite' | 'assertive' }\n | { readonly _tag: 'Notice'; readonly message: string }\n /** At the outcome, when the engine names a rematch config: what \"Play again\" starts with. */\n | { readonly _tag: 'Rematch'; readonly config: Json }\n /** Online with friends: the room after every change (and each second while a clock runs). */\n | { readonly _tag: 'Room'; readonly room: RoomFrame }\n /** The daily puzzle: its recorded result, streaks and share text, or today's earlier result. */\n | { readonly _tag: 'Daily'; readonly daily: DailyFrame }\n /** The puzzle the game plays, when the stage knows its ID (a daily puzzle): for \"Report a problem\". */\n | { readonly _tag: 'Puzzle'; readonly puzzleId: string }\n | { readonly _tag: 'Failed'; readonly message: string };\n\nconst isNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);\n\nexport const isStoredPreferences = (value: unknown): value is StoredPreferences =>\n isRecord(value) &&\n ['system', 'reduce', 'no-preference'].includes(value['reducedMotion'] as string) &&\n typeof value['sound'] === 'boolean' &&\n isNumber(value['volume']) &&\n typeof value['singleKeyShortcuts'] === 'boolean' &&\n ['system', 'light', 'dark'].includes(value['theme'] as string) &&\n (value['locale'] === null || typeof value['locale'] === 'string') &&\n (value['aiContentAccepted'] === undefined || typeof value['aiContentAccepted'] === 'boolean');\n\nconst isSeat = (value: unknown): value is SeatInfo =>\n isRecord(value) &&\n Number.isInteger(value['id']) &&\n typeof value['name'] === 'string' &&\n (value['controller'] === 'human' || value['controller'] === 'ai');\n\nconst isOutcome = (value: unknown): value is Outcome =>\n isRecord(value) &&\n ['won', 'lost', 'draw', 'completed', 'abandoned'].includes(value['result'] as string) &&\n Array.isArray(value['placements']) &&\n typeof value['summary'] === 'string';\n\nconst isFrame = (value: unknown): value is FrameState =>\n isRecord(value) &&\n Array.isArray(value['seats']) &&\n value['seats'].every(isSeat) &&\n (value['handoff'] === null ||\n (isRecord(value['handoff']) &&\n Number.isInteger(value['handoff']['seat']) &&\n typeof value['handoff']['name'] === 'string')) &&\n (value['outcome'] === null || isOutcome(value['outcome'])) &&\n typeof value['safePoint'] === 'boolean' &&\n (value['notice'] === null || typeof value['notice'] === 'string');\n\nconst isClockSetting = (value: unknown): value is ClockSetting =>\n isRecord(value) &&\n ['move', 'bank', 'off'].includes(value['kind'] as string) &&\n parseClockValue(clockValue(value as ClockSetting)) !== undefined;\n\nconst isRoomSeat = (value: unknown): value is RoomSeat =>\n isRecord(value) &&\n Number.isInteger(value['id']) &&\n typeof value['name'] === 'string' &&\n (value['controller'] === 'human' || value['controller'] === 'ai') &&\n (value['aiLevel'] === null || typeof value['aiLevel'] === 'string') &&\n typeof value['claimed'] === 'boolean' &&\n typeof value['ready'] === 'boolean' &&\n typeof value['connected'] === 'boolean';\n\nconst isSeatOrNull = (value: unknown): value is number | null => value === null || Number.isInteger(value);\n\nconst isOfferFrame = (value: unknown): value is RoomOfferFrame =>\n isRecord(value) &&\n Number.isInteger(value['id']) &&\n (value['kind'] === 'rematch' || value['kind'] === 'end') &&\n Number.isInteger(value['by']) &&\n typeof value['byName'] === 'string' &&\n ['open', 'declined', 'withdrawn'].includes(value['status'] as string) &&\n Array.isArray(value['accepted']) &&\n value['accepted'].every((seat) => Number.isInteger(seat)) &&\n isSeatOrNull(value['declinedBy']) &&\n (value['declinedName'] === null || typeof value['declinedName'] === 'string') &&\n isStringArray(value['waiting']) &&\n (value['seconds'] === null || isNumber(value['seconds']));\n\nconst isRoomFrame = (value: unknown): value is RoomFrame => {\n if (!isRecord(value)) return false;\n const timer = value['timer'];\n const options = value['clockOptions'];\n return (\n isRoomCode(value['code']) &&\n typeof value['link'] === 'string' &&\n ['connecting', 'lobby', 'playing', 'finished', 'closed'].includes(value['phase'] as string) &&\n ['connecting', 'online', 'reconnecting', 'closed'].includes(value['connection'] as string) &&\n isSeatOrNull(value['seat']) &&\n isSeatOrNull(value['host']) &&\n Array.isArray(value['seats']) &&\n value['seats'].every(isRoomSeat) &&\n Number.isInteger(value['minSeats']) &&\n isClockSetting(value['clock']) &&\n Array.isArray(options) &&\n options.every((o) => isRecord(o) && typeof o['value'] === 'string' && typeof o['label'] === 'string') &&\n (value['aiLevel'] === null || typeof value['aiLevel'] === 'string') &&\n isStringArray(value['aiLevels']) &&\n Number.isInteger(value['spectators']) &&\n (timer === null ||\n (isRecord(timer) &&\n Number.isInteger(timer['seat']) &&\n typeof timer['name'] === 'string' &&\n isNumber(timer['seconds']) &&\n typeof timer['paused'] === 'boolean')) &&\n (value['notice'] === null || typeof value['notice'] === 'string') &&\n (value['noticeCode'] === null || typeof value['noticeCode'] === 'string') &&\n Number.isInteger(value['match']) &&\n (value['offer'] === null || isOfferFrame(value['offer']))\n );\n};\n\nconst isCount = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 0;\n\nconst isDailyFrame = (value: unknown): value is DailyFrame => {\n if (!isRecord(value)) return false;\n const earlier = value['earlier'];\n return (\n typeof value['date'] === 'string' &&\n /^\\d{4}-\\d{2}-\\d{2}$/.test(value['date']) &&\n typeof value['share'] === 'string' &&\n typeof value['shareWords'] === 'string' &&\n isCount(value['streak']) &&\n isCount(value['bestStreak']) &&\n (value['winStreak'] === null || isCount(value['winStreak'])) &&\n (value['bestWinStreak'] === null || isCount(value['bestWinStreak'])) &&\n typeof value['next'] === 'string' &&\n (value['statistics'] === undefined || dailyWordStatsSchema.safeParse(value['statistics']).success) &&\n (earlier === null ||\n (isRecord(earlier) &&\n ['won', 'lost', 'draw', 'completed', 'abandoned'].includes(earlier['result'] as string) &&\n typeof earlier['summary'] === 'string'))\n );\n};\n\nconst savePart = (value: unknown): value is string =>\n typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(value);\nconst validSaveSummary = (value: unknown): value is SaveSummary => {\n if (\n !isRecord(value) ||\n !isNumber(value['savedAt']) ||\n !isNumber(value['elapsedMs']) ||\n !isStringArray(value['players'])\n )\n return false;\n if (value['slot'] !== undefined && !savePart(value['slot'])) return false;\n const choices = value['choices'];\n return (\n choices === undefined ||\n (Array.isArray(choices) &&\n choices.length > 0 &&\n choices.every(\n (c) =>\n isRecord(c) &&\n savePart(c['slot']) &&\n typeof c['label'] === 'string' &&\n c['label'].length > 0 &&\n c['label'].length <= 100 &&\n isNumber(c['savedAt']) &&\n isNumber(c['elapsedMs']) &&\n isStringArray(c['players']),\n ) &&\n new Set(choices.map((c) => (c as { slot: string }).slot)).size === choices.length &&\n choices.some((c) => (c as { slot: string }).slot === value['slot']))\n );\n};\nconst saveSummarySchema: Validator<SaveSummary> = {\n '~standard': {\n version: 1,\n vendor: 'joystyk',\n validate: (raw) =>\n validSaveSummary(raw) ? { value: raw } : { issues: [{ message: 'Expected a validated saved-game offer.' }] },\n },\n};\nconst isSaveSummary = (raw: unknown): raw is SaveSummary => {\n const p = saveSummarySchema['~standard'].validate(raw);\n return !(p instanceof Promise) && p.issues === undefined;\n};\n\n/** Checks a stage report at the boundary; anything unexpected is dropped. */\nexport const parseStageOutput = (value: unknown): StageOutput | undefined => {\n if (!isRecord(value)) return undefined;\n switch (value['_tag']) {\n case 'Prepared':\n return (value['save'] === null || isSaveSummary(value['save'])) && isStoredPreferences(value['preferences'])\n ? { _tag: 'Prepared', save: value['save'], preferences: value['preferences'] }\n : undefined;\n case 'Preferences':\n return isStoredPreferences(value['preferences'])\n ? { _tag: 'Preferences', preferences: value['preferences'] }\n : undefined;\n case 'Frame':\n return isFrame(value['frame']) ? { _tag: 'Frame', frame: value['frame'] } : undefined;\n case 'ExitRequested':\n return { _tag: 'ExitRequested' };\n case 'Rematch':\n return isJson(value['config']) ? { _tag: 'Rematch', config: value['config'] } : undefined;\n case 'Room':\n return isRoomFrame(value['room']) ? { _tag: 'Room', room: value['room'] } : undefined;\n case 'Daily':\n return isDailyFrame(value['daily']) ? { _tag: 'Daily', daily: value['daily'] } : undefined;\n case 'Puzzle':\n return typeof value['puzzleId'] === 'string' &&\n /^[a-z0-9-]+(:[a-z0-9-]+)+$/.test(value['puzzleId']) &&\n value['puzzleId'].length <= 120\n ? { _tag: 'Puzzle', puzzleId: value['puzzleId'] }\n : undefined;\n case 'Announce':\n return typeof value['message'] === 'string' &&\n (value['politeness'] === 'polite' || value['politeness'] === 'assertive')\n ? { _tag: 'Announce', message: value['message'], politeness: value['politeness'] }\n : undefined;\n case 'Notice':\n case 'Failed':\n return typeof value['message'] === 'string' ? { _tag: value['_tag'], message: value['message'] } : undefined;\n default:\n return undefined;\n }\n};\n\n// --- Results ------------------------------------------------------------------------------\n\nexport type ResultView = {\n readonly result: OutcomeResult;\n readonly headline: string;\n readonly summary: string;\n readonly elapsedMs: number | null;\n /** \"Assisted: 2 undos, hints\", or null for an unassisted game. */\n readonly assisted: string | null;\n};\n\n/** \"0:42\", \"12:05\", \"1:02:03\". */\nexport const formatElapsed = (ms: number): string => {\n const total = Math.max(0, Math.round(ms / 1000));\n const hours = Math.floor(total / 3600);\n const minutes = Math.floor((total % 3600) / 60);\n const seconds = String(total % 60).padStart(2, '0');\n return hours > 0\n ? `${String(hours)}:${String(minutes).padStart(2, '0')}:${seconds}`\n : `${String(minutes)}:${seconds}`;\n};\n\n/** An ISO 8601 duration for `<time datetime>`: \"PT1M5S\". */\nexport const isoDuration = (ms: number): string => {\n const total = Math.max(0, Math.round(ms / 1000));\n const hours = Math.floor(total / 3600);\n const minutes = Math.floor((total % 3600) / 60);\n const seconds = total % 60;\n return `PT${hours > 0 ? `${String(hours)}H` : ''}${minutes > 0 ? `${String(minutes)}M` : ''}${String(seconds)}S`;\n};\n\nconst ASSIST_WORDS: Readonly<Record<Exclude<AssistKind, 'undo'>, string>> = {\n hint: 'hints',\n check: 'checks',\n reveal: 'reveals',\n shuffle: 'shuffles',\n};\n\n/** The assistance line for an outcome, from what the runner recorded, or null when there was none. */\nexport const assistLine = (outcome: Outcome): string | null => {\n const undos = outcome.undosUsed ?? 0;\n const kinds = new Set<AssistKind>(outcome.assists ?? []);\n if (undos > 0) kinds.add('undo');\n const parts = ASSIST_KINDS.filter((kind) => kinds.has(kind)).map((kind) =>\n kind === 'undo' ? (undos > 1 ? `${String(undos)} undos` : undos === 1 ? '1 undo' : 'undo') : ASSIST_WORDS[kind],\n );\n return parts.length === 0 ? null : `Assisted: ${parts.join(', ')}`;\n};\n\nconst nameOf = (seats: readonly SeatInfo[], seat: number | undefined): string =>\n seats.find((s) => s.id === seat)?.name ?? 'Someone';\n\n/**\n * The results screen's words for an outcome. In a room the headline depends on who is\n * looking: `viewer` is the player's seat, or null for a spectator, who hears who won.\n */\nexport const resultView = (\n outcome: Outcome,\n seats: readonly SeatInfo[],\n mode: StageMode,\n viewer: number | null = null,\n): ResultView => {\n const winner = outcome.placements.find((p) => p.place === 1)?.seat;\n const headline = ((): string => {\n switch (outcome.result) {\n case 'won':\n if (mode === 'server') {\n if (viewer === null) return `${nameOf(seats, winner)} wins!`;\n return outcome.placements.find((p) => p.seat === viewer)?.place === 1\n ? 'You won!'\n : `${nameOf(seats, winner)} wins`;\n }\n return mode === 'pass-and-play' ? `${nameOf(seats, winner)} wins!` : 'You won!';\n case 'lost':\n return 'You lost';\n case 'draw':\n return \"It's a draw\";\n case 'completed':\n return 'Complete!';\n case 'abandoned':\n return 'Game over';\n }\n })();\n return {\n result: outcome.result,\n headline,\n summary: outcome.summary,\n elapsedMs: outcome.elapsedMs ?? null,\n assisted: assistLine(outcome),\n };\n};\n\n// --- Preferences --------------------------------------------------------------------------\n\n/** One preference control's change, parsed from its name and value, or undefined if invalid. */\nexport const parsePreferenceChange = (\n name: string,\n value: string | undefined,\n checked: boolean | undefined,\n): Partial<StoredPreferences> | undefined => {\n switch (name) {\n case 'reducedMotion':\n return value === 'system' || value === 'reduce' || value === 'no-preference'\n ? { reducedMotion: value }\n : undefined;\n case 'theme':\n return value === 'system' || value === 'light' || value === 'dark' ? { theme: value } : undefined;\n case 'sound':\n return checked === undefined ? undefined : { sound: checked };\n case 'singleKeyShortcuts':\n return checked === undefined ? undefined : { singleKeyShortcuts: checked };\n case 'aiContentAccepted':\n return checked === undefined ? undefined : { aiContentAccepted: checked };\n case 'volume': {\n const percent = Number(value);\n return value !== undefined && value !== '' && Number.isInteger(percent) && percent >= 0 && percent <= 100\n ? { volume: percent / 100 }\n : undefined;\n }\n default:\n return undefined;\n }\n};\n"],"mappings":"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"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type PropsOf } from '@gyral/core';
|
|
2
|
+
import { type ReportReason } from './report.js';
|
|
3
|
+
export interface ReportRequest {
|
|
4
|
+
readonly game: string;
|
|
5
|
+
/** '' when the launcher knows no puzzle. */
|
|
6
|
+
readonly puzzle: string;
|
|
7
|
+
readonly reason: ReportReason;
|
|
8
|
+
readonly note: string;
|
|
9
|
+
}
|
|
10
|
+
export type ReportAnswer = {
|
|
11
|
+
readonly ok: true;
|
|
12
|
+
} | {
|
|
13
|
+
readonly ok: false;
|
|
14
|
+
readonly message: string;
|
|
15
|
+
};
|
|
16
|
+
export declare const NOT_SENT = "Your report didn\u2019t send. Check your connection, then try again.";
|
|
17
|
+
/** POST /api/reports on this page's origin, as a web form; the answer is JSON. */
|
|
18
|
+
export declare const reportDriver: import("@gyral/core").Driver<ReportRequest, ReportAnswer, unknown>;
|
|
19
|
+
export type ReportPhase = 'idle' | 'sending' | 'sent';
|
|
20
|
+
export interface ReportState {
|
|
21
|
+
readonly phase: ReportPhase;
|
|
22
|
+
/** Why the last attempt failed, worded for players; null otherwise. */
|
|
23
|
+
readonly problem: string | null;
|
|
24
|
+
}
|
|
25
|
+
export type ReportMsg = {
|
|
26
|
+
readonly _tag: 'Send';
|
|
27
|
+
readonly reason: ReportReason;
|
|
28
|
+
readonly note: string;
|
|
29
|
+
} | {
|
|
30
|
+
readonly _tag: 'Answered';
|
|
31
|
+
readonly answer: ReportAnswer;
|
|
32
|
+
};
|
|
33
|
+
/** The note as sent: one line of plain text, trimmed, at most REPORT_NOTE_MAX characters. */
|
|
34
|
+
export declare const cleanNote: (value: unknown) => string;
|
|
35
|
+
export declare const ReportProblem: import("@gyral/core").GyralElementClass<ReportState, ReportMsg, PropsOf<{
|
|
36
|
+
gameId: import("@gyral/core").Prop<string>;
|
|
37
|
+
/** The puzzle the player is on, when the launcher knows it ('' otherwise). */
|
|
38
|
+
puzzleId: import("@gyral/core").Prop<string>;
|
|
39
|
+
}>, never, "Send">;
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { REPORT_FIELDS, REPORT_PATH, REPORT_REASONS, REPORT_REASON_LABELS, isReportReason } from "./report.js";
|
|
2
|
+
import { command, css, define, defineDriver, nothing, prop } from "@gyral/core";
|
|
3
|
+
import { compiled } from "@gyral/core/compiled";
|
|
4
|
+
//#region src/report-problem.ts
|
|
5
|
+
var _gyral$t0 = {
|
|
6
|
+
"id": "1y8dvikqb22",
|
|
7
|
+
"html": "<details><summary>Report a problem</summary><p class=\"status\" role=\"status\"></p></details>",
|
|
8
|
+
"parts": [[1, [0, 1]], [0, [0]]],
|
|
9
|
+
"segments": [
|
|
10
|
+
"<details><summary>Report a problem</summary><p class=\"status\" role=\"status\">",
|
|
11
|
+
{
|
|
12
|
+
"k": "child",
|
|
13
|
+
"in": "p"
|
|
14
|
+
},
|
|
15
|
+
"</p>",
|
|
16
|
+
{
|
|
17
|
+
"k": "child",
|
|
18
|
+
"in": "details"
|
|
19
|
+
},
|
|
20
|
+
"</details>"
|
|
21
|
+
]
|
|
22
|
+
};
|
|
23
|
+
var _gyral$t1 = {
|
|
24
|
+
"id": "1xovvhc3u3l",
|
|
25
|
+
"html": "<form aria-label=\"Report a problem\"><fieldset><legend>What’s wrong?</legend></fieldset><label for=\"report-note\">Anything to add? (optional)</label><p class=\"hint\" id=\"report-note-hint\">Up to <!----> characters. Please leave out your name and how to reach you.</p><textarea id=\"report-note\" rows=\"2\" aria-describedby=\"report-note-hint\"></textarea><p class=\"hint\">We get the game<!---->, the reason and your note, and nothing about you.</p><!----> <button type=\"submit\">Send report</button></form>",
|
|
26
|
+
"parts": [
|
|
27
|
+
[
|
|
28
|
+
3,
|
|
29
|
+
[0],
|
|
30
|
+
"data-intent"
|
|
31
|
+
],
|
|
32
|
+
[0, [0, 0]],
|
|
33
|
+
[
|
|
34
|
+
2,
|
|
35
|
+
[0, 2],
|
|
36
|
+
1
|
|
37
|
+
],
|
|
38
|
+
[
|
|
39
|
+
3,
|
|
40
|
+
[0, 3],
|
|
41
|
+
"name"
|
|
42
|
+
],
|
|
43
|
+
[
|
|
44
|
+
3,
|
|
45
|
+
[0, 3],
|
|
46
|
+
"maxlength"
|
|
47
|
+
],
|
|
48
|
+
[
|
|
49
|
+
2,
|
|
50
|
+
[0, 4],
|
|
51
|
+
1
|
|
52
|
+
],
|
|
53
|
+
[
|
|
54
|
+
2,
|
|
55
|
+
[0],
|
|
56
|
+
5
|
|
57
|
+
],
|
|
58
|
+
[
|
|
59
|
+
5,
|
|
60
|
+
[0, 7],
|
|
61
|
+
"disabled"
|
|
62
|
+
]
|
|
63
|
+
],
|
|
64
|
+
"segments": [
|
|
65
|
+
"<form aria-label=\"Report a problem\"",
|
|
66
|
+
{
|
|
67
|
+
"k": "attr",
|
|
68
|
+
"name": "data-intent"
|
|
69
|
+
},
|
|
70
|
+
"><fieldset><legend>What’s wrong?</legend>",
|
|
71
|
+
{
|
|
72
|
+
"k": "child",
|
|
73
|
+
"in": "fieldset"
|
|
74
|
+
},
|
|
75
|
+
"</fieldset><label for=\"report-note\">Anything to add? (optional)</label><p class=\"hint\" id=\"report-note-hint\">Up to ",
|
|
76
|
+
{
|
|
77
|
+
"k": "child",
|
|
78
|
+
"in": "p"
|
|
79
|
+
},
|
|
80
|
+
"<!----> characters. Please leave out your name and how to reach you.</p><textarea id=\"report-note\" rows=\"2\" aria-describedby=\"report-note-hint\"",
|
|
81
|
+
{
|
|
82
|
+
"k": "attr",
|
|
83
|
+
"name": "name"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"k": "attr",
|
|
87
|
+
"name": "maxlength"
|
|
88
|
+
},
|
|
89
|
+
"></textarea><p class=\"hint\">We get the game",
|
|
90
|
+
{
|
|
91
|
+
"k": "child",
|
|
92
|
+
"in": "p"
|
|
93
|
+
},
|
|
94
|
+
"<!---->, the reason and your note, and nothing about you.</p>",
|
|
95
|
+
{
|
|
96
|
+
"k": "child",
|
|
97
|
+
"in": "form"
|
|
98
|
+
},
|
|
99
|
+
"<!----> <button type=\"submit\"",
|
|
100
|
+
{
|
|
101
|
+
"k": "bool",
|
|
102
|
+
"name": "disabled"
|
|
103
|
+
},
|
|
104
|
+
">Send report</button></form>"
|
|
105
|
+
]
|
|
106
|
+
};
|
|
107
|
+
var _gyral$t2 = {
|
|
108
|
+
"id": "2218n4hoib9",
|
|
109
|
+
"html": "<label class=\"choice\"><input type=\"radio\" required> </label>",
|
|
110
|
+
"parts": [
|
|
111
|
+
[
|
|
112
|
+
3,
|
|
113
|
+
[0, 0],
|
|
114
|
+
"name"
|
|
115
|
+
],
|
|
116
|
+
[
|
|
117
|
+
3,
|
|
118
|
+
[0, 0],
|
|
119
|
+
"value"
|
|
120
|
+
],
|
|
121
|
+
[0, [0]]
|
|
122
|
+
],
|
|
123
|
+
"segments": [
|
|
124
|
+
"<label class=\"choice\"><input type=\"radio\" required",
|
|
125
|
+
{
|
|
126
|
+
"k": "attr",
|
|
127
|
+
"name": "name"
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"k": "attr",
|
|
131
|
+
"name": "value"
|
|
132
|
+
},
|
|
133
|
+
"> ",
|
|
134
|
+
{
|
|
135
|
+
"k": "child",
|
|
136
|
+
"in": "label"
|
|
137
|
+
},
|
|
138
|
+
"</label>"
|
|
139
|
+
]
|
|
140
|
+
};
|
|
141
|
+
var _gyral$t3 = {
|
|
142
|
+
"id": "mbvq8mgd3v",
|
|
143
|
+
"html": "<p class=\"problem\" role=\"alert\"></p>",
|
|
144
|
+
"parts": [[1, [0]]],
|
|
145
|
+
"segments": [
|
|
146
|
+
"<p class=\"problem\" role=\"alert\">",
|
|
147
|
+
{
|
|
148
|
+
"k": "child",
|
|
149
|
+
"in": "p"
|
|
150
|
+
},
|
|
151
|
+
"</p>"
|
|
152
|
+
]
|
|
153
|
+
};
|
|
154
|
+
var NOT_SENT = "Your report didn’t send. Check your connection, then try again.";
|
|
155
|
+
var answerOf = (status, body) => {
|
|
156
|
+
if (status === 201) return { ok: true };
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
message: (typeof body === "object" && body !== null && Array.isArray(body.issues) ? body.issues : []).find((i) => typeof i === "string") ?? "Your report didn’t send. Check your connection, then try again."
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
/** POST /api/reports on this page's origin, as a web form; the answer is JSON. */
|
|
163
|
+
var reportDriver = defineDriver({
|
|
164
|
+
name: "launcher-report",
|
|
165
|
+
run: async (input, { signal }) => {
|
|
166
|
+
const body = new URLSearchParams({
|
|
167
|
+
[REPORT_FIELDS.game]: input.game,
|
|
168
|
+
[REPORT_FIELDS.puzzle]: input.puzzle,
|
|
169
|
+
[REPORT_FIELDS.reason]: input.reason,
|
|
170
|
+
[REPORT_FIELDS.note]: input.note
|
|
171
|
+
});
|
|
172
|
+
try {
|
|
173
|
+
const response = await fetch(new URL(REPORT_PATH, location.origin), {
|
|
174
|
+
method: "POST",
|
|
175
|
+
body,
|
|
176
|
+
headers: { accept: "application/json" },
|
|
177
|
+
credentials: "omit",
|
|
178
|
+
signal
|
|
179
|
+
});
|
|
180
|
+
let parsed = null;
|
|
181
|
+
try {
|
|
182
|
+
parsed = await response.json();
|
|
183
|
+
} catch {
|
|
184
|
+
parsed = null;
|
|
185
|
+
}
|
|
186
|
+
return answerOf(response.status, parsed);
|
|
187
|
+
} catch {
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
message: NOT_SENT
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
concurrency: "exhaust"
|
|
195
|
+
});
|
|
196
|
+
var sendReport = (request, toMsg) => command(reportDriver, request, {
|
|
197
|
+
onSuccess: toMsg,
|
|
198
|
+
onFailure: () => toMsg({
|
|
199
|
+
ok: false,
|
|
200
|
+
message: NOT_SENT
|
|
201
|
+
})
|
|
202
|
+
});
|
|
203
|
+
var props = {
|
|
204
|
+
gameId: prop.string({ required: true }),
|
|
205
|
+
/** The puzzle the player is on, when the launcher knows it ('' otherwise). */
|
|
206
|
+
puzzleId: prop.string({ default: "" })
|
|
207
|
+
};
|
|
208
|
+
/** The note as sent: one line of plain text, trimmed, at most REPORT_NOTE_MAX characters. */
|
|
209
|
+
var cleanNote = (value) => typeof value === "string" ? value.replace(/\s+/g, " ").trim().slice(0, 200) : "";
|
|
210
|
+
var styles = css`
|
|
211
|
+
:host {
|
|
212
|
+
display: block;
|
|
213
|
+
}
|
|
214
|
+
details {
|
|
215
|
+
border-block-start: 1px solid var(--jy-border, currentColor);
|
|
216
|
+
padding-block-start: var(--jy-space-2xs, 0.5rem);
|
|
217
|
+
}
|
|
218
|
+
summary {
|
|
219
|
+
cursor: pointer;
|
|
220
|
+
inline-size: fit-content;
|
|
221
|
+
min-block-size: var(--jy-target-min, 2.75rem);
|
|
222
|
+
display: flex;
|
|
223
|
+
align-items: center;
|
|
224
|
+
color: var(--jy-text-muted, inherit);
|
|
225
|
+
font-size: var(--jy-step--1, 0.9rem);
|
|
226
|
+
}
|
|
227
|
+
summary:focus-visible {
|
|
228
|
+
outline: 3px solid var(--jy-focus-ring, Highlight);
|
|
229
|
+
outline-offset: 2px;
|
|
230
|
+
}
|
|
231
|
+
form {
|
|
232
|
+
display: grid;
|
|
233
|
+
gap: var(--jy-space-2xs, 0.5rem);
|
|
234
|
+
justify-items: start;
|
|
235
|
+
}
|
|
236
|
+
fieldset {
|
|
237
|
+
display: grid;
|
|
238
|
+
gap: var(--jy-space-3xs, 0.25rem);
|
|
239
|
+
margin: 0;
|
|
240
|
+
padding: 0;
|
|
241
|
+
border: 0;
|
|
242
|
+
}
|
|
243
|
+
legend {
|
|
244
|
+
font-weight: 600;
|
|
245
|
+
padding: 0;
|
|
246
|
+
margin-block-end: var(--jy-space-3xs, 0.25rem);
|
|
247
|
+
}
|
|
248
|
+
.choice {
|
|
249
|
+
display: flex;
|
|
250
|
+
align-items: center;
|
|
251
|
+
gap: var(--jy-space-2xs, 0.5rem);
|
|
252
|
+
min-block-size: 2rem;
|
|
253
|
+
}
|
|
254
|
+
label[for] {
|
|
255
|
+
font-weight: 600;
|
|
256
|
+
}
|
|
257
|
+
textarea {
|
|
258
|
+
inline-size: min(100%, 28rem);
|
|
259
|
+
font: inherit;
|
|
260
|
+
color: var(--jy-text, CanvasText);
|
|
261
|
+
background: var(--jy-surface-raised, Canvas);
|
|
262
|
+
border: 1px solid var(--jy-border, currentColor);
|
|
263
|
+
border-radius: var(--jy-radius-s, 0.375rem);
|
|
264
|
+
padding: var(--jy-space-2xs, 0.5rem);
|
|
265
|
+
resize: vertical;
|
|
266
|
+
}
|
|
267
|
+
.hint {
|
|
268
|
+
margin: 0;
|
|
269
|
+
color: var(--jy-text-muted, inherit);
|
|
270
|
+
font-size: var(--jy-step--1, 0.9rem);
|
|
271
|
+
}
|
|
272
|
+
.problem {
|
|
273
|
+
margin: 0;
|
|
274
|
+
color: var(--jy-danger, inherit);
|
|
275
|
+
}
|
|
276
|
+
.status:empty {
|
|
277
|
+
display: none;
|
|
278
|
+
}
|
|
279
|
+
.status {
|
|
280
|
+
margin: 0;
|
|
281
|
+
}
|
|
282
|
+
button {
|
|
283
|
+
min-block-size: var(--jy-target-min, 2.75rem);
|
|
284
|
+
padding-inline: var(--jy-space-s, 1rem);
|
|
285
|
+
font: inherit;
|
|
286
|
+
font-weight: 600;
|
|
287
|
+
color: var(--jy-text, CanvasText);
|
|
288
|
+
background: transparent;
|
|
289
|
+
border: 1px solid var(--jy-border, currentColor);
|
|
290
|
+
border-radius: var(--jy-radius-pill, 999px);
|
|
291
|
+
cursor: pointer;
|
|
292
|
+
}
|
|
293
|
+
button:focus-visible,
|
|
294
|
+
textarea:focus-visible,
|
|
295
|
+
input:focus-visible {
|
|
296
|
+
outline: 3px solid var(--jy-focus-ring, Highlight);
|
|
297
|
+
outline-offset: 2px;
|
|
298
|
+
}
|
|
299
|
+
`;
|
|
300
|
+
var STATUS = {
|
|
301
|
+
idle: "",
|
|
302
|
+
sending: "Sending your report…",
|
|
303
|
+
sent: "Thank you. We’ll take a look."
|
|
304
|
+
};
|
|
305
|
+
var ReportProblem = define()("jy-report-problem", {
|
|
306
|
+
props,
|
|
307
|
+
styles,
|
|
308
|
+
init: () => ({
|
|
309
|
+
phase: "idle",
|
|
310
|
+
problem: null
|
|
311
|
+
}),
|
|
312
|
+
intent: { Send: ({ formData }) => {
|
|
313
|
+
const reason = formData?.get(REPORT_FIELDS.reason);
|
|
314
|
+
return isReportReason(reason) ? {
|
|
315
|
+
_tag: "Send",
|
|
316
|
+
reason,
|
|
317
|
+
note: cleanNote(formData?.get(REPORT_FIELDS.note))
|
|
318
|
+
} : void 0;
|
|
319
|
+
} },
|
|
320
|
+
update: {
|
|
321
|
+
Send: (s, m, { props: p }) => s.phase !== "idle" ? s : [{
|
|
322
|
+
phase: "sending",
|
|
323
|
+
problem: null
|
|
324
|
+
}, [sendReport({
|
|
325
|
+
game: p.gameId,
|
|
326
|
+
puzzle: p.puzzleId,
|
|
327
|
+
reason: m.reason,
|
|
328
|
+
note: m.note
|
|
329
|
+
}, (answer) => ({
|
|
330
|
+
_tag: "Answered",
|
|
331
|
+
answer
|
|
332
|
+
}))]],
|
|
333
|
+
Answered: (_s, m) => m.answer.ok ? {
|
|
334
|
+
phase: "sent",
|
|
335
|
+
problem: null
|
|
336
|
+
} : {
|
|
337
|
+
phase: "idle",
|
|
338
|
+
problem: m.answer.message
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
view: (s, i, { props: p }) => compiled(_gyral$t0, [STATUS[s.phase], s.phase === "sent" ? nothing : compiled(_gyral$t1, [
|
|
342
|
+
i.Send,
|
|
343
|
+
REPORT_REASONS.map((reason) => compiled(_gyral$t2, [
|
|
344
|
+
REPORT_FIELDS.reason,
|
|
345
|
+
reason,
|
|
346
|
+
REPORT_REASON_LABELS[reason]
|
|
347
|
+
])),
|
|
348
|
+
200,
|
|
349
|
+
REPORT_FIELDS.note,
|
|
350
|
+
String(200),
|
|
351
|
+
p.puzzleId === "" ? "" : " and the puzzle",
|
|
352
|
+
s.problem === null ? nothing : compiled(_gyral$t3, [s.problem]),
|
|
353
|
+
s.phase === "sending"
|
|
354
|
+
])])
|
|
355
|
+
});
|
|
356
|
+
//#endregion
|
|
357
|
+
export { NOT_SENT, ReportProblem, cleanNote, reportDriver };
|
|
358
|
+
|
|
359
|
+
//# sourceMappingURL=report-problem.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"report-problem.js","names":["_gyral$compiled"],"sources":["../src/report-problem.ts"],"sourcesContent":["// <jy-report-problem>: the frame's \"Report a problem\" control for AI-content games (D-52,\n// content-pipeline.md, \"Moderation in /admin\"). The launcher puts it in the results and in the\n// pause menu, outside the game's canvas (D-07). Closed, it is one disclosure line; open, a\n// short form: a reason from a fixed list and an optional note. It sends the game, the puzzle\n// ID when the launcher knows it, the reason and the note to the site (report.ts), and nothing\n// about the player (D-26). The request is a command for the `launcher-report` driver, so tests\n// substitute it by name.\nimport { command, css, define, defineDriver, html, nothing, prop, type Command, type PropsOf } from '@gyral/core';\nimport {\n isReportReason,\n REPORT_FIELDS,\n REPORT_NOTE_MAX,\n REPORT_PATH,\n REPORT_REASON_LABELS,\n REPORT_REASONS,\n type ReportReason,\n} from './report.ts';\n\nexport interface ReportRequest {\n readonly game: string;\n /** '' when the launcher knows no puzzle. */\n readonly puzzle: string;\n readonly reason: ReportReason;\n readonly note: string;\n}\n\nexport type ReportAnswer = { readonly ok: true } | { readonly ok: false; readonly message: string };\n\nexport const NOT_SENT = 'Your report didn’t send. Check your connection, then try again.';\n\nconst answerOf = (status: number, body: unknown): ReportAnswer => {\n if (status === 201) return { ok: true };\n const issues =\n typeof body === 'object' && body !== null && Array.isArray((body as { issues?: unknown }).issues)\n ? (body as { issues: unknown[] }).issues\n : [];\n const first = issues.find((i): i is string => typeof i === 'string');\n return { ok: false, message: first ?? NOT_SENT };\n};\n\n/** POST /api/reports on this page's origin, as a web form; the answer is JSON. */\nexport const reportDriver = defineDriver<ReportRequest, ReportAnswer>({\n name: 'launcher-report',\n run: async (input, { signal }) => {\n const body = new URLSearchParams({\n [REPORT_FIELDS.game]: input.game,\n [REPORT_FIELDS.puzzle]: input.puzzle,\n [REPORT_FIELDS.reason]: input.reason,\n [REPORT_FIELDS.note]: input.note,\n });\n try {\n const response = await fetch(new URL(REPORT_PATH, location.origin), {\n method: 'POST',\n body,\n headers: { accept: 'application/json' },\n credentials: 'omit',\n signal,\n });\n let parsed: unknown = null;\n try {\n parsed = await response.json();\n } catch {\n parsed = null;\n }\n return answerOf(response.status, parsed);\n } catch {\n return { ok: false, message: NOT_SENT };\n }\n },\n // One report at a time: a second press while one is out is dropped.\n concurrency: 'exhaust',\n});\n\nconst sendReport = <M>(request: ReportRequest, toMsg: (answer: ReportAnswer) => M): Command<M> =>\n command<ReportRequest, ReportAnswer, unknown, M>(reportDriver, request, {\n onSuccess: toMsg,\n onFailure: () => toMsg({ ok: false, message: NOT_SENT }),\n });\n\nexport type ReportPhase = 'idle' | 'sending' | 'sent';\n\nexport interface ReportState {\n readonly phase: ReportPhase;\n /** Why the last attempt failed, worded for players; null otherwise. */\n readonly problem: string | null;\n}\n\nexport type ReportMsg =\n | { readonly _tag: 'Send'; readonly reason: ReportReason; readonly note: string }\n | { readonly _tag: 'Answered'; readonly answer: ReportAnswer };\n\nconst props = {\n gameId: prop.string({ required: true }),\n /** The puzzle the player is on, when the launcher knows it ('' otherwise). */\n puzzleId: prop.string({ default: '' }),\n};\ntype Props = PropsOf<typeof props>;\n\n/** The note as sent: one line of plain text, trimmed, at most REPORT_NOTE_MAX characters. */\nexport const cleanNote = (value: unknown): string =>\n typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim().slice(0, REPORT_NOTE_MAX) : '';\n\nconst styles = css`\n :host {\n display: block;\n }\n details {\n border-block-start: 1px solid var(--jy-border, currentColor);\n padding-block-start: var(--jy-space-2xs, 0.5rem);\n }\n summary {\n cursor: pointer;\n inline-size: fit-content;\n min-block-size: var(--jy-target-min, 2.75rem);\n display: flex;\n align-items: center;\n color: var(--jy-text-muted, inherit);\n font-size: var(--jy-step--1, 0.9rem);\n }\n summary:focus-visible {\n outline: 3px solid var(--jy-focus-ring, Highlight);\n outline-offset: 2px;\n }\n form {\n display: grid;\n gap: var(--jy-space-2xs, 0.5rem);\n justify-items: start;\n }\n fieldset {\n display: grid;\n gap: var(--jy-space-3xs, 0.25rem);\n margin: 0;\n padding: 0;\n border: 0;\n }\n legend {\n font-weight: 600;\n padding: 0;\n margin-block-end: var(--jy-space-3xs, 0.25rem);\n }\n .choice {\n display: flex;\n align-items: center;\n gap: var(--jy-space-2xs, 0.5rem);\n min-block-size: 2rem;\n }\n label[for] {\n font-weight: 600;\n }\n textarea {\n inline-size: min(100%, 28rem);\n font: inherit;\n color: var(--jy-text, CanvasText);\n background: var(--jy-surface-raised, Canvas);\n border: 1px solid var(--jy-border, currentColor);\n border-radius: var(--jy-radius-s, 0.375rem);\n padding: var(--jy-space-2xs, 0.5rem);\n resize: vertical;\n }\n .hint {\n margin: 0;\n color: var(--jy-text-muted, inherit);\n font-size: var(--jy-step--1, 0.9rem);\n }\n .problem {\n margin: 0;\n color: var(--jy-danger, inherit);\n }\n .status:empty {\n display: none;\n }\n .status {\n margin: 0;\n }\n button {\n min-block-size: var(--jy-target-min, 2.75rem);\n padding-inline: var(--jy-space-s, 1rem);\n font: inherit;\n font-weight: 600;\n color: var(--jy-text, CanvasText);\n background: transparent;\n border: 1px solid var(--jy-border, currentColor);\n border-radius: var(--jy-radius-pill, 999px);\n cursor: pointer;\n }\n button:focus-visible,\n textarea:focus-visible,\n input:focus-visible {\n outline: 3px solid var(--jy-focus-ring, Highlight);\n outline-offset: 2px;\n }\n`;\n\nconst STATUS: Readonly<Record<ReportPhase, string>> = {\n idle: '',\n sending: 'Sending your report…',\n sent: 'Thank you. We’ll take a look.',\n};\n\nexport const ReportProblem = define<ReportState, ReportMsg, Props>()('jy-report-problem', {\n props,\n styles,\n init: () => ({ phase: 'idle', problem: null }),\n intent: {\n // The radios are required, so the browser asks for a reason before the form submits.\n Send: ({ formData }) => {\n const reason = formData?.get(REPORT_FIELDS.reason);\n return isReportReason(reason)\n ? { _tag: 'Send', reason, note: cleanNote(formData?.get(REPORT_FIELDS.note)) }\n : undefined;\n },\n },\n update: {\n Send: (s, m, { props: p }) =>\n s.phase !== 'idle'\n ? s\n : [\n { phase: 'sending', problem: null },\n [\n sendReport<ReportMsg>(\n { game: p.gameId, puzzle: p.puzzleId, reason: m.reason, note: m.note },\n (answer) => ({ _tag: 'Answered', answer }),\n ),\n ],\n ],\n Answered: (_s, m) =>\n m.answer.ok ? { phase: 'sent', problem: null } : { phase: 'idle', problem: m.answer.message },\n },\n view: (s, i, { props: p }) => html`<details>\n <summary>Report a problem</summary>\n <p class=\"status\" role=\"status\">${STATUS[s.phase]}</p>\n ${\n s.phase === 'sent'\n ? nothing\n : html`<form data-intent=${i.Send} aria-label=\"Report a problem\">\n <fieldset>\n <legend>What’s wrong?</legend>\n ${REPORT_REASONS.map(\n (reason) =>\n html`<label class=\"choice\"><input type=\"radio\" name=${REPORT_FIELDS.reason} value=${reason} required /> ${REPORT_REASON_LABELS[reason]}</label>`,\n )}\n </fieldset>\n <label for=\"report-note\">Anything to add? (optional)</label>\n <p class=\"hint\" id=\"report-note-hint\">Up to ${REPORT_NOTE_MAX} characters. Please leave out your name and how to reach you.</p>\n <textarea id=\"report-note\" name=${REPORT_FIELDS.note} rows=\"2\" maxlength=${String(REPORT_NOTE_MAX)} aria-describedby=\"report-note-hint\"></textarea>\n <p class=\"hint\">We get the game${p.puzzleId === '' ? '' : ' and the puzzle'}, the reason and your note, and nothing about you.</p>\n ${s.problem === null ? nothing : html`<p class=\"problem\" role=\"alert\">${s.problem}</p>`}\n <button type=\"submit\" ?disabled=${s.phase === 'sending'}>Send report</button>\n </form>`\n }\n </details>`,\n});\n"],"mappings":"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"}
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Where the form posts (apps/site/src/server/reports/handler.ts). */
|
|
2
|
+
export declare const REPORT_PATH = "/api/reports";
|
|
3
|
+
export declare const REPORT_REASONS: readonly ["wrong-answer", "confusing", "offensive", "other"];
|
|
4
|
+
export type ReportReason = (typeof REPORT_REASONS)[number];
|
|
5
|
+
/** The choices as players read them, in the order the form lists them. */
|
|
6
|
+
export declare const REPORT_REASON_LABELS: Readonly<Record<ReportReason, string>>;
|
|
7
|
+
/** The note's longest length, in characters. */
|
|
8
|
+
export declare const REPORT_NOTE_MAX = 200;
|
|
9
|
+
/** The form's field names. */
|
|
10
|
+
export declare const REPORT_FIELDS: {
|
|
11
|
+
readonly game: "game";
|
|
12
|
+
readonly puzzle: "puzzle";
|
|
13
|
+
readonly reason: "reason";
|
|
14
|
+
readonly note: "note";
|
|
15
|
+
};
|
|
16
|
+
/** A puzzle ID the site accepts: `<game>:<date>`, `<game>:pack:<slug>` and the like. */
|
|
17
|
+
export declare const isReportPuzzleId: (gameId: string, value: string) => boolean;
|
|
18
|
+
export declare const isReportReason: (value: unknown) => value is ReportReason;
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//#region src/report.ts
|
|
2
|
+
/** Where the form posts (apps/site/src/server/reports/handler.ts). */
|
|
3
|
+
var REPORT_PATH = "/api/reports";
|
|
4
|
+
var REPORT_REASONS = [
|
|
5
|
+
"wrong-answer",
|
|
6
|
+
"confusing",
|
|
7
|
+
"offensive",
|
|
8
|
+
"other"
|
|
9
|
+
];
|
|
10
|
+
/** The choices as players read them, in the order the form lists them. */
|
|
11
|
+
var REPORT_REASON_LABELS = {
|
|
12
|
+
"wrong-answer": "The answer is wrong",
|
|
13
|
+
confusing: "A clue or hint is confusing",
|
|
14
|
+
offensive: "Something is unkind or not suitable",
|
|
15
|
+
other: "Something else"
|
|
16
|
+
};
|
|
17
|
+
/** The note's longest length, in characters. */
|
|
18
|
+
var REPORT_NOTE_MAX = 200;
|
|
19
|
+
/** The form's field names. */
|
|
20
|
+
var REPORT_FIELDS = {
|
|
21
|
+
game: "game",
|
|
22
|
+
puzzle: "puzzle",
|
|
23
|
+
reason: "reason",
|
|
24
|
+
note: "note"
|
|
25
|
+
};
|
|
26
|
+
/** A puzzle ID the site accepts: `<game>:<date>`, `<game>:pack:<slug>` and the like. */
|
|
27
|
+
var isReportPuzzleId = (gameId, value) => value.length <= 120 && value.startsWith(`${gameId}:`) && /^[a-z0-9-]+(:[a-z0-9-]+)+$/.test(value);
|
|
28
|
+
var isReportReason = (value) => typeof value === "string" && REPORT_REASONS.includes(value);
|
|
29
|
+
//#endregion
|
|
30
|
+
export { REPORT_FIELDS, REPORT_NOTE_MAX, REPORT_PATH, REPORT_REASONS, REPORT_REASON_LABELS, isReportPuzzleId, isReportReason };
|
|
31
|
+
|
|
32
|
+
//# sourceMappingURL=report.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"report.js","names":[],"sources":["../src/report.ts"],"sourcesContent":["// \"Report a problem\" (content-pipeline.md, \"Moderation in /admin\"): what the frame's report\n// form sends and what the site accepts, shared so the two can't drift. The AI-content notice\n// (D-52) points players here. A report names the game, the puzzle when the frame knows it, one\n// reason from a fixed list and an optional short note; nothing about the player (D-26).\n// Plain data only: the site's server imports it as `@joystyk/launcher/report`.\n\n/** Where the form posts (apps/site/src/server/reports/handler.ts). */\nexport const REPORT_PATH = '/api/reports';\n\nexport const REPORT_REASONS = ['wrong-answer', 'confusing', 'offensive', 'other'] as const;\nexport type ReportReason = (typeof REPORT_REASONS)[number];\n\n/** The choices as players read them, in the order the form lists them. */\nexport const REPORT_REASON_LABELS: Readonly<Record<ReportReason, string>> = {\n 'wrong-answer': 'The answer is wrong',\n confusing: 'A clue or hint is confusing',\n offensive: 'Something is unkind or not suitable',\n other: 'Something else',\n};\n\n/** The note's longest length, in characters. */\nexport const REPORT_NOTE_MAX = 200;\n\n/** The form's field names. */\nexport const REPORT_FIELDS = { game: 'game', puzzle: 'puzzle', reason: 'reason', note: 'note' } as const;\n\n/** A puzzle ID the site accepts: `<game>:<date>`, `<game>:pack:<slug>` and the like. */\nexport const isReportPuzzleId = (gameId: string, value: string): boolean =>\n value.length <= 120 && value.startsWith(`${gameId}:`) && /^[a-z0-9-]+(:[a-z0-9-]+)+$/.test(value);\n\nexport const isReportReason = (value: unknown): value is ReportReason =>\n typeof value === 'string' && (REPORT_REASONS as readonly string[]).includes(value);\n"],"mappings":";;AAOA,IAAa,cAAc;AAE3B,IAAa,iBAAiB;CAAC;CAAgB;CAAa;CAAa;AAAO;;AAIhF,IAAa,uBAA+D;CAC1E,gBAAgB;CAChB,WAAW;CACX,WAAW;CACX,OAAO;AACT;;AAGA,IAAa,kBAAkB;;AAG/B,IAAa,gBAAgB;CAAE,MAAM;CAAQ,QAAQ;CAAU,QAAQ;CAAU,MAAM;AAAO;;AAG9F,IAAa,oBAAoB,QAAgB,UAC/C,MAAM,UAAU,OAAO,MAAM,WAAW,GAAG,OAAO,EAAE,KAAK,6BAA6B,KAAK,KAAK;AAElG,IAAa,kBAAkB,UAC7B,OAAO,UAAU,YAAa,eAAqC,SAAS,KAAK"}
|