commit 1c850911868dd2829a87644b419a017864c63a11 Author: sab.code.lab Date: Fri Aug 7 09:34:02 2026 +0200 chore: восстановление репозитория из снапшота v0.3.1 Прежняя git-история утрачена при переносе проекта на машину владельца (снапшот без .git). Хэши коммитов в docs/reports/* относятся к утраченной истории. Co-Authored-By: Claude Fable 5 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3f324d5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +# CI: гейты качества (format:check + lint + typecheck + test) на каждый push/PR. +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + gates: + name: npm run gates (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run gates diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a03b2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +node_modules/ +dist/ +coverage/ +*.local +.env +.env.* +!.env.example +.DS_Store +Thumbs.db + +*.tsbuildinfo + +# Этап 8: серверный конфиг с секретами (V.4) и локальные файлы SQLite +/server/config.php +*.sqlite +*.sqlite-wal +*.sqlite-shm +**/.astro/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..148512b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +coverage/ +prestart-litany.md +**/.astro/ diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..952df71 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "printWidth": 100, + "singleQuote": true, + "trailingComma": "all", + "endOfLine": "lf" +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..45b2365 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,48 @@ +# AGENTS.md — точка входа для ИИ-агента (инструменто-нейтральная) + +> Единственная точка входа. Чужие конфиг-каталоги инструментов не заводить +> (`gotcha-two-agent-configs`). Здесь — только то, что нужно КАЖДУЮ сессию (IV.6). + +## Перед работой (всегда) + +1. Прочитай `prestart-litany.md` (раздел VI — постоянный чеклист), + `docs/PROJECT_STATE.md`, `docs/INTERFACES.md`, актуальный `docs/plans/`, + свежие `docs/decisions/`. +2. Сверь статусы плана с фактическим кодом (`gotcha-phantom-tails`). +3. Контекст-экономия: большие файлы — поиск → чтение диапазона, не целиком. + +## Module Map + +| Путь | Назначение | Ключевые сущности | +| ------------------ | ------------------------------------ | --------------------------------------------- | +| packages/core | Движок правил Го, чистый TS, без DOM | позиция, группы, SGF, история (этап 1) | +| docs/ | Состояние, контракты, ADR, планы | PROJECT_STATE, INTERFACES, decisions/, plans/ | +| prestart-litany.md | Методология (read-only архив) | грабли — слагами | + +## Гейты (hard, блокирующие) + +`npm run gates` = format:check + lint + typecheck (tsc -b --noEmit) + test (vitest). +Провал: стоп, показать гейт и полный stderr, не чинить молча, не коммитить поверх +красного. Обход по явной команде владельца — `NOTE: committed with failing ` +в тело коммита. + +## Опасные команды (только после письменного «да», одно «да» = одно действие) + +rm -rf, reset --hard, force-push, git checkout -- с потерей правок, git clean -f, +удаление файлов вне зоны задания, push тега (отдельное «да» каждый раз). +Escape-вектора не использовать: `cd x && git push`, `sh -c`, `eval`, pipe через xargs. + +## Правила сессии (кратко; полные — в мастер-промпте) + +- Зона работы — только файлы задания; чужой код не рефакторить. +- Контракты: сначала запись в docs/INTERFACES.md, потом код. +- Отложенное — письменно в момент откладывания (`gotcha-silent-later`). +- Плейсхолдеры помечать (TODO владельцу + «?»). +- Всё общение и документация — на русском; идентификаторы кода не переводить. +- Бамп версии — только по явной команде; SoT версии — корневой package.json. + +## Ритуал закрытия (триггер «закрываем сессию») + +git log + git status → свести: модуль → docs + Module Map; решение → ADR; +отложенное → письменно; если сводить нечего — сказать явно. Отдельный +docs(sync)-коммит. Отчёт для следующей сессии — обязателен. diff --git a/CREDITS.md b/CREDITS.md new file mode 100644 index 0000000..5215ec6 --- /dev/null +++ b/CREDITS.md @@ -0,0 +1,18 @@ +# CREDITS + +## Контент курса и цумэ-го + +Все учебные материалы этого проекта — тексты уроков +(`apps/web/src/content/lessons/`) и задачи (`apps/web/src/content/tasks/`, +включая цумэ-го с деревьями решений в SGF) — **оригинальные**, написаны +специально для go-learn командой проекта. Классические тактические мотивы +(лестница, сеть, ко и т. п.) использованы как общедоступные идеи игры Го; +конкретные позиции и решения сочинены авторами проекта и не скопированы из +сборников. + +Авторство: команда проекта go-learn. + +Лицензия на контент (уроки и задачи): **CC BY 4.0** +(https://creativecommons.org/licenses/by/4.0/deed.ru). + +Дата: 2026-08-06. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3037406 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 go-learn contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f05e457 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# go-learn — обучающий сайт по игре Го + +**v0.1.0 · 2026-08-05** · статус: каркас (Шаг 0), код появится в этапе 1 + +Интерактивный курс Го с нуля до ~10 кю + симулятор партии против ИИ. +Полностью клиентский: статическая сборка на шаред-хостинг, движок и ИИ — в браузере. +Язык сайта — русский. Без монетизации и сбора данных. + +## Модули + +| Пакет | Назначение | Статус | +| ------------- | --------------------------------------------------------------- | ------ | +| packages/core | Движок правил: доска, группы, ко/суперко, подсчёт, SGF, история | этап 1 | + +## Старт + +```bash +npm install +npm run gates # форматтер + линтер + типы + тесты +``` + +Требования: Node.js ≥ 20. + +## Документация + +- Точка входа для ИИ-агентов: AGENTS.md +- Состояние проекта: docs/PROJECT_STATE.md +- Контракты модулей: docs/INTERFACES.md +- Решения: docs/decisions/ (ADR) +- Методология: prestart-litany.md (read-only архив) + +## Лицензия + +TODO владельцу (?): выбрать и добавить LICENSE до публикации (рекомендация триажа — +пермиссивная, MIT). Сторонние компоненты — THIRD_PARTY_LICENSES.md, цумэ-го и веса +сетей — CREDITS.md. diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md new file mode 100644 index 0000000..1b5c972 --- /dev/null +++ b/THIRD_PARTY_LICENSES.md @@ -0,0 +1,31 @@ +# THIRD_PARTY_LICENSES — лицензии сторонних зависимостей + +Обновлено: 2026-08-05. Лицензии сверены с полем `license` в +`node_modules/<пакет>/package.json` на момент фиксации (не по памяти). + +Прямые dev-зависимости (SoT версий — корневой `package.json`): + +| Пакет | Версия | Лицензия | +| -------------------------------- | ------ | ---------- | +| typescript | 5.9.3 | Apache-2.0 | +| vitest | 4.1.10 | MIT | +| eslint | 10.8.0 | MIT | +| @eslint/js | 10.0.1 | MIT | +| @typescript-eslint/eslint-plugin | 8.66.0 | MIT | +| @typescript-eslint/parser | 8.66.0 | MIT | +| eslint-config-prettier | 10.1.8 | MIT | +| prettier | 3.9.6 | MIT | +| fast-check | 4.9.0 | MIT | +| @types/node | 26.1.2 | MIT | + +Прямые dev-зависимости apps/web (SoT версий — `apps/web/package.json`): + +| Пакет | Версия | Лицензия | +| ----- | ------ | -------- | +| astro | 5.18.2 | MIT | + +Runtime-зависимости прод-сборки — только workspace-пакеты @go-learn/* +(свой код). packages/core — чистый TypeScript без зависимостей. +Транзитивные зависимости перечисленных пакетов наследуют их экосистемы +(MIT/Apache-2.0/BSD); полный список — `npm ls --all`, тексты лицензий — в +`node_modules/<пакет>/LICENSE*`. diff --git a/apps/web/astro.config.mjs b/apps/web/astro.config.mjs new file mode 100644 index 0000000..715e20e --- /dev/null +++ b/apps/web/astro.config.mjs @@ -0,0 +1,17 @@ +// @ts-check +import { defineConfig } from 'astro/config'; + +// Статическая сборка без адаптеров: сайт целиком клиентский (ADR-0001). +// build.format 'file': страницы как play.html, а не play/index.html — +// работает на любом статическом сервере без directory index (превью, Apache). +export default defineConfig({ + output: 'static', + build: { format: 'file' }, + markdown: { + shikiConfig: { + // Светлая тема подсветки под дизайн «васи» (этап 10): ASCII-диаграммы + // уроков рендерятся shiki, тёмная тема по умолчанию ломала .lesson-body pre. + theme: 'github-light', + }, + }, +}); diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..d54a2e7 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,20 @@ +{ + "name": "@go-learn/web", + "version": "0.1.0", + "private": true, + "description": "Игровой интерфейс сайта обучения Го: страница партии против ИИ (Astro, статическая сборка, vanilla TS + DOM).", + "type": "module", + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview" + }, + "dependencies": { + "@go-learn/ai": "0.1.0", + "@go-learn/board": "0.1.0", + "@go-learn/core": "0.1.0" + }, + "devDependencies": { + "astro": "5.18.2" + } +} diff --git a/apps/web/public/.htaccess b/apps/web/public/.htaccess new file mode 100644 index 0000000..258c732 --- /dev/null +++ b/apps/web/public/.htaccess @@ -0,0 +1,37 @@ +# Статический сайт на Apache shared hosting (ADR-0001). +# +# HTTPS-редиректа в этом файле НЕТ и быть не должно: включать только +# панелью хостинга. Проверка HTTPS через mod_rewrite за SSL-прокси хостера +# даёт вечный цикл редиректов (gotcha-https-redirect-proxy). + +Options -Indexes +DirectoryIndex index.html + +# MIME для WASM (нужен движку, этап 9): без него потоковая компиляция +# WebAssembly.instantiateStreaming не стартует. +AddType application/wasm .wasm + + + AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml + + + + ExpiresActive On + # Прочее (картинки, шрифты, favicon) — час. + ExpiresDefault "access 1 hour" + # HTML без хэша в имени — всегда перепроверять (Cache-Control ниже). + ExpiresByType text/html "access 0 seconds" + # Ассеты /_astro/ с хэшем в имени — год (Cache-Control immutable ниже). + ExpiresByType text/css "access 1 year" + ExpiresByType application/javascript "access 1 year" + + + + + Header set Cache-Control "no-cache, must-revalidate" + + # Имена ассетов Astro содержат хэш: _astro/<имя>.<хэш>.. + + Header set Cache-Control "public, max-age=31536000, immutable" + + diff --git a/apps/web/public/assets/hero.jpg b/apps/web/public/assets/hero.jpg new file mode 100644 index 0000000..1b19fad Binary files /dev/null and b/apps/web/public/assets/hero.jpg differ diff --git a/apps/web/public/assets/ink.jpg b/apps/web/public/assets/ink.jpg new file mode 100644 index 0000000..6db2ed0 Binary files /dev/null and b/apps/web/public/assets/ink.jpg differ diff --git a/apps/web/public/assets/stones.jpg b/apps/web/public/assets/stones.jpg new file mode 100644 index 0000000..fda83d8 Binary files /dev/null and b/apps/web/public/assets/stones.jpg differ diff --git a/apps/web/public/robots.txt b/apps/web/public/robots.txt new file mode 100644 index 0000000..3b7e6d1 --- /dev/null +++ b/apps/web/public/robots.txt @@ -0,0 +1,3 @@ +# Владелец (2026-08-07): сайт не для индексации поисковиками. +User-agent: * +Disallow: / diff --git a/apps/web/src/ai-worker.ts b/apps/web/src/ai-worker.ts new file mode 100644 index 0000000..79b0b62 --- /dev/null +++ b/apps/web/src/ai-worker.ts @@ -0,0 +1,18 @@ +/** + * Точка входа Web Worker ИИ ур. 4–6 (этап 5). Подключение по комментарию + * packages/ai/src/worker.ts: `self.onmessage = (e) => createWorkerHandler(self)(e.data)`. + * Сборка — Vite: new Worker(new URL('./ai-worker.ts', import.meta.url), { type: 'module' }) + * (см. lib/ai-client.ts, defaultWorkerFactory). + */ +import { createWorkerHandler } from '@go-learn/ai'; +import type { WorkerRequest, WorkerResponse } from '@go-learn/ai'; + +/** Минимальный срез DedicatedWorkerGlobalScope, нужный обработчику. */ +interface WorkerScope { + onmessage: ((event: { data: WorkerRequest }) => void) | null; + postMessage(message: WorkerResponse): void; +} + +const scope = self as unknown as WorkerScope; +const handle = createWorkerHandler(scope); +scope.onmessage = (event): void => handle(event.data); diff --git a/apps/web/src/content.config.ts b/apps/web/src/content.config.ts new file mode 100644 index 0000000..12bcc72 --- /dev/null +++ b/apps/web/src/content.config.ts @@ -0,0 +1,53 @@ +/** + * Коллекции контента (этап 6): уроки (Markdown) и задачи (JSON — массив + * TaskV1 на файл). Контракт — docs/INTERFACES.md, «Форматы данных уроков и + * задач»: zod-схемы повторяют frontmatter урока и TaskV1. + */ +import { defineCollection } from 'astro:content'; +import { glob } from 'astro/loaders'; +import { z } from 'astro:schema'; + +const pointSchema = z.object({ + x: z.number().int().min(0), + y: z.number().int().min(0), +}); + +const goalSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('capture-target') }), + z.object({ type: z.literal('liberties'), count: z.number().int().min(1) }), + z.object({ type: z.literal('connect') }), + z.object({ type: z.literal('two-eyes') }), +]); + +const taskSchema = z.object({ + id: z.string().regex(/^(lsn|tsm)-[a-z0-9]+(-[a-z0-9]+)*$/), + kind: z.enum(['lesson', 'tsumego']), + title: z.string().min(1), + topics: z.array(z.string().min(1)), + difficulty: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5)]), + size: z.union([z.literal(9), z.literal(13), z.literal(19)]), + toPlay: z.enum(['black', 'white']), + setupBlack: z.array(pointSchema), + setupWhite: z.array(pointSchema), + sgf: z.string().min(1), + goal: goalSchema, + hint: z.string().optional(), +}); + +const lessonSchema = z.object({ + title: z.string().min(1), + section: z.string().min(1), + order: z.number().int().min(1), + summary: z.string().min(1), +}); + +export const collections = { + lessons: defineCollection({ + loader: glob({ pattern: '*.md', base: './src/content/lessons' }), + schema: lessonSchema, + }), + tasks: defineCollection({ + loader: glob({ pattern: '*.json', base: './src/content/tasks' }), + schema: z.array(taskSchema), + }), +}; diff --git a/apps/web/src/content/content.test.ts b/apps/web/src/content/content.test.ts new file mode 100644 index 0000000..fee9210 --- /dev/null +++ b/apps/web/src/content/content.test.ts @@ -0,0 +1,345 @@ +/** + * Валидация контента этапа 6: задачи (TaskV1 + SGF-деревья решений) и уроки. + * Читает файлы через node:fs, движок — только @go-learn/core. + * + * Проверки задач: обязательные поля, уникальность id, префикс id по kind, + * легальность позиции (buildPosition), парсинг SGF, разметка цели на корне + * (TR — capture-target/liberties/two-eyes, CR — connect) на камнях нужного + * цвета, легальность каждой вариации от позиции задачи (applyMove), + * чередование цветов от toPlay. Сводные: ≥30 цумэ-го, ≥3 на каждую + * градацию difficulty 1–5. + * + * Проверки уроков: frontmatter (title/section/order/summary), section из + * программы курса, существование data-task-id, покрытие всех 10 разделов, + * объём текста 300–700 слов. + */ +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import type { BoardSize, BoardState, CellState, Color, GameNode, Point } from '@go-learn/core'; +import { applyMove, buildPosition, cellAt, opposite, parseSgf } from '@go-learn/core'; + +type Goal = + | { readonly type: 'capture-target' } + | { readonly type: 'liberties'; readonly count: number } + | { readonly type: 'connect' } + | { readonly type: 'two-eyes' }; + +interface TaskV1 { + readonly id: string; + readonly kind: 'lesson' | 'tsumego'; + readonly title: string; + readonly topics: readonly string[]; + readonly difficulty: number; + readonly size: BoardSize; + readonly toPlay: Color; + readonly setupBlack: readonly Point[]; + readonly setupWhite: readonly Point[]; + readonly sgf: string; + readonly goal: Goal; + readonly hint: string; +} + +const SECTIONS: readonly string[] = [ + 'Дамэ и захват', + 'Атари, лестница, сеть', + 'Самоубийство и ко', + 'Глаза: жизнь и смерть', + 'Подсчёт и конец партии', + 'Связь и разрезание', + 'Углы, стороны, центр', + 'Базовые дзёсэки', + 'Форма', + 'Простое ёсэ', +]; + +/** Разделы, в уроках которых обязателен интерактив (уроки 1–6 и 10). */ +const INTERACTIVE_SECTIONS: readonly string[] = [ + 'Дамэ и захват', + 'Атари, лестница, сеть', + 'Самоубийство и ко', + 'Глаза: жизнь и смерть', + 'Подсчёт и конец партии', + 'Связь и разрезание', + 'Простое ёсэ', +]; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function fail(ctx: string, message: string): never { + throw new Error(`${ctx}: ${message}`); +} + +function parsePoints(value: unknown, ctx: string): Point[] { + if (!Array.isArray(value)) fail(ctx, 'ожидался массив точек'); + return value.map((item, index) => { + if (!isRecord(item)) fail(`${ctx}[${index}]`, 'точка — не объект'); + const { x, y } = item; + if (!Number.isInteger(x) || !Number.isInteger(y)) { + fail(`${ctx}[${index}]`, 'координаты x/y должны быть целыми'); + } + return { x: x as number, y: y as number }; + }); +} + +function parseGoal(value: unknown, ctx: string): Goal { + if (!isRecord(value)) fail(ctx, 'goal — не объект'); + const type = value['type']; + if (type === 'capture-target' || type === 'connect' || type === 'two-eyes') { + return { type }; + } + if (type === 'liberties') { + const count = value['count']; + if (!Number.isInteger(count) || (count as number) < 1) { + fail(ctx, 'goal.count должен быть целым ≥ 1'); + } + return { type, count: count as number }; + } + return fail(ctx, `неизвестный goal.type «${String(type)}»`); +} + +function parseTask(raw: unknown, ctx: string): TaskV1 { + if (!isRecord(raw)) fail(ctx, 'задача — не объект'); + const { id, kind, title, topics, difficulty, size, toPlay, sgf, hint } = raw; + if (typeof id !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) { + fail(ctx, 'id должен быть kebab-case'); + } + if (kind !== 'lesson' && kind !== 'tsumego') fail(ctx, 'kind должен быть lesson|tsumego'); + const prefix = kind === 'lesson' ? 'lsn-' : 'tsm-'; + if (!id.startsWith(prefix)) fail(ctx, `id должен начинаться с «${prefix}»`); + if (typeof title !== 'string' || title.trim() === '') fail(ctx, 'пустой title'); + if ( + !Array.isArray(topics) || + topics.length === 0 || + !topics.every((t) => typeof t === 'string' && t.trim() !== '') + ) { + fail(ctx, 'topics должен быть непустым массивом строк'); + } + if (!Number.isInteger(difficulty) || (difficulty as number) < 1 || (difficulty as number) > 5) { + fail(ctx, 'difficulty должен быть целым 1–5'); + } + if (size !== 9 && size !== 13 && size !== 19) fail(ctx, 'size должен быть 9|13|19'); + if (toPlay !== 'black' && toPlay !== 'white') fail(ctx, 'toPlay должен быть black|white'); + if (typeof sgf !== 'string' || sgf.trim() === '') fail(ctx, 'пустой sgf'); + if (typeof hint !== 'string' || hint.trim() === '') fail(ctx, 'пустой hint'); + const goal = parseGoal(raw['goal'], ctx); + return { + id, + kind, + title, + topics: topics as string[], + difficulty: difficulty as number, + size, + toPlay, + setupBlack: parsePoints(raw['setupBlack'], `${ctx}.setupBlack`), + setupWhite: parsePoints(raw['setupWhite'], `${ctx}.setupWhite`), + sgf, + goal, + hint, + }; +} + +const here = dirname(fileURLToPath(import.meta.url)); +const tasksDir = join(here, 'tasks'); +const lessonsDir = join(here, 'lessons'); + +const loadErrors: string[] = []; +const allTasks: TaskV1[] = []; +const taskFiles = readdirSync(tasksDir) + .filter((name) => name.endsWith('.json')) + .sort(); +for (const file of taskFiles) { + try { + const raw: unknown = JSON.parse(readFileSync(join(tasksDir, file), 'utf8')); + if (!Array.isArray(raw)) fail(file, 'корень JSON — не массив'); + raw.forEach((item, index) => { + allTasks.push(parseTask(item, `${file}[${index}]`)); + }); + } catch (error) { + loadErrors.push(error instanceof Error ? error.message : String(error)); + } +} + +describe('задачи (tasks/*.json)', () => { + it('все файлы парсятся как массивы TaskV1', () => { + expect(loadErrors).toEqual([]); + expect(taskFiles.length).toBeGreaterThan(0); + }); + + it('id уникальны', () => { + const seen = new Set(); + const duplicates: string[] = []; + for (const task of allTasks) { + if (seen.has(task.id)) duplicates.push(task.id); + seen.add(task.id); + } + expect(duplicates).toEqual([]); + }); + + for (const task of allTasks) { + describe(task.id, () => { + it('позиция легальна (buildPosition не бросает)', () => { + expect(() => + buildPosition(task.size, task.setupBlack, task.setupWhite, task.toPlay), + ).not.toThrow(); + }); + + it('SGF парсится, размер совпадает', () => { + const tree = parseSgf(task.sgf); + expect(tree.size).toBe(task.size); + }); + + it('на корне есть разметка цели на камнях нужного цвета', () => { + const tree = parseSgf(task.sgf); + const position = buildPosition(task.size, task.setupBlack, task.setupWhite, task.toPlay); + const marked = + task.goal.type === 'connect' ? tree.root.markup.circles : tree.root.markup.triangles; + expect(marked.length).toBeGreaterThan(0); + const want: CellState = + task.goal.type === 'capture-target' ? opposite(task.toPlay) : task.toPlay; + for (const point of marked) { + expect(cellAt(position, point)).toBe(want); + } + }); + + it('каждая вариация легальна от позиции задачи, цвета чередуются', () => { + const tree = parseSgf(task.sgf); + const initial = buildPosition(task.size, task.setupBlack, task.setupWhite, task.toPlay); + expect(tree.root.children.length).toBeGreaterThan(0); + const walk = ( + node: GameNode, + state: BoardState, + expectColor: Color, + path: string, + ): void => { + node.children.forEach((child, index) => { + const childPath = `${path}/${index}`; + expect(child.move, `${task.id}${childPath}: узел без хода`).not.toBeNull(); + if (child.move === null) return; + expect(child.move.color, `${task.id}${childPath}: нарушено чередование`).toBe( + expectColor, + ); + const result = applyMove(state, child.move); + expect( + result.ok, + `${task.id}${childPath}: нелегальный ход ${result.ok === false ? result.error : ''}`, + ).toBe(true); + if (result.ok) walk(child, result.state, opposite(expectColor), childPath); + }); + }; + walk(tree.root, initial, task.toPlay, ''); + }); + }); + } + + it('не менее 30 цумэ-го', () => { + expect(allTasks.filter((task) => task.kind === 'tsumego').length).toBeGreaterThanOrEqual(30); + }); + + it('на каждую градацию difficulty 1–5 не менее 3 цумэ-го', () => { + for (let difficulty = 1; difficulty <= 5; difficulty += 1) { + const count = allTasks.filter( + (task) => task.kind === 'tsumego' && task.difficulty === difficulty, + ).length; + expect(count, `difficulty ${difficulty}`).toBeGreaterThanOrEqual(3); + } + }); + + it('задачи уроков — на доске 9×9', () => { + for (const task of allTasks.filter((item) => item.kind === 'lesson')) { + expect(task.size, task.id).toBe(9); + } + }); +}); + +interface LessonDoc { + readonly file: string; + readonly frontmatter: Record | null; + readonly body: string; +} + +function parseFrontmatter(text: string): Record | null { + const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text); + if (match === null) return null; + const out: Record = {}; + for (const line of (match[1] ?? '').split(/\r?\n/)) { + const kv = /^([A-Za-z]+):\s*(.*)$/.exec(line); + if (kv !== null) { + let value = (kv[2] ?? '').trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + out[kv[1] ?? ''] = value; + } + } + return out; +} + +const lessonFiles = readdirSync(lessonsDir) + .filter((name) => name.endsWith('.md')) + .sort(); +const lessons: LessonDoc[] = lessonFiles.map((file) => { + const text = readFileSync(join(lessonsDir, file), 'utf8'); + return { file, frontmatter: parseFrontmatter(text), body: text }; +}); + +describe('уроки (lessons/*.md)', () => { + it('уроков не менее 10', () => { + expect(lessons.length).toBeGreaterThanOrEqual(10); + }); + + for (const lesson of lessons) { + describe(lesson.file, () => { + it('есть frontmatter с title/section/order/summary', () => { + const fm = lesson.frontmatter; + expect(fm, 'frontmatter не найден').not.toBeNull(); + if (fm === null) return; + expect(fm['title'] ?? '', 'title').not.toBe(''); + expect(fm['section'] ?? '', 'section').not.toBe(''); + expect(fm['summary'] ?? '', 'summary').not.toBe(''); + expect(Number.isInteger(Number(fm['order'])), 'order — целое число').toBe(true); + }); + + it('section входит в программу курса', () => { + expect(SECTIONS).toContain(lesson.frontmatter?.['section']); + }); + + it('объём текста 300–700 слов', () => { + const withoutFrontmatter = lesson.body.replace(/^---\r?\n[\s\S]*?\r?\n---/, ''); + const words = withoutFrontmatter.split(/\s+/).filter((word) => /\p{L}/u.test(word)); + expect(words.length).toBeGreaterThanOrEqual(300); + expect(words.length).toBeLessThanOrEqual(700); + }); + + it('все data-task-id существуют среди задач', () => { + const ids = new Set(allTasks.map((task) => task.id)); + const refs = [...lesson.body.matchAll(/data-task-id="([^"]+)"/g)].map( + (match) => match[1] ?? '', + ); + for (const ref of refs) { + expect(ids.has(ref), `нет задачи «${ref}»`).toBe(true); + } + }); + + it('разделы 1–6 и 10 содержат интерактивную задачу', () => { + const section = lesson.frontmatter?.['section'] ?? ''; + if (INTERACTIVE_SECTIONS.includes(section)) { + expect(lesson.body).toMatch(/Запасной текст: попробуйте решить задачу (нужен включённый JavaScript). + +## Запомнить + +- Дамэ — свободные соседние пункты камня или группы. +- Нет дамэ — камни снимаются с доски. +- Одно дамэ — атари, группа под угрозой. +- Группа делит дамэ на все свои камни. diff --git a/apps/web/src/content/lessons/02-atari-lestnitsa-set.md b/apps/web/src/content/lessons/02-atari-lestnitsa-set.md new file mode 100644 index 0000000..ef599ad --- /dev/null +++ b/apps/web/src/content/lessons/02-atari-lestnitsa-set.md @@ -0,0 +1,83 @@ +--- +title: 'Атари, лестница, сеть' +section: 'Атари, лестница, сеть' +order: 1 +summary: 'Три базовых тактических приёма: атари как угроза, лестница как погоня по диагонали и сеть как тихий захват без атари.' +--- + +# Атари, лестница, сеть + +## Атари + +**Атари** — позиция, в которой у группы осталось одно дамэ. Поставить атари — +значит пригрозить захватом на следующий ход. Атари — самое сильное оружие +новичка: оно часто вынуждает противника отвечать. Впрочем, отвечать не +обязательно: сильный игрок может сознательно проигнорировать атари ради +большего хода в другом месте. Но помните: атари ради атари — +плохая привычка. Каждый раз спрашивайте себя: «А что я получу, если он +спасётся?» + +## Лестница + +Если загонять камень атари за атари так, что у него всякий раз остаётся +ровно один путь к бегству, получается **лестница** — зигзагообразная погоня +через всю доску: + +``` + A B C D E +1 . . W B . +2 . B W . . +3 B . . . . + лестница бежит по диагонали к углу +``` + +Лестница работает, если на её пути нет камней противника. Если по диагонали +бега стоит хоть один вражеский камень (**ситё-прерыватель** — «лестничная +опора», англ. ladder breaker, ход ситё-атари), погоня срывается: убегающая +группа соединяется с опорой и получает новые дамэ. Поэтому перед тем как начать лестницу, прочитайте её до конца: +проследите зигзаг глазами до края доски. Лестница либо захватывает, либо +катастрофически проигрывает — середины нет. + +Край доски — лучший друг лестницы: у камня на первой линии всего три дамэ, +и убегать ему некуда. Погоня, прижатая к краю, заканчивается захватом +гарантированно. + +## Сеть + +Когда лестница не работает, камень часто можно поймать **сетью** (гэта): +тихим ходом, который не ставит атари, но заранее закрывает все пути +к бегству: + +``` + A B C D +1 . B . . +2 W . . . +3 . . B . + чёрный D-подобный ход накрывает белый камень сетью +``` + +Классическая сеть ставится через один пункт от цели (конём, как в шахматах). +Куда бы белый ни продлился, следующее атари оставляет его без дамэ. +Признак того, что пора думать о сети: у камня два дамэ, а прямое атари +ведёт в лестницу с вражеской опорой на пути. + +## Как выбирать приём + +1. Сначала посмотрите на лестницу: чистый ли путь? +2. Если лестница сломана опорой — ищите сеть. +3. Если не работает ни то, ни другое — не гонитесь: слабый камень противника + никуда не денется, займитесь своими группами. + +## Практика + +В задаче чёрные загоняют белый камень лестницей прямо на край доски. +Проведите всю погоню до захвата. + +Запасной текст: попробуйте решить задачу (нужен включённый JavaScript). + +## Запомнить + +- Атари — угроза захвата в один ход. +- Лестница — серия атари, оставляющих один путь; проверяйте диагональ на + наличие опоры. +- Сеть — тихий захват без атари, когда лестница не проходит. diff --git a/apps/web/src/content/lessons/03-samoubiystvo-i-ko.md b/apps/web/src/content/lessons/03-samoubiystvo-i-ko.md new file mode 100644 index 0000000..51df598 --- /dev/null +++ b/apps/web/src/content/lessons/03-samoubiystvo-i-ko.md @@ -0,0 +1,78 @@ +--- +title: 'Самоубийство и ко' +section: 'Самоубийство и ко' +order: 1 +summary: 'Почему нельзя ставить камень в пункт без дамэ, когда такой ход всё же легален, и как работает правило ко.' +--- + +# Самоубийство и ко + +## Запрет самоубийства + +Ход, после которого у вашей собственной группы не остаётся ни одного дамэ, +**запрещён**. Нельзя «пожертвовать» камень, просто поставив его в полностью +окружённый пункт: по официальным правилам (например, AGA) такой ход +отменяется и засчитывается как пас, а на этом сайте движок просто не даст +поставить такой камень. + +``` + A B C +1 . W . +2 W . W точка B2 для чёрных — под запретом: +3 . W . чёрный камень там сразу останется без дамэ +``` + +## Важное исключение: захват + +Если ход в «запретный» пункт **снимает** группу противника, он легален: +снятые камни освобождают пункты, и у вашего камня появляются дамэ. Поэтому +ход, выглядящий как самоубийство, иногда оказывается решающим ударом. +Всегда проверяйте: не в атари ли группа, внутрь которой вы ставите камень? + +## Правило ко + +Представьте форму, где чёрные снимают один белый камень, а белые могут +тут же снять в ответ один чёрный — и позиция возвращается к исходной. +Без ограничения такая пара захватов повторялась бы бесконечно. Поэтому +действует правило **ко** (по-японски — «вечность»): + +> Немедленно возвращать захваченный камень нельзя. Сначала нужно сделать +> ход в другом месте доски. + +``` + A B C D +2 . B . . +3 B W . B чёрные снимают W на C3 — белые не могут +4 . W W . ответить захватом сразу: это ко +``` + +После того как соперник сходил в другую точку, запрет снимается: если он не +закрыл ко, вы можете вернуться и выиграть его. + +## Ко-борьба + +Из правила ко рождается целая стратегия. Ход в другом месте, который требует +ответа, называется **ко-угрозой**. Выигрывает ко тот, у кого больше угроз: +вы ставите угрозу, соперник отвечает, вы забираете ко обратно. Ценность ко +и ценность угроз нужно сравнивать: иногда выгоднее «продать» ко, получив +два хода подряд в другом месте. + +Для начинающего достаточно трёх правил: + +1. Начав ко, знайте, зачем: выигрыш ко должен чего-то стоить. +2. Перед ко посчитайте свои ко-угрозы — хотя бы одна должна быть. +3. Не отвечайте на мелкие угрозы, если ко дороже. + +## Практика + +В задаче чёрные снимают белый камень, образуя ко. Белые не могут вернуть +камень немедленно и ходят в сторону — после этого чёрные спокойно закрывают +ко соединением. + +Запасной текст: попробуйте решить задачу (нужен включённый JavaScript). + +## Запомнить + +- Ход без дамэ запрещён — если только он не захватывает. +- Ко запрещает немедленный обратный захват одного камня. +- Ко-угроза — способ «купить» право вернуться в ко. diff --git a/apps/web/src/content/lessons/04-glaza-zhizn-i-smert.md b/apps/web/src/content/lessons/04-glaza-zhizn-i-smert.md new file mode 100644 index 0000000..422de6f --- /dev/null +++ b/apps/web/src/content/lessons/04-glaza-zhizn-i-smert.md @@ -0,0 +1,81 @@ +--- +title: 'Глаза: жизнь и смерть' +section: 'Глаза: жизнь и смерть' +order: 1 +summary: 'Что такое глаз, почему два глаза дают вечную жизнь, и как отличить настоящий глаз от ложного.' +--- + +# Глаза: жизнь и смерть + +## Что такое глаз + +**Глаз** — это один или несколько соседних пустых пунктов, полностью +окружённых камнями одного цвета: + +``` + A B C +1 B B . +2 B . B точка B2 — глаз чёрных +3 B B . +``` + +Противник не может просто поставить камень в глаз: это самоубийство. Единственный +способ сыграть туда — сначала снять все внешние дамэ группы, то есть поставить +её в атари. + +## Два глаза — жизнь + +Если у группы есть **два отдельных глаза**, она неуязвима. Чтобы захватить +группу, противнику нужно заполнить оба глаза, но ход в первый глаз — +самоубийство, пока не заполнен второй, и наоборот. Логическая ловушка +замыкается: группа с двумя глазами живёт вечно, сколько бы камней ни +теснилось вокруг. + +Отсюда главная цель обороны в Го: **построить два глаза**. И главная цель +нападения: не дать их построить. + +Один большой глаз — это ещё не жизнь. Если всё внутреннее пространство группы +связно, противник рано или поздно займёт его ключевую точку. Поэтому +пространство нужно **разделять** на две независимые области. + +## Ложный глаз + +Не всякий глаз настоящий. Посмотрите на форму: + +``` + A B C +1 . W . +2 W . W +3 . W . +``` + +Точка B2 выглядит как глаз белых, но четыре белых камня вокруг неё +**не связаны** между собой — они касаются друг друга только по диагонали. +Чёрные могут поставить атари на один из них, а затем занять «глаз». +Такой глаз называется **ложным**: он есть на бумаге, но не даёт жизни. + +Признак настоящего глаза: все камни вокруг него соединены в одну крепкую +группу, и противник не может ни разрезать их, ни поставить в атари. +Прежде чем рассчитывать на глаз, проверьте диагонали: именно по диагоналям +глаз чаще всего оказывается ложным. + +## Жизнь и смерть в задачах + +Типичная задача на жизнь и смерть формулируется так: «чёрные ходят и живут» +или «чёрные ходят и убивают». Решение почти всегда — **жизненная точка**: +единственный пункт, который делит пространство на два глаза (для жизни) или +мешает противнику это сделать (для убийства). Ищите центр симметрии формы — +чаще всего он и есть ответ. + +## Практика + +Белые рассчитывают на глаз, но их камни вокруг него не связаны — глаз ложный. +Найдите ход, который разрушает иллюзию и захватывает отмеченные камни. + +Запасной текст: попробуйте решить задачу (нужен включённый JavaScript). + +## Запомнить + +- Глаз — пустое пространство, окружённое одним цветом. +- Два глаза = вечная жизнь; один глаз = смерть под вопросом. +- Ложный глаз не защищает: проверяйте связность камней вокруг него. diff --git a/apps/web/src/content/lessons/05-podschyot-i-konets-partii.md b/apps/web/src/content/lessons/05-podschyot-i-konets-partii.md new file mode 100644 index 0000000..a39a3c6 --- /dev/null +++ b/apps/web/src/content/lessons/05-podschyot-i-konets-partii.md @@ -0,0 +1,76 @@ +--- +title: 'Подсчёт и конец партии' +section: 'Подсчёт и конец партии' +order: 1 +summary: 'Как заканчивается партия (два паса), что такое территория и как подсчитывается результат по китайским правилам.' +--- + +# Подсчёт и конец партии + +## Когда партия заканчивается + +Партия в Го не заканчивается матом — она заканчивается согласием. Когда оба +игрока считают, что выгодных ходов не осталось, они пасуют. **Два паса +подряд — конец партии.** + +Перед пасом проверьте: + +- все ли границы территорий закрыты; +- нет ли незакрытых дамэ между группами, через которые противник может + разрезать; +- не осталось ли ваших камней в атари, которые ещё можно спасти, и чужих — + которые можно добрать. + +Пасовать раньше времени — отдавать очки. Пасовать позже — тоже ошибка: +по японским правилам ход в свою территорию стоит очко, а по китайским +(нашим) счёт не меняется, но ход впустую отдаёт инициативу. + +## Мёртвые камни + +После двух пасов игроки договариваются, какие группы **мёртвы** — обречены, +хотя формально стоят на доске. Мёртвые камни убираются с доски как пленники. +Если игроки не согласны — партия просто продолжается, и спорная группа +доказывает свою судьбу игрой. Это честное правило: «сомневаешься — играй». + +## Что такое территория + +**Территория** — пустые пункты, окружённые живыми камнями одного цвета так, +что противник не может вторгнуться с надеждой выжить. Каждый такой пункт — +очко. + +## Подсчёт по-китайски (площадь) + +Мы используем китайские правила: очки каждого игрока — это + +> **его живые камни на доске + его территория.** + +Белые дополнительно получают **коми** — компенсацию за второй ход +(в наших партиях — 6,5 очка на большой доске и 5,5 — на доске 9×9; стандарт +китайских правил на большой доске — 7,5; пол-очка исключает ничью). + +Пример. У чёрных: 30 камней на доске и 20 пунктов территории — 50 очков. +У белых: 28 камней и 16 пунктов территории — 44, плюс коми 5,5 — итого 49,5. +Чёрные выиграли с перевесом 0,5 очка. + +Обратите внимание: по китайским правилам ход внутрь своей территории не +меняет счёта (камень занимает пункт территории), а по японским — потерял бы +очко. Поэтому в конце партии по нашим правилам можно безопасно «доукрепляться». + +## Оценка позиции по ходу партии + +Грубая прикидка «кто выигрывает» — полезный навык: посчитайте очевидную +территорию обоих, добавьте коми белым, сравните. Если вы впереди — играйте +проще и надёжнее; если позади — ищите более острые варианты. + +## Практика + +Перед подсчётом нужно прибрать доску: два белых камня внутри чёрной +территории в атари. Снимите их — иначе при подсчёте начнутся споры. + +Запасной текст: попробуйте решить задачу (нужен включённый JavaScript). + +## Запомнить + +- Два паса подряд заканчивают партию. +- Счёт: камни + территория; белые получают коми. +- Мёртвые камни снимаются по согласию; спор решается доигрыванием. diff --git a/apps/web/src/content/lessons/06-svyaz-i-razrezanie.md b/apps/web/src/content/lessons/06-svyaz-i-razrezanie.md new file mode 100644 index 0000000..3e03e78 --- /dev/null +++ b/apps/web/src/content/lessons/06-svyaz-i-razrezanie.md @@ -0,0 +1,75 @@ +--- +title: 'Связь и разрезание' +section: 'Связь и разрезание' +order: 1 +summary: 'Почему связанные группы сильнее, как соединять свои камни надёжно и как разрезать камни противника.' +--- + +# Связь и разрезание + +## Сила связи + +Камни, соединённые в одну группу, делят дамэ и строят глаза вместе. +Две слабые группы по три дамэ — две проблемы; одна связанная группа с шестью +дамэ — крепость. Поэтому **соединяйте свои группы и разрезайте чужие** — +это называют главной тактической идеей Го. + +## Надёжные и ненадёжные связи + +Камни, стоящие вплотную по линии, связаны абсолютно — их нельзя разрезать. +Но многие «красивые» формы связаны лишь условно: + +``` + A B C D +1 B . B . ход через один пункт (иккэн): +2 . . . . белые могут попытаться вклиниться в B1 +``` + +Связь через один пункт надёжна, только если вы готовы ответить соединением +на вклинивание. Диагональный ход (косуми) — тоже не абсолютная связь, но +сам по себе он локально неразрезаем: на разрез в один пункт вы отвечаете +соединением в другой (миаи). Уязвимо косуми лишь тогда, когда вокруг уже +стоят сильные камни противника. + +## Типичные способы соединения + +- **Прямое соединение** — поставить камень в разрыв цепочки. +- **Соединение снизу** — соединиться по первой линии, под защитой края. +- **Захват разрезающего камня** — если вклинившийся камень слаб, снимите его: + связь восстановится сама собой. + +Выбирая, соединяться ли, спросите: «Что случится, если меня разрежут?» +Если обе части живут сами — не тратьте ход. Если одна погибнет — соединяйтесь +немедленно. + +## Как разрезать + +Разрезание — зеркальный приём: ищите **точку разреза** — пункт, через который +противник соединял бы свои группы. Часто это единственный пункт между двумя +диагонально стоящими камнями. Но помните: разрезающий ход должен сам выжить. +Разрезать и тут же потерять разрезающий камень — значит подарить противнику +и связь, и материал. + +## Когда связь важнее территории + +Новички жертвуют связью ради пары пунктов. Почти всегда это ошибка: +разрезанная группа бежит, отдаёт инициативу и тащит за собой потери по всей +доске. Сильные игроки говорят: «Соединяйся — и получишь ход в другом месте». + +Полезно и обратное наблюдение: если противник потратил ход на разрезание, +а обе ваши половины спокойно живут, — он просто подарил вам темп. Ценность +разреза и связи всегда измеряется судьбой групп, а не самим фактом +«разрезал» или «соединил». + +## Практика + +Белый камень вклинился между двумя чёрными группами, но он в атари. Снимите +его — и группы соединятся сами. + +Запасной текст: попробуйте решить задачу (нужен включённый JavaScript). + +## Запомнить + +- Связанная группа сильнее суммы частей. +- Косуми само по себе неразрезаемо, но уязвимо при сильных камнях вокруг. +- Разрезая, позаботьтесь о жизни разрезающего камня. diff --git a/apps/web/src/content/lessons/07-ugly-storony-tsentr.md b/apps/web/src/content/lessons/07-ugly-storony-tsentr.md new file mode 100644 index 0000000..df08bd4 --- /dev/null +++ b/apps/web/src/content/lessons/07-ugly-storony-tsentr.md @@ -0,0 +1,70 @@ +--- +title: 'Углы, стороны, центр' +section: 'Углы, стороны, центр' +order: 1 +summary: 'Почему партия начинается с углов, как край доски работает готовой стеной, и что такое линии доски.' +--- + +# Углы, стороны, центр + +## Неравенство частей доски + +Территория в Го строится у краёв доски, потому что сам край работает как +готовая стена. Сравните, сколько стен нужно построить, чтобы оградить +территорию: + +- **Угол**: два края доски — уже две готовые стены, достроить остаётся две. +- **Сторона**: один край — одна готовая стена, строить придётся три. +- **Центр**: краёв нет — нужны все четыре стены. + +Отсюда знаменитый порядок начала партии: **сначала углы, потом стороны, +потом центр**. Новичок, тянущийся в центр с первых ходов, строит забор +посреди поля — дорого и бесполезно. + +## Линии доски + +У линий доски есть имена и характер: + +- **Первая линия** — край доски: у камня на ней всего три дамэ, в дебюте + туда играть не стоит. +- **Вторая линия** — «линия поражения»: территория есть, но мала и уязвима. +- **Третья линия** — «линия территории»: оптимум для ограждения земли. +- **Четвёртая линия** — «линия влияния»: территории меньше, зато больше + давления на центр и стороны. + +Классический баланс дебюта — игра на третьей и четвёртой линиях. + +## Ключевые пункты угла + +- **3-3** (сан-сан): камень глубоко в углу. Надёжно забирает угол, но низко, + мало влияния. +- **3-4** (комоку): самый популярный первый ход. Угол почти ваш, остаётся + гибкость. +- **4-4** (хоси): звёздный пункт. Угол можно оспорить (вторжение 3-3), зато + камень сильнее тянется к центру. +- **3-5** (мокухадзуси) и **4-5** (такамоку) — реже, с уклоном в сторону + и влияние соответственно. + +## Что делает центр полезным + +Центр не даёт много территории напрямую, но камни в центре: + +- перерезают пути бегства и соединения; +- усиливают вторжения и погони (лестницы!); +- превращают соседние стороны в большие рамки (**мойо**). + +Центр — это сила, а не земля. Играть туда стоит, когда углы и стороны уже +заняты и началась борьба. + +## Практический рецепт для новичка + +1. Первые ходы — в свободные углы (3-4 или 4-4). +2. Дальше — подходы к углам и задержки по сторонам. +3. В центр — только с конкретной целью: атака, спасение, перерезание. +4. Никогда не играйте на первой-второй линии в дебюте без причины. + +## Запомнить + +- Край доски — бесплатная стена; центр её лишён. +- Порядок дебюта: углы → стороны → центр. +- Третья линия — территория, четвёртая — влияние. diff --git a/apps/web/src/content/lessons/08-bazovye-dzyoseki.md b/apps/web/src/content/lessons/08-bazovye-dzyoseki.md new file mode 100644 index 0000000..eb44a69 --- /dev/null +++ b/apps/web/src/content/lessons/08-bazovye-dzyoseki.md @@ -0,0 +1,82 @@ +--- +title: 'Базовые дзёсэки' +section: 'Базовые дзёсэки' +order: 1 +summary: 'Что такое дзёсэки, зачем их учить, и три простейших угловых образца: занятие угла 3-3, цукэ-хики и вторжение 3-3.' +--- + +# Базовые дзёсэки + +## Что такое дзёсэки + +**Дзёсэки** — устоявшаяся «правильная» последовательность ходов в углу, +проверенная поколениями игроков. Итог дзёсэки считается равным (или +почти равным) для обеих сторон: один получает угол, другой — стену или +инициативу. + +Зачем учить дзёсэки новичку? Не для зазубривания, а чтобы: + +- не проигрывать угол за пять ходов из-за типовой ошибки; +- понимать типичные формы: где слабость, где разрез, где жизненная точка; +- освободить внимание для борьбы в других местах доски. + +Важно: дзёсэки — не догма. «Правильный» ход зависит от всей доски; на 9×9 +влияние центра сильнее, чем на 19×19, и многие классические дзёсэки там +ведут себя иначе. Учите идею, а не только порядок ходов. + +## Дзёсэки 1: занятие угла 3-3 + +Простейший способ забрать угол — поставить камень прямо на пункт 3-3. +Соперник прижмёт сверху, вы ответите по краю: + +``` + A B C D E +1 . . . . . +2 . . W . . +3 . . B . . +4 . . . . . +``` + +Чёрный камень на C3 почти неуязвим: у него сразу задел на угол. +Дальше белые обычно давят снаружи, а чёрные ползут по второй-третьей линии, +выстраивая жизнь. Идея: **угол — мой, влияние — ваше**. + +## Дзёсэки 2: цукэ-хики — прижатие и отступление + +Чёрные заняли угол ходом 3-4, белые подошли к нему. Классический образец — +**цукэ-хики**: чёрные прижимаются к подошедшему камню (**цукэ**), белые +отвечают **ханэ** (изгибом вокруг чёрного камня), а чёрные спокойно +отступают (**хики**): + +``` + A B C D E +1 . . . . . +2 . . B . . +3 . B W . . +4 . . . . . +``` + +Хики укрепляет угол чёрных, белые получают внешнюю позицию — это один из +первых дзёсэки, которые стоит узнать новичку. Ключевой урок: +**отступление — не трусость**, а способ сохранить форму без разрезов. + +## Дзёсэки 3: вторжение 3-3 под хоси + +Если соперник начал с 4-4 (хоси), угол ещё не закрыт — вторгайтесь на 3-3. +Дальше развивается типовая борьба: хозяин хоси давит с двух сторон, +вторгшийся ползёт по краю и живёт маленькой группой. Хозяин строит внешнюю +стену. Итог: земля против влияния, равенство. + +## Как относиться к дзёсэки на практике + +1. Знайте два-три образца для каждого углового пункта — этого хватит надолго. +2. После каждой партии смотрите: не вышел ли угол «криво»? Если да — + найдите, где отступили от образца. +3. Помните, что дзёсэки обновляются: профессионалы постоянно находят новые + варианты, а с появлением сильных программ многие оценки изменились. + +## Запомнить + +- Дзёсэки — эталонные угловые размены, равные для обеих сторон. +- Учите смысл ходов, а не только их порядок. +- На малой доске классические дзёсэки работают с поправкой на центр. diff --git a/apps/web/src/content/lessons/09-forma.md b/apps/web/src/content/lessons/09-forma.md new file mode 100644 index 0000000..3aa2679 --- /dev/null +++ b/apps/web/src/content/lessons/09-forma.md @@ -0,0 +1,84 @@ +--- +title: 'Форма' +section: 'Форма' +order: 1 +summary: 'Хорошие и плохие формы камней: тигровая пасть, бамбук, пустой треугольник, данго. Как форма влияет на эффективность.' +--- + +# Форма + +## Что такое форма + +Два игрока могут поставить одинаковое число камней, но у одного они будут +работать на все сто процентов, а у другого — вполсилы. Разница — в **форме**: +в том, насколько камни связаны, гибки и лишены слабостей. Хорошая форма +экономит ходы; плохая форма тащит лишний вес. + +## Хорошие формы + +**Тигровая пасть (кошачья пасть)** — три камня вокруг пустого пункта: + +``` + A B C +1 . B . +2 B . . +3 . B . +``` + +Пустой пункт — почти соединение: если противник вклинится туда, вы ответите +и соединитесь окончательно. Пасть защищает разрез одним камнем. + +**Бамбук (такэфу)** — два камня вплотную и ещё два через пункт: + +``` + A B C D E +1 B B . B B +2 . . . . . +``` + +Связь короткая, пружинящая: разрезать бамбук почти невозможно, а тянется он +быстро. + +**Вытягивание (ноби)** — простой ход вперёд от своего камня. Скромный, но +самый надёжный способ добавить дамэ и не создать слабостей. + +## Плохие формы + +**Пустой треугольник** — три своих камня треугольником вокруг пустого пункта, +который ничего не защищает: + +``` + A B C +1 . B . +2 B B . +``` + +Три камня делают работу двух: тот же пункт можно было прикрыть экономнее. +Поговорка гласит: «пустой треугольник — не ставь никогда». + +**Данго** — «пельменная форма»: бесформенный ком камней без глаз и с малым +числом дамэ. Такая масса тяжела: её легко атаковать, а камни в ней работают +плохо. Данго — признак того, что вас гонят, а вы просто соединяетесь, не +думая. + +**Перегруппировка** — слишком много камней в малой области: они мешают друг +другу строить глаза и дают противнику лишние точки атаки. + +## Как вырабатывать вкус формы + +1. После каждого хода спрашивайте: «Не пустой ли это треугольник?» +2. Предпочитайте ходы, которые делают две вещи сразу: соединяют и строят + глаз, атакуют и укрепляются. +3. Смотрите на свои группы целиком: есть ли у них «головы» (выход наружу), + «ноги» (опора у края) и место для глаз? +4. Пересматривайте свои партии и отмечайте тяжёлые формы — со временем они + начнут сами резать глаз. + +Форма — это эстетика, проверенная логикой: красивый ход в Го почти всегда +оказывается и сильным. + +## Запомнить + +- Тигровая пасть и бамбук — образцы экономной защиты. +- Пустой треугольник и данго — типичные ошибки новичка. +- Хорошая форма = связность + гибкость + потенциал глаз. diff --git a/apps/web/src/content/lessons/10-prostoe-yose.md b/apps/web/src/content/lessons/10-prostoe-yose.md new file mode 100644 index 0000000..c3cd762 --- /dev/null +++ b/apps/web/src/content/lessons/10-prostoe-yose.md @@ -0,0 +1,73 @@ +--- +title: 'Простое ёсэ' +section: 'Простое ёсэ' +order: 1 +summary: 'Эндшпиль по-гошному: ханэ на первой линии, сэнтэ и готэ, почему мелкие ходы в конце решают партию.' +--- + +# Простое ёсэ + +## Что такое ёсэ + +**Ёсэ** — эндшпиль: стадия, когда большие территории уже очерчены и игроки +уточняют границы. Ходы ёсэ обычно мельче ходов миттельшпиля, но среди них +бывают и дорогие: комбинация ханэ у края может стоить около десяти очков. +Партия между равными часто решается именно здесь. Пропустить пару ходов +ёсэ — значит проиграть выигранную партию. + +## Ханэ на первой линии + +Классический приём ёсэ — **ханэ у края**: ход, изгибающийся вокруг камня +противника на первой линии: + +``` + A B C D +8 B W . . +9 B W . . +``` + +Ханэ отодвигает границу на один пункт в вашу пользу. Если соперник ответит — +вы сохранили инициативу; если нет — вы продолжаете ползти дальше. Пара ханэ +«с двух сторон» одной и той же границы стоит уже несколько очков. + +## Сэнтэ и готэ + +- **Сэнтэ** — ход, требующий ответа: после него инициатива (право ходить + первым в новом месте) остаётся у вас. +- **Готэ** — ход, на который можно не отвечать: инициатива переходит + сопернику. + +Правило ёсэ: **сначала играйте сэнтэ-ходы, потом готэ.** Сэнтэ бесплатен: +соперник ответит, и вы снова выбираете, куда ходить. Готэ-ход «покупает» +пункты ценой инициативы — берите его, когда сэнтэ кончились. + +## Как оценивать ходы ёсэ + +Считайте разницу «мы сыграли здесь / соперник сыграл здесь». Если ваш ход +даёт +2 очка вам, а ход соперника там же дал бы +2 ему, ценность пункта — +4 очка. Берите самые дорогие пункты первыми. Когда останутся только +одноочковые размены (**дамэ** — нейтральные пункты), партия близка к концу. + +## Типичные ошибки новичка в ёсэ + +1. Играть в свою территорию «для надёжности» — ход ничего не защищает, + а инициативу отдаёт. +2. Отвечать на готэ-угрозы — соперник даром получает темп. +3. Не добрать последние атари: мёртвые на вид камни иногда спасаются + на одну темповую ошибку. + +Хорошая привычка на 9×9 — за десяток ходов до конца мысленно прикинуть +счёт: это подскажет, нужен ли спокойный ёсэ или пора рисковать. + +## Практика + +Чёрные прижимают белых к самому краю: краевая погоня в духе ёсэ заканчивается +захватом двух камней. Найдите последовательность до конца. + +Запасной текст: попробуйте решить задачу (нужен включённый JavaScript). + +## Запомнить + +- Ёсэ — игра за очки на границах; мелочей не бывает. +- Сэнтэ раньше готэ; считайте ценность каждого пункта. +- Ханэ на первой линии — рабочая лошадка эндшпиля. diff --git a/apps/web/src/content/tasks/lessons.json b/apps/web/src/content/tasks/lessons.json new file mode 100644 index 0000000..7f98f2a --- /dev/null +++ b/apps/web/src/content/tasks/lessons.json @@ -0,0 +1,284 @@ +[ + { + "id": "lsn-dame-capture", + "kind": "lesson", + "title": "Последнее дамэ", + "topics": ["атари", "захват"], + "difficulty": 1, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 3, + "y": 4 + }, + { + "x": 5, + "y": 4 + }, + { + "x": 4, + "y": 3 + } + ], + "setupWhite": [ + { + "x": 4, + "y": 4 + } + ], + "sgf": "(;GM[1]SZ[9]TR[ee];B[ef])", + "goal": { + "type": "capture-target" + }, + "hint": "У белого камня осталось одно дамэ — займите его." + }, + { + "id": "lsn-ladder", + "kind": "lesson", + "title": "Лестница у края", + "topics": ["лестница", "атари"], + "difficulty": 2, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 1, + "y": 1 + }, + { + "x": 2, + "y": 2 + } + ], + "setupWhite": [ + { + "x": 2, + "y": 1 + } + ], + "sgf": "(;GM[1]SZ[9]TR[cb];B[db];W[ca];B[ba];W[da];B[ea])", + "goal": { + "type": "capture-target" + }, + "hint": "Гоните камень к краю: каждое атари оставляет белым один путь." + }, + { + "id": "lsn-ko", + "kind": "lesson", + "title": "Захват в ко", + "topics": ["захват"], + "difficulty": 2, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 3, + "y": 4 + }, + { + "x": 4, + "y": 3 + }, + { + "x": 5, + "y": 4 + } + ], + "setupWhite": [ + { + "x": 4, + "y": 4 + }, + { + "x": 3, + "y": 5 + }, + { + "x": 5, + "y": 5 + }, + { + "x": 4, + "y": 6 + } + ], + "sgf": "(;GM[1]SZ[9]TR[ee];B[ef];W[aa];B[ee])", + "goal": { + "type": "capture-target" + }, + "hint": "Снимите камень — белые не смогут вернуть его сразу: это ко." + }, + { + "id": "lsn-false-eye", + "kind": "lesson", + "title": "Ложный глаз", + "topics": ["глаза", "захват"], + "difficulty": 2, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 0, + "y": 2 + }, + { + "x": 1, + "y": 3 + }, + { + "x": 2, + "y": 2 + }, + { + "x": 3, + "y": 1 + }, + { + "x": 2, + "y": 0 + } + ], + "setupWhite": [ + { + "x": 0, + "y": 1 + }, + { + "x": 1, + "y": 0 + }, + { + "x": 2, + "y": 1 + }, + { + "x": 1, + "y": 2 + } + ], + "sgf": "(;GM[1]SZ[9]TR[bc][cb];B[bb];W[ii])", + "goal": { + "type": "capture-target" + }, + "hint": "Камни вокруг «глаза» не связаны — займите центр и снимите два камня." + }, + { + "id": "lsn-endgame-capture", + "kind": "lesson", + "title": "Чистка территории", + "topics": ["захват", "ёсэ"], + "difficulty": 1, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 3, + "y": 2 + }, + { + "x": 4, + "y": 2 + }, + { + "x": 2, + "y": 3 + }, + { + "x": 5, + "y": 3 + }, + { + "x": 3, + "y": 4 + } + ], + "setupWhite": [ + { + "x": 3, + "y": 3 + }, + { + "x": 4, + "y": 3 + } + ], + "sgf": "(;GM[1]SZ[9]TR[dd][ed];B[ee])", + "goal": { + "type": "capture-target" + }, + "hint": "Два белых камня в атари — доберите их перед подсчётом." + }, + { + "id": "lsn-connect", + "kind": "lesson", + "title": "Соединение через разрез", + "topics": ["связь", "разрезание"], + "difficulty": 2, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 2, + "y": 2 + }, + { + "x": 3, + "y": 2 + }, + { + "x": 2, + "y": 4 + }, + { + "x": 3, + "y": 4 + }, + { + "x": 4, + "y": 3 + } + ], + "setupWhite": [ + { + "x": 3, + "y": 3 + } + ], + "sgf": "(;GM[1]SZ[9]CR[dc][de];B[cd];W[bd])", + "goal": { + "type": "connect" + }, + "hint": "Снимите разрезающий камень — группы соединятся сами." + }, + { + "id": "lsn-yose", + "kind": "lesson", + "title": "Ёсэ у края", + "topics": ["ёсэ", "захват"], + "difficulty": 2, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 1, + "y": 7 + } + ], + "setupWhite": [ + { + "x": 0, + "y": 7 + }, + { + "x": 0, + "y": 8 + } + ], + "sgf": "(;GM[1]SZ[9]TR[ah][ai];B[ag];W[bi];B[ci])", + "goal": { + "type": "capture-target" + }, + "hint": "Прижмите белых к краю: они не убегут." + } +] diff --git a/apps/web/src/content/tasks/tsumego-atari-zahvat.json b/apps/web/src/content/tasks/tsumego-atari-zahvat.json new file mode 100644 index 0000000..8cbf0d8 --- /dev/null +++ b/apps/web/src/content/tasks/tsumego-atari-zahvat.json @@ -0,0 +1,751 @@ +[ + { + "id": "tsm-atari-corner", + "kind": "tsumego", + "title": "Атари в углу", + "topics": ["атари", "захват"], + "difficulty": 1, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 1, + "y": 0 + } + ], + "setupWhite": [ + { + "x": 0, + "y": 0 + } + ], + "sgf": "(;GM[1]SZ[9]TR[aa];B[ab])", + "goal": { + "type": "capture-target" + }, + "hint": "У камня в углу всего два дамэ, одно уже занято." + }, + { + "id": "tsm-atari-edge", + "kind": "tsumego", + "title": "Атари на краю", + "topics": ["атари"], + "difficulty": 1, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 0, + "y": 3 + }, + { + "x": 1, + "y": 4 + } + ], + "setupWhite": [ + { + "x": 0, + "y": 4 + } + ], + "sgf": "(;GM[1]SZ[9]TR[ae];B[af])", + "goal": { + "type": "capture-target" + }, + "hint": "Осталось одно дамэ." + }, + { + "id": "tsm-atari-center", + "kind": "tsumego", + "title": "Одинокий камень", + "topics": ["атари", "захват"], + "difficulty": 1, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 3, + "y": 4 + }, + { + "x": 5, + "y": 4 + }, + { + "x": 4, + "y": 3 + } + ], + "setupWhite": [ + { + "x": 4, + "y": 4 + } + ], + "sgf": "(;GM[1]SZ[9]TR[ee];B[ef])", + "goal": { + "type": "capture-target" + }, + "hint": "Закройте последнее дамэ." + }, + { + "id": "tsm-capture-pair", + "kind": "tsumego", + "title": "Два камня в сети", + "topics": ["захват"], + "difficulty": 1, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 3, + "y": 4 + }, + { + "x": 5, + "y": 4 + }, + { + "x": 4, + "y": 3 + }, + { + "x": 3, + "y": 5 + }, + { + "x": 5, + "y": 5 + } + ], + "setupWhite": [ + { + "x": 4, + "y": 4 + }, + { + "x": 4, + "y": 5 + } + ], + "sgf": "(;GM[1]SZ[9]TR[ee][ef];B[eg])", + "goal": { + "type": "capture-target" + }, + "hint": "У группы из двух камней одно общее дамэ." + }, + { + "id": "tsm-capture-edge-pair", + "kind": "tsumego", + "title": "Пара у края", + "topics": ["захват", "атари"], + "difficulty": 1, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 1, + "y": 3 + }, + { + "x": 1, + "y": 4 + }, + { + "x": 0, + "y": 2 + } + ], + "setupWhite": [ + { + "x": 0, + "y": 3 + }, + { + "x": 0, + "y": 4 + } + ], + "sgf": "(;GM[1]SZ[9]TR[ad][ae];B[af])", + "goal": { + "type": "capture-target" + }, + "hint": "Край доски не даёт лишних дамэ." + }, + { + "id": "tsm-ladder-edge", + "kind": "tsumego", + "title": "Лестница к углу", + "topics": ["лестница"], + "difficulty": 2, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 5, + "y": 1 + }, + { + "x": 6, + "y": 2 + } + ], + "setupWhite": [ + { + "x": 6, + "y": 1 + } + ], + "sgf": "(;GM[1]SZ[9]TR[gb];B[hb];W[ga];B[fa];W[ha];B[ia])", + "goal": { + "type": "capture-target" + }, + "hint": "Атари за атари — белые упираются в угол." + }, + { + "id": "tsm-double-atari", + "kind": "tsumego", + "title": "Двойное атари", + "topics": ["атари", "захват"], + "difficulty": 2, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 4, + "y": 2 + }, + { + "x": 3, + "y": 1 + }, + { + "x": 4, + "y": 4 + }, + { + "x": 3, + "y": 5 + } + ], + "setupWhite": [ + { + "x": 3, + "y": 2 + }, + { + "x": 3, + "y": 4 + } + ], + "sgf": "(;GM[1]SZ[9]TR[dc];B[dd];W[ce];B[cc])", + "goal": { + "type": "capture-target" + }, + "hint": "Один ход — два атари; белые спасут лишь один камень." + }, + { + "id": "tsm-liberties-escape", + "kind": "tsumego", + "title": "Выход из атари", + "topics": ["атари"], + "difficulty": 2, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 4, + "y": 4 + } + ], + "setupWhite": [ + { + "x": 3, + "y": 4 + }, + { + "x": 4, + "y": 3 + }, + { + "x": 4, + "y": 5 + } + ], + "sgf": "(;GM[1]SZ[9]TR[ee];B[fe];W[ge])", + "goal": { + "type": "liberties", + "count": 3 + }, + "hint": "Вытяните камень — пусть у группы станет три дамэ." + }, + { + "id": "tsm-kill-corner", + "kind": "tsumego", + "title": "Угол без глаз", + "topics": ["захват", "жизнь и смерть"], + "difficulty": 2, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 2, + "y": 0 + } + ], + "setupWhite": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 0 + } + ], + "sgf": "(;GM[1]SZ[9]TR[aa][ba];B[bb];W[ab];B[ac])", + "goal": { + "type": "capture-target" + }, + "hint": "Займите точку глаза — белым некуда бежать." + }, + { + "id": "tsm-ladder-down", + "kind": "tsumego", + "title": "Лестница вниз", + "topics": ["лестница"], + "difficulty": 3, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 1, + "y": 7 + }, + { + "x": 2, + "y": 6 + } + ], + "setupWhite": [ + { + "x": 2, + "y": 7 + } + ], + "sgf": "(;GM[1]SZ[9]TR[ch];B[dh];W[ci];B[bi];W[di];B[ei])", + "goal": { + "type": "capture-target" + }, + "hint": "Та же лестница, но к нижнему краю." + }, + { + "id": "tsm-capture-three-corner", + "kind": "tsumego", + "title": "Тройка в углу", + "topics": ["захват", "жизнь и смерть"], + "difficulty": 3, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 2, + "y": 0 + }, + { + "x": 2, + "y": 1 + }, + { + "x": 0, + "y": 2 + } + ], + "setupWhite": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ], + "sgf": "(;GM[1]SZ[9]TR[aa][ba][bb];B[bc];W[ii];B[ab])", + "goal": { + "type": "capture-target" + }, + "hint": "Белые не могут выйти: любое продление — самоубийство." + }, + { + "id": "tsm-liberties-wall", + "kind": "tsumego", + "title": "Прорвите стену", + "topics": ["атари", "жизнь и смерть"], + "difficulty": 3, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 4, + "y": 2 + }, + { + "x": 4, + "y": 3 + } + ], + "setupWhite": [ + { + "x": 3, + "y": 2 + }, + { + "x": 5, + "y": 2 + }, + { + "x": 3, + "y": 3 + }, + { + "x": 5, + "y": 3 + } + ], + "sgf": "(;GM[1]SZ[9]TR[ec][ed];B[ee];W[de])", + "goal": { + "type": "liberties", + "count": 4 + }, + "hint": "Вытяните группу вниз — нужно четыре дамэ." + }, + { + "id": "tsm-ko-fight", + "kind": "tsumego", + "title": "Выигранное ко", + "topics": ["захват"], + "difficulty": 4, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 6, + "y": 3 + }, + { + "x": 5, + "y": 4 + }, + { + "x": 7, + "y": 4 + } + ], + "setupWhite": [ + { + "x": 6, + "y": 4 + }, + { + "x": 5, + "y": 5 + }, + { + "x": 7, + "y": 5 + }, + { + "x": 6, + "y": 6 + } + ], + "sgf": "(;GM[1]SZ[9]TR[ge];B[gf];W[aa];B[ge])", + "goal": { + "type": "capture-target" + }, + "hint": "Начните ко — у белых нет угроз, закройте его." + }, + { + "id": "tsm-double-atari-save", + "kind": "tsumego", + "title": "Двойная угроза", + "topics": ["атари", "захват"], + "difficulty": 4, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 2, + "y": 3 + }, + { + "x": 4, + "y": 3 + }, + { + "x": 3, + "y": 2 + }, + { + "x": 2, + "y": 5 + }, + { + "x": 4, + "y": 5 + } + ], + "setupWhite": [ + { + "x": 3, + "y": 3 + }, + { + "x": 3, + "y": 5 + } + ], + "sgf": "(;GM[1]SZ[9]TR[dd];B[de](;W[dg])(;W[ii]))", + "goal": { + "type": "capture-target" + }, + "hint": "Захват и атари одним ходом — белые не спасут оба камня." + }, + { + "id": "tsm-corner-chase", + "kind": "tsumego", + "title": "Погоня по краю", + "topics": ["захват", "жизнь и смерть"], + "difficulty": 4, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 1, + "y": 4 + }, + { + "x": 2, + "y": 5 + }, + { + "x": 0, + "y": 3 + }, + { + "x": 1, + "y": 3 + } + ], + "setupWhite": [ + { + "x": 0, + "y": 4 + }, + { + "x": 0, + "y": 5 + }, + { + "x": 1, + "y": 5 + } + ], + "sgf": "(;GM[1]SZ[9]TR[ae][af][bf];B[bg];W[ag];B[ah])", + "goal": { + "type": "capture-target" + }, + "hint": "Сокращайте дамэ по очереди — край не оставит места." + }, + { + "id": "tsm-capture-race", + "kind": "tsumego", + "title": "Гонка захватов", + "topics": ["захват", "жизнь и смерть"], + "difficulty": 4, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 6, + "y": 8 + }, + { + "x": 7, + "y": 8 + }, + { + "x": 7, + "y": 6 + } + ], + "setupWhite": [ + { + "x": 8, + "y": 8 + }, + { + "x": 8, + "y": 7 + } + ], + "sgf": "(;GM[1]SZ[9]TR[ih][ii];B[hh];W[ig];B[if])", + "goal": { + "type": "capture-target" + }, + "hint": "У белых два дамэ, у вас темп — начинайте первым." + }, + { + "id": "tsm-ko-threat", + "kind": "tsumego", + "title": "Ко с угрозой", + "topics": ["захват"], + "difficulty": 5, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 2, + "y": 5 + }, + { + "x": 3, + "y": 4 + }, + { + "x": 4, + "y": 5 + } + ], + "setupWhite": [ + { + "x": 3, + "y": 5 + }, + { + "x": 2, + "y": 6 + }, + { + "x": 4, + "y": 6 + }, + { + "x": 3, + "y": 7 + } + ], + "sgf": "(;GM[1]SZ[9]TR[df];B[dg];W[ii];B[df])", + "goal": { + "type": "capture-target" + }, + "hint": "Захватите ко, переждите угрозу и закройте его." + }, + { + "id": "tsm-chase-six", + "kind": "tsumego", + "title": "Шесть камней к краю", + "topics": ["захват", "жизнь и смерть"], + "difficulty": 5, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 1, + "y": 1 + }, + { + "x": 1, + "y": 2 + }, + { + "x": 1, + "y": 3 + }, + { + "x": 1, + "y": 4 + }, + { + "x": 1, + "y": 5 + } + ], + "setupWhite": [ + { + "x": 0, + "y": 1 + }, + { + "x": 0, + "y": 2 + }, + { + "x": 0, + "y": 3 + }, + { + "x": 0, + "y": 4 + }, + { + "x": 0, + "y": 5 + } + ], + "sgf": "(;GM[1]SZ[9]TR[ab][ac][ad][ae][af];B[ag];W[aa];B[ba])", + "goal": { + "type": "capture-target" + }, + "hint": "Выберите правильный конец — второй ведёт в угол." + }, + { + "id": "tsm-liberties-five", + "kind": "tsumego", + "title": "Пять дамэ", + "topics": ["атари", "жизнь и смерть"], + "difficulty": 5, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 4, + "y": 4 + }, + { + "x": 4, + "y": 5 + } + ], + "setupWhite": [ + { + "x": 3, + "y": 4 + }, + { + "x": 5, + "y": 4 + }, + { + "x": 3, + "y": 5 + }, + { + "x": 5, + "y": 5 + } + ], + "sgf": "(;GM[1]SZ[9]TR[ee][ef];B[ed];W[eg];B[ec])", + "goal": { + "type": "liberties", + "count": 5 + }, + "hint": "Одного продления мало — вытягивайтесь дважды." + } +] diff --git a/apps/web/src/content/tasks/tsumego-glaza.json b/apps/web/src/content/tasks/tsumego-glaza.json new file mode 100644 index 0000000..35cc220 --- /dev/null +++ b/apps/web/src/content/tasks/tsumego-glaza.json @@ -0,0 +1,432 @@ +[ + { + "id": "tsm-eyes-three", + "kind": "tsumego", + "title": "Три в ряд", + "topics": ["глаза", "жизнь и смерть"], + "difficulty": 1, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 0 + }, + { + "x": 2, + "y": 0 + }, + { + "x": 0, + "y": 1 + }, + { + "x": 2, + "y": 1 + }, + { + "x": 0, + "y": 2 + }, + { + "x": 2, + "y": 2 + }, + { + "x": 0, + "y": 3 + }, + { + "x": 2, + "y": 3 + }, + { + "x": 0, + "y": 4 + }, + { + "x": 1, + "y": 4 + }, + { + "x": 2, + "y": 4 + } + ], + "setupWhite": [ + { + "x": 3, + "y": 0 + }, + { + "x": 3, + "y": 1 + }, + { + "x": 3, + "y": 2 + }, + { + "x": 3, + "y": 3 + }, + { + "x": 3, + "y": 4 + }, + { + "x": 0, + "y": 5 + }, + { + "x": 1, + "y": 5 + }, + { + "x": 2, + "y": 5 + } + ], + "sgf": "(;GM[1]SZ[9]TR[ba][ac][cc][be];B[bc])", + "goal": { + "type": "two-eyes" + }, + "hint": "Центральная точка делит пространство на два глаза." + }, + { + "id": "tsm-eyes-corner-five", + "kind": "tsumego", + "title": "Пять точек в углу", + "topics": ["глаза", "жизнь и смерть"], + "difficulty": 3, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 0, + "y": 1 + }, + { + "x": 1, + "y": 1 + }, + { + "x": 1, + "y": 2 + }, + { + "x": 1, + "y": 3 + }, + { + "x": 2, + "y": 3 + }, + { + "x": 3, + "y": 3 + }, + { + "x": 3, + "y": 2 + }, + { + "x": 3, + "y": 1 + }, + { + "x": 3, + "y": 0 + } + ], + "setupWhite": [ + { + "x": 0, + "y": 2 + }, + { + "x": 1, + "y": 4 + }, + { + "x": 2, + "y": 4 + }, + { + "x": 3, + "y": 4 + }, + { + "x": 4, + "y": 3 + }, + { + "x": 4, + "y": 2 + }, + { + "x": 4, + "y": 1 + }, + { + "x": 4, + "y": 0 + } + ], + "sgf": "(;GM[1]SZ[9]TR[bb][db][cd];B[ba])", + "goal": { + "type": "two-eyes" + }, + "hint": "Разделите пространство на глаз и глаз." + }, + { + "id": "tsm-eyes-side-resist", + "kind": "tsumego", + "title": "Два глаза на стороне", + "topics": ["глаза", "жизнь и смерть"], + "difficulty": 4, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 0, + "y": 1 + }, + { + "x": 1, + "y": 1 + }, + { + "x": 2, + "y": 1 + }, + { + "x": 3, + "y": 1 + }, + { + "x": 4, + "y": 1 + }, + { + "x": 4, + "y": 0 + } + ], + "setupWhite": [ + { + "x": 5, + "y": 0 + }, + { + "x": 5, + "y": 1 + }, + { + "x": 0, + "y": 2 + }, + { + "x": 1, + "y": 2 + }, + { + "x": 2, + "y": 2 + }, + { + "x": 3, + "y": 2 + }, + { + "x": 4, + "y": 2 + } + ], + "sgf": "(;GM[1]SZ[9]TR[ab][cb][ea];B[ba];W[ca];B[da])", + "goal": { + "type": "two-eyes" + }, + "hint": "Разделите пространство; на вторжение отвечайте захватом." + }, + { + "id": "tsm-eyes-bottom-resist", + "kind": "tsumego", + "title": "Глаза на нижнем краю", + "topics": ["глаза", "жизнь и смерть"], + "difficulty": 4, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 0, + "y": 7 + }, + { + "x": 1, + "y": 7 + }, + { + "x": 2, + "y": 7 + }, + { + "x": 3, + "y": 7 + }, + { + "x": 4, + "y": 7 + }, + { + "x": 4, + "y": 8 + } + ], + "setupWhite": [ + { + "x": 0, + "y": 6 + }, + { + "x": 1, + "y": 6 + }, + { + "x": 2, + "y": 6 + }, + { + "x": 3, + "y": 6 + }, + { + "x": 4, + "y": 6 + }, + { + "x": 5, + "y": 7 + }, + { + "x": 5, + "y": 8 + } + ], + "sgf": "(;GM[1]SZ[9]TR[ah][ch][ei];B[ci];W[bi];B[ai])", + "goal": { + "type": "two-eyes" + }, + "hint": "Правильное деление даёт два глаза даже после вторжения." + }, + { + "id": "tsm-eyes-tee", + "kind": "tsumego", + "title": "Т-образное пространство", + "topics": ["глаза", "жизнь и смерть"], + "difficulty": 5, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 2, + "y": 0 + }, + { + "x": 2, + "y": 1 + }, + { + "x": 3, + "y": 1 + }, + { + "x": 3, + "y": 2 + }, + { + "x": 3, + "y": 3 + }, + { + "x": 4, + "y": 3 + }, + { + "x": 5, + "y": 3 + }, + { + "x": 5, + "y": 2 + }, + { + "x": 5, + "y": 1 + }, + { + "x": 6, + "y": 1 + }, + { + "x": 6, + "y": 0 + } + ], + "setupWhite": [ + { + "x": 1, + "y": 0 + }, + { + "x": 1, + "y": 1 + }, + { + "x": 2, + "y": 2 + }, + { + "x": 2, + "y": 3 + }, + { + "x": 3, + "y": 4 + }, + { + "x": 4, + "y": 4 + }, + { + "x": 5, + "y": 4 + }, + { + "x": 6, + "y": 3 + }, + { + "x": 6, + "y": 2 + }, + { + "x": 7, + "y": 1 + }, + { + "x": 7, + "y": 0 + } + ], + "sgf": "(;GM[1]SZ[9]TR[db][fb][ed];B[eb])", + "goal": { + "type": "two-eyes" + }, + "hint": "Центральная точка превращает Т-форму в два глаза." + } +] diff --git a/apps/web/src/content/tasks/tsumego-svyaz.json b/apps/web/src/content/tasks/tsumego-svyaz.json new file mode 100644 index 0000000..7bc0fc7 --- /dev/null +++ b/apps/web/src/content/tasks/tsumego-svyaz.json @@ -0,0 +1,254 @@ +[ + { + "id": "tsm-connect-gap", + "kind": "tsumego", + "title": "Закройте щель", + "topics": ["связь"], + "difficulty": 1, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 2, + "y": 2 + }, + { + "x": 2, + "y": 4 + } + ], + "setupWhite": [ + { + "x": 1, + "y": 3 + }, + { + "x": 3, + "y": 3 + } + ], + "sgf": "(;GM[1]SZ[9]CR[cc][ce];B[cd])", + "goal": { + "type": "connect" + }, + "hint": "Соедините свои камни до того, как белые разрежут." + }, + { + "id": "tsm-connect-cut-stone", + "kind": "tsumego", + "title": "Снимите разрезающий камень", + "topics": ["связь", "разрезание"], + "difficulty": 2, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 2, + "y": 1 + }, + { + "x": 2, + "y": 2 + }, + { + "x": 2, + "y": 4 + }, + { + "x": 2, + "y": 5 + }, + { + "x": 1, + "y": 3 + } + ], + "setupWhite": [ + { + "x": 2, + "y": 3 + } + ], + "sgf": "(;GM[1]SZ[9]CR[cc][ce];B[dd];W[ed];B[cd])", + "goal": { + "type": "connect" + }, + "hint": "Сначала снимите вклинившийся камень, потом соединитесь." + }, + { + "id": "tsm-cut-wedge", + "kind": "tsumego", + "title": "Вклинивание", + "topics": ["разрезание", "захват"], + "difficulty": 2, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 4, + "y": 2 + }, + { + "x": 4, + "y": 4 + }, + { + "x": 5, + "y": 2 + }, + { + "x": 5, + "y": 4 + }, + { + "x": 6, + "y": 3 + } + ], + "setupWhite": [ + { + "x": 3, + "y": 3 + }, + { + "x": 5, + "y": 3 + } + ], + "sgf": "(;GM[1]SZ[9]TR[fd];B[ed])", + "goal": { + "type": "capture-target" + }, + "hint": "Встаньте между белыми камнями — слабый погибнет." + }, + { + "id": "tsm-connect-choice", + "kind": "tsumego", + "title": "Единственная связь", + "topics": ["связь"], + "difficulty": 3, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 1, + "y": 2 + }, + { + "x": 1, + "y": 3 + }, + { + "x": 3, + "y": 2 + }, + { + "x": 3, + "y": 3 + } + ], + "setupWhite": [ + { + "x": 2, + "y": 2 + } + ], + "sgf": "(;GM[1]SZ[9]CR[bc][dd];B[cd];W[ce])", + "goal": { + "type": "connect" + }, + "hint": "Соединяйтесь снизу — сверху стоит белый камень." + }, + { + "id": "tsm-connect-atari", + "kind": "tsumego", + "title": "Связь с атари", + "topics": ["связь", "атари"], + "difficulty": 3, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 3, + "y": 3 + }, + { + "x": 3, + "y": 4 + }, + { + "x": 5, + "y": 3 + }, + { + "x": 5, + "y": 4 + } + ], + "setupWhite": [ + { + "x": 4, + "y": 4 + } + ], + "sgf": "(;GM[1]SZ[9]CR[dd][fe];B[ed](;W[ef])(;W[ii];B[ef]))", + "goal": { + "type": "connect" + }, + "hint": "Ход соединяет группы и одновременно ставит атари." + }, + { + "id": "tsm-connect-through", + "kind": "tsumego", + "title": "Соединение сквозь клин", + "topics": ["связь", "разрезание"], + "difficulty": 5, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 3, + "y": 1 + }, + { + "x": 3, + "y": 2 + }, + { + "x": 3, + "y": 5 + }, + { + "x": 3, + "y": 6 + }, + { + "x": 2, + "y": 4 + }, + { + "x": 4, + "y": 4 + }, + { + "x": 4, + "y": 2 + } + ], + "setupWhite": [ + { + "x": 3, + "y": 3 + }, + { + "x": 3, + "y": 4 + } + ], + "sgf": "(;GM[1]SZ[9]CR[db][dg];B[cd];W[ed];B[fd];W[ii];B[dd];W[ih];B[de])", + "goal": { + "type": "connect" + }, + "hint": "Загоните клин в атари, доберите — и заполните брешь." + } +] diff --git a/apps/web/src/content/tasks/tsumego-yose-joseki.json b/apps/web/src/content/tasks/tsumego-yose-joseki.json new file mode 100644 index 0000000..7483989 --- /dev/null +++ b/apps/web/src/content/tasks/tsumego-yose-joseki.json @@ -0,0 +1,108 @@ +[ + { + "id": "tsm-yose-edge", + "kind": "tsumego", + "title": "Дожим на первой линии", + "topics": ["ёсэ", "захват"], + "difficulty": 2, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 7, + "y": 6 + }, + { + "x": 7, + "y": 7 + } + ], + "setupWhite": [ + { + "x": 8, + "y": 6 + }, + { + "x": 8, + "y": 7 + } + ], + "sgf": "(;GM[1]SZ[9]TR[ig][ih];B[if];W[ii];B[hi])", + "goal": { + "type": "capture-target" + }, + "hint": "Край помогает: у белых всё меньше дамэ." + }, + { + "id": "tsm-joseki-corner", + "kind": "tsumego", + "title": "Наказание в углу 3-3", + "topics": ["дзёсэки", "захват"], + "difficulty": 2, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 2, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ], + "setupWhite": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 0 + }, + { + "x": 0, + "y": 1 + } + ], + "sgf": "(;GM[1]SZ[9]TR[aa][ba][ab];B[ac])", + "goal": { + "type": "capture-target" + }, + "hint": "Белые ошиблись в дзёсэки — у группы одно дамэ." + }, + { + "id": "tsm-yose-hane", + "kind": "tsumego", + "title": "Ханэ в углу", + "topics": ["ёсэ", "захват"], + "difficulty": 3, + "size": 9, + "toPlay": "black", + "setupBlack": [ + { + "x": 7, + "y": 8 + }, + { + "x": 6, + "y": 7 + } + ], + "setupWhite": [ + { + "x": 8, + "y": 7 + }, + { + "x": 8, + "y": 8 + } + ], + "sgf": "(;GM[1]SZ[9]TR[ih][ii];B[ig];W[hh];B[hg])", + "goal": { + "type": "capture-target" + }, + "hint": "Прижмите к краю и доберите." + } +] diff --git a/apps/web/src/env.d.ts b/apps/web/src/env.d.ts new file mode 100644 index 0000000..158ef29 --- /dev/null +++ b/apps/web/src/env.d.ts @@ -0,0 +1,9 @@ +/// + +// tsc -b не видит сгенерированные типы контента Astro (каталог .astro/ в +// .gitignore и не попадает в include), поэтому модуль astro:content объявлен +// здесь через реальный entry-point пакета astro. Проверяется только +// content.config.ts — страницы .astro tsc не типизирует. +declare module 'astro:content' { + export { defineCollection } from 'astro/content/config'; +} diff --git a/apps/web/src/layouts/Layout.astro b/apps/web/src/layouts/Layout.astro new file mode 100644 index 0000000..8ff1df9 --- /dev/null +++ b/apps/web/src/layouts/Layout.astro @@ -0,0 +1,1489 @@ +--- +import { STRINGS } from '../ui/strings'; + +type Section = 'learn' | 'tsumego' | 'play' | 'progress' | 'account'; + +interface Props { + title: string; + /** meta description страницы (необязательно). */ + description?: string; + /** Активный раздел — получает aria-current="page" в навигации. */ + section?: Section; +} + +const { title, description, section } = Astro.props; + +/** Пункты основного меню: порядок и ссылки — по контракту этапа 10. */ +const NAV_ITEMS: { key: Section; href: string; label: string }[] = [ + { key: 'learn', href: '/learn.html', label: STRINGS.nav.learn }, + { key: 'tsumego', href: '/tsumego.html', label: STRINGS.nav.tsumego }, + { key: 'play', href: '/play.html', label: STRINGS.nav.play }, + { key: 'progress', href: '/progress.html', label: STRINGS.nav.progress }, + { key: 'account', href: '/account.html', label: STRINGS.nav.account }, +]; +--- + + + + + + + {title} + {description !== undefined && } + + + + + + +
+ +
+
+
+ {STRINGS.chrome.privacyNote} + + {STRINGS.chrome.copyright} +
+
+ + + + + diff --git a/apps/web/src/lib/ai-client.test.ts b/apps/web/src/lib/ai-client.test.ts new file mode 100644 index 0000000..86f5685 --- /dev/null +++ b/apps/web/src/lib/ai-client.test.ts @@ -0,0 +1,117 @@ +/** + * Тесты lib/ai-client.ts: маршрутизация ур. 1–3 в синхронный движок, + * ур. 4–6 в воркер (фейк-воркер через инжекцию фабрики), cancel → + * AiCancelledError, фолбэк при выбросе конструктора и при падении воркера. + */ +import type { WorkerChooseRequest, WorkerRequest, WorkerResponse } from '@go-learn/ai'; +import type { Move } from '@go-learn/core'; +import { describe, expect, it } from 'vitest'; +import type { AiChooseRequest, WorkerLike } from './ai-client.js'; +import { AiCancelledError, createAiClient } from './ai-client.js'; + +const PASS: Move = { kind: 'pass', color: 'white' }; + +function baseRequest(level: AiChooseRequest['level']): AiChooseRequest { + return { size: 9, komi: 5.5, moves: [], level, seed: 42 }; +} + +/** Фейк-воркер: режимы — мгновенный result, молчание (для cancel), падение. */ +function fakeWorker(mode: 'result' | 'silent' | 'crash'): WorkerLike & { + sent: WorkerRequest[]; + terminated: boolean; +} { + const self: WorkerLike & { sent: WorkerRequest[]; terminated: boolean } = { + onmessage: null, + onerror: null, + sent: [], + terminated: false, + postMessage(message: WorkerRequest): void { + self.sent.push(message); + if (mode === 'crash') { + self.onerror?.(new Error('boom')); + return; + } + if (mode === 'result' && message.type === 'choose') { + const response: WorkerResponse = { + type: 'result', + requestId: message.requestId, + move: PASS, + topMoves: [{ move: PASS, winRate: 0.5, visits: 10 }], + simulations: 10, + elapsedMs: 1, + }; + queueMicrotask(() => self.onmessage?.({ data: response })); + } + }, + terminate(): void { + self.terminated = true; + }, + }; + return self; +} + +describe('ai-client: маршрутизация по уровням', () => { + it('ур. 1–3 идут синхронно, воркер не создаётся', async () => { + let created = 0; + const client = createAiClient({ + createWorker: () => { + created += 1; + return fakeWorker('result'); + }, + }); + for (const level of [1, 2, 3] as const) { + const answer = await client.choose(baseRequest(level)); + expect(answer.move.kind === 'play' || answer.move.kind === 'pass').toBe(true); + } + expect(created).toBe(0); + expect(client.fallback).toBe(false); + }); + + it('ур. 4–6 уходят в воркер с seed = seed партии + requestId', async () => { + const worker = fakeWorker('result'); + const client = createAiClient({ createWorker: () => worker }); + const answer = await client.choose(baseRequest(5)); + expect(answer.move).toEqual(PASS); + expect(answer.simulations).toBe(10); + const sent = worker.sent[0] as WorkerChooseRequest; + expect(sent.type).toBe('choose'); + expect(sent.level).toBe(5); + expect(sent.seed).toBe(42 + sent.requestId); + expect(sent.timeBudgetMs).toBeGreaterThan(0); + }); +}); + +describe('ai-client: cancel и фолбэк', () => { + it('cancel отклоняет активный запрос с AiCancelledError и гасит воркер', async () => { + const worker = fakeWorker('silent'); + const client = createAiClient({ createWorker: () => worker }); + const promise = client.choose(baseRequest(4)); + client.cancel(); + await expect(promise).rejects.toBeInstanceOf(AiCancelledError); + expect(worker.terminated).toBe(true); + }); + + it('конструктор воркера кинул → синхронный фолбэк + флаг fallback', async () => { + let ticks = 0; + const client = createAiClient({ + createWorker: () => { + throw new Error('no workers'); + }, + clock: { now: () => (ticks += 1000) }, + }); + const answer = await client.choose(baseRequest(4)); + expect(answer.move.kind === 'play' || answer.move.kind === 'pass').toBe(true); + expect(client.fallback).toBe(true); + }); + + it('падение воркера во время запроса → фолбэк достраивает ответ', async () => { + let ticks = 0; + const client = createAiClient({ + createWorker: () => fakeWorker('crash'), + clock: { now: () => (ticks += 1000) }, + }); + const answer = await client.choose(baseRequest(6)); + expect(answer.move.kind === 'play' || answer.move.kind === 'pass').toBe(true); + expect(client.fallback).toBe(true); + }); +}); diff --git a/apps/web/src/lib/ai-client.ts b/apps/web/src/lib/ai-client.ts new file mode 100644 index 0000000..561dbb6 --- /dev/null +++ b/apps/web/src/lib/ai-client.ts @@ -0,0 +1,207 @@ +/** + * Клиент ИИ для UI (контракт — docs/INTERFACES.md, «Клиент ИИ для UI»). + * Ур. 1–3 — синхронный chooseMove этапа 3 (воркер не нужен), ур. 4–6 — + * Web Worker с протоколом этапа 4. seed воркера = seed партии + requestId. + * Fail-safe: конструктор Worker кинул / воркер упал → синхронный фолбэк + * chooseMoveMcts в основном потоке + флаг fallback (для пометки в UI). + * Воркер инжектируется фабрикой (в тестах — фейк-воркер). + */ +import { chooseMove, chooseMoveMcts, LEVEL_TIME_BUDGET_MS, rebuildGameState } from '@go-learn/ai'; +import type { CandidateScore, MctsLevel, WorkerRequest, WorkerResponse } from '@go-learn/ai'; +import { createSeededRng } from '@go-learn/core'; +import type { BoardSize, Clock, Move, Point } from '@go-learn/core'; + +export interface AiAnswer { + readonly move: Move; + readonly topMoves?: readonly CandidateScore[]; + readonly simulations?: number; +} + +export interface AiChooseRequest { + readonly size: BoardSize; + readonly komi: number; + readonly moves: readonly Move[]; + readonly level: 1 | 2 | 3 | 4 | 5 | 6; + readonly seed: number; +} + +export class AiCancelledError extends Error { + constructor() { + super('запрос ИИ отменён'); + this.name = 'AiCancelledError'; + } +} + +export interface AiClient { + /** true после перехода на синхронный фолбэк (Worker недоступен/упал). */ + readonly fallback: boolean; + choose(request: AiChooseRequest): Promise; + cancel(): void; +} + +/** Минимальный интерфейс воркера (структурно совместим с DOM Worker). */ +export interface WorkerLike { + onmessage: ((event: { data: WorkerResponse }) => void) | null; + onerror: ((event: unknown) => void) | null; + postMessage(message: WorkerRequest): void; + terminate(): void; +} + +export interface AiClientDeps { + readonly createWorker?: () => WorkerLike; + readonly clock?: Clock; // часы фолбэка (в тестах — фейк) +} + +interface PendingRequest { + readonly requestId: number; + readonly request: AiChooseRequest; + readonly seed: number; + readonly resolve: (answer: AiAnswer) => void; + readonly reject: (error: unknown) => void; +} + +function defaultWorkerFactory(): WorkerLike { + const worker = new Worker(new URL('../ai-worker.ts', import.meta.url), { type: 'module' }); + return worker as unknown as WorkerLike; +} + +function lastOpponentPoint(moves: readonly Move[]): Point | null { + for (let index = moves.length - 1; index >= 0; index -= 1) { + const move = moves[index]; + if (move !== undefined && move.kind === 'play') return move.point; + } + return null; +} + +export function createAiClient(deps: AiClientDeps = {}): AiClient { + const factory = deps.createWorker ?? defaultWorkerFactory; + const clock: Clock = deps.clock ?? { now: () => performance.now() }; + let worker: WorkerLike | null = null; + let broken = false; + let pending: PendingRequest | null = null; + let nextRequestId = 1; + + /** Синхронный фолбэк ур. 4–6 в основном потоке; помечает fallback. */ + function fallbackChoose(request: AiChooseRequest, seed: number): AiAnswer { + broken = true; + const level = request.level as MctsLevel; + const rebuilt = rebuildGameState(request.size, request.moves); + if (!rebuilt.ok) throw new Error(rebuilt.message); + const result = chooseMoveMcts({ + state: rebuilt.state, + komi: request.komi, + level, + rng: createSeededRng(seed), + clock, + }); + return { move: result.move, topMoves: result.topMoves, simulations: result.simulations }; + } + + function settlePending(response: WorkerResponse): void { + const current = pending; + if (current === null || response.requestId !== current.requestId) return; + pending = null; + if (response.type === 'error') { + current.reject(new Error(response.message)); + return; + } + current.resolve({ + move: response.move, + topMoves: response.topMoves, + simulations: response.simulations, + }); + } + + /** Падение воркера: активный запрос достраиваем синхронным фолбэком. */ + function handleWorkerCrash(): void { + const current = pending; + if (worker !== null) worker.terminate(); + worker = null; + if (current === null) { + broken = true; + return; + } + pending = null; + try { + current.resolve(fallbackChoose(current.request, current.seed)); + } catch (error) { + current.reject(error); + } + } + + function ensureWorker(): WorkerLike | null { + if (broken) return null; + if (worker !== null) return worker; + try { + worker = factory(); + } catch { + broken = true; + return null; + } + worker.onmessage = (event): void => settlePending(event.data); + worker.onerror = (): void => handleWorkerCrash(); + return worker; + } + + /** Ур. 1–3: мгновенный синхронный ход эвристики этапа 3. */ + function heuristicChoose(request: AiChooseRequest, seed: number): AiAnswer { + const rebuilt = rebuildGameState(request.size, request.moves); + if (!rebuilt.ok) throw new Error(rebuilt.message); + const result = chooseMove({ + state: rebuilt.state, + level: request.level as 1 | 2 | 3, + rng: createSeededRng(seed), + lastOpponentMove: lastOpponentPoint(request.moves), + }); + return { move: result.move }; + } + + function workerChoose( + request: AiChooseRequest, + requestId: number, + seed: number, + ): Promise { + const active = ensureWorker(); + if (active === null) return Promise.resolve(fallbackChoose(request, seed)); + const level = request.level as MctsLevel; + return new Promise((resolve, reject) => { + pending = { requestId, request, seed, resolve, reject }; + active.postMessage({ + type: 'choose', + requestId, + size: request.size, + komi: request.komi, + moves: request.moves, + level, + seed, + timeBudgetMs: LEVEL_TIME_BUDGET_MS[level], + }); + }); + } + + return { + get fallback(): boolean { + return broken; + }, + choose(request: AiChooseRequest): Promise { + const requestId = nextRequestId; + nextRequestId += 1; + const seed = (request.seed + requestId) >>> 0; + if (request.level <= 3) { + try { + return Promise.resolve(heuristicChoose(request, seed)); + } catch (error) { + return Promise.reject(error instanceof Error ? error : new Error(String(error))); + } + } + return workerChoose(request, requestId, seed); + }, + cancel(): void { + const current = pending; + pending = null; + if (worker !== null) worker.terminate(); + worker = null; // пересоздание — лениво при следующем choose + if (current !== null) current.reject(new AiCancelledError()); + }, + }; +} diff --git a/apps/web/src/lib/game-store.test.ts b/apps/web/src/lib/game-store.test.ts new file mode 100644 index 0000000..dcda4ff --- /dev/null +++ b/apps/web/src/lib/game-store.test.ts @@ -0,0 +1,198 @@ +/** + * Тесты lib/game-store.ts (чистый стор, без DOM): новая партия, ход через + * pending→confirm, отказ с reason, пас×2 → подсчёт, toggle мёртвых, resign, + * undo пары ходов, просмотр SGF с навигацией. Геометрия — настоящая из + * @go-learn/board; ИИ — фейк-клиент. + */ +import { computeGeometry, pointToPixel } from '@go-learn/board'; +import type { BoardGeometry } from '@go-learn/board'; +import { findNode } from '@go-learn/core'; +import type { Move, Point } from '@go-learn/core'; +import { describe, expect, it } from 'vitest'; +import type { AiAnswer, AiChooseRequest, AiClient } from './ai-client.js'; +import { createGameStore } from './game-store.js'; +import type { GameStore } from './game-store.js'; +import { DEFAULT_SETTINGS } from './storage.js'; +import type { SettingsV1 } from './storage.js'; + +/** Фейк-клиент ИИ: по умолчанию всегда пасует цветом, чей ход. */ +function fakeAi(handler?: (request: AiChooseRequest) => AiAnswer): AiClient & { + calls: AiChooseRequest[]; +} { + const calls: AiChooseRequest[] = []; + return { + calls, + fallback: false, + choose(request: AiChooseRequest): Promise { + calls.push(request); + if (handler !== undefined) return Promise.resolve(handler(request)); + const color = request.moves.length % 2 === 0 ? 'black' : 'white'; + return Promise.resolve({ move: { kind: 'pass', color } }); + }, + cancel(): void {}, + }; +} + +function makeStore( + ai: AiClient, + settings: SettingsV1 = DEFAULT_SETTINGS, + seed = 7, +): { store: GameStore; geo: BoardGeometry } { + const store = createGameStore({ ai, seed, initialSettings: settings }); + const geo = computeGeometry(settings.size, 450, true); + store.setGeometry(geo); + return { store, geo }; +} + +async function playAt(store: GameStore, geo: BoardGeometry, point: Point): Promise { + const pixel = pointToPixel(geo, point); + await store.tapPoint(pixel.x, pixel.y); + await store.confirm(); +} + +function currentMove(store: GameStore): Move | null { + const state = store.getState(); + return findNode(state.tree.root, state.tree.currentId)?.move ?? null; +} + +describe('game-store: новая партия и ходы', () => { + it('начальное состояние — пустая партия, фаза игра', () => { + const { store } = makeStore(fakeAi()); + const state = store.getState(); + expect(state.phase).toBe('playing'); + expect(state.tree.size).toBe(9); + expect(state.tree.currentId).toBe(0); + expect(state.playerColor).toBe('black'); + }); + + it('ход через pending → confirm: фантом, затем ход и ответ бота', async () => { + const { store, geo } = makeStore(fakeAi()); + const pixel = pointToPixel(geo, { x: 2, y: 2 }); + await store.tapPoint(pixel.x, pixel.y); + expect(store.getState().pending).toEqual({ x: 2, y: 2 }); + await store.confirm(); + const state = store.getState(); + expect(state.pending).toBeNull(); + expect(currentMove(store)).toEqual({ kind: 'pass', color: 'white' }); + expect(state.botThinking).toBe(false); + expect(state.lastError).toBeNull(); + }); + + it('нелегальный ход → lastError с reason, дерево не меняется', async () => { + const { store, geo } = makeStore(fakeAi()); + await playAt(store, geo, { x: 2, y: 2 }); + const before = store.getState().tree; + await playAt(store, geo, { x: 2, y: 2 }); + expect(store.getState().lastError).toBe('occupied'); + expect(store.getState().tree).toBe(before); + }); + + it('первый тап переносит фантом, тап по другой точке — новый фантом', async () => { + const { store, geo } = makeStore(fakeAi()); + const a = pointToPixel(geo, { x: 1, y: 1 }); + const b = pointToPixel(geo, { x: 3, y: 3 }); + await store.tapPoint(a.x, a.y); + await store.tapPoint(b.x, b.y); + expect(store.getState().pending).toEqual({ x: 3, y: 3 }); + store.cancelPending(); + expect(store.getState().pending).toBeNull(); + }); +}); + +describe('game-store: окончание партии и подсчёт', () => { + it('пас × 2 → фаза подсчёта с предзаполненным итогом', async () => { + const { store } = makeStore(fakeAi()); + await store.pass(); + const state = store.getState(); + expect(state.phase).toBe('scoring'); + expect(state.score).not.toBeNull(); + expect(state.score?.winner).toBe('white'); // пустая доска: белые выигрывают коми + expect(state.tree.meta.result).toBe('W+5.5'); + }); + + it('toggleDead группы меняет итог подсчёта', async () => { + const { store, geo } = makeStore(fakeAi()); + await playAt(store, geo, { x: 0, y: 0 }); + await playAt(store, geo, { x: 0, y: 1 }); + await store.pass(); + const before = store.getState(); + // Эвристика ядра предложила пару (0,0)+(0,1) мёртвой → префилл dead. + expect(before.dead.size).toBe(2); + expect(before.score?.black).toBe(0); + store.toggleDead({ x: 0, y: 0 }); // снимает отметку со всей группы + const after = store.getState(); + expect(after.dead.size).toBe(0); + expect(after.score?.black).toBe(81); // камни + вся территория снова чёрные + }); + + it('resign → meta.result W+R при сдаче чёрных', async () => { + const { store } = makeStore(fakeAi()); + store.resign(); + const state = store.getState(); + expect(state.tree.meta.result).toBe('W+R'); + expect(findNode(state.tree.root, state.tree.currentId)?.state.over).toBe(true); + }); + + it('undo откатывает пару ходов (игрок + ответ бота)', async () => { + const botPlays = (): AiAnswer => ({ + move: { kind: 'play', color: 'white', point: { x: 4, y: 4 } }, + }); + const { store, geo } = makeStore(fakeAi(botPlays)); + await playAt(store, geo, { x: 2, y: 2 }); + expect(store.getState().tree.currentId).toBe(2); + store.undo(); + expect(store.getState().tree.currentId).toBe(0); + await playAt(store, geo, { x: 5, y: 5 }); + store.undo(); + expect(store.getState().tree.currentId).toBe(0); + }); +}); + +describe('game-store: цвета и бот первым', () => { + it('playerColor white → бот ходит первым за чёрных', async () => { + const ai = fakeAi(); + const { store } = makeStore(ai, { ...DEFAULT_SETTINGS, playerColor: 'white' }); + await store.newGame({ ...DEFAULT_SETTINGS, playerColor: 'white' }); + expect(currentMove(store)).toEqual({ kind: 'pass', color: 'black' }); + expect(ai.calls.length).toBe(1); + }); + + it('random с чётным seed → чёрные, без запроса к ИИ на старте', () => { + const ai = fakeAi(); + const { store } = makeStore(ai, { ...DEFAULT_SETTINGS, playerColor: 'random' }, 8); + expect(store.getState().playerColor).toBe('black'); + expect(ai.calls.length).toBe(0); + }); +}); + +describe('game-store: просмотр SGF', () => { + const SGF = '(;FF[4]GM[1]SZ[9]KM[5.5];B[aa];W[bb];B[cc])'; + + it('loadSgfViewer + навигация вперёд/назад по главной линии', async () => { + const { store, geo } = makeStore(fakeAi()); + await playAt(store, geo, { x: 7, y: 7 }); + const gameTree = store.getState().tree; + expect(store.loadSgfViewer(SGF)).toBe(true); + expect(store.getState().phase).toBe('viewer'); + expect(store.getState().tree.currentId).toBe(0); + store.viewerNext(); + expect(currentMove(store)).toEqual({ kind: 'play', color: 'black', point: { x: 0, y: 0 } }); + store.viewerNext(); + store.viewerNext(); + expect(store.getState().tree.currentId).toBe(3); + store.viewerNext(); // дальше конца — без изменений + expect(store.getState().tree.currentId).toBe(3); + store.viewerPrev(); + expect(store.getState().tree.currentId).toBe(2); + store.exitViewer(); + expect(store.getState().phase).toBe('playing'); + expect(store.getState().tree).toBe(gameTree); + }); + + it('битый SGF → false и sgfError, фаза не меняется', () => { + const { store } = makeStore(fakeAi()); + expect(store.loadSgfViewer('(не sgf')).toBe(false); + expect(store.getState().sgfError).not.toBeNull(); + expect(store.getState().phase).toBe('playing'); + }); +}); diff --git a/apps/web/src/lib/game-store.ts b/apps/web/src/lib/game-store.ts new file mode 100644 index 0000000..ba6472f --- /dev/null +++ b/apps/web/src/lib/game-store.ts @@ -0,0 +1,374 @@ +/** + * Состояние партии для страницы /play: чистый стор без DOM и без строк UI. + * Дерево партии — GameTree из @go-learn/core; тач-ввод — reduceInput из + * @go-learn/board (геометрию вычисляет и подсовывает страница). Ход бота — + * асинхронно через AiClient с защитой от гонки (requestId). Строки + * статусов собирает страница из ui/strings.ts по кодам состояния. + */ +import type { BoardGeometry } from '@go-learn/board'; +import { reduceInput } from '@go-learn/board'; +import type { InputState } from '@go-learn/board'; +import { + appendMove, + createGame, + defaultKomi, + findNode, + goToNode, + groupAt, + parseSgf, + pointKey, + scorePosition, + suggestDeadGroups, +} from '@go-learn/core'; +import type { + Color, + GameNode, + GameTree, + Move, + MoveError, + NodeMarkup, + Point, + ScoreResult, +} from '@go-learn/core'; +import { AiCancelledError } from './ai-client.js'; +import type { AiAnswer, AiClient } from './ai-client.js'; +import type { SettingsV1 } from './storage.js'; + +export type GamePhase = 'playing' | 'scoring' | 'viewer'; + +export interface HintState { + readonly markup: NodeMarkup; // круги топ-3 + подписи winRate % + readonly bestWinRate: number; // 0..1 лучшего хода последнего ответа + readonly delta: number | null; // изменение к прошлой оценке; null — первая +} + +export interface GameStoreState { + readonly phase: GamePhase; + readonly tree: GameTree; + readonly settings: SettingsV1; + readonly playerColor: Color; // 'random' разрешён в конкретный цвет + readonly pending: Point | null; + readonly lastError: MoveError | null; + readonly botThinking: boolean; + readonly botFailed: boolean; // ИИ вернул нелегальный ход / ошибку + readonly aiFallback: boolean; // клиент ИИ перешёл на синхронный фолбэк + readonly dead: ReadonlySet; + readonly score: ScoreResult | null; + readonly hint: HintState | null; + readonly sgfError: string | null; +} + +export interface GameStoreDeps { + readonly ai: AiClient; + readonly seed: number; // seed партии (генерирует страница) + readonly initialSettings: SettingsV1; +} + +export interface GameStore { + getState(): GameStoreState; + subscribe(listener: () => void): () => void; + setGeometry(geometry: BoardGeometry): void; + /** Визуальные настройки (координаты, номера, подсказки) без новой партии. */ + applySettings(settings: SettingsV1): void; + newGame(settings: SettingsV1): Promise; + tapPoint(px: number, py: number): Promise; + confirm(): Promise; + cancelPending(): void; + pass(): Promise; + resign(): void; + undo(): void; + toggleDead(point: Point): void; + computeScore(): void; + loadSgfViewer(text: string): boolean; + viewerPrev(): void; + viewerNext(): void; + exitViewer(): void; +} + +/** Текущий узел дерева (currentId гарантированно существует). */ +function currentNode(tree: GameTree): GameNode { + const node = findNode(tree.root, tree.currentId); + if (node === null) throw new Error('game-store: текущий узел не найден'); + return node; +} + +/** Родитель узла id; null — корень или узел не найден. */ +function parentOf(root: GameNode, id: number): GameNode | null { + for (const child of root.children) { + if (child.id === id) return root; + const found = parentOf(child, id); + if (found !== null) return found; + } + return null; +} + +/** Список ходов от корня до текущего узла (протокол ИИ stateless). */ +function movesFromRoot(tree: GameTree): Move[] { + const moves: Move[] = []; + const walk = (node: GameNode): boolean => { + if (node.id === tree.currentId) return true; + for (const child of node.children) { + if (walk(child)) { + if (child.move !== null) moves.push(child.move); + return true; + } + } + return false; + }; + walk(tree.root); + return moves.reverse(); +} + +/** Разметка подсказки: круги топ-3, подписи — winRate в процентах. */ +function hintMarkup(answer: AiAnswer): NodeMarkup { + const top = (answer.topMoves ?? []).slice(0, 3); + const circles: Point[] = []; + const labels: { point: Point; text: string }[] = []; + for (const candidate of top) { + if (candidate.move.kind !== 'play') continue; + circles.push(candidate.move.point); + labels.push({ + point: candidate.move.point, + text: `${Math.round(candidate.winRate * 100)}%`, + }); + } + return { labels, triangles: [], squares: [], circles }; +} + +export function createGameStore(deps: GameStoreDeps): GameStore { + let state: GameStoreState; + let geometry: BoardGeometry | null = null; + let input: InputState = { pending: null }; + let requestCounter = 0; + let prevBestWinRate: number | null = null; + let savedGame: { tree: GameTree; playerColor: Color; settings: SettingsV1 } | null = null; + const listeners = new Set<() => void>(); + + function patch(part: Partial): void { + state = { ...state, ...part }; + for (const listener of listeners) listener(); + } + + function applyTree(next: GameTree, lastError: MoveError | null): void { + patch({ tree: next, lastError, pending: null, hint: null }); + input = { pending: null }; + } + + /** Подсчёт с текущими dead; заодно проставляет meta.result для SGF. */ + function scoreNow(tree: GameTree, dead: ReadonlySet): ScoreResult { + const score = scorePosition(currentNode(tree).state, { komi: tree.komi, dead }); + const result = + score.margin === 0 ? 'Draw' : score.margin > 0 ? `B+${score.margin}` : `W+${-score.margin}`; + patch({ tree: { ...tree, meta: { ...tree.meta, result } }, score }); + return score; + } + + function enterScoring(tree: GameTree): void { + const dead = new Set(); + for (const group of suggestDeadGroups(currentNode(tree).state)) { + for (const stone of group) dead.add(pointKey(stone)); + } + patch({ phase: 'scoring', dead }); + scoreNow(tree, dead); + } + + /** Ход бота: асинхронно, защита от гонки номером запроса. */ + async function requestBotMove(tree: GameTree): Promise { + const board = currentNode(tree).state; + if (board.over || board.toPlay === state.playerColor || state.phase !== 'playing') return; + const id = ++requestCounter; + patch({ botThinking: true }); + try { + const answer = await deps.ai.choose({ + size: tree.size, + komi: tree.komi, + moves: movesFromRoot(tree), + level: state.settings.level, + seed: deps.seed, + }); + if (id !== requestCounter || state.phase !== 'playing') return; + applyBotAnswer(tree, answer); + } catch (error) { + if (error instanceof AiCancelledError) return; + if (id === requestCounter) patch({ botFailed: true }); + } finally { + if (id === requestCounter) patch({ botThinking: false, aiFallback: deps.ai.fallback }); + } + } + + function applyBotAnswer(tree: GameTree, answer: AiAnswer): void { + const appended = appendMove(tree, answer.move); + if (!appended.ok) { + patch({ botFailed: true }); + return; + } + const hint = buildHint(answer); + patch({ tree: appended.tree, hint, lastError: null }); + if (currentNode(appended.tree).state.over) enterScoring(appended.tree); + } + + /** Подсказка ур. 4+: разметка + дельта winRate лучшего хода. */ + function buildHint(answer: AiAnswer): HintState | null { + const top = answer.topMoves?.[0]; + if (!state.settings.hintsEnabled || top === undefined) return null; + const delta = prevBestWinRate === null ? null : top.winRate - prevBestWinRate; + prevBestWinRate = top.winRate; + return { markup: hintMarkup(answer), bestWinRate: top.winRate, delta }; + } + + /** Общий путь хода игрока/бота: append + реакция на окончание/ответ бота. */ + async function playMove(move: Move): Promise { + if (state.phase !== 'playing' || state.botThinking) return; + const appended = appendMove(state.tree, move); + if (!appended.ok) { + if (appended.reason !== 'node-not-found') patch({ lastError: appended.reason }); + return; + } + applyTree(appended.tree, null); + const over = currentNode(appended.tree).state.over; + if (over && move.kind !== 'resign') enterScoring(appended.tree); + if (!over) await requestBotMove(appended.tree); + } + + function resolveColor(settings: SettingsV1): Color { + if (settings.playerColor !== 'random') return settings.playerColor; + // Детерминированно от seed партии — воспроизводимо в тестах. + return deps.seed % 2 === 0 ? 'black' : 'white'; + } + + function freshState(settings: SettingsV1): GameStoreState { + const komi = settings.komi === 'auto' ? defaultKomi(settings.size) : settings.komi; + return { + phase: 'playing', + tree: createGame({ size: settings.size, komi }), + settings, + playerColor: resolveColor(settings), + pending: null, + lastError: null, + botThinking: false, + botFailed: false, + aiFallback: deps.ai.fallback, + dead: new Set(), + score: null, + hint: null, + sgfError: null, + }; + } + + state = freshState(deps.initialSettings); + + async function handleInput(event: Parameters[1]): Promise { + if (geometry === null || state.phase !== 'playing' || state.botThinking) return; + const board = currentNode(state.tree).state; + if (board.over || board.toPlay !== state.playerColor) return; + const result = reduceInput(input, event, geometry); + input = result.state; + patch({ pending: result.state.pending }); + if (result.commit !== null) { + await playMove({ kind: 'play', color: state.playerColor, point: result.commit }); + } + } + + function undoSteps(): number { + const current = currentNode(state.tree); + if (current.move === null) return 0; + // Бот ещё не ответил (ход игрока — текущий) → откат одного хода. + return current.move.color === state.playerColor ? 1 : 2; + } + + return { + getState: () => state, + subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); + }, + setGeometry(next: BoardGeometry): void { + geometry = next; + }, + applySettings(settings: SettingsV1): void { + patch({ settings }); + }, + async newGame(settings: SettingsV1): Promise { + deps.ai.cancel(); + requestCounter += 1; + prevBestWinRate = null; + input = { pending: null }; + state = { ...freshState(settings), aiFallback: deps.ai.fallback }; + for (const listener of listeners) listener(); + await requestBotMove(state.tree); + }, + tapPoint: (px: number, py: number) => handleInput({ type: 'tap', px, py }), + confirm: () => handleInput({ type: 'confirm' }), + cancelPending(): void { + input = { pending: null }; + patch({ pending: null }); + }, + pass: () => playMove({ kind: 'pass', color: state.playerColor }), + resign(): void { + void playMove({ kind: 'resign', color: state.playerColor }); + }, + undo(): void { + if (state.phase !== 'playing') return; + if (state.botThinking) { + deps.ai.cancel(); + requestCounter += 1; + patch({ botThinking: false }); + } + let tree = state.tree; + for (let step = undoSteps(); step > 0; step -= 1) { + const parent = parentOf(tree.root, tree.currentId); + if (parent === null) break; + tree = goToNode(tree, parent.id); + } + applyTree(tree, null); + patch({ dead: new Set(), score: null, botFailed: false }); + }, + toggleDead(point: Point): void { + if (state.phase !== 'scoring') return; + const group = groupAt(currentNode(state.tree).state, point); + if (group === null) return; + const dead = new Set(state.dead); + const keys = group.stones.map(pointKey); + const removing = keys.every((key) => dead.has(key)); + for (const key of keys) { + if (removing) dead.delete(key); + else dead.add(key); + } + patch({ dead }); + scoreNow(state.tree, dead); + }, + computeScore(): void { + if (state.phase !== 'scoring') return; + scoreNow(state.tree, state.dead); + }, + loadSgfViewer(text: string): boolean { + try { + const parsed = parseSgf(text); + const tree = goToNode(parsed, parsed.root.id); // просмотр с начала + savedGame = { tree: state.tree, playerColor: state.playerColor, settings: state.settings }; + deps.ai.cancel(); + requestCounter += 1; + patch({ phase: 'viewer', tree, botThinking: false, sgfError: null }); + return true; + } catch (error) { + patch({ sgfError: error instanceof Error ? error.message : String(error) }); + return false; + } + }, + viewerPrev(): void { + if (state.phase !== 'viewer') return; + const parent = parentOf(state.tree.root, state.tree.currentId); + if (parent !== null) patch({ tree: goToNode(state.tree, parent.id) }); + }, + viewerNext(): void { + if (state.phase !== 'viewer') return; + const child = currentNode(state.tree).children[0]; + if (child !== undefined) patch({ tree: goToNode(state.tree, child.id) }); + }, + exitViewer(): void { + if (state.phase !== 'viewer' || savedGame === null) return; + patch({ phase: 'playing', tree: savedGame.tree, playerColor: savedGame.playerColor }); + savedGame = null; + }, + }; +} diff --git a/apps/web/src/lib/goals.test.ts b/apps/web/src/lib/goals.test.ts new file mode 100644 index 0000000..56e1754 --- /dev/null +++ b/apps/web/src/lib/goals.test.ts @@ -0,0 +1,120 @@ +/** + * Тесты lib/goals.ts: все 4 предиката целей + формальный глаз v1 + * (позитив/негатив: область 4 пунктов — не глаз; область у края — глаз). + * Позиции строятся buildPosition, ходы — applyMove из @go-learn/core. + */ +import { applyMove, buildPosition } from '@go-learn/core'; +import type { BoardState, Point } from '@go-learn/core'; +import { describe, expect, it } from 'vitest'; +import { goalReached } from './goals.js'; + +function p(x: number, y: number): Point { + return { x, y }; +} + +/** Ход чёрных/белых, падает с ошибкой теста при нелегальности. */ +function play(state: BoardState, color: 'black' | 'white', point: Point): BoardState { + const result = applyMove(state, { kind: 'play', color, point }); + if (!result.ok) throw new Error(`тест: ход нелегален: ${result.error}`); + return result.state; +} + +describe('goals: capture-target', () => { + // Белый камень (4,4) в атари: чёрные (3,4),(5,4),(4,3), дамэ — (4,5). + const start = buildPosition(9, [p(3, 4), p(5, 4), p(4, 3)], [p(4, 4)], 'black'); + const target = [p(4, 4)]; + + it('цель на доске → не выполнено; захват → выполнено', () => { + expect(goalReached(start, { type: 'capture-target' }, target)).toBe(false); + const after = play(start, 'black', p(4, 5)); + expect(goalReached(after, { type: 'capture-target' }, target)).toBe(true); + }); + + it('пустой target → false', () => { + expect(goalReached(start, { type: 'capture-target' }, [])).toBe(false); + }); +}); + +describe('goals: liberties', () => { + // Чёрный (2,2) прижат белыми (1,2),(3,2),(2,1): одно дамэ (2,3). + const start = buildPosition(9, [p(2, 2)], [p(1, 2), p(3, 2), p(2, 1)], 'black'); + const target = [p(2, 2)]; + + it('группа с 1 дамэ < count=2 → false', () => { + expect(goalReached(start, { type: 'liberties', count: 2 }, target)).toBe(false); + }); + + it('после выхода (2,3) у группы 3 дамэ ≥ 2 → true', () => { + const after = play(start, 'black', p(2, 3)); + expect(goalReached(after, { type: 'liberties', count: 2 }, target)).toBe(true); + expect(goalReached(after, { type: 'liberties', count: 4 }, target)).toBe(false); + }); +}); + +describe('goals: connect', () => { + // Два чёрных камня (4,2) и (4,4), соединяются ходом (4,3). + const start = buildPosition(9, [p(4, 2), p(4, 4)], [], 'black'); + const target = [p(4, 2), p(4, 4)]; + + it('разные группы → false; после соединения → true', () => { + expect(goalReached(start, { type: 'connect' }, target)).toBe(false); + const after = play(start, 'black', p(4, 3)); + expect(goalReached(after, { type: 'connect' }, target)).toBe(true); + }); + + it('камни разных цветов не могут быть одной группой → false', () => { + const mixed = buildPosition(9, [p(4, 2)], [p(4, 4)], 'black'); + expect(goalReached(mixed, { type: 'connect' }, target)).toBe(false); + }); +}); + +describe('goals: two-eyes', () => { + // Кольцо чёрных вокруг линии из 3 пустых пунктов (3,4),(4,4),(5,4): + // один формальный глаз (область из 3 пунктов). Ход (4,4) делит область + // на два глаза по 1 пункту. + const ring = [p(2, 4), p(6, 4), p(3, 3), p(4, 3), p(5, 3), p(3, 5), p(4, 5), p(5, 5)]; + const start = buildPosition(9, ring, [], 'black'); + const target = [p(4, 3)]; + + it('один глаз из 3 пунктов → false', () => { + expect(goalReached(start, { type: 'two-eyes' }, target)).toBe(false); + }); + + it('после разделения области — два глаза → true', () => { + const after = play(start, 'black', p(4, 4)); + expect(goalReached(after, { type: 'two-eyes' }, target)).toBe(true); + }); +}); + +describe('goals: формальный глаз v1 (границы определения)', () => { + it('область из 4 пустых пунктов — не глаз', () => { + // Кольцо вокруг линии из 4 пунктов (3,4)..(6,4): формальных глаз нет. + const ring4 = [ + p(2, 4), + p(7, 4), + p(3, 3), + p(4, 3), + p(5, 3), + p(6, 3), + p(3, 5), + p(4, 5), + p(5, 5), + p(6, 5), + ]; + const state = buildPosition(9, ring4, [], 'black'); + expect(goalReached(state, { type: 'two-eyes' }, [p(4, 3)])).toBe(false); + }); + + it('глаз у края доски засчитывается (край = «свой» сосед)', () => { + // Группа чёрных у левого края с двумя глазами: (0,0) и (0,2). + const black = [p(1, 0), p(1, 1), p(0, 1), p(1, 2), p(1, 3), p(0, 3)]; + const state = buildPosition(9, black, [], 'white'); + expect(goalReached(state, { type: 'two-eyes' }, [p(1, 1)])).toBe(true); + }); + + it('область, смежная с двумя цветами, — не глаз', () => { + // Пустой пункт (0,0) между чёрным (1,0) и белым (0,1) — не глаз. + const state = buildPosition(9, [p(1, 0)], [p(0, 1)], 'black'); + expect(goalReached(state, { type: 'two-eyes' }, [p(1, 0)])).toBe(false); + }); +}); diff --git a/apps/web/src/lib/goals.ts b/apps/web/src/lib/goals.ts new file mode 100644 index 0000000..3437ace --- /dev/null +++ b/apps/web/src/lib/goals.ts @@ -0,0 +1,117 @@ +/** + * Предикаты целей задач (уроки и цумэ-го, этап 6). Контракт — + * docs/INTERFACES.md, «Форматы данных уроков и задач». Чистые функции над + * экспортами @go-learn/core: без DOM, без состояния. Критерий успеха задачи — + * ТОЛЬКО эти предикаты, вычисленные движком на текущей позиции (I.6). + */ +import { cellAt, groupAt, pointKey } from '@go-learn/core'; +import type { BoardState, Color, Group, Point } from '@go-learn/core'; + +/** Спецификация цели задачи (TaskV1.goal). Разметка цели в SGF: + * TR — capture-target / liberties / two-eyes, CR — connect (ADR-0003). */ +export type GoalSpec = + | { readonly type: 'capture-target' } // камни с TR сняты с доски + | { readonly type: 'liberties'; readonly count: number } // группа цели (TR) ≥ count дамэ + | { readonly type: 'connect' } // все CR-камни оказались в одной группе + | { readonly type: 'two-eyes' }; // у группы цели (TR) ≥2 формальных глаза + +/** Ортогональные соседи в пределах доски (в core не экспортируется neighbors). */ +function neighborsOf(state: BoardState, point: Point): Point[] { + const candidates: Point[] = [ + { x: point.x - 1, y: point.y }, + { x: point.x + 1, y: point.y }, + { x: point.x, y: point.y - 1 }, + { x: point.x, y: point.y + 1 }, + ]; + return candidates.filter((p) => p.x >= 0 && p.x < state.size && p.y >= 0 && p.y < state.size); +} + +/** + * Формальный глаз (упрощение v1): пустая область из 1–3 смежных пустых + * пунктов, у которой все ортогональные соседи за её пределами — камни одного + * цвета (или край доски). Ложный глаз в v1 не отличаем — зафиксированное + * упрощение контракта. Возвращает цвет глаза или null. + */ +function formalEyeColor(state: BoardState, region: ReadonlyArray): Color | null { + if (region.length === 0 || region.length > 3) return null; + const inside = new Set(region.map(pointKey)); + let color: Color | null = null; + for (const point of region) { + for (const next of neighborsOf(state, point)) { + if (inside.has(pointKey(next))) continue; + const cell = cellAt(state, next); + if (cell === 'empty') return null; + if (color === null) color = cell; + else if (cell !== color) return null; + } + } + return color; +} + +/** Число формальных глаз группы: пустые области, смежные с группой. */ +function countFormalEyes(state: BoardState, group: Group): number { + const first = group.stones[0]; + if (first === undefined) return 0; + const color = cellAt(state, first); + if (color === 'empty') return 0; + const visited = new Set(); + let eyes = 0; + for (const stone of group.stones) { + for (const seed of neighborsOf(state, stone)) { + if (cellAt(state, seed) !== 'empty' || visited.has(pointKey(seed))) continue; + // Flood fill пустой области от seed. + const region: Point[] = []; + const stack: Point[] = [seed]; + visited.add(pointKey(seed)); + while (stack.length > 0) { + const current = stack.pop() as Point; + region.push(current); + for (const next of neighborsOf(state, current)) { + const key = pointKey(next); + if (cellAt(state, next) === 'empty' && !visited.has(key)) { + visited.add(key); + stack.push(next); + } + } + } + if (formalEyeColor(state, region) === color) eyes += 1; + } + } + return eyes; +} + +/** Группы камней цели, оставшиеся на доске (дедупликация по камням группы). */ +function targetGroups(state: BoardState, target: readonly Point[]): Group[] { + const seen = new Set(); + const groups: Group[] = []; + for (const point of target) { + if (seen.has(pointKey(point))) continue; + const group = groupAt(state, point); + if (group === null) continue; + for (const stone of group.stones) seen.add(pointKey(stone)); + groups.push(group); + } + return groups; +} + +/** + * Выполнена ли цель на текущей позиции. target — разметка из корня SGF + * (TR или CR в зависимости от типа цели). Пустой target → false. + */ +export function goalReached(state: BoardState, goal: GoalSpec, target: readonly Point[]): boolean { + if (target.length === 0) return false; + switch (goal.type) { + case 'capture-target': + return target.every((point) => cellAt(state, point) === 'empty'); + case 'liberties': + return targetGroups(state, target).some((group) => group.liberties >= goal.count); + case 'connect': { + const groups = targetGroups(state, target); + if (groups.length !== 1) return false; + const stones = new Set(groups[0]?.stones.map(pointKey) ?? []); + return target.every((point) => stones.has(pointKey(point))); + } + case 'two-eyes': + return targetGroups(state, target).some((group) => countFormalEyes(state, group) >= 2); + } +} diff --git a/apps/web/src/lib/keyboard.test.ts b/apps/web/src/lib/keyboard.test.ts new file mode 100644 index 0000000..5d8a380 --- /dev/null +++ b/apps/web/src/lib/keyboard.test.ts @@ -0,0 +1,95 @@ +/** + * Тесты lib/keyboard.ts: кламп курсора по краям, старт с центра, маппинг + * клавиш, слияние курсор-маркера с разметкой. Чистые функции, без DOM. + */ +import type { NodeMarkup } from '@go-learn/core'; +import { describe, expect, it } from 'vitest'; +import { addCursor, arrowOf, centerCursor, clampCursor, reduceCursor } from './keyboard.js'; + +describe('keyboard: reduceCursor', () => { + it('двигает курсор на одно пересечение в каждую сторону', () => { + const start = { x: 4, y: 4 }; + expect(reduceCursor(start, 'up', 9)).toEqual({ x: 4, y: 3 }); + expect(reduceCursor(start, 'down', 9)).toEqual({ x: 4, y: 5 }); + expect(reduceCursor(start, 'left', 9)).toEqual({ x: 3, y: 4 }); + expect(reduceCursor(start, 'right', 9)).toEqual({ x: 5, y: 4 }); + }); + + it('клампит по всем краям доски', () => { + expect(reduceCursor({ x: 0, y: 0 }, 'up', 9)).toEqual({ x: 0, y: 0 }); + expect(reduceCursor({ x: 0, y: 0 }, 'left', 9)).toEqual({ x: 0, y: 0 }); + expect(reduceCursor({ x: 8, y: 8 }, 'down', 9)).toEqual({ x: 8, y: 8 }); + expect(reduceCursor({ x: 8, y: 8 }, 'right', 9)).toEqual({ x: 8, y: 8 }); + }); + + it('кламп по размеру доски (13 и 19)', () => { + expect(reduceCursor({ x: 12, y: 0 }, 'right', 13)).toEqual({ x: 12, y: 0 }); + expect(reduceCursor({ x: 0, y: 18 }, 'down', 19)).toEqual({ x: 0, y: 18 }); + }); + + it('не мутирует исходную точку', () => { + const start = { x: 4, y: 4 }; + reduceCursor(start, 'up', 9); + expect(start).toEqual({ x: 4, y: 4 }); + }); +}); + +describe('keyboard: centerCursor и clampCursor', () => { + it('старт — центр доски', () => { + expect(centerCursor(9)).toEqual({ x: 4, y: 4 }); + expect(centerCursor(13)).toEqual({ x: 6, y: 6 }); + expect(centerCursor(19)).toEqual({ x: 9, y: 9 }); + }); + + it('курсор за пределами новой доски прижимается к краю', () => { + expect(clampCursor({ x: 9, y: 18 }, 9)).toEqual({ x: 8, y: 8 }); + expect(clampCursor({ x: 4, y: 4 }, 9)).toEqual({ x: 4, y: 4 }); + }); +}); + +describe('keyboard: arrowOf', () => { + it('стрелки маппятся в направления', () => { + expect(arrowOf('ArrowUp')).toBe('up'); + expect(arrowOf('ArrowDown')).toBe('down'); + expect(arrowOf('ArrowLeft')).toBe('left'); + expect(arrowOf('ArrowRight')).toBe('right'); + }); + + it('прочие клавиши → null', () => { + expect(arrowOf('Enter')).toBeNull(); + expect(arrowOf(' ')).toBeNull(); + expect(arrowOf('Escape')).toBeNull(); + }); +}); + +describe('keyboard: addCursor', () => { + const base: NodeMarkup = { + labels: [], + triangles: [{ x: 1, y: 1 }], + squares: [], + circles: [{ x: 2, y: 2 }], + }; + + it('cursor === null → разметка без изменений (та же ссылка)', () => { + expect(addCursor(base, null)).toBe(base); + expect(addCursor(undefined, null)).toBeUndefined(); + }); + + it('курсор добавляется квадратом, прочая разметка сохраняется', () => { + const merged = addCursor(base, { x: 4, y: 4 }); + expect(merged?.squares).toEqual([{ x: 4, y: 4 }]); + expect(merged?.triangles).toEqual(base.triangles); + expect(merged?.circles).toEqual(base.circles); + }); + + it('без базовой разметки — курсор на пустой разметке', () => { + const merged = addCursor(undefined, { x: 0, y: 0 }); + expect(merged?.squares).toEqual([{ x: 0, y: 0 }]); + expect(merged?.labels).toEqual([]); + }); + + it('не мутирует базовую разметку', () => { + addCursor(base, { x: 4, y: 4 }); + expect(base.squares).toEqual([]); + }); +}); diff --git a/apps/web/src/lib/keyboard.ts b/apps/web/src/lib/keyboard.ts new file mode 100644 index 0000000..57f0a0c --- /dev/null +++ b/apps/web/src/lib/keyboard.ts @@ -0,0 +1,71 @@ +/** + * Клавиатурный ввод доски (этап 7, контракт INTERFACES.md «Полировка»): + * чистый редьюсер курсора-крестика, без DOM. Курсор стартует с центра доски, + * стрелки двигают его с клампом по краям; подтверждение хода идёт через + * существующий reduceInput (tap по курсору → фантом → повтор → commit) — + * это делают тонкие DOM-подписки в ui/play-page.ts и ui/task-widget.ts. + */ +import type { BoardSize, NodeMarkup, Point } from '@go-learn/core'; + +export type CursorKey = 'up' | 'down' | 'left' | 'right'; + +/** KeyboardEvent.key → направление стрелки; null — клавиша не стрелка. */ +export function arrowOf(key: string): CursorKey | null { + switch (key) { + case 'ArrowUp': + return 'up'; + case 'ArrowDown': + return 'down'; + case 'ArrowLeft': + return 'left'; + case 'ArrowRight': + return 'right'; + default: + return null; + } +} + +/** Стартовая позиция курсора — центр доски. */ +export function centerCursor(size: BoardSize): Point { + const center = Math.floor(size / 2); + return { x: center, y: center }; +} + +const DELTAS: Readonly> = { + up: { x: 0, y: -1 }, + down: { x: 0, y: 1 }, + left: { x: -1, y: 0 }, + right: { x: 1, y: 0 }, +}; + +/** Сдвиг курсора на одно пересечение с клампом по краям доски. */ +export function reduceCursor(cursor: Point, key: CursorKey, size: BoardSize): Point { + const delta = DELTAS[key]; + return { + x: Math.min(size - 1, Math.max(0, cursor.x + delta.x)), + y: Math.min(size - 1, Math.max(0, cursor.y + delta.y)), + }; +} + +/** Прижатие курсора к доске (нужно после смены размера доски). */ +export function clampCursor(cursor: Point, size: BoardSize): Point { + return { + x: Math.min(size - 1, Math.max(0, cursor.x)), + y: Math.min(size - 1, Math.max(0, cursor.y)), + }; +} + +const EMPTY_MARKUP: NodeMarkup = { labels: [], triangles: [], squares: [], circles: [] }; + +/** + * Курсор — квадратный маркер поверх существующей разметки (подсказки, цели + * задачи, разметка SGF). cursor === null → разметка без изменений. + */ +export function addCursor( + base: NodeMarkup | undefined, + cursor: Point | null, +): NodeMarkup | undefined { + if (cursor === null) return base; + const markup = base ?? EMPTY_MARKUP; + return { ...markup, squares: [...markup.squares, cursor] }; +} diff --git a/apps/web/src/lib/storage.test.ts b/apps/web/src/lib/storage.test.ts new file mode 100644 index 0000000..1681861 --- /dev/null +++ b/apps/web/src/lib/storage.test.ts @@ -0,0 +1,337 @@ +/** + * Тесты lib/storage.ts: roundtrip, порча JSON/checksum/version → дефолты + + * recovered, стабильность checksumOf. Хранилище — in-memory заглушка + * (Node 20 без localStorage), инжектируется вторым аргументом. + */ +import { describe, expect, it } from 'vitest'; +import type { Clock, ProgressV1, ProgressV2, StorageEnvelope, StorageLike } from './storage.js'; +import { + checksumOf, + DEFAULT_PROGRESS, + DEFAULT_SETTINGS, + isProgressPayload, + loadProgress, + loadSettings, + markLessonDone, + PROGRESS_KEY, + PROGRESS_VERSION, + saveProgress, + saveSettings, + SETTINGS_KEY, + SETTINGS_VERSION, + touchStreak, + writeProgressSnapshot, +} from './storage.js'; + +function memoryStorage(initial: Record = {}): StorageLike & { + data: Record; +} { + const data: Record = { ...initial }; + return { + data, + getItem: (key) => (key in data ? (data[key] ?? null) : null), + setItem: (key, value): void => { + data[key] = value; + }, + }; +} + +describe('storage: roundtrip и дефолты', () => { + it('нет ключа → дефолты без recovered', () => { + const { settings, recovered } = loadSettings(memoryStorage()); + expect(settings).toEqual(DEFAULT_SETTINGS); + expect(recovered).toBe(false); + }); + + it('save → load даёт идентичные настройки', () => { + const storage = memoryStorage(); + const custom = { + ...DEFAULT_SETTINGS, + size: 19 as const, + level: 5 as const, + komi: 7.5, + hintsEnabled: true, + }; + saveSettings(custom, storage); + const { settings, recovered } = loadSettings(storage); + expect(settings).toEqual(custom); + expect(recovered).toBe(false); + }); +}); + +describe('storage: порча данных', () => { + it('битый JSON → дефолты + recovered', () => { + const storage = memoryStorage({ [SETTINGS_KEY]: '{не json' }); + const { settings, recovered } = loadSettings(storage); + expect(settings).toEqual(DEFAULT_SETTINGS); + expect(recovered).toBe(true); + }); + + it('несовпадение checksum → дефолты + recovered', () => { + const good = JSON.stringify(DEFAULT_SETTINGS); + const envelope: StorageEnvelope = { + version: SETTINGS_VERSION, + checksum: checksumOf(good), + payload: { ...DEFAULT_SETTINGS, level: 6 }, + }; + const storage = memoryStorage({ [SETTINGS_KEY]: JSON.stringify(envelope) }); + const { settings, recovered } = loadSettings(storage); + expect(settings).toEqual(DEFAULT_SETTINGS); + expect(recovered).toBe(true); + }); + + it('неизвестная version → дефолты + recovered', () => { + const envelope: StorageEnvelope = { + version: 99, + checksum: checksumOf(JSON.stringify(DEFAULT_SETTINGS)), + payload: DEFAULT_SETTINGS, + }; + const storage = memoryStorage({ [SETTINGS_KEY]: JSON.stringify(envelope) }); + const { settings, recovered } = loadSettings(storage); + expect(settings).toEqual(DEFAULT_SETTINGS); + expect(recovered).toBe(true); + }); + + it('payload неверной схемы → дефолты + recovered', () => { + const payload = { ...DEFAULT_SETTINGS, level: 42 }; + const envelope: StorageEnvelope = { + version: SETTINGS_VERSION, + checksum: checksumOf(JSON.stringify(payload)), + payload, + }; + const storage = memoryStorage({ [SETTINGS_KEY]: JSON.stringify(envelope) }); + const { settings, recovered } = loadSettings(storage); + expect(settings).toEqual(DEFAULT_SETTINGS); + expect(recovered).toBe(true); + }); +}); + +describe('checksumOf', () => { + it('стабилен для одинакового входа и различен для разного', () => { + const json = JSON.stringify(DEFAULT_SETTINGS); + expect(checksumOf(json)).toBe(checksumOf(json)); + expect(checksumOf(json)).not.toBe(checksumOf(json + ' ')); + expect(checksumOf(json)).toMatch(/^[0-9a-f]+$/); + }); +}); + +/* ── Прогресс курса (этапы 6 и 8) ── */ + +/** Фиксированные часы для детерминированных тестов (этап 8, Clock-инжекция). */ +const FIXED_ISO = '2026-08-06T12:00:00.000Z'; +const fixedClock: Clock = () => FIXED_ISO; + +const SAMPLE_PROGRESS_V1: ProgressV1 = { + lessons: { 'dame-intro': { done: true, tasksSolved: ['lsn-dame-1', 'lsn-dame-2'] } }, + tasks: { + 'lsn-dame-1': { solved: true, attempts: 2 }, + 'tsm-atari-1': { solved: false, attempts: 3 }, + }, + streak: { lastDay: '2026-08-05', current: 4 }, +}; + +const SAMPLE_PROGRESS: ProgressV2 = { ...SAMPLE_PROGRESS_V1, updatedAt: FIXED_ISO }; + +describe('storage: прогресс, roundtrip и дефолты', () => { + it('нет ключа → дефолты без recovered', () => { + const { progress, recovered } = loadProgress(memoryStorage()); + expect(progress).toEqual(DEFAULT_PROGRESS); + expect(recovered).toBe(false); + }); + + it('roundtrip V2: save (фикс-часы) → load даёт идентичный прогресс', () => { + const storage = memoryStorage(); + saveProgress(SAMPLE_PROGRESS_V1, storage, fixedClock); + const { progress, recovered } = loadProgress(storage, fixedClock); + expect(progress).toEqual(SAMPLE_PROGRESS); + expect(recovered).toBe(false); + }); + + it('saveProgress перевыставляет updatedAt при каждой записи (Clock-инжекция)', () => { + const storage = memoryStorage(); + saveProgress(SAMPLE_PROGRESS, storage, () => '2026-08-07T08:30:00.000Z'); + const { progress } = loadProgress(storage, fixedClock); + expect(progress.updatedAt).toBe('2026-08-07T08:30:00.000Z'); + }); + + it('writeProgressSnapshot НЕ сдвигает updatedAt (снимок sync-клиента)', () => { + const storage = memoryStorage(); + writeProgressSnapshot(SAMPLE_PROGRESS, storage); + const { progress } = loadProgress(storage, fixedClock); + expect(progress).toEqual(SAMPLE_PROGRESS); + }); + + it('миграция V1→V2: updatedAt = момент миграции, поля сохранены', () => { + const envelope: StorageEnvelope = { + version: PROGRESS_VERSION, + checksum: checksumOf(JSON.stringify(SAMPLE_PROGRESS_V1)), + payload: SAMPLE_PROGRESS_V1, + }; + const storage = memoryStorage({ [PROGRESS_KEY]: JSON.stringify(envelope) }); + const { progress, recovered } = loadProgress(storage, fixedClock); + expect(progress).toEqual({ ...SAMPLE_PROGRESS_V1, updatedAt: FIXED_ISO }); + expect(recovered).toBe(false); + // Roundtrip мигрированного прогресса: запись → чтение без изменений. + saveProgress(progress, storage, fixedClock); + expect(loadProgress(storage, fixedClock).progress).toEqual(progress); + }); + + it('испорченный updatedAt (не строка) → дефолты + recovered', () => { + const payload = { ...SAMPLE_PROGRESS_V1, updatedAt: 42 }; + const envelope: StorageEnvelope = { + version: PROGRESS_VERSION, + checksum: checksumOf(JSON.stringify(payload)), + payload, + }; + const storage = memoryStorage({ [PROGRESS_KEY]: JSON.stringify(envelope) }); + const { progress, recovered } = loadProgress(storage, fixedClock); + expect(progress).toEqual(DEFAULT_PROGRESS); + expect(recovered).toBe(true); + }); + + it('битый JSON → дефолты + recovered', () => { + const storage = memoryStorage({ [PROGRESS_KEY]: '{не json' }); + const { progress, recovered } = loadProgress(storage); + expect(progress).toEqual(DEFAULT_PROGRESS); + expect(recovered).toBe(true); + }); + + it('несовпадение checksum → дефолты + recovered', () => { + const envelope: StorageEnvelope = { + version: PROGRESS_VERSION, + checksum: 'deadbeef', + payload: SAMPLE_PROGRESS, + }; + const storage = memoryStorage({ [PROGRESS_KEY]: JSON.stringify(envelope) }); + const { progress, recovered } = loadProgress(storage); + expect(progress).toEqual(DEFAULT_PROGRESS); + expect(recovered).toBe(true); + }); + + it('неизвестная version → дефолты + recovered', () => { + const envelope: StorageEnvelope = { + version: 99, + checksum: checksumOf(JSON.stringify(SAMPLE_PROGRESS)), + payload: SAMPLE_PROGRESS, + }; + const storage = memoryStorage({ [PROGRESS_KEY]: JSON.stringify(envelope) }); + const { progress, recovered } = loadProgress(storage); + expect(progress).toEqual(DEFAULT_PROGRESS); + expect(recovered).toBe(true); + }); + + it('payload неверной схемы → дефолты + recovered', () => { + const payload = { ...SAMPLE_PROGRESS, streak: { lastDay: 42 } }; + const envelope: StorageEnvelope = { + version: PROGRESS_VERSION, + checksum: checksumOf(JSON.stringify(payload)), + payload, + }; + const storage = memoryStorage({ [PROGRESS_KEY]: JSON.stringify(envelope) }); + const { progress, recovered } = loadProgress(storage); + expect(progress).toEqual(DEFAULT_PROGRESS); + expect(recovered).toBe(true); + }); + + it('ключи настроек и прогресса не пересекаются', () => { + const storage = memoryStorage(); + saveSettings(DEFAULT_SETTINGS, storage); + saveProgress(SAMPLE_PROGRESS, storage, fixedClock); + expect(loadSettings(storage).settings).toEqual(DEFAULT_SETTINGS); + expect(loadProgress(storage, fixedClock).progress).toEqual(SAMPLE_PROGRESS); + }); + + it('isProgressPayload: валидный прогресс проходит, мусор — нет', () => { + expect(isProgressPayload(SAMPLE_PROGRESS_V1)).toBe(true); + expect(isProgressPayload(SAMPLE_PROGRESS)).toBe(true); + expect(isProgressPayload(null)).toBe(false); + expect(isProgressPayload({ lessons: {} })).toBe(false); + }); +}); + +describe('storage: touchStreak', () => { + const base: ProgressV1 = { + ...DEFAULT_PROGRESS, + streak: { lastDay: '2026-08-04', current: 3 }, + }; + + it('вчера → current+1 и lastDay = today', () => { + const next = touchStreak(base, '2026-08-05'); + expect(next.streak).toEqual({ lastDay: '2026-08-05', current: 4 }); + }); + + it('сегодня → без изменений (тот же объект)', () => { + expect(touchStreak(base, '2026-08-04')).toBe(base); + }); + + it('разрыв (позавчера и раньше) → 1', () => { + const next = touchStreak(base, '2026-08-07'); + expect(next.streak).toEqual({ lastDay: '2026-08-07', current: 1 }); + }); + + it('пустой lastDay (первый день) → 1', () => { + const next = touchStreak(DEFAULT_PROGRESS, '2026-08-05'); + expect(next.streak).toEqual({ lastDay: '2026-08-05', current: 1 }); + }); + + it('переход через границу месяца считается корректно', () => { + const prev: ProgressV1 = { + ...DEFAULT_PROGRESS, + streak: { lastDay: '2026-07-31', current: 9 }, + }; + expect(touchStreak(prev, '2026-08-01').streak).toEqual({ + lastDay: '2026-08-01', + current: 10, + }); + }); + + it('битая today → серия сбрасывается в 1', () => { + const next = touchStreak(base, 'не дата'); + expect(next.streak).toEqual({ lastDay: 'не дата', current: 1 }); + }); +}); + +describe('storage: markLessonDone', () => { + const withTasks: ProgressV1 = { + ...DEFAULT_PROGRESS, + lessons: { '01-dame-i-zahvat': { done: false, tasksSolved: ['lsn-01-a'] } }, + }; + + it('новый урок → done=true, пустой tasksSolved, streak тронут', () => { + const next = markLessonDone(DEFAULT_PROGRESS, '07-ugly-storony-tsentr', '2026-08-06'); + expect(next.lessons['07-ugly-storony-tsentr']).toEqual({ done: true, tasksSolved: [] }); + expect(next.streak).toEqual({ lastDay: '2026-08-06', current: 1 }); + }); + + it('tasksSolved авто-прогресса (этап 6) сохраняются', () => { + const next = markLessonDone(withTasks, '01-dame-i-zahvat', '2026-08-06'); + expect(next.lessons['01-dame-i-zahvat']).toEqual({ + done: true, + tasksSolved: ['lsn-01-a'], + }); + }); + + it('повторная отметка в тот же день не меняет запись урока', () => { + const done: ProgressV1 = { + ...DEFAULT_PROGRESS, + lessons: { '01-dame-i-zahvat': { done: true, tasksSolved: ['lsn-01-a'] } }, + streak: { lastDay: '2026-08-05', current: 2 }, + }; + const same = markLessonDone(done, '01-dame-i-zahvat', '2026-08-05'); + expect(same.lessons['01-dame-i-zahvat']).toEqual(done.lessons['01-dame-i-zahvat']); + expect(same.streak).toEqual({ lastDay: '2026-08-05', current: 2 }); + const nextDay = markLessonDone(done, '01-dame-i-zahvat', '2026-08-06'); + expect(nextDay.streak).toEqual({ lastDay: '2026-08-06', current: 3 }); + }); + + it('прогресс других уроков и задач не затрагивается', () => { + const base: ProgressV1 = { + lessons: { '01-dame-i-zahvat': { done: true, tasksSolved: [] } }, + tasks: { 'lsn-01-a': { solved: true, attempts: 1 } }, + streak: { lastDay: '', current: 0 }, + }; + const next = markLessonDone(base, '02-atari-lestnitsa-set', '2026-08-06'); + expect(next.lessons['01-dame-i-zahvat']).toEqual({ done: true, tasksSolved: [] }); + expect(next.tasks).toEqual(base.tasks); + }); +}); diff --git a/apps/web/src/lib/storage.ts b/apps/web/src/lib/storage.ts new file mode 100644 index 0000000..a2929ff --- /dev/null +++ b/apps/web/src/lib/storage.ts @@ -0,0 +1,342 @@ +/** + * Хранение настроек в localStorage (контракт — docs/INTERFACES.md, «Схема + * localStorage»). Конверт {version, checksum, payload}; checksum — FNV-1a hex + * от JSON.stringify(payload). Битое состояние не роняет приложение: чтение + * возвращает дефолты с пометкой recovered. Запись — только через + * saveSettings. DOM не используется напрямую: хранилище инжектируется + * (в браузере — глобальный localStorage, в тестах — заглушка). + */ +import type { BoardSize } from '@go-learn/core'; + +export interface StorageEnvelope { + readonly version: number; + readonly checksum: string; + readonly payload: T; +} + +export interface SettingsV1 { + readonly size: BoardSize; + readonly level: 1 | 2 | 3 | 4 | 5 | 6; + readonly playerColor: 'black' | 'white' | 'random'; + readonly komi: number | 'auto'; + readonly showCoordinates: boolean; + readonly showMoveNumbers: boolean; + readonly hintsEnabled: boolean; +} + +/** Минимальный интерфейс хранилища (совместим с DOM Storage). */ +export interface StorageLike { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +export const SETTINGS_KEY = 'go-learn:settings'; +export const SETTINGS_VERSION = 1; + +export const DEFAULT_SETTINGS: SettingsV1 = { + size: 9, + level: 2, + playerColor: 'black', + komi: 'auto', + showCoordinates: true, + showMoveNumbers: false, + hintsEnabled: false, +}; + +/** FNV-1a (32-бит), hex без ведущих нулей — стабилен для одинакового json. */ +export function checksumOf(json: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < json.length; index += 1) { + hash ^= json.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16); +} + +/** Хранилище по умолчанию — глобальный localStorage; null, если недоступен. */ +function defaultStorage(): StorageLike | null { + try { + const candidate = (globalThis as { localStorage?: StorageLike }).localStorage; + return candidate ?? null; + } catch { + return null; // доступ к localStorage может кидать (приватный режим и т.п.) + } +} + +function isBoardSize(value: unknown): value is BoardSize { + return value === 9 || value === 13 || value === 19; +} + +function isLevel(value: unknown): value is SettingsV1['level'] { + return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 6; +} + +/** Структурная валидация payload: неизвестные поля игнорируются. */ +function isSettingsV1(value: unknown): value is SettingsV1 { + if (typeof value !== 'object' || value === null) return false; + const record = value as Record; + const colors = ['black', 'white', 'random']; + return ( + isBoardSize(record['size']) && + isLevel(record['level']) && + colors.includes(record['playerColor'] as string) && + (record['komi'] === 'auto' || typeof record['komi'] === 'number') && + typeof record['showCoordinates'] === 'boolean' && + typeof record['showMoveNumbers'] === 'boolean' && + typeof record['hintsEnabled'] === 'boolean' + ); +} + +/** Разбор конверта; null — любая порча (JSON, checksum, version, схема). */ +function parseEnvelope( + raw: string, + version: number, + guard: (value: unknown) => value is T, +): T | null { + let envelope: StorageEnvelope; + try { + envelope = JSON.parse(raw) as StorageEnvelope; + } catch { + return null; + } + if (typeof envelope !== 'object' || envelope === null) return null; + if (envelope.version !== version) return null; + if (checksumOf(JSON.stringify(envelope.payload)) !== envelope.checksum) return null; + return guard(envelope.payload) ? envelope.payload : null; +} + +/** Общая запись конверта; без хранилища — тихий no-op. */ +function writeEnvelope(key: string, version: number, payload: T, storage?: StorageLike): void { + const store = storage ?? defaultStorage(); + if (store === null) return; + const payloadJson = JSON.stringify(payload); + const envelope: StorageEnvelope = { + version, + checksum: checksumOf(payloadJson), + payload, + }; + store.setItem(key, JSON.stringify(envelope)); +} + +/** + * Чтение настроек: нет ключа → дефолты (recovered: false); битый JSON / + * несовпадение checksum / неизвестная version → дефолты + recovered: true. + */ +export function loadSettings(storage?: StorageLike): { settings: SettingsV1; recovered: boolean } { + const store = storage ?? defaultStorage(); + if (store === null) return { settings: DEFAULT_SETTINGS, recovered: false }; + const raw = store.getItem(SETTINGS_KEY); + if (raw === null) return { settings: DEFAULT_SETTINGS, recovered: false }; + const parsed = parseEnvelope(raw, SETTINGS_VERSION, isSettingsV1); + if (parsed === null) return { settings: DEFAULT_SETTINGS, recovered: true }; + return { settings: parsed, recovered: false }; +} + +/** Запись настроек с пересчётом checksum; без хранилища — тихий no-op. */ +export function saveSettings(settings: SettingsV1, storage?: StorageLike): void { + writeEnvelope(SETTINGS_KEY, SETTINGS_VERSION, settings, storage); +} + +/* ── Прогресс курса (этап 6, контракт «Прогресс курса» в INTERFACES.md) ── */ + +export interface LessonProgress { + readonly done: boolean; + readonly tasksSolved: readonly string[]; +} + +export interface TaskProgress { + readonly solved: boolean; + readonly attempts: number; +} + +export interface StreakV1 { + readonly lastDay: string; // 'YYYY-MM-DD' последней активности + readonly current: number; // дней подряд +} + +export interface ProgressV1 { + readonly lessons: Readonly>; // key = lesson id + readonly tasks: Readonly>; // key = task id + readonly streak: StreakV1; +} + +/* ── Прогресс V2 и синхронизация (этап 8, INTERFACES.md «Личный кабинет») ── */ + +/** + * Источник метки времени (ISO). Инжектируется параметром: чистые функции + * сами Date/Date.now не вызывают (контракт этапа 8). + */ +export type Clock = () => string; + +/** Часы по умолчанию — текущее время в ISO (UTC-суффикс Z). */ +const defaultClock: Clock = () => new Date().toISOString(); + +/** + * ProgressV2 = ProgressV1 + updatedAt (ISO локального изменения). Пустая + * строка — «локальных изменений ещё не было» (дефолты без ключа): sync-client + * трактует её как отсутствие локальной метки. Ключ и конверт — те же, что у + * V1; миграция V1→V2 — при чтении (updatedAt = момент миграции), до дефолтов. + */ +export interface ProgressV2 extends ProgressV1 { + readonly updatedAt: string; +} + +export const PROGRESS_KEY = 'go-learn:progress'; +export const PROGRESS_VERSION = 1; + +export const DEFAULT_PROGRESS: ProgressV2 = { + lessons: {}, + tasks: {}, + streak: { lastDay: '', current: 0 }, + updatedAt: '', +}; + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); +} + +function isLessonProgress(value: unknown): value is LessonProgress { + if (typeof value !== 'object' || value === null) return false; + const record = value as Record; + return typeof record['done'] === 'boolean' && isStringArray(record['tasksSolved']); +} + +function isTaskProgress(value: unknown): value is TaskProgress { + if (typeof value !== 'object' || value === null) return false; + const record = value as Record; + return typeof record['solved'] === 'boolean' && typeof record['attempts'] === 'number'; +} + +function isRecordOf(value: unknown, guard: (item: unknown) => item is T): boolean { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + return Object.values(value).every((item) => guard(item)); +} + +/** Структурная валидация payload прогресса (V1-поля; updatedAt не требуется). */ +function isProgressV1(value: unknown): value is ProgressV1 { + if (typeof value !== 'object' || value === null) return false; + const record = value as Record; + const streak = record['streak']; + if (typeof streak !== 'object' || streak === null) return false; + const streakRecord = streak as Record; + return ( + isRecordOf(record['lessons'], isLessonProgress) && + isRecordOf(record['tasks'], isTaskProgress) && + typeof streakRecord['lastDay'] === 'string' && + typeof streakRecord['current'] === 'number' + ); +} + +/** + * Публичная проверка формы прогресса — для sync-client (валидация payload, + * пришедшего с сервера, до локальной записи). updatedAt не проверяется: + * серверная метка подставляется отдельно. + */ +export function isProgressPayload(value: unknown): value is ProgressV1 { + return isProgressV1(value); +} + +/** + * Миграция V1→V2: payload без updatedAt получает updatedAt = clock() + * (момент миграции); payload с updatedAt-строкой — уже V2. null — updatedAt + * испорчен (не строка): дальше сработают дефолты с recovered. + */ +function migrateProgressV2(payload: ProgressV1, clock: Clock): ProgressV2 | null { + const updatedAt = (payload as unknown as Record)['updatedAt']; + if (updatedAt === undefined) return { ...payload, updatedAt: clock() }; + if (typeof updatedAt === 'string') return payload as ProgressV2; + return null; +} + +/** + * Чтение прогресса: нет ключа → дефолты (recovered: false); payload V1 → + * миграция в V2 (до дефолтов, контракт конверта); битый JSON / несовпадение + * checksum / неизвестная version / испорченный updatedAt → дефолты + + * recovered: true. Clock инжектируется для детерминированных тестов. + */ +export function loadProgress( + storage?: StorageLike, + clock: Clock = defaultClock, +): { + progress: ProgressV2; + recovered: boolean; +} { + const store = storage ?? defaultStorage(); + if (store === null) return { progress: DEFAULT_PROGRESS, recovered: false }; + const raw = store.getItem(PROGRESS_KEY); + if (raw === null) return { progress: DEFAULT_PROGRESS, recovered: false }; + const parsed = parseEnvelope(raw, PROGRESS_VERSION, isProgressV1); + if (parsed === null) return { progress: DEFAULT_PROGRESS, recovered: true }; + const migrated = migrateProgressV2(parsed, clock); + if (migrated === null) return { progress: DEFAULT_PROGRESS, recovered: true }; + return { progress: migrated, recovered: false }; +} + +/** + * Запись прогресса: updatedAt перевыставляется в clock() при КАЖДОЙ записи + * (контракт этапа 8) — через saveProgress проходят все сохранители + * (авто-прогресс задач, markLessonDone-страница урока). Чистые функции + * (touchStreak, markLessonDone) updatedAt не трогают: штамп — здесь. + * Без хранилища — тихий no-op. + */ +export function saveProgress( + progress: ProgressV1, + storage?: StorageLike, + clock: Clock = defaultClock, +): void { + writeEnvelope(PROGRESS_KEY, PROGRESS_VERSION, { ...progress, updatedAt: clock() }, storage); +} + +/** + * Прямая запись снимка V2 БЕЗ сдвига updatedAt. Только для sync-client + * (pull и подтверждение push): серверная метка сохраняется как есть, иначе + * следующая сверка увидела бы «локальное изменение» и ушла бы в ложный push. + * Обычные правки прогресса — через saveProgress. + */ +export function writeProgressSnapshot(progress: ProgressV2, storage?: StorageLike): void { + writeEnvelope(PROGRESS_KEY, PROGRESS_VERSION, progress, storage); +} + +/** Вчерашняя дата относительно 'YYYY-MM-DD' (UTC-арифметика, без DST). */ +function previousDay(today: string): string | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(today); + if (match === null) return null; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const time = Date.UTC(year, month - 1, day); + const date = new Date(time); + // Отсекаем невалидные даты вида 2026-02-31 (Date их «прокручивает»). + if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1) return null; + const prev = new Date(time - 86_400_000); + const yyyy = prev.getUTCFullYear(); + const mm = String(prev.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(prev.getUTCDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; +} + +/** + * Серия дней (чистая): lastDay == today → без изменений; lastDay == вчера → + * current+1; иначе (разрыв, пустая или битая дата) → 1. today — 'YYYY-MM-DD' + * локальной даты. + */ +export function touchStreak(progress: ProgressV1, today: string): ProgressV1 { + const { streak } = progress; + if (streak.lastDay === today) return progress; + const current = previousDay(today) === streak.lastDay ? streak.current + 1 : 1; + return { ...progress, streak: { lastDay: today, current } }; +} + +/** + * Ручная отметка «урок пройден» (этап 7, чистая): lessons[id].done = true, + * tasksSolved сохраняются, серия дней — через touchStreak. today — 'YYYY-MM-DD' + * локальной даты. Уже пройденный урок — без изменений записи урока. + */ +export function markLessonDone(progress: ProgressV1, lessonId: string, today: string): ProgressV1 { + const lesson = progress.lessons[lessonId]; + const lessons = { + ...progress.lessons, + [lessonId]: { done: true, tasksSolved: lesson?.tasksSolved ?? [] }, + }; + return touchStreak({ ...progress, lessons }, today); +} diff --git a/apps/web/src/lib/sync-client.test.ts b/apps/web/src/lib/sync-client.test.ts new file mode 100644 index 0000000..5cb698f --- /dev/null +++ b/apps/web/src/lib/sync-client.test.ts @@ -0,0 +1,420 @@ +/** + * Тесты lib/sync-client.ts (этап 8): mergeDecision — все ветки чистой + * функции; syncProgress и auth-обёртки — fetch-моки (Node 20: глобальный + * Response доступен). Хранилище — in-memory заглушка, как в storage.test.ts. + */ +import { describe, expect, it, vi } from 'vitest'; +import type { ProgressV2, StorageLike } from './storage.js'; +import { loadProgress, saveProgress } from './storage.js'; +import type { FetchLike } from './sync-client.js'; +import { + confirmPasswordReset, + login, + logout, + me, + mergeDecision, + register, + requestPasswordReset, + syncProgress, +} from './sync-client.js'; + +function memoryStorage(initial: Record = {}): StorageLike & { + data: Record; +} { + const data: Record = { ...initial }; + return { + data, + getItem: (key) => (key in data ? (data[key] ?? null) : null), + setItem: (key, value): void => { + data[key] = value; + }, + }; +} + +/** fetch-мок из таблицы «url+method → Response»; throw — сетевая ошибка. */ +function fetchMock(handler: (url: string, init?: RequestInit) => Response): FetchLike { + return vi.fn((url: string, init?: RequestInit) => Promise.resolve(handler(url, init))); +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +const LOCAL_ISO = '2026-08-06T12:00:00.000Z'; +const REMOTE_NEWER_ISO = '2026-08-06T13:00:00.000Z'; +const REMOTE_OLDER_ISO = '2026-08-06T11:00:00.000Z'; + +const SAMPLE: ProgressV2 = { + lessons: { '01-dame-i-zahvat': { done: true, tasksSolved: ['lsn-01-a'] } }, + tasks: { 'lsn-01-a': { solved: true, attempts: 1 } }, + streak: { lastDay: '2026-08-06', current: 1 }, + updatedAt: LOCAL_ISO, +}; + +describe('mergeDecision: все ветки', () => { + it('сервер новее → pull', () => { + expect(mergeDecision(LOCAL_ISO, { updatedAt: REMOTE_NEWER_ISO })).toBe('pull'); + }); + + it('локаль новее → push', () => { + expect(mergeDecision(LOCAL_ISO, { updatedAt: REMOTE_OLDER_ISO })).toBe('push'); + }); + + it('метки равны → noop', () => { + expect(mergeDecision(LOCAL_ISO, { updatedAt: LOCAL_ISO })).toBe('noop'); + }); + + it('обе метки отсутствуют → noop', () => { + expect(mergeDecision(null, null)).toBe('noop'); + }); + + it('сервера нет (null), локаль есть → push', () => { + expect(mergeDecision(LOCAL_ISO, null)).toBe('push'); + }); + + it('локали нет, сервер есть → pull', () => { + expect(mergeDecision(null, { updatedAt: REMOTE_NEWER_ISO })).toBe('pull'); + }); + + it('remote unauthorized → unauthorized', () => { + expect(mergeDecision(LOCAL_ISO, 'unauthorized')).toBe('unauthorized'); + expect(mergeDecision(null, 'unauthorized')).toBe('unauthorized'); + }); + + it('remote unavailable → unavailable', () => { + expect(mergeDecision(LOCAL_ISO, 'unavailable')).toBe('unavailable'); + expect(mergeDecision(null, 'unavailable')).toBe('unavailable'); + }); +}); + +describe('syncProgress: GET-ветки', () => { + it('401 → unauthorized, локаль не тронута', async () => { + const storage = memoryStorage(); + saveProgress(SAMPLE, storage, () => LOCAL_ISO); + const result = await syncProgress({ + storage, + fetch: fetchMock(() => new Response(null, { status: 401 })), + }); + expect(result).toBe('unauthorized'); + expect(loadProgress(storage).progress).toEqual(SAMPLE); + }); + + it('сетевая ошибка → unavailable', async () => { + const doFetch: FetchLike = () => Promise.reject(new Error('network down')); + expect(await syncProgress({ storage: memoryStorage(), fetch: doFetch })).toBe('unavailable'); + }); + + it('404 и 500 → unavailable', async () => { + for (const status of [404, 500]) { + const result = await syncProgress({ + storage: memoryStorage(), + fetch: fetchMock(() => new Response(null, { status })), + }); + expect(result).toBe('unavailable'); + } + }); + + it('200 с битым payload → unavailable, локаль не тронута', async () => { + const storage = memoryStorage(); + saveProgress(SAMPLE, storage, () => LOCAL_ISO); + const result = await syncProgress({ + storage, + fetch: fetchMock(() => jsonResponse(200, { payload: { junk: 1 }, updatedAt: LOCAL_ISO })), + }); + expect(result).toBe('unavailable'); + expect(loadProgress(storage).progress).toEqual(SAMPLE); + }); +}); + +describe('syncProgress: pull / push / noop', () => { + it('сервер новее → pulled, локаль перезаписана серверным снимком', async () => { + const storage = memoryStorage(); + saveProgress(SAMPLE, storage, () => LOCAL_ISO); + const remotePayload: ProgressV2 = { + ...SAMPLE, + streak: { lastDay: '2026-08-06', current: 7 }, + updatedAt: REMOTE_NEWER_ISO, + }; + const put = vi.fn(); + const doFetch: FetchLike = (url, init) => { + if (init?.method === 'PUT') { + put(url, init); + return Promise.resolve(jsonResponse(200, { updatedAt: REMOTE_NEWER_ISO })); + } + return Promise.resolve( + jsonResponse(200, { payload: remotePayload, updatedAt: REMOTE_NEWER_ISO }), + ); + }; + const result = await syncProgress({ storage, fetch: doFetch }); + expect(result).toBe('pulled'); + expect(put).not.toHaveBeenCalled(); + // Локаль = серверный payload с серверной меткой (без сдвига updatedAt). + expect(loadProgress(storage).progress).toEqual(remotePayload); + }); + + it('локаль новее → pushed, PUT с заголовком X-GoLearn-Client и {payload}', async () => { + const storage = memoryStorage(); + saveProgress(SAMPLE, storage, () => LOCAL_ISO); + const put = vi.fn((_url: string, _init?: RequestInit) => + jsonResponse(200, { updatedAt: REMOTE_OLDER_ISO }), + ); + const doFetch: FetchLike = (url, init) => { + if (init?.method === 'PUT') return Promise.resolve(put(url, init)); + return Promise.resolve(jsonResponse(200, { payload: SAMPLE, updatedAt: REMOTE_OLDER_ISO })); + }; + const result = await syncProgress({ storage, fetch: doFetch }); + expect(result).toBe('pushed'); + expect(put).toHaveBeenCalledTimes(1); + const [url, init] = put.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe('/api/progress.php'); + expect((init.headers as Record)['X-GoLearn-Client']).toBe('web'); + expect(JSON.parse(init.body as string)).toEqual({ payload: SAMPLE }); + // Серверная метка из ответа PUT принята локально → следующая сверка noop. + expect(loadProgress(storage).progress.updatedAt).toBe(REMOTE_OLDER_ISO); + }); + + it('метки равны → noop, PUT не вызывается, локаль не меняется', async () => { + const storage = memoryStorage(); + saveProgress(SAMPLE, storage, () => LOCAL_ISO); + const put = vi.fn(); + const doFetch: FetchLike = (url, init) => { + if (init?.method === 'PUT') { + put(); + return Promise.resolve(jsonResponse(200, { updatedAt: LOCAL_ISO })); + } + return Promise.resolve(jsonResponse(200, { payload: SAMPLE, updatedAt: LOCAL_ISO })); + }; + const result = await syncProgress({ storage, fetch: doFetch }); + expect(result).toBe('noop'); + expect(put).not.toHaveBeenCalled(); + expect(loadProgress(storage).progress).toEqual(SAMPLE); + }); + + it('204 (прогресса нет) + локаль есть → pushed', async () => { + const storage = memoryStorage(); + saveProgress(SAMPLE, storage, () => LOCAL_ISO); + const doFetch: FetchLike = (_url, init) => { + if (init?.method === 'PUT') + return Promise.resolve(jsonResponse(200, { updatedAt: LOCAL_ISO })); + return Promise.resolve(new Response(null, { status: 204 })); + }; + expect(await syncProgress({ storage, fetch: doFetch })).toBe('pushed'); + }); + + it('204 + локали нет → noop', async () => { + const doFetch: FetchLike = () => Promise.resolve(new Response(null, { status: 204 })); + expect(await syncProgress({ storage: memoryStorage(), fetch: doFetch })).toBe('noop'); + }); + + it('пустая локаль + сервер есть → pulled', async () => { + const storage = memoryStorage(); + const doFetch: FetchLike = () => + Promise.resolve(jsonResponse(200, { payload: SAMPLE, updatedAt: LOCAL_ISO })); + expect(await syncProgress({ storage, fetch: doFetch })).toBe('pulled'); + expect(loadProgress(storage).progress).toEqual(SAMPLE); + }); + + it('PUT отвечает 401 → unauthorized', async () => { + const storage = memoryStorage(); + saveProgress(SAMPLE, storage, () => LOCAL_ISO); + const doFetch: FetchLike = (_url, init) => { + if (init?.method === 'PUT') return Promise.resolve(new Response(null, { status: 401 })); + return Promise.resolve(new Response(null, { status: 204 })); + }; + expect(await syncProgress({ storage, fetch: doFetch })).toBe('unauthorized'); + }); +}); + +describe('auth-обёртки', () => { + const credentials = ['user@example.com', 'password-8', 'AB3CD'] as const; + + it('register: 201 → ok, тело с captcha и заголовок X-GoLearn-Client', async () => { + const doFetch = vi.fn((url: string, init?: RequestInit) => { + expect(url).toBe('/api/register.php'); + expect((init?.headers as Record)['X-GoLearn-Client']).toBe('web'); + expect(JSON.parse(init?.body as string)).toEqual({ + email: credentials[0], + password: credentials[1], + captcha: credentials[2], + }); + return Promise.resolve(jsonResponse(201, { ok: true })); + }); + expect(await register(credentials[0], credentials[1], credentials[2], doFetch)).toBe('ok'); + }); + + it('register: 409 → conflict, 422 → invalid, 429 → rate-limited, сбой → unavailable', async () => { + expect( + await register( + ...credentials, + fetchMock(() => new Response(null, { status: 409 })), + ), + ).toBe('conflict'); + expect( + await register( + ...credentials, + fetchMock(() => jsonResponse(422, { error: 'invalid' })), + ), + ).toBe('invalid'); + expect( + await register( + ...credentials, + fetchMock(() => new Response(null, { status: 422 })), + ), + ).toBe('invalid'); + expect( + await register( + ...credentials, + fetchMock(() => new Response(null, { status: 429 })), + ), + ).toBe('rate-limited'); + expect(await register(...credentials, () => Promise.reject(new Error('down')))).toBe( + 'unavailable', + ); + }); + + it('register: 422 {error:"captcha"} → captcha', async () => { + expect( + await register( + ...credentials, + fetchMock(() => jsonResponse(422, { error: 'captcha' })), + ), + ).toBe('captcha'); + }); + + it('login: 200 → ok; 401 → unauthorized; 422 → invalid; 429 → rate-limited', async () => { + expect( + await login( + credentials[0], + credentials[1], + fetchMock(() => jsonResponse(200, { ok: true })), + ), + ).toBe('ok'); + expect( + await login( + credentials[0], + credentials[1], + fetchMock(() => new Response(null, { status: 401 })), + ), + ).toBe('unauthorized'); + expect( + await login( + credentials[0], + credentials[1], + fetchMock(() => new Response(null, { status: 422 })), + ), + ).toBe('invalid'); + expect( + await login( + credentials[0], + credentials[1], + fetchMock(() => new Response(null, { status: 429 })), + ), + ).toBe('rate-limited'); + expect( + await login(credentials[0], credentials[1], () => Promise.reject(new Error('down'))), + ).toBe('unavailable'); + }); + + it('me: 200 → email; 401 → null; сбой → null', async () => { + expect(await me(fetchMock(() => jsonResponse(200, { email: credentials[0] })))).toBe( + credentials[0], + ); + expect(await me(fetchMock(() => new Response(null, { status: 401 })))).toBeNull(); + expect(await me(() => Promise.reject(new Error('down')))).toBeNull(); + }); + + it('logout: 200 → ok; 500 → unavailable', async () => { + expect(await logout(fetchMock(() => jsonResponse(200, { ok: true })))).toBe('ok'); + expect(await logout(fetchMock(() => new Response(null, { status: 500 })))).toBe('unavailable'); + }); + + it('requestPasswordReset: url, тело и заголовок X-GoLearn-Client', async () => { + const doFetch = vi.fn((url: string, init?: RequestInit) => { + expect(url).toBe('/api/password-reset/request.php'); + expect((init?.headers as Record)['X-GoLearn-Client']).toBe('web'); + expect(JSON.parse(init?.body as string)).toEqual({ email: credentials[0] }); + return Promise.resolve(jsonResponse(200, { ok: true })); + }); + expect(await requestPasswordReset(credentials[0], doFetch)).toBe('ok'); + }); + + it('requestPasswordReset: 200 → ok, 503 → mail_unavailable, 429 → rate_limited, сбой → unavailable', async () => { + expect( + await requestPasswordReset( + credentials[0], + fetchMock(() => jsonResponse(200, { ok: true })), + ), + ).toBe('ok'); + expect( + await requestPasswordReset( + credentials[0], + fetchMock(() => jsonResponse(503, { error: 'mail_unavailable' })), + ), + ).toBe('mail_unavailable'); + expect( + await requestPasswordReset( + credentials[0], + fetchMock(() => new Response(null, { status: 429 })), + ), + ).toBe('rate_limited'); + expect( + await requestPasswordReset( + credentials[0], + fetchMock(() => new Response(null, { status: 500 })), + ), + ).toBe('unavailable'); + expect( + await requestPasswordReset(credentials[0], () => Promise.reject(new Error('down'))), + ).toBe('unavailable'); + }); + + it('confirmPasswordReset: url, тело с token+password и заголовок X-GoLearn-Client', async () => { + const doFetch = vi.fn((url: string, init?: RequestInit) => { + expect(url).toBe('/api/password-reset/confirm.php'); + expect((init?.headers as Record)['X-GoLearn-Client']).toBe('web'); + expect(JSON.parse(init?.body as string)).toEqual({ + token: 'token-hex', + password: credentials[1], + }); + return Promise.resolve(jsonResponse(200, { ok: true })); + }); + expect(await confirmPasswordReset('token-hex', credentials[1], doFetch)).toBe('ok'); + }); + + it('confirmPasswordReset: 400 → token_invalid, 422 → invalid, 429 → rate_limited, сбой → unavailable', async () => { + expect( + await confirmPasswordReset( + 'token-hex', + credentials[1], + fetchMock(() => jsonResponse(400, { error: 'token_invalid' })), + ), + ).toBe('token_invalid'); + expect( + await confirmPasswordReset( + 'token-hex', + credentials[1], + fetchMock(() => new Response(null, { status: 422 })), + ), + ).toBe('invalid'); + expect( + await confirmPasswordReset( + 'token-hex', + credentials[1], + fetchMock(() => new Response(null, { status: 429 })), + ), + ).toBe('rate_limited'); + expect( + await confirmPasswordReset( + 'token-hex', + credentials[1], + fetchMock(() => new Response(null, { status: 500 })), + ), + ).toBe('unavailable'); + expect( + await confirmPasswordReset('token-hex', credentials[1], () => Promise.reject(new Error('x'))), + ).toBe('unavailable'); + }); +}); diff --git a/apps/web/src/lib/sync-client.ts b/apps/web/src/lib/sync-client.ts new file mode 100644 index 0000000..fcd5f7d --- /dev/null +++ b/apps/web/src/lib/sync-client.ts @@ -0,0 +1,296 @@ +/** + * Клиент личного кабинета и синхронизации прогресса (этап 8, контракт — + * docs/INTERFACES.md «Личный кабинет и синхронизация прогресса», ADR-0004). + * Fetch API, базовый путь '/api' — тот же origin, сессия — cookie + * (credentials: 'same-origin'). Тихая деградация: сетевая ошибка, 404 или + * 5xx → 'unavailable', UI пишет статус-строку, алертов нет. Мутирующие + * запросы несут заголовок X-GoLearn-Client: web (CSRF-мера сервера). + */ +import type { Clock, ProgressV2, StorageLike } from './storage.js'; +import { isProgressPayload, loadProgress, writeProgressSnapshot } from './storage.js'; + +/** Совместимый с fetch интерфейс; инжектируется в тестах. */ +export type FetchLike = (url: string, init?: RequestInit) => Promise; + +const API_BASE = '/api'; +const CLIENT_HEADER = { 'X-GoLearn-Client': 'web' }; +const JSON_HEADERS = { ...CLIENT_HEADER, 'Content-Type': 'application/json' }; + +/** Глобальный fetch; null — среда без fetch (старый браузер/тесты). */ +function defaultFetch(): FetchLike | null { + const candidate = (globalThis as { fetch?: FetchLike }).fetch; + return candidate !== undefined ? candidate.bind(globalThis) : null; +} + +/* ── Решение о слиянии (чистая функция) ── */ + +/** Ответ сервера о прогрессе, сведённый к метке; либо исход запроса. */ +export type RemoteState = { updatedAt: string } | null | 'unauthorized' | 'unavailable'; + +export type MergeAction = 'pull' | 'push' | 'noop' | 'unauthorized' | 'unavailable'; + +/** Сравнение ISO-меток: по времени, при невалидных — лексикографически. */ +function compareIso(a: string, b: string): number { + const timeA = Date.parse(a); + const timeB = Date.parse(b); + if (!Number.isNaN(timeA) && !Number.isNaN(timeB)) return timeA - timeB; + return a < b ? -1 : a > b ? 1 : 0; +} + +/** + * Решение о слиянии: сервер новее → 'pull'; локаль новее или сервера нет + * (null) при наличии локальной метки → 'push'; обе метки отсутствуют или + * равны → 'noop'. Сбои запроса прозрачно пробрасываются наружу. + */ +export function mergeDecision(localUpdatedAt: string | null, remote: RemoteState): MergeAction { + if (remote === 'unauthorized') return 'unauthorized'; + if (remote === 'unavailable') return 'unavailable'; + if (remote === null) return localUpdatedAt === null ? 'noop' : 'push'; + if (localUpdatedAt === null) return 'pull'; + const order = compareIso(remote.updatedAt, localUpdatedAt); + if (order > 0) return 'pull'; + if (order < 0) return 'push'; + return 'noop'; +} + +/* ── Синхронизация прогресса ── */ + +export type SyncResult = 'pulled' | 'pushed' | 'noop' | 'unauthorized' | 'unavailable'; + +/** Зависимости syncProgress; в браузере — дефолты (localStorage, fetch). */ +export interface SyncDeps { + readonly storage?: StorageLike; + readonly fetch?: FetchLike; + readonly clock?: Clock; +} + +/** Серверный ответ GET /progress: payload + серверная метка; null — 204. */ +type RemoteProgress = { payload: ProgressV2; updatedAt: string } | null; + +async function fetchRemoteProgress( + doFetch: FetchLike, +): Promise { + let response: Response; + try { + response = await doFetch(`${API_BASE}/progress.php`, { credentials: 'same-origin' }); + } catch { + return 'unavailable'; // сетевая ошибка — тихая деградация + } + if (response.status === 401) return 'unauthorized'; + if (response.status === 204) return null; // прогресса на сервере нет + if (response.status !== 200) return 'unavailable'; // 404/5xx и прочие + try { + const body = (await response.json()) as { payload?: unknown; updatedAt?: unknown }; + if (typeof body.updatedAt !== 'string' || !isProgressPayload(body.payload)) { + return 'unavailable'; // ответ не по контракту — локаль не трогаем + } + return { payload: { ...body.payload, updatedAt: body.updatedAt }, updatedAt: body.updatedAt }; + } catch { + return 'unavailable'; + } +} + +async function pushProgress( + doFetch: FetchLike, + progress: ProgressV2, + storage?: StorageLike, +): Promise { + let response: Response; + try { + response = await doFetch(`${API_BASE}/progress.php`, { + method: 'PUT', + credentials: 'same-origin', + headers: JSON_HEADERS, + body: JSON.stringify({ payload: progress }), + }); + } catch { + return 'unavailable'; + } + if (response.status === 401) return 'unauthorized'; + if (!response.ok) return 'unavailable'; // 403/413/422/5xx + // Сервер хранит свою метку updated_at: принимаем её локально снимком БЕЗ + // сдвига updatedAt — иначе следующая сверка увидит расхождение меток и + // уйдёт в ложный pull/push-цикл. + try { + const body = (await response.json()) as { updatedAt?: unknown }; + if (typeof body.updatedAt === 'string') { + writeProgressSnapshot({ ...progress, updatedAt: body.updatedAt }, storage); + } + } catch { + // Тело без updatedAt — оставляем локальную метку, синхронизация уже состоялась. + } + return 'pushed'; +} + +/** + * Синхронизация прогресса: GET → mergeDecision → pull (снимок с серверной + * меткой, без сдвига updatedAt) / push (PUT {payload}) / noop. Локальное + * хранилище остаётся SoT для неавторизованных (401 → 'unauthorized', локаль + * не меняется). + */ +export async function syncProgress(deps: SyncDeps = {}): Promise { + const doFetch = deps.fetch ?? defaultFetch(); + if (doFetch === null) return 'unavailable'; + const remote = await fetchRemoteProgress(doFetch); + if (remote === 'unauthorized' || remote === 'unavailable') return remote; + const { progress } = loadProgress(deps.storage, deps.clock); + // Пустая метка дефолтов = «локальных изменений не было» → null для сверки. + const localUpdatedAt = progress.updatedAt === '' ? null : progress.updatedAt; + const decision = mergeDecision(localUpdatedAt, remote); + switch (decision) { + case 'pull': + // remote !== null гарантирован решением 'pull'. + if (remote !== null) writeProgressSnapshot(remote.payload, deps.storage); + return 'pulled'; + case 'push': + return pushProgress(doFetch, progress, deps.storage); + case 'noop': + return 'noop'; + default: + return decision; // 'unauthorized' | 'unavailable' — недостижимо после GET + } +} + +/* ── Регистрация, вход, сессия (тонкие fetch-обёртки) ── */ + +export type AuthResult = + 'ok' | 'conflict' | 'unauthorized' | 'invalid' | 'rate-limited' | 'unavailable'; + +/** Исход регистрации: как AuthResult + 'captcha' (422 {error:"captcha"}). */ +export type RegisterResult = AuthResult | 'captcha'; + +/** Исход запроса письма восстановления (этап 8.1). */ +export type PasswordResetRequestResult = 'ok' | 'mail_unavailable' | 'rate_limited' | 'unavailable'; + +/** Исход подтверждения нового пароля по токену (этап 8.1). */ +export type PasswordResetConfirmResult = + 'ok' | 'token_invalid' | 'invalid' | 'rate_limited' | 'unavailable'; + +async function postAuth( + doFetch: FetchLike, + endpoint: string, + body: Record, +): Promise { + try { + return await doFetch(`${API_BASE}/${endpoint}`, { + method: 'POST', + credentials: 'same-origin', + headers: JSON_HEADERS, + body: JSON.stringify(body), + }); + } catch { + return 'unavailable'; + } +} + +/** + * POST /api/register.php (этап 8.1 — с капчей): 201 → ok; 409 email занят; + * 422 {error:"captcha"} → captcha (код сжигается любой попыткой — клиент + * обновляет картинку); прочие 422 → invalid; 429 лимит. + */ +export async function register( + email: string, + password: string, + captcha: string, + fetchImpl?: FetchLike, +): Promise { + const doFetch = fetchImpl ?? defaultFetch(); + if (doFetch === null) return 'unavailable'; + const response = await postAuth(doFetch, 'register.php', { email, password, captcha }); + if (response === 'unavailable') return 'unavailable'; + if (response.status === 201) return 'ok'; + if (response.status === 409) return 'conflict'; + if (response.status === 422) { + try { + const body = (await response.json()) as { error?: unknown }; + if (body.error === 'captcha') return 'captcha'; + } catch { + // Тело не по контракту — считаем общей ошибкой валидации. + } + return 'invalid'; + } + if (response.status === 429) return 'rate-limited'; + return 'unavailable'; +} + +/** POST /api/login.php: 200 → ok (+сессия-cookie); 401 неверная пара; 422; 429. */ +export async function login( + email: string, + password: string, + fetchImpl?: FetchLike, +): Promise { + const doFetch = fetchImpl ?? defaultFetch(); + if (doFetch === null) return 'unavailable'; + const response = await postAuth(doFetch, 'login.php', { email, password }); + if (response === 'unavailable') return 'unavailable'; + if (response.status === 200) return 'ok'; + if (response.status === 401) return 'unauthorized'; + if (response.status === 422) return 'invalid'; + if (response.status === 429) return 'rate-limited'; + return 'unavailable'; +} + +/** POST /api/logout.php: 200 → ok (сессия уничтожена сервером). */ +export async function logout(fetchImpl?: FetchLike): Promise<'ok' | 'unavailable'> { + const doFetch = fetchImpl ?? defaultFetch(); + if (doFetch === null) return 'unavailable'; + const response = await postAuth(doFetch, 'logout.php', {}); + if (response === 'unavailable') return 'unavailable'; + return response.status === 200 ? 'ok' : 'unavailable'; +} + +/* ── Восстановление пароля (этап 8.1) ── */ + +/** + * POST /api/password-reset/request.php {email}: 200 → ok (всегда, даже для + * незарегистрированного email — без enumeration); 503 → mail_unavailable; + * 429 → rate_limited. + */ +export async function requestPasswordReset( + email: string, + fetchImpl?: FetchLike, +): Promise { + const doFetch = fetchImpl ?? defaultFetch(); + if (doFetch === null) return 'unavailable'; + const response = await postAuth(doFetch, 'password-reset/request.php', { email }); + if (response === 'unavailable') return 'unavailable'; + if (response.status === 200) return 'ok'; + if (response.status === 503) return 'mail_unavailable'; + if (response.status === 429) return 'rate_limited'; + return 'unavailable'; +} + +/** + * POST /api/password-reset/confirm.php {token, password}: 200 → ok (пароль + * обновлён); 400 → token_invalid (ссылка недействительна/истекла); + * 422 → invalid (пароль <8); 429 → rate_limited. + */ +export async function confirmPasswordReset( + token: string, + password: string, + fetchImpl?: FetchLike, +): Promise { + const doFetch = fetchImpl ?? defaultFetch(); + if (doFetch === null) return 'unavailable'; + const response = await postAuth(doFetch, 'password-reset/confirm.php', { token, password }); + if (response === 'unavailable') return 'unavailable'; + if (response.status === 200) return 'ok'; + if (response.status === 400) return 'token_invalid'; + if (response.status === 422) return 'invalid'; + if (response.status === 429) return 'rate_limited'; + return 'unavailable'; +} + +/** GET /api/me.php: email при активной сессии; null — 401 или любой сбой. */ +export async function me(fetchImpl?: FetchLike): Promise { + const doFetch = fetchImpl ?? defaultFetch(); + if (doFetch === null) return null; + try { + const response = await doFetch(`${API_BASE}/me.php`, { credentials: 'same-origin' }); + if (response.status !== 200) return null; + const body = (await response.json()) as { email?: unknown }; + return typeof body.email === 'string' ? body.email : null; + } catch { + return null; + } +} diff --git a/apps/web/src/lib/task-session.test.ts b/apps/web/src/lib/task-session.test.ts new file mode 100644 index 0000000..b6bf08c --- /dev/null +++ b/apps/web/src/lib/task-session.test.ts @@ -0,0 +1,156 @@ +/** + * Тесты lib/task-session.ts: init (buildPosition + дерево SGF), легальный + * ход вне дерева → откат + attempts, нелегальный → 'illegal' + reason, + * ход из дерева → авто-ответ соперника → проверка goalReached, reset, hint. + */ +import { cellAt } from '@go-learn/core'; +import type { Point } from '@go-learn/core'; +import { describe, expect, it } from 'vitest'; +import { createTaskSession } from './task-session.js'; +import type { TaskV1 } from './task-session.js'; + +function p(x: number, y: number): Point { + return { x, y }; +} + +/** Белый (4,4) в атари; чёрные забирают ходом (4,5). Цель — TR (4,4). */ +const CAPTURE_TASK: TaskV1 = { + id: 'lsn-test-capture', + kind: 'lesson', + title: 'Забрать камень', + topics: ['захват'], + difficulty: 1, + size: 9, + toPlay: 'black', + setupBlack: [p(3, 4), p(5, 4), p(4, 3)], + setupWhite: [p(4, 4)], + sgf: '(;GM[1]SZ[9]TR[ee](;B[ef]))', + goal: { type: 'capture-target' }, + hint: 'У белого камня одно дамэ.', +}; + +/** Три чёрных камня (4,2),(4,4),(4,6); соединить все (CR). Дерево: + * B(4,3) → авто-ответ W(0,0) → B(4,5). */ +const CONNECT_TASK: TaskV1 = { + id: 'tsm-test-connect', + kind: 'tsumego', + title: 'Соединить три камня', + topics: ['связь'], + difficulty: 2, + size: 9, + toPlay: 'black', + setupBlack: [p(4, 2), p(4, 4), p(4, 6)], + setupWhite: [], + sgf: '(;GM[1]SZ[9]CR[ec][ee][eg];B[ed];W[aa];B[ef])', + goal: { type: 'connect' }, +}; + +describe('task-session: init', () => { + it('стартовая позиция из setup-полей, статус playing, цель из разметки', () => { + const session = createTaskSession(CAPTURE_TASK); + const snap = session.snapshot(); + expect(snap.status).toBe('playing'); + expect(snap.attempts).toBe(0); + expect(snap.state.toPlay).toBe('black'); + expect(cellAt(snap.state, p(4, 4))).toBe('white'); + expect(snap.markup.triangles).toEqual([p(4, 4)]); + expect(snap.markup.circles).toEqual([]); + }); + + it('цвет игрока — toPlay задачи (белые)', () => { + const whiteTask: TaskV1 = { + ...CAPTURE_TASK, + id: 'tsm-test-white', + toPlay: 'white', + setupBlack: [p(4, 4)], + setupWhite: [p(3, 4), p(5, 4), p(4, 3)], + sgf: '(;GM[1]SZ[9]TR[ee](;W[ef]))', + }; + const session = createTaskSession(whiteTask); + expect(session.snapshot().state.toPlay).toBe('white'); + expect(session.play(p(4, 5)).status).toBe('solved'); + }); + + it('битый контент (группа без дамэ / плохой SGF) → Error', () => { + expect(() => + createTaskSession({ ...CAPTURE_TASK, setupBlack: [...CAPTURE_TASK.setupBlack, p(4, 5)] }), + ).toThrow(); + expect(() => createTaskSession({ ...CAPTURE_TASK, sgf: '(;GM[2])' })).toThrow(); + }); +}); + +describe('task-session: play', () => { + it('нелегальный ход → illegal + reason, позиция не меняется', () => { + const session = createTaskSession(CAPTURE_TASK); + const snap = session.play(p(3, 4)); // занято чёрным + expect(snap.status).toBe('illegal'); + expect(snap.reason).toBe('occupied'); + expect(cellAt(snap.state, p(4, 4))).toBe('white'); + expect(snap.attempts).toBe(0); + }); + + it('легальный ход вне дерева → wrong + откат + attempts++', () => { + const session = createTaskSession(CAPTURE_TASK); + const snap = session.play(p(0, 0)); + expect(snap.status).toBe('wrong'); + expect(snap.reason).toBeNull(); + expect(snap.attempts).toBe(1); + expect(cellAt(snap.state, p(0, 0))).toBe('empty'); // откат + expect(cellAt(snap.state, p(4, 4))).toBe('white'); + // повторная ошибка накапливается, после неё правильный ход решает + expect(session.play(p(1, 1)).attempts).toBe(2); + expect(session.play(p(4, 5)).status).toBe('solved'); + }); + + it('ход из дерева → цель достигнута → solved, камень снят', () => { + const session = createTaskSession(CAPTURE_TASK); + const snap = session.play(p(4, 5)); + expect(snap.status).toBe('solved'); + expect(cellAt(snap.state, p(4, 4))).toBe('empty'); + expect(snap.lastMove).toEqual(p(4, 5)); + }); + + it('после solved ходы игнорируются', () => { + const session = createTaskSession(CAPTURE_TASK); + session.play(p(4, 5)); + const snap = session.play(p(0, 0)); + expect(snap.status).toBe('solved'); + expect(cellAt(snap.state, p(0, 0))).toBe('empty'); + }); + + it('ход из дерева без цели → playing + авто-ответ соперника', () => { + const session = createTaskSession(CONNECT_TASK); + const snap = session.play(p(4, 3)); + expect(snap.status).toBe('playing'); // (4,6) ещё не соединён + expect(cellAt(snap.state, p(0, 0))).toBe('white'); // авто-ответ + expect(snap.lastMove).toEqual(p(0, 0)); + expect(snap.state.toPlay).toBe('black'); + expect(session.play(p(4, 5)).status).toBe('solved'); + }); +}); + +describe('task-session: reset и hint', () => { + it('reset возвращает к стартовой позиции и обнуляет attempts', () => { + const session = createTaskSession(CAPTURE_TASK); + session.play(p(0, 0)); + session.play(p(4, 5)); + const snap = session.reset(); + expect(snap.status).toBe('playing'); + expect(snap.attempts).toBe(0); + expect(snap.lastMove).toBeNull(); + expect(cellAt(snap.state, p(4, 4))).toBe('white'); + expect(session.play(p(4, 5)).status).toBe('solved'); + }); + + it('hint: текст из task.hint + первый ход ведущего варианта', () => { + const session = createTaskSession(CAPTURE_TASK); + expect(session.hint()).toEqual({ text: 'У белого камня одно дамэ.', point: p(4, 5) }); + }); + + it('hint без task.hint → только точка первого варианта', () => { + const task: TaskV1 = { ...CAPTURE_TASK, id: 'lsn-test-nohint' }; + delete (task as { hint?: string }).hint; + const session = createTaskSession(task); + expect(session.hint()).toEqual({ text: null, point: p(4, 5) }); + }); +}); diff --git a/apps/web/src/lib/task-session.ts b/apps/web/src/lib/task-session.ts new file mode 100644 index 0000000..7859a50 --- /dev/null +++ b/apps/web/src/lib/task-session.ts @@ -0,0 +1,187 @@ +/** + * Машина состояния задачи (урок/цумэ-го, этап 6). Чистая логика без DOM: + * позиция — buildPosition из @go-learn/core (ADR-0003), дерево вариантов — + * parseSgf, критерий успеха — ТОЛЬКО goalReached (goals.ts), а не сравнение + * с координатами решения. Ход игрока вне дерева → откат + attempts++. + * Контракт — docs/INTERFACES.md, «Форматы данных уроков и задач». + */ +import { applyMove, buildPosition, parseSgf, pointKey } from '@go-learn/core'; +import type { + BoardSize, + BoardState, + Color, + GameNode, + MoveError, + NodeMarkup, + Point, +} from '@go-learn/core'; +import { goalReached } from './goals.js'; +import type { GoalSpec } from './goals.js'; + +/** Задача (контракт TaskV1). JSON-файлы коллекции tasks валидируются + * zod-схемой той же формы (apps/web/src/content.config.ts). */ +export interface TaskV1 { + readonly id: string; // уникальный kebab-case, префикс kind: 'lsn-*' | 'tsm-*' + readonly kind: 'lesson' | 'tsumego'; + readonly title: string; + readonly topics: readonly string[]; + readonly difficulty: 1 | 2 | 3 | 4 | 5; + readonly size: BoardSize; + readonly toPlay: Color; // цвет игрока: игрок ходит только им + readonly setupBlack: readonly Point[]; + readonly setupWhite: readonly Point[]; + readonly sgf: string; // дерево вариантов от стартовой позиции + TR/CR цели + readonly goal: GoalSpec; + readonly hint?: string; +} + +export type TaskStatus = 'playing' | 'solved' | 'wrong' | 'illegal'; + +/** Неизменяемый снимок состояния сессии — то, что рисует виджет. */ +export interface TaskSnapshot { + readonly state: BoardState; // текущая позиция (после авто-ответа) + readonly status: TaskStatus; + readonly reason: MoveError | null; // при status 'illegal' + readonly attempts: number; // неверные ходы (вне дерева) с последнего сброса + readonly lastMove: Point | null; + readonly markup: NodeMarkup; // разметка цели с корня SGF (TR/CR) +} + +/** Подсказка: текст из task.hint и/или первый ход ведущего варианта. */ +export interface TaskHint { + readonly text: string | null; + readonly point: Point | null; +} + +export interface TaskSession { + readonly task: TaskV1; + snapshot(): TaskSnapshot; + play(point: Point): TaskSnapshot; + reset(): TaskSnapshot; + hint(): TaskHint; +} + +/** Разметка цели по типу: TR, для connect — CR (ADR-0003). */ +function targetPoints(root: GameNode, task: TaskV1): readonly Point[] { + return task.goal.type === 'connect' ? root.markup.circles : root.markup.triangles; +} + +/** Дочерний узел-вариант с ходом в точку (или пас — не ищем, игрок ходит). */ +function childWithMove(node: GameNode, point: Point): GameNode | null { + const key = pointKey(point); + for (const child of node.children) { + if (child.move?.kind === 'play' && pointKey(child.move.point) === key) return child; + } + return null; +} + +/** + * Нормализация SGF под parseSgf: парсер моделирует ходы от пустой доски + * начиная с чёрных (свойство PL не поддерживается), поэтому для задач с + * toPlay 'white' меняем B↔W в свойствах ходов. Структура дерева, координаты + * и разметка сохраняются; при проигрывании цвет каждого хода берётся из + * state.toPlay, так что инверсия цветов в дереве безопасна. Ограничение: + * комментарии C[...] не должны содержать подстрок вида "B["/"W[". + */ +function normalizedSgf(task: TaskV1): string { + if (task.toPlay === 'black') return task.sgf; + return task.sgf.replace(/([BW])\[/g, (_raw, letter: string) => (letter === 'B' ? 'W[' : 'B[')); +} + +/** + * Сессия задачи. Бросает Error при битом контенте (невалидная позиция + * buildPosition или SgfError parseSgf) — вызывающий код (виджет) ловит. + */ +export function createTaskSession(task: TaskV1): TaskSession { + const initial = buildPosition(task.size, task.setupBlack, task.setupWhite, task.toPlay); + const root = parseSgf(normalizedSgf(task)).root; + const target = targetPoints(root, task); + + let state = initial; + let node = root; + let status: TaskStatus = 'playing'; + let reason: MoveError | null = null; + let attempts = 0; + let lastMove: Point | null = null; + + function snapshot(): TaskSnapshot { + return { state, status, reason, attempts, lastMove, markup: root.markup }; + } + + /** Авто-ответ соперника: первый дочерний узел текущего, если это ход. + * Цвет — state.toPlay (цвета в дереве могут быть инвертированы + * нормализацией для toPlay 'white', поэтому на них не опираемся). */ + function applyOpponentReply(): void { + const reply = node.children[0]; + if (reply === undefined) return; + const move = reply.move; + if (move === null || move.kind === 'resign') return; + const result = applyMove( + state, + move.kind === 'play' + ? { kind: 'play', color: state.toPlay, point: move.point } + : { kind: 'pass', color: state.toPlay }, + ); + // Нелегальный ответ — дефект контента; защищаемся: остаёмся на ходу игрока. + if (!result.ok) return; + state = result.state; + node = reply; + if (move.kind === 'play') lastMove = move.point; + } + + function play(point: Point): TaskSnapshot { + if (status === 'solved') return snapshot(); + const result = applyMove(state, { kind: 'play', color: state.toPlay, point }); + if (!result.ok) { + status = 'illegal'; + reason = result.reason; + return snapshot(); + } + const child = childWithMove(node, point); + if (child === null) { + // Легальный ход вне дерева вариантов: откат, attempts++. + status = 'wrong'; + reason = null; + attempts += 1; + return snapshot(); + } + state = result.state; + node = child; + lastMove = point; + reason = null; + // Контракт: goal проверяется после КАЖДОГО хода игрока — до авто-ответа + // (транзиентные цели вроде liberties могут достигаться ходом игрока и + // «сгорать» от ответа соперника, tsm-liberties-escape). + if (goalReached(state, task.goal, target)) { + status = 'solved'; + return snapshot(); + } + applyOpponentReply(); + status = goalReached(state, task.goal, target) ? 'solved' : 'playing'; + return snapshot(); + } + + function reset(): TaskSnapshot { + state = initial; + node = root; + status = 'playing'; + reason = null; + attempts = 0; + lastMove = null; + return snapshot(); + } + + function hint(): TaskHint { + const text = task.hint ?? null; + let point: Point | null = null; + for (const child of node.children) { + if (child.move?.kind === 'play') { + point = child.move.point; + break; + } + } + return { text, point }; + } + + return { task, snapshot, play, reset, hint }; +} diff --git a/apps/web/src/lib/territory.test.ts b/apps/web/src/lib/territory.test.ts new file mode 100644 index 0000000..1a19dda --- /dev/null +++ b/apps/web/src/lib/territory.test.ts @@ -0,0 +1,48 @@ +/** + * Тесты lib/territory.ts: регион одного цвета — территория владельца, + * регион на границе двух цветов — нейтральный, мёртвые камни сняты. + */ +import { applyMove, createBoard, pointKey } from '@go-learn/core'; +import type { BoardState, Color, Point } from '@go-learn/core'; +import { describe, expect, it } from 'vitest'; +import { territorySets } from './territory.js'; + +function playSequence(size: 9 | 13 | 19, plays: [Color, Point][]): BoardState { + let state = createBoard(size); + for (const [color, point] of plays) { + const result = applyMove(state, { kind: 'play', color, point }); + if (result.ok) state = result.state; + } + return state; +} + +describe('territorySets', () => { + it('пустая доска — один нейтральный регион без владельца', () => { + const sets = territorySets(createBoard(9), new Set()); + expect(sets.black.size).toBe(0); + expect(sets.white.size).toBe(0); + }); + + it('регион вокруг одного камня — территория его цвета', () => { + const state = playSequence(9, [ + ['black', { x: 4, y: 4 }], + ['white', { x: 4, y: 4 }], // нелегален, пропускается + ]); + const sets = territorySets(state, new Set()); + expect(sets.black.size).toBe(80); + expect(sets.white.size).toBe(0); + }); + + it('мёртвый камень снят: регион пересчитывается без него', () => { + const state = playSequence(9, [ + ['black', { x: 0, y: 0 }], + ['white', { x: 8, y: 8 }], + ]); + const alive = territorySets(state, new Set()); + expect(alive.black.size + alive.white.size).toBe(0); // общий регион нейтрален + const dead = new Set([pointKey({ x: 8, y: 8 })]); + const sets = territorySets(state, dead); + expect(sets.black.size).toBe(80); // клетка снятого камня — территория чёрных + expect(sets.white.size).toBe(0); + }); +}); diff --git a/apps/web/src/lib/territory.ts b/apps/web/src/lib/territory.ts new file mode 100644 index 0000000..9b6c08d --- /dev/null +++ b/apps/web/src/lib/territory.ts @@ -0,0 +1,85 @@ +/** + * Карта территории для заливки при подсчёте (RenderOptions.territory из + * @go-learn/board): пустые регионы после снятия мёртвых камней; регион, + * граничащий только с одним цветом, — его территория, иначе нейтральная. + * Чистая функция, без DOM. Логика зеркалит scorePosition ядра, но нужна + * пометочная карта (ScoreResult отдаёт только суммы и neutral). + */ +import { cellAt, pointKey } from '@go-learn/core'; +import type { BoardState, Color, Point } from '@go-learn/core'; + +/** Ортогональные соседи в пределах доски (core neighbors не экспортирован). */ +function neighborPoints(state: BoardState, point: Point): Point[] { + const result: Point[] = []; + const deltas = [ + { x: 1, y: 0 }, + { x: -1, y: 0 }, + { x: 0, y: 1 }, + { x: 0, y: -1 }, + ]; + for (const delta of deltas) { + const next = { x: point.x + delta.x, y: point.y + delta.y }; + if (next.x >= 0 && next.x < state.size && next.y >= 0 && next.y < state.size) { + result.push(next); + } + } + return result; +} + +/** Эффективная клетка: мёртвые камни считаются снятыми (пустыми). */ +function effectiveCell(state: BoardState, dead: ReadonlySet, point: Point): string { + const cell = cellAt(state, point); + if (cell === 'empty') return 'empty'; + return dead.has(pointKey(point)) ? 'empty' : (cell as string); +} + +export interface TerritorySets { + readonly black: Set; + readonly white: Set; +} + +/** Пометка одного пустого региона от стартовой точки (BFS). */ +function floodRegion( + state: BoardState, + dead: ReadonlySet, + start: Point, + visited: Set, +): { points: Point[]; border: Set } { + const points: Point[] = []; + const border = new Set(); + const queue: Point[] = [start]; + visited.add(pointKey(start)); + while (queue.length > 0) { + const point = queue.pop() as Point; + points.push(point); + for (const next of neighborPoints(state, point)) { + const cell = effectiveCell(state, dead, next); + if (cell === 'empty' && !visited.has(pointKey(next))) { + visited.add(pointKey(next)); + queue.push(next); + } else if (cell === 'black' || cell === 'white') { + border.add(cell); + } + } + } + return { points, border }; +} + +/** Карта территории: ключи pointKey по цветам-владельцам регионов. */ +export function territorySets(state: BoardState, dead: ReadonlySet): TerritorySets { + const black = new Set(); + const white = new Set(); + const visited = new Set(); + for (let y = 0; y < state.size; y += 1) { + for (let x = 0; x < state.size; x += 1) { + const point = { x, y }; + if (visited.has(pointKey(point))) continue; + if (effectiveCell(state, dead, point) !== 'empty') continue; + const region = floodRegion(state, dead, point, visited); + const target = region.border.size === 1 ? [...region.border][0] : undefined; + const sink = target === 'black' ? black : target === 'white' ? white : null; + if (sink !== null) for (const p of region.points) sink.add(pointKey(p)); + } + } + return { black, white }; +} diff --git a/apps/web/src/pages/account.astro b/apps/web/src/pages/account.astro new file mode 100644 index 0000000..d2c8683 --- /dev/null +++ b/apps/web/src/pages/account.astro @@ -0,0 +1,188 @@ +--- +import Layout from '../layouts/Layout.astro'; +import { STRINGS } from '../ui/strings'; + +const s = STRINGS.account; +--- + + +
+ + + + + + +

+
+
+ + + + diff --git a/apps/web/src/pages/index.astro b/apps/web/src/pages/index.astro new file mode 100644 index 0000000..519bea3 --- /dev/null +++ b/apps/web/src/pages/index.astro @@ -0,0 +1,98 @@ +--- +import { getCollection } from 'astro:content'; +import Layout from '../layouts/Layout.astro'; +import { format, STRINGS } from '../ui/strings'; + +const s = STRINGS.home; + +// Честные числа hero-meta: разделы — из STRINGS.learn.sections, +// задачи цумэ-го — из коллекции, уровни ИИ — 1..6 (селект страницы игры). +const sectionsCount = STRINGS.learn.sections.length; +const allTasks = (await getCollection('tasks')).flatMap((entry) => entry.data); +const tsumegoCount = allTasks.filter((task) => task.kind === 'tsumego').length; +const aiLevelsCount = 6; + +// Ссылка цитатного блока — на первый урок курса (по order во frontmatter). +const lessons = await getCollection('lessons'); +const firstLesson = [...lessons].sort((a, b) => a.data.order - b.data.order)[0]; +--- + + +
+
+
+ {s.kicker} +

{s.headingBefore}{s.headingEm}{s.headingAfter}

+

{s.lead}

+ +
+
{sectionsCount}{s.metaSections}
+
{tsumegoCount}{s.metaTsumego}
+
{aiLevelsCount}{s.metaLevels}
+
+
+
+ {s.heroAlt} +
{s.heroCaption}
+
+
+
+ +
+
+
+
+

{s.featuresKicker}

+

{s.featuresHeading}

+
+

{s.featuresLead}

+
+
+
+ +

{s.feature1Title}

+

{s.feature1Text}

+
+
+ +

{s.feature2Title}

+

{s.feature2Text}

+
+
+ +

{s.feature3Title}

+

{s.feature3Text}

+
+
+ +
+
+ {s.inkAlt} +
{s.inkCaption}
+
+
+ +
+
+ {s.stonesAlt} +
+
+
{s.quote}
+ {s.quoteCite} + { + firstLesson !== undefined && ( +

+ + {format(s.firstLessonLink, { title: firstLesson.data.title.toLowerCase() })} + +

+ ) + } +
+
+
+
+
diff --git a/apps/web/src/pages/istochniki.astro b/apps/web/src/pages/istochniki.astro new file mode 100644 index 0000000..a04bcb8 --- /dev/null +++ b/apps/web/src/pages/istochniki.astro @@ -0,0 +1,413 @@ +--- +import Layout from '../layouts/Layout.astro'; +import { STRINGS } from '../ui/strings'; + +// Этап 11: страница проверяемых источников. Железное правило владельца: +// каждая ссылка — только из docs/audit/*.md (там URL реально открывались +// и есть цитаты). Что подтвердить не удалось — честно в последнем разделе. +const s = STRINGS.sources; +--- + + +
+
+

{s.kicker}

+

{s.title}

+

{s.lead}

+
+
+ +
+
+

{s.sectionRules}

+ +
+ +
+

{s.sectionLife}

+
    +
  • + Глаз — пустое пространство, окружённое камнями одного цвета — + Sensei's Library: Eye +
  • +
  • + Два отдельных глаза — группа живёт; один большой глаз — ещё не жизнь — + Sensei's Library: Two Eyes +
  • +
  • + Ложный глаз: часть окружающих камней можно поставить в атари — + Sensei's Library: False Eye +
  • +
  • + Жизненная точка (vital point / накадэ) — + Sensei's Library: Vital Point +
  • +
+
+ +
+

{s.sectionScoring}

+ +
+ +
+

{s.sectionConnect}

+
    +
  • + «Соединяй свои группы и разрезай чужие» — центральная идея — + Sensei's Library: Cut +
  • +
  • + Иккэн (one-point jump) уязвим к разрезанию в зависимости от окружения — + Sensei's Library: Ikken Tobi +
  • +
  • + Косуми — диагональный ход; изолированное косуми локально неразрезаемо (миаи) — + Sensei's Library: Kosumi +
  • +
+
+ +
+

{s.sectionOpening}

+ +
+ +
+

{s.sectionJoseki}

+
    +
  • + Дзёсэки — эталонные угловые последовательности с локально равным итогом; оценки меняются + со временем, включая влияние ИИ — + Sensei's Library: Joseki +
  • +
  • + Цукэ-хики (3-4, подход, прижатие-отступление): хики укрепляет угол чёрных — один из + первых дзёсэки для новичков — + Sensei's Library: 3-4 Point, High Approach, Attach-Drawback Joseki +
  • +
  • + Вторжение 3-3 под хоси: земля против влияния, вторгшийся живёт ~10 очками в углу — + Sensei's Library: 4-4 Point 3-3 Invasion Joseki +
  • +
+
+ +
+

{s.sectionShape}

+
    +
  • + Тигровая пасть (нэко но као) — форма из трёх камней вокруг пустого пункта — + Sensei's Library: Tiger's Mouth +
  • +
  • + Бамбуковый стык (такэфу): практически неразрезаем — + Sensei's Library: Bamboo Joint +
  • +
  • + Ноби — надёжное вытягивание, добавляет дамэ — + Sensei's Library: Nobi +
  • +
  • + Пустой треугольник — неэффективная форма (с редкими исключениями) — + Sensei's Library: Empty Triangle +
  • +
  • + Данго — «пельменная форма»: бесформенный ком камней без глаз — + Sensei's Library: Dango +
  • +
  • + Перегруппировка (коригатати) — слишком много камней для их работы — + Sensei's Library: Korigatachi +
  • +
+
+ +
+

{s.sectionYose}

+ +
+ +
+

{s.unconfirmedHeading}

+
    +
  • + Приём «соединение снизу по первой линии» (урок 06) — авторитетная страница не открылась. +
  • +
  • + Числа «угол 2–3 / сторона 4–5 / центр 8–10 камней» (урок 07) — источник не найден; в + каноне другие числа (стены 2/3/4; жизнь минимум 6/8/10 камней). +
  • +
  • + Термин «линия смерти» для первой линии (урок 07) — в авторитетных источниках не + подтверждён. +
  • +
  • + Диапазон «ходы ёсэ приносят 1–3 очка» (урок 10) — не подтверждён; встречаются ёсэ-ходы + дороже (ханэ-комбо ~10 очков по дэири). +
  • +
  • + Суперлатив «самый частый приём ёсэ — ханэ на первой линии» (урок 10) — не подтверждён. +
  • +
+
+ +

{s.note}

+
+
+ + diff --git a/apps/web/src/pages/learn.astro b/apps/web/src/pages/learn.astro new file mode 100644 index 0000000..5cef75c --- /dev/null +++ b/apps/web/src/pages/learn.astro @@ -0,0 +1,78 @@ +--- +import { getCollection } from 'astro:content'; +import Layout from '../layouts/Layout.astro'; +import { STRINGS } from '../ui/strings'; + +// Плоская сетка карточек (этап 10): разделы курса совпадают с уроками 1:1. +// Порядок прохождения = числовой префикс id файла (01-…10-…): поле order во +// frontmatter сейчас у всех 1, поэтому сортируем по id. +const lessons = (await getCollection('lessons')).sort((a, b) => a.id.localeCompare(b.id)); +--- + + +
+
+ +

с нуля до 10 кю

+

{STRINGS.learn.title}

+

+ Десять уроков, каждый — короткая теория и задачи на доске. Идите по порядку: каждый урок + опирается на предыдущий. Отметки «пройден» сохраняются на вашем устройстве. +

+

+ + {STRINGS.chrome.courseDisclaimer}{' '} + {STRINGS.chrome.sourcesLink} +

+
+
+ + +
+ + + + diff --git a/apps/web/src/pages/learn/[id].astro b/apps/web/src/pages/learn/[id].astro new file mode 100644 index 0000000..5647140 --- /dev/null +++ b/apps/web/src/pages/learn/[id].astro @@ -0,0 +1,140 @@ +--- +import { getCollection, render } from 'astro:content'; +import Layout from '../../layouts/Layout.astro'; +import { STRINGS } from '../../ui/strings'; + +export async function getStaticPaths() { + const lessons = await getCollection('lessons'); + return lessons.map((lesson) => ({ params: { id: lesson.id }, props: { lesson } })); +} + +const { lesson } = Astro.props; +const { Content } = await render(lesson); + +// Навигация prev/next и кикер «урок N из M» — по порядку прохождения (этап 10). +// Порядок = числовой префикс id файла: поле order во frontmatter у всех 1. +const ordered = (await getCollection('lessons')).sort((a, b) => a.id.localeCompare(b.id)); +const index = ordered.findIndex((entry) => entry.id === lesson.id); +const prev = index > 0 ? ordered[index - 1] : undefined; +const next = index >= 0 && index < ordered.length - 1 ? ordered[index + 1] : undefined; + +// Задачи, встроенные в тело урока элементами : +// отдаём их данные клиенту JSON-островком для гидратации виджетов. +const taskIds = [...(lesson.body ?? '').matchAll(/data-task-id="([^"]+)"/g)].map( + (match) => match[1], +); +const allTasks = (await getCollection('tasks')).flatMap((entry) => entry.data); +const tasks = taskIds + .map((id) => allTasks.find((task) => task.id === id)) + .filter((task) => task !== undefined); +const island = JSON.stringify({ tasks }).replace(/ +
+
+ +

урок {index + 1} из {ordered.length}

+

{lesson.data.title}

+

{lesson.data.summary}

+
+
+ +
+ + + + diff --git a/apps/web/src/pages/password-reset.astro b/apps/web/src/pages/password-reset.astro new file mode 100644 index 0000000..519dfb4 --- /dev/null +++ b/apps/web/src/pages/password-reset.astro @@ -0,0 +1,187 @@ +--- +import Layout from '../layouts/Layout.astro'; +import { STRINGS } from '../ui/strings'; + +const s = STRINGS.passwordReset; +--- + + +
+ + + + + +

+ +
+
+ + + + diff --git a/apps/web/src/pages/play.astro b/apps/web/src/pages/play.astro new file mode 100644 index 0000000..beaaff5 --- /dev/null +++ b/apps/web/src/pages/play.astro @@ -0,0 +1,175 @@ +--- +import Layout from '../layouts/Layout.astro'; +import { STRINGS } from '../ui/strings'; + +const s = STRINGS; +--- + + +
+
+

партия против компьютера

+

{s.site.playLink}

+

+ Нажмите на пересечение, чтобы поставить камень. ИИ играет по правилам с захватом, запретом + самоубийства и простым ко. +

+
+
+ +
+
+
+ + +
+ + +
+ +
+
+ + + + diff --git a/apps/web/src/pages/progress.astro b/apps/web/src/pages/progress.astro new file mode 100644 index 0000000..33ae170 --- /dev/null +++ b/apps/web/src/pages/progress.astro @@ -0,0 +1,153 @@ +--- +import { getCollection } from 'astro:content'; +import Layout from '../layouts/Layout.astro'; +import { STRINGS } from '../ui/strings'; + +const s = STRINGS.progressPage; +const lessons = await getCollection('lessons'); +const allTasks = (await getCollection('tasks')).flatMap((entry) => entry.data); +const totals = { + lessons: lessons.length, + lessonTasks: allTasks.filter((task) => task.kind === 'lesson').length, + tsumegoTasks: allTasks.filter((task) => task.kind === 'tsumego').length, +}; +const island = JSON.stringify(totals).replace(/ +
+
+ +

тихий счёт упорству

+

{s.title}

+

+ Всё считается локально, на этом устройстве. Ничего не отправляется на сервер без вашей + команды — поэтому цифры честные. +

+
+
+ +
+
+
+
+
уроков пройдено
+
+
+
+
задач в уроках решено
+
+
+
+
цумэ-го решено
+
+
+
+
дней подряд
+
+
+ +
+

Путь по курсу

+
+
+
+

+

+ Десять минут в день надёжнее трёх часов раз в месяц: Го растёт на регулярности, а не на + подвигах. +

+ +
+ + + + + diff --git a/apps/web/src/pages/register.astro b/apps/web/src/pages/register.astro new file mode 100644 index 0000000..c8f7826 --- /dev/null +++ b/apps/web/src/pages/register.astro @@ -0,0 +1,199 @@ +--- +import Layout from '../layouts/Layout.astro'; +import { STRINGS } from '../ui/strings'; + +const s = STRINGS.register; +--- + + +
+ +
+

{s.heading}

+

+ Одна учётная запись — чтобы прогресс жил на всех устройствах. Никакой рассылки. +

+
+
+ + + {s.statusInvalid} +
+
+ + +

{s.passwordHint}

+
+
+ + + {s.statusPasswordMismatch} +
+
+ +
+ {s.captchaAlt} + +
+ + {s.statusCaptcha} +
+ +
+ +
+ +

+
+
+ + + + diff --git a/apps/web/src/pages/tsumego.astro b/apps/web/src/pages/tsumego.astro new file mode 100644 index 0000000..61b43a6 --- /dev/null +++ b/apps/web/src/pages/tsumego.astro @@ -0,0 +1,177 @@ +--- +import { getCollection } from 'astro:content'; +import Layout from '../layouts/Layout.astro'; +import { format, STRINGS } from '../ui/strings'; + +const s = STRINGS.tsumego; +const allTasks = (await getCollection('tasks')).flatMap((entry) => entry.data); +const tasks = allTasks.filter((task) => task.kind === 'tsumego'); +const topics = [...new Set(tasks.flatMap((task) => task.topics))].sort((a, b) => + a.localeCompare(b, 'ru'), +); +const island = JSON.stringify({ tasks }).replace(/ +
+
+ +

жизнь и смерть в миниатюре

+

{s.title}

+

+ Задачи на локальную тактику: раскройте задачу и решите её прямо на доске. Фильтры по теме, + сложности и статусу помогают добраться до нерешённого. +

+
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ { + tasks.map((task) => ( +
+ + {task.title} + + {format(s.difficultyLabel, { level: String(task.difficulty) })} + + + + + Запасной текст без JS: откройте задачу в браузере с включённым JavaScript. + +
+ )) + } +
+ + + diff --git a/apps/web/src/ui/play-page.ts b/apps/web/src/ui/play-page.ts new file mode 100644 index 0000000..e861f46 --- /dev/null +++ b/apps/web/src/ui/play-page.ts @@ -0,0 +1,419 @@ +/** + * DOM-привязка страницы /play: канва (render из @go-learn/board), панель + * управления и статус-строка ↔ lib/game-store.ts. Вся логика партии — в + * сторе; здесь только DOM: события, чтение контролов, отрисовка кадра. + * Строки — только из ui/strings.ts. + */ +import { + attachPointerInput, + computeGeometry, + pixelToPoint, + pointToPixel, + render, + setupCanvas, +} from '@go-learn/board'; +import type { BoardGeometry, RenderOptions } from '@go-learn/board'; +import { findNode, pointKey, serializeSgf } from '@go-learn/core'; +import type { BoardSize, GameNode, GameTree, Point } from '@go-learn/core'; +import { createAiClient } from '../lib/ai-client.js'; +import { createGameStore } from '../lib/game-store.js'; +import type { GameStore, GameStoreState } from '../lib/game-store.js'; +import { addCursor, arrowOf, centerCursor, clampCursor, reduceCursor } from '../lib/keyboard.js'; +import { loadSettings, saveSettings } from '../lib/storage.js'; +import type { SettingsV1 } from '../lib/storage.js'; +import { territorySets } from '../lib/territory.js'; +import { format, STRINGS } from './strings.js'; + +interface PageElements { + canvas: HTMLCanvasElement; + status: HTMLElement; + warning: HTMLElement; + captures: HTMLElement; + optSize: HTMLSelectElement; + optLevel: HTMLSelectElement; + optColor: HTMLSelectElement; + optKomiAuto: HTMLInputElement; + optKomi: HTMLInputElement; + optCoords: HTMLInputElement; + optNumbers: HTMLInputElement; + optHints: HTMLInputElement; + btnNew: HTMLButtonElement; + btnPass: HTMLButtonElement; + btnResign: HTMLButtonElement; + btnUndo: HTMLButtonElement; + btnScore: HTMLButtonElement; + btnDownload: HTMLButtonElement; + btnLoad: HTMLButtonElement; + btnPrev: HTMLButtonElement; + btnNext: HTMLButtonElement; + btnExitViewer: HTMLButtonElement; + viewerBar: HTMLElement; + sgfFile: HTMLInputElement; +} + +interface PageState { + geo: BoardGeometry | null; + recoveredNote: boolean; // настройки были восстановлены из дефолтов + cursor: Point | null; // клавиатурный курсор-крестик (этап 7) +} + +function byId(id: string): T { + const element = document.getElementById(id); + if (element === null) throw new Error(`play-page: элемент #${id} не найден`); + return element as T; +} + +function collectElements(): PageElements { + return { + canvas: byId('board'), + status: byId('status'), + warning: byId('warning'), + captures: byId('captures'), + optSize: byId('opt-size'), + optLevel: byId('opt-level'), + optColor: byId('opt-color'), + optKomiAuto: byId('opt-komi-auto'), + optKomi: byId('opt-komi'), + optCoords: byId('opt-coords'), + optNumbers: byId('opt-numbers'), + optHints: byId('opt-hints'), + btnNew: byId('btn-new'), + btnPass: byId('btn-pass'), + btnResign: byId('btn-resign'), + btnUndo: byId('btn-undo'), + btnScore: byId('btn-score'), + btnDownload: byId('btn-download'), + btnLoad: byId('btn-load'), + btnPrev: byId('btn-prev'), + btnNext: byId('btn-next'), + btnExitViewer: byId('btn-exit-viewer'), + viewerBar: byId('viewer-bar'), + sgfFile: byId('sgf-file'), + }; +} + +/** Черновик настроек из контролов панели. */ +function readDraft(els: PageElements): SettingsV1 { + return { + size: Number(els.optSize.value) as BoardSize, + level: Number(els.optLevel.value) as SettingsV1['level'], + playerColor: els.optColor.value as SettingsV1['playerColor'], + komi: els.optKomiAuto.checked ? 'auto' : Number(els.optKomi.value), + showCoordinates: els.optCoords.checked, + showMoveNumbers: els.optNumbers.checked, + hintsEnabled: els.optHints.checked, + }; +} + +function syncControls(els: PageElements, settings: SettingsV1): void { + els.optSize.value = String(settings.size); + els.optLevel.value = String(settings.level); + els.optColor.value = settings.playerColor; + els.optKomiAuto.checked = settings.komi === 'auto'; + els.optKomi.value = settings.komi === 'auto' ? '6.5' : String(settings.komi); + els.optCoords.checked = settings.showCoordinates; + els.optNumbers.checked = settings.showMoveNumbers; + els.optHints.checked = settings.hintsEnabled; +} + +/** Узлы главной линии от корня до текущего (включительно). */ +function mainPath(tree: GameTree): GameNode[] { + const path: GameNode[] = []; + const walk = (node: GameNode): boolean => { + path.push(node); + if (node.id === tree.currentId) return true; + for (const child of node.children) if (walk(child)) return true; + path.pop(); + return false; + }; + walk(tree.root); + return path; +} + +/** Номера ходов для render: ключ pointKey → номер по главной линии. */ +function moveNumberMap(tree: GameTree): Map { + const map = new Map(); + let number = 0; + for (const node of mainPath(tree)) { + if (node.move?.kind !== 'play') continue; + number += 1; + map.set(pointKey(node.move.point), number); + } + return map; +} + +function renderOptionsFor( + els: PageElements, + state: GameStoreState, + node: GameNode, + page: PageState, +): RenderOptions { + const scoring = state.phase === 'scoring'; + const baseMarkup = + state.phase === 'viewer' + ? node.markup + : state.phase === 'playing' && state.hint !== null + ? state.hint.markup + : undefined; + const markup = addCursor(baseMarkup, page.cursor); + return { + showCoordinates: els.optCoords.checked, + showMoveNumbers: els.optNumbers.checked, + lastMove: node.move?.kind === 'play' ? node.move.point : null, + ...(els.optNumbers.checked ? { moveNumbers: moveNumberMap(state.tree) } : {}), + ...(state.phase === 'playing' && state.pending !== null + ? { phantom: { point: state.pending, color: state.playerColor } } + : {}), + ...(markup !== undefined ? { markup } : {}), + ...(scoring ? { dead: state.dead, territory: territorySets(node.state, state.dead) } : {}), + }; +} + +function draw(els: PageElements, store: GameStore, page: PageState): void { + const ctx = els.canvas.getContext('2d'); + if (ctx === null || page.geo === null) return; + const state = store.getState(); + const node = findNode(state.tree.root, state.tree.currentId); + if (node === null) return; + render(ctx, node.state, renderOptionsFor(els, state, node, page)); +} + +/** Размер канвы — по ширине колонки (адаптивность), квадрат. */ +function fitCanvas(els: PageElements, store: GameStore, page: PageState): void { + const column = els.canvas.parentElement; + const width = Math.min(column?.clientWidth ?? 480, 720); + const cssSize = Math.max(280, Math.floor(width)); + setupCanvas(els.canvas, cssSize); + page.geo = computeGeometry(store.getState().tree.size, cssSize, els.optCoords.checked); + store.setGeometry(page.geo); + draw(els, store, page); +} + +function playingStatus(els: PageElements, state: GameStoreState): string { + const node = findNode(state.tree.root, state.tree.currentId); + if (node === null) return ''; + if (node.state.over && state.tree.meta.result !== null) { + return format(STRINGS.status.gameOver, { result: state.tree.meta.result }); + } + if (state.botFailed) return STRINGS.status.botMoveError; + if (state.botThinking) return STRINGS.status.turnBot; + if (state.lastError !== null) return STRINGS.moveErrors[state.lastError]; + if (state.pending !== null) return STRINGS.status.pendingConfirm; + const color = STRINGS.colorNames[state.playerColor]; + return format(STRINGS.status.turnPlayer, { color }); +} + +function hintStatus(state: GameStoreState): string | null { + if (state.phase !== 'playing' || state.hint === null) return null; + const winRate = String(Math.round(state.hint.bestWinRate * 100)); + if (state.hint.delta === null) return format(STRINGS.status.hintFirst, { winRate }); + const percent = Math.round(state.hint.delta * 100); + const delta = percent > 0 ? `+${percent}%` : `${percent}%`; + return format(STRINGS.status.hintDelta, { winRate, delta }); +} + +function viewerStatus(state: GameStoreState): string { + const index = mainPath(state.tree).length - 1; + let total = 0; + let node: GameNode | undefined = state.tree.root; + while ((node = node.children[0]) !== undefined) total += 1; + const values = { index: String(index), total: String(total) }; + return index === 0 + ? format(STRINGS.status.viewerStart, values) + : format(STRINGS.status.viewerMode, values); +} + +function statusText(els: PageElements, state: GameStoreState, page: PageState): string { + const parts: string[] = []; + if (state.sgfError !== null) { + parts.push(format(STRINGS.status.sgfError, { message: state.sgfError })); + } + if (state.phase === 'viewer') parts.push(viewerStatus(state)); + else if (state.phase === 'scoring') parts.push(scoringStatus(state)); + else parts.push(playingStatus(els, state)); + const hint = hintStatus(state); + if (hint !== null) parts.push(hint); + if (state.aiFallback) parts.push(STRINGS.status.aiFallback); + if (page.recoveredNote) parts.push(STRINGS.status.settingsRecovered); + return parts.join(' '); +} + +function scoringStatus(state: GameStoreState): string { + const result = state.tree.meta.result; + if (result === null || state.score === null) return STRINGS.status.scoringPick; + const margin = String(Math.abs(state.score.margin)); + return format(STRINGS.status.scoreResult, { result, margin }); +} + +function refreshControls(els: PageElements, state: GameStoreState): void { + const node = findNode(state.tree.root, state.tree.currentId); + const playing = state.phase === 'playing'; + const blocked = !playing || state.botThinking || node === null || node.state.over; + const playerTurn = node !== null && node.state.toPlay === state.playerColor; + els.btnPass.disabled = blocked || !playerTurn; + els.btnResign.disabled = blocked || !playerTurn; + els.btnUndo.disabled = !playing || state.tree.currentId === 0; + els.btnScore.hidden = state.phase !== 'scoring'; + els.viewerBar.hidden = state.phase !== 'viewer'; + els.btnPrev.disabled = state.phase !== 'viewer' || state.tree.currentId === 0; + els.btnNext.disabled = state.phase !== 'viewer' || (node?.children.length ?? 0) === 0; +} + +function refreshPanel(els: PageElements, state: GameStoreState): void { + const level = Number(els.optLevel.value); + els.optHints.disabled = level < 4; + els.optHints.title = level < 4 ? STRINGS.warnings.hintsUnavailable : ''; + const bigBoard = els.optSize.value === '19' && level >= 4; + els.warning.hidden = !bigBoard; + els.warning.textContent = bigBoard ? STRINGS.warnings.bigBoardMcts : ''; + const node = findNode(state.tree.root, state.tree.currentId); + if (node !== null) { + const c = node.state.captures; + els.captures.textContent = + `${STRINGS.panel.captures}: ${STRINGS.panel.capturesBlack} ${c.black}, ` + + `${STRINGS.panel.capturesWhite} ${c.white}`; + } +} + +function refresh(els: PageElements, store: GameStore, page: PageState): void { + const state = store.getState(); + fitCanvas(els, store, page); + els.status.textContent = statusText(els, state, page); + refreshControls(els, state); + refreshPanel(els, state); +} + +function pad2(value: number): string { + return String(value).padStart(2, '0'); +} + +/** Имя файла SGF: go-learn-YYYYMMDD-HHmm.sgf (дата — UI-слой, допустимо). */ +function sgfFilename(now: Date): string { + const day = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}`; + return `go-learn-${day}-${pad2(now.getHours())}${pad2(now.getMinutes())}.sgf`; +} + +function downloadSgf(tree: GameTree): void { + const blob = new Blob([serializeSgf(tree)], { type: 'application/x-go-sgf' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = sgfFilename(new Date()); + anchor.click(); + URL.revokeObjectURL(url); +} + +function bindCanvas(els: PageElements, store: GameStore, page: PageState): void { + attachPointerInput(els.canvas, (event) => { + if (event.type !== 'tap' || page.geo === null) return; + if (store.getState().phase === 'scoring') { + const point = pixelToPoint(page.geo, event.px, event.py); + if (point !== null) store.toggleDead(point); + return; + } + void store.tapPoint(event.px, event.py); + }); +} + +/** + * Клавиатурный ввод (этап 7): стрелки двигают курсор-крестик, Enter/пробел — + * tap по курсору через reduceInput стора (первое нажатие — фантом, второе — + * ход), Escape — отмена фантома. Pointer-ввод независим: общий InputState + * живёт в сторе, эта подписка лишь шлёт те же события. + */ +function bindKeyboard(els: PageElements, store: GameStore, page: PageState): void { + els.canvas.tabIndex = 0; + els.canvas.setAttribute('aria-label', `${STRINGS.aria.board}. ${STRINGS.aria.boardKeyboard}`); + els.status.setAttribute('aria-live', 'polite'); + els.canvas.addEventListener('keydown', (event) => { + const size = store.getState().tree.size; + const arrow = arrowOf(event.key); + if (arrow !== null) { + event.preventDefault(); + const current = clampCursor(page.cursor ?? centerCursor(size), size); + page.cursor = reduceCursor(current, arrow, size); + draw(els, store, page); + return; + } + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + if (page.geo === null) return; + page.cursor = clampCursor(page.cursor ?? centerCursor(size), size); + if (store.getState().phase === 'scoring') { + store.toggleDead(page.cursor); + return; + } + const { x, y } = pointToPixel(page.geo, page.cursor); + void store.tapPoint(x, y); + return; + } + if (event.key === 'Escape') { + event.preventDefault(); + store.cancelPending(); + } + }); +} + +function bindSettings(els: PageElements, store: GameStore, page: PageState): void { + const persist = (): void => { + saveSettings(readDraft(els)); + refresh(els, store, page); + }; + for (const select of [els.optSize, els.optLevel, els.optColor]) { + select.addEventListener('change', persist); + } + for (const input of [els.optKomiAuto, els.optKomi]) { + input.addEventListener('change', persist); + } + for (const checkbox of [els.optCoords, els.optNumbers, els.optHints]) { + checkbox.addEventListener('change', () => { + const draft = readDraft(els); + saveSettings(draft); + store.applySettings(draft); // визуальные настройки — сразу, без новой партии + }); + } +} + +function bindButtons(els: PageElements, store: GameStore, page: PageState): void { + els.btnNew.addEventListener('click', () => { + const draft = readDraft(els); + saveSettings(draft); + page.recoveredNote = false; + void store.newGame(draft); + }); + els.btnPass.addEventListener('click', () => void store.pass()); + els.btnResign.addEventListener('click', () => store.resign()); + els.btnUndo.addEventListener('click', () => store.undo()); + els.btnScore.addEventListener('click', () => store.computeScore()); + els.btnDownload.addEventListener('click', () => downloadSgf(store.getState().tree)); + els.btnLoad.addEventListener('click', () => els.sgfFile.click()); + els.btnPrev.addEventListener('click', () => store.viewerPrev()); + els.btnNext.addEventListener('click', () => store.viewerNext()); + els.btnExitViewer.addEventListener('click', () => store.exitViewer()); + els.sgfFile.addEventListener('change', () => void loadSgfFile(els, store)); +} + +async function loadSgfFile(els: PageElements, store: GameStore): Promise { + const file = els.sgfFile.files?.[0]; + els.sgfFile.value = ''; + if (file === undefined) return; + store.loadSgfViewer(await file.text()); +} + +/** Точка входа страницы /play (вызывается из play.astro). */ +export function bootPlayPage(): void { + const els = collectElements(); + const { settings, recovered } = loadSettings(); + const seed = Math.floor(Math.random() * 2 ** 31); + const store = createGameStore({ ai: createAiClient(), seed, initialSettings: settings }); + const page: PageState = { geo: null, recoveredNote: recovered, cursor: null }; + syncControls(els, settings); + bindCanvas(els, store, page); + bindKeyboard(els, store, page); + bindSettings(els, store, page); + bindButtons(els, store, page); + store.subscribe(() => refresh(els, store, page)); + window.addEventListener('resize', () => refresh(els, store, page)); + refresh(els, store, page); + void store.newGame(settings); // бот может ходить первым (белые/случайный цвет) +} diff --git a/apps/web/src/ui/strings.ts b/apps/web/src/ui/strings.ts new file mode 100644 index 0000000..45a3241 --- /dev/null +++ b/apps/web/src/ui/strings.ts @@ -0,0 +1,294 @@ +/** + * Все строки UI сайта — единственный источник (спека: ни одной строки UI + * вне этого файла). Значения — шаблоны с плейсхолдерами {name}; подстановка + * — функцией format() отсюда же, чтобы страницы не собирали строки вручную. + */ +import type { MoveError } from '@go-learn/core'; + +export const STRINGS = { + site: { + title: 'Го — обучение и игра', + tagline: + 'Интерактивный курс игры Го и партии против ИИ прямо в браузере. ' + + 'Без регистрации: настройки и прогресс хранятся только на вашем устройстве.', + playLink: 'Играть', + /** Название бренда в шапке (этап 10, светлая тема «васи»). */ + brand: 'Иго · школа Го', + }, + /** Шапка и подвал каркаса (этап 10). */ + chrome: { + navAriaLabel: 'Основное меню', + footerNavAriaLabel: 'Меню в подвале', + menuOpen: 'Открыть меню', + menuClose: 'Закрыть меню', + privacyNote: + '◎ Прогресс — на вашем устройстве; кабинет нужен только для синхронизации между устройствами.', + copyright: '© 2026 Иго', + sourcesLink: 'Источники', + courseDisclaimer: + 'Курс создан с помощью ИИ и находится в открытом редактировании — сверяйте спорные места с источниками.', + }, + /** Главная страница (этап 10, паттерн docs/design/kimi-export/index.html). */ + home: { + description: + 'Камерная школа Го (вэйци, бадук) на русском: курс с нуля до 10 кю, ' + + 'цумэ-го с проверкой, партии против ИИ шести уровней.', + kicker: 'вэйци · бадук · го', + headingBefore: 'Научитесь играть ', + headingEm: 'в\u00a0Го', + headingAfter: ' с\u00a0нуля', + lead: + 'Спокойный курс без регистрации-принуждения: теория, задачи с проверкой ' + + 'на доске и партии против компьютера — от первых камней до уверенного 10\u00a0кю.', + startCourse: 'Начать курс', + playAi: 'Сыграть с ИИ', + metaSections: 'разделов курса', + metaTsumego: 'задач цумэ-го', + metaLevels: 'уровней ИИ', + heroChip: 'Ваш ход — E5', + heroAlt: 'Деревянный гобан с несколькими чёрными и белыми камнями в начальной позиции', + heroCaption: 'Гобан, сланец и ракушка — три тысячи лет той же партии.', + featuresKicker: 'как это работает', + featuresHeading: 'Как устроено обучение', + featuresLead: + 'Три простые вещи, которые вместе дают рост: теория малыми дозами, ' + + 'задачи до автоматизма и живая практика.', + feature1Title: 'С нуля, по шагам', + feature1Text: + 'Десять разделов: от дамэ и атари до дзёсэки и ёсэ. Короткие уроки, схемы ' + + 'позиций, никакой воды — только то, что работает на доске.', + feature2Title: 'Задачи с проверкой', + feature2Text: + 'Жизнь и смерть, сэмэаи, лестницы. Фильтры по теме и сложности, решённые ' + + 'отмечаются автоматически — видно, что уже в опоре.', + feature3Title: 'Партии с ИИ, 6 уровней', + feature3Text: + 'Доски 9×9, 13×13 и 19×19, коми, отмена хода и подсказки трёх лучших ходов. ' + + 'Начните с первого уровня — он терпелив.', + inkAlt: 'Позиция на углу гобана в манере тушевой живописи суми-э', + inkCaption: 'Угол доски, тушь и пустое пространство — эстетика ма, в которой учится глаз.', + stonesAlt: 'Чёрные сланцевые и белые ракушечные камни Го в деревянных чашах', + quote: '«Го — это игра, которую легко выучить и невозможно исчерпать».', + quoteCite: '— старая пословица. Проверьте на себе: первый урок занимает десять минут.', + firstLessonLink: 'Первый урок: {title} →', + }, + buttons: { + newGame: 'Новая партия', + pass: 'Пас', + resign: 'Сдаться', + undo: 'Отменить ход', + downloadSgf: 'Скачать SGF', + loadSgf: 'Загрузить SGF', + viewerPrev: 'Назад', + viewerNext: 'Вперёд', + viewerExit: 'Выйти из просмотра', + confirmScore: 'Завершить подсчёт', + }, + panel: { + settingsHeading: 'Настройки партии', + size: 'Размер доски', + level: 'Уровень ИИ', + playerColor: 'Ваш цвет', + colorBlack: 'Чёрные', + colorWhite: 'Белые', + colorRandom: 'Случайно', + komi: 'Коми', + komiAuto: 'Авто', + showCoordinates: 'Координаты', + showMoveNumbers: 'Номера ходов', + hintsEnabled: 'Подсказки (ур. 4+)', + captures: 'Пленники', + capturesBlack: 'чёрные взяли', + capturesWhite: 'белые взяли', + }, + status: { + turnPlayer: 'Ваш ход ({color}).', + turnBot: 'Думает ИИ…', + pendingConfirm: 'Нажмите ещё раз, чтобы подтвердить ход.', + gameOver: 'Партия окончена: {result}.', + resignNote: 'Сдача.', + scoringPick: 'Подсчёт: отметьте мёртвые группы кликом, затем завершите подсчёт.', + scoreResult: 'Итог: {result} (разница {margin}).', + viewerMode: 'Просмотр SGF: ход {index} из {total}.', + viewerStart: 'Просмотр SGF: начальная позиция, ходов {total}.', + sgfLoaded: 'SGF загружен.', + sgfError: 'Не удалось прочитать SGF: {message}', + settingsRecovered: 'Настройки были повреждены — восстановлены значения по умолчанию.', + aiFallback: 'Web Worker недоступен: ИИ считает в основном потоке (возможны паузы).', + hintDelta: 'Оценка лучшего хода: {winRate}% ({delta} к прошлой оценке).', + hintFirst: 'Оценка лучшего хода: {winRate}%.', + botMoveError: 'ИИ вернул нелегальный ход — партия остановлена.', + }, + colorNames: { + black: 'чёрные', + white: 'белые', + }, + nav: { + learn: 'Курс', + tsumego: 'Цумэ-го', + progress: 'Прогресс', + play: 'Играть', + account: 'Кабинет', + }, + account: { + title: 'Кабинет', + loginHeading: 'Вход', + emailLabel: 'Email', + passwordLabel: 'Пароль', + loginSubmit: 'Войти', + registerLink: 'Нет аккаунта? Зарегистрируйтесь', + resetLink: 'Забыли пароль?', + loggedInAs: 'Вы вошли как {email}', + syncButton: 'Синхронизировать прогресс', + logoutButton: 'Выйти', + statusLoggedOut: 'Вы вышли из кабинета.', + statusLoginOk: 'Вы вошли.', + statusUnauthorized: 'Неверная пара email/пароль.', + statusInvalid: 'Проверьте email и пароль (минимум 8 символов).', + statusRateLimited: 'Слишком много попыток — повторите позже.', + statusUnavailable: 'Сервер кабинета недоступен — попробуйте позже.', + syncPushed: 'Прогресс отправлен', + syncPulled: 'Прогресс загружен с сервера', + syncNoop: 'Уже синхронизировано', + syncUnavailable: 'Сервер синхронизации недоступен — прогресс хранится локально', + syncUnauthorized: 'Сессия истекла — войдите снова.', + }, + register: { + title: 'Регистрация', + heading: 'Регистрация', + emailLabel: 'Email', + passwordLabel: 'Пароль', + passwordHint: 'Минимум 8 символов.', + passwordRepeatLabel: 'Повторите пароль', + captchaLabel: 'Код с картинки', + captchaAlt: 'Капча: пять символов', + captchaRefresh: 'Обновить капчу', + submit: 'Зарегистрироваться', + loginLink: 'Уже есть аккаунт? Войти', + statusPasswordMismatch: 'Пароли не совпадают.', + statusConflict: 'Этот email уже зарегистрирован — попробуйте войти.', + statusCaptcha: 'Неверный код с картинки — попробуйте ещё раз.', + statusInvalid: 'Проверьте email и пароль (минимум 8 символов).', + statusRateLimited: 'Слишком много попыток — повторите позже.', + statusUnavailable: 'Сервер кабинета недоступен — попробуйте позже.', + }, + passwordReset: { + title: 'Восстановление пароля', + requestHeading: 'Запросить ссылку для восстановления', + emailLabel: 'Email', + requestSubmit: 'Отправить письмо', + requestDone: 'Если такой email зарегистрирован, письмо со ссылкой отправлено.', + statusMailUnavailable: 'Отправка письма сейчас недоступна, попробуйте позже.', + statusRateLimited: 'Слишком много попыток — повторите позже.', + statusUnavailable: 'Сервер кабинета недоступен — попробуйте позже.', + confirmHeading: 'Новый пароль', + passwordLabel: 'Новый пароль', + passwordHint: 'Минимум 8 символов.', + passwordRepeatLabel: 'Повторите пароль', + confirmSubmit: 'Обновить пароль', + statusPasswordMismatch: 'Пароли не совпадают.', + confirmDone: 'Пароль обновлён.', + loginLink: 'Войти', + statusTokenInvalid: 'Ссылка недействительна или истекла — запросите новую.', + requestAgainLink: 'Запросить новую ссылку', + statusInvalid: 'Пароль должен быть не короче 8 символов.', + }, + learn: { + title: 'Курс', + lessonDone: 'пройден', + markDone: 'Отметить пройденным', + doneState: 'Пройден', + /** Разделы курса — фиксированный список, порядок = порядок прохождения + * (контракт INTERFACES.md, «Разделы курса»). */ + sections: [ + 'Дамэ и захват', + 'Атари, лестница, сеть', + 'Самоубийство и ко', + 'Глаза: жизнь и смерть', + 'Подсчёт и конец партии', + 'Связь и разрезание', + 'Углы, стороны, центр', + 'Базовые дзёсэки', + 'Форма', + 'Простое ёсэ', + ], + }, + task: { + reset: 'Сброс', + hint: 'Подсказка', + statusPlaying: 'Ваш ход ({color}).', + statusPending: 'Нажмите ещё раз, чтобы подтвердить ход.', + statusSolved: 'Задача решена!', + statusWrong: 'Неверно, попробуйте ещё раз. Ошибок: {count}.', + hintPoint: 'Попробуйте ход {point}.', + hintNone: 'Для этой задачи подсказки нет.', + broken: 'Не удалось загрузить задачу.', + }, + tsumego: { + title: 'Цумэ-го', + filterTopic: 'Тема', + filterDifficulty: 'Сложность', + filterStatus: 'Статус', + allTopics: 'Все темы', + allDifficulties: 'Любая', + statusAll: 'Все', + statusSolved: 'Решённые', + statusUnsolved: 'Нерешённые', + difficultyLabel: 'сложность {level}', + solvedBadge: 'решена', + empty: 'Нет задач по выбранным фильтрам.', + }, + progressPage: { + title: 'Мой прогресс', + lessons: 'Уроков пройдено: {done} из {total}', + lessonTasks: 'Задач в уроках решено: {done} из {total}', + tsumegoTasks: 'Цумэ-го решено: {done} из {total}', + streak: 'Серия дней подряд с решёнными задачами: {count}', + recovered: 'Сохранённый прогресс был повреждён — начат заново.', + }, + /** Страница «Источники» (этап 11): проверяемые факты по итогам аудита. */ + sources: { + title: 'Источники', + kicker: 'каждый факт — со ссылкой', + lead: + 'Один проверяемый факт — одна строка и ссылка на первоисточник. ' + + 'Ссылки открываются в новой вкладке.', + sectionRules: 'Правила и базовые термины (уроки 01–03)', + sectionLife: 'Жизнь и смерть (урок 04)', + sectionScoring: 'Подсчёт и конец партии (урок 05)', + sectionConnect: 'Связь и разрезание (урок 06)', + sectionOpening: 'Дебют: углы, стороны, линии (урок 07)', + sectionJoseki: 'Дзёсэки (урок 08)', + sectionShape: 'Форма (урок 09)', + sectionYose: 'Ёсэ (урок 10)', + unconfirmedHeading: 'Что пока не удалось подтвердить', + note: + 'Страница собрана по итогам аудита контента от 2026-08-07; полные ' + + 'отчёты лежат в репозитории (docs/audit/).', + }, + moveErrors: { + 'out-of-bounds': 'Точка за пределами доски.', + occupied: 'Пересечение уже занято.', + suicide: 'Самоубийственный ход запрещён.', + ko: 'Запрет ко: нельзя немедленно повторять позицию.', + superko: 'Позиция уже встречалась в партии (суперко).', + 'game-over': 'Партия уже завершена.', + 'wrong-turn': 'Сейчас ход другого цвета.', + } satisfies Record, + warnings: { + bigBoardMcts: 'На 19×19 ИИ уровней 4–6 играет заметно слабее (таргет движка — 9×9 и 13×13).', + hintsUnavailable: 'Подсказки доступны только на уровнях ИИ 4–6.', + }, + aria: { + board: 'Игровая доска Го', + boardKeyboard: + 'Управление с клавиатуры: стрелки двигают курсор, Enter или пробел — ' + + 'ход (первое нажатие — фантом, второе — подтверждение), Escape — отмена.', + sgfFile: 'Выберите файл SGF', + }, +} as const; + +/** Подстановка {name} в шаблон; неизвестные ключи остаются как есть. */ +export function format(template: string, values: Record): string { + return template.replace(/\{(\w+)\}/g, (raw, key: string) => values[key] ?? raw); +} diff --git a/apps/web/src/ui/task-widget.ts b/apps/web/src/ui/task-widget.ts new file mode 100644 index 0000000..c75374e --- /dev/null +++ b/apps/web/src/ui/task-widget.ts @@ -0,0 +1,282 @@ +/** + * Гидратация элементов (этап 6): канва + * packages/board (setupCanvas/render), ввод reduceInput с подтверждением, + * статус-строка, кнопки «Сброс»/«Подсказка», запись прогресса (tasks + + * streak + lessons.tasksSolved). Данные задач страница отдаёт JSON-островком + * + + diff --git a/docs/design/kimi-export/index.html b/docs/design/kimi-export/index.html new file mode 100644 index 0000000..1ea42f7 --- /dev/null +++ b/docs/design/kimi-export/index.html @@ -0,0 +1,166 @@ + + + + + + Иго — научитесь играть в Го с нуля + + + + + + + + + +
+
+
+
+ вэйци · бадук · го +

Научитесь играть в Го с нуля

+

+ Спокойный курс без регистрации-принуждения: теория, задачи с проверкой на доске и + партии против компьютера — от первых камней до уверенного 10 кю. +

+ +
+
10разделов курса
+
12задач цумэ-го
+
6уровней ИИ
+
+
+
+ Ваш ход — E5 + Деревянный гобан с несколькими чёрными и белыми камнями в начальной позиции +
Гобан, сланец и ракушка — три тысячи лет той же партии.
+
+
+
+ +
+
+
+
+

как это работает

+

Как устроено обучение

+
+

+ Три простые вещи, которые вместе дают рост: теория малыми дозами, задачи до + автоматизма и живая практика. +

+
+
+
+ +

С нуля, по шагам

+

+ Десять разделов: от дамэ и атари до дзёсэки и ёсэ. Короткие уроки, схемы позиций, + никакой воды — только то, что работает на доске. +

+
+
+ +

Задачи с проверкой

+

+ Жизнь и смерть, сэмэаи, лестницы. Фильтры по теме и сложности, решённые отмечаются + автоматически — видно, что уже в опоре. +

+
+
+ +

Партии с ИИ, 6 уровней

+

+ Доски 9×9, 13×13 и 19×19, коми, отмена хода и подсказки трёх лучших ходов. Начните с + первого уровня — он терпелив. +

+
+
+ +
+
+ Позиция на углу гобана в манере тушевой живописи суми-э +
+ Угол доски, тушь и пустое пространство — эстетика ма, в которой учится глаз. +
+
+
+ +
+
+ Чёрные сланцевые и белые ракушечные камни Го в деревянных чашах +
+
+
+ «Го — это игра, которую легко выучить и невозможно исчерпать». +
+ — старая пословица. Проверьте на себе: первый урок занимает десять минут. +

+ Первый урок: дамэ и захват → +

+
+
+
+
+
+ + + + + + diff --git a/docs/design/kimi-export/learn.html b/docs/design/kimi-export/learn.html new file mode 100644 index 0000000..184acbc --- /dev/null +++ b/docs/design/kimi-export/learn.html @@ -0,0 +1,193 @@ + + + + + + Курс — Иго · школа Го + + + + + + + + + +
+
+
+

с нуля до 10 кю

+

Программа курса

+

+ Десять разделов, каждый — короткая теория и задачи на доске. Идите по порядку: каждый + раздел опирается на предыдущий. Отметки «пройден» сохраняются на вашем устройстве. +

+
+
+ +
+
+ + 1 +
+

Дамэ и захват

+

+ Дыхания группы, постановка камня на доску, как камни снимаются с доски. Самый первый + урок — с него начинается всё. +

+
10 мин
+
+
+ + + 2 +
+

Атари, лестница, сеть

+

+ Угроза захвата и три способа её реализовать: прямое атари, ситэтя (лестница) и гэтэ + (сеть). +

+
15 мин
+
+
+ + + 3 +
+

Самоубийство и ко

+

+ Запрещённые пункты, правило ко и ко-угрозы. Почему партия не может повторяться + бесконечно. +

+
12 мин
+
+
+ + + 4 +
+

Глаза: жизнь и смерть

+

+ Два глаза — жизнь. Настоящие и ложные глаза, базовые формы: прямая четвёрка, гнутая + тройка, косяк. +

+
20 мин
+
+
+ + + 5 +
+

Подсчёт и конец партии

+

+ Территория и пленники, коми, два паса, мёртвые камни. Как понять, кто победил, не + считая на пальцах. +

+
15 мин
+
+
+ + + 6 +
+

Связь и разрезание

+

Крепкие и тонкие связи, точки разрезания, защита своих слабостей и атака чужих.

+
15 мин
+
+
+ + + 7 +
+

Углы, стороны, центр

+

+ Почему партия начинается в углах, ценность третьей и четвёртой линии, первые + принципы фусэки. +

+
15 мин
+
+
+ + + 8 +
+

Базовые дзёсэки

+

+ Установленные последовательности в углу: хоси-кэима, комоку и три простейших дзёсэки + наизусть. +

+
25 мин
+
+
+ + + 9 +
+

Форма

+

+ Хорошие и плохие формы: тигровая пасть, бамбуковый стык, пустой треугольник и почему + его избегают. +

+
20 мин
+
+
+ + + 10 +
+

Простое ёсэ

+

+ Эндшпиль без боли: сэнтэ и готэ, ценность ходов, типичные завершения на границе + территорий. +

+
20 мин
+
+
+
+
+
+ + + + + + diff --git a/docs/design/kimi-export/lesson.html b/docs/design/kimi-export/lesson.html new file mode 100644 index 0000000..ceddfff --- /dev/null +++ b/docs/design/kimi-export/lesson.html @@ -0,0 +1,184 @@ + + + + + + Урок 1. Дамэ и захват — Иго · школа Го + + + + + + + + + +
+
+
+

урок 1 из 10

+

Дамэ и захват

+

+ Десять минут — и вы будете знать главное правило Го. Всё остальное в игре растёт из + него. +

+
+
+ +
+
+

Камень живёт, пока дышит

+

+ Камни ставятся на пересечения линий, а не в клетки. Поставленный камень + не двигается — он либо остаётся на доске до конца партии, либо снимается при захвате. +

+

+ Соседние пункты по линиям — это дамэ, дыхания камня. У камня в центре + четыре дыхания, на стороне — три, в углу — два. +

+ +
+  · ─ · ─ ·
+  │   │   │
+  · ─ ○ ─ ·     у белого камня
+  │   │   │     четыре дамэ: слева, справа,
+  · ─ · ─ ·     сверху и снизу
+

Диаграмма 1. Четыре дыхания камня в центре доски.

+ +

+ Камни одного цвета, стоящие рядом по линиям, образуют группу — они + делят дыхания и живут или умирают вместе. +

+ +

Атари и захват

+

+ Когда у группы остаётся последнее дыхание, говорят «атари» — + предупреждение об угрозе. Если противник занимает последнее дамэ, группа + снимается с доски и становится пленниками. +

+ +
+  · ─ ● ─ ·
+  │   │   │
+  ● ─ ○ ─ ·     белый в атари: осталось
+  │   │   │     одно дамэ справа
+  · ─ ● ─ ·
+

Диаграмма 2. Чёрные поставили белого в атари.

+ +
+  · ─ ● ─ ·
+  │   │   │
+  ● ─ ● ← ·     чёрные заняли последнее дамэ —
+  │   │   │     белый камень снят с доски
+  · ─ ● ─ ·
+

Диаграмма 3. Захват: пленник уходит в чашу чёрных.

+ +
+
+
+

Попробуйте сами

+

+ Белый камень на D5 в атари. Куда чёрным поставить камень, чтобы снять его с доски? + Отметка подсказывает район — найдите точное пересечение, а затем + проверьте себя в игре против ИИ. +

+
+
+ +

Зачем это знать

+

+ Вся тактика Го — игра с дыханиями: вы сокращаете дамэ чужих групп и бережёте свои. + Лестница, сеть, сэмэаи, жизнь и смерть — всё это про дыхания, которые мы разберём в + следующих уроках. +

+

+ Запомните три числа: 4–3–2. Столько дыханий у одинокого камня в центре, + на стороне и в углу. Поэтому угол и умирает быстрее всех — и поэтому же там проще всего + строить территорию, но об этом в седьмом уроке. +

+ +
+
+
+

Попробуйте сами

+

+ Два белых камня стоят группой. Сколько дамэ у этой группы? Посчитайте все пустые + соседние пункты обоих камней — общие дыхания считаются один раз. +

+
+
+ +

Кратко

+

+ Камни ставятся на пересечения и не двигаются. Дыхания (дамэ) — соседние пустые пункты. + Последнее дамэ занято — группа снята. Дальше: + атари, лестница и сеть. +

+ +
+ ← Все разделы + + Урок 2: атари → +
+
+
+
+ + + + + + diff --git a/docs/design/kimi-export/play.html b/docs/design/kimi-export/play.html new file mode 100644 index 0000000..9816eff --- /dev/null +++ b/docs/design/kimi-export/play.html @@ -0,0 +1,161 @@ + + + + + + Играть против ИИ — Иго · школа Го + + + + + + + + + +
+
+
+

партия против компьютера

+

Играть

+

+ Нажмите на пересечение, чтобы поставить камень. ИИ играет по правилам с захватом, + запретом самоубийства и простым ко. +

+
+
+ +
+
+
+ +
+ + +
+
+
+ + + + + + + diff --git a/docs/design/kimi-export/progress.html b/docs/design/kimi-export/progress.html new file mode 100644 index 0000000..27f79f5 --- /dev/null +++ b/docs/design/kimi-export/progress.html @@ -0,0 +1,105 @@ + + + + + + Прогресс — Иго · школа Го + + + + + + + + + +
+
+
+

тихий счёт упорству

+

Прогресс

+

+ Всё считается локально, на этом устройстве. Ничего не отправляется на сервер — поэтому + цифры честные. +

+
+
+ +
+
+
+
0 / 10
+
уроков пройдено
+
+
+
0 / 12
+
задач решено
+
+
+
1
+
дней подряд
+
+
+ +
+

Путь по курсу

+
+
+
+

Курс пройден на 0%

+

+ Десять минут в день надёжнее трёх часов раз в месяц: Го растёт на регулярности, а не на + подвигах. +

+ +
+
+
+ + + + + + diff --git a/docs/design/kimi-export/register.html b/docs/design/kimi-export/register.html new file mode 100644 index 0000000..7b879b9 --- /dev/null +++ b/docs/design/kimi-export/register.html @@ -0,0 +1,135 @@ + + + + + + Регистрация — Иго · школа Го + + + + + + + + + +
+
+
+

Регистрация

+

+ Одна учётная запись — чтобы прогресс жил на всех устройствах. Никакой рассылки. +

+
+
+ + + Введите корректный адрес почты. +
+
+ + + Пароль не может быть пустым. +
+
+ + + Пароли не совпадают. +
+
+ +
+ + +
+ + Введите код с картинки. +
+ +
+ +
+
+
+ + + + + + diff --git a/docs/design/kimi-export/style.css b/docs/design/kimi-export/style.css new file mode 100644 index 0000000..2606979 --- /dev/null +++ b/docs/design/kimi-export/style.css @@ -0,0 +1,1363 @@ +/* ============================================================ + Го-школа «Иго» — современная версия + Японский минимализм на современный лад: много воздуха, + крупная типографика, мягкая глубина, одна светлая тема. + ============================================================ */ + +:root { + --paper: #faf7f1; + --paper-2: #f2ede3; + --paper-3: #e9e2d3; + --surface: #ffffff; + --ink: #23201b; + --ink-soft: #6b6154; + --ink-faint: #a29885; + --accent: #3d4f7c; /* индиго аи */ + --accent-strong: #2e3d63; + --accent-soft: #e9ecf5; + --terra: #c25a33; /* терракота */ + --terra-soft: #f6e8e0; + --line: #e3dbc9; + --board: #e6c48a; /* дерево гобана */ + --board-line: #8a6a3f; + --stone-black: #211e1a; + --stone-white: #f6f2e8; + --ok: #4a7a4e; + --ok-soft: #e4efe2; + --err: #b0492f; + --err-soft: #f8e8e2; + --focus: #2e3d63; + --star: #c25a33; + --radius: 18px; + --radius-sm: 12px; + --radius-pill: 999px; + --shadow-sm: 0 1px 2px rgba(35, 32, 27, 0.05), 0 2px 8px rgba(35, 32, 27, 0.04); + --shadow-md: 0 2px 6px rgba(35, 32, 27, 0.05), 0 12px 32px rgba(35, 32, 27, 0.08); + --shadow-lg: 0 4px 12px rgba(35, 32, 27, 0.06), 0 24px 64px rgba(35, 32, 27, 0.12); + --measure: 62ch; + --font-head: 'PT Serif', 'Georgia', serif; + --font-body: 'PT Sans', 'Segoe UI', system-ui, sans-serif; + --font-mono: 'PT Mono', 'SFMono-Regular', Consolas, monospace; + --ease: cubic-bezier(0.22, 1, 0.36, 1); +} + +/* ---------- база ---------- */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font-family: var(--font-body); + font-size: 17px; + line-height: 1.65; + -webkit-font-smoothing: antialiased; + overflow-x: hidden; +} + +img { + max-width: 100%; + display: block; +} + +h1, +h2, +h3, +h4 { + font-family: var(--font-head); + font-weight: 700; + line-height: 1.18; + margin: 0 0 0.5em; + letter-spacing: -0.01em; +} + +h1 { + font-size: clamp(2.2rem, 5.5vw, 3.6rem); +} +h2 { + font-size: clamp(1.5rem, 3vw, 2.1rem); +} +h3 { + font-size: 1.25rem; +} + +p { + margin: 0 0 1em; +} + +a { + color: var(--accent); + text-decoration-thickness: 1px; + text-underline-offset: 3px; +} +a:hover { + color: var(--accent-strong); +} + +a:focus-visible, +button:focus-visible, +input:focus-visible, +select:focus-visible, +[tabindex]:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 3px; + border-radius: 4px; +} + +::selection { + background: var(--accent-soft); +} + +.container { + max-width: 1120px; + margin: 0 auto; + padding: 0 24px; +} + +.container--narrow { + max-width: 720px; +} + +/* ---------- шапка: стекло ---------- */ + +.site-header { + position: sticky; + top: 0; + z-index: 20; + background: rgba(250, 247, 241, 0.82); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + border-bottom: 1px solid var(--line); +} + +.site-header .container { + display: flex; + align-items: center; + gap: 24px; + min-height: 68px; +} + +.logo { + display: flex; + align-items: center; + gap: 11px; + font-family: var(--font-head); + font-size: 1.2rem; + font-weight: 700; + color: var(--ink); + text-decoration: none; + white-space: nowrap; +} + +.logo .kanji { + display: grid; + place-items: center; + width: 36px; + height: 36px; + border-radius: 50%; + font-size: 1.05rem; + line-height: 1; + color: var(--paper); + background: var(--stone-black); + box-shadow: var(--shadow-sm); +} + +.main-nav { + margin-left: auto; +} + +.main-nav ul { + display: flex; + gap: 2px; + list-style: none; + margin: 0; + padding: 0; +} + +.main-nav a { + display: block; + padding: 9px 16px; + color: var(--ink-soft); + text-decoration: none; + border-radius: var(--radius-pill); + transition: + color 0.2s var(--ease), + background 0.2s var(--ease); +} + +.main-nav a:hover { + color: var(--ink); + background: var(--paper-2); +} + +.main-nav a[aria-current='page'] { + color: var(--ink); + background: var(--paper-2); + font-weight: 700; +} + +.header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.menu-toggle { + display: none; + place-items: center; + width: 42px; + height: 42px; + border: 1px solid var(--line); + border-radius: 50%; + background: var(--surface); + color: var(--ink); + cursor: pointer; + font-size: 1.05rem; + transition: background 0.2s; +} + +.menu-toggle:hover { + background: var(--paper-2); +} + +/* ---------- кнопки: пилюли ---------- */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 13px 28px; + font-family: var(--font-body); + font-size: 1rem; + font-weight: 700; + line-height: 1.3; + letter-spacing: 0.01em; + color: var(--accent-strong); + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-pill); + cursor: pointer; + text-decoration: none; + box-shadow: var(--shadow-sm); + transition: + transform 0.25s var(--ease), + box-shadow 0.25s var(--ease), + background 0.2s, + border-color 0.2s; +} + +.btn:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); + border-color: var(--accent); +} + +.btn:active { + transform: translateY(0); + box-shadow: var(--shadow-sm); +} + +.btn--primary { + background: var(--accent); + color: #fdfcf8; + border-color: var(--accent); + box-shadow: + 0 2px 8px rgba(61, 79, 124, 0.3), + 0 8px 24px rgba(61, 79, 124, 0.2); +} + +.btn--primary:hover { + background: var(--accent-strong); + color: #fdfcf8; + border-color: var(--accent-strong); + box-shadow: + 0 4px 12px rgba(61, 79, 124, 0.35), + 0 16px 40px rgba(61, 79, 124, 0.25); +} + +.btn--quiet { + background: transparent; + box-shadow: none; + color: var(--ink-soft); +} + +.btn--quiet:hover { + background: var(--paper-2); + color: var(--ink); + box-shadow: none; + border-color: var(--ink-faint); +} + +.btn--danger { + color: var(--err); +} +.btn--danger:hover { + background: var(--err-soft); + border-color: var(--err); +} + +.btn:disabled, +.btn[disabled] { + opacity: 0.45; + cursor: not-allowed; + pointer-events: none; +} + +.btn--small { + padding: 8px 18px; + font-size: 0.92rem; +} + +.btn-row { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +/* ---------- бейджи, звёзды ---------- */ + +.badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 12px; + font-size: 0.8rem; + font-weight: 700; + border-radius: var(--radius-pill); + color: var(--ink-soft); + background: var(--paper-2); + white-space: nowrap; +} + +.badge--done { + color: var(--ok); + background: var(--ok-soft); +} +.badge--accent { + color: var(--accent-strong); + background: var(--accent-soft); +} + +.stars { + color: var(--star); + letter-spacing: 2px; + font-size: 0.95rem; + white-space: nowrap; +} +.stars .off { + color: var(--line); +} + +/* ---------- карточки ---------- */ + +.card { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 28px; + box-shadow: var(--shadow-sm); +} + +.card--link { + display: block; + color: var(--ink); + text-decoration: none; + transition: + transform 0.25s var(--ease), + box-shadow 0.25s var(--ease), + border-color 0.2s; +} + +.card--link:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-md); + border-color: transparent; +} + +/* ---------- формы ---------- */ + +.field { + margin-bottom: 18px; +} + +.field label { + display: block; + margin-bottom: 7px; + font-size: 0.92rem; + font-weight: 700; + color: var(--ink-soft); +} + +.field input, +.field select { + width: 100%; + padding: 12px 16px; + font: inherit; + color: var(--ink); + background: var(--paper); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + transition: + border-color 0.15s, + box-shadow 0.15s, + background 0.15s; +} + +.field input:hover, +.field select:hover { + border-color: var(--ink-faint); +} + +.field input:focus, +.field select:focus { + border-color: var(--accent); + background: var(--surface); + box-shadow: 0 0 0 4px var(--accent-soft); + outline: none; +} + +.field .hint { + font-size: 0.85rem; + color: var(--ink-faint); + margin: 6px 0 0; +} + +.field--error input { + border-color: var(--err); + background: var(--err-soft); +} + +.field--error .error-text { + display: block; + margin-top: 6px; + font-size: 0.85rem; + color: var(--err); +} + +.error-text { + display: none; +} +.field--error .error-text { + display: block; +} + +/* ---------- подвал ---------- */ + +.site-footer { + margin-top: 96px; + border-top: 1px solid var(--line); + padding: 44px 0; + color: var(--ink-soft); + font-size: 0.92rem; +} + +.site-footer .container { + display: flex; + flex-wrap: wrap; + gap: 16px 40px; + align-items: center; + justify-content: space-between; +} + +.site-footer nav ul { + display: flex; + flex-wrap: wrap; + gap: 4px 22px; + list-style: none; + margin: 0; + padding: 0; +} + +.privacy-note { + display: flex; + align-items: center; + gap: 8px; +} + +/* ---------- главная ---------- */ + +.hero { + position: relative; + padding: 96px 0 88px; + overflow: hidden; +} + +/* мягкое свечение вместо иероглифов-обоев */ +.hero::before { + content: ''; + position: absolute; + top: -220px; + right: -140px; + width: 620px; + height: 620px; + border-radius: 50%; + background: radial-gradient(closest-side, var(--accent-soft), transparent 72%); + pointer-events: none; +} + +.hero::after { + content: ''; + position: absolute; + bottom: -260px; + left: -180px; + width: 560px; + height: 560px; + border-radius: 50%; + background: radial-gradient(closest-side, var(--terra-soft), transparent 70%); + pointer-events: none; +} + +.hero .container { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: 1.05fr 1fr; + gap: 64px; + align-items: center; +} + +.hero-kicker { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--terra); + background: var(--terra-soft); + padding: 6px 16px; + border-radius: var(--radius-pill); + margin-bottom: 22px; +} + +.hero h1 em { + font-style: normal; + color: var(--accent-strong); +} + +.hero p.lead { + font-size: 1.18rem; + color: var(--ink-soft); + max-width: 44ch; + margin-bottom: 32px; +} + +.hero-meta { + display: flex; + gap: 28px; + margin-top: 36px; + flex-wrap: wrap; +} + +.hero-meta .item strong { + display: block; + font-family: var(--font-head); + font-size: 1.5rem; + color: var(--ink); +} + +.hero-meta .item span { + font-size: 0.88rem; + color: var(--ink-faint); +} + +.hero-figure { + margin: 0; + position: relative; + border-radius: 24px; + overflow: hidden; + box-shadow: var(--shadow-lg); + transform: rotate(1.2deg); +} + +.hero-figure img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.6s var(--ease); +} + +.hero-figure:hover img { + transform: scale(1.03); +} + +.hero-figure figcaption { + position: absolute; + left: 14px; + bottom: 14px; + right: 14px; + padding: 12px 18px; + font-size: 0.85rem; + color: var(--ink); + background: rgba(250, 247, 241, 0.88); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border-radius: 14px; +} + +/* парящий камень-плашка поверх фото */ +.hero-chip { + position: absolute; + top: 18px; + left: -26px; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 18px 10px 12px; + background: var(--surface); + border-radius: var(--radius-pill); + box-shadow: var(--shadow-md); + font-size: 0.88rem; + font-weight: 700; + z-index: 2; +} + +.hero-chip .dot { + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--stone-black); + box-shadow: inset -3px -3px 6px rgba(255, 255, 255, 0.15); +} + +.features { + padding: 72px 0 8px; +} + +.section-head { + display: flex; + align-items: end; + justify-content: space-between; + gap: 24px; + margin-bottom: 40px; + flex-wrap: wrap; +} + +.section-head .kicker { + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--terra); + margin-bottom: 10px; +} + +.section-head h2 { + margin: 0; +} + +.section-head p { + color: var(--ink-soft); + max-width: 40ch; + margin: 0; +} + +.features-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 22px; +} + +.feature-card { + padding: 32px 28px; + transition: + transform 0.25s var(--ease), + box-shadow 0.25s var(--ease); +} + +.feature-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-md); +} + +.feature-card .icon { + display: grid; + place-items: center; + width: 52px; + height: 52px; + border-radius: 16px; + background: var(--accent-soft); + color: var(--accent-strong); + font-family: var(--font-head); + font-size: 1.4rem; + margin-bottom: 20px; +} + +.feature-card p { + color: var(--ink-soft); + margin: 0; + font-size: 0.97rem; +} + +/* широкая полоса с суми-э — почти во всю ширину, современно */ +.ink-band { + margin: 72px 0 0; +} + +.ink-band figure { + margin: 0; + border-radius: 24px; + overflow: hidden; + box-shadow: var(--shadow-md); + position: relative; +} + +.ink-band img { + width: 100%; + height: 320px; + object-fit: cover; + object-position: center 42%; +} + +.ink-band figcaption { + position: absolute; + left: 20px; + bottom: 20px; + padding: 10px 18px; + font-size: 0.85rem; + color: var(--ink); + background: rgba(250, 247, 241, 0.88); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border-radius: 12px; +} + +.home-strip { + margin-top: 72px; + display: grid; + grid-template-columns: 1fr 1.15fr; + gap: 24px; + align-items: stretch; +} + +.home-strip .hero-figure { + transform: none; +} + +.home-strip .quote { + display: flex; + flex-direction: column; + justify-content: center; + padding: 40px; + border-radius: var(--radius); +} + +.home-strip blockquote { + margin: 0 0 14px; + font-family: var(--font-head); + font-size: clamp(1.3rem, 2.4vw, 1.7rem); + line-height: 1.4; +} + +.home-strip cite { + color: var(--ink-soft); + font-style: normal; + font-size: 0.94rem; +} + +/* ---------- заголовок страницы ---------- */ + +.page-head { + padding: 64px 0 36px; +} + +.page-head .kicker { + display: inline-flex; + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--terra); + background: var(--terra-soft); + padding: 5px 14px; + border-radius: var(--radius-pill); + margin-bottom: 18px; +} + +.page-head p { + color: var(--ink-soft); + max-width: var(--measure); + font-size: 1.05rem; +} + +/* ---------- курс ---------- */ + +.course-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; + padding-bottom: 48px; +} + +.lesson-card { + display: flex; + gap: 20px; + align-items: flex-start; + padding: 26px; +} + +.lesson-card .lesson-num { + flex: 0 0 auto; + display: grid; + place-items: center; + width: 50px; + height: 50px; + border-radius: 50%; + background: var(--board); + color: var(--stone-black); + font-family: var(--font-head); + font-size: 1.2rem; + border: 2px solid rgba(138, 106, 63, 0.35); +} + +.lesson-card h3 { + margin-bottom: 5px; +} +.lesson-card p { + margin: 0 0 12px; + color: var(--ink-soft); + font-size: 0.95rem; +} + +.lesson-card .meta { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.lesson-card.is-done { + border-color: var(--ok); +} + +.lesson-card.is-done .lesson-num { + background: var(--ok-soft); + border-color: var(--ok); + color: var(--ok); +} + +/* ---------- урок ---------- */ + +.lesson-body { + max-width: var(--measure); + padding-bottom: 48px; + font-size: 1.05rem; +} + +.lesson-body h2 { + margin-top: 2.2em; + padding-top: 0.8em; + border-top: 1px solid var(--line); +} + +.lesson-body h2:first-of-type { + border-top: 0; + margin-top: 1em; + padding-top: 0; +} + +.lesson-body pre { + font-family: var(--font-mono); + font-size: 0.92rem; + line-height: 1.5; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-sm); + padding: 20px 22px; + overflow-x: auto; + color: var(--ink); +} + +.lesson-body .diagram-caption { + font-size: 0.85rem; + color: var(--ink-faint); + margin: -4px 0 24px; +} + +.try-it { + display: grid; + grid-template-columns: 160px 1fr; + gap: 22px; + align-items: center; + margin: 32px 0; + padding: 22px; + border: 1.5px dashed var(--accent); + border-radius: var(--radius); + background: var(--accent-soft); +} + +.try-it .label { + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--accent-strong); + margin-bottom: 7px; +} + +.try-it p { + margin: 0; + font-size: 0.97rem; +} + +.try-it .goban-static { + border-radius: var(--radius-sm); + overflow: hidden; + box-shadow: var(--shadow-sm); +} + +.lesson-nav { + display: flex; + justify-content: space-between; + gap: 12px; + margin-top: 56px; + padding-top: 28px; + border-top: 1px solid var(--line); +} + +/* ---------- цумэ-го ---------- */ + +.filters { + display: flex; + flex-wrap: wrap; + gap: 16px 28px; + align-items: end; + padding: 22px 26px; + margin-bottom: 32px; +} + +.filters .field { + margin: 0; + min-width: 170px; + flex: 1; +} +.filters .field:last-child { + flex: 0 0 auto; +} + +.problems-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 20px; + padding-bottom: 48px; +} + +.problem-card { + padding: 16px; + transition: + transform 0.25s var(--ease), + box-shadow 0.25s var(--ease); +} + +.problem-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-md); +} + +.problem-card .thumb { + border-radius: var(--radius-sm); + overflow: hidden; + margin-bottom: 14px; + box-shadow: var(--shadow-sm); +} + +.problem-card .thumb svg { + display: block; + width: 100%; + height: auto; +} + +.problem-card h3 { + font-size: 1.05rem; + margin-bottom: 8px; +} + +.problem-card .meta { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.empty-state { + padding: 64px 24px; + text-align: center; + border: 1.5px dashed var(--line); + border-radius: var(--radius); + color: var(--ink-soft); +} + +.empty-state .glyph { + font-size: 2rem; + display: block; + margin-bottom: 12px; +} + +/* ---------- игра ---------- */ + +.play-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: 28px; + align-items: start; + padding-bottom: 48px; +} + +.board-wrap { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 24px; + padding: 16px; + box-shadow: var(--shadow-md); +} + +#goban { + width: 100%; + height: auto; + display: block; + border-radius: 12px; + touch-action: manipulation; +} + +#goban .point { + cursor: pointer; +} +#goban .point:hover .hover-dot { + opacity: 0.4; +} +#goban .hover-dot { + opacity: 0; + transition: opacity 0.15s; +} + +.play-panel { + display: flex; + flex-direction: column; + gap: 16px; + position: sticky; + top: 92px; +} + +.play-panel .card { + padding: 22px; +} + +.play-panel h2 { + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--ink-faint); + margin-bottom: 14px; + padding: 0; + border: 0; + font-family: var(--font-body); +} + +.settings-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.settings-grid .field { + margin: 0; + min-width: 0; +} + +.captures { + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 0.95rem; +} + +.captures .side { + display: flex; + align-items: center; + gap: 9px; +} + +.stone-dot { + width: 18px; + height: 18px; + border-radius: 50%; + flex: 0 0 auto; + box-shadow: var(--shadow-sm); +} + +.stone-dot.black { + background: var(--stone-black); +} +.stone-dot.white { + background: var(--stone-white); + border: 1px solid var(--line); +} + +.status-line { + padding: 14px 18px; + border-radius: var(--radius-sm); + background: var(--accent-soft); + color: var(--accent-strong); + font-size: 0.95rem; + font-weight: 700; + box-shadow: var(--shadow-sm); +} + +.hints-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.hints-list li { + display: flex; + justify-content: space-between; + gap: 10px; + padding: 10px 14px; + background: var(--paper); + border-radius: var(--radius-sm); + font-size: 0.95rem; +} + +.hints-list .coord { + font-family: var(--font-mono); + color: var(--accent-strong); + font-weight: 700; +} +.hints-list .score { + color: var(--ink-faint); +} + +/* ---------- прогресс ---------- */ + +.stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + margin: 36px 0; +} + +.stat-card { + text-align: center; + padding: 40px 24px; + transition: + transform 0.25s var(--ease), + box-shadow 0.25s var(--ease); +} + +.stat-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-md); +} + +.stat-card .value { + font-family: var(--font-head); + font-size: clamp(2.4rem, 5vw, 3.4rem); + line-height: 1; + color: var(--accent-strong); +} + +.stat-card .label { + color: var(--ink-soft); + margin-top: 10px; +} + +.progress-track { + height: 10px; + background: var(--paper-2); + border-radius: var(--radius-pill); + overflow: hidden; + margin: 12px 0 8px; +} + +.progress-fill { + height: 100%; + width: 0; + background: var(--accent); + border-radius: var(--radius-pill); + transition: width 0.7s var(--ease); +} + +.progress-note { + font-size: 0.9rem; + color: var(--ink-faint); +} + +/* ---------- кабинет / регистрация ---------- */ + +.auth-card { + max-width: 440px; + margin: 72px auto; + padding: 44px; + box-shadow: var(--shadow-md); +} + +.auth-card h1 { + font-size: 1.7rem; + text-align: center; +} + +.auth-links { + margin-top: 24px; + display: flex; + flex-direction: column; + gap: 8px; + text-align: center; + font-size: 0.95rem; +} + +.captcha-row { + display: flex; + gap: 10px; + align-items: center; +} + +.captcha-box { + flex: 0 0 180px; + height: 60px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--paper-3); + display: block; +} + +.captcha-box svg { + display: block; + width: 100%; + height: 100%; +} + +.logged-in { + text-align: center; +} + +.logged-in .email { + font-family: var(--font-mono); + background: var(--paper-2); + border-radius: var(--radius-pill); + padding: 8px 18px; + display: inline-block; + margin-bottom: 22px; +} + +/* ---------- анимация появления ---------- */ + +.reveal { + opacity: 0; + transform: translateY(18px); + transition: + opacity 0.6s var(--ease), + transform 0.6s var(--ease); +} + +.reveal.is-visible { + opacity: 1; + transform: none; +} + +/* ---------- адаптив ---------- */ + +@media (max-width: 960px) { + .hero { + padding: 64px 0 56px; + } + .hero .container { + grid-template-columns: 1fr; + gap: 40px; + } + .hero-chip { + left: 12px; + top: -14px; + } + .features-grid { + grid-template-columns: 1fr; + } + .home-strip { + grid-template-columns: 1fr; + } + .play-layout { + grid-template-columns: 1fr; + } + .play-panel { + position: static; + } + .course-grid { + grid-template-columns: 1fr; + } + .stats-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 720px) { + .menu-toggle { + display: grid; + } + + .main-nav { + position: absolute; + top: 69px; + left: 0; + right: 0; + background: rgba(250, 247, 241, 0.97); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + border-bottom: 1px solid var(--line); + display: none; + } + + .main-nav.is-open { + display: block; + } + + .main-nav ul { + flex-direction: column; + padding: 10px 20px 18px; + gap: 4px; + } + + .main-nav a { + padding: 13px 14px; + } + + .try-it { + grid-template-columns: 1fr; + } + .try-it .goban-static { + max-width: 200px; + } +} + +/* ---------- reduced motion ---------- */ + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001s !important; + transition-duration: 0.001s !important; + } + html { + scroll-behavior: auto; + } + .reveal { + opacity: 1; + transform: none; + } +} diff --git a/docs/design/kimi-export/tsumego.html b/docs/design/kimi-export/tsumego.html new file mode 100644 index 0000000..f8cc194 --- /dev/null +++ b/docs/design/kimi-export/tsumego.html @@ -0,0 +1,340 @@ + + + + + + Цумэ-го — Иго · школа Го + + + + + + + + + +
+
+
+

жизнь и смерть в миниатюре

+

Цумэ-го

+

+ Задачи на локальную тактику. Ход чёрных — если не сказано иное. Решайте в уме, затем + проверяйте на доске в разделе «Играть». +

+
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+
+
+

Последнее дыхание

+
+ ★★★★ +
+
+ +
+
+
+
+

Камень у края

+
+ ★★★★ +
+
+ +
+
+
+
+

Один глаз или два?

+
+ ★★★★★ +
+
+ +
+
+
+
+

Лестница работает?

+
+ ★★★★★ +
+
+ +
+
+
+
+

Гнутая четвёрка

+
+ ★★★★★ +
+
+ +
+
+
+
+

Гонка на захват

+
+ ★★★★★ +
+
+ +
+
+
+
+

Сеть вместо лестницы

+
+ ★★★★★ +
+
+ +
+
+
+
+

Лестница с приманкой

+
+ ★★★★ +
+
+ +
+
+
+
+

Четыре против трёх

+
+ ★★★★ +
+
+ +
+
+
+
+

Шесть в углу живут?

+
★★★★★
+
+ +
+
+
+
+

Двойное атари

+
+ ★★★★★ +
+
+ +
+
+
+
+

Прямая тройка

+
+ ★★★★ +
+
+
+ + +
+
+ + + + + + diff --git a/docs/design/oauth-vk-yandex.md b/docs/design/oauth-vk-yandex.md new file mode 100644 index 0000000..2ed8529 --- /dev/null +++ b/docs/design/oauth-vk-yandex.md @@ -0,0 +1,61 @@ +# Дизайн: вход через VK ID и Яндекс ID (этап 8.2, запланирован) + +Дата: 2026-08-06. Статус: дизайн (реализация — отдельной командой, когда +владелец получит ключи). Оба провайдера — российские юрлица; зарубежные +провайдеры исключены решением владельца. + +## Что нужно от владельца (действия вне репозитория) + +1. **VK ID**: кабинет https://id.vk.com/business/go → создать приложение + (веб-сайт) → получить `client_id` и `client_secret` (secure key) → + указать redirect URI: `https://<домен>/api/oauth/vk/callback.php`. +2. **Яндекс ID**: https://oauth.yandex.ru → новое приложение → права: + «Доступ к адресу электронной почты» (login:email), при желании + «имя и аватарка» → получить `ClientID`/`Client secret` → redirect + URI: `https://<домен>/api/oauth/yandex/callback.php`. +3. Вписать ключи в `config.php` на хостинге (не в репо!): + `'vk_client_id'`, `'vk_client_secret'`, `'yandex_client_id'`, + `'yandex_client_secret'`. +4. Домен обязан быть на HTTPS — оба провайдера требуют https redirect + URI (включается панелью хостинга, не .htaccess). + +## Архитектура (когда будем делать) + +- Таблица `oauth_accounts(user_id INTEGER, provider TEXT +('vk'|'yandex'), provider_user_id TEXT, PRIMARY KEY(provider, +provider_user_id))`; связь с users — по email (провайдер отдаёт + подтверждённый email → ищем/создаём users-запись, пароль при этом + NULL-able или случайный хэш — вход по паролю для такой учётки + отключён до установки пароля через «сменить пароль»). +- Эндпоинты: `GET /api/oauth//start.php` → 302 на + авторизацию провайдера (state = random в сессии, anti-CSRF); + `GET /api/oauth//callback.php?code&state` → проверка state + → обмен code на token (server-side, client_secret в теле POST) → + получение email (VK: метод id.vk.com/oauth2/user_info; Яндекс: + https://login.yandex.ru/info?format=json с Bearer) → find-or-create + user + oauth_accounts → сессия → 302 на /account.html. +- VK ID — OAuth 2.1 + PKCE (code_verifier/code_challenge в сессии); + Яндекс — классический OAuth 2.0 code flow. +- Кнопки «Войти через VK ID» / «Войти через Яндекс ID» на /account.html + и /register.html; официальные бренд-ресурсы кнопок — из пресс-китов + провайдеров (лицензии — в CREDITS.md). +- Rate limit на start/callback; ошибки провайдера → /account.html с + тихой пометкой «вход через … не удался». + +## Правовые заметки (не юридическая консультация) + +- VK ID и Яндекс ID — сервисы российских юрлиц (ООО «В Контакте», + ООО «Яндекс»); требование владельца «без зарубежной аутентификации» + соблюдено. +- 152-ФЗ: email/идентификаторы — персональные данные; при целевой + аудитории РФ рекомендуется хостинг с серверами в РФ и уведомление + Роскомнадзора как оператора ПДн (если применимо к проекту — вопрос + к юристу; сайт учебный, без монетизации). +- В privacy-заметку сайта (если появится) — перечислить, что храним: + email, хэш пароля, прогресс обучения. + +## Объём реализации (оценка этапа 8.2) + +server/: oauth/start+callback ×2 провайдера, миграция oauth_accounts, +smoke (моки token/userinfo через GOLEARN_TEST-хук); apps/web: кнопки, +строки; docs: обновить deploy.md (шаги ключей). ~1 сессия. diff --git a/docs/plan-phase-6.md b/docs/plan-phase-6.md new file mode 100644 index 0000000..c22c14a --- /dev/null +++ b/docs/plan-phase-6.md @@ -0,0 +1,62 @@ +# План этапа 6 — курс и цумэ-го + +Дата: 2026-08-06. Статус: в работе. Предшественник: этап 5 (игровой UI) +завершён, main = 4aee5b7 + hotfix play.html. + +## Ключевые решения (не пере-решать) + +1. Контент — оригинальный, авторский (не скачиваем чужие сборники цумэ-го): + это снимает лицензионный вопрос; атрибуция — CREDITS.md. Спека допускает + «свободная лицензия с атрибуцией ЛИБО формат импорта»; собственный + контент строго безопаснее (ADR-0001, I.16). +2. Интерактив в Markdown — raw HTML-элемент ``, + гидратация клиентским скриптом страницы. Никаких remark/rehype-плагинов + и новых зависимостей. Теория читаема без JS (запасной текст внутри + элемента). +3. Критерий успеха задачи — goal-предикат, вычисляемый движком + (apps/web/src/lib/goals.ts поверх @go-learn/core), НЕ сравнение с + координатами решения. Дерево вариантов в SGF — только автоответы + соперника и подсказки. +4. Формальный глаз — упрощение v1 (пустая область 1–3 пункта, окружённая + одним цветом); ложный глаз не отличаем, уроки про ложный глаз строятся + на capture-target. Уточнение — через ADR, не в этой фазе. +5. Прогресс — ключ `go-learn:progress` (ProgressV1) в том же конверте + version+checksum, API — расширение существующего storage.ts. Серия дней — + чистая функция touchStreak. +6. Зона работы — apps/web + CREDITS.md + docs; packages/board и + packages/ai не трогаем (goal-предикаты живут в apps/web). Исключение по + ADR-0003: в core добавлена одна аддитивная функция buildPosition + (parseSgf не умеет AB/AW), разметка connect — CR вместо MA. +7. Параллельные ветки: stage-6-core (механика: goals, storage, виджет, + страницы, строки) и stage-6-content (уроки, цумэ-го, CREDITS, + контентные тесты). Мерж: core → content. + +## Объём + +- Механика: goals.ts (4 предиката + формальные глаза), прогресс в storage.ts, + виджет задачи (канва board, автоответы по дереву, сброс/подсказка), + страницы /learn, /learn/[id], /tsumego, /progress, навигация, strings.ts. +- Контент: 10 уроков (по разделу каждый, программа из мастер-промпта), + ≥30 цумэ-го (difficulty 1–5, ≥3 на градацию), задачи для уроков + (≥1 интерактив там, где уместно), CREDITS.md. +- Тесты: goals (все предикаты + ложный/формальный глаз), storage roundtrip + прогресса, touchStreak (вчера/сегодня/разрыв), валидация контента (все + TaskV1 парсятся, SGF легальны движком, goal-разметка присутствует, id + уникальны, у уроков валидный frontmatter и существующие task-id), + wiring-страниц. + +## Приёмка этапа + +- npm run gates зелёные (0 warnings), astro build PASS. +- ≥10 уроков, ≥30 цумэ-го, фильтры работают, прогресс и серия дней + сохраняются в localStorage (roundtrip). +- Страница урока читаема без JS (проверка отключением гидратации). +- Вес страниц остаётся скромным (жёсткий бюджет ≤300 КБ — гейт этапа 7, но + не раздуваем заранее). + +## Риски + +- suggestDeadGroups грубовата (PROJECT_STATE) — two-eyes предикат пишем + собственный, на неё не опираемся. +- Контент-объём большой: качество SGF важнее количества сверх минимума; + валидационный тест — обязательный страховочный гейт. diff --git a/docs/plans/plan-phase-1.md b/docs/plans/plan-phase-1.md new file mode 100644 index 0000000..dd8a775 --- /dev/null +++ b/docs/plans/plan-phase-1.md @@ -0,0 +1,40 @@ +# План фазы 1: этап 1 — движок правил (packages/core) + +Обновлено: 2026-08-05 | Статус: завершена (этап 1 принят, гейты зелёные) + +## Критические долги (реестр — в начале, III.5) + +- Нет. + +## Ключевые решения (не пере-решать) + +- Профиль проекта M, триаж литании — ADR-0001. +- Подсчёт — китайские правила (Tromp-Taylor), не японские: однозначно алгоритмизируется. +- Группы/дамэ: flood fill или union-find — выбрать по бенчмарку в этапе 1, выбор — в ADR. +- Ко: простое ко + позиционное суперко (оба, суперко нужен для китайских правил). +- Определение мёртвых групп — отдельная функция + ручная корректировка игроком, + не авто-анализ. + +## Объём этапа 1 + +1. Доски 9×9, 13×13, 19×19; позиция — типизированный массив. +2. Группы и дамэ, снятие камней, запрет самоубийства (кроме самоубийства с захватом), + простое ко и суперко. +3. Подсчёт Tromp-Taylor; коми 6.5 (19×19) / 5.5 (9×9); сэки обрабатывается корректно. +4. SGF FF[4]: парсер и генератор (ходы, вариации, комментарии, разметка LB/TR/SQ/CR). +5. История с откатом и ветвлением. +6. Контракты — сначала в docs/INTERFACES.md, потом код. +7. Бенчмарк flood fill vs union-find → ADR-0002. + +## Тесты (гейт приёмки этапа) + +- Захват камня и группы; запрет самоубийства; самоубийство с захватом (легально); + простое ко; суперко; сэки при подсчёте; ничья. +- Wiring: orphan-check «кто вызывает» по каждой публичной функции ядра + (`gotcha-green-modules-dead-system`). +- Интеграционный тест полной партии (по заранее записанной SGF). +- Property-based тесты инвариантов (fast-check): инварианты — в docstring функций. + +## Критерий выхода + +Все тесты зелёные + `npm run gates` зелёный. К этапу 2 не переходим без этого. diff --git a/docs/plans/plan-phase-10.md b/docs/plans/plan-phase-10.md new file mode 100644 index 0000000..2d291d3 --- /dev/null +++ b/docs/plans/plan-phase-10.md @@ -0,0 +1,59 @@ +# План этапа 10 — дизайн-перенос (Kimi WebSites → каркас Astro) + +Дата: 2026-08-06. Ветка интеграции: main. + +## Контекст + +Пользователь получил от Kimi WebSites переносной пакет дизайна +(«васи + гобан», светлая тема, индиго + терракота). Эталон распакован и +сохранён в `docs/design/kimi-export/`. Задача — натянуть дизайн на +существующий каркас, не трогая логику, контент, API и тесты. + +Контракт: `docs/INTERFACES.md`, раздел «Дизайн-перенос (этап 10)» — +токены (с 3 правками контраста), каркас Layout, тема доски, изображения, +паттерны страниц, критерии приёмки. + +## Стадии + +### Стадия 0 (оркестратор, сделано) + +- Распаковка пакета, сжатие изображений в `apps/web/public/assets/` + (hero 53КБ / ink 118КБ / stones 24КБ). +- Эталон в `docs/design/kimi-export/`, контракт в INTERFACES.md. + +### Стадия 1 — параллельные кодеры (3 ветки) + +- **design-a (фундамент)**: Layout.astro (глобальные стили = адаптация + kimi-export/style.css + правила [hidden]/go-task, шапка/подвал/проп + section/мобильное меню/шрифты), packages/board render.ts DEFAULT_THEME → + светлая, index.astro (hero+features+ink-band+home-strip). Файлы: + `apps/web/src/layouts/Layout.astro`, `packages/board/src/render.ts`, + `apps/web/src/pages/index.astro`, при необходимости + `apps/web/src/ui/strings.ts` (только новые строки главной). +- **design-b (контентные страницы)**: learn.astro, learn/[id].astro, + tsumego.astro, progress.astro — разметка по паттернам контракта, + логика/id/data-атрибуты не меняются. +- **design-c (страницы приложения)**: play.astro, account.astro, + register.astro, password-reset.astro — то же правило. + +Пересечений по файлам нет (Layout только у A). Общий API — проп +`section` Layout, описан в контракте. + +### Стадия 2 — интеграция (оркестратор) + +- Слияние веток в main, разрешение конфликтов. +- Гейты, точечные правки (wiring/тема-тесты). +- Проверка веса страниц dist и контраста. +- Сборка dist → `/mnt/agents/output/app`, НОВАЯ версия превью + (website_version_manager) — старая рабочая версия остаётся для сравнения. + +### Стадия 3 — ритуал закрытия + +- PROJECT_STATE.md (0.3.0), отчёт session-10, docs(sync) коммит, zip. + +## Риски + +- Слияние: Layout меняет только A — конфликтов не ожидается; strings.ts + могут трогать A и B — B не трогает strings (правило в брифе). +- Контраст исправлен на уровне токенов — регрессий быть не должно. +- Вес главной: hero 53КБ + CSS/JS — в бюджете. diff --git a/docs/plans/plan-phase-11.md b/docs/plans/plan-phase-11.md new file mode 100644 index 0000000..dbf9047 --- /dev/null +++ b/docs/plans/plan-phase-11.md @@ -0,0 +1,60 @@ +# План этапа 11 — аудит контента, страница «Источники», дисклеймер + +Дата: 2026-08-07. Инициатор: владелец. Ветка интеграции: main. + +## Вводные + +Владелец спросил, насколько можно доверять обучающему контенту. Ответ: +движок и проверка задач — машинно проверены; проза уроков и база цумэ-го +написаны ИИ без рецензии сильного игрока. Решение владельца: + +1. Провести аудит контента по авторитетным источникам (обязательно). +2. Сделать на сайте отдельную страницу «Источники» — только голые + проверяемые факты. Чего не нашли — пишем «не найдено». Выдумывать + источники и факты КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО (требование владельца). +3. Добавить дисклеймер об открытом редактировании курса. + +## Стадии + +### Стадия 0 (оркестратор) + +- Контракт в INTERFACES.md (этап 11): правила аудита, формат страницы + источников, текст и размещение дисклеймера. + +### Стадия 1 — аудит (3 параллельных research-агента) + +- audit-1: уроки 01–03 (дамэ/захват, атари/лестница/сеть, самоубийство/ко). +- audit-2: уроки 04–07 (глаза, подсчёт/коми, связь/разрезание, фусэки). +- audit-3: уроки 08–10 (дзёсэки — конкретные последовательности!, форма, + ёсэ) + специфические числовые утверждения всего курса. + +Каждый агент: читает markdown уроков из репо, извлекает проверяемые +утверждения, ищет подтверждения в авторитетных источниках (Sensei's +Library, Wikipedia, BGA/AGA, gobase и т.п.), КАЖДЫЙ источник обязан +реально открыть инструментом и процитировать подтверждающую строку. +Вердикты: ПОДТВЕРЖДЕНО (URL + цитата) | НЕ НАЙДЕНО | РАСХОЖДЕНИЕ (что +именно расходится). Отчёт — markdown-файл docs/audit/NN.md, коммит в +ветку audit/rN. + +### Стадия 2 — интеграция (оркестратор + coder) + +- Сверка отчётов, выборка подтверждённых источников (только реально + открытые URL — оркестратор перепроверяет выборочно сам). +- Страница /istochniki.html: по темам — факт + ссылка; раздел «что не + удалось подтвердить» честно перечислен. +- Дисклеймер: на странице курса и в футере (ссылка на «Источники»). +- Если аудит нашёл РЕАЛЬНЫЕ ошибки контента — отдельный список владельцу; + правки контента только с подтверждающим источником. + +### Стадия 3 — приёмка и ритуал + +- Гейты, вес, контраст; скриншот страницы источников; новая версия + превью; PROJECT_STATE, отчёт, zip. + +## Риски + +- Главный риск — галлюцинация источников агентами. Митигация: правило + «URL только из реально открытых страниц, с цитатой» + выборочная + перепроверка оркестратором через web_open_url. +- Sensei's Library и gobase могут быть медленными/недоступными — тогда + честный «не найдено» по части утверждений. diff --git a/docs/plans/plan-phase-2.md b/docs/plans/plan-phase-2.md new file mode 100644 index 0000000..2c40bdb --- /dev/null +++ b/docs/plans/plan-phase-2.md @@ -0,0 +1,70 @@ +# План фазы 2: этап 2 — доска Canvas (packages/board) + +Обновлено: 2026-08-05 | Статус: код и тесты готовы, gates зелёный (74 теста: +43 core + 31 board). Осталось: визуальная проверка — на этапе 5. + +## Пометки о ходе (2026-08-05) + +- Объём выполнен целиком: geometry.ts, render.ts (тёмная тема по умолчанию), + input.ts, dom.ts, index.ts; корневой tsconfig.json — reference на + packages/board; зависимость @go-learn/core 0.1.0 в package.json пакета. +- Все тесты из раздела «Тесты» ниже написаны и зелёные (31 тест board). +- Отклонение: в packages/board/tsconfig.json нет references на ../core — + `tsc -b --noEmit` (гейт typecheck) с project references падает с чистого + состояния (TS6310, CLI --noEmit распространяется на referenced-проект, + TS 5.9.3). Импорт резолвится через workspace-симлинк; подробности — + комментарий в packages/board/tsconfig.json. +- Уточнение семантики territory (в первой редакции контракта стороны не + различались): структура TerritoryMap { black, white } с множествами ключей + "x,y" по цветам — зафиксировано в контракте (правка main loop при приёмке). + +## Критические долги (реестр — в начале, III.5) + +- Нет. + +## Ключевые решения (не пере-решать) + +- Доска — Canvas 2D, не DOM/SVG (плавность на 361 пересечении) — мастер-промпт. +- render(ctx, position, options) — чистая функция состояния, весь кадр из аргументов. +- Геометрия, редьюсер ввода и рендер — headless-тестируемые; DOM — тонкий адаптер + (setupCanvas, attachPointerInput), юнит-тестами не покрывается — см. матрицу + «чего НЕ покрывает ни один тест» ниже. +- Координаты — буквы без «I» (стандарт Го), числа снизу вверх. +- Дефолтная тема — тёмная (мастер-промпт: минималистичный дизайн, тёмная тема). +- Контракт — docs/INTERFACES.md, раздел «Контракт отрисовки доски» (зафиксирован + 2026-08-05 до кода). + +## Объём этапа 2 + +1. packages/board: geometry.ts (computeGeometry, pointToPixel, pixelToPoint, + hoshiPoints), render.ts (render + BoardTheme + дефолтная тёмная тема), + input.ts (reduceInput), dom.ts (setupCanvas, attachPointerInput), index.ts. +2. Отзывчивый размер и devicePixelRatio (setupCanvas). +3. Хоси, координаты и номера ходов (переключаемые), фантомный камень, + подсветка последнего хода, тач-ввод с подтверждением (первый тап — позиция, + второй тап или confirm — ход), слои разметки (LB/TR/SQ/CR, заливка + территории, приглушение мёртвых). +4. Корневой tsconfig: references += packages/board; packages/board зависит от + @go-learn/core (project reference). + +## Тесты (гейт приёмки этапа) + +- Геометрия: хоси для 9/13/19; roundtrip pixelToPoint∘pointToPixel; отклонение + точки дальше половины клетки; padding при showCoordinates on/off. +- Редьюсер ввода: тап → pending; второй тап по той же точке → commit; тап по + другой точке → перенос; confirm без pending → commit null; cancel → сброс; + тап мимо доски → без изменений. +- Рендер: мок-ctx с записью вызовов — число камней = числу занятых клеток + BoardState; фантом рисуется с phantomOpacity; метка последнего хода только + при lastMove; LB/TR/SQ/CR из markup; заливка только для territory-ключей; + мёртвые приглушены. Не проверяем каждый вызов — только инварианты. +- Wiring: orphan-check по публичным функциям packages/board (вызывающие — + тесты допустимы, дом-адаптер помечен как входная точка). +- Матрица «чего НЕ покрывает ни один тест»: реальный вид кадра, pointer-события + браузера, поведение на мобильном Safari — ручная проверка в этапе 5/7 + (смоук-сценарий, слой GUI — litany II.8). + +## Критерий выхода + +Все тесты зелёные + `npm run gates` зелёный. Визуальная проверка — на этапе 5 +(когда появится страница); до неё — demo-страница не нужна. diff --git a/docs/plans/plan-phase-3.md b/docs/plans/plan-phase-3.md new file mode 100644 index 0000000..b343668 --- /dev/null +++ b/docs/plans/plan-phase-3.md @@ -0,0 +1,53 @@ +# План фазы 3: этап 3 — ИИ ур. 1–3, эвристика (packages/ai) + +Обновлено: 2026-08-05 | Статус: выполнен (gates зелёные; perf chooseMove 19×19 ≈ 8 мс) + +## Критические долги (реестр — в начале, III.5) + +- Нет. + +## Ключевые решения (не пере-решать) + +- Отдельный пакет packages/ai — core остаётся чистыми правилами (рекомендация + отчёта сессии 2, принято). +- ИИ — синхронный чистый вычислитель; воркер появится в этапе 4 (MCTS), + протокол postMessage фиксируется там же. +- Случайность — только seeded Rng из core, передаётся параметром (детерминизм + = воспроизводимые партии и тесты). +- Легальность — только через applyMove из core; дублирование правил запрещено. +- Приоритеты и вероятности уровней — по контракту в docs/INTERFACES.md + (зафиксирован 2026-08-05 до кода). +- Простая эвристика глаза: все ортогональные соседи — свои камни, без анализа + диагоналей; осознанно грубо, ручной игры «подложить себе» бот не имитирует. + +## Объём этапа 3 + +1. packages/ai (@go-learn/ai 0.1.0, зависимость @go-learn/core): + chooseMove + LEVEL_ATARI_PROBABILITY + типы по контракту. +2. Внутренние эвристики: поиск групп в атари (groupAt/liberties из core), + ходы-спасения (>1 дамэ после хода или контрзахват), ходы-захвата, + фильтр своего глаза, кандидаты nearby (Chebyshev ≤ 2), взвешенный выбор. +3. Корневой tsconfig: references += packages/ai (без project reference на core — + TS6310, см. PROJECT_STATE; резолв через workspace-симлинк). + +## Тесты (гейт приёмки этапа) + +- Детерминизм: одинаковый seed → идентичная последовательность ходов в полной + партии бот-бот (два прогона, сравнение SGF или списка ходов). +- Приоритеты: сконструированные позиции — при rng «всегда 0» (ролл < p) + бот спасает своё атари / забирает чужое; при rng «всегда 0.99» (ролл ≥ p) + приоритет пропускается. +- Фильтр глаза: бот не заполняет свой глаз при наличии других ходов. +- Легальность: все ходы бота в длинных партиях легальны (property: случайные + партии бот-бот до двух пасов, seed запинен; ни одного ok:false). +- Завершение: партия бот-бот 9×9 завершается (over) за разумное число ходов; + после over вызов chooseMove кидает Error. +- Perf-smoke: chooseMove на позиции 19×19 средней игры < 200 мс в CI-среде + (критерий приёмки «ур.3 < 50 мс на среднем ноутбуке» — замер фактический, + число в отчёте сессии; порог в тесте с запасом, машинно-зависимый). +- Wiring: orphan-check по экспортам packages/ai. + +## Критерий выхода + +Все тесты зелёные + `npm run gates` зелёный. Полная партия 9×9 бот-бот +завершается корректно (предпосылка критерия приёмки v1 №2 для ур. 5 в этапе 4). diff --git a/docs/plans/plan-phase-4.md b/docs/plans/plan-phase-4.md new file mode 100644 index 0000000..cc51316 --- /dev/null +++ b/docs/plans/plan-phase-4.md @@ -0,0 +1,81 @@ +# План фазы 4: этап 4 — ИИ ур. 4–6, MCTS + Web Worker (packages/ai) + +Обновлено: 2026-08-05 | Статус: реализовано (гейты зелёные, 118 тестов) + +## Критические долги (реестр — в начале, III.5) + +- Нет. + +## Ключевые решения (не пере-решать) + +- MCTS-движок — чистый модуль в packages/ai (без воркер-API); воркер — тонкая + обёртка worker.ts с абстракцией порта. Однопоточно (без SharedArrayBuffer — + COOP/COEP на шареде не гарантированы). +- Плейаут — «лёгкая» политика, НЕ chooseMove этапа 3: тот сканирует всю доску + через applyMove на каждый ход (~мс на ход) — в плейауте непозволительно. + Лёгкая: дешёвая реакция на атари у последнего хода + случайные точки с + фильтром своего глаза и лимитом попыток. Компромисс силы ради бюджета — + зафиксирован здесь. +- Бюджеты: {4: 300, 5: 800, 6: 1500} мс; ур.5 — дефолт между уровнями (спека + задаёт только крайние) — записано в контракте. +- Сериализация позиции в протоколе — списком ходов от пустой доски (воркер + пересобирает и валидирует через applyMove), не снапшотом grid. +- cancel → воркер отвечает result с лучшим найденным, не молчит и не error. +- maxSimulations — override для детерминированных тестов. +- Контракт — docs/INTERFACES.md (зафиксирован 2026-08-05 до кода). + +## Объём этапа 4 + +1. packages/ai/src/mcts.ts — дерево UCT (c=1.4), ленивая экспансия, лёгкий + плейаут, оценка scorePosition с komi, бюджет clock/shouldStop/maxSimulations, + topMoves (до 3) для подсказки этапа 5. +2. packages/ai/src/worker-protocol.ts — типы WorkerRequest/WorkerResponse + + пересборка BoardState из списка ходов (валидация, error при нелегальном). +3. packages/ai/src/worker.ts — тонкая обёртка порта (onmessage → choose → + result/error; cancel → result с лучшим найденным). +4. index.ts — экспорты движка и протокола. + +## Тесты (гейт приёмки этапа) + +- Детерминизм: maxSimulations=50 + фиксированный seed → идентичный move и + topMoves в двух прогонах на одной позиции. +- Легальность: ход MCTS легален на случайных позициях (запиненные seed'ы, + 9×9 и 13×13); pass возвращается, только если легальных постановок нет или + он лучший по визитам (в любом случае applyMove не падает). +- Бюджет: фейк-clock (счётчик now()) — остановка по deadline; shouldStop() — + остановка после N симуляций, ответ всегда с легальным ходом. +- Качество (мягкий): MCTS ур.4 с maxSimulations=200 забирает группу в атари + на сконструированной позиции (где эвристика ур.1 с rng 0.99 пропускает) — + не эталон силы, а smoke «дерево реально ищет». +- Протокол: фейк-порт — choose → result с тем же requestId; нелегальный список + ходов → error; cancel активного → result, не error; cancel чужого id → + без эффекта. +- Полная партия 9×9 бот(MCTS ур.4, maxSimulations=100) против бота ур.3 — + завершается до over; все ходы легальны (предпосылка критерия v1 №2). +- Perf-smoke: 1500 мс бюджета → ≤ ~2.2 с фактически (overhead кроме симуляций + мал); число симуляций за 300 мс на 9×9 — в отчёт. +- Wiring: orphan-check обновить под новые экспорты. + +## Критерий выхода + +Все тесты зелёные + `npm run gates` зелёный. Критерий v1 №3 («ур.6 < 2 с на +среднем ноутбуке») — по бюджету 1500 мс + overhead; факт — в отчёт сессии. + +## Пометки по факту реализации (2026-08-05) + +- Все пункты объёма и тестов выполнены; гейты зелёные (118 тестов, lint без + предупреждений). Perf-факты: ~147 симуляций за 300 мс на 9×9; ур.6 на + 13×13 — фактически ~1500 мс (бюджет 1500 мс + overhead < 5 мс). +- Экспансия untried-детей — в детерминированном порядке legalMoves, первыми + идут ходы со снятием камней (захваты разворачиваются в дерево раньше). + Контракт порядок экспансии не оговаривает — эвристика внутри рамок UCT; + без неё сигнал качество-smoke (захват группы за 200 симуляций) не + выделяется на фоне шума плейаутов. +- createWorkerHandler(port, deps?) — необязательный второй параметр + WorkerDeps с инжектируемыми часами (в контракте «функция вида…»): нужен + для детерминированных тестов протокола (фейк-часы, реентерабельный cancel + из колбэка now()). +- Качество-smoke: позиция «кольцо в атари + сплошные стены» — единственная + из проверенных конструкций, где захват даёт устойчивый перекос winRate в + случайных плейаутах (≈0.95 против ≈0.57). Seed запинен: при 200 симуляциях + захват выбирается в ~75% seed'ов (при 800 — 12/12). diff --git a/docs/plans/plan-phase-5.md b/docs/plans/plan-phase-5.md new file mode 100644 index 0000000..d2d2bdd --- /dev/null +++ b/docs/plans/plan-phase-5.md @@ -0,0 +1,82 @@ +# План фазы 5: этап 5 — игровой интерфейс (apps/web, Astro) + +Обновлено: 2026-08-05 | Статус: завершён (гейты зелёные, astro build собирает dist) + +## Итоговые пометки (факт против плана) + +- Astro запинен **5.18.2**, а не 7.1.6: astro 6/7 жёстко требуют Node + > =22.12.0 (bin/astro.mjs, проверено по tarball), в окружении Node 20. + > 5.18.2 — последняя линия с engines `^20.3.0`. Конфиг и объём — по плану + > (output: 'static', без интеграций). +- Зона расширена на 3 корневых конфига игноров: eslint.config.js, + .prettierignore, .gitignore — по одной записи `**/.astro/` (генерируемый + astro каталог ломал lint/format). +- AiClient дополнен `readonly fallback` (предусмотрено текстом контракта); + loadSettings/saveSettings — необязательным параметром StorageLike. +- Воркер собран Vite (chunk ai-worker-*.js в dist). Смоук dist в браузере: + доска рендерится, ход игрока + ответ бота работают. + +## Критические долги (реестр — в начале, III.5) + +- Нет. + +## Ключевые решения (не пере-решать) + +- Astro 7.1.6 (версия сверена npm view 2026-08-05), output: 'static', без + UI-фреймворков — vanilla TS + DOM (спека: «без тяжёлых UI-фреймворков»). +- Строки UI — один ресурсный файл src/ui/strings.ts с первого дня (спека). +- Отмена хода бота — terminate + пересоздание воркера (решение по известной + проблеме cancel в реальном Worker, отчёт сессии 4). +- Режим подсказки: topMoves последнего MCTS-ответа как разметка + оценка; + дельта = изменение winRate лучшего хода между запросами (упрощение — + оценка с точки зрения стороны, чей ход анализировался; зафиксировано). +- Схема localStorage и AiClient — контракты в docs/INTERFACES.md + (зафиксированы 2026-08-05 до кода). +- Страницы: / — заглушка-визитка со ссылкой на партию; /play — игра. + Курс — этап 6, полировка (a11y, .htaccess) — этап 7. + +## Объём этапа 5 + +1. apps/web (Astro): package.json @go-learn/web 0.1.0, astro.config.mjs + (static), tsconfig (extends базового, lib DOM), workspaces += "apps/*". +2. Страница /play: доска (packages/board render + reduceInput + setupCanvas + + attachPointerInput), панель: выбор доски 9/13/19, уровня 1–6, цвета + (чёрные/белые/случайно), коми (auto/число), кнопки «Пас», «Сдаться», + «Отменить ход», «Новая партия»; счётчики пленников; статус-строка + (чей ход, причина отказа из MoveError, итог партии). +3. Подсчёт по окончании (два паса): scorePosition + отметка мёртвых + (suggestDeadGroups + ручной toggle по клику) → итог. +4. SGF: «Скачать SGF» (serializeSgf, blob-скачивание), «Загрузка SGF» — + режим просмотра (parseSgf, навигация назад/вперёд без игры). +5. Подсказки ур. 4+ (переключатель, hintsEnabled): топ-3 хода маркерами с + оценкой; дельта winRate. +6. 19×19 при ур. 4–6 — честное предупреждение (спека этапа 4). +7. Тёмная минималистичная тема (согласована с палитрой packages/board), + адаптивность desktop+мобильные. +8. lib/storage.ts (по контракту), lib/ai-client.ts (по контракту), + lib/game-store.ts (чистый, без DOM: GameTree + действия), ui/strings.ts. +9. Воркер: entry ai-worker.ts (строка подключения — комментарий в + packages/ai/src/worker.ts), сборка воркера средствами Astro/Vite. + +## Тесты (гейт приёмки этапа) + +- storage: roundtrip save→load; битый JSON/checksum/неизвестная version → + дефолты + recovered; миграция-путь задокументирован. +- game-store (чистый): новая партия, ход игрока через pending→confirm, + отказ с reason при нелегальном, пас×2 → фаза подсчёта, toggle мёртвой + группы, итог, resign → result, отмена хода, viewer: загрузка SGF, + навигация prev/next. +- ai-client: фейк-worker (инжекция) — маршрутизация ур. 1–3 в синхронный + движок, ур. 4–6 в воркер; cancel → AiCancelledError; фолбэк при отказе + воркера. +- Матрица «чего НЕ покрывает ни один тест»: реальный кадр канвы в браузере, + pointer на тачах, реальный Worker, astro build — смоук этапа 7 + + ручная проверка владельцем. +- vitest.config: include += apps/*/src. + +## Критерий выхода + +Все тесты зелёные + `npm run gates` зелёный + `astro build` собирает dist. +Критерий v1 №4 («чистый клон собирается») проверяется сборкой в этой фазе; +№2 (партия против ур.5) и №5 (мобильный Safari) — ручная приёмка владельцем +после фазы 5–7. diff --git a/docs/plans/plan-phase-6.md b/docs/plans/plan-phase-6.md new file mode 100644 index 0000000..874677f --- /dev/null +++ b/docs/plans/plan-phase-6.md @@ -0,0 +1,62 @@ +# План этапа 6 — курс и цумэ-го + +Дата: 2026-08-06. Статус: завершён (413/413, dist 280 КБ). Предшественник: этап 5 (игровой UI) +завершён, main = 4aee5b7 + hotfix play.html. + +## Ключевые решения (не пере-решать) + +1. Контент — оригинальный, авторский (не скачиваем чужие сборники цумэ-го): + это снимает лицензионный вопрос; атрибуция — CREDITS.md. Спека допускает + «свободная лицензия с атрибуцией ЛИБО формат импорта»; собственный + контент строго безопаснее (ADR-0001, I.16). +2. Интерактив в Markdown — raw HTML-элемент ``, + гидратация клиентским скриптом страницы. Никаких remark/rehype-плагинов + и новых зависимостей. Теория читаема без JS (запасной текст внутри + элемента). +3. Критерий успеха задачи — goal-предикат, вычисляемый движком + (apps/web/src/lib/goals.ts поверх @go-learn/core), НЕ сравнение с + координатами решения. Дерево вариантов в SGF — только автоответы + соперника и подсказки. +4. Формальный глаз — упрощение v1 (пустая область 1–3 пункта, окружённая + одним цветом); ложный глаз не отличаем, уроки про ложный глаз строятся + на capture-target. Уточнение — через ADR, не в этой фазе. +5. Прогресс — ключ `go-learn:progress` (ProgressV1) в том же конверте + version+checksum, API — расширение существующего storage.ts. Серия дней — + чистая функция touchStreak. +6. Зона работы — apps/web + CREDITS.md + docs; packages/board и + packages/ai не трогаем (goal-предикаты живут в apps/web). Исключение по + ADR-0003: в core добавлена одна аддитивная функция buildPosition + (parseSgf не умеет AB/AW), разметка connect — CR вместо MA. +7. Параллельные ветки: stage-6-core (механика: goals, storage, виджет, + страницы, строки) и stage-6-content (уроки, цумэ-го, CREDITS, + контентные тесты). Мерж: core → content. + +## Объём + +- Механика: goals.ts (4 предиката + формальные глаза), прогресс в storage.ts, + виджет задачи (канва board, автоответы по дереву, сброс/подсказка), + страницы /learn, /learn/[id], /tsumego, /progress, навигация, strings.ts. +- Контент: 10 уроков (по разделу каждый, программа из мастер-промпта), + ≥30 цумэ-го (difficulty 1–5, ≥3 на градацию), задачи для уроков + (≥1 интерактив там, где уместно), CREDITS.md. +- Тесты: goals (все предикаты + ложный/формальный глаз), storage roundtrip + прогресса, touchStreak (вчера/сегодня/разрыв), валидация контента (все + TaskV1 парсятся, SGF легальны движком, goal-разметка присутствует, id + уникальны, у уроков валидный frontmatter и существующие task-id), + wiring-страниц. + +## Приёмка этапа + +- npm run gates зелёные (0 warnings), astro build PASS. +- ≥10 уроков, ≥30 цумэ-го, фильтры работают, прогресс и серия дней + сохраняются в localStorage (roundtrip). +- Страница урока читаема без JS (проверка отключением гидратации). +- Вес страниц остаётся скромным (жёсткий бюджет ≤300 КБ — гейт этапа 7, но + не раздуваем заранее). + +## Риски + +- suggestDeadGroups грубовата (PROJECT_STATE) — two-eyes предикат пишем + собственный, на неё не опираемся. +- Контент-объём большой: качество SGF важнее количества сверх минимума; + валидационный тест — обязательный страховочный гейт. diff --git a/docs/plans/plan-phase-7.md b/docs/plans/plan-phase-7.md new file mode 100644 index 0000000..63667cb --- /dev/null +++ b/docs/plans/plan-phase-7.md @@ -0,0 +1,50 @@ +# План этапа 7 — полировка + +Дата: 2026-08-06. Статус: завершён (431/431, dist 296 КБ). Предшественник: этап 6 завершён, +main = 2b249a5 (413/413, dist 280 КБ). + +## Ключевые решения (не пере-решать) + +1. .htaccess — apps/web/public/.htaccess (Astro копирует public/ в dist + как есть); содержимое зафиксировано в INTERFACES.md; HTTPS-редирект + сознательно ОТСУТСТВУЕТ (только панель хостинга, + gotcha-https-redirect-proxy). +2. Клавиатурный ввод — НЕ новый код в packages/board: чистый редьюсер + курсора в apps/web/src/lib/keyboard.ts, подтверждение хода идёт через + существующий reduceInput (tap по курсору → фантом → повтор → commit). + Семантика подтверждения едина с тач-вводом (I.6, одна механика). +3. ARIA-live — role="status" polite на существующих статусных строках, + без live-region-спама: меняется только текстовое содержимое. +4. Бюджет 300 КБ — по сырым байтам dist (с запасом к gzip); тест считает + html + локальные ассеты, на которые страница ссылается. +5. Отметка «урок пройден» — ручная кнопка + markLessonDone в storage.ts; + авто- done для уроков с задачами (этап 6) сохраняется, кнопка — + дополнение, не замена. +6. Зона — только apps/web; packages и корневые конфиги не трогаем. + +## Объём + +- public/.htaccess + wiring-проверка директив в dist. +- keyboard.ts (reduceCursor, кламп, тесты) + подписки в play-page.ts и + task-widget.ts (курсор, стрелки/Enter/Space/Escape, aria-labels). +- prefers-reduced-motion блок, :focus-visible, контроль контраста + (--muted при недоборе), role="status" на статусах. +- Кнопка «Отметить пройденным» на странице урока + markLessonDone + тест. +- Wiring-тест бюджета веса уроков ≤300 КБ. +- Строки — strings.ts. + +## Приёмка этапа (и критерии v1, что можно проверить автоматически) + +- npm run gates зелёные, 0 warnings; astro build PASS. +- dist/.htaccess с требуемыми директивами; без RewriteCond %{HTTPS}. +- Каждая страница урока ≤ 300 КБ по тесту. +- Критерии v1: №1 (тесты) и №4 (чистый клон собирается) — автоматически; + №2 (партия vs ур.5), №3 (тайминги бота), №5 (мобильный Safari) — + ручные, владельцу. + +## Риски + +- Клавиатурный курсор не должен ломать pointer-ввод: общий InputState, + подписки независимы. +- suggestDeadGroups грубовата (PROJECT_STATE) — в этап 7 НЕ входит + (отдельная задача через ADR при жалобах на подсчёт). diff --git a/docs/plans/plan-phase-8-1.md b/docs/plans/plan-phase-8-1.md new file mode 100644 index 0000000..0708eee --- /dev/null +++ b/docs/plans/plan-phase-8-1.md @@ -0,0 +1,47 @@ +# План этапа 8.1 — регистрация отдельной страницей, восстановление пароля, капча + +Дата: 2026-08-06. Статус: завершён (467/467 + smoke 58 OK). Предшественник: этап 8 завершён, +main = 7da814c (461/461 + smoke OK). Решения владельца: своя автономная +капча; OAuth — позже (дизайн docs/design/oauth-vk-yandex.md). + +## Ключевые решения (не пере-решать) + +1. Регистрация — ОТДЕЛЬНАЯ страница /register.html (не модалка): проще, + доступнее (a11y), диплинкабельна. /account.html становится страницей + входа со ссылками «Нет аккаунта? Зарегистрируйтесь» и «Забыли + пароль?». +2. Капча — автономная SVG (БЕЗ GD): glyph-повроты + шумовые кривые; + код в сессии (sha256, 10 мин), одноразовая; только на регистрации + (login прикрыт rate limit). SVG выбран вместо GD: GD не везде есть + на хостингах, а локальный рантайм из deb — тем более. +3. Восстановление — токен 1 час, sha256 в БД, request всегда 200 (нет + enumeration), сбой mail() → 503; тестовый режим GOLEARN_TEST=1: + письмо в файл, капча-код в заголовке X-Captcha-Debug. +4. Параллельные ветки: stage-8-1-server (эндпоинты + миграция + password_resets + smoke) и stage-8-1-web (страницы + строки); + зоны не пересекаются. Мерж: server → web. + +## Объём + +- server/: api/captcha.php, api/password-reset/request.php, + api/password-reset/confirm.php; register.php += captcha; миграция + password_resets; config.example.php += base_url, mail_from; + smoke.sh += сценарии капчи и восстановления. +- apps/web: pages/register.html, pages/password-reset.html (два + режима по query), правки account.astro (ссылки), sync-client.ts + += requestPasswordReset/confirmPasswordReset + captcha в register, + strings.ts. +- docs/deploy.md += шаги: проверка mail() (тестовое письмо), base_url + в config.php. + +## Приёмка + +- npm run gates зелёные; smoke.sh (с новыми сценариями) зелёный + локально; astro build PASS (18 страниц). + +## Риски + +- Доставляемость mail() с shared-хостинга (SPF/DKIM) — ручная проверка + владельцем на деплое; риск в PROJECT_STATE. +- SVG-капча слабее GD/сервисной к OCR-ботам — для учебного сайта + приемлемо; при злоупотреблениях — Yandex SmartCaptcha через ADR. diff --git a/docs/plans/plan-phase-8.md b/docs/plans/plan-phase-8.md new file mode 100644 index 0000000..0623ca1 --- /dev/null +++ b/docs/plans/plan-phase-8.md @@ -0,0 +1,50 @@ +# План этапа 8 — личный кабинет и синхронизация прогресса + +Дата: 2026-08-06. Статус: завершён (461/461 + smoke.sh OK). Предшественник: этапы 1–7 завершены, +main = a99aeb9 (431/431). Активация — команда владельца; модель учётки и +до-триаж раздела V — ADR-0004. + +## Ключевые решения (не пере-решать) + +1. Модель — email+пароль (ADR-0004); БЕЗ верификации почты и + восстановления пароля (нет SMTP на shared-хостинге; предупреждение в + UI при регистрации). +2. PHP 8 без фреймворка, PDO SQLite (WAL, busy_timeout, БД вне web root), + prepared statements везде, лимит тела 256 КБ, rate limit по IP, + SameSite=Lax + заголовок X-GoLearn-Client как CSRF-мера. Синтаксис + PHP ≥ 8.0 — совместимость с хостингами. +3. Прогресс на сервере — opaque ProgressV2 (ProgressV1 + updatedAt), + last-write-wins по updatedAt; localStorage — SoT для гостей; + миграция V1→V2 в storage.ts по контракту конверта. +4. Превью статическое: кабинет деградирует тихо при недоступном API + (status 'unavailable', пометка в UI, без алертов). +5. Локальная проверка бэкенда: PHP 8.2 CLI (распакованные deb-пакеты в + /tmp/debs, обёртка /tmp/php-run) + php -S + curl smoke.sh. В среде + нет системного PHP — гейты npm PHP не запускают. +6. Параллельные ветки: stage-8-server (server/) и stage-8-web (apps/web); + общих файлов нет. Мерж: server → web. + +## Объём + +- server/: api/{register,login,logout,me,progress,health}.php, + lib/{db,http,auth,ratelimit}.php, config.example.php, .htaccess + (заголовки, запрет листинга), tests/smoke.sh. +- apps/web: storage.ts (ProgressV2 + миграция), sync-client.ts + (mergeDecision чистая + fetch), pages/account.astro, nav + strings. +- docs/deploy.md — деплой-чеклист V.2 (артефакты, порядок, smoke, + откат, пути на хостинге, бэкап/restore SQLite). +- PROJECT_STATE: этап 8 разморожен → в работе → завершён. + +## Приёмка этапа + +- npm run gates зелёные (0 warnings), astro build PASS. +- server/tests/smoke.sh зелёный локально (PHP 8.2): полный прогон + register/login/put/get/401/403/429/health. +- Ручная приёмка владельцем на хостинге — по docs/deploy.md. + +## Риски + +- PHP на хостинге владельца может отличаться (версия, pdo_sqlite) — + чек-лист deploy.md начинается с проверки `php -v` и модулей. +- Сессии PHP на shared-хостинге: gc и путь сессий — зафиксировать + session_save_path в config при проблемах (заметка в deploy.md). diff --git a/docs/reports/session-1.md b/docs/reports/session-1.md new file mode 100644 index 0000000..616a1b1 --- /dev/null +++ b/docs/reports/session-1.md @@ -0,0 +1,51 @@ +# Отчёт сессии 1 (для следующей сессии) + +Дата: 2026-08-05 | Ветка: main | Последний коммит: см. git log + +## Что сделано + +1. **Шаг 0 — каркас.** Триаж литании (ADR-0001, профиль M), git + .gitattributes + (`* text=auto eol=lf`) с первого коммита, docs-каркас, AGENTS.md (точка входа), + гейты `npm run gates` (prettier + eslint + tsc -b --noEmit + vitest). + Запинены: typescript 5.9.3, vitest 4.1.10, eslint 10.8.0, prettier 3.9.6, + fast-check 4.9.0. CI — GitHub Actions (ubuntu+windows), написан, не запускался. +2. **Этап 1 — packages/core.** Движок правил полностью по контракту + docs/INTERFACES.md: доски 9/13/19, группы/дамэ (flood fill — ADR-0002, + бенчмарк: union-find медленнее в 1.74×), захваты, запрет самоубийства + (самоубийство с захватом легально), простое ко + позиционное суперко, + подсчёт Tromp-Taylor со снятием dead-камней и ничьей, история-дерево с + ветвлением, SGF FF[4] (парсер+генератор, roundtrip), seeded RNG (mulberry32). + Тесты: 43/43 (юнит, wiring orphan-check, интеграция полной партии 9×9, + property-based fast-check). +3. **Решения владельца (молчание = дефолт):** каркас подтверждён, коми 13×13 = 5.5, + LICENSE = MIT (файл добавлен), CI на GitHub Actions. + +## Что НЕ сделано + +- Этапы 2–9 не начаты. +- CI не проверен на GitHub (репо локальный). +- Настоящая позиция двойного ко для юнит-теста суперко (см. PROJECT_STATE). + +## Подводные камни для следующей сессии + +- **Среда выполнения:** `/mnt/agents/output` НЕ поддерживает симлинки → + `npm install` невозможен там. Работать только через git worktree в `$HOME` + (`git worktree add $HOME/work- ` из /mnt/agents/output/go-learn). + Каталог `$HOME` может сбрасываться между сессиями — worktree пересоздавать, + коммиты в общем .git не теряются. `git worktree prune` не запускать, пока + живы субагенты; мёртвые записи удалять `git worktree remove --force `. +- **У субагентов своя файловая система** (или $HOME сбрасывается): НЕЗАКОММИЧЕННЫЕ + правки в worktree теряются. Правило: перед передачей работы субагенту всё + коммитить; контракты передавать и в тексте задания, не только файлами. +- Контракт core уже зафиксирован — изменения только через обновление + INTERFACES.md ПЕРЕД кодом. UI этапа 5 ветвится по полю `reason` (MoveError), + не по тексту error. +- В /mnt-репозитории были мусорные пустые каталоги от ошибочной команды с + brace-expansion (шелл без bash) — вычищены в сессии 1; mkdir писать без `{}`. + +## С чего начать продолжение + +Этап 2 (доска Canvas): сначала дописать контракт рендера в docs/INTERFACES.md +(render(ctx, position, options) — чистая функция, devicePixelRatio, хоси, +тач-ввод), затем код. План — docs/plans/plan-phase-1.md обновить или создать +plan-phase-2.md. diff --git a/docs/reports/session-10.md b/docs/reports/session-10.md new file mode 100644 index 0000000..f5bd019 --- /dev/null +++ b/docs/reports/session-10.md @@ -0,0 +1,75 @@ +# Отчёт сессии 10 — этап 10: дизайн-перенос «васи» + +Дата: 2026-08-06. Ветка main: 0a5e2bd → 3f8fc5b (+docs(sync)). Версия 0.3.0. + +## Вводные + +Пользователь получил от Kimi WebSites переносной пакет дизайна +(`/mnt/agents/upload/Иго-сайт-перенос.md`: 8 HTML-страниц, style.css ~23 КБ, +2 JS, 3 JPG в base64) и попросил натянуть его на наш каркас. Эталон +распакован и сохранён в `docs/design/kimi-export/` (только как референс, в +сборку не входит). + +## Что сделано + +1. **Контракт** (INTERFACES.md, «Дизайн-перенос (этап 10)»): токены, + каркас Layout, тема доски, паттерны страниц, приёмка. Три сознательных + отклонения от палитры эталона ради WCAG AA ≥ 4.5:1 (замерено расчётом): + `--ink-faint` #a29885→#6e675c, `--terra` #c25a33→#a84e2c, + `--ok` #4a7a4e→#46734a. +2. **Изображения**: hero/ink/stones сжаты (276→53 КБ, 215→118 КБ, + 96→24 КБ) в `apps/web/public/assets/`. +3. **Три параллельных кодера** (ветки design/a,b,c, слиты --no-ff): + - A: Layout.astro — глобальные стили = адаптация style.css целиком + + `[hidden]` + go-task на светлых токенах; шапка-стекло с канji 碁, + проп `section` → aria-current, мобильное меню, подвал, Google Fonts + (PT Sans/Serif/Mono); DEFAULT_THEME доски → светлая; главная по + эталону (hero-meta с честными числами: 10/33/6). + - B: learn, урок, tsumego, progress — разметка по паттернам. + - C: play, account, register, password-reset — разметка по паттернам. +4. **Интеграционные правки оркестратора** (найдены скриншот-проверкой): + - страницы потока B без `.container` — контент уезжал в полную ширину; + - сортировка уроков по `data.order` не работала (во frontmatter у всех + уроков `order: 1` — исторически) → сортировка по id файла; + - shiki рендерил ASCII-диаграммы тёмной темой поверх светлого pre → + `markdown.shikiConfig.theme: 'github-light'` в astro.config; + - дублирующий H1 из тела markdown → скрыт через + `.lesson-body > :global(h1:first-child)` (scoped-стили Astro не + достают до разметки `` — важный нюанс, записать в опыт). + +## Проверки + +- gates: 467/467 (28 файлов), format+lint+typecheck зелёные — после + каждого слияния и финально. +- Вес страниц (HTML+CSS+JS+img eager): максимум ~157 КБ (index с hero); + ink/stones — loading="lazy". Бюджет ≤300 КБ выполнен. +- Контраст ключевых пар ≥ 4.5:1 (расчёт по токенам + отчёт кодера A). +- Скриншот-проверка в браузере: index, learn, урок, tsumego, play, + account, register — соответствуют эталону; логика (id, data-атрибуты, + островки) на месте. + +## Доставка + +- dist → /mnt/agents/output/app, сохранена **новая версия превью** + (website_version_manager, id b0b2da7). Прежняя рабочая версия не + тронута — можно сравнить. + +## Опыт сессии + +- Scoped-стили .astro не действуют на разметку из `` и + `set:html` — использовать `:global(...)` с узким селектором. +- Shiki в Astro по умолчанию тёмный: при смене темы сайта не забыть + `shikiConfig.theme`. +- /mnt FS: повторяющиеся сбои `index.lock`/`HEAD.lock` при git-операциях — + лечатся повтором; stale HEAD.lock удалять вручную после проверки, что + git-процессов нет. merge лучше делать маленькими порциями. + +## Открыто + +- Тексты кикеров/абзацев этапа 10 живут прямо в разметке страниц + (помечены комментариями «до переноса в strings.ts») — кандидат на + чистку при следующем касании. +- Поле `order` во frontmatter уроков у всех = 1 (не используется; + порядок — по id). Либо проставить 1..10, либо убрать из схемы. +- Восстановление пароля/капча в статическом превью не работают (API на + хостинге) — by design, картинка капчи в превью битая. diff --git a/docs/reports/session-11.md b/docs/reports/session-11.md new file mode 100644 index 0000000..f4f6ed2 --- /dev/null +++ b/docs/reports/session-11.md @@ -0,0 +1,96 @@ +# Отчёт сессии 11 — этап 11: аудит контента, страница «Источники», дисклеймер + +Дата: 2026-08-07. Ветка main: 32199a6 → 66dc7a5 (+docs(sync)). Версия 0.3.1. + +## Вводные + +Владелец спросил, насколько можно доверять обучающему контенту, и принял +решение: (1) обязательный аудит курса по авторитетным источникам; (2) отдельная +страница «Источники» — только голые проверяемые факты, что не нашли — честно +«не найдено», выдумывать КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО; (3) дисклеймер об открытом +редактировании. Железное правило зафиксировано в INTERFACES.md (этап 11). + +## Что сделано + +1. **Контракт** (INTERFACES.md, «Аудит контента, страница „Источники“, + дисклеймер (этап 11)», 4b6bf80): правило реально открытых URL с цитатой, + формат отчётов и страницы, приёмка с выборочной перепроверкой оркестратором. +2. **Аудит — 3 параллельных research-агента** (ветки audit/r1..r3, слиты + --no-ff; отчёты `docs/audit/01-03.md`, `04-07.md`, `08-10.md`): + - 85 проверяемых утверждений: **70 подтверждено, 10 расхождений, + 5 не найдено**. Источники: правила AGA (зеркала BGA и cs.cmu.edu), + Sensei's Library, rusgolib.gofederation.ru. + - Каждое подтверждение — URL + дословная цитата из реально открытой + страницы. +3. **Перепроверка оркестратором** (приёмка контракта): открыто и сверено + с цитатами 5 URL из отчётов (Atari, Calling Out Atari, Ladder, + Bamboo Joint, Dango — все Sensei's Library); дополнительно curl'ом + подтверждены ключевые для правок страницы ChineseRules (коми 7.5, cost 0), + Komi и 34PointHighApproachAttachDrawbackJoseki (цукэ-хики). Часть страниц + SL/BGA в момент проверки отвечала таймаутами — то же наблюдали аудиторы. +4. **Дисклеймер** (32199a6): примечание на /learn.html («курс создан с + помощью ИИ и находится в открытом редактировании…») со ссылкой на + «Источники»; пункт «Источники» в футере. +5. **Страница /istochniki.html** (218c292): 8 тематических разделов, + формат «факт одной строкой — ссылка на первоисточник», 50 ссылок + (все — только из аудит-отчётов, `target="_blank" rel="noopener"`), + обязательный честный раздел «Что пока не удалось подтвердить» (5 пунктов). + Строки вынесены в `strings.ts` (раздел `sources`). +6. **Правки уроков по подтверждённым расхождениям** (218c292, 9 файлов): + - урок 01: «атари» вслух — практика обучающих партий, опытные не объявляют; + - урок 02: атари вынуждает, но не обязывает отвечать; «сирэ» → + «ситё-прерыватель» (ladder breaker); + - урок 03: нелегальный ход по AGA отменяется и засчитывается как пас; + - урок 05: ход в свою территорию — −1 очко только по японским правилам, + по китайским (нашим) счёт тот же; про коми добавлено: наши 6,5/5,5, + стандарт китайских — 7,5; + - урок 06: изолированное косуми локально неразрезаемо (миаи); + - урок 07: неподтверждённые числа камней → канонические «стены 2/3/4»; + убран термин «линия смерти»; + - урок 08: «ханэ у подошвы 3-4» → цукэ-хики с верными ролями цветов + (цукэ/хики — чёрные, ханэ — белые); + - урок 09: «танэки» → «такэфу»; «столб» → «данго» (пельменная форма); + диаграммы тигровой пасти и бамбука приведены к собственному тексту; + - урок 10: убран неподтверждённый диапазон «1–3 очка» и суперлатив + «самый частый приём». + +## Проверки + +- gates: **467/467** (28 файлов), format+lint+typecheck зелёные — прогон + оркестратором в чистом worktree на merge-коммите 66dc7a5. +- Сборка: 19 страниц, `dist/istochniki.html` на месте; eager-вес страницы + источников ~38 КБ (HTML 20 КБ + общий CSS 18 КБ) — бюджет ≤300 КБ с + большим запасом. +- Скриншот-проверка /istochniki.html в браузере: шапка, разделы, ссылки — + в порядке. +- Контрактная перепроверка ≥5 URL — выполнена (см. выше). + +## Доставка + +- dist → /mnt/agents/output/app (rsync с `--exclude=.git`), сохранена + **новая версия превью** (website_version_manager, id 70226a8). + +## Опыт сессии + +- Sensei's Library периодически отвечает таймаутами на web_open_url — + аудиторам и оркестратору стоит делать повторы и дублировать проверку + curl'ом; недоступность источника фиксировать честно («не найдено»). +- Аудит-отчёты от субагентов пришли неформатированными — prettier --check + падал; механическая нормализация docs/audit/*.md (только формат, без + изменения текста) включена в коммит 218c292. + +## Открыто + +- **Расхождение коми**: движок и INTERFACES.md держат 6,5 (19×19) по решению + владельца от 2026-08-05, а стандарт китайских правил — 7,5. Урок теперь + честно разводит «наши партии» и «стандарт». Сменить ли дефолт движка на + 7,5 — отдельное решение владельца (затронет контракт, тесты подсчёта, + сохранённые партии). +- Поле `order` во frontmatter уроков у всех = 1 (не используется; порядок — + по id). Либо проставить 1..10, либо убрать из схемы. +- Тексты кикеров этапа 10 частично живут в разметке (помечены «до переноса + в strings.ts»). +- Этап 8.2 (OAuth VK ID / Yandex ID) ждёт ключи приложений от владельца; + дизайн — docs/design/oauth-vk-yandex.md. Иностранные провайдеры запрещены. +- Этап 9 (KataGo ONNX) заморожен; деплой на хостинг и ручная приёмка — + «немного попозже» (владелец). diff --git a/docs/reports/session-2.md b/docs/reports/session-2.md new file mode 100644 index 0000000..0d72101 --- /dev/null +++ b/docs/reports/session-2.md @@ -0,0 +1,54 @@ +# Отчёт сессии 2 (для следующей сессии) + +Дата: 2026-08-05 | Ветка: main | Этап 2 (доска Canvas) — завершён + +## Что сделано + +1. **Контракт packages/board** зафиксирован в docs/INTERFACES.md до кода + (геометрия, рендер, редьюсер ввода, DOM-адаптер). +2. **packages/board реализован:** geometry.ts (computeGeometry, pointToPixel, + pixelToPoint, hoshiPoints 19→9/13→5/9→5), render.ts (render — чистая функция, + 11 слоёв в контрактном порядке, дефолтная тёмная тема, координаты без «I», + хоси, фантом, метка последнего хода, номера ходов, разметка LB/TR/SQ/CR, + заливка территории TerritoryMap, приглушение мёртвых), input.ts (reduceInput: + тап → фантом, повторный тап или confirm → ход), dom.ts (setupCanvas с DPR, + attachPointerInput с отпиской). +3. **Тесты: 74/74** (43 core + 31 board): геометрия, таблица переходов редьюсера, + инварианты рендера через мок-рекордер ctx, orphan-check. +4. **Интеграционные правки main loop при приёмке:** + - TerritoryMap { black, white } вместо префиксной кодировки "w:x,y" — + контракт синхронизирован; + - eslint: `no-undef: off` для TS-файлов (typescript-eslint рекомендация, + DOM-глобалы видит только tsc) — костыльные eslint-disable убраны; + - в контракте board типы ключей приведены к string (типа PointKey в core нет); + - вычищены пустые мусорные каталоги `{docs…` от команды с brace-expansion. + +## Что НЕ сделано + +- Этапы 3–9 не начаты. +- Визуальная проверка кадра, pointer-события, мобильный Safari — смоук этапа 5/7 + (записано в матрице «чего НЕ покрывает ни один тест», plan-phase-2). +- CI на GitHub не запускался (репо локальный). + +## Подводные камни для следующей сессии + +- Среда: работать только через `git worktree add $HOME/work- ` из + /mnt/agents/output/go-learn; $HOME субагента/сессии может быть чистым — + worktree пересоздавать (`git worktree remove --force <старый путь>` если + «already checked out»), коммиты в общем .git не теряются. Незакоммиченное + теряется — коммитить перед концом работы. Контракты дублировать в тексте + задания субагенту, не только файлами. +- `tsc -b --noEmit` несовместим с project references (TS6310, TS 5.9.3) — + packages/board без reference на core, резолв через workspace-симлинк. + Не «чинить» добавлением reference — сломает гейт с чистого состояния. +- docs/*.md коммитить только после prettier --write (format-гейт ловит и md). +- mkdir без фигурных скобок — шелл без brace-expansion плодит мусорные каталоги. + +## С чего начать продолжение + +Этап 3 (ИИ ур. 1–3, эвристика): сначала контракт модуля ИИ в docs/INTERFACES.md +(выбор хода по эвристикам: спасти своё атари → забрать чужое → взвешенный +случайный ход вблизи хода противника → не заполнять свой глаз → пас; уровни +0.3/0.6/0.9; RNG — createSeededRng из core), затем код. Решить и записать: +packages/ai отдельным пакетом или модулем в core — рекомендация: отдельный +packages/ai (core остаётся чистыми правилами). Создать docs/plans/plan-phase-3.md. diff --git a/docs/reports/session-3.md b/docs/reports/session-3.md new file mode 100644 index 0000000..36a62ed --- /dev/null +++ b/docs/reports/session-3.md @@ -0,0 +1,53 @@ +# Отчёт сессии 3 (для следующей сессии) + +Дата: 2026-08-05 | Ветка: main | Этап 3 (ИИ ур. 1–3, эвристика) — завершён + +## Что сделано + +1. **Контракт packages/ai** зафиксирован в docs/INTERFACES.md до кода: + chooseMove, AiLevel 1–3, LEVEL_ATARI_PROBABILITY {1:0.3, 2:0.6, 3:0.9}, + приоритеты save-atari → capture-atari → nearby (Chebyshev ≤ 2) → random → + pass, фильтр своего глаза, легальность только через applyMove из core. +2. **packages/ai реализован** (@go-learn/ai 0.1.0): ai.ts (chooseMove, роллы + rng в фиксированном порядке — позиция в потоке не зависит от доски), + candidates.ts (легальные + фильтр глаза), tactics.ts (атари, спасение + по факту «>1 дамэ после хода» — контрзахват покрыт без частных случаев), + pick.ts (равномерный/взвешенный выбор, nearby с весами 2/1). +3. **Тесты: 99/99** (74 + 25 новых): приоритеты на сконструированных позициях + с rng-заглушками (0 / 0.99), фильтр глаза, Error при over, детерминизм + (3 seed × 2 прогона полных партий), 12 партий бот-бот 9×9 до over ≤ 200 + ходов с проверкой легальности каждого хода, perf-smoke, orphan-check. +4. **Perf: chooseMove 19×19 ≈ 7.7 мс** (критерий «ур.3 < 50 мс» с запасом). +5. Интеграционная правка main loop: return type в хелперах теста — lint + 0 warnings (цель литании для нового кода). + +## Что НЕ сделано + +- Этапы 4–9 не начаты. Воркер и протокол postMessage — этап 4 (по контракту). + +## Подводные камни для следующей сессии + +- Среда: git worktree в $HOME, пересоздание (`git worktree remove --force` при + «already checked out»), коммитить всё перед концом — незакоммиченное теряется; + контракты дублировать в тексте задания субагенту. +- packages без project references на core (TS6310 при `tsc -b --noEmit`) — + резолв через workspace-симлинк; не «чинить». +- docs/*.md — через prettier --write перед коммитом (format-гейт). +- Ролл-семантика chooseMove задокументирована в шапке ai.ts: два ролла на ход + всегда — менять порядок/число роллов = менять воспроизводимость партий, + только через ADR. +- Порог perf-теста 200 мс — машинно-зависимый; не ужесточать в CI. + +## С чего начать продолжение + +Этап 4 (ИИ ур. 4–6, MCTS в Web Worker): + +1. Сначала в docs/INTERFACES.md: протокол postMessage (запрос/ответ/отмена, + сериализация позиции, бюджет времени, уровень) И контракт движка MCTS + (UCT, плейауты по эвристикам этапа 3 — переиспользовать packages/ai). +2. Бюджеты: ур.4 — 300 мс, ур.6 — 1500 мс; таргет 9×9 и 13×13, на 19×19 — + честное предупреждение в UI (этап 5). +3. Только однопоточный код — SharedArrayBuffer требует COOP/COEP, которых + может не быть на шаред-хостинге. +4. Плейауты тоже через seeded Rng — реплеи MCTS воспроизводимы. +5. Создать docs/plans/plan-phase-4.md. diff --git a/docs/reports/session-4.md b/docs/reports/session-4.md new file mode 100644 index 0000000..a9af2e6 --- /dev/null +++ b/docs/reports/session-4.md @@ -0,0 +1,63 @@ +# Отчёт сессии 4 (для следующей сессии) + +Дата: 2026-08-05 | Ветка: main | Этап 4 (MCTS ИИ ур. 4–6 + воркер) — завершён + +## Что сделано + +1. **Контракт MCTS + протокол postMessage** зафиксирован в docs/INTERFACES.md + до кода: chooseMoveMcts (UCT c=1.4, ленивая экспансия, лёгкий плейаут, + бюджеты {4:300, 5:800, 6:1500} мс, maxSimulations для тестов, topMoves до 3), + WorkerRequest/WorkerResponse (позиция — списком ходов, cancel → result). +2. **packages/ai/src/mcts.ts** — движок: лёгкая плейаут-политика (атари-реакция + вокруг последнего хода + случайные точки с фильтром глаза), оценка + scorePosition с komi, стоп по deadline/shouldStop/maxSimulations. + Детерминированная экспансия (захваты первыми) — задокументирована. +3. **worker-protocol.ts + worker.ts** — пересборка позиции из списка ходов с + валидацией, createWorkerHandler(port, deps?) с инжектируемыми часами; + браузерный self.onmessage опущен — подключает этап 5 (строка в комментарии). +4. **Тесты: 118/118** (+19): детерминизм, легальность 9×9/13×13, бюджет по + фейк-часам, shouldStop, захват в атари за 200 симуляций (smoke качества), + партия MCTS vs ур.3, протокол через фейк-порт, perf-smoke. +5. **Perf: ~147 симуляций за 300 мс на 9×9**; ур.6 на 13×13 ≈ 1500 мс wall — + критерий v1 №3 «<2 с» выполнен. + +## Что НЕ сделано + +- Этапы 5–9 не начаты. UI и реальное подключение воркера — этап 5. + +## Подводные камни для следующей сессии (этап 5) + +- **Cancel в реальном Worker**: синхронный handler не примет сообщение во время + счёта (event loop). Варианты: чанкование chooseMoveMcts через setTimeout в + воркере (меняет код движка/обёртки — через контракт!) или terminate воркера + - пересоздание (протокол stateless — позиция передаётся списком ходов, + поэтому пересоздание дёшево). Рекомендация: terminate+recreate, код не трогать. +- **Сила MCTS ограничена** ~147 сим/300 мс на 9×9 (applyMove копирует + positionHashes — O(n²) в плейаутах). Если на приёмке ур. 4–6 слабоваты — + отдельная задача оптимизации core (хэш инкрементальный), через ADR. +- **19×19 для MCTS**: UI обязан показать честное предупреждение (спека этапа 4); + рекомендуемые таргеты — 9×9 и 13×13. +- Среда: worktree в $HOME, пересоздание, коммитить всё; контракты дублировать + в задании; docs через prettier перед коммитом; project references не добавлять. +- Seed воркера: протокол передаёт seed в каждом choose — UI формирует + (например, seed партии + requestId); это часть воспроизводимости реплеев. + +## С чего начать продолжение + +Этап 5 (игровой интерфейс) — первый этап с реальным сайтом: + +1. Astro в apps/web (проверить актуальную версию astro через npm view ДО + конфигов — gotcha-llm-stale-configs; статический выход, без SSR). +2. Страница партии: packages/board render + input-редьюсер, выбор + доски/уровня/цвета, коми (defaultKomi + ручное), пас/сдаться/отмена/новая, + счётчики пленников, скачивание SGF (serializeSgf) и загрузка для просмотра + (parseSgf), режим подсказки на ур. 4+ (topMoves из MctsResult, дельта + после хода игрока). +3. Подключение worker.ts: строка подключения — комментарий в worker.ts; + fallback: если Worker недоступен — синхронный вызов в основном потоке + (fail-safe, с пометкой в UI). +4. Строки UI — в один ресурсный файл с первого дня (спека). +5. Смоук вручную: визуальная проверка кадра из матрицы plan-phase-2. +6. Контракт: схема localStorage для настроек/прогресса партии — в INTERFACES.md + ДО кода (version + checksum + миграция + roundtrip-тест). +7. Создать docs/plans/plan-phase-5.md. diff --git a/docs/reports/session-5.md b/docs/reports/session-5.md new file mode 100644 index 0000000..ef359ab --- /dev/null +++ b/docs/reports/session-5.md @@ -0,0 +1,68 @@ +# Отчёт сессии 5 (для следующей сессии) + +Дата: 2026-08-05 | Ветка: main | Этап 5 (игровой интерфейс) — завершён + +## Что сделано + +1. **Контракты этапа 5** в docs/INTERFACES.md до кода: схема localStorage + (конверт version+checksum FNV-1a, SettingsV1, recovered при битом состоянии) + и AiClient (ур. 1–3 синхронно, ур. 4–6 воркер, cancel=terminate+recreate, + fail-safe синхронный фолбэк с пометкой). +2. **apps/web (Astro 5.18.2, static, vanilla TS + DOM):** + - lib: storage.ts, ai-client.ts, game-store.ts (чистый: фазы + playing/scoring/viewer, гонка requestId, undo пары ходов, подсчёт с + toggle мёртвых, SGF-просмотр), territory.ts (карта территории для заливки); + - страницы: / (визитка) и /play (канва + полная панель: размер/уровень/ + цвет/коми, чекбоксы, Пас/Сдаться/Отменить/Новая, счётчики пленников, + статус-строка с причинами MoveError, предупреждение 19×19 при ур. 4–6, + подсказки топ-3 с winRate и дельтой, скачивание/загрузка SGF); + - ui/strings.ts — все строки UI одним файлом; тёмная тёплая тема, + адаптивность. +3. **Тесты: 147/147** (+29: storage 7, ai-client 5, game-store 12, territory 3, + wiring 2), lint 0 warnings. **astro build PASS, dist 80K**, worker-chunk + 9.75 kB. Браузерный смоук исполнителем: рендер доски, пас + ответ бота, + hidden-состояния. +4. Интеграционная правка main loop: astro 5.18.2 (MIT) добавлен в + THIRD_PARTY_LICENSES.md — лицензионный гейт. + +## Принятые отклонения (задокументированы исполнителем) + +- Astro 5.18.2 вместо 7.1.6: astro 6/7 требуют Node ≥22.12 (hardcoded в bin), + окружение Node 20.20.2. Записано в PROJECT_STATE. +- AiClient.fallback: boolean и StorageLike-инжекция в storage — дополнения + контрактов, поведение не меняют. +- eslint.config.js/.prettierignore/.gitignore += `**/.astro/`. + +## Что НЕ сделано + +- Этапы 6–9 не начаты. +- Ручная приёмка: критерии v1 №2 (партия vs ур.5 до подсчёта) и №5 (мобильный + Safari) — владельцем; визуальный смоук кадра — по матрице plan-phase-2. + +## Подводные камни для следующей сессии (этап 6) + +- Среда: worktree в $HOME, пересоздание, коммитить всё; контракты дублировать + в задании; docs через prettier; project references не добавлять; `.astro/` + в игноре — не пугаться генерируемого каталога. +- suggestDeadGroups грубовата (предлагает живые группы на разрежённых + позициях) — при работе с уроками «жизнь и смерть» это станет заметно; + кандидат на уточнение (через ADR, зона core). +- Строки UI — только через ui/strings.ts, новые строки туда же. +- Worker-chunk собирается Vite; при изменении packages/ai/src/worker.ts + пересобирать apps/web. + +## С чего начать продолжение + +Этап 6 (курс + цумэ-го): + +1. Контракт форматов в docs/INTERFACES.md ДО кода: схема Markdown-урока + (разделы → уроки → шаги), формат интерактивной задачи (SGF-позиция + + условие успеха, проверяемое движком — не хардкод координат), схема + localStorage прогресса курса (version + checksum, ключ go-learn:progress). +2. Программа по спеке: дамэ/захват → атари/лестница/сеть → самоубийство и ко → + глаза, жизнь и смерть, ложный глаз → подсчёт/пас/конец → связь/разрезание → + углы-стороны-центр → 3–4 базовых дзёсэки → форма → простое ёсэ. +3. Цумэ-го ≥30 задач, фильтры, свободная лицензия с атрибуцией (CREDITS.md) + либо формат импорта. +4. «Мой прогресс»: уроки, задачи, серия дней. +5. Создать docs/plans/plan-phase-6.md. diff --git a/docs/reports/session-6.md b/docs/reports/session-6.md new file mode 100644 index 0000000..be1a9f1 --- /dev/null +++ b/docs/reports/session-6.md @@ -0,0 +1,76 @@ +# Отчёт сессии 6 (для следующей сессии) + +Дата: 2026-08-06 | Ветка: main | Этап 6 (курс + цумэ-го) — завершён + +## Что сделано + +1. **Контракты этапа 6 в INTERFACES.md до кода** (раздел «Форматы данных + уроков и задач»): схема Markdown-урока (10 фиксированных разделов, + `` raw-HTML в теле), TaskV1 (goal-предикаты вместо + хардкода координат), ProgressV1 + touchStreak, страницы + /learn, /learn/[id], /tsumego, /progress. План — docs/plans/plan-phase-6.md. +2. **ADR-0003**: аудит контракта выявил, что parseSgf не умеет AB/AW и MA. + В core добавлена ОДНА аддитивная функция `buildPosition` (валидация: + вне доски/дубли/группа без дамэ → Error; корректный positionHashes); + разметка connect — CR вместо MA. +6 тестов core. +3. **Механика (подагент, ветка stage-6-core)**: goals.ts (4 предиката + + формальный глаз v1: пустая область 1–3 пункта, окружённая одним цветом), + storage.ts += ProgressV1 (тот же конверт), task-session.ts (чистая + машина: illegal/wrong с откатом/playing/solved, авто-ответ по дереву, + reset, hint), task-widget.ts (гидратация go-task, JSON-островок + #go-task-data), 4 страницы, content.config.ts (zod из astro:schema), + nav + строки в strings.ts. +4. **Контент (подагент, ветка stage-6-content)**: 10 уроков (300–700 слов, + разделы 1–6 и 10 с интерактивом), 33 цумэ-го + 7 задач уроков + (capture 20 / connect 5 / two-eyes 5 / liberties 3; difficulty 1–5, + ≥3 на градацию), валидационный content.test.ts (217 проверок: позиции + легальны buildPosition, вариации легальны applyMove, разметка цели), + CREDITS.md (контент оригинальный, CC BY 4.0). +5. **Интеграция (main loop)**: wiring-тесты переведены с placeholder на + реальный контент; добавлен тест «главная линия каждой задачи достигает + goal». Он поймал реальный дефект: goal проверялся только ПОСЛЕ + авто-ответа соперника → транзиентные цели (liberties) недостижимы. + Фикс task-session по контракту: проверка после КАЖДОГО хода игрока, + до авто-ответа. Placeholder-контент удалён. +6. **Гейты main: 413/413** (включая astro build в wiring-тесте), + 0 lint warnings. dist = 15 страниц, 280 КБ. Превью обновлено + (version 89825fe). + +## Принятые отклонения (подагенты задокументировали) + +- env.d.ts в apps/web/src: tsc -b не видит сгенерированные типы astro:content + (каталог .astro/ в .gitignore) — модуль объявлен через entry astro. +- Для toPlay:'white' task-session инвертирует B/W в SGF перед parseSgf + (парсер моделирует от пустой доски с чёрных). Контент v1 весь + toPlay:'black' — инверсия не задействована. +- lessons.done = решены все lsn-задачи урока; чисто теоретические уроки + (7–9) пометки «пройден» не получают (в контракте нет UI для ручной + отметки) — кандидат на этап 7. +- Wiring-тест запускает реальный `npm run build` внутри vitest (timeout + 240 с) — гейты включают сборку. + +## Известные ограничения контента v1 (в PROJECT_STATE) + +- Мотив снэпбэк невыразим (parseSgf падает на occupied при повторной игре + точки); тема «сеть» в цумэ-го отсутствует (есть в тексте урока 2); + у two-eyes задач авто-ответ иногда тэнуки. + +## Подводные камни для следующей сессии (этап 7) + +- Среда прежняя: worktree в $HOME, коммитить всё, контракты inline в + задании, prettier на docs, sh без brace expansion, no `worktree prune`. +- Удаление чужих worktree после мержа: `git worktree remove --force` + + при необходимости `rm -rf .git/worktrees/`, затем `git branch -d`. +- Этап 7 по спеке: .htaccess (gzip, кэш, MIME application/wasm), + prefers-reduced-motion, клавиатурное управление доской, ARIA-live, + страница урока ≤ 300 КБ по сети, теория без JS (уже держим), HTTPS + ТОЛЬКО панелью хостинга (НЕ RewriteCond %{HTTPS} — вечный редирект за + SSL-прокси). Плюс кандидаты: ручная отметка «урок пройден», suggestDeadGroups + грубовата для уроков жизни и смерти (через ADR, зона core). +- Ручная приёмка владельцем: критерии v1 №2 (партия vs ур.5) и №5 + (мобильный Safari) всё ещё открыты. + +## С чего начать продолжение + +Этап 7 (полировка) по мастер-промпту; docs/plans/plan-phase-7.md создать, +контракты .htaccess/a11y — в INTERFACES.md до кода. diff --git a/docs/reports/session-7.md b/docs/reports/session-7.md new file mode 100644 index 0000000..8644923 --- /dev/null +++ b/docs/reports/session-7.md @@ -0,0 +1,62 @@ +# Отчёт сессии 7 (для следующей сессии) + +Дата: 2026-08-06 | Ветка: main | Этап 7 (полировка) — завершён + +## Что сделано + +1. **Контракты этапа 7 в INTERFACES.md до кода** (раздел «Полировка: + .htaccess, a11y, бюджет веса») + docs/plans/plan-phase-7.md. +2. **.htaccess** (apps/web/public/.htaccess → dist): AddType + application/wasm, mod_deflate (html/css/js/json/svg), mod_expires + (_astro 1 год immutable, html no-cache, прочее 1 час), Options + -Indexes, DirectoryIndex index.html. HTTPS-редиректа НЕТ (комментарий + с gotcha-https-redirect-proxy). Wiring-тест проверяет директивы в dist. +3. **Клавиатура** (/play и go-task): чистый reduceCursor в + lib/keyboard.ts (кламп по краям, старт — центр), стрелки/Enter/Space/ + Escape через существующий reduceInput (фантом → commit, единая + семантика с тачем); курсор — квадратный маркер accent через render. +4. **A11y**: role="status" aria-live="polite" на статусах партии/задачи/ + отметки урока; prefers-reduced-motion глушит анимации; :focus-visible + outline; контраст посчитан: --text 13.2/11.6, --muted 5.64/4.97 — + ≥4.5:1, осветление не потребовалось. +5. **Отметка «урок пройден»**: markLessonDone в storage.ts (чистая, + сохраняет tasksSolved авто-done этапа 6, streak через touchStreak) + + кнопка на странице урока. +6. **Бюджет веса**: wiring-тест — каждая страница урока ≤300 КБ (самая + тяжёлая ~10.3 КБ по формуле контракта, ~42 КБ с транзитивными чанками). +7. **Гейты main: 431/431** (+12 keyboard, +4 markLessonDone, +2 wiring), + 0 lint warnings, astro build PASS, dist 296 КБ. Превью обновлено + (version 7f93e64). + +## Инцидент среды + +Финальное сообщение подагента stage-7 не доставлено (alive=0 при +посылке), но работа была полностью закоммичена в ветку stage-7 — +потерь нет. Правило «коммить всё» сработало как страховка. Независимая +проверка гейтов main loop выполнена после мержа. + +## Состояние критериев приёмки v1 + +- №1 (тесты движка, суперко, сэки, wiring) — зелёные. +- №3 (тайминги) — частично: perf-smoke в packages/ai (ур.1–3 < 200 мс + среднее по 20 вызовам 19×19); полная проверка на ноутбуке владельца. +- №4 (чистый клон собирается) — проверяется каждым воркрием с нуля. +- №2 (партия vs ур.5 до подсчёта) и №5 (мобильный Safari) — ручные, + владельцу. + +## Что осталось (этапы 1–7 завершены) + +- Ручная приёмка владельцем (критерии №2, №3, №5). +- По желанию владельца: этап 8 (PHP+SQLite синхронизация прогресса, + ОПЦИОНАЛЬНО, требует до-триажа раздела V литании) и этап 9 (KataGo + ONNX, «этап мечты», не начинать до приёмки 1–7). +- Кандидаты на уточнение (через ADR, по жалобам): suggestDeadGroups + грубовата; контент v1 (снэпбэк невыразим, тема «сеть» только в тексте, + двухглазые авто-ответы иногда тэнуки); метрика веса — только прямые + ассеты (уточнить при росте). + +## Подводные камни среды (без изменений) + +Worktree в $HOME; коммитить всё перед делегированием и завершением; +контракты inline в задании; prettier на docs; sh без brace expansion; +не `git worktree prune`; чистка: remove --force + rm .git/worktrees/. diff --git a/docs/reports/session-8-1.md b/docs/reports/session-8-1.md new file mode 100644 index 0000000..38266d0 --- /dev/null +++ b/docs/reports/session-8-1.md @@ -0,0 +1,56 @@ +# Отчёт сессии 9, этап 8.1 (для следующей сессии) + +Дата: 2026-08-06 | Ветка: main | Этап 8.1 (кабинет 2.0) — завершён + +## Что сделано + +1. **Контракты 8.1** в INTERFACES.md + plan-phase-8-1.md; дизайн OAuth + VK ID / Яндекс ID — docs/design/oauth-vk-yandex.md (что нужно от + владельца: приложения в консолях id.vk.com/business/go и + oauth.yandex.ru, ключи в config.php, HTTPS redirect URI; реализация — + этап 8.2, ~1 сессия, когда будут ключи). +2. **server (подагент)**: api/captcha.php (SVG-капча БЕЗ GD: 5 символов, + повороты/шумовые кривые, sha256+expires 10 мин в сессии, одноразовая); + register += captcha (порядок: 429→403→422 captcha→422→409→201); + api/password-reset/request.php (всегда 200, anti-enumeration; токен + bin2hex(32), sha256 в БД, 1 час; mail() со ссылкой; сбой → 503; + 5/10мин) и confirm.php (200/400/422; 10/10мин); миграция + password_resets; config.example.php += base_url/mail_from; тестовый + режим GOLEARN_TEST=1 (X-Captcha-Debug, письмо в GOLEARN_TEST_MAIL). +3. **web (подагент)**: /account.html — только вход + ссылки «Нет + аккаунта? Зарегистрируйтесь» и «Забыли пароль?»; новые страницы + /register.html (email, пароль ×2, капча с кнопкой обновления, + авто-login → /account) и /password-reset.html (два режима по + ?token=); sync-client += requestPasswordReset/confirmPasswordReset, + register(email, password, captcha); все статусы role=status. +4. **Гейты main: 467/467** (+6), 0 warnings, astro build PASS + (18 страниц). **smoke.sh: 58 проверок**, прогнан main loop + независимо — SMOKE OK (капча, полный цикл reset: письмо→токен→новый + пароль→login старым 401/новым 200, 429-е). +5. docs/deploy.md += base_url/mail_from и шаг проверки почты (4.1). + +## Отклонения исполнителей (приняты) + +- confirm.php: 422 (пароль <8) проверяется до 400 token_invalid — + контракт порядок не фиксировал, оба кода по контракту. +- rate_check в register — до проверки заголовка (403-пробы не тратят + лимит); запись попытки после заголовка. +- Тема письма — сырой UTF-8 без RFC 2047 (риск отображения темы на + части MTA; ссылка не страдает) — в PROJECT_STATE. + +## Среда + +- /tmp обнуляется между сессиями: PHP-рантайм (debs в /tmp/debs + + /tmp/php-run) пересоздавать; рецепт — в session-8.md. Статический + бинарь с dl.static-php.dev не докачивается (обрывы); deb-способ рабочий. +- Финальные сообщения подагентов второй раз приходят пустыми/теряются, + но «коммить всё» спасает: проверять ветки гитом перед повторной + делегацией. + +## Что осталось + +- Этап 8.2 (VK ID / Яндекс ID) — когда владелец получит ключи; + дизайн готов. +- Ручная приёмка: деплой по docs/deploy.md (включая проверку почты), + критерии v1 №2/№3/№5. +- Этап 9 (KataGo ONNX) — после приёмки 1–7. diff --git a/docs/reports/session-8.md b/docs/reports/session-8.md new file mode 100644 index 0000000..3723e0b --- /dev/null +++ b/docs/reports/session-8.md @@ -0,0 +1,57 @@ +# Отчёт сессии 8 (для следующей сессии) + +Дата: 2026-08-06 | Ветка: main | Этап 8 (личный кабинет, синхронизация) — завершён + +## Что сделано + +1. **ADR-0004**: владелец выбрал email+пароль (отступление от «анонимного + токена» спеки); до-триаж раздела V литании выполнен и задокументирован + (IN: V.1, V.2, V.4, V.5, V.6-минимум, V.7-минимум, V.8; OUT: V.3, + внешняя наблюдаемость). Без верификации почты/восстановления пароля + (нет SMTP) — предупреждение в UI. +2. **Контракты этапа 8** в INTERFACES.md до кода: схема SQLite, 7 + эндпоинтов, ProgressV2 (+updatedAt) с миграцией V1→V2, mergeDecision + (pull/push/noop/unauthorized/unavailable), тихая деградация. +3. **server/ (подагент stage-8-server)**: PHP 8 без фреймворка, PDO + SQLite (WAL, busy_timeout, foreign_keys, идемпотентная init-миграция), + prepared statements везде, 256 КБ лимит тела (413), rate limit 10/10мин + по IP (429), X-GoLearn-Client как CSRF-мера (403), security-заголовки, + config.example.php ([SECRET], config.php в .gitignore), health.php + (200 {"status":"ok"}; 503 при сбое БД без текста исключений). + smoke.sh: 30+ проверок — зелёный. +4. **apps/web (подагент stage-8-web)**: ProgressV2 + миграция + Clock- + инжекция, sync-client.ts (mergeDecision + fetch-обёртки + метка + серверного времени после push → следующая сверка noop), страница + /account (вход/регистрация/выход/ручной sync, role=status), навигация + «Кабинет». Интеграционная доработка main loop: тихий авто-sync на + /progress при активной сессии (до отрисовки цифр). +5. **docs/deploy.md** (V.2): 3 артефакта (dist → web root, server/ → + /api, config.php + БД вне web root), порядок, smoke, откат, бэкап/ + restore SQLite, канонические пути — заполнить при первом деплое. +6. **Гейты main: 461/461**, 0 warnings, astro build PASS (16 страниц). + Smoke PHP прогнан main loop независимо — SMOKE OK. + +## Среда: локальный PHP-рантайм + +Системного PHP нет и apt недоступен. Решение: deb-пакеты Debian bookworm +(php8.2-cli/common/sqlite3) распаковываются dpkg-deb -x в /tmp/debs, +обёртка /tmp/php-run (pdo → pdo_sqlite → sqlite3, порядок важен). +ВНИМАНИЕ: /tmp между сессиями обнуляется — рантайм пересоздавать +(команды в отчёте кодера stage-8-server и в этом абзаце). +Статический бинарь с dl.static-php.dev не скачался (обрывы ~14 МБ). + +## Известные ограничения (в PROJECT_STATE) + +- Кабинет в статическом превью = «сервер синхронизации недоступен» + (by design; API оживает на хостинге). +- Риски хостинга: Secure-cookie за TLS-прокси без HTTPS=on; rate limit + общий за NAT; WAL на NFS медленный; Apache 2.2 игнорирует Require + (защита config.php — тогда БД строго вне web root). + +## Что осталось + +- Ручная приёмка владельцем: деплой на хостинг по docs/deploy.md + + критерии v1 №2/№3/№5. +- Этап 9 (KataGo ONNX) — «этап мечты», только после приёмки 1–7. +- Кандидаты через ADR: suggestDeadGroups грубовата; контент v1 (снэпбэк, + «сеть», тэнуки-ответы); метрика веса — только прямые ассеты. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..2dfb417 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,31 @@ +import js from '@eslint/js'; +import tseslint from '@typescript-eslint/eslint-plugin'; +import tsparser from '@typescript-eslint/parser'; +import prettier from 'eslint-config-prettier'; + +export default [ + { + ignores: ['node_modules/', 'dist/', 'coverage/', '**/dist/', '**/.astro/'], + }, + js.configs.recommended, + { + files: ['**/*.ts'], + languageOptions: { + parser: tsparser, + parserOptions: { sourceType: 'module', ecmaVersion: 2022 }, + }, + plugins: { '@typescript-eslint': tseslint }, + rules: { + ...tseslint.configs.recommended.rules, + // typescript-eslint: no-undef для TS отключаем — типы проверяет tsc, + // иначе ложные срабатывания на DOM-глобалах (lib "DOM" видит только tsc) + 'no-undef': 'off', + // strict-профиль: any запрещён спекой проекта + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/explicit-function-return-type': 'warn', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + 'no-console': ['warn', { allow: ['warn', 'error'] }], + }, + }, + prettier, +]; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e7fe7d6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,8214 @@ +{ + "name": "go-learn", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "go-learn", + "version": "0.1.0", + "workspaces": [ + "packages/*", + "apps/*" + ], + "devDependencies": { + "@eslint/js": "10.0.1", + "@types/node": "26.1.2", + "@typescript-eslint/eslint-plugin": "8.66.0", + "@typescript-eslint/parser": "8.66.0", + "eslint": "10.8.0", + "eslint-config-prettier": "10.1.8", + "fast-check": "4.9.0", + "prettier": "3.9.6", + "typescript": "5.9.3", + "vitest": "4.1.10" + }, + "engines": { + "node": ">=20" + } + }, + "apps/web": { + "name": "@go-learn/web", + "version": "0.1.0", + "dependencies": { + "@go-learn/ai": "0.1.0", + "@go-learn/board": "0.1.0", + "@go-learn/core": "0.1.0" + }, + "devDependencies": { + "astro": "5.18.2" + } + }, + "node_modules/@astrojs/compiler": { + "version": "2.13.1", + "resolved": "https://npm.mirrors.msh.team/@astrojs/compiler/-/compiler-2.13.1.tgz", + "integrity": "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@astrojs/internal-helpers": { + "version": "0.7.6", + "resolved": "https://npm.mirrors.msh.team/@astrojs/internal-helpers/-/internal-helpers-0.7.6.tgz", + "integrity": "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@astrojs/markdown-remark": { + "version": "6.3.11", + "resolved": "https://npm.mirrors.msh.team/@astrojs/markdown-remark/-/markdown-remark-6.3.11.tgz", + "integrity": "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.7.6", + "@astrojs/prism": "3.3.0", + "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", + "import-meta-resolve": "^4.2.0", + "js-yaml": "^4.1.1", + "mdast-util-definitions": "^6.0.0", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-smartypants": "^3.0.2", + "shiki": "^3.21.0", + "smol-toml": "^1.6.0", + "unified": "^11.0.5", + "unist-util-remove-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3" + } + }, + "node_modules/@astrojs/prism": { + "version": "3.3.0", + "resolved": "https://npm.mirrors.msh.team/@astrojs/prism/-/prism-3.3.0.tgz", + "integrity": "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@astrojs/telemetry": { + "version": "3.3.0", + "resolved": "https://npm.mirrors.msh.team/@astrojs/telemetry/-/telemetry-3.3.0.tgz", + "integrity": "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ci-info": "^4.2.0", + "debug": "^4.4.0", + "dlv": "^1.1.3", + "dset": "^3.1.4", + "is-docker": "^3.0.0", + "is-wsl": "^3.1.0", + "which-pm-runs": "^1.1.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://npm.mirrors.msh.team/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://npm.mirrors.msh.team/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://npm.mirrors.msh.team/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://npm.mirrors.msh.team/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@capsizecss/unpack": { + "version": "4.0.1", + "resolved": "https://npm.mirrors.msh.team/@capsizecss/unpack/-/unpack-4.0.1.tgz", + "integrity": "sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://npm.mirrors.msh.team/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://npm.mirrors.msh.team/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://npm.mirrors.msh.team/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://npm.mirrors.msh.team/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://npm.mirrors.msh.team/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://npm.mirrors.msh.team/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://npm.mirrors.msh.team/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://npm.mirrors.msh.team/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://npm.mirrors.msh.team/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@go-learn/ai": { + "resolved": "packages/ai", + "link": true + }, + "node_modules/@go-learn/board": { + "resolved": "packages/board", + "link": true + }, + "node_modules/@go-learn/core": { + "resolved": "packages/core", + "link": true + }, + "node_modules/@go-learn/web": { + "resolved": "apps/web", + "link": true + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://npm.mirrors.msh.team/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://npm.mirrors.msh.team/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://npm.mirrors.msh.team/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://npm.mirrors.msh.team/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://npm.mirrors.msh.team/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://npm.mirrors.msh.team/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://npm.mirrors.msh.team/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://npm.mirrors.msh.team/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://npm.mirrors.msh.team/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://npm.mirrors.msh.team/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://npm.mirrors.msh.team/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://npm.mirrors.msh.team/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://npm.mirrors.msh.team/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://npm.mirrors.msh.team/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://npm.mirrors.msh.team/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://npm.mirrors.msh.team/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://npm.mirrors.msh.team/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://npm.mirrors.msh.team/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://npm.mirrors.msh.team/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://npm.mirrors.msh.team/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://npm.mirrors.msh.team/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://npm.mirrors.msh.team/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://npm.mirrors.msh.team/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://npm.mirrors.msh.team/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://npm.mirrors.msh.team/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://npm.mirrors.msh.team/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://npm.mirrors.msh.team/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://npm.mirrors.msh.team/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://npm.mirrors.msh.team/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://npm.mirrors.msh.team/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://npm.mirrors.msh.team/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://npm.mirrors.msh.team/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://npm.mirrors.msh.team/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://npm.mirrors.msh.team/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.2", + "resolved": "https://npm.mirrors.msh.team/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz", + "integrity": "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.2", + "resolved": "https://npm.mirrors.msh.team/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.2.tgz", + "integrity": "sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.2", + "resolved": "https://npm.mirrors.msh.team/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.2.tgz", + "integrity": "sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.2", + "resolved": "https://npm.mirrors.msh.team/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.2.tgz", + "integrity": "sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.2", + "resolved": "https://npm.mirrors.msh.team/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.2.tgz", + "integrity": "sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.2", + "resolved": "https://npm.mirrors.msh.team/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.2.tgz", + "integrity": "sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.2", + "resolved": "https://npm.mirrors.msh.team/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.2.tgz", + "integrity": "sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.2", + "resolved": "https://npm.mirrors.msh.team/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.2.tgz", + "integrity": "sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.2", + "resolved": "https://npm.mirrors.msh.team/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.2.tgz", + "integrity": "sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.2", + "resolved": "https://npm.mirrors.msh.team/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.2.tgz", + "integrity": "sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.2", + "resolved": "https://npm.mirrors.msh.team/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.2.tgz", + "integrity": "sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.2", + "resolved": "https://npm.mirrors.msh.team/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.2.tgz", + "integrity": "sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.2", + "resolved": "https://npm.mirrors.msh.team/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.2.tgz", + "integrity": "sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.2", + "resolved": "https://npm.mirrors.msh.team/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.2.tgz", + "integrity": "sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://npm.mirrors.msh.team/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://npm.mirrors.msh.team/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://npm.mirrors.msh.team/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "3.23.0", + "resolved": "https://npm.mirrors.msh.team/@shikijs/core/-/core-3.23.0.tgz", + "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.23.0", + "resolved": "https://npm.mirrors.msh.team/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", + "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://npm.mirrors.msh.team/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://npm.mirrors.msh.team/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://npm.mirrors.msh.team/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://npm.mirrors.msh.team/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://npm.mirrors.msh.team/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://npm.mirrors.msh.team/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://npm.mirrors.msh.team/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://npm.mirrors.msh.team/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://npm.mirrors.msh.team/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://npm.mirrors.msh.team/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://npm.mirrors.msh.team/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://npm.mirrors.msh.team/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://npm.mirrors.msh.team/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://npm.mirrors.msh.team/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://npm.mirrors.msh.team/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/nlcst": { + "version": "2.0.3", + "resolved": "https://npm.mirrors.msh.team/@types/nlcst/-/nlcst-2.0.3.tgz", + "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/node": { + "version": "26.1.2", + "resolved": "https://npm.mirrors.msh.team/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://npm.mirrors.msh.team/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.66.0", + "resolved": "https://npm.mirrors.msh.team/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", + "integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/type-utils": "8.66.0", + "@typescript-eslint/utils": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.66.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.66.0", + "resolved": "https://npm.mirrors.msh.team/@typescript-eslint/parser/-/parser-8.66.0.tgz", + "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.66.0", + "resolved": "https://npm.mirrors.msh.team/@typescript-eslint/project-service/-/project-service-8.66.0.tgz", + "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.66.0", + "@typescript-eslint/types": "^8.66.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.66.0", + "resolved": "https://npm.mirrors.msh.team/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz", + "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.66.0", + "resolved": "https://npm.mirrors.msh.team/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz", + "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.66.0", + "resolved": "https://npm.mirrors.msh.team/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz", + "integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/utils": "8.66.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.66.0", + "resolved": "https://npm.mirrors.msh.team/@typescript-eslint/types/-/types-8.66.0.tgz", + "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.66.0", + "resolved": "https://npm.mirrors.msh.team/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz", + "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.66.0", + "@typescript-eslint/tsconfig-utils": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.66.0", + "resolved": "https://npm.mirrors.msh.team/@typescript-eslint/utils/-/utils-8.66.0.tgz", + "integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.66.0", + "resolved": "https://npm.mirrors.msh.team/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz", + "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.66.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://npm.mirrors.msh.team/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://npm.mirrors.msh.team/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://npm.mirrors.msh.team/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://npm.mirrors.msh.team/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://npm.mirrors.msh.team/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://npm.mirrors.msh.team/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://npm.mirrors.msh.team/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://npm.mirrors.msh.team/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://npm.mirrors.msh.team/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://npm.mirrors.msh.team/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://npm.mirrors.msh.team/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://npm.mirrors.msh.team/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://npm.mirrors.msh.team/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://npm.mirrors.msh.team/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://npm.mirrors.msh.team/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://npm.mirrors.msh.team/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://npm.mirrors.msh.team/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://npm.mirrors.msh.team/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://npm.mirrors.msh.team/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://npm.mirrors.msh.team/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://npm.mirrors.msh.team/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://npm.mirrors.msh.team/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://npm.mirrors.msh.team/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-iterate": { + "version": "2.0.1", + "resolved": "https://npm.mirrors.msh.team/array-iterate/-/array-iterate-2.0.1.tgz", + "integrity": "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://npm.mirrors.msh.team/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/astro": { + "version": "5.18.2", + "resolved": "https://npm.mirrors.msh.team/astro/-/astro-5.18.2.tgz", + "integrity": "sha512-TnFwLnAXty5MXKPDGuKXqK4AMBXG+FH6RUdK7Oyc3gyfNoFIthT+4eRbzOK43bdRlLaZuxgciDSjgtggZ3OtGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@astrojs/compiler": "^2.13.0", + "@astrojs/internal-helpers": "0.7.6", + "@astrojs/markdown-remark": "6.3.11", + "@astrojs/telemetry": "3.3.0", + "@capsizecss/unpack": "^4.0.0", + "@oslojs/encoding": "^1.1.0", + "@rollup/pluginutils": "^5.3.0", + "acorn": "^8.15.0", + "aria-query": "^5.3.2", + "axobject-query": "^4.1.0", + "boxen": "8.0.1", + "ci-info": "^4.3.1", + "clsx": "^2.1.1", + "common-ancestor-path": "^1.0.1", + "cookie": "^1.1.1", + "cssesc": "^3.0.0", + "debug": "^4.4.3", + "deterministic-object-hash": "^2.0.2", + "devalue": "^5.6.2", + "diff": "^8.0.3", + "dlv": "^1.1.3", + "dset": "^3.1.4", + "es-module-lexer": "^1.7.0", + "esbuild": "^0.27.3", + "estree-walker": "^3.0.3", + "flattie": "^1.1.1", + "fontace": "~0.4.0", + "github-slugger": "^2.0.0", + "html-escaper": "3.0.3", + "http-cache-semantics": "^4.2.0", + "import-meta-resolve": "^4.2.0", + "js-yaml": "^4.1.1", + "magic-string": "^0.30.21", + "magicast": "^0.5.1", + "mrmime": "^2.0.1", + "neotraverse": "^0.6.18", + "p-limit": "^6.2.0", + "p-queue": "^8.1.1", + "package-manager-detector": "^1.6.0", + "piccolore": "^0.1.3", + "picomatch": "^4.0.3", + "prompts": "^2.4.2", + "rehype": "^13.0.2", + "semver": "^7.7.3", + "shiki": "^3.21.0", + "smol-toml": "^1.6.0", + "svgo": "^4.0.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tsconfck": "^3.1.6", + "ultrahtml": "^1.6.0", + "unifont": "~0.7.3", + "unist-util-visit": "^5.0.0", + "unstorage": "^1.17.4", + "vfile": "^6.0.3", + "vite": "^6.4.1", + "vitefu": "^1.1.1", + "xxhash-wasm": "^1.1.0", + "yargs-parser": "^21.1.1", + "yocto-spinner": "^0.2.3", + "zod": "^3.25.76", + "zod-to-json-schema": "^3.25.1", + "zod-to-ts": "^1.2.0" + }, + "bin": { + "astro": "astro.js" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/astrodotbuild" + }, + "optionalDependencies": { + "sharp": "^0.34.0" + } + }, + "node_modules/astro/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://npm.mirrors.msh.team/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/astro/node_modules/p-limit": { + "version": "6.2.0", + "resolved": "https://npm.mirrors.msh.team/p-limit/-/p-limit-6.2.0.tgz", + "integrity": "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/astro/node_modules/vite": { + "version": "6.4.3", + "resolved": "https://npm.mirrors.msh.team/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/astro/node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://npm.mirrors.msh.team/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/astro/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://npm.mirrors.msh.team/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://npm.mirrors.msh.team/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://npm.mirrors.msh.team/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://npm.mirrors.msh.team/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://npm.mirrors.msh.team/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==", + "dev": true, + "license": "MIT" + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://npm.mirrors.msh.team/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/boxen": { + "version": "8.0.1", + "resolved": "https://npm.mirrors.msh.team/boxen/-/boxen-8.0.1.tgz", + "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^8.0.0", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "string-width": "^7.2.0", + "type-fest": "^4.21.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://npm.mirrors.msh.team/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://npm.mirrors.msh.team/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://npm.mirrors.msh.team/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://npm.mirrors.msh.team/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://npm.mirrors.msh.team/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://npm.mirrors.msh.team/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://npm.mirrors.msh.team/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://npm.mirrors.msh.team/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://npm.mirrors.msh.team/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://npm.mirrors.msh.team/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://npm.mirrors.msh.team/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://npm.mirrors.msh.team/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://npm.mirrors.msh.team/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://npm.mirrors.msh.team/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/common-ancestor-path": { + "version": "1.0.1", + "resolved": "https://npm.mirrors.msh.team/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz", + "integrity": "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==", + "dev": true, + "license": "ISC" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://npm.mirrors.msh.team/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://npm.mirrors.msh.team/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-es": { + "version": "1.2.3", + "resolved": "https://npm.mirrors.msh.team/cookie-es/-/cookie-es-1.2.3.tgz", + "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://npm.mirrors.msh.team/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://npm.mirrors.msh.team/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://npm.mirrors.msh.team/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://npm.mirrors.msh.team/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://npm.mirrors.msh.team/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://npm.mirrors.msh.team/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://npm.mirrors.msh.team/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://npm.mirrors.msh.team/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://npm.mirrors.msh.team/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://npm.mirrors.msh.team/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://npm.mirrors.msh.team/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://npm.mirrors.msh.team/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://npm.mirrors.msh.team/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://npm.mirrors.msh.team/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://npm.mirrors.msh.team/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://npm.mirrors.msh.team/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/deterministic-object-hash": { + "version": "2.0.2", + "resolved": "https://npm.mirrors.msh.team/deterministic-object-hash/-/deterministic-object-hash-2.0.2.tgz", + "integrity": "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "base-64": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/devalue": { + "version": "5.9.0", + "resolved": "https://npm.mirrors.msh.team/devalue/-/devalue-5.9.0.tgz", + "integrity": "sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://npm.mirrors.msh.team/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://npm.mirrors.msh.team/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://npm.mirrors.msh.team/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://npm.mirrors.msh.team/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://npm.mirrors.msh.team/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://npm.mirrors.msh.team/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://npm.mirrors.msh.team/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://npm.mirrors.msh.team/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://npm.mirrors.msh.team/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://npm.mirrors.msh.team/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://npm.mirrors.msh.team/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://npm.mirrors.msh.team/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://npm.mirrors.msh.team/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://npm.mirrors.msh.team/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.8.0", + "resolved": "https://npm.mirrors.msh.team/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://npm.mirrors.msh.team/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://npm.mirrors.msh.team/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://npm.mirrors.msh.team/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://npm.mirrors.msh.team/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://npm.mirrors.msh.team/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://npm.mirrors.msh.team/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://npm.mirrors.msh.team/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://npm.mirrors.msh.team/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://npm.mirrors.msh.team/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://npm.mirrors.msh.team/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://npm.mirrors.msh.team/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://npm.mirrors.msh.team/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://npm.mirrors.msh.team/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://npm.mirrors.msh.team/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://npm.mirrors.msh.team/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "4.9.0", + "resolved": "https://npm.mirrors.msh.team/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://npm.mirrors.msh.team/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://npm.mirrors.msh.team/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://npm.mirrors.msh.team/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://npm.mirrors.msh.team/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://npm.mirrors.msh.team/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://npm.mirrors.msh.team/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://npm.mirrors.msh.team/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://npm.mirrors.msh.team/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/flattie": { + "version": "1.1.1", + "resolved": "https://npm.mirrors.msh.team/flattie/-/flattie-1.1.1.tgz", + "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fontace": { + "version": "0.4.1", + "resolved": "https://npm.mirrors.msh.team/fontace/-/fontace-0.4.1.tgz", + "integrity": "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.2" + } + }, + "node_modules/fontkitten": { + "version": "1.0.3", + "resolved": "https://npm.mirrors.msh.team/fontkitten/-/fontkitten-1.0.3.tgz", + "integrity": "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tiny-inflate": "^1.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://npm.mirrors.msh.team/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://npm.mirrors.msh.team/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://npm.mirrors.msh.team/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://npm.mirrors.msh.team/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/h3": { + "version": "1.15.11", + "resolved": "https://npm.mirrors.msh.team/h3/-/h3-1.15.11.tgz", + "integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.3", + "crossws": "^0.3.5", + "defu": "^6.1.6", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.4", + "radix3": "^1.1.2", + "ufo": "^1.6.3", + "uncrypto": "^0.1.3" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://npm.mirrors.msh.team/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://npm.mirrors.msh.team/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://npm.mirrors.msh.team/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://npm.mirrors.msh.team/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://npm.mirrors.msh.team/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://npm.mirrors.msh.team/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://npm.mirrors.msh.team/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://npm.mirrors.msh.team/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://npm.mirrors.msh.team/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://npm.mirrors.msh.team/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://npm.mirrors.msh.team/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://npm.mirrors.msh.team/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://npm.mirrors.msh.team/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://npm.mirrors.msh.team/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://npm.mirrors.msh.team/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://npm.mirrors.msh.team/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://npm.mirrors.msh.team/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://npm.mirrors.msh.team/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://npm.mirrors.msh.team/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://npm.mirrors.msh.team/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://npm.mirrors.msh.team/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://npm.mirrors.msh.team/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://npm.mirrors.msh.team/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://npm.mirrors.msh.team/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://npm.mirrors.msh.team/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://npm.mirrors.msh.team/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://npm.mirrors.msh.team/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://npm.mirrors.msh.team/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://npm.mirrors.msh.team/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://npm.mirrors.msh.team/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://npm.mirrors.msh.team/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://npm.mirrors.msh.team/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://npm.mirrors.msh.team/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://npm.mirrors.msh.team/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://npm.mirrors.msh.team/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://npm.mirrors.msh.team/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://npm.mirrors.msh.team/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://npm.mirrors.msh.team/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://npm.mirrors.msh.team/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://npm.mirrors.msh.team/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://npm.mirrors.msh.team/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://npm.mirrors.msh.team/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://npm.mirrors.msh.team/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://npm.mirrors.msh.team/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://npm.mirrors.msh.team/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://npm.mirrors.msh.team/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://npm.mirrors.msh.team/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://npm.mirrors.msh.team/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://npm.mirrors.msh.team/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://npm.mirrors.msh.team/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-definitions": { + "version": "6.0.0", + "resolved": "https://npm.mirrors.msh.team/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", + "integrity": "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://npm.mirrors.msh.team/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://npm.mirrors.msh.team/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://npm.mirrors.msh.team/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://npm.mirrors.msh.team/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://npm.mirrors.msh.team/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://npm.mirrors.msh.team/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://npm.mirrors.msh.team/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://npm.mirrors.msh.team/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://npm.mirrors.msh.team/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://npm.mirrors.msh.team/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://npm.mirrors.msh.team/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://npm.mirrors.msh.team/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://npm.mirrors.msh.team/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://npm.mirrors.msh.team/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://npm.mirrors.msh.team/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://npm.mirrors.msh.team/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://npm.mirrors.msh.team/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://npm.mirrors.msh.team/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://npm.mirrors.msh.team/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://npm.mirrors.msh.team/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://npm.mirrors.msh.team/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://npm.mirrors.msh.team/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://npm.mirrors.msh.team/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://npm.mirrors.msh.team/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://npm.mirrors.msh.team/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://npm.mirrors.msh.team/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://npm.mirrors.msh.team/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://npm.mirrors.msh.team/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://npm.mirrors.msh.team/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://npm.mirrors.msh.team/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://npm.mirrors.msh.team/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://npm.mirrors.msh.team/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://npm.mirrors.msh.team/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://npm.mirrors.msh.team/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://npm.mirrors.msh.team/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://npm.mirrors.msh.team/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://npm.mirrors.msh.team/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://npm.mirrors.msh.team/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://npm.mirrors.msh.team/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://npm.mirrors.msh.team/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://npm.mirrors.msh.team/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://npm.mirrors.msh.team/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://npm.mirrors.msh.team/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://npm.mirrors.msh.team/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://npm.mirrors.msh.team/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://npm.mirrors.msh.team/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://npm.mirrors.msh.team/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/neotraverse": { + "version": "0.6.18", + "resolved": "https://npm.mirrors.msh.team/neotraverse/-/neotraverse-0.6.18.tgz", + "integrity": "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/nlcst-to-string": { + "version": "4.0.0", + "resolved": "https://npm.mirrors.msh.team/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", + "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://npm.mirrors.msh.team/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-mock-http": { + "version": "1.0.5", + "resolved": "https://npm.mirrors.msh.team/node-mock-http/-/node-mock-http-1.0.5.tgz", + "integrity": "sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://npm.mirrors.msh.team/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://npm.mirrors.msh.team/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://npm.mirrors.msh.team/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://npm.mirrors.msh.team/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://npm.mirrors.msh.team/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://npm.mirrors.msh.team/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "dev": true, + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://npm.mirrors.msh.team/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://npm.mirrors.msh.team/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://npm.mirrors.msh.team/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://npm.mirrors.msh.team/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "8.1.1", + "resolved": "https://npm.mirrors.msh.team/p-queue/-/p-queue-8.1.1.tgz", + "integrity": "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://npm.mirrors.msh.team/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://npm.mirrors.msh.team/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse-latin": { + "version": "7.0.0", + "resolved": "https://npm.mirrors.msh.team/parse-latin/-/parse-latin-7.0.0.tgz", + "integrity": "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "@types/unist": "^3.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-modify-children": "^4.0.0", + "unist-util-visit-children": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://npm.mirrors.msh.team/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://npm.mirrors.msh.team/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://npm.mirrors.msh.team/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://npm.mirrors.msh.team/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/piccolore": { + "version": "0.1.3", + "resolved": "https://npm.mirrors.msh.team/piccolore/-/piccolore-0.1.3.tgz", + "integrity": "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==", + "dev": true, + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://npm.mirrors.msh.team/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://npm.mirrors.msh.team/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://npm.mirrors.msh.team/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://npm.mirrors.msh.team/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://npm.mirrors.msh.team/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://npm.mirrors.msh.team/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://npm.mirrors.msh.team/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://npm.mirrors.msh.team/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://npm.mirrors.msh.team/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://npm.mirrors.msh.team/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://npm.mirrors.msh.team/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://npm.mirrors.msh.team/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://npm.mirrors.msh.team/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://npm.mirrors.msh.team/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://npm.mirrors.msh.team/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true, + "license": "MIT" + }, + "node_modules/rehype": { + "version": "13.0.2", + "resolved": "https://npm.mirrors.msh.team/rehype/-/rehype-13.0.2.tgz", + "integrity": "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "rehype-parse": "^9.0.0", + "rehype-stringify": "^10.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-parse": { + "version": "9.0.1", + "resolved": "https://npm.mirrors.msh.team/rehype-parse/-/rehype-parse-9.0.1.tgz", + "integrity": "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-html": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://npm.mirrors.msh.team/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://npm.mirrors.msh.team/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://npm.mirrors.msh.team/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://npm.mirrors.msh.team/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://npm.mirrors.msh.team/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-smartypants": { + "version": "3.0.3", + "resolved": "https://npm.mirrors.msh.team/remark-smartypants/-/remark-smartypants-3.0.3.tgz", + "integrity": "sha512-gCaK+ndZ0hYezlqFegHFCVh2CQemsi0Npdh1qVM9bxlUFknjkbP6VmojWhddOCrbK0PbbacmYLWfTULRiT1eWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "retext": "^9.0.0", + "retext-smartypants": "^6.0.0", + "unified": "^11.0.4", + "unist-util-visit": "^5.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://npm.mirrors.msh.team/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext": { + "version": "9.0.0", + "resolved": "https://npm.mirrors.msh.team/retext/-/retext-9.0.0.tgz", + "integrity": "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "retext-latin": "^4.0.0", + "retext-stringify": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-latin": { + "version": "4.0.0", + "resolved": "https://npm.mirrors.msh.team/retext-latin/-/retext-latin-4.0.0.tgz", + "integrity": "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "parse-latin": "^7.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-smartypants": { + "version": "6.2.0", + "resolved": "https://npm.mirrors.msh.team/retext-smartypants/-/retext-smartypants-6.2.0.tgz", + "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-stringify": { + "version": "4.0.0", + "resolved": "https://npm.mirrors.msh.team/retext-stringify/-/retext-stringify-4.0.0.tgz", + "integrity": "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rolldown": { + "version": "1.2.2", + "resolved": "https://npm.mirrors.msh.team/rolldown/-/rolldown-1.2.2.tgz", + "integrity": "sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.142.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.2", + "@rolldown/binding-darwin-arm64": "1.2.2", + "@rolldown/binding-darwin-x64": "1.2.2", + "@rolldown/binding-freebsd-x64": "1.2.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.2", + "@rolldown/binding-linux-arm64-gnu": "1.2.2", + "@rolldown/binding-linux-arm64-musl": "1.2.2", + "@rolldown/binding-linux-ppc64-gnu": "1.2.2", + "@rolldown/binding-linux-s390x-gnu": "1.2.2", + "@rolldown/binding-linux-x64-gnu": "1.2.2", + "@rolldown/binding-linux-x64-musl": "1.2.2", + "@rolldown/binding-openharmony-arm64": "1.2.2", + "@rolldown/binding-win32-arm64-msvc": "1.2.2", + "@rolldown/binding-win32-x64-msvc": "1.2.2" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://npm.mirrors.msh.team/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://npm.mirrors.msh.team/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://npm.mirrors.msh.team/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://npm.mirrors.msh.team/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://npm.mirrors.msh.team/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://npm.mirrors.msh.team/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shiki": { + "version": "3.23.0", + "resolved": "https://npm.mirrors.msh.team/shiki/-/shiki-3.23.0.tgz", + "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.23.0", + "@shikijs/engine-javascript": "3.23.0", + "@shikijs/engine-oniguruma": "3.23.0", + "@shikijs/langs": "3.23.0", + "@shikijs/themes": "3.23.0", + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://npm.mirrors.msh.team/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://npm.mirrors.msh.team/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/smol-toml": { + "version": "1.7.1", + "resolved": "https://npm.mirrors.msh.team/smol-toml/-/smol-toml-1.7.1.tgz", + "integrity": "sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://npm.mirrors.msh.team/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://npm.mirrors.msh.team/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://npm.mirrors.msh.team/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://npm.mirrors.msh.team/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://npm.mirrors.msh.team/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://npm.mirrors.msh.team/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://npm.mirrors.msh.team/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/svgo": { + "version": "4.0.2", + "resolved": "https://npm.mirrors.msh.team/svgo/-/svgo-4.0.2.tgz", + "integrity": "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^5.1.0", + "css-tree": "^3.0.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "^1.5.0" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://npm.mirrors.msh.team/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://npm.mirrors.msh.team/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://npm.mirrors.msh.team/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://npm.mirrors.msh.team/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://npm.mirrors.msh.team/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://npm.mirrors.msh.team/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://npm.mirrors.msh.team/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://npm.mirrors.msh.team/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://npm.mirrors.msh.team/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "dev": true, + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://npm.mirrors.msh.team/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://npm.mirrors.msh.team/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://npm.mirrors.msh.team/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://npm.mirrors.msh.team/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://npm.mirrors.msh.team/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ultrahtml": { + "version": "1.7.0", + "resolved": "https://npm.mirrors.msh.team/ultrahtml/-/ultrahtml-1.7.0.tgz", + "integrity": "sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://npm.mirrors.msh.team/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://npm.mirrors.msh.team/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://npm.mirrors.msh.team/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unifont": { + "version": "0.7.4", + "resolved": "https://npm.mirrors.msh.team/unifont/-/unifont-0.7.4.tgz", + "integrity": "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.1.0", + "ofetch": "^1.5.1", + "ohash": "^2.0.11" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://npm.mirrors.msh.team/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://npm.mirrors.msh.team/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-modify-children": { + "version": "4.0.0", + "resolved": "https://npm.mirrors.msh.team/unist-util-modify-children/-/unist-util-modify-children-4.0.0.tgz", + "integrity": "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "array-iterate": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://npm.mirrors.msh.team/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://npm.mirrors.msh.team/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://npm.mirrors.msh.team/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://npm.mirrors.msh.team/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-children": { + "version": "3.0.0", + "resolved": "https://npm.mirrors.msh.team/unist-util-visit-children/-/unist-util-visit-children-3.0.0.tgz", + "integrity": "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://npm.mirrors.msh.team/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unstorage": { + "version": "1.17.5", + "resolved": "https://npm.mirrors.msh.team/unstorage/-/unstorage-1.17.5.tgz", + "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.10", + "lru-cache": "^11.2.7", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://npm.mirrors.msh.team/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://npm.mirrors.msh.team/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://npm.mirrors.msh.team/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://npm.mirrors.msh.team/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "8.2.0", + "resolved": "https://npm.mirrors.msh.team/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://npm.mirrors.msh.team/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://npm.mirrors.msh.team/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://npm.mirrors.msh.team/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://npm.mirrors.msh.team/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-pm-runs": { + "version": "1.1.0", + "resolved": "https://npm.mirrors.msh.team/which-pm-runs/-/which-pm-runs-1.1.0.tgz", + "integrity": "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://npm.mirrors.msh.team/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://npm.mirrors.msh.team/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://npm.mirrors.msh.team/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://npm.mirrors.msh.team/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://npm.mirrors.msh.team/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://npm.mirrors.msh.team/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://npm.mirrors.msh.team/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yocto-spinner": { + "version": "0.2.3", + "resolved": "https://npm.mirrors.msh.team/yocto-spinner/-/yocto-spinner-0.2.3.tgz", + "integrity": "sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18.19" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.2.0", + "resolved": "https://npm.mirrors.msh.team/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://npm.mirrors.msh.team/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://npm.mirrors.msh.team/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "node_modules/zod-to-ts": { + "version": "1.2.0", + "resolved": "https://npm.mirrors.msh.team/zod-to-ts/-/zod-to-ts-1.2.0.tgz", + "integrity": "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==", + "dev": true, + "peerDependencies": { + "typescript": "^4.9.4 || ^5.0.2", + "zod": "^3" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://npm.mirrors.msh.team/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "packages/ai": { + "name": "@go-learn/ai", + "version": "0.1.0", + "dependencies": { + "@go-learn/core": "0.1.0" + } + }, + "packages/board": { + "name": "@go-learn/board", + "version": "0.1.0", + "dependencies": { + "@go-learn/core": "0.1.0" + } + }, + "packages/core": { + "name": "@go-learn/core", + "version": "0.1.0" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e81d238 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "go-learn", + "version": "0.1.0", + "private": true, + "description": "Обучающий сайт по игре Го: интерактивный курс + симулятор партии против ИИ. Полностью клиентский, статическая сборка.", + "type": "module", + "workspaces": [ + "packages/*", + "apps/*" + ], + "scripts": { + "format": "prettier --write .", + "format:check": "prettier --check .", + "lint": "eslint .", + "typecheck": "tsc -b --noEmit", + "test": "vitest run", + "gates": "npm run format:check && npm run lint && npm run typecheck && npm run test" + }, + "devDependencies": { + "@eslint/js": "10.0.1", + "@types/node": "26.1.2", + "@typescript-eslint/eslint-plugin": "8.66.0", + "@typescript-eslint/parser": "8.66.0", + "eslint": "10.8.0", + "eslint-config-prettier": "10.1.8", + "fast-check": "4.9.0", + "prettier": "3.9.6", + "typescript": "5.9.3", + "vitest": "4.1.10" + }, + "engines": { + "node": ">=20" + } +} diff --git a/packages/ai/package.json b/packages/ai/package.json new file mode 100644 index 0000000..490e33d --- /dev/null +++ b/packages/ai/package.json @@ -0,0 +1,14 @@ +{ + "name": "@go-learn/ai", + "version": "0.1.0", + "private": true, + "description": "Эвристический ИИ ур. 1–3: приоритеты атари (спасение/захват), ходы вблизи хода противника, фильтр своего глаза. Синхронный чистый вычислитель: случайность только через инжектируемый Rng, легальность только через applyMove из @go-learn/core.", + "type": "module", + "main": "src/index.ts", + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "@go-learn/core": "0.1.0" + } +} diff --git a/packages/ai/src/ai.ts b/packages/ai/src/ai.ts new file mode 100644 index 0000000..751ab06 --- /dev/null +++ b/packages/ai/src/ai.ts @@ -0,0 +1,98 @@ +/** + * Эвристический ИИ ур. 1–3 (контракт — docs/INTERFACES.md, раздел packages/ai). + * Синхронный чистый вычислитель: без DOM, без воркер-API, без собственного + * состояния; случайность — только через request.rng; легальность — только + * через applyMove из @go-learn/core. + * + * Ролл-семантика уровней: на каждый ход — два ролла rng.next() в фиксированном + * порядке: первый для save-atari, второй для capture-atari. Ролл < p(level) → + * приоритет активен; при неактивном приоритете или отсутствии подходящих + * кандидатов — переход к следующему. Роллы делаются всегда, даже если групп + * в атари нет, — позиция в потоке rng не зависит от позиции на доске. + */ +import type { BoardState, Move, Point, Rng } from '@go-learn/core'; +import type { Candidate } from './candidates.js'; +import { legalCandidates } from './candidates.js'; +import { nearbyCandidates, pickUniform, pickWeighted } from './pick.js'; +import { captureMoves, savingMoves } from './tactics.js'; + +export type AiLevel = 1 | 2 | 3; + +/** + * Вероятность применения приоритетов «спасти своё атари» / «забрать чужое» + * на каждом уровне (ур.1 ≈ 0.3, ур.3 ≈ 0.9 — по спеке). + */ +export const LEVEL_ATARI_PROBABILITY: Readonly> = { + 1: 0.3, + 2: 0.6, + 3: 0.9, +}; + +export interface AiMoveRequest { + readonly state: BoardState; // ходит state.toPlay + readonly level: AiLevel; + readonly rng: Rng; // seeded снаружи + readonly lastOpponentMove?: Point | null; // для приоритета nearby +} + +export type AiReason = + | 'save-atari' // спас свою группу в атари + | 'capture-atari' // забрал чужую группу в атари + | 'nearby' // взвешенный ход вблизи lastOpponentMove (Chebyshev ≤ 2) + | 'random' // случайный легальный + | 'pass'; // нет кандидатов + +export interface AiMoveResult { + readonly move: Move; // kind 'play' | 'pass'; resign на ур. 1–3 не генерируется + readonly reason: AiReason; +} + +/** Результат-постановка камня. */ +function playResult(state: BoardState, candidate: Candidate, reason: AiReason): AiMoveResult { + return { + move: { kind: 'play', color: state.toPlay, point: candidate.point }, + reason, + }; +} + +/** Приоритеты 1–2: тактика атари с роллами вероятности уровня. */ +function tacticalMove( + state: BoardState, + candidates: readonly Candidate[], + probability: number, + rng: Rng, +): AiMoveResult | null { + if (rng.next() < probability) { + const saving = pickUniform(savingMoves(state, candidates), rng); + if (saving !== null) return playResult(state, saving, 'save-atari'); + } + if (rng.next() < probability) { + const capturing = pickUniform(captureMoves(state, candidates), rng); + if (capturing !== null) return playResult(state, capturing, 'capture-atari'); + } + return null; +} + +/** + * Выбор хода по приоритетам: save-atari → capture-atari → nearby → random → + * pass (только когда кандидатов не осталось). + * Вызов при state.over === true — программная ошибка: кидает Error. + */ +export function chooseMove(request: AiMoveRequest): AiMoveResult { + const { state, level, rng } = request; + if (state.over) throw new Error('chooseMove: партия уже завершена (state.over === true)'); + const candidates = legalCandidates(state); + if (candidates.length === 0) { + return { move: { kind: 'pass', color: state.toPlay }, reason: 'pass' }; + } + const tactical = tacticalMove(state, candidates, LEVEL_ATARI_PROBABILITY[level], rng); + if (tactical !== null) return tactical; + const nearby = nearbyCandidates(candidates, request.lastOpponentMove ?? null); + if (nearby.length > 0) { + const picked = pickWeighted(nearby, rng); + if (picked !== null) return playResult(state, picked, 'nearby'); + } + const random = pickUniform(candidates, rng); + if (random !== null) return playResult(state, random, 'random'); + return { move: { kind: 'pass', color: state.toPlay }, reason: 'pass' }; +} diff --git a/packages/ai/src/candidates.ts b/packages/ai/src/candidates.ts new file mode 100644 index 0000000..20463a3 --- /dev/null +++ b/packages/ai/src/candidates.ts @@ -0,0 +1,59 @@ +/** + * Кандидаты на ход: легальные постановки камня, не заполняющие свой глаз. + * Легальность проверяется ТОЛЬКО через applyMove из @go-learn/core — + * дублирование правил запрещено контрактом. + */ +import { applyMove, cellAt } from '@go-learn/core'; +import type { BoardState, Point } from '@go-learn/core'; + +/** Легальный ход-кандидат с предрасчитанным состоянием после хода. */ +export interface Candidate { + readonly point: Point; + /** Состояние после постановки камня в point. */ + readonly next: BoardState; +} + +/** Ортогональные соседи точки в пределах доски. */ +export function orthogonalNeighbors(size: number, point: Point): Point[] { + const result: Point[] = []; + const deltas = [ + { x: -1, y: 0 }, + { x: 1, y: 0 }, + { x: 0, y: -1 }, + { x: 0, y: 1 }, + ]; + for (const delta of deltas) { + const neighbor = { x: point.x + delta.x, y: point.y + delta.y }; + const inside = neighbor.x >= 0 && neighbor.x < size && neighbor.y >= 0 && neighbor.y < size; + if (inside) result.push(neighbor); + } + return result; +} + +/** + * Эвристика «свой глаз» (простая, по контракту): все ортогональные соседи + * точки — свои камни, и ход ничего не захватывает. Захват не может быть + * заполнением глаза, поэтому ходы с capturedCount > 0 не отсеиваются. + */ +function isOwnEyeFill(state: BoardState, point: Point, capturedCount: number): boolean { + if (capturedCount > 0) return false; + return orthogonalNeighbors(state.size, point).every( + (neighbor) => cellAt(state, neighbor) === state.toPlay, + ); +} + +/** Все легальные ходы-постановки, прошедшие фильтр своего глаза. */ +export function legalCandidates(state: BoardState): Candidate[] { + const result: Candidate[] = []; + for (let y = 0; y < state.size; y += 1) { + for (let x = 0; x < state.size; x += 1) { + const point = { x, y }; + if (cellAt(state, point) !== 'empty') continue; + const applied = applyMove(state, { kind: 'play', color: state.toPlay, point }); + if (!applied.ok) continue; + if (isOwnEyeFill(state, point, applied.captured.length)) continue; + result.push({ point, next: applied.state }); + } + } + return result; +} diff --git a/packages/ai/src/choose-move.test.ts b/packages/ai/src/choose-move.test.ts new file mode 100644 index 0000000..988b3c3 --- /dev/null +++ b/packages/ai/src/choose-move.test.ts @@ -0,0 +1,210 @@ +/** + * Тесты приоритетов и фильтров chooseMove на сконструированных позициях: + * save-atari (включая контрзахват), capture-atari, фильтр своего глаза, + * pass без кандидатов, запрет вызова при over, вероятности уровней. + */ +import { applyMove, createBoard, groupAt } from '@go-learn/core'; +import type { BoardState, Rng } from '@go-learn/core'; +import { describe, expect, it } from 'vitest'; +import { chooseMove, LEVEL_ATARI_PROBABILITY } from './index.js'; +import { stateFromAscii } from './test-utils.js'; + +/** Rng-заглушки: ролл всегда ниже / всегда выше любой вероятности уровня. */ +const alwaysZero: Rng = { next: () => 0 }; +const alwaysHigh: Rng = { next: () => 0.99 }; + +/** Чёрный камень (4,4) в атари, единственное дамэ — (4,5); ход чёрных. */ +function ownAtariState(): BoardState { + return stateFromAscii(9, 'black', [ + '.........', + '.........', + '.........', + '....O....', + '...OXO...', + '.........', + '.........', + '.........', + '.........', + ]); +} + +/** Белый камень (4,4) в атари, последнее дамэ — (4,5); ход чёрных. */ +function enemyAtariState(): BoardState { + return stateFromAscii(9, 'black', [ + '.........', + '.........', + '.........', + '....X....', + '...XOX...', + '.........', + '.........', + '.........', + '.........', + ]); +} + +/** + * Контрзахват: чёрный (4,4) в атари, продление в (5,4) — самоубийство, + * единственное спасение — забрать белый (3,4) ходом в (3,5). + */ +function counterCaptureState(): BoardState { + return stateFromAscii(9, 'black', [ + '.........', + '.........', + '.........', + '...XOO...', + '..XOX.O..', + '....OO...', + '.........', + '.........', + '.........', + ]); +} + +/** + * Фильтр глаза: (1,1) — легальный ход, заполняющий свой глаз (все соседи — + * свои, захвата нет); единственный ход после фильтра — (8,8) со захватом. + */ +function eyeFillState(): BoardState { + return stateFromAscii(9, 'black', [ + 'XXXXXXXXX', + 'X.XXXXXXX', + 'XXXXXXXXX', + 'XXXXXXXXX', + 'XXXXXXXXX', + 'XXXXXXXXX', + 'XXXXXXXXX', + 'XXXXXXXXO', + 'XXXXXXXX.', + ]); +} + +/** Единственная пустая точка — самоубийственный глаз: кандидатов нет. */ +function onlyEyeState(): BoardState { + return stateFromAscii(9, 'black', [ + 'XXXXXXXXX', + 'X.XXXXXXX', + 'XXXXXXXXX', + 'XXXXXXXXX', + 'XXXXXXXXX', + 'XXXXXXXXX', + 'XXXXXXXXX', + 'XXXXXXXXX', + 'XXXXXXXXX', + ]); +} + +describe('LEVEL_ATARI_PROBABILITY', () => { + it('вероятности по спеке: ур.1 ≈ 0.3, ур.3 ≈ 0.9, монотонны', () => { + expect(LEVEL_ATARI_PROBABILITY[1]).toBe(0.3); + expect(LEVEL_ATARI_PROBABILITY[2]).toBe(0.6); + expect(LEVEL_ATARI_PROBABILITY[3]).toBe(0.9); + expect(LEVEL_ATARI_PROBABILITY[1]).toBeLessThan(LEVEL_ATARI_PROBABILITY[2]); + expect(LEVEL_ATARI_PROBABILITY[2]).toBeLessThan(LEVEL_ATARI_PROBABILITY[3]); + }); +}); + +describe('приоритет save-atari', () => { + it('rng «всегда 0»: бот спасает свою группу, после хода у неё >1 дамэ', () => { + const state = ownAtariState(); + const result = chooseMove({ state, level: 3, rng: alwaysZero }); + expect(result.reason).toBe('save-atari'); + expect(result.move).toEqual({ kind: 'play', color: 'black', point: { x: 4, y: 5 } }); + const applied = applyMove(state, result.move); + expect(applied.ok).toBe(true); + if (applied.ok) { + expect(groupAt(applied.state, { x: 4, y: 4 })?.liberties).toBeGreaterThan(1); + } + }); + + it('rng «всегда 0»: контрзахват — единственное спасение нападением', () => { + const state = counterCaptureState(); + const result = chooseMove({ state, level: 3, rng: alwaysZero }); + expect(result.reason).toBe('save-atari'); + expect(result.move).toEqual({ kind: 'play', color: 'black', point: { x: 3, y: 5 } }); + const applied = applyMove(state, result.move); + expect(applied.ok).toBe(true); + if (applied.ok) expect(applied.captured).toEqual([{ x: 3, y: 4 }]); + }); + + it('rng «всегда 0.99»: приоритет пропущен, reason другой', () => { + const state = ownAtariState(); + const result = chooseMove({ + state, + level: 3, + rng: alwaysHigh, + lastOpponentMove: { x: 4, y: 3 }, + }); + expect(result.reason).not.toBe('save-atari'); + expect(result.reason).toBe('nearby'); + }); +}); + +describe('приоритет capture-atari', () => { + it('rng «всегда 0»: бот забирает чужую группу в атари', () => { + const state = enemyAtariState(); + const result = chooseMove({ state, level: 3, rng: alwaysZero }); + expect(result.reason).toBe('capture-atari'); + expect(result.move).toEqual({ kind: 'play', color: 'black', point: { x: 4, y: 5 } }); + const applied = applyMove(state, result.move); + expect(applied.ok).toBe(true); + if (applied.ok) expect(applied.captured).toEqual([{ x: 4, y: 4 }]); + }); + + it('rng «всегда 0.99»: приоритет пропущен, reason другой', () => { + const state = enemyAtariState(); + const result = chooseMove({ + state, + level: 3, + rng: alwaysHigh, + lastOpponentMove: { x: 0, y: 0 }, + }); + expect(result.reason).not.toBe('capture-atari'); + }); +}); + +describe('фильтр своего глаза', () => { + it('единственный ход после фильтра — не глаз (точка (8,8))', () => { + const state = eyeFillState(); + const result = chooseMove({ state, level: 3, rng: alwaysZero }); + expect(result.move).toEqual({ kind: 'play', color: 'black', point: { x: 8, y: 8 } }); + }); + + it('глаз (1,1) не выбирается ни на одном из seed’ов', () => { + const state = eyeFillState(); + for (let seed = 1; seed <= 20; seed += 1) { + let value = seed; + const rng: Rng = { + next: () => { + value = (value * 1103515245 + 12345) % 2147483648; + return value / 2147483648; + }, + }; + const result = chooseMove({ state, level: 1, rng }); + expect(result.move).not.toEqual({ + kind: 'play', + color: 'black', + point: { x: 1, y: 1 }, + }); + } + }); + + it('кандидатов нет (единственная точка — самоубийственный глаз) → pass', () => { + const result = chooseMove({ state: onlyEyeState(), level: 3, rng: alwaysZero }); + expect(result).toEqual({ move: { kind: 'pass', color: 'black' }, reason: 'pass' }); + }); +}); + +describe('вызов при завершённой партии', () => { + it('state.over === true → Error', () => { + let state = createBoard(9); + const first = applyMove(state, { kind: 'pass', color: 'black' }); + expect(first.ok).toBe(true); + if (first.ok) state = first.state; + const second = applyMove(state, { kind: 'pass', color: 'white' }); + expect(second.ok).toBe(true); + if (second.ok) state = second.state; + expect(state.over).toBe(true); + expect(() => chooseMove({ state, level: 3, rng: alwaysZero })).toThrow(Error); + }); +}); diff --git a/packages/ai/src/game.test.ts b/packages/ai/src/game.test.ts new file mode 100644 index 0000000..e692e8d --- /dev/null +++ b/packages/ai/src/game.test.ts @@ -0,0 +1,40 @@ +/** + * Партии бот-бот: детерминизм (одинаковый seed → идентичная партия), + * легальность каждого хода и завершение за ≤ 200 ходов (property-style + * на запиненных seed’ах; проверка легальности — внутри playBotGame через + * applyMove, нелегальный ход = ошибка теста). + */ +import { describe, expect, it } from 'vitest'; +import type { AiLevel } from './index.js'; +import { playBotGame } from './test-utils.js'; + +const MAX_MOVES_9x9 = 200; + +describe('детерминизм', () => { + it('одинаковый seed → идентичные последовательности ходов (два прогона)', () => { + for (const seed of [7, 42, 2026]) { + const first = playBotGame(seed, 3, 9, MAX_MOVES_9x9); + const second = playBotGame(seed, 3, 9, MAX_MOVES_9x9); + expect(second.moves).toEqual(first.moves); + expect(second.finalState.grid).toEqual(first.finalState.grid); + } + }); +}); + +describe('легальность и завершение (property, запиненные seed’ы)', () => { + const levels: readonly AiLevel[] = [1, 2, 3]; + const cases = Array.from({ length: 12 }, (_, index) => ({ + seed: index + 1, + level: levels[index % levels.length] ?? 1, + })); + for (const { seed, level } of cases) { + it(`партия seed=${seed} ур.${level}: все ходы легальны, over за ≤ ${MAX_MOVES_9x9}`, () => { + const game = playBotGame(seed, level, 9, MAX_MOVES_9x9); + expect(game.finalState.over).toBe(true); + expect(game.moves.length).toBeLessThanOrEqual(MAX_MOVES_9x9); + // Партия завершается двумя пасами подряд. + expect(game.moves[game.moves.length - 1]).toBe('pass'); + expect(game.moves[game.moves.length - 2]).toBe('pass'); + }); + } +}); diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts new file mode 100644 index 0000000..f1ce3bd --- /dev/null +++ b/packages/ai/src/index.ts @@ -0,0 +1,18 @@ +/** + * Публичный API ИИ ур. 1–6 (контракт — docs/INTERFACES.md). + * Чистый TypeScript: без DOM/worker API, без Date.now()/Math.random(). + */ +export type { AiLevel, AiMoveRequest, AiMoveResult, AiReason } from './ai.js'; +export { chooseMove, LEVEL_ATARI_PROBABILITY } from './ai.js'; +export type { CandidateScore, MctsLevel, MctsRequest, MctsResult } from './mcts.js'; +export { chooseMoveMcts, LEVEL_TIME_BUDGET_MS } from './mcts.js'; +export type { + RebuildResult, + WorkerCancelRequest, + WorkerChooseRequest, + WorkerRequest, + WorkerResponse, +} from './worker-protocol.js'; +export { rebuildGameState } from './worker-protocol.js'; +export type { MessagePort, WorkerDeps } from './worker.js'; +export { createWorkerHandler } from './worker.js'; diff --git a/packages/ai/src/mcts.test.ts b/packages/ai/src/mcts.test.ts new file mode 100644 index 0000000..036cdec --- /dev/null +++ b/packages/ai/src/mcts.test.ts @@ -0,0 +1,207 @@ +/** + * Тесты MCTS-движка (гейт приёмки этапа 4, docs/plans/plan-phase-4.md): + * детерминизм, легальность, бюджет, качество-smoke, полная партия, perf-smoke. + * Date.now() здесь — только в perf-smoke (замер реального времени в ТЕСТЕ, + * движок получает часы через запрос). + */ +import { applyMove, createBoard, createSeededRng, defaultKomi } from '@go-learn/core'; +import type { BoardSize, BoardState, Clock, Move, Point } from '@go-learn/core'; +import { describe, expect, it } from 'vitest'; +import { chooseMove } from './ai.js'; +import type { MctsRequest } from './mcts.js'; +import { chooseMoveMcts, LEVEL_TIME_BUDGET_MS } from './mcts.js'; +import { stateFromAscii } from './test-utils.js'; + +/** Часы-заглушка: время не течёт (для прогонов с maxSimulations). */ +const frozenClock: Clock = { now: () => 0 }; + +/** Базовый запрос с переопределениями. */ +function request(overrides: Partial & { state: BoardState }): MctsRequest { + return { + komi: defaultKomi(overrides.state.size), + level: 4, + rng: createSeededRng(1), + clock: frozenClock, + ...overrides, + }; +} + +/** Позиция после count ходов эвристики ур.2 от пустой доски (запиненный seed). */ +function positionAfterMoves(size: BoardSize, seed: number, count: number): BoardState { + const rng = createSeededRng(seed); + let state = createBoard(size); + let lastOpponentMove: Point | null = null; + for (let index = 0; index < count && !state.over; index += 1) { + const result = chooseMove({ state, level: 2, rng, lastOpponentMove }); + const applied = applyMove(state, result.move); + if (!applied.ok) throw new Error(`хелпер: нелегальный ход эвристики: ${applied.error}`); + lastOpponentMove = result.move.kind === 'play' ? result.move.point : null; + state = applied.state; + } + return state; +} + +/** Проверка легальности хода MCTS через applyMove. */ +function expectLegal(state: BoardState, move: Move): void { + const applied = applyMove(state, move); + expect(applied.ok, applied.ok ? '' : applied.error).toBe(true); +} + +describe('chooseMoveMcts: детерминизм', () => { + it('maxSimulations=50 + фиксированный seed → идентичные move и topMoves', () => { + const state = positionAfterMoves(9, 42, 20); + const run = (): { move: Move; topMoves: unknown } => { + const result = chooseMoveMcts( + request({ state, rng: createSeededRng(777), maxSimulations: 50 }), + ); + return { move: result.move, topMoves: result.topMoves }; + }; + const first = run(); + const second = run(); + expect(second).toEqual(first); + }); + + it('вызов при state.over === true кидает Error', () => { + let state = createBoard(9); + for (const move of [ + { kind: 'pass', color: 'black' }, + { kind: 'pass', color: 'white' }, + ] as const) { + const applied = applyMove(state, move); + if (!applied.ok) throw new Error('хелпер: пас не применился'); + state = applied.state; + } + expect(state.over).toBe(true); + expect(() => chooseMoveMcts(request({ state }))).toThrow(/завершена/); + }); +}); + +describe('chooseMoveMcts: легальность', () => { + const cases: ReadonlyArray<{ size: BoardSize; seed: number; moves: number }> = [ + { size: 9, seed: 11, moves: 15 }, + { size: 9, seed: 22, moves: 30 }, + { size: 9, seed: 33, moves: 50 }, + { size: 13, seed: 44, moves: 30 }, + { size: 13, seed: 55, moves: 60 }, + ]; + for (const { size, seed, moves } of cases) { + it(`ход легален на случайной позиции ${size}×${size} (seed ${seed}, ${moves} ходов)`, () => { + const state = positionAfterMoves(size, seed, moves); + const result = chooseMoveMcts( + request({ state, rng: createSeededRng(seed * 1000), maxSimulations: 30 }), + ); + expectLegal(state, result.move); + for (const candidate of result.topMoves) expectLegal(state, candidate.move); + expect(result.simulations).toBe(30); + expect(result.topMoves.length).toBeGreaterThan(0); + expect(result.topMoves.length).toBeLessThanOrEqual(3); + }); + } +}); + +describe('chooseMoveMcts: бюджет', () => { + it('остановка по deadline фейк-часов (now() += 50 мс на вызов)', () => { + let t = 0; + const clock: Clock = { now: () => (t += 50) }; + const result = chooseMoveMcts(request({ state: createBoard(9), clock })); + expect(result.simulations).toBeGreaterThanOrEqual(1); + expect(result.simulations).toBeLessThanOrEqual(10); + expect(result.elapsedMs).toBeGreaterThanOrEqual(LEVEL_TIME_BUDGET_MS[4]); + expectLegal(createBoard(9), result.move); + }); + + it('shouldStop после 5 симуляций → стоп, ход легален', () => { + let calls = 0; + const result = chooseMoveMcts( + request({ state: createBoard(9), shouldStop: () => (calls += 1) > 5 }), + ); + expect(result.simulations).toBeLessThanOrEqual(6); + expect(result.simulations).toBeGreaterThanOrEqual(1); + expectLegal(createBoard(9), result.move); + }); + + it('shouldStop() до первой симуляции → легальный ход, без падения', () => { + const result = chooseMoveMcts(request({ state: createBoard(9), shouldStop: () => true })); + expect(result.simulations).toBe(0); + expectLegal(createBoard(9), result.move); + }); +}); + +describe('chooseMoveMcts: качество-smoke', () => { + // Белое кольцо из 8 камней в атари: единственное дамэ — (4,5), ход туда + // снимает всё кольцо. Баланс подобран так, что захват решает партию даже + // в случайных плейаутах: сплошные стены сверху (белые) и снизу (чёрные) + // выживают в плейаутах, поэтому живая группа ≈ победа белых, снятая ≈ + // победа чёрных. Smoke «дерево реально ищет», не эталон силы: seed + // запинен (при 200 симуляциях сигнал сильный, но не абсолютный). + const state = stateFromAscii(9, 'black', [ + 'OOOOOOOOO', + 'OOOOOOOOO', + '.........', + '...XXX...', + '..XOOOX..', + '..XO.OX..', + '..XOOOX..', + '...XXX...', + 'XXXXXXXXX', + ]); + + it('ур.4 (maxSimulations=200) забирает группу в атари', () => { + const result = chooseMoveMcts(request({ state, rng: createSeededRng(1), maxSimulations: 200 })); + expect(result.move).toEqual({ kind: 'play', color: 'black', point: { x: 4, y: 5 } }); + }); +}); + +describe('chooseMoveMcts: полная партия 9×9 против эвристики ур.3', () => { + it('партия доигрывается до over, все ходы легальны', { timeout: 120_000 }, () => { + const mctsRng = createSeededRng(9001); + const heuristicRng = createSeededRng(9002); + let state = createBoard(9); + let lastOpponentMove: Point | null = null; + let plies = 0; + while (!state.over) { + plies += 1; + if (plies > 400) throw new Error('партия не завершилась за 400 полуходов'); + const move: Move = + state.toPlay === 'black' + ? chooseMoveMcts(request({ state, rng: mctsRng, maxSimulations: 100 })).move + : chooseMove({ state, level: 3, rng: heuristicRng, lastOpponentMove }).move; + const applied = applyMove(state, move); + if (!applied.ok) throw new Error(`нелегальный ход на полуходе ${plies}: ${applied.error}`); + lastOpponentMove = move.kind === 'play' ? move.point : null; + state = applied.state; + } + expect(state.over).toBe(true); + }); +}); + +describe('chooseMoveMcts: perf-smoke (реальное время, только для отчёта)', () => { + it('число симуляций за 300 мс на 9×9 > 10', { timeout: 10_000 }, () => { + const result = chooseMoveMcts( + request({ state: createBoard(9), clock: { now: () => Date.now() } }), + ); + console.warn(`[perf] MCTS ур.4 9×9: ${result.simulations} симуляций за ${result.elapsedMs} мс`); + expect(result.simulations).toBeGreaterThan(10); + }); + + it( + 'ур.6 на 13×13 укладывается в бюджет 1500 мс + overhead (< 2.2 с)', + { timeout: 10_000 }, + () => { + const start = Date.now(); + const result = chooseMoveMcts( + request({ + state: createBoard(13), + level: 6, + rng: createSeededRng(2), + clock: { now: () => Date.now() }, + }), + ); + const wall = Date.now() - start; + console.warn( + `[perf] MCTS ур.6 13×13: фактически ${wall} мс, ${result.simulations} симуляций`, + ); + expect(wall).toBeLessThan(2200); + }, + ); +}); diff --git a/packages/ai/src/mcts.ts b/packages/ai/src/mcts.ts new file mode 100644 index 0000000..eb1e412 --- /dev/null +++ b/packages/ai/src/mcts.ts @@ -0,0 +1,301 @@ +/** + * MCTS-движок ИИ ур. 4–6 (контракт — docs/INTERFACES.md, раздел «Контракт + * MCTS ИИ ур. 4–6»). Чистый синхронный вычислитель: без DOM/worker API, без + * Date.now()/Math.random() — случайность и время только через Rng/Clock + * из запроса. Легальность ходов — только через applyMove из @go-learn/core. + * + * Дерево: UCT (c = 1.4), ленивая экспансия легальных детей, pass — легальный + * узел. Плейаут — «лёгкая» политика: дешёвая реакция на атари вокруг + * последнего хода, иначе случайная точка с фильтром своего глаза и лимитом + * попыток; конец — два паса или кап size²×2 ходов. Оценка — scorePosition + * с komi запроса. + */ +import { applyMove, cellAt, groupAt, opposite, scorePosition } from '@go-learn/core'; +import type { BoardState, Clock, Color, Group, Move, Point, Rng } from '@go-learn/core'; +import { legalCandidates, orthogonalNeighbors } from './candidates.js'; +import { pickUniform } from './pick.js'; + +export type MctsLevel = 4 | 5 | 6; + +/** Бюджет по времени: ур.4 — 300 мс, ур.5 — 800 мс, ур.6 — 1500 мс. */ +export const LEVEL_TIME_BUDGET_MS: Readonly> = { + 4: 300, + 5: 800, + 6: 1500, +}; + +/** Константа разведки UCT. */ +const UCT_C = 1.4; +/** Лимит попыток случайной постановки в плейауте, дальше — пас. */ +const PLAYOUT_ATTEMPTS = 10; + +export interface MctsRequest { + readonly state: BoardState; // ходит state.toPlay + readonly komi: number; // для оценки плейаута (scorePosition) + readonly level: MctsLevel; + readonly rng: Rng; // seeded снаружи + readonly clock: Clock; // бюджет по времени + readonly maxSimulations?: number; // override: ровно N симуляций, без тайм-бюджета + readonly shouldStop?: () => boolean; // отмена: проверяется между симуляциями +} + +export interface CandidateScore { + readonly move: Move; // play или pass + readonly winRate: number; // 0..1 по визитам + readonly visits: number; +} + +export interface MctsResult { + readonly move: Move; // лучший по визитам (UCT — внутри дерева) + readonly topMoves: readonly CandidateScore[]; // до 3 лучших — подсказка этапа 5 + readonly simulations: number; + readonly elapsedMs: number; // по инжектированным часам +} + +/** + * Узел дерева. wins/visits — с точки зрения игрока player (сделавшего ход + * в этот узел); у корня player — формальный (opposite(toPlay)), в UCT корня + * не участвует. untried: null — легальные дети ещё не вычислялись (лениво). + */ +interface MctsNode { + readonly move: Move | null; // null только у корня + readonly state: BoardState; + readonly player: Color; + visits: number; + wins: number; + readonly children: MctsNode[]; + untried: Move[] | null; +} + +/** + * Все легальные ходы-постановки (через applyMove) плюс пас. Порядок — + * эвристика экспансии: первыми идут ходы со снятием камней (захваты + * разворачиваются в дерево раньше остальных), пас — последним. + */ +function legalMoves(state: BoardState): Move[] { + const captures: Move[] = []; + const quiet: Move[] = []; + for (let y = 0; y < state.size; y += 1) { + for (let x = 0; x < state.size; x += 1) { + const point = { x, y }; + if (cellAt(state, point) !== 'empty') continue; + const move: Move = { kind: 'play', color: state.toPlay, point }; + const applied = applyMove(state, move); + if (!applied.ok) continue; + if (applied.captured.length > 0) captures.push(move); + else quiet.push(move); + } + } + return [...captures, ...quiet, { kind: 'pass', color: state.toPlay }]; +} + +/** UCT-оценка ребёнка; непосещённый узел — бесконечный приоритет. */ +function uctScore(child: MctsNode, parentVisits: number): number { + if (child.visits === 0) return Infinity; + const exploitation = child.wins / child.visits; + const exploration = UCT_C * Math.sqrt(Math.log(parentVisits) / child.visits); + return exploitation + exploration; +} + +/** Ребёнок с максимальной UCT-оценкой (при равенстве — первый). */ +function selectChild(node: MctsNode): MctsNode { + let best: MctsNode | null = null; + let bestScore = -Infinity; + for (const child of node.children) { + const score = uctScore(child, node.visits); + if (score > bestScore) { + best = child; + bestScore = score; + } + } + return best as MctsNode; +} + +/** Ленивая экспансия: первый untried-ход (порядок legalMoves) — новый узел. */ +function expand(node: MctsNode): MctsNode | null { + if (node.untried === null) node.untried = node.state.over ? [] : legalMoves(node.state); + if (node.untried.length === 0) return null; + const move = node.untried.shift() as Move; + const applied = applyMove(node.state, move); + if (!applied.ok) return null; // недостижимо: untried собран из легальных + const child: MctsNode = { + move, + state: applied.state, + player: opposite(applied.state.toPlay), + visits: 0, + wins: 0, + children: [], + untried: null, + }; + node.children.push(child); + return child; +} + +/** Первое дамэ группы (у группы в атари оно единственное). */ +function soleLiberty(state: BoardState, group: Group): Point | null { + for (const stone of group.stones) { + for (const neighbor of orthogonalNeighbors(state.size, stone)) { + if (cellAt(state, neighbor) === 'empty') return neighbor; + } + } + return null; +} + +/** Дамэ группы цвета color в атари среди самой точки и её ортососедей. */ +function adjacentAtariLiberty(state: BoardState, point: Point, color: Color): Point | null { + const seen = new Set(); + for (const candidate of [point, ...orthogonalNeighbors(state.size, point)]) { + const key = `${candidate.x},${candidate.y}`; + if (seen.has(key) || cellAt(state, candidate) !== color) continue; + const group = groupAt(state, candidate); + if (group === null) continue; + for (const stone of group.stones) seen.add(`${stone.x},${stone.y}`); + if (group.liberties === 1) return soleLiberty(state, group); + } + return null; +} + +/** Дешёвая реакция на атари вокруг последнего хода: добрать чужое / спасти своё. */ +function atariReaction(state: BoardState, lastPoint: Point): Move | null { + const capture = adjacentAtariLiberty(state, lastPoint, opposite(state.toPlay)); + const liberty = capture ?? adjacentAtariLiberty(state, lastPoint, state.toPlay); + if (liberty === null) return null; + return { kind: 'play', color: state.toPlay, point: liberty }; +} + +/** Эвристика «свой глаз»: все ортогональные соседи точки — свои камни. */ +function fillsOwnEye(state: BoardState, point: Point): boolean { + return orthogonalNeighbors(state.size, point).every( + (neighbor) => cellAt(state, neighbor) === state.toPlay, + ); +} + +/** Случайная легальная постановка с фильтром своего глаза; после лимита — пас. */ +function randomPlayoutMove(state: BoardState, rng: Rng): Move { + for (let attempt = 0; attempt < PLAYOUT_ATTEMPTS; attempt += 1) { + const point = { + x: Math.floor(rng.next() * state.size), + y: Math.floor(rng.next() * state.size), + }; + if (cellAt(state, point) !== 'empty' || fillsOwnEye(state, point)) continue; + const move: Move = { kind: 'play', color: state.toPlay, point }; + if (applyMove(state, move).ok) return move; + } + return { kind: 'pass', color: state.toPlay }; +} + +/** Один ход плейаута: реакция на атари или случайный ход; гарантированно легальный. */ +function playoutStep( + state: BoardState, + lastPoint: Point | null, + rng: Rng, +): { readonly move: Move; readonly state: BoardState } { + const move = + (lastPoint === null ? null : atariReaction(state, lastPoint)) ?? randomPlayoutMove(state, rng); + const applied = applyMove(state, move); + if (applied.ok) return { move, state: applied.state }; + const pass: Move = { kind: 'pass', color: state.toPlay }; + const passed = applyMove(state, pass); + return { move: pass, state: passed.ok ? passed.state : state }; +} + +/** Плейаут до двух пасов или капа size²×2 ходов; оценка — scorePosition с komi. */ +function playout(state: BoardState, rng: Rng, komi: number): Color | 'draw' { + let current = state; + let lastPoint: Point | null = null; + const cap = state.size * state.size * 2; + let moves = 0; + while (!current.over && moves < cap) { + const step = playoutStep(current, lastPoint, rng); + lastPoint = step.move.kind === 'play' ? step.move.point : null; + current = step.state; + moves += 1; + } + return scorePosition(current, { komi, dead: new Set() }).winner; +} + +/** Раздача результата плейаута по пути: wins — с точки зрения player узла. */ +function backpropagate(path: readonly MctsNode[], winner: Color | 'draw'): void { + for (const node of path) { + node.visits += 1; + if (winner === node.player) node.wins += 1; + else if (winner === 'draw') node.wins += 0.5; + } +} + +/** Одна симуляция: спуск по UCT → ленивая экспансия → плейаут → backprop. */ +function simulate(root: MctsNode, rng: Rng, komi: number): void { + const path: MctsNode[] = [root]; + let node = root; + while (node.untried !== null && node.untried.length === 0 && node.children.length > 0) { + node = selectChild(node); + path.push(node); + } + if (!node.state.over) { + const child = expand(node); + if (child !== null) { + node = child; + path.push(node); + } + } + backpropagate(path, playout(node.state, rng, komi)); +} + +/** Условие продолжения цикла симуляций: бюджет, shouldStop, maxSimulations. */ +function shouldContinue(request: MctsRequest, simulations: number, deadline: number): boolean { + if (request.maxSimulations !== undefined && simulations >= request.maxSimulations) return false; + if (request.shouldStop?.() === true) return false; + if (request.maxSimulations === undefined && request.clock.now() >= deadline) return false; + return true; +} + +/** Легальный запасной ход для случая shouldStop() до первой симуляции. */ +function fallbackMove(state: BoardState, rng: Rng): Move { + const picked = pickUniform(legalCandidates(state), rng); + if (picked !== null) return { kind: 'play', color: state.toPlay, point: picked.point }; + return { kind: 'pass', color: state.toPlay }; +} + +/** До 3 лучших детей корня по визитам (pass включается, если в топе). */ +function topMoves(root: MctsNode): CandidateScore[] { + return [...root.children] + .sort((a, b) => b.visits - a.visits) + .slice(0, 3) + .map((child) => ({ + move: child.move as Move, + winRate: child.visits === 0 ? 0 : child.wins / child.visits, + visits: child.visits, + })); +} + +/** + * Выбор хода MCTS. Бюджет: стоп при clock.now() >= deadline ИЛИ shouldStop(); + * при maxSimulations — ровно N итераций (тайм-бюджет игнорируется). + * При shouldStop() до первой завершённой симуляции всё равно возвращается + * легальный ход (случайный легальный или пас). + * Вызов при state.over === true — программная ошибка: кидает Error. + */ +export function chooseMoveMcts(request: MctsRequest): MctsResult { + const { state, komi, level, rng, clock } = request; + if (state.over) throw new Error('chooseMoveMcts: партия уже завершена (state.over === true)'); + const root: MctsNode = { + move: null, + state, + player: opposite(state.toPlay), + visits: 0, + wins: 0, + children: [], + untried: null, + }; + const start = clock.now(); + const deadline = start + LEVEL_TIME_BUDGET_MS[level]; + let simulations = 0; + while (shouldContinue(request, simulations, deadline)) { + simulate(root, rng, komi); + simulations += 1; + } + const best = [...root.children].sort((a, b) => b.visits - a.visits)[0]; + const move = + best !== undefined && best.visits > 0 ? (best.move as Move) : fallbackMove(state, rng); + return { move, topMoves: topMoves(root), simulations, elapsedMs: clock.now() - start }; +} diff --git a/packages/ai/src/perf.test.ts b/packages/ai/src/perf.test.ts new file mode 100644 index 0000000..700be42 --- /dev/null +++ b/packages/ai/src/perf.test.ts @@ -0,0 +1,42 @@ +/** + * Perf-smoke: среднее время chooseMove на позиции 19×19 средней игры + * (~100 ходов сыграно) по 20 вызовам — ниже порога с запасом (порог + * машинно-зависимый; критерий приёмки «ур.3 < 50 мс» проверяется фактическим + * замером, число выводится в лог и уходит в отчёт сессии). + */ +import { applyMove, createBoard, createSeededRng } from '@go-learn/core'; +import type { BoardState } from '@go-learn/core'; +import { performance } from 'node:perf_hooks'; +import { describe, expect, it } from 'vitest'; +import { chooseMove } from './index.js'; + +const PERF_THRESHOLD_MS = 200; +const SAMPLES = 20; + +/** Позиция 19×19 после ~100 ходов партии бот-бот (ур.3, запиненный seed). */ +function midgamePosition(size: 19, movesToPlay: number, seed: number): BoardState { + const rng = createSeededRng(seed); + let state = createBoard(size); + for (let played = 0; played < movesToPlay && !state.over; played += 1) { + const result = chooseMove({ state, level: 3, rng }); + const applied = applyMove(state, result.move); + if (!applied.ok) throw new Error(`нелегальный ход при разогреве: ${applied.error}`); + state = applied.state; + } + if (state.over) throw new Error('партия завершилась раньше 100 ходов'); + return state; +} + +describe('perf-smoke chooseMove 19×19', () => { + it(`среднее время по ${SAMPLES} вызовам < ${PERF_THRESHOLD_MS} мс`, () => { + const state = midgamePosition(19, 100, 99); + const rng = createSeededRng(7); + const start = performance.now(); + for (let sample = 0; sample < SAMPLES; sample += 1) { + chooseMove({ state, level: 3, rng }); + } + const averageMs = (performance.now() - start) / SAMPLES; + console.warn(`[perf] chooseMove 19×19 (~100 ходов), среднее: ${averageMs.toFixed(2)} мс`); + expect(averageMs).toBeLessThan(PERF_THRESHOLD_MS); + }); +}); diff --git a/packages/ai/src/pick.ts b/packages/ai/src/pick.ts new file mode 100644 index 0000000..69b9dc0 --- /dev/null +++ b/packages/ai/src/pick.ts @@ -0,0 +1,53 @@ +/** + * Случайный выбор кандидата: равномерный и взвешенный. + * Единственный источник случайности — переданный Rng. + */ +import type { Point, Rng } from '@go-learn/core'; +import type { Candidate } from './candidates.js'; + +/** Кандидат с весом для взвешенного выбора. */ +export interface WeightedCandidate { + readonly candidate: Candidate; + readonly weight: number; +} + +/** Равномерный выбор; null для пустого списка. */ +export function pickUniform(candidates: readonly Candidate[], rng: Rng): Candidate | null { + if (candidates.length === 0) return null; + const index = Math.floor(rng.next() * candidates.length); + return candidates[index] ?? null; +} + +/** Взвешенный выбор пропорционально весам; null для пустого списка. */ +export function pickWeighted(items: readonly WeightedCandidate[], rng: Rng): Candidate | null { + const total = items.reduce((sum, item) => sum + item.weight, 0); + if (total <= 0) return null; + let roll = rng.next() * total; + for (const item of items) { + roll -= item.weight; + if (roll < 0) return item.candidate; + } + return items[items.length - 1]?.candidate ?? null; +} + +/** + * Кандидаты в радиусе Chebyshev ≤ 2 от последнего хода противника. + * Вес тем больше, чем ближе точка: соседи (d = 1) — вес 2, вторая линия + * (d = 2) — вес 1. + */ +export function nearbyCandidates( + candidates: readonly Candidate[], + lastOpponentMove: Point | null, +): WeightedCandidate[] { + if (lastOpponentMove === null) return []; + const result: WeightedCandidate[] = []; + for (const candidate of candidates) { + const dx = Math.abs(candidate.point.x - lastOpponentMove.x); + const dy = Math.abs(candidate.point.y - lastOpponentMove.y); + const distance = Math.max(dx, dy); + if (distance >= 1 && distance <= 2) { + result.push({ candidate, weight: 3 - distance }); + } + } + return result; +} diff --git a/packages/ai/src/tactics.ts b/packages/ai/src/tactics.ts new file mode 100644 index 0000000..c8cd415 --- /dev/null +++ b/packages/ai/src/tactics.ts @@ -0,0 +1,64 @@ +/** + * Тактика атари: спасение своих групп с одним дамэ (включая контрзахват) + * и захват чужих групп с одним дамэ. + */ +import { cellAt, groupAt, opposite, pointKey } from '@go-learn/core'; +import type { BoardState, Color, Group, Point } from '@go-learn/core'; +import type { Candidate } from './candidates.js'; +import { orthogonalNeighbors } from './candidates.js'; + +/** Группы цвета ровно с одним дамэ (в атари). */ +function groupsInAtari(state: BoardState, color: Color): Group[] { + const groups: Group[] = []; + const seen = new Set(); + for (let y = 0; y < state.size; y += 1) { + for (let x = 0; x < state.size; x += 1) { + const point = { x, y }; + if (seen.has(pointKey(point)) || cellAt(state, point) !== color) continue; + const group = groupAt(state, point); + if (group === null) continue; + for (const stone of group.stones) seen.add(pointKey(stone)); + if (group.liberties === 1) groups.push(group); + } + } + return groups; +} + +/** + * Ход спасает группу из атари, если после него у группы больше одного дамэ. + * Охватывает и продление (ход в последнее дамэ), и контрзахват нападающей + * группы: проверка делается по факту — по дамэ группы после хода. + */ +function savesGroup(candidate: Candidate, group: Group): boolean { + const stone = group.stones[0]; + if (stone === undefined) return false; + const after = groupAt(candidate.next, stone); + return after !== null && after.liberties > 1; +} + +/** Кандидаты, спасающие хотя бы одну свою группу из атари. */ +export function savingMoves(state: BoardState, candidates: readonly Candidate[]): Candidate[] { + const inAtari = groupsInAtari(state, state.toPlay); + if (inAtari.length === 0) return []; + return candidates.filter((candidate) => inAtari.some((group) => savesGroup(candidate, group))); +} + +/** Первое найденное дамэ группы (у группы в атари оно единственное). */ +function soleLiberty(state: BoardState, group: Group): Point | null { + for (const stone of group.stones) { + for (const neighbor of orthogonalNeighbors(state.size, stone)) { + if (cellAt(state, neighbor) === 'empty') return neighbor; + } + } + return null; +} + +/** Кандидаты в последнее дамэ чужой группы в атари (захват). */ +export function captureMoves(state: BoardState, candidates: readonly Candidate[]): Candidate[] { + const targets = new Set(); + for (const group of groupsInAtari(state, opposite(state.toPlay))) { + const liberty = soleLiberty(state, group); + if (liberty !== null) targets.add(pointKey(liberty)); + } + return candidates.filter((candidate) => targets.has(pointKey(candidate.point))); +} diff --git a/packages/ai/src/test-utils.ts b/packages/ai/src/test-utils.ts new file mode 100644 index 0000000..a706959 --- /dev/null +++ b/packages/ai/src/test-utils.ts @@ -0,0 +1,81 @@ +/** + * Тестовые помощники: построение позиции из ASCII и прогон партии бот-бот. + * В публичный API (index.ts) не экспортируются. + */ +import { applyMove, createBoard, createSeededRng } from '@go-learn/core'; +import type { BoardSize, BoardState, CellState, Color, Move, Point } from '@go-learn/core'; +import type { AiLevel } from './ai.js'; +import { chooseMove } from './ai.js'; + +/** + * Позиция из ASCII-строк: 'X' — чёрные, 'O' — белые, '.' — пусто. + * positionHashes пуст: для сконструированных позиций суперко-проверка + * против истории не нужна (повторов «пустого» прошлого быть не может). + */ +export function stateFromAscii( + size: BoardSize, + toPlay: Color, + rows: readonly string[], +): BoardState { + if (rows.length !== size) throw new Error(`нужно ровно ${size} строк`); + const grid: CellState[] = []; + for (const row of rows) { + if (row.length !== size) throw new Error(`строка должна быть длины ${size}: "${row}"`); + for (const char of row) { + if (char === 'X') grid.push('black'); + else if (char === 'O') grid.push('white'); + else if (char === '.') grid.push('empty'); + else throw new Error(`неизвестный символ клетки: "${char}"`); + } + } + return { + size, + grid, + toPlay, + captures: { black: 0, white: 0 }, + koPoint: null, + positionHashes: [], + over: false, + }; +} + +/** Запись партии бот-бот: журнал ходов и финальное состояние. */ +export interface BotGameRecord { + readonly moves: readonly string[]; + readonly finalState: BoardState; +} + +/** Компактная запись хода для сравнения партий. */ +function formatMove(move: Move): string { + return move.kind === 'play' ? `${move.point.x},${move.point.y}` : move.kind; +} + +/** + * Партия бот-бот от пустой доски до over. Каждый ход бота применяется через + * applyMove: нелегальный ход или превышение maxMoves — ошибка теста. + */ +export function playBotGame( + seed: number, + level: AiLevel, + size: BoardSize, + maxMoves: number, +): BotGameRecord { + const rng = createSeededRng(seed); + let state = createBoard(size); + const moves: string[] = []; + let lastOpponentMove: Point | null = null; + while (!state.over) { + if (moves.length >= maxMoves) { + throw new Error(`партия (seed ${seed}) не завершилась за ${maxMoves} ходов`); + } + const result = chooseMove({ state, level, rng, lastOpponentMove }); + const applied = applyMove(state, result.move); + if (!applied.ok) { + throw new Error(`бот сделал нелегальный ход (seed ${seed}): ${applied.error}`); + } + moves.push(formatMove(result.move)); + if (result.move.kind === 'play') lastOpponentMove = result.move.point; + state = applied.state; + } + return { moves, finalState: state }; +} diff --git a/packages/ai/src/wiring.test.ts b/packages/ai/src/wiring.test.ts new file mode 100644 index 0000000..e5bca80 --- /dev/null +++ b/packages/ai/src/wiring.test.ts @@ -0,0 +1,51 @@ +/** + * Wiring-тест (gotcha-green-modules-dead-system): orphan-check публичного API + * packages/ai — каждая экспортируемая сущность упоминается хотя бы раз вне + * своего определения (в другом модуле пакета или в тесте). + */ +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const srcDir = dirname(fileURLToPath(import.meta.url)); + +function sourceFiles(): string[] { + return readdirSync(srcDir) + .filter((name) => name.endsWith('.ts')) + .map((name) => join(srcDir, name)); +} + +/** Имена, экспортируемые из index.ts (типы пропускаем). */ +function exportedNames(): string[] { + const index = readFileSync(join(srcDir, 'index.ts'), 'utf8'); + const names: string[] = []; + const pattern = /export\s+(?!type)\{([^}]*)\}\s*from/g; + for (const match of index.matchAll(pattern)) { + const body = match[1] ?? ''; + for (const part of body.split(',')) { + const name = part.trim(); + if (name !== '') names.push(name); + } + } + return names; +} + +describe('orphan-check: публичный API ИИ связан с кодом', () => { + it('каждый экспорт упоминается вне своего определения', () => { + const files = sourceFiles().filter((file) => !file.endsWith('index.ts')); + const contents = files.map((file) => readFileSync(file, 'utf8')); + const missing: string[] = []; + for (const name of exportedNames()) { + const usage = new RegExp(`\\b${name}\\b`); + const mentions = contents.filter((content) => usage.test(content)).length; + // Определение + минимум одно использование — минимум два файла; + // упоминание в любом *.test.ts тоже засчитывается. + const inTests = contents.some( + (content, index) => files[index]?.endsWith('.test.ts') === true && usage.test(content), + ); + if (mentions < 2 && !inTests) missing.push(name); + } + expect(missing).toEqual([]); + }); +}); diff --git a/packages/ai/src/worker-protocol.ts b/packages/ai/src/worker-protocol.ts new file mode 100644 index 0000000..d60c4b2 --- /dev/null +++ b/packages/ai/src/worker-protocol.ts @@ -0,0 +1,63 @@ +/** + * Типы протокола postMessage воркера ИИ ур. 4–6 (контракт — + * docs/INTERFACES.md, «Протокол postMessage воркера»). Протокол stateless: + * каждый choose несёт полный список ходов партии от пустой доски; воркер + * пересобирает BoardState через applyMove и валидирует каждый ход. + */ +import { applyMove, createBoard } from '@go-learn/core'; +import type { BoardSize, BoardState, Move } from '@go-learn/core'; +import type { CandidateScore, MctsLevel } from './mcts.js'; + +export interface WorkerChooseRequest { + readonly type: 'choose'; + readonly requestId: number; + readonly size: BoardSize; + readonly komi: number; + readonly moves: readonly Move[]; // вся партия от пустой доски + readonly level: MctsLevel; + readonly seed: number; // seed воркера (партия + requestId — забота UI) + readonly timeBudgetMs: number; +} + +export interface WorkerCancelRequest { + readonly type: 'cancel'; + readonly requestId: number; +} + +export type WorkerRequest = WorkerChooseRequest | WorkerCancelRequest; + +export type WorkerResponse = + | { + readonly type: 'result'; + readonly requestId: number; + readonly move: Move; + readonly topMoves: readonly CandidateScore[]; + readonly simulations: number; + readonly elapsedMs: number; + } + | { readonly type: 'error'; readonly requestId: number; readonly message: string }; + +/** Результат пересборки позиции из списка ходов. */ +export type RebuildResult = + | { readonly ok: true; readonly state: BoardState } + | { readonly ok: false; readonly moveIndex: number; readonly message: string }; + +/** + * Пересборка позиции: createBoard(size) + applyMove по списку ходов. + * Нелегальный ход → ok: false с индексом хода (0-based) и сообщением ядра. + */ +export function rebuildGameState(size: BoardSize, moves: readonly Move[]): RebuildResult { + let state = createBoard(size); + for (let index = 0; index < moves.length; index += 1) { + const applied = applyMove(state, moves[index] as Move); + if (!applied.ok) { + return { + ok: false, + moveIndex: index, + message: `ход #${index + 1} нелегален: ${applied.error}`, + }; + } + state = applied.state; + } + return { ok: true, state }; +} diff --git a/packages/ai/src/worker.test.ts b/packages/ai/src/worker.test.ts new file mode 100644 index 0000000..9dbc5a4 --- /dev/null +++ b/packages/ai/src/worker.test.ts @@ -0,0 +1,130 @@ +/** + * Тесты протокола воркера через фейк-порт (без реального Worker): + * choose → result с тем же requestId; нелегальный список → error; cancel + * активного → result с лучшим найденным; cancel чужого id → без эффекта. + */ +import type { Clock } from '@go-learn/core'; +import { describe, expect, it } from 'vitest'; +import type { WorkerChooseRequest, WorkerRequest, WorkerResponse } from './worker-protocol.js'; +import type { MessagePort } from './worker.js'; +import { createWorkerHandler } from './worker.js'; + +/** Фейк-порт: собирает все ответы воркера. */ +function fakePort(): { port: MessagePort; messages: WorkerResponse[] } { + const messages: WorkerResponse[] = []; + return { port: { postMessage: (msg) => messages.push(msg) }, messages }; +} + +/** Базовый choose-запрос с переопределениями. */ +function chooseMsg(overrides: Partial = {}): WorkerChooseRequest { + return { + type: 'choose', + requestId: 1, + size: 9, + komi: 5.5, + moves: [], + level: 4, + seed: 42, + timeBudgetMs: 300, + ...overrides, + }; +} + +/** Часы-счётчик: +100 мс на каждый вызов now() — остановка по deadline. */ +function countingClock(onCall?: (calls: number) => void): Clock { + let t = 0; + let calls = 0; + return { + now: (): number => { + calls += 1; + onCall?.(calls); + return (t += 100); + }, + }; +} + +describe('worker-протокол через фейк-порт', () => { + it('choose → result с тем же requestId и легальным ходом', () => { + const { port, messages } = fakePort(); + const handler = createWorkerHandler(port, { clock: countingClock() }); + handler( + chooseMsg({ + moves: [ + { kind: 'play', color: 'black', point: { x: 2, y: 2 } }, + { kind: 'play', color: 'white', point: { x: 6, y: 6 } }, + ], + }), + ); + expect(messages).toHaveLength(1); + const response = messages[0]; + expect(response?.type).toBe('result'); + expect(response?.requestId).toBe(1); + if (response?.type === 'result') { + expect(response.simulations).toBeGreaterThanOrEqual(1); + expect(response.topMoves.length).toBeLessThanOrEqual(3); + } + }); + + it('нелегальный ход в списке → error с номером хода', () => { + const { port, messages } = fakePort(); + const handler = createWorkerHandler(port, { clock: countingClock() }); + handler( + chooseMsg({ + moves: [ + { kind: 'play', color: 'black', point: { x: 2, y: 2 } }, + { kind: 'play', color: 'white', point: { x: 2, y: 2 } }, // занято + ], + }), + ); + expect(messages).toEqual([ + expect.objectContaining({ + type: 'error', + requestId: 1, + message: expect.stringContaining('#2'), + }), + ]); + }); + + it('список ходов до конца партии → error (партия завершена)', () => { + const { port, messages } = fakePort(); + const handler = createWorkerHandler(port, { clock: countingClock() }); + handler( + chooseMsg({ + moves: [ + { kind: 'pass', color: 'black' }, + { kind: 'pass', color: 'white' }, + ], + }), + ); + expect(messages[0]?.type).toBe('error'); + }); + + it('cancel активного requestId → result с лучшим найденным (не error)', () => { + const { port, messages } = fakePort(); + let handler: (msg: WorkerRequest) => void; + // Реентерабельная отмена: на 3-м вызове now() (внутри счёта) шлём cancel. + const clock = countingClock((calls) => { + if (calls === 3) handler({ type: 'cancel', requestId: 7 }); + }); + handler = createWorkerHandler(port, { clock }); + handler(chooseMsg({ requestId: 7, timeBudgetMs: 1_000_000 })); // «бесконечный» бюджет + expect(messages).toHaveLength(1); + const response = messages[0]; + expect(response?.type).toBe('result'); + expect(response?.requestId).toBe(7); + if (response?.type === 'result') expect(response.simulations).toBeLessThanOrEqual(5); + }); + + it('cancel чужого requestId → без эффекта: choose завершается обычным result', () => { + const { port, messages } = fakePort(); + const handler = createWorkerHandler(port, { clock: countingClock() }); + handler({ type: 'cancel', requestId: 999 }); + expect(messages).toHaveLength(0); // cancel сам по себе не порождает ответа + handler(chooseMsg({ requestId: 2 })); + expect(messages).toHaveLength(1); + const response = messages[0]; + expect(response?.type).toBe('result'); + expect(response?.requestId).toBe(2); + if (response?.type === 'result') expect(response.simulations).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/packages/ai/src/worker.ts b/packages/ai/src/worker.ts new file mode 100644 index 0000000..f0749b9 --- /dev/null +++ b/packages/ai/src/worker.ts @@ -0,0 +1,109 @@ +/** + * Тонкая воркер-обёртка MCTS (контракт — docs/INTERFACES.md, «Протокол + * postMessage воркера»): парсинг → пересборка позиции → chooseMoveMcts → + * ответ. Абстракция порта позволяет тестировать протокол фейк-портом без + * реального Worker. Точку входа браузера (self.onmessage) подключает UI + * этапа 5: `self.onmessage = (e) => createWorkerHandler(self)(e.data)`. + * + * cancel активного requestId → вычисление прерывается через shouldStop и + * воркер отвечает result с лучшим найденным (НЕ error, НЕ молчание); + * cancel чужого id игнорируется. Замечание о реальном Worker: обработчик + * синхронный, поэтому cancel, пришедший во время счёта, будет обработан + * event loop'ом после result — фактическая отмена в браузере возможна, + * только если UI пришлёт cancel до choose или движок будет крутиться + * ломтиками (этап 5+); семантика протокола здесь реализована полностью. + */ +import { createSeededRng } from '@go-learn/core'; +import type { Clock } from '@go-learn/core'; +import { chooseMoveMcts, LEVEL_TIME_BUDGET_MS } from './mcts.js'; +import type { MctsLevel } from './mcts.js'; +import type { WorkerChooseRequest, WorkerRequest, WorkerResponse } from './worker-protocol.js'; +import { rebuildGameState } from './worker-protocol.js'; + +/** Минимальный интерфейс порта: ровно то, что нужно обработчику. */ +export interface MessagePort { + postMessage(msg: WorkerResponse): void; +} + +/** Инжектируемые зависимости обёртки (в тестах — фейк-часы). */ +export interface WorkerDeps { + readonly clock?: Clock; +} + +/** + * Часы, отображающие timeBudgetMs запроса на бюджет уровня движка: движок + * останавливается по LEVEL_TIME_BUDGET_MS[level] своих часов, поэтому шкалу + * растягиваем так, чтобы фактический бюджет был timeBudgetMs. При равенстве + * бюджетов масштаб 1 — часы проходят без искажений. + */ +function budgetClock(base: Clock, level: MctsLevel, timeBudgetMs: number): Clock { + const levelBudget = LEVEL_TIME_BUDGET_MS[level]; + if (timeBudgetMs === levelBudget) return base; + const scale = timeBudgetMs > 0 ? levelBudget / timeBudgetMs : Infinity; + const start = base.now(); + return { now: () => start + (base.now() - start) * scale }; +} + +/** Обработка choose: пересборка, вычисление, ответ result/error. */ +function handleChoose( + msg: WorkerChooseRequest, + port: MessagePort, + baseClock: Clock, + isCancelled: () => boolean, +): void { + const rebuilt = rebuildGameState(msg.size, msg.moves); + if (!rebuilt.ok) { + port.postMessage({ type: 'error', requestId: msg.requestId, message: rebuilt.message }); + return; + } + if (rebuilt.state.over) { + port.postMessage({ + type: 'error', + requestId: msg.requestId, + message: 'партия уже завершена (state.over === true)', + }); + return; + } + const clock = budgetClock(baseClock, msg.level, msg.timeBudgetMs); + const result = chooseMoveMcts({ + state: rebuilt.state, + komi: msg.komi, + level: msg.level, + rng: createSeededRng(msg.seed), + clock, + shouldStop: isCancelled, + }); + port.postMessage({ + type: 'result', + requestId: msg.requestId, + move: result.move, + topMoves: result.topMoves, + simulations: result.simulations, + elapsedMs: result.elapsedMs, + }); +} + +/** + * Обработчик сообщений воркера: choose → result/error, cancel активного + * requestId → флаг отмены (движок остановится и вернёт лучший найденный). + */ +export function createWorkerHandler( + port: MessagePort, + deps: WorkerDeps = {}, +): (msg: WorkerRequest) => void { + const baseClock: Clock = deps.clock ?? { now: () => performance.now() }; + let active: { readonly requestId: number; cancelled: boolean } | null = null; + return (msg: WorkerRequest): void => { + if (msg.type === 'cancel') { + if (active !== null && active.requestId === msg.requestId) active.cancelled = true; + return; + } + const current = { requestId: msg.requestId, cancelled: false }; + active = current; + try { + handleChoose(msg, port, baseClock, () => current.cancelled); + } finally { + active = null; + } + }; +} diff --git a/packages/ai/tsconfig.json b/packages/ai/tsconfig.json new file mode 100644 index 0000000..76db3af --- /dev/null +++ b/packages/ai/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022"], + "types": ["node"] + }, + // Намеренно БЕЗ references на ../core: гейт typecheck — `tsc -b --noEmit`, + // а TS 5.9 в build-режиме с --noEmit отклоняет любую цепочку project + // references с чистого состояния (TS6310 "may not disable emit" — CLI-флаг + // распространяется на referenced-проект). Импорт @go-learn/core резолвится + // через workspace-симлинк (main: src/index.ts) — типы видны и так. + // Корневой tsconfig.json по-прежнему ссылается на packages/ai. + "include": ["src"] +} diff --git a/packages/board/package.json b/packages/board/package.json new file mode 100644 index 0000000..a6d6e8d --- /dev/null +++ b/packages/board/package.json @@ -0,0 +1,14 @@ +{ + "name": "@go-learn/board", + "version": "0.1.0", + "private": true, + "description": "Отрисовка доски Го на Canvas 2D: чистая геометрия, рендер состояния, редьюсер тач-ввода с подтверждением, тонкий DOM-адаптер.", + "type": "module", + "main": "src/index.ts", + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "@go-learn/core": "0.1.0" + } +} diff --git a/packages/board/src/dom.ts b/packages/board/src/dom.ts new file mode 100644 index 0000000..e701a6f --- /dev/null +++ b/packages/board/src/dom.ts @@ -0,0 +1,43 @@ +/** + * Тонкий DOM-адаптер: настройка канвы под devicePixelRatio и перевод + * pointer-событий в InputEvent. Логики здесь нет — вся в geometry/input. + * Юнит-тестами не покрывается (проверка — смоук на этапе 5). + */ +import type { InputEvent } from './input.js'; + +/** + * Выставляет canvas.width/height = cssSize × devicePixelRatio и + * ctx.setTransform(dpr, …), чтобы рисовать в логических CSS px. + * Возвращает применённый dpr. + */ +export function setupCanvas(canvas: HTMLCanvasElement, cssSize: number): number { + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.round(cssSize * dpr); + canvas.height = Math.round(cssSize * dpr); + canvas.style.width = `${cssSize}px`; + canvas.style.height = `${cssSize}px`; + canvas.getContext('2d')?.setTransform(dpr, 0, 0, dpr, 0, 0); + return dpr; +} + +/** + * Подписывается на pointerdown канвы и вызывает onEvent с событием 'tap' + * в координатах канвы (CSS px). Возвращает функцию отписки. + */ +export function attachPointerInput( + canvas: HTMLCanvasElement, + onEvent: (event: InputEvent) => void, +): () => void { + const handler = (event: PointerEvent): void => { + const rect = canvas.getBoundingClientRect(); + onEvent({ + type: 'tap', + px: event.clientX - rect.left, + py: event.clientY - rect.top, + }); + }; + canvas.addEventListener('pointerdown', handler); + return (): void => { + canvas.removeEventListener('pointerdown', handler); + }; +} diff --git a/packages/board/src/geometry.test.ts b/packages/board/src/geometry.test.ts new file mode 100644 index 0000000..08b540b --- /dev/null +++ b/packages/board/src/geometry.test.ts @@ -0,0 +1,124 @@ +/** + * Тесты геометрии: хоси, roundtrip pixelToPoint∘pointToPixel, отклонение + * курсора дальше половины клетки, поля с координатами и без. + */ +import { describe, expect, it } from 'vitest'; +import type { BoardSize, Point } from '@go-learn/core'; +import { computeGeometry, hoshiPoints, pixelToPoint, pointToPixel } from './geometry.js'; + +function hasPoint(points: readonly Point[], x: number, y: number): boolean { + return points.some((p) => p.x === x && p.y === y); +} + +describe('hoshiPoints', () => { + it('19×19: 9 точек, углы на линиях 3-3 (0-based)', () => { + const points = hoshiPoints(19); + expect(points).toHaveLength(9); + for (const x of [3, 9, 15]) { + for (const y of [3, 9, 15]) expect(hasPoint(points, x, y)).toBe(true); + } + }); + + it('13×13: 5 точек — углы 3-3 и центр', () => { + const points = hoshiPoints(13); + expect(points).toHaveLength(5); + for (const [x, y] of [ + [3, 3], + [9, 3], + [6, 6], + [3, 9], + [9, 9], + ]) { + expect(hasPoint(points, x ?? 0, y ?? 0)).toBe(true); + } + }); + + it('9×9: 5 точек — углы 2-2 и центр', () => { + const points = hoshiPoints(9); + expect(points).toHaveLength(5); + for (const [x, y] of [ + [2, 2], + [6, 2], + [4, 4], + [2, 6], + [6, 6], + ]) { + expect(hasPoint(points, x ?? 0, y ?? 0)).toBe(true); + } + }); +}); + +describe('computeGeometry', () => { + it('сетка + два поля ровно заполняют квадрат канвы', () => { + for (const size of [9, 13, 19] as const) { + for (const show of [true, false]) { + const geo = computeGeometry(size, 600, show); + expect(geo.padding * 2 + geo.cell * (size - 1)).toBeCloseTo(600, 10); + } + } + }); + + it('padding с координатами больше, чем без', () => { + const withCoords = computeGeometry(19, 600, true); + const without = computeGeometry(19, 600, false); + expect(withCoords.padding).toBeGreaterThan(without.padding); + }); + + it('крайний камень не обрезается: padding ≥ cell/2', () => { + for (const size of [9, 13, 19] as const) { + expect(computeGeometry(size, 300, false).padding).toBeGreaterThanOrEqual( + computeGeometry(size, 300, false).cell / 2, + ); + } + }); +}); + +describe('roundtrip pixelToPoint∘pointToPixel', () => { + it('каждое пересечение возвращается в себя', () => { + for (const size of [9, 13, 19] as const) { + const geo = computeGeometry(size, 570, true); + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + const px = pointToPixel(geo, { x, y }); + expect(pixelToPoint(geo, px.x, px.y)).toEqual({ x, y }); + } + } + } + }); + + it('сдвиг меньше половины клетки — ближайшее пересечение', () => { + const geo = computeGeometry(9, 450, false); + const center = pointToPixel(geo, { x: 4, y: 4 }); + const hit = pixelToPoint(geo, center.x + geo.cell * 0.4, center.y - geo.cell * 0.4); + expect(hit).toEqual({ x: 4, y: 4 }); + }); +}); + +describe('pixelToPoint: отклонения', () => { + const geo = computeGeometry(9, 450, false); + + it('дальше половины клетки за краем доски — null', () => { + const corner = pointToPixel(geo, { x: 8, y: 8 }); + expect(pixelToPoint(geo, corner.x + geo.cell * 0.6, corner.y)).toBeNull(); + expect(pixelToPoint(geo, corner.x, corner.y + geo.cell * 0.6)).toBeNull(); + }); + + it('за пределами канвы — null', () => { + expect(pixelToPoint(geo, -10, 100)).toBeNull(); + expect(pixelToPoint(geo, 100, geo.pixelSize + 10)).toBeNull(); + }); + + it('в пределах половины клетки от крайнего пересечения — не null', () => { + const edge = pointToPixel(geo, { x: 8, y: 0 }); + expect(pixelToPoint(geo, edge.x + geo.cell * 0.4, edge.y)).toEqual({ x: 8, y: 0 }); + }); +}); + +describe('размер доски в геометрии', () => { + it('size пробрасывается из аргумента', () => { + const sizes: BoardSize[] = [9, 13, 19]; + for (const size of sizes) { + expect(computeGeometry(size, 500, true).size).toBe(size); + } + }); +}); diff --git a/packages/board/src/geometry.ts b/packages/board/src/geometry.ts new file mode 100644 index 0000000..1b81158 --- /dev/null +++ b/packages/board/src/geometry.ts @@ -0,0 +1,71 @@ +/** + * Геометрия доски: перевод пересечений в пиксели канвы и обратно. + * Чистый модуль: без DOM, без состояния, без Date.now()/Math.random(). + * + * Канва — логический квадрат pixelSize×pixelSize (CSS px); сетка занимает + * (size−1)·cell, остаток поровну уходит в поля padding с четырёх сторон. + */ +import type { BoardSize, Point } from '@go-learn/core'; + +export interface BoardGeometry { + readonly size: BoardSize; + readonly pixelSize: number; // логический квадрат канвы в CSS px + readonly padding: number; // поля (под координаты, если включены) + readonly cell: number; // шаг сетки в CSS px +} + +/** + * Геометрия доски под квадрат pixelSize. + * Поля считаются от шага сетки: с координатами — целая клетка (место под + * буквы/числа), без — полклетки с запасом, чтобы крайние камни не резались. + */ +export function computeGeometry( + size: BoardSize, + pixelSize: number, + showCoordinates: boolean, +): BoardGeometry { + const marginCells = showCoordinates ? 1 : 0.55; + const cell = pixelSize / (size - 1 + marginCells * 2); + const padding = marginCells * cell; + return { size, pixelSize, padding, cell }; +} + +/** Центр пересечения в CSS px (от левого верхнего угла канвы). */ +export function pointToPixel(geo: BoardGeometry, p: Point): { x: number; y: number } { + return { x: geo.padding + p.x * geo.cell, y: geo.padding + p.y * geo.cell }; +} + +/** + * Ближайшее пересечение к точке касания; null — курсор вне доски либо + * дальше половины клетки от любого пересечения. + */ +export function pixelToPoint(geo: BoardGeometry, px: number, py: number): Point | null { + const x = Math.round((px - geo.padding) / geo.cell); + const y = Math.round((py - geo.padding) / geo.cell); + if (x < 0 || x >= geo.size || y < 0 || y >= geo.size) return null; + const dx = Math.abs(px - (geo.padding + x * geo.cell)); + const dy = Math.abs(py - (geo.padding + y * geo.cell)); + if (dx > geo.cell / 2 || dy > geo.cell / 2) return null; + return { x, y }; +} + +/** + * Хоси (звёздные пункты): 19×19 — 9 точек (линии 3/9/15 в 0-based), + * 13×13 и 9×9 — по 5 (углы + центр). + */ +export function hoshiPoints(size: BoardSize): readonly Point[] { + if (size === 19) { + const lines = [3, 9, 15]; + return lines.flatMap((y) => lines.map((x) => ({ x, y }))); + } + const edge = size === 13 ? 3 : 2; + const center = (size - 1) / 2; + const far = size - 1 - edge; + return [ + { x: edge, y: edge }, + { x: far, y: edge }, + { x: center, y: center }, + { x: edge, y: far }, + { x: far, y: far }, + ]; +} diff --git a/packages/board/src/index.ts b/packages/board/src/index.ts new file mode 100644 index 0000000..3583d34 --- /dev/null +++ b/packages/board/src/index.ts @@ -0,0 +1,13 @@ +/** + * Публичный API пакета доски (контракт — docs/INTERFACES.md). + * Геометрия, ввод и рендер — чистые функции без DOM; DOM — только dom.ts. + * Типа PointKey в @go-learn/core нет: ключи множеств/карт — string формата + * pointKey ("x,y"), см. комментарии у RenderOptions. + */ +export type { BoardGeometry } from './geometry.js'; +export { computeGeometry, hoshiPoints, pixelToPoint, pointToPixel } from './geometry.js'; +export type { BoardTheme, RenderOptions, TerritoryMap } from './render.js'; +export { DEAD_STONE_ALPHA, render } from './render.js'; +export type { InputEvent, InputResult, InputState } from './input.js'; +export { reduceInput } from './input.js'; +export { attachPointerInput, setupCanvas } from './dom.js'; diff --git a/packages/board/src/input.test.ts b/packages/board/src/input.test.ts new file mode 100644 index 0000000..ed49290 --- /dev/null +++ b/packages/board/src/input.test.ts @@ -0,0 +1,79 @@ +/** + * Тесты редьюсера тач-ввода: вся таблица переходов. + * tap → pixelToPoint; pending/commit/cancel/перенос по контракту. + */ +import { describe, expect, it } from 'vitest'; +import type { Point } from '@go-learn/core'; +import { computeGeometry, pointToPixel } from './geometry.js'; +import type { InputEvent, InputState } from './input.js'; +import { reduceInput } from './input.js'; + +const geo = computeGeometry(9, 450, false); +const IDLE: InputState = { pending: null }; + +function tap(point: Point): InputEvent { + const { x, y } = pointToPixel(geo, point); + return { type: 'tap', px: x, py: y }; +} + +const A: Point = { x: 3, y: 3 }; +const B: Point = { x: 5, y: 6 }; + +describe('reduceInput: tap', () => { + it('тап при pending=null → pending=точка, commit=null', () => { + const result = reduceInput(IDLE, tap(A), geo); + expect(result.commit).toBeNull(); + expect(result.state.pending).toEqual(A); + }); + + it('повторный тап по той же точке → commit=точка, pending=null', () => { + const result = reduceInput({ pending: A }, tap(A), geo); + expect(result.commit).toEqual(A); + expect(result.state.pending).toBeNull(); + }); + + it('тап по другой точке → перенос pending, commit=null', () => { + const result = reduceInput({ pending: A }, tap(B), geo); + expect(result.commit).toBeNull(); + expect(result.state.pending).toEqual(B); + }); + + it('тап мимо доски → состояние не меняется, commit=null', () => { + const miss: InputEvent = { type: 'tap', px: -20, py: 450 + 20 }; + const fromIdle = reduceInput(IDLE, miss, geo); + expect(fromIdle.state).toBe(IDLE); + expect(fromIdle.commit).toBeNull(); + const withPending: InputState = { pending: A }; + const fromPending = reduceInput(withPending, miss, geo); + expect(fromPending.state).toBe(withPending); + expect(fromPending.commit).toBeNull(); + }); +}); + +describe('reduceInput: confirm', () => { + it('confirm при pending → commit=pending, pending=null', () => { + const result = reduceInput({ pending: A }, { type: 'confirm' }, geo); + expect(result.commit).toEqual(A); + expect(result.state.pending).toBeNull(); + }); + + it('confirm без pending → commit=null, pending=null', () => { + const result = reduceInput(IDLE, { type: 'confirm' }, geo); + expect(result.commit).toBeNull(); + expect(result.state.pending).toBeNull(); + }); +}); + +describe('reduceInput: cancel', () => { + it('cancel при pending → сброс, commit=null', () => { + const result = reduceInput({ pending: A }, { type: 'cancel' }, geo); + expect(result.commit).toBeNull(); + expect(result.state.pending).toBeNull(); + }); + + it('cancel без pending → commit=null, pending=null', () => { + const result = reduceInput(IDLE, { type: 'cancel' }, geo); + expect(result.commit).toBeNull(); + expect(result.state.pending).toBeNull(); + }); +}); diff --git a/packages/board/src/input.ts b/packages/board/src/input.ts new file mode 100644 index 0000000..d1fbaa7 --- /dev/null +++ b/packages/board/src/input.ts @@ -0,0 +1,38 @@ +/** + * Тач-ввод с подтверждением: чистый редьюсер, без DOM. + * Первый тап — фантом (pending); второй тап по той же точке или confirm — + * ход (commit); тап по другой точке — перенос фантома; cancel — сброс. + */ +import type { Point } from '@go-learn/core'; +import type { BoardGeometry } from './geometry.js'; +import { pixelToPoint } from './geometry.js'; + +export type InputEvent = + | { readonly type: 'tap'; readonly px: number; readonly py: number } + | { readonly type: 'confirm' } + | { readonly type: 'cancel' }; + +export interface InputState { + readonly pending: Point | null; +} + +export interface InputResult { + readonly state: InputState; + readonly commit: Point | null; // не-null — пользователь подтвердил ход +} + +function samePoint(a: Point, b: Point): boolean { + return a.x === b.x && a.y === b.y; +} + +/** Единственная точка изменения состояния ввода; неизменяемый переход. */ +export function reduceInput(state: InputState, event: InputEvent, geo: BoardGeometry): InputResult { + if (event.type === 'cancel') return { state: { pending: null }, commit: null }; + if (event.type === 'confirm') return { state: { pending: null }, commit: state.pending }; + const point = pixelToPoint(geo, event.px, event.py); + if (point === null) return { state, commit: null }; + if (state.pending !== null && samePoint(state.pending, point)) { + return { state: { pending: null }, commit: point }; + } + return { state: { pending: point }, commit: null }; +} diff --git a/packages/board/src/render.test.ts b/packages/board/src/render.test.ts new file mode 100644 index 0000000..251ead2 --- /dev/null +++ b/packages/board/src/render.test.ts @@ -0,0 +1,254 @@ +/** + * Тесты рендера: мок CanvasRenderingContext2D с записью вызовов. + * Проверяем только инварианты, не каждый вызов: + * — число камней = числу непустых клеток BoardState; + * — фантом рисуется с globalAlpha = phantomOpacity; + * — метка последнего хода — только при lastMove; + * — подписи LB и фигуры TR/SQ/CR приходят из markup; + * — fillRect-заливка — только фон и territory-ключи; + * — мёртвые камни рисуются с пониженной альфой (DEAD_STONE_ALPHA). + */ +import { describe, expect, it } from 'vitest'; +import type { BoardState, NodeMarkup, Point } from '@go-learn/core'; +import { applyMove, createBoard } from '@go-learn/core'; +import type { BoardTheme, RenderOptions } from './render.js'; +import { DEAD_STONE_ALPHA, render } from './render.js'; + +interface PathOp { + readonly kind: 'arc' | 'moveTo' | 'lineTo'; +} + +interface PaintRecord { + readonly color: string; + readonly globalAlpha: number; + readonly path: readonly PathOp[]; +} + +interface RectRecord { + readonly x: number; + readonly y: number; + readonly w: number; + readonly h: number; + readonly color: string; +} + +interface TextRecord { + readonly text: string; + readonly x: number; + readonly y: number; + readonly color: string; + readonly font: string; +} + +/** Рекордер вызовов CanvasRenderingContext2D (только нужный рендеру набор). */ +class CtxRecorder { + fillStyle = '#000000'; + strokeStyle = '#000000'; + globalAlpha = 1; + lineWidth = 1; + font = '10px sans-serif'; + textAlign = 'start'; + textBaseline = 'alphabetic'; + readonly canvas = { width: 600, height: 600 } as HTMLCanvasElement; + readonly fills: PaintRecord[] = []; + readonly strokes: PaintRecord[] = []; + readonly fillRects: RectRecord[] = []; + readonly strokeRects: RectRecord[] = []; + readonly texts: TextRecord[] = []; + saves = 0; + restores = 0; + private path: PathOp[] = []; + private readonly stack: number[] = []; + + save(): void { + this.saves += 1; + this.stack.push(this.globalAlpha); + } + + restore(): void { + this.restores += 1; + this.globalAlpha = this.stack.pop() ?? 1; + } + + setTransform(): void {} + scale(): void {} + setLineDash(): void {} + beginPath(): void { + this.path = []; + } + arc(): void { + this.path.push({ kind: 'arc' }); + } + moveTo(): void { + this.path.push({ kind: 'moveTo' }); + } + lineTo(): void { + this.path.push({ kind: 'lineTo' }); + } + getTransform(): DOMMatrix { + return { a: 1 } as DOMMatrix; + } + fill(): void { + this.fills.push({ color: this.fillStyle, globalAlpha: this.globalAlpha, path: this.path }); + } + stroke(): void { + this.strokes.push({ + color: this.strokeStyle, + globalAlpha: this.globalAlpha, + path: this.path, + }); + } + fillRect(x: number, y: number, w: number, h: number): void { + this.fillRects.push({ x, y, w, h, color: this.fillStyle }); + } + strokeRect(x: number, y: number, w: number, h: number): void { + this.strokeRects.push({ x, y, w, h, color: this.strokeStyle }); + } + fillText(text: string, x: number, y: number): void { + this.texts.push({ text, x, y, color: this.fillStyle, font: this.font }); + } +} + +/** Различимые цвета для инвариантов (остальное — дефолтная тёмная тема). */ +const THEME: Partial = { + blackStone: '#111111', + whiteStone: '#eeeeee', + phantomOpacity: 0.4, + lastMoveMarker: '#ff00ff', + markupColor: '#00ff00', + territoryBlack: 'rgba(1, 2, 3, 0.5)', + territoryWhite: 'rgba(4, 5, 6, 0.5)', +}; + +function play(state: BoardState, point: Point): BoardState { + const result = applyMove(state, { kind: 'play', color: state.toPlay, point }); + if (!result.ok) throw new Error(result.error); + return result.state; +} + +/** Позиция 9×9 с тремя камнями: чёрные (2,2) и (4,4), белый (6,2). */ +function threeStones(): BoardState { + let state = createBoard(9); + state = play(state, { x: 2, y: 2 }); + state = play(state, { x: 6, y: 2 }); + return play(state, { x: 4, y: 4 }); +} + +function draw(state: BoardState, options: RenderOptions = {}): CtxRecorder { + const rec = new CtxRecorder(); + render(rec as unknown as CanvasRenderingContext2D, state, { + theme: THEME, + ...options, + }); + return rec; +} + +function stoneFills(rec: CtxRecorder, alpha: number): PaintRecord[] { + return rec.fills.filter( + (f) => (f.color === '#111111' || f.color === '#eeeeee') && f.globalAlpha === alpha, + ); +} + +describe('render: камни', () => { + it('число нарисованных камней = числу непустых клеток', () => { + const rec = draw(threeStones()); + expect(stoneFills(rec, 1)).toHaveLength(3); + expect(rec.saves).toBe(rec.restores); + }); + + it('мёртвые камни приглушены альфой DEAD_STONE_ALPHA', () => { + const rec = draw(threeStones(), { dead: new Set(['2,2', '6,2']) }); + expect(stoneFills(rec, DEAD_STONE_ALPHA)).toHaveLength(2); + expect(stoneFills(rec, 1)).toHaveLength(1); + }); +}); + +describe('render: фантом и последний ход', () => { + it('фантом рисуется с globalAlpha = phantomOpacity', () => { + const rec = draw(threeStones(), { phantom: { point: { x: 0, y: 0 }, color: 'black' } }); + const phantom = rec.fills.filter((f) => f.color === '#111111' && f.globalAlpha === 0.4); + expect(phantom).toHaveLength(1); + expect(stoneFills(rec, 1)).toHaveLength(3); + }); + + it('метка последнего хода — только при lastMove', () => { + const without = draw(threeStones()); + expect(without.strokes.filter((s) => s.color === '#ff00ff')).toHaveLength(0); + const withMarker = draw(threeStones(), { lastMove: { x: 4, y: 4 } }); + const marker = withMarker.strokes.filter((s) => s.color === '#ff00ff'); + expect(marker).toHaveLength(1); + expect(marker[0]?.path.some((op) => op.kind === 'arc')).toBe(true); + }); +}); + +describe('render: разметка из markup', () => { + const markup: NodeMarkup = { + labels: [ + { point: { x: 1, y: 1 }, text: 'A' }, + { point: { x: 2, y: 2 }, text: 'Б' }, + ], + triangles: [{ x: 3, y: 5 }], + squares: [{ x: 5, y: 3 }], + circles: [{ x: 7, y: 7 }], + }; + + it('подписи LB присутствуют', () => { + const rec = draw(threeStones(), { markup }); + const texts = rec.texts.map((t) => t.text); + expect(texts).toContain('A'); + expect(texts).toContain('Б'); + }); + + it('SQ/TR/CR рисуются цветом markupColor по одной фигуре', () => { + const rec = draw(threeStones(), { markup }); + const squares = rec.strokeRects.filter((r) => r.color === '#00ff00'); + expect(squares).toHaveLength(1); + const shapes = rec.strokes.filter((s) => s.color === '#00ff00'); + const lineTos = (s: PaintRecord): number => s.path.filter((op) => op.kind === 'lineTo').length; + expect(shapes.filter((s) => lineTos(s) === 3)).toHaveLength(1); // TR + expect(shapes.filter((s) => s.path.some((op) => op.kind === 'arc'))).toHaveLength(1); // CR + }); +}); + +describe('render: заливка территории', () => { + it('fillRect — только фон и territory-ключи, цвет по владельцу', () => { + const rec = draw(threeStones(), { + territory: { black: new Set(['0,0', '1,0']), white: new Set(['8,8']) }, + }); + expect(rec.fillRects).toHaveLength(4); // фон + 3 клетки территории + expect(rec.fillRects.filter((r) => r.color === 'rgba(1, 2, 3, 0.5)')).toHaveLength(2); + expect(rec.fillRects.filter((r) => r.color === 'rgba(4, 5, 6, 0.5)')).toHaveLength(1); + }); + + it('заливка совпадает с координатами ключа "1,0"', () => { + const rec = draw(createBoard(9), { territory: { black: new Set(['1,0']), white: new Set() } }); + const cell = 600 / (8 + 2); // size 9, координаты включены + const rect = rec.fillRects.find((r) => r.color === 'rgba(1, 2, 3, 0.5)'); + expect(rect?.x).toBeCloseTo(cell + cell - cell / 2, 6); + expect(rect?.y).toBeCloseTo(cell - cell / 2, 6); + expect(rect?.w).toBeCloseTo(cell, 6); + }); + + it('без territory — единственный fillRect (фон)', () => { + expect(draw(threeStones()).fillRects).toHaveLength(1); + }); +}); + +describe('render: номера ходов', () => { + it('номера рисуются только при showMoveNumbers и только на занятых клетках', () => { + const moveNumbers = new Map([ + ['2,2', 1], + ['6,6', 5], + ]); + // Координаты выключены, чтобы их числа не совпадали с номерами ходов. + const off = draw(threeStones(), { moveNumbers, showCoordinates: false }); + expect(off.texts.filter((t) => t.text === '1')).toHaveLength(0); + const on = draw(threeStones(), { + showMoveNumbers: true, + moveNumbers, + showCoordinates: false, + }); + expect(on.texts.filter((t) => t.text === '1')).toHaveLength(1); + expect(on.texts.filter((t) => t.text === '5')).toHaveLength(0); // клетка пуста + }); +}); diff --git a/packages/board/src/render.ts b/packages/board/src/render.ts new file mode 100644 index 0000000..8e2c929 --- /dev/null +++ b/packages/board/src/render.ts @@ -0,0 +1,333 @@ +/** + * Рендер доски на Canvas 2D: чистая функция состояния. Каждый кадр рисуется + * целиком из аргументов (state + options); render ничего не хранит и не + * читает, кроме ctx. CanvasRenderingContext2D — только тип параметра. + * + * Порядок слоёв: фон доски → заливка территории → сетка → хоси → координаты → + * разметка SQ/TR/CR → камни → метка последнего хода → номера ходов → фантом → + * подписи LB. + * + * Логический размер канвы выводится из ctx: pixelSize = canvas.width / dpr, + * где dpr — масштаб текущего трансформа (его выставляет setupCanvas). + */ +import type { BoardState, Color, NodeMarkup, Point } from '@go-learn/core'; +import { cellAt, pointKey } from '@go-learn/core'; +import type { BoardGeometry } from './geometry.js'; +import { computeGeometry, hoshiPoints, pointToPixel } from './geometry.js'; + +const TAU = Math.PI * 2; + +/** Непрозрачность приглушения мёртвых камней (помеченных в options.dead). */ +export const DEAD_STONE_ALPHA = 0.45; + +export interface BoardTheme { + readonly boardBackground: string; + readonly lineColor: string; + readonly blackStone: string; + readonly whiteStone: string; + readonly blackStoneEdge: string; + readonly whiteStoneEdge: string; + readonly coordinateColor: string; + readonly markupColor: string; + readonly lastMoveMarker: string; + readonly phantomOpacity: number; // 0..1 + readonly territoryBlack: string; // заливка с альфой + readonly territoryWhite: string; +} + +/** Заливка территории при подсчёте: ключи pointKey ("x,y") по цветам. */ +export interface TerritoryMap { + readonly black: ReadonlySet; + readonly white: ReadonlySet; +} + +export interface RenderOptions { + readonly theme?: Partial; // дефолт — тёмная тема + readonly showCoordinates?: boolean; // дефолт true + readonly showMoveNumbers?: boolean; // дефолт false + readonly moveNumbers?: ReadonlyMap; // ключ pointKey ("x,y") → номер хода + readonly lastMove?: Point | null; + readonly phantom?: { readonly point: Point; readonly color: Color } | null; + readonly markup?: NodeMarkup; // из @go-learn/core + readonly territory?: TerritoryMap; // заливка при подсчёте + readonly dead?: ReadonlySet; // ключи pointKey ("x,y"); приглушаются (пониженная непрозрачность) +} + +/** + * Дефолтная светлая тема «васи» (этап 10): светлое дерево гобана, коричневая + * сетка, почти чёрный и молочный камни с читаемой обводкой; цвета совпадают + * с токенами сайта (Layout.astro), разметка — терракота, последний ход — + * индиго. Константы — часть дизайна (контракт docs/INTERFACES.md). + */ +const DEFAULT_THEME: BoardTheme = { + boardBackground: '#e6c48a', + lineColor: '#8a6a3f', + blackStone: '#211e1a', + whiteStone: '#f6f2e8', + blackStoneEdge: '#4a453c', + whiteStoneEdge: '#b9ac93', + coordinateColor: '#8a6a3f', + markupColor: '#a84e2c', + lastMoveMarker: '#3d4f7c', + phantomOpacity: 0.5, + territoryBlack: 'rgba(20, 15, 10, 0.45)', + territoryWhite: 'rgba(236, 229, 216, 0.35)', +}; + +/** Буквы координат без «I» (стандарт Го). */ +const LETTERS = 'ABCDEFGHJKLMNOPQRST'; + +function resolveTheme(partial: Partial | undefined): BoardTheme { + return { ...DEFAULT_THEME, ...partial }; +} + +/** Разбор ключа pointKey ("x,y"); null — формат не совпал. */ +function parseKey(key: string): Point | null { + const [xs, ys] = key.split(','); + const x = Number(xs); + const y = Number(ys); + if (!Number.isInteger(x) || !Number.isInteger(y)) return null; + return { x, y }; +} + +/** Контрастный цвет текста поверх клетки (камня или доски). */ +function contrastOn(state: BoardState, point: Point, theme: BoardTheme): string { + const cell = cellAt(state, point); + if (cell === 'black') return theme.whiteStone; + if (cell === 'white') return theme.blackStone; + return theme.markupColor; +} + +export function render( + ctx: CanvasRenderingContext2D, + state: BoardState, + options: RenderOptions = {}, +): void { + const theme = resolveTheme(options.theme); + const showCoordinates = options.showCoordinates ?? true; + const dpr = ctx.getTransform().a || 1; // масштаб, выставленный setupCanvas + const geo = computeGeometry(state.size, ctx.canvas.width / dpr, showCoordinates); + ctx.save(); + drawBackground(ctx, theme, geo.pixelSize); + if (options.territory !== undefined) drawTerritory(ctx, geo, options.territory, theme); + drawGrid(ctx, geo, theme); + drawHoshi(ctx, geo, theme); + if (showCoordinates) drawCoordinates(ctx, geo, theme); + if (options.markup !== undefined) drawMarkupShapes(ctx, geo, options.markup, theme); + drawStones(ctx, state, geo, theme, options.dead); + if (options.lastMove != null) drawLastMove(ctx, geo, options.lastMove, theme); + if ((options.showMoveNumbers ?? false) && options.moveNumbers !== undefined) { + drawMoveNumbers(ctx, state, geo, options.moveNumbers, theme); + } + if (options.phantom != null) drawPhantom(ctx, geo, options.phantom, theme); + if (options.markup !== undefined) drawLabels(ctx, state, geo, options.markup, theme); + ctx.restore(); +} + +function drawBackground(ctx: CanvasRenderingContext2D, theme: BoardTheme, pixelSize: number): void { + ctx.fillStyle = theme.boardBackground; + ctx.fillRect(0, 0, pixelSize, pixelSize); +} + +/** Заливка территории: клетка вокруг пересечения, цвет — по владельцу. */ +function drawTerritory( + ctx: CanvasRenderingContext2D, + geo: BoardGeometry, + territory: TerritoryMap, + theme: BoardTheme, +): void { + const paint = (keys: ReadonlySet, fill: string): void => { + ctx.fillStyle = fill; + for (const key of keys) { + const point = parseKey(key); + if (point === null) continue; + const { x, y } = pointToPixel(geo, point); + ctx.fillRect(x - geo.cell / 2, y - geo.cell / 2, geo.cell, geo.cell); + } + }; + paint(territory.black, theme.territoryBlack); + paint(territory.white, theme.territoryWhite); +} + +function drawGrid(ctx: CanvasRenderingContext2D, geo: BoardGeometry, theme: BoardTheme): void { + const start = geo.padding; + const end = geo.padding + (geo.size - 1) * geo.cell; + ctx.strokeStyle = theme.lineColor; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let i = 0; i < geo.size; i += 1) { + const pos = geo.padding + i * geo.cell; + ctx.moveTo(start, pos); + ctx.lineTo(end, pos); + ctx.moveTo(pos, start); + ctx.lineTo(pos, end); + } + ctx.stroke(); +} + +function drawHoshi(ctx: CanvasRenderingContext2D, geo: BoardGeometry, theme: BoardTheme): void { + ctx.fillStyle = theme.lineColor; + const radius = Math.max(2, geo.cell * 0.09); + for (const point of hoshiPoints(geo.size)) { + const { x, y } = pointToPixel(geo, point); + ctx.beginPath(); + ctx.arc(x, y, radius, 0, TAU); + ctx.fill(); + } +} + +/** Буквы (без «I») снизу слева направо, числа слева, 1 внизу. */ +function drawCoordinates( + ctx: CanvasRenderingContext2D, + geo: BoardGeometry, + theme: BoardTheme, +): void { + const fontSize = Math.max(9, Math.round(geo.cell * 0.34)); + ctx.fillStyle = theme.coordinateColor; + ctx.font = `${fontSize}px system-ui, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const bottom = geo.padding + (geo.size - 1) * geo.cell; + for (let i = 0; i < geo.size; i += 1) { + const pos = geo.padding + i * geo.cell; + ctx.fillText(LETTERS[i] ?? '', pos, bottom + geo.padding * 0.55); + ctx.fillText(String(geo.size - i), geo.padding * 0.45, pos); + } +} + +function drawTriangle(ctx: CanvasRenderingContext2D, x: number, y: number, r: number): void { + ctx.beginPath(); + ctx.moveTo(x, y - r); + ctx.lineTo(x + r * 0.87, y + r * 0.5); + ctx.lineTo(x - r * 0.87, y + r * 0.5); + ctx.lineTo(x, y - r); + ctx.stroke(); +} + +/** Разметка SQ/TR/CR — контурными фигурами, под камнями. */ +function drawMarkupShapes( + ctx: CanvasRenderingContext2D, + geo: BoardGeometry, + markup: NodeMarkup, + theme: BoardTheme, +): void { + ctx.strokeStyle = theme.markupColor; + ctx.lineWidth = Math.max(1.5, geo.cell * 0.06); + const r = geo.cell * 0.32; + for (const point of markup.squares) { + const { x, y } = pointToPixel(geo, point); + ctx.strokeRect(x - r, y - r, r * 2, r * 2); + } + for (const point of markup.triangles) { + const { x, y } = pointToPixel(geo, point); + drawTriangle(ctx, x, y, r); + } + for (const point of markup.circles) { + const { x, y } = pointToPixel(geo, point); + ctx.beginPath(); + ctx.arc(x, y, r, 0, TAU); + ctx.stroke(); + } +} + +/** Один камень: круг чуть меньше полклетки, тонкая обводка edge-цветом. */ +function drawStone( + ctx: CanvasRenderingContext2D, + geo: BoardGeometry, + point: Point, + color: Color, + theme: BoardTheme, + alpha: number, +): void { + const { x, y } = pointToPixel(geo, point); + ctx.save(); + ctx.globalAlpha = alpha; + ctx.beginPath(); + ctx.arc(x, y, geo.cell / 2 - 1, 0, TAU); + ctx.fillStyle = color === 'black' ? theme.blackStone : theme.whiteStone; + ctx.fill(); + ctx.strokeStyle = color === 'black' ? theme.blackStoneEdge : theme.whiteStoneEdge; + ctx.lineWidth = 1; + ctx.stroke(); + ctx.restore(); +} + +function drawStones( + ctx: CanvasRenderingContext2D, + state: BoardState, + geo: BoardGeometry, + theme: BoardTheme, + dead: ReadonlySet | undefined, +): void { + for (let y = 0; y < state.size; y += 1) { + for (let x = 0; x < state.size; x += 1) { + const point = { x, y }; + const cell = cellAt(state, point); + if (cell === 'empty') continue; + const alpha = dead?.has(pointKey(point)) === true ? DEAD_STONE_ALPHA : 1; + drawStone(ctx, geo, point, cell, theme, alpha); + } + } +} + +/** Метка последнего хода: контрастное кольцо, различимое на обоих цветах. */ +function drawLastMove( + ctx: CanvasRenderingContext2D, + geo: BoardGeometry, + lastMove: Point, + theme: BoardTheme, +): void { + const { x, y } = pointToPixel(geo, lastMove); + ctx.strokeStyle = theme.lastMoveMarker; + ctx.lineWidth = Math.max(2, geo.cell * 0.07); + ctx.beginPath(); + ctx.arc(x, y, geo.cell * 0.22, 0, TAU); + ctx.stroke(); +} + +/** Номера ходов — только на занятых клетках, контрастом к цвету камня. */ +function drawMoveNumbers( + ctx: CanvasRenderingContext2D, + state: BoardState, + geo: BoardGeometry, + moveNumbers: ReadonlyMap, + theme: BoardTheme, +): void { + ctx.font = `${Math.max(8, Math.round(geo.cell * 0.4))}px system-ui, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + for (const [key, num] of moveNumbers) { + const point = parseKey(key); + if (point === null || cellAt(state, point) === 'empty') continue; + ctx.fillStyle = contrastOn(state, point, theme); + const { x, y } = pointToPixel(geo, point); + ctx.fillText(String(num), x, y); + } +} + +function drawPhantom( + ctx: CanvasRenderingContext2D, + geo: BoardGeometry, + phantom: { readonly point: Point; readonly color: Color }, + theme: BoardTheme, +): void { + drawStone(ctx, geo, phantom.point, phantom.color, theme, theme.phantomOpacity); +} + +/** Подписи LB — поверх всех слоёв, контрастом к содержимому клетки. */ +function drawLabels( + ctx: CanvasRenderingContext2D, + state: BoardState, + geo: BoardGeometry, + markup: NodeMarkup, + theme: BoardTheme, +): void { + ctx.font = `bold ${Math.max(9, Math.round(geo.cell * 0.42))}px system-ui, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + for (const label of markup.labels) { + ctx.fillStyle = contrastOn(state, label.point, theme); + const { x, y } = pointToPixel(geo, label.point); + ctx.fillText(label.text, x, y); + } +} diff --git a/packages/board/src/wiring.test.ts b/packages/board/src/wiring.test.ts new file mode 100644 index 0000000..75a4ced --- /dev/null +++ b/packages/board/src/wiring.test.ts @@ -0,0 +1,59 @@ +/** + * Wiring-тест (gotcha-green-modules-dead-system): orphan-check публичного API + * packages/board — каждая экспортируемая функция упоминается хотя бы раз вне + * своего определения (в другом модуле пакета или в тесте). + * + * Допустимые «внешние» входные точки: setupCanvas и attachPointerInput — + * DOM-адаптер для будущего UI (этап 5), юнит-тестами не покрывается + * (см. plan-phase-2, матрица «чего НЕ покрывает ни один тест»). + */ +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const srcDir = dirname(fileURLToPath(import.meta.url)); + +/** Входные точки DOM-адаптера: вызываются будущим UI, не тестами. */ +const EXTERNAL_ENTRY_POINTS = new Set(['setupCanvas', 'attachPointerInput']); + +function sourceFiles(): string[] { + return readdirSync(srcDir) + .filter((name) => name.endsWith('.ts')) + .map((name) => join(srcDir, name)); +} + +/** Имена, экспортируемые из index.ts (типы пропускаем). */ +function exportedNames(): string[] { + const index = readFileSync(join(srcDir, 'index.ts'), 'utf8'); + const names: string[] = []; + const pattern = /export\s+(?!type)\{([^}]*)\}\s*from/g; + for (const match of index.matchAll(pattern)) { + const body = match[1] ?? ''; + for (const part of body.split(',')) { + const name = part.trim(); + if (name !== '') names.push(name); + } + } + return names; +} + +describe('orphan-check: публичный API доски связан с кодом', () => { + it('каждый экспорт упоминается вне своего определения', () => { + const files = sourceFiles().filter((file) => !file.endsWith('index.ts')); + const contents = files.map((file) => readFileSync(file, 'utf8')); + const missing: string[] = []; + for (const name of exportedNames()) { + if (EXTERNAL_ENTRY_POINTS.has(name)) continue; + const usage = new RegExp(`\\b${name}\\b`); + const mentions = contents.filter((content) => usage.test(content)).length; + // Определение + минимум одно использование — минимум два файла; + // упоминание в любом *.test.ts тоже засчитывается. + const inTests = contents.some( + (content, index) => files[index]?.endsWith('.test.ts') === true && usage.test(content), + ); + if (mentions < 2 && !inTests) missing.push(name); + } + expect(missing).toEqual([]); + }); +}); diff --git a/packages/board/tsconfig.json b/packages/board/tsconfig.json new file mode 100644 index 0000000..1344cbe --- /dev/null +++ b/packages/board/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"], + "types": ["node"] + }, + // Намеренно БЕЗ references на ../core: гейт typecheck — `tsc -b --noEmit`, + // а TS 5.9 в build-режиме с --noEmit отклоняет любую цепочку project + // references с чистого состояния (TS6310 "may not disable emit" — CLI-флаг + // распространяется на referenced-проект). Импорт @go-learn/core резолвится + // через workspace-симлинк (main: src/index.ts) — типы видны и так. + // Корневой tsconfig.json по-прежнему ссылается на packages/board. + "include": ["src"] +} diff --git a/packages/core/bench/group-bench.mts b/packages/core/bench/group-bench.mts new file mode 100644 index 0000000..39be366 --- /dev/null +++ b/packages/core/bench/group-bench.mts @@ -0,0 +1,141 @@ +/** + * Микро-бенчмарк: flood fill vs union-find для поиска групп и дамэ + * на случайных позициях 19×19. Запуск: npx tsx packages/core/bench/group-bench.mts + * (tsx — эфемерный инструмент прогона, не зависимость пакета). + * Случайность — только createSeededRng с фиксированным seed (детерминизм). + */ +import { performance } from 'node:perf_hooks'; +import { createSeededRng } from '../src/index.js'; + +const SIZE = 19; +const CELLS = SIZE * SIZE; +const POSITIONS = 40; +const ITERATIONS = 300; + +/** Случайная позиция: клетки заняты с вероятностью density, цвет 50/50. */ +function randomPosition(seed: number, density: number): Uint8Array { + const rng = createSeededRng(seed); + const grid = new Uint8Array(CELLS); + for (let index = 0; index < CELLS; index += 1) { + if (rng.next() < density) grid[index] = rng.next() < 0.5 ? 1 : 2; + } + return grid; +} + +/** Соседи клетки по индексу. */ +function neighborIndices(index: number): number[] { + const x = index % SIZE; + const y = Math.floor(index / SIZE); + const result: number[] = []; + if (x > 0) result.push(index - 1); + if (x < SIZE - 1) result.push(index + 1); + if (y > 0) result.push(index - SIZE); + if (y < SIZE - 1) result.push(index + SIZE); + return result; +} + +/** Flood fill: группы + число дамэ (как в движке, groupAt). */ +function groupsFloodFill(grid: Uint8Array): number { + const seen = new Uint8Array(CELLS); + let groups = 0; + let liberties = 0; + for (let start = 0; start < CELLS; start += 1) { + if (grid[start] === 0 || seen[start] === 1) continue; + groups += 1; + const color = grid[start]; + const stack = [start]; + seen[start] = 1; + const libs = new Set(); + while (stack.length > 0) { + const current = stack.pop() as number; + for (const next of neighborIndices(current)) { + if (grid[next] === 0) libs.add(next); + if (grid[next] === color && seen[next] === 0) { + seen[next] = 1; + stack.push(next); + } + } + } + liberties += libs.size; + } + return groups * 1000 + liberties; +} + +/** Union-find: группы + число дамэ. */ +function groupsUnionFind(grid: Uint8Array): number { + const parent = new Int32Array(CELLS); + for (let index = 0; index < CELLS; index += 1) parent[index] = index; + const find = (value: number): number => { + let root = value; + while (parent[root] !== root) root = parent[root] ?? root; + let node = value; + while (parent[node] !== node) { + const next = parent[node] ?? node; + parent[node] = root; + node = next; + } + return root; + }; + for (let index = 0; index < CELLS; index += 1) { + if (grid[index] === 0) continue; + for (const next of neighborIndices(index)) { + if (grid[next] === grid[index]) parent[find(index)] = find(next); + } + } + const libsByRoot = new Map>(); + for (let index = 0; index < CELLS; index += 1) { + if (grid[index] === 0) continue; + const root = find(index); + let libs = libsByRoot.get(root); + if (libs === undefined) { + libs = new Set(); + libsByRoot.set(root, libs); + } + for (const next of neighborIndices(index)) { + if (grid[next] === 0) libs.add(next); + } + } + let liberties = 0; + for (const libs of libsByRoot.values()) liberties += libs.size; + return libsByRoot.size * 1000 + liberties; +} + +/** Прогон одного алгоритма на наборе позиций; возвращает мс и контрольную сумму. */ +function measure( + positions: Uint8Array[], + algorithm: (grid: Uint8Array) => number, +): { ms: number; checksum: number } { + let checksum = 0; + const started = performance.now(); + for (let iteration = 0; iteration < ITERATIONS; iteration += 1) { + for (const grid of positions) checksum = (checksum + algorithm(grid)) % 1000003; + } + return { ms: performance.now() - started, checksum }; +} + +const rng = createSeededRng(7); +const densities = [0.35, 0.55, 0.75]; +let totalFlood = 0; +let totalUnion = 0; +for (const density of densities) { + const positions = Array.from({ length: POSITIONS }, (_, index) => + randomPosition(Math.floor(rng.next() * 100000), density), + ); + const flood = measure(positions, groupsFloodFill); + const union = measure(positions, groupsUnionFind); + if (flood.checksum !== union.checksum) { + console.warn(`ОШИБКА: контрольные суммы не совпали (${flood.checksum} != ${union.checksum})`); + process.exitCode = 1; + } + totalFlood += flood.ms; + totalUnion += union.ms; + console.warn( + `density=${density}: flood fill ${flood.ms.toFixed(1)} мс, union-find ${union.ms.toFixed(1)} мс, ` + + `отношение u/f ${(union.ms / flood.ms).toFixed(2)}`, + ); +} +console.warn( + `ИТОГО: flood fill ${totalFlood.toFixed(1)} мс, union-find ${totalUnion.toFixed(1)} мс, ` + + `отношение u/f ${(totalUnion / totalFlood).toFixed(2)} ` + + `(${POSITIONS * ITERATIONS * densities.length} прогонов позиций 19x19)`, +); diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..208fe60 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,11 @@ +{ + "name": "@go-learn/core", + "version": "0.1.0", + "private": true, + "description": "Движок правил Го: доска, группы, захваты, ко/суперко, подсчёт Tromp-Taylor, SGF, история. Чистый TypeScript, ноль зависимостей от DOM, детерминизм (часы и RNG инжектируются).", + "type": "module", + "main": "src/index.ts", + "scripts": { + "test": "vitest run" + } +} diff --git a/packages/core/src/board.ts b/packages/core/src/board.ts new file mode 100644 index 0000000..94d622e --- /dev/null +++ b/packages/core/src/board.ts @@ -0,0 +1,171 @@ +/** + * Базовые типы и операции доски: сетка, соседи, группы, дамэ. + * Позиция — строго типизированная структура: обычный Array с readonly-типом. + */ + +export type BoardSize = 9 | 13 | 19; +export type Color = 'black' | 'white'; +export type CellState = 'empty' | 'black' | 'white'; + +export interface Point { + readonly x: number; + readonly y: number; +} + +export type Move = + | { readonly kind: 'play'; readonly color: Color; readonly point: Point } + | { readonly kind: 'pass'; readonly color: Color } + | { readonly kind: 'resign'; readonly color: Color }; + +export interface BoardState { + readonly size: BoardSize; + readonly grid: ReadonlyArray; + readonly toPlay: Color; + readonly captures: { readonly black: number; readonly white: number }; + readonly koPoint: Point | null; + readonly positionHashes: ReadonlyArray; + readonly over: boolean; +} + +/** Противоположный цвет. */ +export function opposite(color: Color): Color { + return color === 'black' ? 'white' : 'black'; +} + +/** Ключ точки для множеств ("x,y"). */ +export function pointKey(point: Point): string { + return `${point.x},${point.y}`; +} + +/** Точка в пределах доски. */ +export function inBounds(size: BoardSize, point: Point): boolean { + return point.x >= 0 && point.x < size && point.y >= 0 && point.y < size; +} + +/** Индекс клетки в grid. */ +export function cellIndex(size: BoardSize, point: Point): number { + return point.y * size + point.x; +} + +/** Содержимое клетки; 'empty', если точка вне доски. */ +export function cellAt(state: BoardState, point: Point): CellState { + if (!inBounds(state.size, point)) return 'empty'; + return state.grid[cellIndex(state.size, point)] ?? 'empty'; +} + +/** Ортогональные соседи точки в пределах доски. */ +export function neighbors(size: BoardSize, point: Point): Point[] { + const result: Point[] = []; + const candidates: Point[] = [ + { x: point.x - 1, y: point.y }, + { x: point.x + 1, y: point.y }, + { x: point.x, y: point.y - 1 }, + { x: point.x, y: point.y + 1 }, + ]; + for (const candidate of candidates) { + if (inBounds(size, candidate)) result.push(candidate); + } + return result; +} + +export interface Group { + readonly stones: ReadonlyArray; + readonly liberties: number; +} + +/** + * Группа в точке и число её дамэ (flood fill). + * Инвариант (property-тесты): после любого легального хода каждая группа + * на доске имеет минимум одно дамэ; группа без дамэ снимается с доски. + */ +export function groupAt(state: BoardState, point: Point): Group | null { + const color = cellAt(state, point); + if (color === 'empty' || !inBounds(state.size, point)) return null; + const stones: Point[] = []; + const seen = new Set(); + const libertyKeys = new Set(); + const stack: Point[] = [point]; + seen.add(pointKey(point)); + while (stack.length > 0) { + const current = stack.pop() as Point; + stones.push(current); + for (const next of neighbors(state.size, current)) { + const cell = cellAt(state, next); + if (cell === 'empty') libertyKeys.add(pointKey(next)); + if (cell === color && !seen.has(pointKey(next))) { + seen.add(pointKey(next)); + stack.push(next); + } + } + } + return { stones, liberties: libertyKeys.size }; +} + +/** Пустая позиция: хэш стартовой позиции уже в positionHashes. */ +export function createBoard(size: BoardSize): BoardState { + const grid: CellState[] = new Array(size * size).fill('empty'); + const state: BoardState = { + size, + grid, + toPlay: 'black', + captures: { black: 0, white: 0 }, + koPoint: null, + positionHashes: [], + over: false, + }; + return { ...state, positionHashes: [hashPosition(state)] }; +} + +/** Хэш «позиция + toPlay» для позиционного суперко. */ +export function hashPosition(state: BoardState): string { + return `${state.grid.join(',')}|${state.toPlay}`; +} + +/** + * Произвольная стартовая позиция (этап 6: уроки и цумэ-го, ADR-0003). + * Валидация: точки в пределах доски, без дублей и пересечений цветов, + * каждая группа имеет ≥1 дамэ (позиция не «мёртва на старте»). + * Нарушение — Error с описанием; доска не создаётся. + * captures = 0, koPoint = null, positionHashes = [хэш позиции]. + */ +export function buildPosition( + size: BoardSize, + black: ReadonlyArray, + white: ReadonlyArray, + toPlay: Color, +): BoardState { + const grid: CellState[] = new Array(size * size).fill('empty'); + const place = (stones: ReadonlyArray, color: CellState): void => { + for (const point of stones) { + if (!inBounds(size, point)) { + throw new Error(`buildPosition: точка ${pointKey(point)} вне доски ${size}×${size}`); + } + const index = cellIndex(size, point); + if (grid[index] !== 'empty') { + throw new Error(`buildPosition: точка ${pointKey(point)} занята дважды`); + } + grid[index] = color; + } + }; + place(black, 'black'); + place(white, 'white'); + const state: BoardState = { + size, + grid, + toPlay, + captures: { black: 0, white: 0 }, + koPoint: null, + positionHashes: [], + over: false, + }; + for (let index = 0; index < grid.length; index++) { + const cell = grid[index]; + if (cell === 'empty') continue; + const point: Point = { x: index % size, y: Math.floor(index / size) }; + const group = groupAt(state, point); + if (group !== null && group.liberties === 0) { + throw new Error(`buildPosition: группа в ${pointKey(point)} без дамэ`); + } + } + return { ...state, positionHashes: [hashPosition(state)] }; +} diff --git a/packages/core/src/build-position.test.ts b/packages/core/src/build-position.test.ts new file mode 100644 index 0000000..77ac6cc --- /dev/null +++ b/packages/core/src/build-position.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { applyMove, buildPosition, cellAt, groupAt } from './index.js'; + +describe('buildPosition', () => { + it('расставляет камни, очередь хода и хэш позиции', () => { + const state = buildPosition( + 9, + [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + ], + [{ x: 8, y: 8 }], + 'white', + ); + expect(cellAt(state, { x: 0, y: 0 })).toBe('black'); + expect(cellAt(state, { x: 8, y: 8 })).toBe('white'); + expect(state.toPlay).toBe('white'); + expect(state.captures).toEqual({ black: 0, white: 0 }); + expect(state.positionHashes).toHaveLength(1); + }); + + it('позиция играбельна: applyMove работает, суперко-хэши копятся', () => { + const state = buildPosition(9, [{ x: 4, y: 4 }], [], 'black'); + const result = applyMove(state, { kind: 'play', color: 'black', point: { x: 0, y: 0 } }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.state.positionHashes).toHaveLength(2); + }); + + it('отклоняет точку вне доски', () => { + expect(() => buildPosition(9, [{ x: 9, y: 0 }], [], 'black')).toThrow(/вне доски/); + }); + + it('отклоняет двойное занятие точки', () => { + expect(() => buildPosition(9, [{ x: 1, y: 1 }], [{ x: 1, y: 1 }], 'black')).toThrow( + /занята дважды/, + ); + }); + + it('отклоняет группу без дамэ', () => { + expect(() => + buildPosition( + 9, + [{ x: 0, y: 0 }], + [ + { x: 1, y: 0 }, + { x: 0, y: 1 }, + ], + 'black', + ), + ).toThrow(/без дамэ/); + }); + + it('захват с построенной позиции считается движком', () => { + const state = buildPosition(9, [{ x: 1, y: 0 }], [{ x: 0, y: 0 }], 'black'); + expect(groupAt(state, { x: 0, y: 0 })?.liberties).toBe(1); + const result = applyMove(state, { kind: 'play', color: 'black', point: { x: 0, y: 1 } }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(cellAt(result.state, { x: 0, y: 0 })).toBe('empty'); + expect(result.state.captures.black).toBe(1); + } + }); +}); diff --git a/packages/core/src/history.test.ts b/packages/core/src/history.test.ts new file mode 100644 index 0000000..03e84e5 --- /dev/null +++ b/packages/core/src/history.test.ts @@ -0,0 +1,83 @@ +/** + * Юнит-тесты истории: неизменяемое дерево, ветвление, навигация. + */ +import { describe, expect, it } from 'vitest'; +import type { GameTree } from './index.js'; +import { appendMove, createGame, findNode, goToNode } from './index.js'; + +/** Линейная партия из трёх ходов: B(0,0) W(1,1) B(2,2). */ +function threeMoveGame(): GameTree { + let tree = createGame({ size: 9 }); + let result = appendMove(tree, { kind: 'play', color: 'black', point: { x: 0, y: 0 } }); + if (!result.ok) throw new Error('ход 1 нелегален'); + result = appendMove(result.tree, { kind: 'play', color: 'white', point: { x: 1, y: 1 } }); + if (!result.ok) throw new Error('ход 2 нелегален'); + result = appendMove(result.tree, { kind: 'play', color: 'black', point: { x: 2, y: 2 } }); + if (!result.ok) throw new Error('ход 3 нелегален'); + return result.tree; +} + +describe('createGame/appendMove', () => { + it('линейное добавление ходов; currentId — последний узел', () => { + const tree = threeMoveGame(); + expect(tree.currentId).toBe(3); + expect(tree.nextId).toBe(4); + expect(tree.komi).toBe(5.5); + const node = findNode(tree.root, 3); + expect(node?.move).toEqual({ kind: 'play', color: 'black', point: { x: 2, y: 2 } }); + }); + + it('нелегальный ход не меняет дерево', () => { + const tree = threeMoveGame(); + const result = appendMove(tree, { kind: 'play', color: 'white', point: { x: 0, y: 0 } }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.tree).toBe(tree); + }); + + it('исходное дерево не мутируется при добавлении', () => { + const tree = threeMoveGame(); + const rootChildren = tree.root.children; + const result = appendMove(tree, { kind: 'play', color: 'white', point: { x: 5, y: 5 } }); + expect(result.ok).toBe(true); + expect(tree.root.children).toBe(rootChildren); + expect(tree.currentId).toBe(3); + }); + + it('resign проставляет meta.result', () => { + const tree = threeMoveGame(); + const result = appendMove(tree, { kind: 'resign', color: 'white' }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.tree.meta.result).toBe('B+R'); + }); +}); + +describe('ветвление и goToNode', () => { + it('appendMove от отмотанного узла создаёт ветку в children', () => { + const tree = threeMoveGame(); + const back = goToNode(tree, 2); + expect(back.currentId).toBe(2); + const result = appendMove(back, { kind: 'play', color: 'black', point: { x: 7, y: 7 } }); + expect(result.ok).toBe(true); + if (!result.ok) return; + const fork = findNode(result.tree.root, 2); + expect(fork?.children).toHaveLength(2); + expect(result.tree.currentId).toBe(result.nodeId); + // Основная ветка на месте: состояние узла 3 не изменилось. + expect(findNode(result.tree.root, 3)?.state.grid).toEqual(findNode(tree.root, 3)?.state.grid); + }); + + it('goToNode к несуществующему узлу возвращает дерево без изменений', () => { + const tree = threeMoveGame(); + expect(goToNode(tree, 999)).toBe(tree); + }); + + it('ход в отмотанной позиции валидируется по состоянию того узла', () => { + const tree = goToNode(threeMoveGame(), 1); + const result = appendMove(tree, { kind: 'play', color: 'black', point: { x: 4, y: 4 } }); + expect(result.ok).toBe(false); // после узла 1 ход белых + if (result.ok) return; + expect(result.error).toContain('цвета'); + }); +}); diff --git a/packages/core/src/history.ts b/packages/core/src/history.ts new file mode 100644 index 0000000..9bfacdd --- /dev/null +++ b/packages/core/src/history.ts @@ -0,0 +1,160 @@ +/** + * История партии: неизменяемое дерево узлов с ветвлением. + * createGame/appendMove/goToNode не мутируют входные структуры. + */ +import type { BoardState, Color, Move, Point } from './board.js'; +import { createBoard } from './board.js'; +import { applyMove } from './rules.js'; +import type { MoveError } from './rules.js'; +import { defaultKomi } from './score.js'; + +export interface Label { + readonly point: Point; + readonly text: string; +} + +export interface NodeMarkup { + readonly labels: ReadonlyArray