Initialize calm game project
This commit is contained in:
+29
@@ -0,0 +1,29 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Codex/local agent state
|
||||
.codex/logs/
|
||||
.codex/cache/
|
||||
.agents/logs/
|
||||
.agents/cache/
|
||||
@@ -0,0 +1,45 @@
|
||||
# AGENTS.md
|
||||
|
||||
SvelteKit + TypeScript family quiz game.
|
||||
|
||||
This file is the durable project contract for AI coding agents. Keep reusable
|
||||
agent workflow guidance here or under `development/_reference/ai/`.
|
||||
|
||||
## Workflow
|
||||
|
||||
- Codex is the orchestrator, verifier, reviewer, and committer.
|
||||
- Use `npm run agent:loop -- --task "<concrete task>"` when Codex delegates
|
||||
implementation to the Pi worker harness.
|
||||
- Worker agents receive one concrete task. They must not broaden scope, commit,
|
||||
push, deploy, rewrite git history, or edit agent workflow files unless the task
|
||||
explicitly asks.
|
||||
- Progress is captured through reviewed git checkpoint commits, not through
|
||||
long-lived execution logs.
|
||||
- Technical run artifacts live in `.codex/logs/agent-runs/` and are ignored.
|
||||
|
||||
## Verification
|
||||
|
||||
Run the narrowest relevant checks after changes:
|
||||
|
||||
- `npm run check` for Svelte/TypeScript changes.
|
||||
- `npm run build` for production-build confidence.
|
||||
- Use a real local browser smoke for visible UI behavior changes.
|
||||
|
||||
Never claim work is done until there is a code diff and observed verification.
|
||||
|
||||
## Project Shape
|
||||
|
||||
- `src/lib/types.ts` holds domain types.
|
||||
- `src/lib/question-sets/` holds question data.
|
||||
- `src/lib/stores/game.ts` owns game state and localStorage persistence.
|
||||
- `src/lib/components/` holds UI components.
|
||||
- `src/routes/+page.svelte` orchestrates screens.
|
||||
|
||||
## Product Rules
|
||||
|
||||
- Keep the game calm, family-friendly, and host-led.
|
||||
- Preserve the separation between question data, state, and UI.
|
||||
- Do not add backend, auth, database, multiplayer, or an editor unless explicitly
|
||||
requested.
|
||||
- Keep user-facing copy consistent with the language and tone of the touched UI.
|
||||
- Prefer small, focused changes and checkpoint commits.
|
||||
@@ -0,0 +1,123 @@
|
||||
# 🌙 Уютная викторина — Calm Family Quiz
|
||||
|
||||
Спокойная семейная игра-викторина в стиле «Своей игры», созданная для уютного
|
||||
вечера перед сном. Все смотрят на одно табло, ведущий управляет игрой, команда
|
||||
отвечает вместе — без таймеров, давления и спешки.
|
||||
|
||||
## 🚀 Запуск
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Откройте адрес из консоли (обычно `http://localhost:5173`).
|
||||
|
||||
### Прочее
|
||||
|
||||
```bash
|
||||
npm run build # production-сборка
|
||||
npm run preview # предпросмотр сборки
|
||||
npm run check # проверка типов (svelte-check)
|
||||
```
|
||||
|
||||
## 🎮 Как играть
|
||||
|
||||
1. **Выбор набора** — выберите тему вопросов.
|
||||
2. **Табло** — категории и карточки очков (100 / 200 / 300 / 500 / 1000).
|
||||
Чем дороже карточка, тем сложнее вопрос.
|
||||
3. Ведущий открывает вопрос, затем «Показать ответ», и решает:
|
||||
- **Засчитать +N** — команде идут очки;
|
||||
- **Не засчитывать** — без очков, но вопрос закрыт.
|
||||
- Штрафов и минусов нет.
|
||||
4. Для творческих вопросов (type `creative`) кнопка мягче:
|
||||
«Засчитать творческий ответ +N».
|
||||
5. Сыгранный вопрос затемняется и больше не выбирается.
|
||||
6. Когда сыграны все вопросы — экран результата с мягкой финальной фразой.
|
||||
|
||||
Прогресс сохраняется автоматически — после перезагрузки игра продолжится с того же места.
|
||||
|
||||
## 🧩 Добавление нового набора вопросов
|
||||
|
||||
Архитектура задумана так, чтобы наборы добавлялись без правок UI.
|
||||
|
||||
1. Создайте файл `src/lib/question-sets/<name>.ts`:
|
||||
|
||||
```ts
|
||||
import type { QuestionSet } from '$lib/types';
|
||||
|
||||
export const mySet: QuestionSet = {
|
||||
id: 'my-set', // уникальный
|
||||
title: 'Моя тема',
|
||||
description: 'Короткое описание',
|
||||
ageRange: '6+ лет',
|
||||
categories: [
|
||||
{
|
||||
id: 'cat-1', // уникально в пределах набора
|
||||
title: 'Категория',
|
||||
questions: [
|
||||
{ id: 'q1', points: 100, question: '...', answer: '...', type: 'strict' },
|
||||
{ id: 'q2', points: 200, question: '...', answer: '...', type: 'strict' },
|
||||
{ id: 'q3', points: 300, question: '...', answer: '...', type: 'strict' },
|
||||
{ id: 'q4', points: 500, question: '...', answer: '...', type: 'creative' },
|
||||
{ id: 'q5', points: 1000, question: '...', answer: '...', type: 'strict' }
|
||||
// ровно 5 вопросов с очками 100/200/300/500/1000
|
||||
]
|
||||
}
|
||||
// …остальные категории
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
2. Зарегистрируйте набор в `src/lib/question-sets/index.ts`:
|
||||
|
||||
```ts
|
||||
import { mySet } from './my-set';
|
||||
export const questionSets = [animalsSet, cozySet, mySet];
|
||||
```
|
||||
|
||||
Готово — набор автоматически появится на экране выбора.
|
||||
|
||||
## 🏗️ Архитектура
|
||||
|
||||
Код аккуратно разделён по слоям:
|
||||
|
||||
```
|
||||
src/
|
||||
├─ lib/
|
||||
│ ├─ types.ts # Доменные типы (Question, Category, QuestionSet, GameState)
|
||||
│ ├─ question-sets/ # ДАННЫЕ (отделены от UI)
|
||||
│ │ ├─ animals.ts # набор-заглушка
|
||||
│ │ ├─ cozy.ts # набор-заглушка
|
||||
│ │ └─ index.ts # реестр наборов + хелперы поиска
|
||||
│ ├─ stores/
|
||||
│ │ └─ game.ts # СОСТОЯНИЕ игры (Svelte store + localStorage)
|
||||
│ └─ components/ # UI-компоненты
|
||||
│ ├─ QuestionSetSelector.svelte
|
||||
│ ├─ GameBoard.svelte
|
||||
│ ├─ QuestionCard.svelte
|
||||
│ ├─ ScorePanel.svelte
|
||||
│ ├─ QuestionModal.svelte
|
||||
│ ├─ HostControls.svelte
|
||||
│ ├─ GameResult.svelte
|
||||
│ └─ ConfirmDialog.svelte
|
||||
└─ routes/
|
||||
├─ +layout.svelte # подключение глобальных стилей
|
||||
├─ +layout.ts # CSR-only (localStorage)
|
||||
└─ +page.svelte # оркестрация экранов
|
||||
```
|
||||
|
||||
**Принципы:**
|
||||
- Данные вопросов полностью отделены от состояния и UI.
|
||||
- Состояние игры живёт в Svelte store с автосохранением в `localStorage`
|
||||
и восстанавливается после перезагрузки.
|
||||
- Очки берутся из данных вопроса — ничего не хардкодится в UI.
|
||||
- Никакого бэкенда, БД, авторизации, мультиплеера или редактора в MVP.
|
||||
|
||||
## 🎨 Стек
|
||||
|
||||
- **SvelteKit + TypeScript**
|
||||
- **Tailwind CSS v4** (через `@tailwindcss/vite`)
|
||||
- Без тяжёлых UI-библиотек — только лёгкие зависимости.
|
||||
|
||||
Лицензия: личный/семейный проект.
|
||||
@@ -0,0 +1,16 @@
|
||||
# AI Reference
|
||||
|
||||
Shared references for Codex and other AI coding agents in this project.
|
||||
|
||||
## Entry Points
|
||||
|
||||
| Topic | Location |
|
||||
|---|---|
|
||||
| Project contract | [AGENTS.md](../../../AGENTS.md) |
|
||||
| Autonomous agent harness | [autonomous-agent-harness.md](autonomous-agent-harness.md) |
|
||||
| Worker loop command | [scripts/run-agent-loop.mjs](scripts/run-agent-loop.mjs) |
|
||||
| Pi worker wrapper | [scripts/run-pi-worker.mjs](scripts/run-pi-worker.mjs) |
|
||||
|
||||
Use `npm run agent:loop -- --task "<concrete task>"` when Codex delegates
|
||||
implementation to Pi. Codex remains the orchestrator, verifier, reviewer, and
|
||||
committer.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Autonomous Agent Harness
|
||||
|
||||
This project uses a Codex-led development loop with independent worker,
|
||||
verifier, and reviewer roles.
|
||||
|
||||
## Core Model
|
||||
|
||||
1. Codex reads `AGENTS.md`, the current git state, relevant code, and any
|
||||
project notes.
|
||||
2. Codex defines one concrete worker task from the user's request.
|
||||
3. Codex may use independent reviewer/verifier subagents for focused checks.
|
||||
4. Pi is used as the implementation worker through `npm run agent:loop`.
|
||||
5. Codex inspects the diff, runs checks, performs review, and asks for a worker
|
||||
fix pass if needed.
|
||||
6. Once accepted, Codex stages only the expected files and creates a git
|
||||
checkpoint commit.
|
||||
|
||||
The worker is autonomous inside its assignment, but it does not own "done". It
|
||||
must not commit, push, deploy, rewrite history, or start adjacent tasks.
|
||||
|
||||
## State
|
||||
|
||||
Git checkpoint commits are the durable state. Avoid long-lived execution logs for
|
||||
ordinary implementation progress.
|
||||
|
||||
Pi run artifacts are technical traces only and are ignored under
|
||||
`.codex/logs/agent-runs/`.
|
||||
|
||||
## Command
|
||||
|
||||
```bash
|
||||
npm run agent:loop -- \
|
||||
--task "Add a calm end-game reset flow and verify the Svelte app."
|
||||
```
|
||||
|
||||
Use `--name` only when a short run label helps:
|
||||
|
||||
```bash
|
||||
npm run agent:loop -- \
|
||||
--name "End game reset" \
|
||||
--task "Add a reset flow on the result screen. Keep the copy calm and run the relevant checks."
|
||||
```
|
||||
|
||||
## Gate
|
||||
|
||||
After a worker run, Codex accepts output only when `summary.json` reports
|
||||
`orchestratorStatus=WORKER_DONE_PENDING_CODEX_GATE`. Then Codex runs:
|
||||
|
||||
- `git status --short`
|
||||
- `git diff --check`
|
||||
- `git status --short -- .pi .agents .codex AGENTS.md development/_reference/ai`
|
||||
- automatic checks selected from changed files
|
||||
|
||||
For app changes the automatic checks are:
|
||||
|
||||
- `npm run check`
|
||||
- `npm run build`
|
||||
|
||||
Visible UI changes still need a real local browser smoke before a commit.
|
||||
|
||||
## Stop Conditions
|
||||
|
||||
Stop and ask the user when:
|
||||
|
||||
- the task needs product/design judgment not present in context;
|
||||
- credentials, deployment, external services, or destructive commands are needed;
|
||||
- unrelated local changes block safe progress;
|
||||
- reviewer returns the same blocking concern twice after fix attempts.
|
||||
+454
@@ -0,0 +1,454 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const DONE_STATUS = 'WORKER_DONE_PENDING_CODEX_GATE';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {};
|
||||
const booleans = new Set(['dry-run', 'gate-only', 'skip-worker', 'help']);
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (!arg.startsWith('--')) {
|
||||
out._ = out._ || [];
|
||||
out._.push(arg);
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = arg.slice(2);
|
||||
if (booleans.has(key)) {
|
||||
out[key] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = argv[i + 1];
|
||||
if (!value || value.startsWith('--')) {
|
||||
throw new Error(`Missing value for --${key}`);
|
||||
}
|
||||
out[key] = value;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function usage() {
|
||||
return `Usage:
|
||||
npm run agent:loop -- --task "Add a calm end-game reset flow and verify the Svelte app."
|
||||
|
||||
Options:
|
||||
--version Development version. Defaults to v<root package.json version>.
|
||||
--name Optional run label. Defaults to a slug from --task.
|
||||
--task Worker assignment text. Required unless --prompt-file is used.
|
||||
--prompt-file Optional file with a longer Codex-authored assignment.
|
||||
--run-dir Existing .codex/logs/agent-runs/<version>/... directory for --gate-only.
|
||||
--runs-dir Optional base directory for run artifacts. Defaults to .codex/logs/agent-runs.
|
||||
--run-checks none, auto, or full. Defaults to auto.
|
||||
auto runs git gate and existing npm check/build scripts for app changes.
|
||||
full is currently the same as auto unless more project checks are added.
|
||||
--model Optional Pi model. Forwarded to run-pi-worker.mjs.
|
||||
--thinking Optional Pi thinking level. Forwarded to run-pi-worker.mjs.
|
||||
--pi Optional pi binary path. Forwarded to run-pi-worker.mjs.
|
||||
--session-id Optional Pi session id. Forwarded to run-pi-worker.mjs.
|
||||
--event-idle-timeout-ms
|
||||
Optional Pi idle watchdog. Forwarded to run-pi-worker.mjs.
|
||||
--dry-run Generate the Pi prompt/artifacts only; skip the Codex gate.
|
||||
--gate-only Do not launch Pi; run the Codex gate for --run-dir or latest run.
|
||||
`;
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
return JSON.parse(readFileSync(path, 'utf8'));
|
||||
}
|
||||
|
||||
function rootVersion(cwd) {
|
||||
const pkg = readJson(resolve(cwd, 'package.json'));
|
||||
if (!pkg.version) throw new Error('Root package.json has no version');
|
||||
return pkg.version;
|
||||
}
|
||||
|
||||
function normalizeVersion(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return raw;
|
||||
return raw.startsWith('v') ? raw : `v${raw}`;
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return String(value || 'worker')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80) || 'worker';
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
const text = String(value);
|
||||
if (/^[a-zA-Z0-9_./:=@+-]+$/.test(text)) return text;
|
||||
return `'${text.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
function formatCommand(command, args) {
|
||||
return [command, ...args].map(shellQuote).join(' ');
|
||||
}
|
||||
|
||||
function printHeading(title) {
|
||||
console.log(`\n== ${title} ==`);
|
||||
}
|
||||
|
||||
function capture(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd || process.cwd(),
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return {
|
||||
command: formatCommand(command, args),
|
||||
cwd: options.cwd || process.cwd(),
|
||||
code: 1,
|
||||
signal: null,
|
||||
stdout: result.stdout || '',
|
||||
stderr: `${result.stderr || ''}${result.error.message || result.error}\n`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: formatCommand(command, args),
|
||||
cwd: options.cwd || process.cwd(),
|
||||
code: result.status ?? (result.signal ? 1 : 0),
|
||||
signal: result.signal,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
};
|
||||
}
|
||||
|
||||
function runCheck(label, command, args, options = {}) {
|
||||
console.log(`\n$ ${formatCommand(command, args)}`);
|
||||
const result = capture(command, args, options);
|
||||
if (result.stdout.trim()) process.stdout.write(result.stdout);
|
||||
if (result.stderr.trim()) process.stderr.write(result.stderr);
|
||||
if (!result.stdout.trim() && !result.stderr.trim()) console.log('(no output)');
|
||||
console.log(`[exit ${result.code}] ${label}`);
|
||||
return { label, ...result };
|
||||
}
|
||||
|
||||
function lines(text) {
|
||||
return String(text || '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function uniqueSorted(values) {
|
||||
return [...new Set(values.filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function getChangedFiles(cwd, pathspecs = []) {
|
||||
const tracked = capture('git', ['diff', '--name-only', 'HEAD', '--', ...pathspecs], { cwd });
|
||||
const untracked = capture('git', ['ls-files', '--others', '--exclude-standard', '--', ...pathspecs], { cwd });
|
||||
|
||||
return uniqueSorted([
|
||||
...(tracked.code === 0 ? lines(tracked.stdout) : []),
|
||||
...(untracked.code === 0 ? lines(untracked.stdout) : []),
|
||||
]);
|
||||
}
|
||||
|
||||
function defaultRunsDir(cwd, args) {
|
||||
return resolve(cwd, args['runs-dir'] || '.codex/logs/agent-runs');
|
||||
}
|
||||
|
||||
function newestRunDir(cwd, args, version, name) {
|
||||
const runsDir = resolve(defaultRunsDir(cwd, args), version);
|
||||
if (!existsSync(runsDir)) return null;
|
||||
|
||||
const wantedSuffix = name ? `-pi-worker-${slugify(name)}` : null;
|
||||
const dirs = readdirSync(runsDir)
|
||||
.map((name) => resolve(runsDir, name))
|
||||
.filter((path) => {
|
||||
if (!existsSync(path) || !statSync(path).isDirectory()) return false;
|
||||
if (wantedSuffix && !basename(path).endsWith(wantedSuffix)) return false;
|
||||
return existsSync(join(path, 'summary.json'));
|
||||
})
|
||||
.map((path) => ({ path, mtimeMs: statSync(path).mtimeMs }))
|
||||
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
|
||||
return dirs[0]?.path || null;
|
||||
}
|
||||
|
||||
function resolveRunDir(cwd, args, version, name) {
|
||||
if (args['run-dir']) {
|
||||
const runDir = resolve(cwd, args['run-dir']);
|
||||
if (!existsSync(runDir)) throw new Error(`Run directory does not exist: ${runDir}`);
|
||||
return runDir;
|
||||
}
|
||||
|
||||
const runDir = newestRunDir(cwd, args, version, name);
|
||||
if (!runDir) {
|
||||
throw new Error(`Could not find an agent run for ${version}${name ? ` / ${name}` : ''}`);
|
||||
}
|
||||
return runDir;
|
||||
}
|
||||
|
||||
function summaryVersionFromRunDir(cwd, args) {
|
||||
if (!args['run-dir']) return null;
|
||||
const summaryPath = resolve(cwd, args['run-dir'], 'summary.json');
|
||||
if (!existsSync(summaryPath)) return null;
|
||||
const summary = readJson(summaryPath);
|
||||
return summary.version ? normalizeVersion(summary.version) : null;
|
||||
}
|
||||
|
||||
function runWorker(cwd, args, version, name) {
|
||||
const workerScript = resolve(cwd, 'development/_reference/ai/scripts/run-pi-worker.mjs');
|
||||
const workerArgs = [
|
||||
workerScript,
|
||||
'--version',
|
||||
version,
|
||||
];
|
||||
|
||||
if (name) workerArgs.push('--name', name);
|
||||
if (args.task) workerArgs.push('--task', args.task);
|
||||
if (args['prompt-file']) workerArgs.push('--prompt-file', args['prompt-file']);
|
||||
if (args['runs-dir']) workerArgs.push('--runs-dir', args['runs-dir']);
|
||||
if (args.model) workerArgs.push('--model', args.model);
|
||||
if (args.thinking) workerArgs.push('--thinking', args.thinking);
|
||||
if (args.pi) workerArgs.push('--pi', args.pi);
|
||||
if (args['session-id']) workerArgs.push('--session-id', args['session-id']);
|
||||
if (args['event-idle-timeout-ms']) {
|
||||
workerArgs.push('--event-idle-timeout-ms', args['event-idle-timeout-ms']);
|
||||
}
|
||||
if (args['dry-run']) workerArgs.push('--dry-run');
|
||||
|
||||
printHeading('Pi Worker');
|
||||
console.log(`$ ${formatCommand(process.execPath, workerArgs)}`);
|
||||
const result = spawnSync(process.execPath, workerArgs, {
|
||||
cwd,
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(result.error.message || result.error);
|
||||
}
|
||||
|
||||
return {
|
||||
code: result.error ? 1 : (result.status ?? (result.signal ? 1 : 0)),
|
||||
signal: result.signal,
|
||||
};
|
||||
}
|
||||
|
||||
function forbiddenPathspecs(version) {
|
||||
return [
|
||||
'.pi',
|
||||
'.agents',
|
||||
'.codex',
|
||||
'AGENTS.md',
|
||||
'development/_reference/ai',
|
||||
];
|
||||
}
|
||||
|
||||
function packageScripts(cwd) {
|
||||
try {
|
||||
return readJson(resolve(cwd, 'package.json')).scripts || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function hasScript(cwd, name) {
|
||||
return Object.prototype.hasOwnProperty.call(packageScripts(cwd), name);
|
||||
}
|
||||
|
||||
function selectAutoChecks(cwd, changedFiles, mode) {
|
||||
if (mode === 'none') return [];
|
||||
|
||||
const checks = [];
|
||||
const appChanged = changedFiles.some((file) => (
|
||||
file.startsWith('src/') ||
|
||||
file.startsWith('static/') ||
|
||||
[
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'svelte.config.js',
|
||||
'tsconfig.json',
|
||||
'vite.config.ts',
|
||||
'vite.config.js',
|
||||
].includes(file)
|
||||
));
|
||||
|
||||
if (appChanged) {
|
||||
if (hasScript(cwd, 'check')) checks.push({ label: 'Svelte/type check', command: 'npm', args: ['run', 'check'], cwd });
|
||||
if (hasScript(cwd, 'build')) checks.push({ label: 'Production build', command: 'npm', args: ['run', 'build'], cwd });
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
function hasUiBehaviorRisk(changedFiles) {
|
||||
return changedFiles.some((file) => {
|
||||
if (!file.startsWith('src/') && !file.startsWith('static/')) return false;
|
||||
return ['.svelte', '.ts', '.js', '.html', '.css', '.svg'].some((suffix) => file.endsWith(suffix));
|
||||
});
|
||||
}
|
||||
|
||||
function writeGateArtifact(runDir, payload) {
|
||||
const path = join(runDir, 'codex-gate.json');
|
||||
writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`);
|
||||
return path;
|
||||
}
|
||||
|
||||
function runGate({ cwd, args, version, name, preForbiddenFiles, workerResult }) {
|
||||
const runChecks = args['run-checks'] || 'auto';
|
||||
if (!['none', 'auto', 'full'].includes(runChecks)) {
|
||||
throw new Error(`Unsupported --run-checks ${runChecks}. Expected none, auto, or full.`);
|
||||
}
|
||||
|
||||
const runDir = resolveRunDir(cwd, args, version, name);
|
||||
const summaryPath = join(runDir, 'summary.json');
|
||||
if (!existsSync(summaryPath)) throw new Error(`Missing summary.json in ${runDir}`);
|
||||
|
||||
const summary = readJson(summaryPath);
|
||||
const checks = [];
|
||||
const failures = [];
|
||||
|
||||
printHeading('Worker Summary');
|
||||
console.log(`Run directory: ${runDir}`);
|
||||
console.log(`Final status: ${summary.finalStatus || 'null'}`);
|
||||
console.log(`Orchestrator status: ${summary.orchestratorStatus || 'null'}`);
|
||||
console.log(`Last message: ${summary.lastAssistantTextPath || join(runDir, 'last-message.txt')}`);
|
||||
if (workerResult?.code) {
|
||||
console.log(`Worker process exit: ${workerResult.code}${workerResult.signal ? ` (${workerResult.signal})` : ''}`);
|
||||
}
|
||||
|
||||
printHeading('Codex Gate');
|
||||
checks.push(runCheck('git status', 'git', ['status', '--short'], { cwd }));
|
||||
checks.push(runCheck('git diff whitespace check', 'git', ['diff', '--check'], { cwd }));
|
||||
|
||||
const forbidden = forbiddenPathspecs(version);
|
||||
const forbiddenStatusCheck = runCheck(
|
||||
'forbidden orchestration path status',
|
||||
'git',
|
||||
['status', '--short', '--', ...forbidden],
|
||||
{ cwd },
|
||||
);
|
||||
checks.push(forbiddenStatusCheck);
|
||||
|
||||
const currentForbiddenFiles = getChangedFiles(cwd, forbidden);
|
||||
const preForbidden = new Set(preForbiddenFiles || []);
|
||||
const newForbiddenFiles = currentForbiddenFiles.filter((file) => !preForbidden.has(file));
|
||||
|
||||
if (summary.orchestratorStatus !== DONE_STATUS) {
|
||||
failures.push(`worker status is ${summary.orchestratorStatus || 'missing'}, expected ${DONE_STATUS}`);
|
||||
}
|
||||
for (const check of checks) {
|
||||
if (check.code !== 0) failures.push(`${check.label} exited ${check.code}`);
|
||||
}
|
||||
if (newForbiddenFiles.length > 0) {
|
||||
failures.push(`forbidden paths changed during/after worker: ${newForbiddenFiles.join(', ')}`);
|
||||
}
|
||||
|
||||
const changedFiles = getChangedFiles(cwd);
|
||||
const autoChecks = [];
|
||||
if (failures.length === 0) {
|
||||
const selectedChecks = selectAutoChecks(cwd, changedFiles, runChecks);
|
||||
if (selectedChecks.length > 0) printHeading('Auto Checks');
|
||||
for (const check of selectedChecks) {
|
||||
const result = runCheck(check.label, check.command, check.args, { cwd: check.cwd });
|
||||
autoChecks.push(result);
|
||||
if (result.code !== 0) failures.push(`${check.label} exited ${result.code}`);
|
||||
}
|
||||
} else {
|
||||
console.log('\nSkipping auto checks because the basic gate did not pass.');
|
||||
}
|
||||
|
||||
const uiSmokeRequired = hasUiBehaviorRisk(changedFiles);
|
||||
const status = failures.length === 0 ? 'PASSED_PENDING_REVIEW' : 'FAILED';
|
||||
const gatePath = writeGateArtifact(runDir, {
|
||||
status,
|
||||
generatedAt: new Date().toISOString(),
|
||||
runDir,
|
||||
version,
|
||||
name,
|
||||
runChecks,
|
||||
workerResult: workerResult || null,
|
||||
workerSummary: {
|
||||
finalStatus: summary.finalStatus || null,
|
||||
orchestratorStatus: summary.orchestratorStatus || null,
|
||||
lastAssistantTextPath: summary.lastAssistantTextPath || null,
|
||||
gitStatusAfter: summary.gitStatusAfter || null,
|
||||
},
|
||||
changedFiles,
|
||||
forbiddenFiles: currentForbiddenFiles,
|
||||
newForbiddenFiles,
|
||||
checks,
|
||||
autoChecks,
|
||||
uiSmokeRequired,
|
||||
failures,
|
||||
});
|
||||
|
||||
printHeading('Gate Result');
|
||||
console.log(`Gate artifact: ${gatePath}`);
|
||||
console.log(`Changed files: ${changedFiles.length ? changedFiles.join(', ') : '(none)'}`);
|
||||
if (uiSmokeRequired) {
|
||||
console.log('UI smoke: required if this changed visible behavior; use the local stand and a real browser before commit.');
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.log('Verdict: FAILED');
|
||||
for (const failure of failures) console.log(`- ${failure}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
console.log('Verdict: PASSED_PENDING_REVIEW');
|
||||
console.log('Next: Codex reviews the diff, then stages only expected files and commits if approved.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const cwd = process.cwd();
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (args.help) {
|
||||
console.log(usage());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existsSync(resolve(cwd, 'AGENTS.md')) || !existsSync(resolve(cwd, 'development/_reference/ai/scripts/run-pi-worker.mjs'))) {
|
||||
throw new Error('Run this command from the project repository root.');
|
||||
}
|
||||
|
||||
const gateOnly = Boolean(args['gate-only'] || args['skip-worker']);
|
||||
const runDirVersion = gateOnly ? summaryVersionFromRunDir(cwd, args) : null;
|
||||
const version = normalizeVersion(args.version || runDirVersion || rootVersion(cwd));
|
||||
if (args.version && runDirVersion && normalizeVersion(args.version) !== runDirVersion) {
|
||||
throw new Error(`--version ${normalizeVersion(args.version)} does not match run summary version ${runDirVersion}`);
|
||||
}
|
||||
const name = args.name || (gateOnly ? '' : slugify(args.task || args['prompt-file'] || 'worker'));
|
||||
const hasTask = Boolean(args.task || args['prompt-file']);
|
||||
|
||||
if (!gateOnly && !hasTask) throw new Error(`Missing --task or --prompt-file\n\n${usage()}`);
|
||||
|
||||
const runsDir = resolve(defaultRunsDir(cwd, args), version);
|
||||
mkdirSync(runsDir, { recursive: true });
|
||||
|
||||
const preForbiddenFiles = gateOnly ? [] : getChangedFiles(cwd, forbiddenPathspecs(version));
|
||||
const workerResult = gateOnly ? null : runWorker(cwd, args, version, name);
|
||||
|
||||
if (args['dry-run']) {
|
||||
console.log('\nDry run complete. Pi was not launched and the Codex gate was skipped.');
|
||||
process.exit(workerResult.code || 0);
|
||||
}
|
||||
|
||||
const exitCode = runGate({ cwd, args, version, name, preForbiddenFiles, workerResult });
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error.message || error);
|
||||
process.exit(1);
|
||||
}
|
||||
+579
@@ -0,0 +1,579 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createWriteStream, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (!arg.startsWith('--')) {
|
||||
out._ = out._ || [];
|
||||
out._.push(arg);
|
||||
continue;
|
||||
}
|
||||
const key = arg.slice(2);
|
||||
if (key === 'dry-run') {
|
||||
out[key] = true;
|
||||
continue;
|
||||
}
|
||||
const value = argv[i + 1];
|
||||
if (!value || value.startsWith('--')) {
|
||||
throw new Error(`Missing value for --${key}`);
|
||||
}
|
||||
out[key] = value;
|
||||
i += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function usage() {
|
||||
return `Usage:
|
||||
node development/_reference/ai/scripts/run-pi-worker.mjs \\
|
||||
--version v0.1.0 \\
|
||||
--name "Add calm reset flow" \\
|
||||
--task "Implement only the concrete worker task described by Codex."
|
||||
|
||||
Options:
|
||||
--version Required development version, for example v0.6.47.
|
||||
--name Optional run label. Defaults to a slug from --task.
|
||||
--task Worker assignment text. Required unless --prompt-file is used.
|
||||
--prompt-file Optional file with a longer Codex-authored assignment.
|
||||
--session-id Optional Pi session id. Defaults to a stable version/name id.
|
||||
--model Optional Pi model. Defaults to zai/glm-5.2.
|
||||
--thinking Optional Pi thinking level. Defaults to xhigh, which Z.ai maps to reasoning_effort=max.
|
||||
--pi Optional pi binary path. Defaults to PI_BIN or pi.
|
||||
--pi-mode Optional Pi mode: json or text. Defaults to json.
|
||||
--runs-dir Optional base directory for run artifacts. Defaults to .codex/logs/agent-runs.
|
||||
--event-idle-timeout-ms
|
||||
Optional watchdog timeout without worker output. Defaults to 600000 (10 minutes). Use 0 to disable.
|
||||
--dry-run Write prompt/meta, print command, but do not launch Pi.
|
||||
`;
|
||||
}
|
||||
|
||||
function requireArg(args, name) {
|
||||
if (!args[name]) throw new Error(`Missing required --${name}\n\n${usage()}`);
|
||||
return args[name];
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return String(value || 'worker')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80) || 'worker';
|
||||
}
|
||||
|
||||
function timestamp() {
|
||||
return new Date().toISOString().replace(/[:.]/g, '-');
|
||||
}
|
||||
|
||||
function readOptionalFile(path) {
|
||||
if (!path) return '';
|
||||
return readFileSync(resolve(path), 'utf8').trim();
|
||||
}
|
||||
|
||||
function parseNonNegativeInt(value, fallback) {
|
||||
if (value === undefined) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
throw new Error(`Expected a non-negative integer, got: ${value}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function getGitStatus(cwd) {
|
||||
try {
|
||||
return execFileSync('git', ['status', '--short'], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (error) {
|
||||
return `GIT_STATUS_ERROR: ${error.message || error}`;
|
||||
}
|
||||
}
|
||||
|
||||
function textFromContent(value) {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
if (Array.isArray(value)) return value.map(textFromContent).filter(Boolean).join('');
|
||||
if (typeof value !== 'object') return '';
|
||||
|
||||
if (typeof value.text === 'string') return value.text;
|
||||
if (typeof value.delta === 'string') return value.delta;
|
||||
if (typeof value.content === 'string') return value.content;
|
||||
if (Array.isArray(value.content)) return textFromContent(value.content);
|
||||
if (value.type === 'text' && typeof value.value === 'string') return value.value;
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function textFromMessage(message) {
|
||||
if (!message || message.role !== 'assistant') return '';
|
||||
return textFromContent(message.content).trim();
|
||||
}
|
||||
|
||||
function extractTextDelta(event) {
|
||||
const update = event?.assistantMessageEvent;
|
||||
if (!update || typeof update !== 'object') return '';
|
||||
if (typeof update.delta === 'string') return update.delta;
|
||||
if (typeof update.text === 'string') return update.text;
|
||||
return '';
|
||||
}
|
||||
|
||||
function findLastAssistantTextFromMessages(messages) {
|
||||
if (!Array.isArray(messages)) return '';
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const text = textFromMessage(messages[i]);
|
||||
if (text) return text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function findFinalStatus(text) {
|
||||
const matches = [...String(text || '').matchAll(/(?:^|\n)\s*WORKER_STATUS:\s*(WORKER_DONE|BLOCKED|NEEDS_DISCUSSION)\b/g)];
|
||||
return matches.at(-1)?.[1] || null;
|
||||
}
|
||||
|
||||
function summarizeOrchestratorStatus({ finalStatus, exitCode, signal, killedByIdle, gitStatusAfter }) {
|
||||
if (killedByIdle) {
|
||||
return gitStatusAfter ? 'WORKER_STALLED_WITH_DIFF' : 'WORKER_STALLED_NO_DIFF';
|
||||
}
|
||||
if (signal) return `PROCESS_SIGNAL_${signal}`;
|
||||
if (exitCode !== null && exitCode !== undefined && exitCode !== 0) return 'PROCESS_FAILED';
|
||||
if (finalStatus === 'WORKER_DONE') return 'WORKER_DONE_PENDING_CODEX_GATE';
|
||||
if (finalStatus === 'BLOCKED') return 'WORKER_BLOCKED';
|
||||
if (finalStatus === 'NEEDS_DISCUSSION') return 'WORKER_NEEDS_DISCUSSION';
|
||||
if (exitCode === null || exitCode === undefined) return 'RUNNING_AWAITING_STATUS';
|
||||
return 'NO_STRUCTURED_STATUS';
|
||||
}
|
||||
|
||||
function renderEventForConsole(event) {
|
||||
if (!event || typeof event !== 'object') return null;
|
||||
if (event.type === 'message_update') return extractTextDelta(event) || null;
|
||||
if (event.type === 'tool_execution_start') return `\n[pi] tool start: ${event.toolName || 'unknown'}\n`;
|
||||
if (event.type === 'tool_execution_end') {
|
||||
const status = event.isError ? 'error' : 'ok';
|
||||
return `\n[pi] tool end: ${event.toolName || 'unknown'} (${status})\n`;
|
||||
}
|
||||
if (
|
||||
event.type === 'agent_start' ||
|
||||
event.type === 'agent_end' ||
|
||||
event.type === 'turn_start' ||
|
||||
event.type === 'turn_end' ||
|
||||
event.type === 'compaction_start' ||
|
||||
event.type === 'compaction_end' ||
|
||||
event.type === 'auto_retry_start' ||
|
||||
event.type === 'auto_retry_end' ||
|
||||
event.type === 'extension_error'
|
||||
) {
|
||||
return `\n[pi] ${event.type}\n`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function assertRequiredFiles(cwd, version) {
|
||||
const required = [
|
||||
'AGENTS.md',
|
||||
'development/_reference/ai/autonomous-agent-harness.md',
|
||||
];
|
||||
|
||||
const missing = required.filter((file) => !existsSync(resolve(cwd, file)));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing required orchestration files:\n${missing.map((file) => `- ${file}`).join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function existingContextFiles(cwd, version) {
|
||||
return [
|
||||
'README.md',
|
||||
`development/${version}/prd.md`,
|
||||
`development/${version}/plan.md`,
|
||||
`development/${version}/execution-log.md`,
|
||||
'development/_reference/ai/autonomous-agent-harness.md',
|
||||
].filter((file) => existsSync(resolve(cwd, file)));
|
||||
}
|
||||
|
||||
function buildPrompt({ contextFiles, name, taskText, version }) {
|
||||
const contextLine = contextFiles.length > 0
|
||||
? `Read these context files when relevant: ${contextFiles.join(', ')}.`
|
||||
: 'No version planning files were found; use the direct Codex assignment as the scoped contract.';
|
||||
|
||||
return `You are the Pi worker launched by Codex-Orchestrator for this project.
|
||||
|
||||
Codex remains the orchestrator, verifier, reviewer, and committer. You are only the bounded implementation worker.
|
||||
|
||||
Task label:
|
||||
${name}
|
||||
|
||||
Worker assignment:
|
||||
${taskText}
|
||||
|
||||
Repository contract:
|
||||
- Read AGENTS.md before editing.
|
||||
- ${contextLine}
|
||||
- The direct Codex assignment is the binding scope. Do not broaden it into adjacent cleanup, dependent tasks, or a full release pass.
|
||||
- If the assignment is too large to finish safely, implement a coherent safe subset and report the recommended next worker task.
|
||||
- Do not edit planning or reference docs unless the assignment explicitly asks.
|
||||
- Do not create or update execution logs unless the assignment explicitly asks. Durable progress is captured by Codex through reviewed git checkpoint commits.
|
||||
- Do not commit, push, merge, reset, clean, or rewrite git history.
|
||||
- Do not deploy, publish, or touch external services unless the assignment explicitly says so.
|
||||
- Do not run destructive cleanup or wipe commands.
|
||||
- Do not edit .pi/**, .agents/**, AGENTS.md, or development/_reference/ai/**.
|
||||
- If unrelated uncommitted changes exist, work around them. Stop with NEEDS_DISCUSSION if they block the task.
|
||||
- If credentials, external access, clean local stand setup, or human business choice is required, stop with BLOCKED instead of asking a question.
|
||||
- The ask_question tool is disabled. Do not request interactive confirmation.
|
||||
|
||||
Implementation rules:
|
||||
- Prefer existing repo patterns and narrow diffs.
|
||||
- This is a SvelteKit + TypeScript app. Keep UI behavior calm, readable, and consistent with the existing design.
|
||||
- Keep game data, game state, and UI components separated according to the current structure.
|
||||
- Keep user-facing copy consistent with the app language and tone already present in the touched files.
|
||||
- Prefer existing npm scripts from package.json. Do not invent scripts.
|
||||
- For UI behavior changes, describe the local browser smoke path Codex should verify.
|
||||
|
||||
Worker output contract:
|
||||
- Run only relevant local checks that exist in package.json / repo docs.
|
||||
- If UI behavior changed and local browser smoke is required, use only a local dev server.
|
||||
- Finish with one of: WORKER_DONE, BLOCKED, or NEEDS_DISCUSSION.
|
||||
- End your final response with a standalone final status line in this exact shape:
|
||||
WORKER_STATUS: WORKER_DONE
|
||||
or WORKER_STATUS: BLOCKED
|
||||
or WORKER_STATUS: NEEDS_DISCUSSION
|
||||
- List changed files and observed command exit codes.
|
||||
- If there is meaningful follow-up work, propose the next concrete worker task.
|
||||
- Do not claim review approval or human acceptance. Codex will verify, review, and commit if appropriate.
|
||||
`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const cwd = process.cwd();
|
||||
const version = requireArg(args, 'version');
|
||||
const model = args.model || 'zai/glm-5.2';
|
||||
const thinking = args.thinking || 'xhigh';
|
||||
const piBin = args.pi || process.env.PI_BIN || 'pi';
|
||||
const piMode = args['pi-mode'] || 'json';
|
||||
const eventIdleTimeoutMs = parseNonNegativeInt(args['event-idle-timeout-ms'], 600000);
|
||||
const taskText = [args.task || '', readOptionalFile(args['prompt-file'])]
|
||||
.filter(Boolean)
|
||||
.join('\n\nAdditional Codex assignment details:\n')
|
||||
.trim();
|
||||
|
||||
if (!taskText) throw new Error(`Missing --task or --prompt-file\n\n${usage()}`);
|
||||
if (!['json', 'text'].includes(piMode)) {
|
||||
throw new Error(`Unsupported --pi-mode ${piMode}. Expected json or text.`);
|
||||
}
|
||||
|
||||
assertRequiredFiles(cwd, version);
|
||||
const name = args.name || slugify(taskText);
|
||||
const contextFiles = existingContextFiles(cwd, version);
|
||||
const runsBaseDir = resolve(cwd, args['runs-dir'] || '.codex/logs/agent-runs');
|
||||
|
||||
const runSlug = slugify(`${name}`);
|
||||
const runDir = resolve(runsBaseDir, version, `${timestamp()}-pi-worker-${runSlug}`);
|
||||
mkdirSync(runDir, { recursive: true });
|
||||
|
||||
const prompt = buildPrompt({ contextFiles, name, taskText, version });
|
||||
const promptPath = join(runDir, 'prompt.txt');
|
||||
const stdoutPath = join(runDir, 'stdout.txt');
|
||||
const stderrPath = join(runDir, 'stderr.txt');
|
||||
const metaPath = join(runDir, 'meta.json');
|
||||
const eventsPath = join(runDir, 'events.jsonl');
|
||||
const summaryPath = join(runDir, 'summary.json');
|
||||
const lastMessagePath = join(runDir, 'last-message.txt');
|
||||
|
||||
const sessionId = args['session-id'] || `calm-${version.replace(/[^a-zA-Z0-9]+/g, '-')}-${runSlug}`;
|
||||
const piArgs = [
|
||||
...(piMode === 'json' ? ['--mode', 'json'] : ['-p']),
|
||||
'--model',
|
||||
model,
|
||||
'--thinking',
|
||||
thinking,
|
||||
'--approve',
|
||||
'--exclude-tools',
|
||||
'ask_question',
|
||||
'--session-id',
|
||||
sessionId,
|
||||
prompt,
|
||||
];
|
||||
|
||||
writeFileSync(promptPath, prompt);
|
||||
const startedAt = new Date().toISOString();
|
||||
const baseMeta = {
|
||||
tool: 'pi',
|
||||
piBin,
|
||||
args: piArgs.slice(0, -1).concat(['<prompt>']),
|
||||
cwd,
|
||||
version,
|
||||
name,
|
||||
model,
|
||||
thinking,
|
||||
piMode,
|
||||
eventIdleTimeoutMs,
|
||||
sessionId,
|
||||
runDir,
|
||||
promptPath,
|
||||
stdoutPath,
|
||||
stderrPath,
|
||||
eventsPath,
|
||||
summaryPath,
|
||||
lastMessagePath,
|
||||
startedAt,
|
||||
dryRun: Boolean(args['dry-run']),
|
||||
};
|
||||
|
||||
writeFileSync(metaPath, `${JSON.stringify(baseMeta, null, 2)}\n`);
|
||||
writeFileSync(summaryPath, `${JSON.stringify({
|
||||
...baseMeta,
|
||||
status: 'NOT_STARTED',
|
||||
finalStatus: null,
|
||||
orchestratorStatus: 'NOT_STARTED',
|
||||
eventCounts: {},
|
||||
eventCount: 0,
|
||||
parseErrors: [],
|
||||
gitStatusAfter: null,
|
||||
}, null, 2)}\n`);
|
||||
|
||||
console.log(`Run directory: ${runDir}`);
|
||||
console.log(`Prompt: ${promptPath}`);
|
||||
console.log(`Events: ${eventsPath}`);
|
||||
console.log(`Summary: ${summaryPath}`);
|
||||
console.log(`Command: ${piBin} ${piArgs.slice(0, -1).join(' ')} <prompt>`);
|
||||
|
||||
if (args['dry-run']) {
|
||||
console.log('Dry run: Pi was not launched.');
|
||||
return;
|
||||
}
|
||||
|
||||
const stdout = createWriteStream(stdoutPath);
|
||||
const stderr = createWriteStream(stderrPath);
|
||||
const events = createWriteStream(eventsPath);
|
||||
let stdoutRemainder = '';
|
||||
let stdoutTail = '';
|
||||
let lastAssistantText = '';
|
||||
let lastEventAt = null;
|
||||
let lastEventType = null;
|
||||
let parentSignal = null;
|
||||
let killedByIdle = false;
|
||||
let idleTimer = null;
|
||||
const parseErrors = [];
|
||||
const eventCounts = {};
|
||||
let eventCount = 0;
|
||||
|
||||
const child = spawn(piBin, piArgs, {
|
||||
cwd,
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
function writeRunningSummary(extra = {}) {
|
||||
const gitStatusAfter = extra.gitStatusAfter ?? null;
|
||||
const finalStatus = findFinalStatus(lastAssistantText);
|
||||
const orchestratorStatus = summarizeOrchestratorStatus({
|
||||
finalStatus,
|
||||
exitCode: extra.exitCode ?? null,
|
||||
signal: extra.signal ?? null,
|
||||
killedByIdle,
|
||||
gitStatusAfter,
|
||||
});
|
||||
|
||||
writeFileSync(summaryPath, `${JSON.stringify({
|
||||
...baseMeta,
|
||||
status: extra.status || 'RUNNING',
|
||||
finalStatus,
|
||||
orchestratorStatus,
|
||||
eventCounts,
|
||||
eventCount,
|
||||
parseErrors,
|
||||
lastEventAt,
|
||||
lastEventType,
|
||||
parentSignal,
|
||||
killedByIdle,
|
||||
gitStatusAfter,
|
||||
stdoutTail,
|
||||
lastAssistantTextPath: lastMessagePath,
|
||||
...extra,
|
||||
}, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function resetIdleTimer() {
|
||||
if (eventIdleTimeoutMs === 0) return;
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
killedByIdle = true;
|
||||
console.error(`\nPi worker produced no output for ${eventIdleTimeoutMs}ms; sending SIGTERM.`);
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
||||
}, 10000).unref();
|
||||
}, eventIdleTimeoutMs);
|
||||
idleTimer.unref();
|
||||
}
|
||||
|
||||
function handleEvent(event) {
|
||||
eventCount += 1;
|
||||
eventCounts[event.type || 'unknown'] = (eventCounts[event.type || 'unknown'] || 0) + 1;
|
||||
lastEventAt = new Date().toISOString();
|
||||
lastEventType = event.type || 'unknown';
|
||||
events.write(`${JSON.stringify(event)}\n`);
|
||||
|
||||
if (event.type === 'message_end') {
|
||||
const text = textFromMessage(event.message);
|
||||
if (text) lastAssistantText = text;
|
||||
} else if (event.type === 'agent_end') {
|
||||
const text = findLastAssistantTextFromMessages(event.messages);
|
||||
if (text) lastAssistantText = text;
|
||||
}
|
||||
|
||||
if (lastAssistantText) writeFileSync(lastMessagePath, `${lastAssistantText}\n`);
|
||||
|
||||
const rendered = renderEventForConsole(event);
|
||||
if (rendered) process.stdout.write(rendered);
|
||||
}
|
||||
|
||||
function handleStdoutChunk(chunk) {
|
||||
const text = chunk.toString('utf8');
|
||||
stdoutTail = `${stdoutTail}${text}`.slice(-12000);
|
||||
stdout.write(chunk);
|
||||
resetIdleTimer();
|
||||
|
||||
if (piMode !== 'json') {
|
||||
process.stdout.write(chunk);
|
||||
return;
|
||||
}
|
||||
|
||||
stdoutRemainder += text;
|
||||
const lines = stdoutRemainder.split('\n');
|
||||
stdoutRemainder = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
handleEvent(JSON.parse(trimmed));
|
||||
} catch (error) {
|
||||
parseErrors.push({
|
||||
at: new Date().toISOString(),
|
||||
error: error.message || String(error),
|
||||
line: trimmed.slice(0, 500),
|
||||
});
|
||||
process.stdout.write(`${line}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
writeRunningSummary();
|
||||
}
|
||||
|
||||
function handleParentSignal(signal) {
|
||||
parentSignal = signal;
|
||||
console.error(`\nReceived ${signal}; forwarding to Pi worker.`);
|
||||
child.kill(signal);
|
||||
}
|
||||
|
||||
process.once('SIGINT', handleParentSignal);
|
||||
process.once('SIGTERM', handleParentSignal);
|
||||
|
||||
child.on('error', (error) => {
|
||||
parseErrors.push({
|
||||
at: new Date().toISOString(),
|
||||
error: error.message || String(error),
|
||||
line: 'spawn',
|
||||
});
|
||||
});
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
handleStdoutChunk(chunk);
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
resetIdleTimer();
|
||||
process.stderr.write(chunk);
|
||||
stderr.write(chunk);
|
||||
});
|
||||
|
||||
resetIdleTimer();
|
||||
writeRunningSummary();
|
||||
|
||||
const { exitCode, signal } = await new Promise((resolveExit) => {
|
||||
child.on('close', (code, closeSignal) => resolveExit({ exitCode: code, signal: closeSignal }));
|
||||
});
|
||||
clearTimeout(idleTimer);
|
||||
|
||||
if (piMode === 'json' && stdoutRemainder.trim()) {
|
||||
try {
|
||||
handleEvent(JSON.parse(stdoutRemainder.trim()));
|
||||
} catch (error) {
|
||||
parseErrors.push({
|
||||
at: new Date().toISOString(),
|
||||
error: error.message || String(error),
|
||||
line: stdoutRemainder.trim().slice(0, 500),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
stdout.end();
|
||||
stderr.end();
|
||||
events.end();
|
||||
|
||||
const finishedAt = new Date().toISOString();
|
||||
const gitStatusAfter = getGitStatus(cwd);
|
||||
const finalStatus = piMode === 'json' ? findFinalStatus(lastAssistantText) : findFinalStatus(stdoutTail);
|
||||
const orchestratorStatus = summarizeOrchestratorStatus({
|
||||
finalStatus,
|
||||
exitCode,
|
||||
signal,
|
||||
killedByIdle,
|
||||
gitStatusAfter,
|
||||
});
|
||||
|
||||
writeFileSync(
|
||||
metaPath,
|
||||
`${JSON.stringify({ ...baseMeta, finishedAt, exitCode, signal }, null, 2)}\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
summaryPath,
|
||||
`${JSON.stringify({
|
||||
...baseMeta,
|
||||
status: 'FINISHED',
|
||||
finalStatus,
|
||||
orchestratorStatus,
|
||||
eventCounts,
|
||||
eventCount,
|
||||
parseErrors,
|
||||
lastEventAt,
|
||||
lastEventType,
|
||||
parentSignal,
|
||||
killedByIdle,
|
||||
gitStatusAfter,
|
||||
stdoutTail,
|
||||
lastAssistantTextPath: lastMessagePath,
|
||||
finishedAt,
|
||||
exitCode,
|
||||
signal,
|
||||
}, null, 2)}\n`,
|
||||
);
|
||||
|
||||
console.log(`\nPi worker finished with ${orchestratorStatus}. See ${runDir}`);
|
||||
|
||||
if (parentSignal === 'SIGINT') process.exit(130);
|
||||
if (parentSignal === 'SIGTERM') process.exit(143);
|
||||
if (killedByIdle) process.exit(124);
|
||||
if (exitCode !== 0 || signal) {
|
||||
console.error(`Pi worker exited with code ${exitCode ?? 'null'} signal ${signal ?? 'null'}. See ${runDir}`);
|
||||
process.exit(exitCode || 1);
|
||||
}
|
||||
if (!finalStatus) {
|
||||
console.error(`Pi worker exited without WORKER_STATUS marker. See ${summaryPath}`);
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
console.log(`Pi worker completed. Structured summary: ${summaryPath}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.message || error);
|
||||
process.exit(1);
|
||||
});
|
||||
Generated
+2241
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "my-calm-game",
|
||||
"version": "0.1.0",
|
||||
"description": "Calm family quiz night — a cozy 'Svoya Igra'-style game for teams with a host",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"agent:loop": "node development/_reference/ai/scripts/run-agent-loop.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.3.1",
|
||||
"@sveltejs/kit": "^2.8.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"svelte": "^5.1.9",
|
||||
"svelte-check": "^4.0.5",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"canvas-confetti": "^1.9.4"
|
||||
}
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* =========================================================================
|
||||
Уютная ночная тема — тёплый градиент, мягкое свечение, спокойные цвета.
|
||||
Эти переменные используются во всём приложении, чтобы тему было легко
|
||||
менять в одном месте.
|
||||
========================================================================= */
|
||||
@theme {
|
||||
--color-night-950: #0b0a1f;
|
||||
--color-night-900: #14112e;
|
||||
--color-night-800: #1d1840;
|
||||
--color-night-700: #2a2356;
|
||||
--color-night-600: #3a2f70;
|
||||
|
||||
--color-ember-50: #fff7ed;
|
||||
--color-ember-200: #fed7aa;
|
||||
--color-ember-300: #fdba74;
|
||||
--color-ember-400: #fb923c;
|
||||
--color-ember-500: #f97316;
|
||||
|
||||
--color-gold-300: #fcd34d;
|
||||
--color-gold-400: #fbbf24;
|
||||
|
||||
--color-mint-300: #6ee7b7;
|
||||
--color-mint-400: #34d399;
|
||||
|
||||
--color-plum-300: #d8b4fe;
|
||||
--color-plum-400: #c084fc;
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-display: 'Quicksand', ui-rounded, 'Segoe UI', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-display);
|
||||
color: theme(--color-night-50, #f8f7ff);
|
||||
background:
|
||||
radial-gradient(ellipse at top, #2a2356 0%, transparent 55%),
|
||||
radial-gradient(ellipse at bottom, #3a1f5d 0%, transparent 50%),
|
||||
linear-gradient(160deg, #0b0a1f 0%, #14112e 50%, #1d1840 100%);
|
||||
background-attachment: fixed;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Мягкие «звёзды» на фоне для атмосферы */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
radial-gradient(1px 1px at 20% 30%, rgba(255, 255, 255, 0.5), transparent),
|
||||
radial-gradient(1px 1px at 70% 60%, rgba(255, 255, 255, 0.35), transparent),
|
||||
radial-gradient(1px 1px at 40% 80%, rgba(255, 255, 255, 0.3), transparent),
|
||||
radial-gradient(1px 1px at 85% 20%, rgba(255, 255, 255, 0.4), transparent),
|
||||
radial-gradient(1px 1px at 55% 45%, rgba(255, 255, 255, 0.25), transparent);
|
||||
background-size: 100% 100%;
|
||||
opacity: 0.6;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Убираем раздражающую синюю подсветку на мобильных */
|
||||
button {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
Курсор pointer для всех интерактивных элементов — централизованно.
|
||||
По умолчанию браузеры дают кнопкам курсор `default`, что ощущается
|
||||
«неживым». Включаем pointer глобально, а отключённым элементам —
|
||||
`not-allowed`, чтобы было понятно, что клик невозможен.
|
||||
===================================================================== */
|
||||
button:not(:disabled),
|
||||
[role='button']:not([aria-disabled='true']),
|
||||
[role='button']:not(:disabled),
|
||||
a[href],
|
||||
summary,
|
||||
label[for],
|
||||
select,
|
||||
[tabindex]:not([tabindex='-1']) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
[role='button'][aria-disabled='true'],
|
||||
[aria-disabled='true'] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Скроллбар в теме */
|
||||
*::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: rgba(192, 132, 252, 0.3);
|
||||
border-radius: 999px;
|
||||
}
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ===================== Анимации ===================== */
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pop-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.92) translateY(12px);
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
transform: scale(1.02) translateY(-2px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes soft-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes reveal-answer {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.98);
|
||||
filter: blur(6px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes score-bump {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.18);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 18px rgba(251, 191, 36, 0.25);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 34px rgba(251, 191, 36, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.45s ease both;
|
||||
}
|
||||
.animate-pop-in {
|
||||
animation: pop-in 0.4s cubic-bezier(0.2, 0.9, 0.3, 1.3) both;
|
||||
}
|
||||
.animate-soft-rise {
|
||||
animation: soft-rise 0.5s ease both;
|
||||
}
|
||||
.animate-reveal-answer {
|
||||
animation: reveal-answer 0.55s ease both;
|
||||
}
|
||||
.animate-score-bump {
|
||||
animation: score-bump 0.5s ease;
|
||||
}
|
||||
.animate-glow-pulse {
|
||||
animation: glow-pulse 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content="Спокойная семейная игра-викторина перед сном" />
|
||||
<title>Уютная игра · Семейная викторина</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { fade, scale } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
message?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open,
|
||||
title = 'Подтвердите',
|
||||
message = 'Вы уверены?',
|
||||
confirmLabel = 'Да, продолжить',
|
||||
cancelLabel = 'Отмена',
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: Props = $props();
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (!open) return;
|
||||
if (e.key === 'Escape' || e.key === 'Enter') {
|
||||
e.key === 'Enter' ? onConfirm() : onCancel();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
{#if open}
|
||||
<!-- Затемнённый фон -->
|
||||
<div
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-night-950/80 p-4 backdrop-blur-md"
|
||||
transition:fade={{ duration: 200 }}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-md rounded-3xl border border-white/10 bg-gradient-to-br from-night-700/95 to-night-900/95 p-7 text-center shadow-2xl shadow-night-950/60"
|
||||
transition:scale={{ duration: 220, start: 0.92, opacity: 0.5 }}
|
||||
>
|
||||
<h2 class="text-xl font-bold text-white sm:text-2xl">{title}</h2>
|
||||
<p class="mt-3 text-sm leading-relaxed text-night-50/70">{message}</p>
|
||||
|
||||
<div class="mt-7 flex flex-col gap-3 sm:flex-row sm:justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCancel}
|
||||
class="rounded-2xl border border-white/15 bg-white/5 px-6 py-3 text-sm font-semibold text-night-50/80 transition-all duration-200 hover:bg-white/10 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-white/20 active:scale-95"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onConfirm}
|
||||
class="rounded-2xl bg-gradient-to-br from-ember-400 to-ember-500 px-6 py-3 text-sm font-bold text-night-900 shadow-lg shadow-ember-500/30 transition-all duration-200 hover:shadow-ember-500/50 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ember-400/50 active:scale-95"
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import type { QuestionRef, QuestionSet } from '$lib/types';
|
||||
import QuestionCard from './QuestionCard.svelte';
|
||||
|
||||
interface Props {
|
||||
set: QuestionSet;
|
||||
/** Множество сыгранных вопросов (`categoryId:questionId`). */
|
||||
played: QuestionRef[];
|
||||
onOpenQuestion: (ref: QuestionRef) => void;
|
||||
}
|
||||
|
||||
let { set, played, onOpenQuestion }: Props = $props();
|
||||
|
||||
const isPlayed = (categoryId: string, questionId: string) =>
|
||||
played.includes(`${categoryId}:${questionId}`);
|
||||
|
||||
// Эмодзи-иконка для строки категории (чисто декоративная, по индексу).
|
||||
const rowIcons = ['🐾', '🌍', '✨'];
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 w-full flex-col gap-2 sm:gap-3">
|
||||
{#each set.categories as category, ci (category.id)}
|
||||
<div class="grid min-h-0 flex-1 gap-2 sm:gap-3" style="grid-template-columns: minmax(7rem, 1fr) repeat(5, minmax(0, 1fr))">
|
||||
<!-- Заголовок категории (левая «колонка» строки) -->
|
||||
<div
|
||||
class="flex min-h-0 items-center gap-2 rounded-2xl border border-plum-400/30 bg-gradient-to-br from-plum-400/15 to-night-700/60 px-3 backdrop-blur-sm sm:px-4"
|
||||
>
|
||||
<span class="text-xl sm:text-2xl">{rowIcons[ci % rowIcons.length]}</span>
|
||||
<h3
|
||||
class="font-bold leading-tight text-plum-300"
|
||||
style="font-size: clamp(0.8rem, 1.6vw, 1.5rem); text-shadow: 0 0 14px rgba(192, 132, 252, 0.3)"
|
||||
>
|
||||
{category.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- Карточки очков 100 / 200 / 300 / 500 / 1000 -->
|
||||
{#each category.questions as question (question.id)}
|
||||
{@const ref = `${category.id}:${question.id}`}
|
||||
<div class="min-h-0 animate-pop-in" style="animation-delay: {ci * 50}ms">
|
||||
<QuestionCard
|
||||
points={question.points}
|
||||
played={isPlayed(category.id, question.id)}
|
||||
onSelect={() => onOpenQuestion(ref)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
score: number;
|
||||
playedCount: number;
|
||||
onPlayAgain: () => void;
|
||||
onChangeSet: () => void;
|
||||
}
|
||||
|
||||
let { score, playedCount, onPlayAgain, onChangeSet }: Props = $props();
|
||||
|
||||
// Мягкая финальная фраза в зависимости от результата.
|
||||
const finalPhrase = $derived.by(() => {
|
||||
const per = playedCount > 0 ? score / playedCount : 0;
|
||||
if (per >= 450) return 'Великолепная командная игра — вы блестяще справились!';
|
||||
if (per >= 250) return 'Замечательный вечер! Вы отлично играли вместе.';
|
||||
if (per > 0) return 'Хорошая игра! Главное — вы провели время вместе.';
|
||||
return 'Спасибо за тёплый вечер. Главное — что вы были вместе.';
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="animate-fade-in mx-auto flex min-h-[80vh] w-full max-w-2xl flex-col items-center justify-center px-4 py-10 text-center">
|
||||
<div class="mb-6 animate-pop-in text-6xl" aria-hidden="true">🌟</div>
|
||||
|
||||
<h1
|
||||
class="bg-gradient-to-r from-gold-300 via-ember-300 to-plum-300 bg-clip-text text-3xl font-bold text-transparent sm:text-5xl"
|
||||
>
|
||||
Вечер завершён
|
||||
</h1>
|
||||
|
||||
<!-- Итоговый счёт -->
|
||||
<div
|
||||
class="mt-10 w-full max-w-md rounded-3xl border border-gold-400/30 bg-gradient-to-br from-night-700/80 to-night-800/80 p-8 shadow-2xl shadow-night-950/50 backdrop-blur-sm animate-soft-rise"
|
||||
>
|
||||
<span class="text-xs font-semibold uppercase tracking-widest text-gold-300/80">
|
||||
Общий счёт команды
|
||||
</span>
|
||||
<div
|
||||
class="mt-2 text-6xl font-extrabold tabular-nums text-gold-300 sm:text-7xl animate-score-bump"
|
||||
style="text-shadow: 0 0 30px rgba(251, 191, 36, 0.4)"
|
||||
>
|
||||
{score.toLocaleString('ru-RU')}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-center gap-6 text-sm text-night-50/60">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg">✅</span>
|
||||
<span>{playedCount} вопросов сыграно</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Мягкая финальная фраза -->
|
||||
<p class="mt-8 max-w-md text-lg leading-relaxed text-night-50/80 animate-soft-rise">
|
||||
{finalPhrase}
|
||||
</p>
|
||||
|
||||
<!-- Действия -->
|
||||
<div class="mt-10 flex flex-wrap items-center justify-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onPlayAgain}
|
||||
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-br from-mint-400 to-mint-300 px-7 py-4 text-base font-bold text-night-900 shadow-lg shadow-mint-400/30 transition-all duration-200 hover:scale-105 hover:shadow-mint-400/50 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-mint-400/50 active:scale-95"
|
||||
>
|
||||
🔄 Сыграть снова
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={onChangeSet}
|
||||
class="inline-flex items-center gap-2 rounded-2xl border border-white/15 bg-white/5 px-7 py-4 text-base font-semibold text-night-50/80 transition-all duration-200 hover:scale-105 hover:border-white/30 hover:bg-white/10 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-white/20 active:scale-95"
|
||||
>
|
||||
🗂️ Выбрать другой набор
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="mt-12 text-sm text-night-50/40">Спокойной ночи и до новых игр 🌙</p>
|
||||
</div>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import type { QuestionRef, QuestionSet } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
set: QuestionSet;
|
||||
played: QuestionRef[];
|
||||
onRandom: () => void;
|
||||
onNewGame: () => void;
|
||||
/** Полный сброс к экрану выбора набора. */
|
||||
onChangeSet: () => void;
|
||||
}
|
||||
|
||||
let { set, played, onRandom, onNewGame, onChangeSet }: Props = $props();
|
||||
|
||||
/** Есть ли ещё несыгранные вопросы (для кнопки «Случайный вопрос»). */
|
||||
const hasUnplayed = $derived(
|
||||
set.categories.some((c) => c.questions.some((q) => !played.includes(`${c.id}:${q.id}`)))
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-3 sm:gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onRandom}
|
||||
disabled={!hasUnplayed}
|
||||
class="inline-flex items-center gap-2 rounded-2xl border border-mint-400/40 bg-gradient-to-br from-mint-400/20 to-night-700/60 px-5 py-3 text-sm font-bold text-mint-300 shadow-lg shadow-night-950/30 transition-all duration-200 hover:scale-105 hover:border-mint-400/70 hover:shadow-mint-400/20 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-mint-400/40 active:scale-95 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:scale-100 sm:text-base"
|
||||
>
|
||||
🎲 <span>Случайный вопрос</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={onNewGame}
|
||||
class="inline-flex items-center gap-2 rounded-2xl border border-white/15 bg-white/5 px-5 py-3 text-sm font-semibold text-night-50/80 transition-all duration-200 hover:scale-105 hover:border-white/30 hover:bg-white/10 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-white/20 active:scale-95 sm:text-base"
|
||||
>
|
||||
🔄 <span>Новая игра</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={onChangeSet}
|
||||
class="inline-flex items-center gap-2 rounded-2xl border border-white/15 bg-white/5 px-5 py-3 text-sm font-semibold text-night-50/80 transition-all duration-200 hover:scale-105 hover:border-white/30 hover:bg-white/10 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-white/20 active:scale-95 sm:text-base"
|
||||
>
|
||||
🗂️ <span>Сменить набор</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import type { PointValue } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
/** Стоимость вопроса (берётся из данных, НЕ хардкод). */
|
||||
points: PointValue;
|
||||
/** Сыгран ли вопрос (затемняется и неактивен). */
|
||||
played: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
let { points, played, onSelect }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if played}
|
||||
<!-- Сыгранный вопрос: затемнён, неактивен -->
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
aria-disabled="true"
|
||||
aria-label="Вопрос сыгран"
|
||||
class="flex h-full w-full min-h-0 items-center justify-center rounded-2xl border border-white/5 bg-night-900/60 text-night-50/25 transition-colors"
|
||||
>
|
||||
<span class="text-2xl font-bold line-through decoration-night-50/20 lg:text-4xl">✓</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onSelect}
|
||||
aria-label="Открыть вопрос за {points} очков"
|
||||
class="group relative flex h-full w-full min-h-0 items-center justify-center overflow-hidden rounded-2xl border border-gold-400/30 bg-gradient-to-br from-night-700/90 to-night-800/90 shadow-lg shadow-night-950/40 transition-all duration-300 hover:scale-[1.03] hover:border-gold-400/70 hover:shadow-xl hover:shadow-gold-400/30 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-gold-400/50 active:scale-95"
|
||||
>
|
||||
<!-- свечение при наведении -->
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 bg-gradient-to-br from-ember-400/0 via-ember-400/0 to-gold-400/0 opacity-0 transition-opacity duration-300 group-hover:from-ember-400/20 group-hover:via-ember-400/5 group-hover:to-gold-400/20 group-hover:opacity-100"
|
||||
></div>
|
||||
<span
|
||||
class="relative font-extrabold tabular-nums text-gold-300 transition-transform duration-300 group-hover:scale-110"
|
||||
style="font-size: clamp(1rem, 3vw, 2.6rem); text-shadow: 0 0 16px rgba(251, 191, 36, 0.35)"
|
||||
>
|
||||
{points.toLocaleString('ru-RU')}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,164 @@
|
||||
<script lang="ts">
|
||||
import type { Category, Question } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
category: Category;
|
||||
question: Question;
|
||||
onAward: (points: number) => void;
|
||||
onReject: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { category, question, onAward, onReject, onClose }: Props = $props();
|
||||
|
||||
let answerShown = $state(false);
|
||||
let hintShown = $state(false);
|
||||
|
||||
// При смене вопроса — сбрасываем локальное состояние показа.
|
||||
$effect(() => {
|
||||
question.id;
|
||||
answerShown = false;
|
||||
hintShown = false;
|
||||
});
|
||||
|
||||
// Творческие вопросы — мягкие формулировки.
|
||||
const isCreative = $derived(question.type === 'creative');
|
||||
|
||||
// Управление с клавиатуры: Esc — вернуться (если ответ скрыт — закрыть модал,
|
||||
// иначе просто убрать фокус с кнопки). Удобно ведущему.
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && !answerShown) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<!-- Затемнённый фон -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-night-950/80 p-4 backdrop-blur-md animate-fade-in"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Вопрос викторины"
|
||||
>
|
||||
<div
|
||||
class="relative w-full max-w-3xl overflow-hidden rounded-3xl border border-white/10 bg-gradient-to-br from-night-700/95 to-night-900/95 shadow-2xl shadow-night-950/60 animate-pop-in"
|
||||
>
|
||||
<!-- верхняя подсветка -->
|
||||
<div
|
||||
class="pointer-events-none absolute -top-24 left-1/2 h-48 w-[120%] -translate-x-1/2 rounded-full bg-gradient-to-r from-ember-400/20 via-gold-400/20 to-plum-400/20 blur-3xl"
|
||||
></div>
|
||||
|
||||
<div class="relative p-6 sm:p-10">
|
||||
<!-- Категория + стоимость -->
|
||||
<div class="mb-6 flex items-center justify-between gap-4">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-plum-400/15 px-4 py-1.5 text-sm font-semibold text-plum-300"
|
||||
>
|
||||
{category.title}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-gold-400/15 px-4 py-1.5 text-lg font-extrabold tabular-nums text-gold-300"
|
||||
style="text-shadow: 0 0 14px rgba(251, 191, 36, 0.4)"
|
||||
>
|
||||
{question.points.toLocaleString('ru-RU')}
|
||||
{#if isCreative}
|
||||
<span class="ml-2 text-xs font-medium text-plum-300">творческий</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Сам вопрос -->
|
||||
<p
|
||||
class="mb-2 text-center text-2xl font-bold leading-snug text-white sm:text-4xl"
|
||||
style="text-shadow: 0 0 20px rgba(255,255,255,0.08)"
|
||||
>
|
||||
{question.question}
|
||||
</p>
|
||||
|
||||
<!-- Подсказка (необязательная) -->
|
||||
{#if question.optionalHint}
|
||||
<div class="mt-4 flex justify-center">
|
||||
{#if !hintShown}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (hintShown = true)}
|
||||
class="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-4 py-2 text-sm text-night-50/70 transition-colors hover:bg-white/10"
|
||||
>
|
||||
💡 Показать подсказку
|
||||
</button>
|
||||
{:else}
|
||||
<p class="animate-fade-in max-w-lg rounded-2xl bg-white/5 px-4 py-3 text-center text-sm italic text-night-50/70">
|
||||
💡 {question.optionalHint}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Ответ -->
|
||||
{#if answerShown}
|
||||
<div class="mt-8 animate-reveal-answer">
|
||||
<div
|
||||
class="rounded-2xl border border-mint-400/30 bg-mint-400/10 p-5 text-center"
|
||||
>
|
||||
<span class="mb-1 block text-xs font-semibold uppercase tracking-widest text-mint-300/80">
|
||||
Ответ
|
||||
</span>
|
||||
<p class="text-xl font-bold text-mint-300 sm:text-2xl">{question.answer}</p>
|
||||
</div>
|
||||
|
||||
<!-- Действия ведущего -->
|
||||
<div class="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onAward(question.points)}
|
||||
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-br from-mint-400 to-mint-300 px-6 py-3.5 text-base font-bold text-night-900 shadow-lg shadow-mint-400/30 transition-all duration-200 hover:scale-105 hover:shadow-mint-400/50 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-mint-400/50 active:scale-95"
|
||||
>
|
||||
✓ {isCreative ? 'Засчитать творческий ответ' : 'Засчитать'}
|
||||
<span class="tabular-nums">+{question.points.toLocaleString('ru-RU')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={onReject}
|
||||
class="inline-flex items-center gap-2 rounded-2xl border border-white/15 bg-white/5 px-6 py-3.5 text-base font-semibold text-night-50/70 transition-all duration-200 hover:scale-105 hover:border-white/30 hover:bg-white/10 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-white/20 active:scale-95"
|
||||
>
|
||||
✕ Не засчитывать
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="text-sm text-night-50/50 underline-offset-4 transition-colors hover:text-night-50/80 hover:underline"
|
||||
>
|
||||
Вернуться к табло
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-8 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (answerShown = true)}
|
||||
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-br from-gold-400 to-ember-500 px-8 py-4 text-lg font-bold text-night-900 shadow-lg shadow-gold-400/30 transition-all duration-200 hover:scale-105 hover:shadow-gold-400/50 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-gold-400/50 active:scale-95"
|
||||
>
|
||||
👁️ Показать ответ
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="text-sm text-night-50/50 underline-offset-4 transition-colors hover:text-night-50/80 hover:underline"
|
||||
>
|
||||
← Вернуться к табло
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import type { QuestionSet } from '$lib/types';
|
||||
import { getTotalQuestions } from '$lib/question-sets';
|
||||
|
||||
interface Props {
|
||||
sets: QuestionSet[];
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
let { sets, onSelect }: Props = $props();
|
||||
|
||||
let hovered = $state<string | null>(null);
|
||||
</script>
|
||||
|
||||
<div class="animate-fade-in mx-auto w-full max-w-6xl px-4 py-10 sm:py-16">
|
||||
<header class="mb-10 text-center sm:mb-14">
|
||||
<div
|
||||
class="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-3xl bg-gradient-to-br from-gold-400 to-ember-500 text-4xl shadow-lg shadow-ember-500/30 animate-glow-pulse"
|
||||
>
|
||||
🌙
|
||||
</div>
|
||||
<h1
|
||||
class="bg-gradient-to-r from-gold-300 via-ember-300 to-plum-300 bg-clip-text text-3xl font-bold tracking-tight text-transparent sm:text-5xl"
|
||||
>
|
||||
Уютная викторина
|
||||
</h1>
|
||||
<p class="mx-auto mt-4 max-w-xl text-base text-night-50/70 sm:text-lg">
|
||||
Спокойная игра для всей семьи перед сном. Выберите набор вопросов — и устраивайтесь поудобнее на диване.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each sets as set, i (set.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onSelect(set.id)}
|
||||
onmouseenter={() => (hovered = set.id)}
|
||||
onmouseleave={() => (hovered = null)}
|
||||
style="--i: {i}"
|
||||
class="group relative flex flex-col overflow-hidden rounded-3xl border border-white/10 bg-white/5 p-6 text-left backdrop-blur-sm transition-all duration-300 hover:-translate-y-1 hover:border-gold-400/50 hover:bg-white/10 hover:shadow-2xl hover:shadow-ember-500/20 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-gold-400/40 animate-soft-rise"
|
||||
>
|
||||
<!-- декоративное свечение -->
|
||||
<div
|
||||
class="pointer-events-none absolute -right-10 -top-10 h-32 w-32 rounded-full bg-gradient-to-br from-ember-400/30 to-plum-400/20 blur-2xl transition-opacity duration-300 group-hover:opacity-100 {hovered ===
|
||||
set.id
|
||||
? 'opacity-100'
|
||||
: 'opacity-50'}"
|
||||
></div>
|
||||
|
||||
<div class="relative mb-4 flex items-center justify-between">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-gold-400/15 px-3 py-1 text-xs font-semibold text-gold-300"
|
||||
>
|
||||
{set.ageRange}
|
||||
</span>
|
||||
<span class="text-2xl opacity-70 transition-transform duration-300 group-hover:translate-x-1">
|
||||
→
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 class="relative text-xl font-bold text-white sm:text-2xl">{set.title}</h2>
|
||||
<p class="relative mt-2 flex-1 text-sm leading-relaxed text-night-50/70">
|
||||
{set.description}
|
||||
</p>
|
||||
|
||||
<div class="relative mt-5 flex items-center gap-4 text-xs text-night-50/50">
|
||||
<span class="inline-flex items-center gap-1">📂 {set.categories.length} категорий</span>
|
||||
<span class="inline-flex items-center gap-1">❓ {getTotalQuestions(set)} вопросов</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<footer class="mt-12 text-center text-xs text-night-50/40">
|
||||
Ведущий управляет игрой · команда отвечает вместе · никто не торопится
|
||||
</footer>
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
score: number;
|
||||
/** Триггер для анимации «подпрыгивания» при изменении счёта. */
|
||||
bumpSignal?: number;
|
||||
}
|
||||
|
||||
let { score, bumpSignal = 0 }: Props = $props();
|
||||
|
||||
// Реагируем на каждое изменение сигнала — пересоздаём анимацию.
|
||||
let bumpClass = $state('');
|
||||
$effect(() => {
|
||||
// читаем сигнал
|
||||
bumpSignal;
|
||||
if (score === 0) return;
|
||||
bumpClass = 'animate-score-bump';
|
||||
const t = setTimeout(() => (bumpClass = ''), 500);
|
||||
return () => clearTimeout(t);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-2xl border border-gold-400/30 bg-gradient-to-br from-night-700/80 to-night-800/80 px-5 py-3 shadow-lg shadow-night-950/40 backdrop-blur-sm"
|
||||
>
|
||||
<span class="text-2xl" aria-hidden="true">⭐</span>
|
||||
<div class="flex flex-col leading-none">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-widest text-gold-300/80">
|
||||
Счёт команды
|
||||
</span>
|
||||
<span
|
||||
class="text-3xl font-extrabold tabular-nums text-gold-300 sm:text-4xl {bumpClass}"
|
||||
style="text-shadow: 0 0 18px rgba(251, 191, 36, 0.4)"
|
||||
>
|
||||
{score.toLocaleString('ru-RU')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,51 @@
|
||||
import confetti from 'canvas-confetti';
|
||||
|
||||
/**
|
||||
* Мягкое «спокойное» конфетти за правильный ответ.
|
||||
*
|
||||
* Использует каноничную библиотеку `canvas-confetti` (без зависимостей,
|
||||
* рендер в overlay-<canvas> поверх всего). Цвета — в тёплой теме приложения.
|
||||
*
|
||||
* Намеренно НЕ агрессивно: меньше частиц, мягкий разлёт, без бесконечных
|
||||
* залпов. Уютное поздравление, а не новогодний салют.
|
||||
*
|
||||
* Важно: используем `useWorker: false` (рисуем на обычном DOM-canvas в главном
|
||||
* потоке). Режим Web Worker по умолчанию использует OffscreenCanvas вне DOM,
|
||||
* что в некоторых окружениях (headless/автоматизация, отдельные origin-условия)
|
||||
* рендерит вхолостую. Для редких залпов викторины главный поток более чем
|
||||
* достаточен и гарантированно работает везде.
|
||||
*/
|
||||
export function celebrate(): void {
|
||||
const colors = ['#fcd34d', '#fbbf24', '#fb923c', '#c084fc', '#6ee7b7'];
|
||||
const base = { colors, useWorker: false, disableForReducedMotion: true };
|
||||
|
||||
// Основной «пуфф» из центра-сверху.
|
||||
confetti({
|
||||
...base,
|
||||
particleCount: 70,
|
||||
spread: 70,
|
||||
startVelocity: 38,
|
||||
gravity: 0.9,
|
||||
scalar: 1.05,
|
||||
ticks: 200,
|
||||
origin: { y: 0.35 }
|
||||
});
|
||||
|
||||
// Два лёгких боковых дополнения с задержкой — мягкое эхо.
|
||||
setTimeout(() => {
|
||||
confetti({
|
||||
...base,
|
||||
particleCount: 28,
|
||||
angle: 60,
|
||||
spread: 55,
|
||||
origin: { x: 0, y: 0.5 }
|
||||
});
|
||||
confetti({
|
||||
...base,
|
||||
particleCount: 28,
|
||||
angle: 120,
|
||||
spread: 55,
|
||||
origin: { x: 1, y: 0.5 }
|
||||
});
|
||||
}, 180);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { QuestionSet } from '$lib/types';
|
||||
|
||||
/**
|
||||
* Тестовый набор-заглушка: «Животные».
|
||||
*
|
||||
* Это пример структуры данных и НЕ содержит реального контента —
|
||||
* только простые вопросы-заглушки. Реальные наборы добавляются отдельно
|
||||
* по той же схеме. В каждой категории ровно 5 вопросов с очками
|
||||
* 100 / 200 / 300 / 500 / 1000.
|
||||
*/
|
||||
export const animalsSet: QuestionSet = {
|
||||
id: 'animals',
|
||||
title: 'Животные',
|
||||
description: 'Знакомые звери и птицы — тёплый разогрев для всей семьи.',
|
||||
ageRange: '6+ лет',
|
||||
categories: [
|
||||
{
|
||||
id: 'who',
|
||||
title: 'Кто это?',
|
||||
questions: [
|
||||
{
|
||||
id: 'q1',
|
||||
points: 100,
|
||||
question: 'Кто говорит «мяу»?',
|
||||
answer: 'Кошка',
|
||||
type: 'strict'
|
||||
},
|
||||
{
|
||||
id: 'q2',
|
||||
points: 200,
|
||||
question: 'У какого домашнего животного есть грива?',
|
||||
answer: 'Лев (или лошадь — грива есть и у жеребца)',
|
||||
optionalHint: 'Его часто называют царём зверей.',
|
||||
type: 'strict'
|
||||
},
|
||||
{
|
||||
id: 'q3',
|
||||
points: 300,
|
||||
question: 'Какое животное носит дом на спине?',
|
||||
answer: 'Улитка (или черепаха)',
|
||||
type: 'strict'
|
||||
},
|
||||
{
|
||||
id: 'q4',
|
||||
points: 500,
|
||||
question: 'Какое самое крупное наземное животное?',
|
||||
answer: 'Африканский слон',
|
||||
optionalHint: 'У него большие уши и хобот.',
|
||||
type: 'strict'
|
||||
},
|
||||
{
|
||||
id: 'q5',
|
||||
points: 1000,
|
||||
question: 'Какое морское животное — млекопитающее, а не рыба?',
|
||||
answer: 'Кит или дельфин',
|
||||
optionalHint: 'Оно дышит воздухом и кормит детёнышей молоком.',
|
||||
type: 'strict'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'where',
|
||||
title: 'Где живут?',
|
||||
questions: [
|
||||
{
|
||||
id: 'q1',
|
||||
points: 100,
|
||||
question: 'Где живёт аквариумная рыбка?',
|
||||
answer: 'В аквариуме (в воде)',
|
||||
type: 'strict'
|
||||
},
|
||||
{
|
||||
id: 'q2',
|
||||
points: 200,
|
||||
question: 'Как называется домик для пчёл?',
|
||||
answer: 'Улей (или пасека)',
|
||||
type: 'strict'
|
||||
},
|
||||
{
|
||||
id: 'q3',
|
||||
points: 300,
|
||||
question: 'В каком холодном месте живут белые медведи?',
|
||||
answer: 'В Арктике (на Северном полюсе / во льдах)',
|
||||
type: 'strict'
|
||||
},
|
||||
{
|
||||
id: 'q4',
|
||||
points: 500,
|
||||
question: 'Где можно встретить стаю диких обезьян?',
|
||||
answer: 'В тропическом лесу / джунглях',
|
||||
optionalHint: 'Там тепло, влажно и много деревьев.',
|
||||
type: 'strict'
|
||||
},
|
||||
{
|
||||
id: 'q5',
|
||||
points: 1000,
|
||||
question: 'В какой природной зоне живут верблюды?',
|
||||
answer: 'В пустыне',
|
||||
optionalHint: 'Их называют «кораблями» этих мест.',
|
||||
type: 'strict'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'invent',
|
||||
title: 'Придумай сам',
|
||||
questions: [
|
||||
{
|
||||
id: 'q1',
|
||||
points: 100,
|
||||
question: 'Придумай ласковое прозвище для котёнка.',
|
||||
answer: 'Любое тёплое имя (например: Пушок, Мурлык)',
|
||||
type: 'creative'
|
||||
},
|
||||
{
|
||||
id: 'q2',
|
||||
points: 200,
|
||||
question: 'Каким голосом, по-твоему, разговаривает собака?',
|
||||
answer: 'Любой творческий ответ',
|
||||
optionalHint: 'Это фантазийный вопрос — поощряй воображение.',
|
||||
type: 'creative'
|
||||
},
|
||||
{
|
||||
id: 'q3',
|
||||
points: 300,
|
||||
question: 'Придумай, чем хобот слона может быть полезен.',
|
||||
answer: 'Любая идея (пить, обниматься, душ, рисовать)',
|
||||
type: 'creative'
|
||||
},
|
||||
{
|
||||
id: 'q4',
|
||||
points: 500,
|
||||
question: 'Опиши воображаемого зверя, который живёт на диване.',
|
||||
answer: 'Любой фантазийный ответ',
|
||||
optionalHint: 'Чем нелепее и добрее — тем лучше.',
|
||||
type: 'creative'
|
||||
},
|
||||
{
|
||||
id: 'q5',
|
||||
points: 1000,
|
||||
question: 'Придумай короткую сказку про дружбу кота и пчелы.',
|
||||
answer: 'Любой связный рассказ',
|
||||
type: 'creative'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { QuestionSet } from '$lib/types';
|
||||
|
||||
/**
|
||||
* Тестовый набор-заглушка: «Уютные вечера».
|
||||
*
|
||||
* Содержит простые вопросы-заглушки без реального контента — структура
|
||||
* данных и форматирование. Каждая категория содержит ровно 5 вопросов
|
||||
* с очками 100 / 200 / 300 / 500 / 1000.
|
||||
*/
|
||||
export const cozySet: QuestionSet = {
|
||||
id: 'cozy-evenings',
|
||||
title: 'Уютные вечера',
|
||||
description: 'Спокойные вопросы про дом, сон и вечерние ритуалы.',
|
||||
ageRange: 'вся семья',
|
||||
categories: [
|
||||
{
|
||||
id: 'bedtime',
|
||||
title: 'Перед сном',
|
||||
questions: [
|
||||
{
|
||||
id: 'q1',
|
||||
points: 100,
|
||||
question: 'Что мы надеваем перед тем, как лечь спать?',
|
||||
answer: 'Пижаму',
|
||||
type: 'strict'
|
||||
},
|
||||
{
|
||||
id: 'q2',
|
||||
points: 200,
|
||||
question: 'Как называется мягкая подушка-одеяло, в которую закутываются?',
|
||||
answer: 'Плед',
|
||||
type: 'strict'
|
||||
},
|
||||
{
|
||||
id: 'q3',
|
||||
points: 300,
|
||||
question: 'Что светит в окно ночью на небе?',
|
||||
answer: 'Луна (и звёзды)',
|
||||
type: 'strict'
|
||||
},
|
||||
{
|
||||
id: 'q4',
|
||||
points: 500,
|
||||
question: 'Как называется горячий вечерний напиток из молока?',
|
||||
answer: 'Какао (или горячий шоколад / тёплое молоко)',
|
||||
optionalHint: 'Его часто пьют перед сном.',
|
||||
type: 'strict'
|
||||
},
|
||||
{
|
||||
id: 'q5',
|
||||
points: 1000,
|
||||
question: 'Что нужно сделать с кроватью, чтобы в ней было уютно спать?',
|
||||
answer: 'Заправить / застелить / взбить подушку',
|
||||
optionalHint: 'Это вечерний ритуал порядка.',
|
||||
type: 'strict'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'home',
|
||||
title: 'Дом и семья',
|
||||
questions: [
|
||||
{
|
||||
id: 'q1',
|
||||
points: 100,
|
||||
question: 'Кто будит всех по утрам в семье самым первым? (по-твоему)',
|
||||
answer: 'Любой правдоподобный ответ (мама, папа, кот, будильник)',
|
||||
type: 'creative'
|
||||
},
|
||||
{
|
||||
id: 'q2',
|
||||
points: 200,
|
||||
question: 'Где семья чаще всего ужинает вместе?',
|
||||
answer: 'На кухне / за столом',
|
||||
type: 'strict'
|
||||
},
|
||||
{
|
||||
id: 'q3',
|
||||
points: 300,
|
||||
question: 'Что висит на стене и показывает время в доме?',
|
||||
answer: 'Часы',
|
||||
type: 'strict'
|
||||
},
|
||||
{
|
||||
id: 'q4',
|
||||
points: 500,
|
||||
question: 'Как называется лампа на прикроватной тумбочке?',
|
||||
answer: 'Настольная лампа / ночник',
|
||||
optionalHint: 'Она даёт мягкий свет вечером.',
|
||||
type: 'strict'
|
||||
},
|
||||
{
|
||||
id: 'q5',
|
||||
points: 1000,
|
||||
question: 'Какой домашний вечерний ритуал помогает уснуть спокойнее?',
|
||||
answer: 'Чтение сказки / тёплый напиток / тёплый душ / тишину',
|
||||
optionalHint: 'Главное — чтобы было тихо и тепло.',
|
||||
type: 'creative'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'dreams',
|
||||
title: 'Фантазии перед сном',
|
||||
questions: [
|
||||
{
|
||||
id: 'q1',
|
||||
points: 100,
|
||||
question: 'Если бы ты мог летать во сне, куда бы ты полетел?',
|
||||
answer: 'Любой творческий ответ',
|
||||
type: 'creative'
|
||||
},
|
||||
{
|
||||
id: 'q2',
|
||||
points: 200,
|
||||
question: 'Какой цвет ты бы выбрал для звёзд?',
|
||||
answer: 'Любой цвет с объяснением',
|
||||
optionalHint: 'Может быть даже необычный.',
|
||||
type: 'creative'
|
||||
},
|
||||
{
|
||||
id: 'q3',
|
||||
points: 300,
|
||||
question: 'Придумай имя для доброго ночного облачка.',
|
||||
answer: 'Любое мягкое имя',
|
||||
type: 'creative'
|
||||
},
|
||||
{
|
||||
id: 'q4',
|
||||
points: 500,
|
||||
question: 'Опиши самый уютный сон, который ты можешь представить.',
|
||||
answer: 'Любой тёплый рассказ',
|
||||
optionalHint: 'Мягкие цвета, тёплое место, добрые герои.',
|
||||
type: 'creative'
|
||||
},
|
||||
{
|
||||
id: 'q5',
|
||||
points: 1000,
|
||||
question: 'Сочини доброе пожелание перед сном для всей семьи.',
|
||||
answer: 'Любое искреннее пожелание',
|
||||
type: 'creative'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Category, Question, QuestionRef, QuestionSet } from '$lib/types';
|
||||
import { animalsSet } from './animals';
|
||||
import { cozySet } from './cozy';
|
||||
|
||||
/**
|
||||
* Реестр всех доступных наборов вопросов.
|
||||
*
|
||||
* ┌───────────────────────────────────────────────────────────────────────┐
|
||||
* │ КАК ДОБАВИТЬ НОВЫЙ НАБОР: │
|
||||
* │ 1. Создай файл `src/lib/question-sets/<name>.ts` │
|
||||
* │ 2. Экспортируй из него объект типа `QuestionSet`. │
|
||||
* │ (id, title, description, ageRange, categories[][]) │
|
||||
* │ 3. Импортируй его сюда и добавь в массив `questionSets` ниже. │
|
||||
* │ Всё — на экране выбора набор появится автоматически. │
|
||||
* └───────────────────────────────────────────────────────────────────────┘
|
||||
*/
|
||||
export const questionSets: QuestionSet[] = [animalsSet, cozySet];
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* Хелперы поиска по данным (без состояния). */
|
||||
/* ----------------------------------------------------------------------- */
|
||||
|
||||
export function getQuestionSetById(id: string): QuestionSet | undefined {
|
||||
return questionSets.find((set) => set.id === id);
|
||||
}
|
||||
|
||||
/** Сколько всего вопросов в наборе (для прогресс-бара). */
|
||||
export function getTotalQuestions(set: QuestionSet): number {
|
||||
return set.categories.reduce((sum, cat) => sum + cat.questions.length, 0);
|
||||
}
|
||||
|
||||
/** Ключ для множества сыгранных вопросов: `categoryId:questionId`. */
|
||||
export function makeQuestionRef(categoryId: string, questionId: string): QuestionRef {
|
||||
return `${categoryId}:${questionId}`;
|
||||
}
|
||||
|
||||
/** Разбивает ключ обратно на id категории и id вопроса. */
|
||||
export function parseQuestionRef(ref: QuestionRef): { categoryId: string; questionId: string } {
|
||||
const [categoryId, questionId] = ref.split(':');
|
||||
return { categoryId, questionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Находит вопрос по ключу `categoryId:questionId` внутри набора.
|
||||
* Возвращает категорию, вопрос или undefined, если не найдено.
|
||||
*/
|
||||
export function findQuestion(
|
||||
set: QuestionSet,
|
||||
ref: QuestionRef
|
||||
): { category: Category; question: Question } | undefined {
|
||||
const { categoryId, questionId } = parseQuestionRef(ref);
|
||||
const category = set.categories.find((c) => c.id === categoryId);
|
||||
if (!category) return undefined;
|
||||
const question = category.questions.find((q) => q.id === questionId);
|
||||
if (!question) return undefined;
|
||||
return { category, question };
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
import type { GameState, QuestionRef } from '$lib/types';
|
||||
import { getQuestionSetById, getTotalQuestions } from '$lib/question-sets';
|
||||
|
||||
/**
|
||||
* Игровое состояние полностью отделено от компонентов.
|
||||
*
|
||||
* Магия Svelte store: один `writable`, за которым следят все экраны.
|
||||
* Действия ведущего (выбор набора, открытие вопроса, засчёт, новая игра)
|
||||
* реализованы как функции, мутирующие этот store. При каждом изменении
|
||||
* состояние автоматически сохраняется в localStorage, а при перезагрузке
|
||||
* страницы — восстанавливается.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'my-calm-game:state:v1';
|
||||
|
||||
/** Начальное (пустое) состояние — экран выбора набора. */
|
||||
function initialState(): GameState {
|
||||
return {
|
||||
questionSetId: null,
|
||||
score: 0,
|
||||
played: [],
|
||||
current: null
|
||||
};
|
||||
}
|
||||
|
||||
/** Безопасно читаем состояние из localStorage, игнорируя мусор/ошибки. */
|
||||
function loadState(): GameState {
|
||||
if (!browser) return initialState();
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return initialState();
|
||||
const parsed = JSON.parse(raw) as Partial<GameState>;
|
||||
// Защита от повреждённых/чужих данных: проверяем базовую структуру.
|
||||
if (
|
||||
parsed &&
|
||||
(parsed.questionSetId === null || typeof parsed.questionSetId === 'string') &&
|
||||
typeof parsed.score === 'number' &&
|
||||
Array.isArray(parsed.played) &&
|
||||
(parsed.current === null || typeof parsed.current === 'string')
|
||||
) {
|
||||
return {
|
||||
questionSetId: parsed.questionSetId ?? null,
|
||||
score: parsed.score ?? 0,
|
||||
played: parsed.played as QuestionRef[],
|
||||
current: parsed.current ?? null
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Повреждённый JSON — просто начинаем заново.
|
||||
}
|
||||
return initialState();
|
||||
}
|
||||
|
||||
/** Сохраняем состояние в localStorage (только в браузере). */
|
||||
function saveState(state: GameState): void {
|
||||
if (!browser) return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
// localStorage переполнен или недоступен (приватный режим) —
|
||||
// игра продолжит работать в памяти, просто без сохранения.
|
||||
}
|
||||
}
|
||||
|
||||
function createGameStore() {
|
||||
const { subscribe, update, set } = writable<GameState>(loadState());
|
||||
|
||||
// Автосохранение при каждом изменении.
|
||||
if (browser) {
|
||||
subscribe((state) => saveState(state));
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
/** Выбрать набор и перейти к табло. */
|
||||
selectQuestionSet(id: string) {
|
||||
update((s) => ({ ...s, questionSetId: id, score: 0, played: [], current: null }));
|
||||
},
|
||||
|
||||
/** Открыть вопрос по ключу `categoryId:questionId`. */
|
||||
openQuestion(ref: QuestionRef) {
|
||||
update((s) => ({ ...s, current: ref }));
|
||||
},
|
||||
|
||||
/** Закрыть текущий вопрос без решения (вернуться к табло). */
|
||||
closeQuestion() {
|
||||
update((s) => ({ ...s, current: null }));
|
||||
},
|
||||
|
||||
/**
|
||||
* Засчитать ответ: добавить очки и пометить вопрос сыгранным.
|
||||
* Очки берутся из данных вопроса, а не хардкодятся.
|
||||
*/
|
||||
awardQuestion(ref: QuestionRef, points: number) {
|
||||
update((s) => ({
|
||||
...s,
|
||||
score: s.score + points,
|
||||
played: s.played.includes(ref) ? s.played : [...s.played, ref],
|
||||
current: null
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Не засчитывать: вопрос всё равно считается сыгранным, но очки не идут.
|
||||
* Штрафов и минусов нет.
|
||||
*/
|
||||
rejectQuestion(ref: QuestionRef) {
|
||||
update((s) => ({
|
||||
...s,
|
||||
played: s.played.includes(ref) ? s.played : [...s.played, ref],
|
||||
current: null
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Новая игра: сбросить прогресс по ТЕКУЩЕМУ набору (счёт, сыгранные,
|
||||
* текущий вопрос). Набор остаётся выбранным. Сохранение очищается
|
||||
* соответствующим образом (через автосохранение).
|
||||
*/
|
||||
newGame() {
|
||||
update((s) => ({ ...s, score: 0, played: [], current: null }));
|
||||
},
|
||||
|
||||
/**
|
||||
* Сменить набор: вернуться к экрану выбора набора.
|
||||
* Полный сброс партии.
|
||||
*/
|
||||
changeSet() {
|
||||
set(initialState());
|
||||
},
|
||||
|
||||
/** Принудительно заменить состояние (для тестов/отладки). */
|
||||
reset() {
|
||||
set(initialState());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const game = createGameStore();
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* Производные значения как отдельные store (computed-like). */
|
||||
/* ----------------------------------------------------------------------- */
|
||||
|
||||
import { derived } from 'svelte/store';
|
||||
|
||||
/** Текущий выбранный набор (объект) или null. */
|
||||
export const currentSet = derived(game, ($g) =>
|
||||
$g.questionSetId ? getQuestionSetById($g.questionSetId) ?? null : null
|
||||
);
|
||||
|
||||
/** Сколько всего вопросов в текущем наборе. */
|
||||
export const totalQuestions = derived(currentSet, ($set) =>
|
||||
$set ? getTotalQuestions($set) : 0
|
||||
);
|
||||
|
||||
/** Сколько вопросов уже сыграно. */
|
||||
export const playedCount = derived(game, ($g) => $g.played.length);
|
||||
|
||||
/**
|
||||
* Сыграли ли все вопросы набора → пора показывать финал.
|
||||
* Финал показывается только если набор выбран и сыграны ВСЕ вопросы.
|
||||
*/
|
||||
export const isFinished = derived(
|
||||
[currentSet, totalQuestions, playedCount],
|
||||
([$set, $total, $played]) => $set !== null && $total > 0 && $played >= $total
|
||||
);
|
||||
|
||||
/**
|
||||
* Глобальное «app screen» — высчитывается из состояния.
|
||||
* - 'select' — экран выбора набора
|
||||
* - 'board' — основное табло
|
||||
* - 'question' — открыт вопрос
|
||||
* - 'result' — финальный экран
|
||||
*
|
||||
* Восстанавливается корректно после перезагрузки.
|
||||
*/
|
||||
export const screen = derived([game, currentSet, isFinished], ([$g, $set, $finished]) => {
|
||||
if (!$set) return 'select' as const;
|
||||
if ($finished) return 'result' as const;
|
||||
if ($g.current) return 'question' as const;
|
||||
return 'board' as const;
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Доменные типы игры.
|
||||
*
|
||||
* Данные вопросов (`QuestionSet`, `Category`, `Question`) полностью отделены
|
||||
* от состояния игры и от UI. Реальные наборы добавляются как файлы в
|
||||
* `src/lib/question-sets/` и регистрируются в `src/lib/question-sets/index.ts`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Тип вопроса.
|
||||
* - `strict` — есть однозначный правильный ответ (ведущий решает строго).
|
||||
* - `creative` — творческий вопрос, допускающий разные interpretations.
|
||||
* UI для таких вопросов использует более мягкие формулировки действий.
|
||||
*/
|
||||
export type QuestionType = 'strict' | 'creative';
|
||||
|
||||
/**
|
||||
* Стоимость вопроса. Ограничена разрешёнными номиналами «Своей игры».
|
||||
* Чем выше стоимость — тем сложнее вопрос (сложность заложена в тексте).
|
||||
*/
|
||||
export const POINT_VALUES = [100, 200, 300, 500, 1000] as const;
|
||||
export type PointValue = (typeof POINT_VALUES)[number];
|
||||
|
||||
/**
|
||||
* Структура вопроса (исходные данные, без состояния).
|
||||
*
|
||||
* `played` намеренно НЕ хранится здесь — это игровое состояние, оно живёт
|
||||
* отдельно в `GameState` и сохраняется в localStorage.
|
||||
*/
|
||||
export interface Question {
|
||||
/** Стабильный идентификатор, уникальный в пределах набора. */
|
||||
id: string;
|
||||
/** Стоимость вопроса, берётся из данных — НЕ хардкодится в UI. */
|
||||
points: PointValue;
|
||||
/** Текст вопроса. */
|
||||
question: string;
|
||||
/** Правильный ответ. */
|
||||
answer: string;
|
||||
/** Необязательная подсказка, ведущий может её показать по желанию. */
|
||||
optionalHint?: string;
|
||||
/** Тип вопроса влияет на формулировки действий ведущего. */
|
||||
type: QuestionType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Категория набора — содержит ровно 5 вопросов с очками 100/200/300/500/1000.
|
||||
*
|
||||
* `id` нужен для стабильной адресации вопросов (`categoryId:questionId`),
|
||||
* так как id вопросов уникальны лишь в пределах категории.
|
||||
*/
|
||||
export interface Category {
|
||||
id: string;
|
||||
title: string;
|
||||
questions: Question[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Возрастная рекомендация набора.
|
||||
*/
|
||||
export type AgeRange = string;
|
||||
|
||||
/**
|
||||
* Структура набора вопросов (исходные данные).
|
||||
*
|
||||
* Чтобы добавить новый набор:
|
||||
* 1. Создайте файл в `src/lib/question-sets/<name>.ts`.
|
||||
* 2. Экспортируйте из него объект, удовлетворяющий этому интерфейсу.
|
||||
* 3. Добавьте его в массив `questionSets` в `src/lib/question-sets/index.ts`.
|
||||
*/
|
||||
export interface QuestionSet {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Возрастная рекомендация, напр. "6–9 лет" или "вся семья". */
|
||||
ageRange: AgeRange;
|
||||
categories: Category[];
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* Игровое состояние (живёт отдельно от данных вопросов) */
|
||||
/* ----------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Идентификатор сыгранного/открытого вопроса в виде `<categoryId>:<questionId>`.
|
||||
* Используется как ключ в множестве сыгранных вопросов.
|
||||
*/
|
||||
export type QuestionRef = string;
|
||||
|
||||
/**
|
||||
* Полное состояние одной партии.
|
||||
* Сериализуется в localStorage и восстанавливается после перезагрузки.
|
||||
*/
|
||||
export interface GameState {
|
||||
/** id выбранного набора (или null, если на экране выбора). */
|
||||
questionSetId: string | null;
|
||||
/** Текущий счёт команды (только засчитанные ответы). */
|
||||
score: number;
|
||||
/** Множество сыгранных вопросов в виде `categoryId:questionId`. */
|
||||
played: QuestionRef[];
|
||||
/**
|
||||
* Текущий открытый вопрос (`categoryId:questionId`) или null,
|
||||
* если табло открыто и вопрос не выбран.
|
||||
*/
|
||||
current: QuestionRef | null;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="relative z-10 min-h-screen">
|
||||
{@render children()}
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
// Отключаем SSR: игра полностью клиентская (работа с localStorage).
|
||||
// Это упрощает работу с браузерным состоянием и avoids hydration mismatches
|
||||
// при восстановлении сохранённой партии.
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
@@ -0,0 +1,195 @@
|
||||
<script lang="ts">
|
||||
import { game, currentSet, totalQuestions, playedCount, isFinished, screen } from '$lib/stores/game';
|
||||
import { questionSets, findQuestion } from '$lib/question-sets';
|
||||
import { celebrate } from '$lib/confetti';
|
||||
import type { QuestionRef } from '$lib/types';
|
||||
import QuestionSetSelector from '$lib/components/QuestionSetSelector.svelte';
|
||||
import GameBoard from '$lib/components/GameBoard.svelte';
|
||||
import ScorePanel from '$lib/components/ScorePanel.svelte';
|
||||
import HostControls from '$lib/components/HostControls.svelte';
|
||||
import QuestionModal from '$lib/components/QuestionModal.svelte';
|
||||
import GameResult from '$lib/components/GameResult.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
|
||||
// ---- Состояние подтверждений ----
|
||||
let confirmState = $state<{ kind: 'newGame' | 'changeSet' | null }>({ kind: null });
|
||||
|
||||
// Сигнал для анимации счёта.
|
||||
let bumpSignal = $state(0);
|
||||
|
||||
/** Текущий открытый вопрос (объект), если экран вопроса активен. */
|
||||
const currentQuestionData = $derived.by(() => {
|
||||
const set = $currentSet;
|
||||
const ref = $game.current;
|
||||
if (!set || !ref) return null;
|
||||
return findQuestion(set, ref) ?? null;
|
||||
});
|
||||
|
||||
// ---- Действия ведущего ----
|
||||
|
||||
function selectSet(id: string) {
|
||||
game.selectQuestionSet(id);
|
||||
}
|
||||
|
||||
function openQuestion(ref: QuestionRef) {
|
||||
game.openQuestion(ref);
|
||||
}
|
||||
|
||||
function closeQuestion() {
|
||||
game.closeQuestion();
|
||||
}
|
||||
|
||||
function awardQuestion(points: number) {
|
||||
const ref = $game.current;
|
||||
if (!ref) return;
|
||||
game.awardQuestion(ref, points);
|
||||
bumpSignal++;
|
||||
// Праздничное конфетти — засчитанный ответ!
|
||||
celebrate();
|
||||
}
|
||||
|
||||
function rejectQuestion() {
|
||||
const ref = $game.current;
|
||||
if (!ref) return;
|
||||
game.rejectQuestion(ref);
|
||||
}
|
||||
|
||||
function randomQuestion() {
|
||||
const set = $currentSet;
|
||||
if (!set) return;
|
||||
const unplayed: QuestionRef[] = [];
|
||||
for (const cat of set.categories) {
|
||||
for (const q of cat.questions) {
|
||||
const ref = `${cat.id}:${q.id}`;
|
||||
if (!$game.played.includes(ref)) unplayed.push(ref);
|
||||
}
|
||||
}
|
||||
if (unplayed.length === 0) return;
|
||||
const pick = unplayed[Math.floor(Math.random() * unplayed.length)];
|
||||
openQuestion(pick);
|
||||
}
|
||||
|
||||
// Подтверждаемые действия — только если игра уже началась (есть прогресс).
|
||||
const hasProgress = $derived($game.score > 0 || $game.played.length > 0);
|
||||
|
||||
function askNewGame() {
|
||||
if (hasProgress) confirmState = { kind: 'newGame' };
|
||||
else game.newGame();
|
||||
}
|
||||
|
||||
function askChangeSet() {
|
||||
if (hasProgress) confirmState = { kind: 'changeSet' };
|
||||
else game.changeSet();
|
||||
}
|
||||
|
||||
function confirmAction() {
|
||||
if (confirmState.kind === 'newGame') game.newGame();
|
||||
else if (confirmState.kind === 'changeSet') game.changeSet();
|
||||
confirmState = { kind: null };
|
||||
}
|
||||
|
||||
function cancelAction() {
|
||||
confirmState = { kind: null };
|
||||
}
|
||||
|
||||
// Прогресс в процентах для полоски.
|
||||
const progressPct = $derived(
|
||||
$totalQuestions > 0 ? Math.round(($playedCount / $totalQuestions) * 100) : 0
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- ===================== Экран выбора набора ===================== -->
|
||||
{#if $screen === 'select'}
|
||||
<QuestionSetSelector sets={questionSets} onSelect={selectSet} />
|
||||
{:else if $screen === 'result'}
|
||||
<GameResult
|
||||
score={$game.score}
|
||||
playedCount={$playedCount}
|
||||
onPlayAgain={() => game.newGame()}
|
||||
onChangeSet={() => game.changeSet()}
|
||||
/>
|
||||
{:else if $screen === 'board' || $screen === 'question'}
|
||||
{@const set = $currentSet}
|
||||
{#if set}
|
||||
<!-- Полноэкранная раскладка: всегда влезает во вьюпорт без скролла.
|
||||
h-dvh = «настоящая» высота вьюпорта (учитывает моб. адресную строку).
|
||||
flex-col + board во flex-1/min-h-0 забирает всё оставшееся место. -->
|
||||
<div
|
||||
class="mx-auto flex h-[100dvh] w-full max-w-[1400px] flex-col gap-3 px-4 py-4 sm:gap-4 sm:px-6 sm:py-5"
|
||||
>
|
||||
<!-- Шапка табло -->
|
||||
<header class="flex flex-wrap items-center justify-between gap-3 sm:gap-4">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span class="text-2xl sm:text-3xl">🌙</span>
|
||||
<div class="min-w-0">
|
||||
<div
|
||||
class="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-widest text-plum-300/70 sm:text-xs"
|
||||
>
|
||||
📂 Набор вопросов
|
||||
</div>
|
||||
<h1
|
||||
class="truncate font-bold text-white"
|
||||
style="font-size: clamp(1.1rem, 2.4vw, 2rem)"
|
||||
>
|
||||
{set.title}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<ScorePanel score={$game.score} bumpSignal={bumpSignal} />
|
||||
</header>
|
||||
|
||||
<!-- Прогресс -->
|
||||
<div class="shrink-0">
|
||||
<div class="mb-1 flex items-center justify-between text-xs text-night-50/50">
|
||||
<span>🎯 Прогресс · {$playedCount} из {$totalQuestions}</span>
|
||||
<span>{progressPct}%</span>
|
||||
</div>
|
||||
<div class="h-2 w-full overflow-hidden rounded-full bg-white/5">
|
||||
<div
|
||||
class="h-full rounded-full bg-gradient-to-r from-mint-400 via-gold-400 to-ember-400 transition-all duration-500 ease-out"
|
||||
style="width: {progressPct}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Само табло — забирает всё свободное место по высоте -->
|
||||
<main class="min-h-0 flex-1">
|
||||
<GameBoard set={set} played={$game.played} onOpenQuestion={openQuestion} />
|
||||
</main>
|
||||
|
||||
<!-- Управление ведущего -->
|
||||
<footer class="shrink-0">
|
||||
<HostControls
|
||||
set={set}
|
||||
played={$game.played}
|
||||
onRandom={randomQuestion}
|
||||
onNewGame={askNewGame}
|
||||
onChangeSet={askChangeSet}
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- ===================== Модальный экран вопроса ===================== -->
|
||||
{#if $screen === 'question' && currentQuestionData}
|
||||
<QuestionModal
|
||||
category={currentQuestionData.category}
|
||||
question={currentQuestionData.question}
|
||||
onAward={awardQuestion}
|
||||
onReject={rejectQuestion}
|
||||
onClose={closeQuestion}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- ===================== Диалог подтверждения ===================== -->
|
||||
<ConfirmDialog
|
||||
open={confirmState.kind !== null}
|
||||
title={confirmState.kind === 'changeSet' ? 'Сменить набор?' : 'Начать новую игру?'}
|
||||
message={confirmState.kind === 'changeSet'
|
||||
? 'Текущая партия будет завершена, и вы вернётесь к выбору набора. Прогресс не сохранится.'
|
||||
: 'Счёт и сыгранные вопросы будут сброшены для этого набора.'}
|
||||
confirmLabel={confirmState.kind === 'changeSet' ? 'Сменить набор' : 'Начать заново'}
|
||||
onConfirm={confirmAction}
|
||||
onCancel={cancelAction}
|
||||
/>
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#fbbf24"/>
|
||||
<stop offset="1" stop-color="#fb923c"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="8" fill="#1d1840"/>
|
||||
<path d="M16 4a5 5 0 0 0-5 5v1a1 1 0 0 0 2 0V9a3 3 0 0 1 6 0c0 1.5-1 2.4-2.2 3.3C15.5 13.3 14 14.5 14 17v1a1 1 0 0 0 2 0v-1c0-1.4 1-2.2 2.2-3.1C19.6 13 21 11.7 21 9a5 5 0 0 0-5-5Z" fill="url(#g)"/>
|
||||
<circle cx="16" cy="23" r="2" fill="url(#g)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 546 B |
@@ -0,0 +1,12 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()]
|
||||
});
|
||||
Reference in New Issue
Block a user