From 1c850911868dd2829a87644b419a017864c63a11 Mon Sep 17 00:00:00 2001 From: "sab.code.lab" Date: Fri, 7 Aug 2026 09:34:02 +0200 Subject: [PATCH] =?UTF-8?q?chore:=20=D0=B2=D0=BE=D1=81=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=80=D0=B5?= =?UTF-8?q?=D0=BF=D0=BE=D0=B7=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D1=8F=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D1=81=D0=BD=D0=B0=D0=BF=D1=88=D0=BE=D1=82=D0=B0=20v0.?= =?UTF-8?q?3.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прежняя git-история утрачена при переносе проекта на машину владельца (снапшот без .git). Хэши коммитов в docs/reports/* относятся к утраченной истории. Co-Authored-By: Claude Fable 5 --- .gitattributes | 1 + .github/workflows/ci.yml | 25 + .gitignore | 18 + .prettierignore | 5 + .prettierrc.json | 6 + AGENTS.md | 48 + CREDITS.md | 18 + LICENSE | 21 + README.md | 36 + THIRD_PARTY_LICENSES.md | 31 + apps/web/astro.config.mjs | 17 + apps/web/package.json | 20 + apps/web/public/.htaccess | 37 + apps/web/public/assets/hero.jpg | Bin 0 -> 65593 bytes apps/web/public/assets/ink.jpg | Bin 0 -> 120593 bytes apps/web/public/assets/stones.jpg | Bin 0 -> 24956 bytes apps/web/public/robots.txt | 3 + apps/web/src/ai-worker.ts | 18 + apps/web/src/content.config.ts | 53 + apps/web/src/content/content.test.ts | 345 + .../src/content/lessons/01-dame-i-zahvat.md | 74 + .../content/lessons/02-atari-lestnitsa-set.md | 83 + .../content/lessons/03-samoubiystvo-i-ko.md | 78 + .../content/lessons/04-glaza-zhizn-i-smert.md | 81 + .../lessons/05-podschyot-i-konets-partii.md | 76 + .../content/lessons/06-svyaz-i-razrezanie.md | 75 + .../content/lessons/07-ugly-storony-tsentr.md | 70 + .../content/lessons/08-bazovye-dzyoseki.md | 82 + apps/web/src/content/lessons/09-forma.md | 84 + .../src/content/lessons/10-prostoe-yose.md | 73 + apps/web/src/content/tasks/lessons.json | 284 + .../content/tasks/tsumego-atari-zahvat.json | 751 ++ apps/web/src/content/tasks/tsumego-glaza.json | 432 + apps/web/src/content/tasks/tsumego-svyaz.json | 254 + .../content/tasks/tsumego-yose-joseki.json | 108 + apps/web/src/env.d.ts | 9 + apps/web/src/layouts/Layout.astro | 1489 +++ apps/web/src/lib/ai-client.test.ts | 117 + apps/web/src/lib/ai-client.ts | 207 + apps/web/src/lib/game-store.test.ts | 198 + apps/web/src/lib/game-store.ts | 374 + apps/web/src/lib/goals.test.ts | 120 + apps/web/src/lib/goals.ts | 117 + apps/web/src/lib/keyboard.test.ts | 95 + apps/web/src/lib/keyboard.ts | 71 + apps/web/src/lib/storage.test.ts | 337 + apps/web/src/lib/storage.ts | 342 + apps/web/src/lib/sync-client.test.ts | 420 + apps/web/src/lib/sync-client.ts | 296 + apps/web/src/lib/task-session.test.ts | 156 + apps/web/src/lib/task-session.ts | 187 + apps/web/src/lib/territory.test.ts | 48 + apps/web/src/lib/territory.ts | 85 + apps/web/src/pages/account.astro | 188 + apps/web/src/pages/index.astro | 98 + apps/web/src/pages/istochniki.astro | 413 + apps/web/src/pages/learn.astro | 78 + apps/web/src/pages/learn/[id].astro | 140 + apps/web/src/pages/password-reset.astro | 187 + apps/web/src/pages/play.astro | 175 + apps/web/src/pages/progress.astro | 153 + apps/web/src/pages/register.astro | 199 + apps/web/src/pages/tsumego.astro | 177 + apps/web/src/ui/play-page.ts | 419 + apps/web/src/ui/strings.ts | 294 + apps/web/src/ui/task-widget.ts | 282 + apps/web/src/wiring.test.ts | 344 + apps/web/tsconfig.json | 9 + docs/INTERFACES.md | 1127 +++ docs/PROJECT_STATE.md | 95 + docs/README.md | 9 + docs/architecture.md | 27 + docs/audit/01-03.md | 283 + docs/audit/04-07.md | 493 + docs/audit/08-10.md | 426 + docs/decisions/ADR-0001-triage-litany.md | 89 + docs/decisions/ADR-0002-group-algorithm.md | 57 + docs/decisions/ADR-0003-build-position.md | 35 + .../ADR-0004-account-email-password.md | 43 + docs/deploy.md | 101 + docs/design/kimi-export/cabinet.html | 116 + docs/design/kimi-export/index.html | 166 + docs/design/kimi-export/learn.html | 193 + docs/design/kimi-export/lesson.html | 184 + docs/design/kimi-export/play.html | 161 + docs/design/kimi-export/progress.html | 105 + docs/design/kimi-export/register.html | 135 + docs/design/kimi-export/style.css | 1363 +++ docs/design/kimi-export/tsumego.html | 340 + docs/design/oauth-vk-yandex.md | 61 + docs/plan-phase-6.md | 62 + docs/plans/plan-phase-1.md | 40 + docs/plans/plan-phase-10.md | 59 + docs/plans/plan-phase-11.md | 60 + docs/plans/plan-phase-2.md | 70 + docs/plans/plan-phase-3.md | 53 + docs/plans/plan-phase-4.md | 81 + docs/plans/plan-phase-5.md | 82 + docs/plans/plan-phase-6.md | 62 + docs/plans/plan-phase-7.md | 50 + docs/plans/plan-phase-8-1.md | 47 + docs/plans/plan-phase-8.md | 50 + docs/reports/session-1.md | 51 + docs/reports/session-10.md | 75 + docs/reports/session-11.md | 96 + docs/reports/session-2.md | 54 + docs/reports/session-3.md | 53 + docs/reports/session-4.md | 63 + docs/reports/session-5.md | 68 + docs/reports/session-6.md | 76 + docs/reports/session-7.md | 62 + docs/reports/session-8-1.md | 56 + docs/reports/session-8.md | 57 + eslint.config.js | 31 + package-lock.json | 8214 +++++++++++++++++ package.json | 34 + packages/ai/package.json | 14 + packages/ai/src/ai.ts | 98 + packages/ai/src/candidates.ts | 59 + packages/ai/src/choose-move.test.ts | 210 + packages/ai/src/game.test.ts | 40 + packages/ai/src/index.ts | 18 + packages/ai/src/mcts.test.ts | 207 + packages/ai/src/mcts.ts | 301 + packages/ai/src/perf.test.ts | 42 + packages/ai/src/pick.ts | 53 + packages/ai/src/tactics.ts | 64 + packages/ai/src/test-utils.ts | 81 + packages/ai/src/wiring.test.ts | 51 + packages/ai/src/worker-protocol.ts | 63 + packages/ai/src/worker.test.ts | 130 + packages/ai/src/worker.ts | 109 + packages/ai/tsconfig.json | 16 + packages/board/package.json | 14 + packages/board/src/dom.ts | 43 + packages/board/src/geometry.test.ts | 124 + packages/board/src/geometry.ts | 71 + packages/board/src/index.ts | 13 + packages/board/src/input.test.ts | 79 + packages/board/src/input.ts | 38 + packages/board/src/render.test.ts | 254 + packages/board/src/render.ts | 333 + packages/board/src/wiring.test.ts | 59 + packages/board/tsconfig.json | 16 + packages/core/bench/group-bench.mts | 141 + packages/core/package.json | 11 + packages/core/src/board.ts | 171 + packages/core/src/build-position.test.ts | 63 + packages/core/src/history.test.ts | 83 + packages/core/src/history.ts | 160 + packages/core/src/index.ts | 23 + packages/core/src/property.test.ts | 149 + packages/core/src/rng.test.ts | 30 + packages/core/src/rng.ts | 32 + packages/core/src/rules.test.ts | 185 + packages/core/src/rules.ts | 196 + packages/core/src/score.test.ts | 120 + packages/core/src/score.ts | 224 + packages/core/src/sgf.test.ts | 89 + packages/core/src/sgf.ts | 392 + packages/core/src/test-utils.ts | 26 + packages/core/src/wiring.test.ts | 119 + packages/core/tsconfig.json | 9 + prestart-litany.md | 759 ++ server/.htaccess | 17 + server/README.md | 38 + server/api/captcha.php | 89 + server/api/health.php | 22 + server/api/login.php | 47 + server/api/logout.php | 24 + server/api/me.php | 24 + server/api/password-reset/confirm.php | 68 + server/api/password-reset/request.php | 93 + server/api/progress.php | 65 + server/api/register.php | 74 + server/config.example.php | 26 + server/lib/auth.php | 99 + server/lib/db.php | 95 + server/lib/http.php | 113 + server/lib/ratelimit.php | 54 + server/tests/smoke.sh | 288 + tsconfig.base.json | 18 + tsconfig.json | 9 + vitest.config.ts | 12 + 184 files changed, 33303 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 AGENTS.md create mode 100644 CREDITS.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 THIRD_PARTY_LICENSES.md create mode 100644 apps/web/astro.config.mjs create mode 100644 apps/web/package.json create mode 100644 apps/web/public/.htaccess create mode 100644 apps/web/public/assets/hero.jpg create mode 100644 apps/web/public/assets/ink.jpg create mode 100644 apps/web/public/assets/stones.jpg create mode 100644 apps/web/public/robots.txt create mode 100644 apps/web/src/ai-worker.ts create mode 100644 apps/web/src/content.config.ts create mode 100644 apps/web/src/content/content.test.ts create mode 100644 apps/web/src/content/lessons/01-dame-i-zahvat.md create mode 100644 apps/web/src/content/lessons/02-atari-lestnitsa-set.md create mode 100644 apps/web/src/content/lessons/03-samoubiystvo-i-ko.md create mode 100644 apps/web/src/content/lessons/04-glaza-zhizn-i-smert.md create mode 100644 apps/web/src/content/lessons/05-podschyot-i-konets-partii.md create mode 100644 apps/web/src/content/lessons/06-svyaz-i-razrezanie.md create mode 100644 apps/web/src/content/lessons/07-ugly-storony-tsentr.md create mode 100644 apps/web/src/content/lessons/08-bazovye-dzyoseki.md create mode 100644 apps/web/src/content/lessons/09-forma.md create mode 100644 apps/web/src/content/lessons/10-prostoe-yose.md create mode 100644 apps/web/src/content/tasks/lessons.json create mode 100644 apps/web/src/content/tasks/tsumego-atari-zahvat.json create mode 100644 apps/web/src/content/tasks/tsumego-glaza.json create mode 100644 apps/web/src/content/tasks/tsumego-svyaz.json create mode 100644 apps/web/src/content/tasks/tsumego-yose-joseki.json create mode 100644 apps/web/src/env.d.ts create mode 100644 apps/web/src/layouts/Layout.astro create mode 100644 apps/web/src/lib/ai-client.test.ts create mode 100644 apps/web/src/lib/ai-client.ts create mode 100644 apps/web/src/lib/game-store.test.ts create mode 100644 apps/web/src/lib/game-store.ts create mode 100644 apps/web/src/lib/goals.test.ts create mode 100644 apps/web/src/lib/goals.ts create mode 100644 apps/web/src/lib/keyboard.test.ts create mode 100644 apps/web/src/lib/keyboard.ts create mode 100644 apps/web/src/lib/storage.test.ts create mode 100644 apps/web/src/lib/storage.ts create mode 100644 apps/web/src/lib/sync-client.test.ts create mode 100644 apps/web/src/lib/sync-client.ts create mode 100644 apps/web/src/lib/task-session.test.ts create mode 100644 apps/web/src/lib/task-session.ts create mode 100644 apps/web/src/lib/territory.test.ts create mode 100644 apps/web/src/lib/territory.ts create mode 100644 apps/web/src/pages/account.astro create mode 100644 apps/web/src/pages/index.astro create mode 100644 apps/web/src/pages/istochniki.astro create mode 100644 apps/web/src/pages/learn.astro create mode 100644 apps/web/src/pages/learn/[id].astro create mode 100644 apps/web/src/pages/password-reset.astro create mode 100644 apps/web/src/pages/play.astro create mode 100644 apps/web/src/pages/progress.astro create mode 100644 apps/web/src/pages/register.astro create mode 100644 apps/web/src/pages/tsumego.astro create mode 100644 apps/web/src/ui/play-page.ts create mode 100644 apps/web/src/ui/strings.ts create mode 100644 apps/web/src/ui/task-widget.ts create mode 100644 apps/web/src/wiring.test.ts create mode 100644 apps/web/tsconfig.json create mode 100644 docs/INTERFACES.md create mode 100644 docs/PROJECT_STATE.md create mode 100644 docs/README.md create mode 100644 docs/architecture.md create mode 100644 docs/audit/01-03.md create mode 100644 docs/audit/04-07.md create mode 100644 docs/audit/08-10.md create mode 100644 docs/decisions/ADR-0001-triage-litany.md create mode 100644 docs/decisions/ADR-0002-group-algorithm.md create mode 100644 docs/decisions/ADR-0003-build-position.md create mode 100644 docs/decisions/ADR-0004-account-email-password.md create mode 100644 docs/deploy.md create mode 100644 docs/design/kimi-export/cabinet.html create mode 100644 docs/design/kimi-export/index.html create mode 100644 docs/design/kimi-export/learn.html create mode 100644 docs/design/kimi-export/lesson.html create mode 100644 docs/design/kimi-export/play.html create mode 100644 docs/design/kimi-export/progress.html create mode 100644 docs/design/kimi-export/register.html create mode 100644 docs/design/kimi-export/style.css create mode 100644 docs/design/kimi-export/tsumego.html create mode 100644 docs/design/oauth-vk-yandex.md create mode 100644 docs/plan-phase-6.md create mode 100644 docs/plans/plan-phase-1.md create mode 100644 docs/plans/plan-phase-10.md create mode 100644 docs/plans/plan-phase-11.md create mode 100644 docs/plans/plan-phase-2.md create mode 100644 docs/plans/plan-phase-3.md create mode 100644 docs/plans/plan-phase-4.md create mode 100644 docs/plans/plan-phase-5.md create mode 100644 docs/plans/plan-phase-6.md create mode 100644 docs/plans/plan-phase-7.md create mode 100644 docs/plans/plan-phase-8-1.md create mode 100644 docs/plans/plan-phase-8.md create mode 100644 docs/reports/session-1.md create mode 100644 docs/reports/session-10.md create mode 100644 docs/reports/session-11.md create mode 100644 docs/reports/session-2.md create mode 100644 docs/reports/session-3.md create mode 100644 docs/reports/session-4.md create mode 100644 docs/reports/session-5.md create mode 100644 docs/reports/session-6.md create mode 100644 docs/reports/session-7.md create mode 100644 docs/reports/session-8-1.md create mode 100644 docs/reports/session-8.md create mode 100644 eslint.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/ai/package.json create mode 100644 packages/ai/src/ai.ts create mode 100644 packages/ai/src/candidates.ts create mode 100644 packages/ai/src/choose-move.test.ts create mode 100644 packages/ai/src/game.test.ts create mode 100644 packages/ai/src/index.ts create mode 100644 packages/ai/src/mcts.test.ts create mode 100644 packages/ai/src/mcts.ts create mode 100644 packages/ai/src/perf.test.ts create mode 100644 packages/ai/src/pick.ts create mode 100644 packages/ai/src/tactics.ts create mode 100644 packages/ai/src/test-utils.ts create mode 100644 packages/ai/src/wiring.test.ts create mode 100644 packages/ai/src/worker-protocol.ts create mode 100644 packages/ai/src/worker.test.ts create mode 100644 packages/ai/src/worker.ts create mode 100644 packages/ai/tsconfig.json create mode 100644 packages/board/package.json create mode 100644 packages/board/src/dom.ts create mode 100644 packages/board/src/geometry.test.ts create mode 100644 packages/board/src/geometry.ts create mode 100644 packages/board/src/index.ts create mode 100644 packages/board/src/input.test.ts create mode 100644 packages/board/src/input.ts create mode 100644 packages/board/src/render.test.ts create mode 100644 packages/board/src/render.ts create mode 100644 packages/board/src/wiring.test.ts create mode 100644 packages/board/tsconfig.json create mode 100644 packages/core/bench/group-bench.mts create mode 100644 packages/core/package.json create mode 100644 packages/core/src/board.ts create mode 100644 packages/core/src/build-position.test.ts create mode 100644 packages/core/src/history.test.ts create mode 100644 packages/core/src/history.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/property.test.ts create mode 100644 packages/core/src/rng.test.ts create mode 100644 packages/core/src/rng.ts create mode 100644 packages/core/src/rules.test.ts create mode 100644 packages/core/src/rules.ts create mode 100644 packages/core/src/score.test.ts create mode 100644 packages/core/src/score.ts create mode 100644 packages/core/src/sgf.test.ts create mode 100644 packages/core/src/sgf.ts create mode 100644 packages/core/src/test-utils.ts create mode 100644 packages/core/src/wiring.test.ts create mode 100644 packages/core/tsconfig.json create mode 100644 prestart-litany.md create mode 100644 server/.htaccess create mode 100644 server/README.md create mode 100644 server/api/captcha.php create mode 100644 server/api/health.php create mode 100644 server/api/login.php create mode 100644 server/api/logout.php create mode 100644 server/api/me.php create mode 100644 server/api/password-reset/confirm.php create mode 100644 server/api/password-reset/request.php create mode 100644 server/api/progress.php create mode 100644 server/api/register.php create mode 100644 server/config.example.php create mode 100644 server/lib/auth.php create mode 100644 server/lib/db.php create mode 100644 server/lib/http.php create mode 100644 server/lib/ratelimit.php create mode 100644 server/tests/smoke.sh create mode 100644 tsconfig.base.json create mode 100644 tsconfig.json create mode 100644 vitest.config.ts 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 0000000000000000000000000000000000000000..1b19fadcddf150efb725f999fa71733ba4582924 GIT binary patch literal 65593 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 z z()(7RX7^DNoQrVV_WDSrJslwrWQ$uLTf3^=h`|Yn8}6{bN6^K85kz{gXD9&{BhqgC zS7~sX&2;RAg=3kRMaubXk8oZ)_gY3SUOomS)t%E{yM~f@)!DT1uTZ>aC;zWd{91%_ z0AZ(JHTfQYeWKpaMEhR$fO`KNY0#`QslQi*FhZcn&;m#X z-={<9pY2$fe5uh>=`>4K)H6_!x+ENv1)fI-1#ZbmEKup7Xp-+AL+(R8BG}(-L@-QAIErTt_e%H45xYyT zNqN-x2Im{s|KGeK{cFVkMH{vb(o!Pmt`0KvY^pvOF}5$~Yx?Iw1|PF93fj5+#Q%z1 zU%mFf?EincRF>i-nsdm5cN>wxYTOv`^hy*d|4D9cW%_1FZ#x<+C#K49@lKXT1rRSD3|v(iiKwr zJXkIKVXscH4(VUxoheKwtH!q5%fb?c*8Hjo^ktKk2JK^WTeJTHzaY}Y?WrE|SB!09 zR+Ba3n-pAGLjiJROh>BscQziV#;}XuwohEs5Uc z`c+TV}}g{IDqrY>a+dHe&UjZ!UI`){Pwi~l5(JpLH`8HUWr@+h7j$g2esMm6*J zHy;a{TtaXOT^T0Z+iESPzscGtE?PdAY{5Xm65w&wZOU?3(J}t)9l>s7;Bu))OJ?>8 z4qPu{?z-1I!)PV{4rf@;|ESr70JHe#0Wh$DM$D;aFlT}`{w~bR)Fpgk3h7a)H?RJU zJM^z|Jr4&2^{boz<~5}U)8-Z5`)rW5K9ZK}i258y(}5wD`va5;Eq&Tx{?hz)f`_;g zZNf_L_bY(Gqs3W4AQ0j>C`1e{wJ|4$B)!g4Bvr~)14J8dPBlzV(=+FXx<3d|8cp|e zH(>PPerSQ6mo(jC!CzMIiIySlF98B6|3{8;zXQUnFOk%WtN>-Y_CENQa0!GYjifWl zxkyD%mJ+}7-?sAEFaaKhe;xo#0Z1XK;B@3JXOaGE9Q>=>K8c>s;^FvzI~1V*FXOGA zmvsg_rSnl1jW#1larwVPX{sZ$lNdS4ET3M69g(LW8I^}*k;J{~_tO*X2zQ=D|d= z5a5_k04|Z3fZzoLaBLzN%x(eT@BU!0g8lb+6Aw7xpMV~qJbZwO@jttPJ_Lj4=y{|x z%uif{5_4-1r)ynLCI9morv!aTpC?&hbhEeC*n7L)k?!=`7`DD=2$V{KEkvj?*2tv6 zF-Tr(Mdq27H;7SL)j|g%-xyIUi16bnIZ(;9@QQj$oyWylN;2)OUEez!qpV_Q`MI9< zSI6;1J6KcE*p6(b*ISb~Gmov+&dWqp1eMz>k`)qW48vRJ`{w~BU5Y2HH0qTNFXS|w zf^0<$ohPxCHOi%AFn3}}g`HxK=ACdwcbY4~c6i{enWeG{8R<+2h1F}2kxT*Do`}iz zm*|t;DC3LHe;y1|Fi?C(pfihN5x>h!8H3|lS#QcvXb9*nKxKD(Jw{V_@;DmHI|>hp zZLN*WtrSPG%GajDREv^JKknHHe=wWy#86Q&8IO$z=06GrAP#+Y6ohv}t1n{i*^!W7zD=k|}}dl*~QFQBV3z zAXjovDLcPn^nJ#^*)97#>HX6_sY|Xqfeurgi}LoMi@L~q2Hlx#B7tfu{?-arV6suL zT8Vk3r9zk(N?-&xaH*qg1iQRHfo;k3u4x)j64w_Q%q9B~>@qwi-e{=7TVxYiGhu&J z)Q!Fn*p>G!0B5keH*=VzRlTw?8V~wj9|dL?p${RE4}@z~D#(Md!s%L`43>^aN$7+7HwP(Ifh8867t5V2W4+siF4~ z6XB&)s)a3}GX^L>0~Ea^j^E9ep^_?2%Pl`~Fig6nS;JY5jV>xCCAm29YsIcxehNqm zRclXba7$T3p!IcMm%$KRq4zykzBI+tlKrQ-1V7%HQIirHqTFc<r4vQPV^wJW3Ex^>3i4w`$ST{%WmFJB6` zXI6c(Lf4Vyw%MoTJoxt05A38D(UeB0{|&gg6=-eXa#>$e6KDSPY5vpxQMrommof@&{$y5tv5DTJCNNUYrnDqLz}X+^vE;rj$Z{l_ z+->W8P0!Yxok6DISIVP_#lyp_A*dTQmU^r8ajC9!N!k7o!>_oW`sf4xP%P*bzt)%T zlRf7hO{YTD^X{S}Pr}yqV*j(!<<}r7^hG(C#`?tW((az~iHCuR0i;6xHC9A2jExC$ zE+i}O@syggJ5eJz|1ooNjr?I)XkmAQJ7t0jGi(uB{Lo0`%fM$w5Z68hU6Al3CvcHg z)W?-46R%@5FZTp1_PhBl34lF!ky-xe<|{w{B%O834f|rfo3?quCq3#*sdUaQ zH)Rrew~y$SWf%Ckc~_3O_?Bl{b|DhqTW3Jou=`;EArYT@o?@MR|DZp*d4XjYYLm8| zX~PJK8DEy$PjY8`TΝ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 X#|ll$Gi4>e$kL(|U{WV8_doyHOAN2j literal 0 HcmV?d00001 diff --git a/apps/web/public/assets/ink.jpg b/apps/web/public/assets/ink.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6db2ed0180b7ae7b9f4216ec97b17bc81ae25fde GIT binary patch literal 120593 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̲RzZX>a-l8(Z(zVT&$t(6n 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�b!gPcH0tQ1DHZztCcB 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 literal 0 HcmV?d00001 diff --git a/apps/web/public/assets/stones.jpg b/apps/web/public/assets/stones.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fda83d82161612413aa87c2721b91e255c81e3b5 GIT binary patch literal 24956 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 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