chore: восстановление репозитория из снапшота v0.3.1
Прежняя git-история утрачена при переносе проекта на машину владельца (снапшот без .git). Хэши коммитов в docs/reports/* относятся к утраченной истории. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
1c85091186
184 changed files with 33303 additions and 0 deletions
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
* text=auto eol=lf
|
||||||
25
.github/workflows/ci.yml
vendored
Normal file
25
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -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
|
||||||
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
|
|
@ -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/
|
||||||
5
.prettierignore
Normal file
5
.prettierignore
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
prestart-litany.md
|
||||||
|
**/.astro/
|
||||||
6
.prettierrc.json
Normal file
6
.prettierrc.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
48
AGENTS.md
Normal file
48
AGENTS.md
Normal file
|
|
@ -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 <gate>`
|
||||||
|
в тело коммита.
|
||||||
|
|
||||||
|
## Опасные команды (только после письменного «да», одно «да» = одно действие)
|
||||||
|
|
||||||
|
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)-коммит. Отчёт для следующей сессии — обязателен.
|
||||||
18
CREDITS.md
Normal file
18
CREDITS.md
Normal file
|
|
@ -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.
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -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.
|
||||||
36
README.md
Normal file
36
README.md
Normal file
|
|
@ -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.
|
||||||
31
THIRD_PARTY_LICENSES.md
Normal file
31
THIRD_PARTY_LICENSES.md
Normal file
|
|
@ -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*`.
|
||||||
17
apps/web/astro.config.mjs
Normal file
17
apps/web/astro.config.mjs
Normal file
|
|
@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
20
apps/web/package.json
Normal file
20
apps/web/package.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
37
apps/web/public/.htaccess
Normal file
37
apps/web/public/.htaccess
Normal file
|
|
@ -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
|
||||||
|
|
||||||
|
<IfModule mod_deflate.c>
|
||||||
|
AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
<IfModule mod_expires.c>
|
||||||
|
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"
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
<FilesMatch "\.html$">
|
||||||
|
Header set Cache-Control "no-cache, must-revalidate"
|
||||||
|
</FilesMatch>
|
||||||
|
# Имена ассетов Astro содержат хэш: _astro/<имя>.<хэш>.<css|js>.
|
||||||
|
<FilesMatch "\.[0-9A-Za-z_-]{8,}\.(css|js)$">
|
||||||
|
Header set Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
</FilesMatch>
|
||||||
|
</IfModule>
|
||||||
BIN
apps/web/public/assets/hero.jpg
Normal file
BIN
apps/web/public/assets/hero.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
BIN
apps/web/public/assets/ink.jpg
Normal file
BIN
apps/web/public/assets/ink.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
BIN
apps/web/public/assets/stones.jpg
Normal file
BIN
apps/web/public/assets/stones.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
3
apps/web/public/robots.txt
Normal file
3
apps/web/public/robots.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Владелец (2026-08-07): сайт не для индексации поисковиками.
|
||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
18
apps/web/src/ai-worker.ts
Normal file
18
apps/web/src/ai-worker.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
/**
|
||||||
|
* Точка входа Web Worker ИИ ур. 4–6 (этап 5). Подключение по комментарию
|
||||||
|
* packages/ai/src/worker.ts: `self.onmessage = (e) => createWorkerHandler(self)(e.data)`.
|
||||||
|
* Сборка — Vite: new Worker(new URL('./ai-worker.ts', import.meta.url), { type: 'module' })
|
||||||
|
* (см. lib/ai-client.ts, defaultWorkerFactory).
|
||||||
|
*/
|
||||||
|
import { createWorkerHandler } from '@go-learn/ai';
|
||||||
|
import type { WorkerRequest, WorkerResponse } from '@go-learn/ai';
|
||||||
|
|
||||||
|
/** Минимальный срез DedicatedWorkerGlobalScope, нужный обработчику. */
|
||||||
|
interface WorkerScope {
|
||||||
|
onmessage: ((event: { data: WorkerRequest }) => void) | null;
|
||||||
|
postMessage(message: WorkerResponse): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = self as unknown as WorkerScope;
|
||||||
|
const handle = createWorkerHandler(scope);
|
||||||
|
scope.onmessage = (event): void => handle(event.data);
|
||||||
53
apps/web/src/content.config.ts
Normal file
53
apps/web/src/content.config.ts
Normal file
|
|
@ -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),
|
||||||
|
}),
|
||||||
|
};
|
||||||
345
apps/web/src/content/content.test.ts
Normal file
345
apps/web/src/content/content.test.ts
Normal file
|
|
@ -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<string, unknown> {
|
||||||
|
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<string>();
|
||||||
|
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<string, string> | null;
|
||||||
|
readonly body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFrontmatter(text: string): Record<string, string> | null {
|
||||||
|
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
||||||
|
if (match === null) return null;
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
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(/<go-task data-task-id="lsn-/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('все 10 разделов программы покрыты уроками', () => {
|
||||||
|
const covered = new Set(lessons.map((lesson) => lesson.frontmatter?.['section'] ?? ''));
|
||||||
|
for (const section of SECTIONS) {
|
||||||
|
expect(covered.has(section), `нет урока по разделу «${section}»`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
74
apps/web/src/content/lessons/01-dame-i-zahvat.md
Normal file
74
apps/web/src/content/lessons/01-dame-i-zahvat.md
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
---
|
||||||
|
title: 'Дамэ и захват'
|
||||||
|
section: 'Дамэ и захват'
|
||||||
|
order: 1
|
||||||
|
summary: 'Что такое дамэ (дыхания) группы, как считать их и как происходит захват камней. Основа всех правил Го.'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Дамэ и захват
|
||||||
|
|
||||||
|
Камень в Го «живёт», пока у него есть **дамэ** — свободные соседние пункты по
|
||||||
|
линиям доски (по горизонтали и вертикали, диагонали не считаются). Дамэ — это
|
||||||
|
«дыхание» камня: как только последнее дамэ исчезает, камень снимается с доски.
|
||||||
|
|
||||||
|
## Как считать дамэ
|
||||||
|
|
||||||
|
У одинокого камня в центре доски четыре дамэ: сверху, снизу, слева и справа.
|
||||||
|
На краю доски — три, в углу — всего два:
|
||||||
|
|
||||||
|
```
|
||||||
|
A B C A B C
|
||||||
|
1 . . . 1 X . .
|
||||||
|
2 . X . 2 . . .
|
||||||
|
3 . . . 3 . . .
|
||||||
|
центр: 4 угол A1: 2 (B1 и A2)
|
||||||
|
```
|
||||||
|
|
||||||
|
Камни одного цвета, стоящие рядом по линиям, образуют **группу**. Группа
|
||||||
|
делит дамэ на всех: у цепочки из двух камней в центре уже шесть дамэ.
|
||||||
|
Захватывается всегда группа целиком — нельзя снять «половину» цепочки.
|
||||||
|
|
||||||
|
## Захват
|
||||||
|
|
||||||
|
Когда вы ставите камень так, что у группы противника не остаётся ни одного
|
||||||
|
дамэ, эта группа немедленно снимается с доски и становится вашими пленниками.
|
||||||
|
|
||||||
|
```
|
||||||
|
A B C
|
||||||
|
1 . B .
|
||||||
|
2 B W .
|
||||||
|
3 . . .
|
||||||
|
Белый B2 в атari: осталось одно дамэ — B3.
|
||||||
|
```
|
||||||
|
|
||||||
|
Ситуация, когда у группы осталось ровно одно дамэ, называется **атари** —
|
||||||
|
это предупреждение: «ещё один ход, и камни снимут». В обучающих партиях с
|
||||||
|
новичками «атари» принято говорить вслух — так учатся замечать угрозы;
|
||||||
|
опытные игроки вслух его обычно не объявляют. Начинающим полезно замечать
|
||||||
|
атари самостоятельно, пересчитывая дамэ после каждого хода.
|
||||||
|
|
||||||
|
## Почему это важно
|
||||||
|
|
||||||
|
Всё в Го построено на подсчёте дамэ: и борьба за жизнь группы, и захваты,
|
||||||
|
и гонки «кто кого первым снимет». Простейшая тактика звучит так: у группы
|
||||||
|
противника мало дамэ — сокращайте их; у вашей группы мало дамэ — добавляйте
|
||||||
|
(продлевайтесь, соединяйтесь с друзьями).
|
||||||
|
|
||||||
|
Типичная ошибка новичка — не заметить, что его собственная группа уже в
|
||||||
|
атари, и потерять её. После каждого хода противника спрашивайте себя:
|
||||||
|
«Сколько теперь дамэ у моих групп?»
|
||||||
|
|
||||||
|
## Практика
|
||||||
|
|
||||||
|
В задаче ниже белый камень окружён с трёх сторон и у него осталось одно
|
||||||
|
дамэ. Найдите этот пункт и поставьте туда чёрный камень — белый будет
|
||||||
|
захвачен.
|
||||||
|
|
||||||
|
<go-task data-task-id="lsn-dame-capture">Запасной текст: попробуйте решить задачу (нужен включённый JavaScript).</go-task>
|
||||||
|
|
||||||
|
## Запомнить
|
||||||
|
|
||||||
|
- Дамэ — свободные соседние пункты камня или группы.
|
||||||
|
- Нет дамэ — камни снимаются с доски.
|
||||||
|
- Одно дамэ — атари, группа под угрозой.
|
||||||
|
- Группа делит дамэ на все свои камни.
|
||||||
83
apps/web/src/content/lessons/02-atari-lestnitsa-set.md
Normal file
83
apps/web/src/content/lessons/02-atari-lestnitsa-set.md
Normal file
|
|
@ -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. Если не работает ни то, ни другое — не гонитесь: слабый камень противника
|
||||||
|
никуда не денется, займитесь своими группами.
|
||||||
|
|
||||||
|
## Практика
|
||||||
|
|
||||||
|
В задаче чёрные загоняют белый камень лестницей прямо на край доски.
|
||||||
|
Проведите всю погоню до захвата.
|
||||||
|
|
||||||
|
<go-task data-task-id="lsn-ladder">Запасной текст: попробуйте решить задачу (нужен включённый JavaScript).</go-task>
|
||||||
|
|
||||||
|
## Запомнить
|
||||||
|
|
||||||
|
- Атари — угроза захвата в один ход.
|
||||||
|
- Лестница — серия атари, оставляющих один путь; проверяйте диагональ на
|
||||||
|
наличие опоры.
|
||||||
|
- Сеть — тихий захват без атари, когда лестница не проходит.
|
||||||
78
apps/web/src/content/lessons/03-samoubiystvo-i-ko.md
Normal file
78
apps/web/src/content/lessons/03-samoubiystvo-i-ko.md
Normal file
|
|
@ -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. Не отвечайте на мелкие угрозы, если ко дороже.
|
||||||
|
|
||||||
|
## Практика
|
||||||
|
|
||||||
|
В задаче чёрные снимают белый камень, образуя ко. Белые не могут вернуть
|
||||||
|
камень немедленно и ходят в сторону — после этого чёрные спокойно закрывают
|
||||||
|
ко соединением.
|
||||||
|
|
||||||
|
<go-task data-task-id="lsn-ko">Запасной текст: попробуйте решить задачу (нужен включённый JavaScript).</go-task>
|
||||||
|
|
||||||
|
## Запомнить
|
||||||
|
|
||||||
|
- Ход без дамэ запрещён — если только он не захватывает.
|
||||||
|
- Ко запрещает немедленный обратный захват одного камня.
|
||||||
|
- Ко-угроза — способ «купить» право вернуться в ко.
|
||||||
81
apps/web/src/content/lessons/04-glaza-zhizn-i-smert.md
Normal file
81
apps/web/src/content/lessons/04-glaza-zhizn-i-smert.md
Normal file
|
|
@ -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 выглядит как глаз белых, но четыре белых камня вокруг неё
|
||||||
|
**не связаны** между собой — они касаются друг друга только по диагонали.
|
||||||
|
Чёрные могут поставить атари на один из них, а затем занять «глаз».
|
||||||
|
Такой глаз называется **ложным**: он есть на бумаге, но не даёт жизни.
|
||||||
|
|
||||||
|
Признак настоящего глаза: все камни вокруг него соединены в одну крепкую
|
||||||
|
группу, и противник не может ни разрезать их, ни поставить в атари.
|
||||||
|
Прежде чем рассчитывать на глаз, проверьте диагонали: именно по диагоналям
|
||||||
|
глаз чаще всего оказывается ложным.
|
||||||
|
|
||||||
|
## Жизнь и смерть в задачах
|
||||||
|
|
||||||
|
Типичная задача на жизнь и смерть формулируется так: «чёрные ходят и живут»
|
||||||
|
или «чёрные ходят и убивают». Решение почти всегда — **жизненная точка**:
|
||||||
|
единственный пункт, который делит пространство на два глаза (для жизни) или
|
||||||
|
мешает противнику это сделать (для убийства). Ищите центр симметрии формы —
|
||||||
|
чаще всего он и есть ответ.
|
||||||
|
|
||||||
|
## Практика
|
||||||
|
|
||||||
|
Белые рассчитывают на глаз, но их камни вокруг него не связаны — глаз ложный.
|
||||||
|
Найдите ход, который разрушает иллюзию и захватывает отмеченные камни.
|
||||||
|
|
||||||
|
<go-task data-task-id="lsn-false-eye">Запасной текст: попробуйте решить задачу (нужен включённый JavaScript).</go-task>
|
||||||
|
|
||||||
|
## Запомнить
|
||||||
|
|
||||||
|
- Глаз — пустое пространство, окружённое одним цветом.
|
||||||
|
- Два глаза = вечная жизнь; один глаз = смерть под вопросом.
|
||||||
|
- Ложный глаз не защищает: проверяйте связность камней вокруг него.
|
||||||
76
apps/web/src/content/lessons/05-podschyot-i-konets-partii.md
Normal file
76
apps/web/src/content/lessons/05-podschyot-i-konets-partii.md
Normal file
|
|
@ -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 очка.
|
||||||
|
|
||||||
|
Обратите внимание: по китайским правилам ход внутрь своей территории не
|
||||||
|
меняет счёта (камень занимает пункт территории), а по японским — потерял бы
|
||||||
|
очко. Поэтому в конце партии по нашим правилам можно безопасно «доукрепляться».
|
||||||
|
|
||||||
|
## Оценка позиции по ходу партии
|
||||||
|
|
||||||
|
Грубая прикидка «кто выигрывает» — полезный навык: посчитайте очевидную
|
||||||
|
территорию обоих, добавьте коми белым, сравните. Если вы впереди — играйте
|
||||||
|
проще и надёжнее; если позади — ищите более острые варианты.
|
||||||
|
|
||||||
|
## Практика
|
||||||
|
|
||||||
|
Перед подсчётом нужно прибрать доску: два белых камня внутри чёрной
|
||||||
|
территории в атари. Снимите их — иначе при подсчёте начнутся споры.
|
||||||
|
|
||||||
|
<go-task data-task-id="lsn-endgame-capture">Запасной текст: попробуйте решить задачу (нужен включённый JavaScript).</go-task>
|
||||||
|
|
||||||
|
## Запомнить
|
||||||
|
|
||||||
|
- Два паса подряд заканчивают партию.
|
||||||
|
- Счёт: камни + территория; белые получают коми.
|
||||||
|
- Мёртвые камни снимаются по согласию; спор решается доигрыванием.
|
||||||
75
apps/web/src/content/lessons/06-svyaz-i-razrezanie.md
Normal file
75
apps/web/src/content/lessons/06-svyaz-i-razrezanie.md
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
---
|
||||||
|
title: 'Связь и разрезание'
|
||||||
|
section: 'Связь и разрезание'
|
||||||
|
order: 1
|
||||||
|
summary: 'Почему связанные группы сильнее, как соединять свои камни надёжно и как разрезать камни противника.'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Связь и разрезание
|
||||||
|
|
||||||
|
## Сила связи
|
||||||
|
|
||||||
|
Камни, соединённые в одну группу, делят дамэ и строят глаза вместе.
|
||||||
|
Две слабые группы по три дамэ — две проблемы; одна связанная группа с шестью
|
||||||
|
дамэ — крепость. Поэтому **соединяйте свои группы и разрезайте чужие** —
|
||||||
|
это называют главной тактической идеей Го.
|
||||||
|
|
||||||
|
## Надёжные и ненадёжные связи
|
||||||
|
|
||||||
|
Камни, стоящие вплотную по линии, связаны абсолютно — их нельзя разрезать.
|
||||||
|
Но многие «красивые» формы связаны лишь условно:
|
||||||
|
|
||||||
|
```
|
||||||
|
A B C D
|
||||||
|
1 B . B . ход через один пункт (иккэн):
|
||||||
|
2 . . . . белые могут попытаться вклиниться в B1
|
||||||
|
```
|
||||||
|
|
||||||
|
Связь через один пункт надёжна, только если вы готовы ответить соединением
|
||||||
|
на вклинивание. Диагональный ход (косуми) — тоже не абсолютная связь, но
|
||||||
|
сам по себе он локально неразрезаем: на разрез в один пункт вы отвечаете
|
||||||
|
соединением в другой (миаи). Уязвимо косуми лишь тогда, когда вокруг уже
|
||||||
|
стоят сильные камни противника.
|
||||||
|
|
||||||
|
## Типичные способы соединения
|
||||||
|
|
||||||
|
- **Прямое соединение** — поставить камень в разрыв цепочки.
|
||||||
|
- **Соединение снизу** — соединиться по первой линии, под защитой края.
|
||||||
|
- **Захват разрезающего камня** — если вклинившийся камень слаб, снимите его:
|
||||||
|
связь восстановится сама собой.
|
||||||
|
|
||||||
|
Выбирая, соединяться ли, спросите: «Что случится, если меня разрежут?»
|
||||||
|
Если обе части живут сами — не тратьте ход. Если одна погибнет — соединяйтесь
|
||||||
|
немедленно.
|
||||||
|
|
||||||
|
## Как разрезать
|
||||||
|
|
||||||
|
Разрезание — зеркальный приём: ищите **точку разреза** — пункт, через который
|
||||||
|
противник соединял бы свои группы. Часто это единственный пункт между двумя
|
||||||
|
диагонально стоящими камнями. Но помните: разрезающий ход должен сам выжить.
|
||||||
|
Разрезать и тут же потерять разрезающий камень — значит подарить противнику
|
||||||
|
и связь, и материал.
|
||||||
|
|
||||||
|
## Когда связь важнее территории
|
||||||
|
|
||||||
|
Новички жертвуют связью ради пары пунктов. Почти всегда это ошибка:
|
||||||
|
разрезанная группа бежит, отдаёт инициативу и тащит за собой потери по всей
|
||||||
|
доске. Сильные игроки говорят: «Соединяйся — и получишь ход в другом месте».
|
||||||
|
|
||||||
|
Полезно и обратное наблюдение: если противник потратил ход на разрезание,
|
||||||
|
а обе ваши половины спокойно живут, — он просто подарил вам темп. Ценность
|
||||||
|
разреза и связи всегда измеряется судьбой групп, а не самим фактом
|
||||||
|
«разрезал» или «соединил».
|
||||||
|
|
||||||
|
## Практика
|
||||||
|
|
||||||
|
Белый камень вклинился между двумя чёрными группами, но он в атари. Снимите
|
||||||
|
его — и группы соединятся сами.
|
||||||
|
|
||||||
|
<go-task data-task-id="lsn-connect">Запасной текст: попробуйте решить задачу (нужен включённый JavaScript).</go-task>
|
||||||
|
|
||||||
|
## Запомнить
|
||||||
|
|
||||||
|
- Связанная группа сильнее суммы частей.
|
||||||
|
- Косуми само по себе неразрезаемо, но уязвимо при сильных камнях вокруг.
|
||||||
|
- Разрезая, позаботьтесь о жизни разрезающего камня.
|
||||||
70
apps/web/src/content/lessons/07-ugly-storony-tsentr.md
Normal file
70
apps/web/src/content/lessons/07-ugly-storony-tsentr.md
Normal file
|
|
@ -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. Никогда не играйте на первой-второй линии в дебюте без причины.
|
||||||
|
|
||||||
|
## Запомнить
|
||||||
|
|
||||||
|
- Край доски — бесплатная стена; центр её лишён.
|
||||||
|
- Порядок дебюта: углы → стороны → центр.
|
||||||
|
- Третья линия — территория, четвёртая — влияние.
|
||||||
82
apps/web/src/content/lessons/08-bazovye-dzyoseki.md
Normal file
82
apps/web/src/content/lessons/08-bazovye-dzyoseki.md
Normal file
|
|
@ -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. Помните, что дзёсэки обновляются: профессионалы постоянно находят новые
|
||||||
|
варианты, а с появлением сильных программ многие оценки изменились.
|
||||||
|
|
||||||
|
## Запомнить
|
||||||
|
|
||||||
|
- Дзёсэки — эталонные угловые размены, равные для обеих сторон.
|
||||||
|
- Учите смысл ходов, а не только их порядок.
|
||||||
|
- На малой доске классические дзёсэки работают с поправкой на центр.
|
||||||
84
apps/web/src/content/lessons/09-forma.md
Normal file
84
apps/web/src/content/lessons/09-forma.md
Normal file
|
|
@ -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. Пересматривайте свои партии и отмечайте тяжёлые формы — со временем они
|
||||||
|
начнут сами резать глаз.
|
||||||
|
|
||||||
|
Форма — это эстетика, проверенная логикой: красивый ход в Го почти всегда
|
||||||
|
оказывается и сильным.
|
||||||
|
|
||||||
|
## Запомнить
|
||||||
|
|
||||||
|
- Тигровая пасть и бамбук — образцы экономной защиты.
|
||||||
|
- Пустой треугольник и данго — типичные ошибки новичка.
|
||||||
|
- Хорошая форма = связность + гибкость + потенциал глаз.
|
||||||
73
apps/web/src/content/lessons/10-prostoe-yose.md
Normal file
73
apps/web/src/content/lessons/10-prostoe-yose.md
Normal file
|
|
@ -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 — за десяток ходов до конца мысленно прикинуть
|
||||||
|
счёт: это подскажет, нужен ли спокойный ёсэ или пора рисковать.
|
||||||
|
|
||||||
|
## Практика
|
||||||
|
|
||||||
|
Чёрные прижимают белых к самому краю: краевая погоня в духе ёсэ заканчивается
|
||||||
|
захватом двух камней. Найдите последовательность до конца.
|
||||||
|
|
||||||
|
<go-task data-task-id="lsn-yose">Запасной текст: попробуйте решить задачу (нужен включённый JavaScript).</go-task>
|
||||||
|
|
||||||
|
## Запомнить
|
||||||
|
|
||||||
|
- Ёсэ — игра за очки на границах; мелочей не бывает.
|
||||||
|
- Сэнтэ раньше готэ; считайте ценность каждого пункта.
|
||||||
|
- Ханэ на первой линии — рабочая лошадка эндшпиля.
|
||||||
284
apps/web/src/content/tasks/lessons.json
Normal file
284
apps/web/src/content/tasks/lessons.json
Normal file
|
|
@ -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": "Прижмите белых к краю: они не убегут."
|
||||||
|
}
|
||||||
|
]
|
||||||
751
apps/web/src/content/tasks/tsumego-atari-zahvat.json
Normal file
751
apps/web/src/content/tasks/tsumego-atari-zahvat.json
Normal file
|
|
@ -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": "Одного продления мало — вытягивайтесь дважды."
|
||||||
|
}
|
||||||
|
]
|
||||||
432
apps/web/src/content/tasks/tsumego-glaza.json
Normal file
432
apps/web/src/content/tasks/tsumego-glaza.json
Normal file
|
|
@ -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": "Центральная точка превращает Т-форму в два глаза."
|
||||||
|
}
|
||||||
|
]
|
||||||
254
apps/web/src/content/tasks/tsumego-svyaz.json
Normal file
254
apps/web/src/content/tasks/tsumego-svyaz.json
Normal file
|
|
@ -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": "Загоните клин в атари, доберите — и заполните брешь."
|
||||||
|
}
|
||||||
|
]
|
||||||
108
apps/web/src/content/tasks/tsumego-yose-joseki.json
Normal file
108
apps/web/src/content/tasks/tsumego-yose-joseki.json
Normal file
|
|
@ -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": "Прижмите к краю и доберите."
|
||||||
|
}
|
||||||
|
]
|
||||||
9
apps/web/src/env.d.ts
vendored
Normal file
9
apps/web/src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
/// <reference types="astro/client" />
|
||||||
|
|
||||||
|
// 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';
|
||||||
|
}
|
||||||
1489
apps/web/src/layouts/Layout.astro
Normal file
1489
apps/web/src/layouts/Layout.astro
Normal file
File diff suppressed because it is too large
Load diff
117
apps/web/src/lib/ai-client.test.ts
Normal file
117
apps/web/src/lib/ai-client.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
207
apps/web/src/lib/ai-client.ts
Normal file
207
apps/web/src/lib/ai-client.ts
Normal file
|
|
@ -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<AiAnswer>;
|
||||||
|
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<AiAnswer> {
|
||||||
|
const active = ensureWorker();
|
||||||
|
if (active === null) return Promise.resolve(fallbackChoose(request, seed));
|
||||||
|
const level = request.level as MctsLevel;
|
||||||
|
return new Promise<AiAnswer>((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<AiAnswer> {
|
||||||
|
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());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
198
apps/web/src/lib/game-store.test.ts
Normal file
198
apps/web/src/lib/game-store.test.ts
Normal file
|
|
@ -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<AiAnswer> {
|
||||||
|
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<void> {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
374
apps/web/src/lib/game-store.ts
Normal file
374
apps/web/src/lib/game-store.ts
Normal file
|
|
@ -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<string>;
|
||||||
|
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<void>;
|
||||||
|
tapPoint(px: number, py: number): Promise<void>;
|
||||||
|
confirm(): Promise<void>;
|
||||||
|
cancelPending(): void;
|
||||||
|
pass(): Promise<void>;
|
||||||
|
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<GameStoreState>): 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<string>): 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<string>();
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string>(),
|
||||||
|
score: null,
|
||||||
|
hint: null,
|
||||||
|
sgfError: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
state = freshState(deps.initialSettings);
|
||||||
|
|
||||||
|
async function handleInput(event: Parameters<typeof reduceInput>[1]): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string>(), 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
120
apps/web/src/lib/goals.test.ts
Normal file
120
apps/web/src/lib/goals.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
117
apps/web/src/lib/goals.ts
Normal file
117
apps/web/src/lib/goals.ts
Normal file
|
|
@ -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<Point>): 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<string>();
|
||||||
|
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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
apps/web/src/lib/keyboard.test.ts
Normal file
95
apps/web/src/lib/keyboard.test.ts
Normal file
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
71
apps/web/src/lib/keyboard.ts
Normal file
71
apps/web/src/lib/keyboard.ts
Normal file
|
|
@ -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<Record<CursorKey, Point>> = {
|
||||||
|
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] };
|
||||||
|
}
|
||||||
337
apps/web/src/lib/storage.test.ts
Normal file
337
apps/web/src/lib/storage.test.ts
Normal file
|
|
@ -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<string, string> = {}): StorageLike & {
|
||||||
|
data: Record<string, string>;
|
||||||
|
} {
|
||||||
|
const data: Record<string, string> = { ...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<unknown> = {
|
||||||
|
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<unknown> = {
|
||||||
|
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<unknown> = {
|
||||||
|
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<unknown> = {
|
||||||
|
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<unknown> = {
|
||||||
|
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<unknown> = {
|
||||||
|
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<unknown> = {
|
||||||
|
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<unknown> = {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
342
apps/web/src/lib/storage.ts
Normal file
342
apps/web/src/lib/storage.ts
Normal file
|
|
@ -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<T> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<T>(
|
||||||
|
raw: string,
|
||||||
|
version: number,
|
||||||
|
guard: (value: unknown) => value is T,
|
||||||
|
): T | null {
|
||||||
|
let envelope: StorageEnvelope<unknown>;
|
||||||
|
try {
|
||||||
|
envelope = JSON.parse(raw) as StorageEnvelope<unknown>;
|
||||||
|
} 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<T>(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<T> = {
|
||||||
|
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<Record<string, LessonProgress>>; // key = lesson id
|
||||||
|
readonly tasks: Readonly<Record<string, TaskProgress>>; // 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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
return typeof record['solved'] === 'boolean' && typeof record['attempts'] === 'number';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecordOf<T>(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<string, unknown>;
|
||||||
|
const streak = record['streak'];
|
||||||
|
if (typeof streak !== 'object' || streak === null) return false;
|
||||||
|
const streakRecord = streak as Record<string, unknown>;
|
||||||
|
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<string, unknown>)['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);
|
||||||
|
}
|
||||||
420
apps/web/src/lib/sync-client.test.ts
Normal file
420
apps/web/src/lib/sync-client.test.ts
Normal file
|
|
@ -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<string, string> = {}): StorageLike & {
|
||||||
|
data: Record<string, string>;
|
||||||
|
} {
|
||||||
|
const data: Record<string, string> = { ...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<string, string>)['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<string, string>)['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<string, string>)['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<string, string>)['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');
|
||||||
|
});
|
||||||
|
});
|
||||||
296
apps/web/src/lib/sync-client.ts
Normal file
296
apps/web/src/lib/sync-client.ts
Normal file
|
|
@ -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<Response>;
|
||||||
|
|
||||||
|
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<RemoteProgress | 'unauthorized' | 'unavailable'> {
|
||||||
|
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<SyncResult> {
|
||||||
|
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<SyncResult> {
|
||||||
|
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<string, string>,
|
||||||
|
): Promise<Response | 'unavailable'> {
|
||||||
|
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<RegisterResult> {
|
||||||
|
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<AuthResult> {
|
||||||
|
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<PasswordResetRequestResult> {
|
||||||
|
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<PasswordResetConfirmResult> {
|
||||||
|
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<string | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
156
apps/web/src/lib/task-session.test.ts
Normal file
156
apps/web/src/lib/task-session.test.ts
Normal file
|
|
@ -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) });
|
||||||
|
});
|
||||||
|
});
|
||||||
187
apps/web/src/lib/task-session.ts
Normal file
187
apps/web/src/lib/task-session.ts
Normal file
|
|
@ -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 };
|
||||||
|
}
|
||||||
48
apps/web/src/lib/territory.test.ts
Normal file
48
apps/web/src/lib/territory.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
85
apps/web/src/lib/territory.ts
Normal file
85
apps/web/src/lib/territory.ts
Normal file
|
|
@ -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<string>, 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<string>;
|
||||||
|
readonly white: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пометка одного пустого региона от стартовой точки (BFS). */
|
||||||
|
function floodRegion(
|
||||||
|
state: BoardState,
|
||||||
|
dead: ReadonlySet<string>,
|
||||||
|
start: Point,
|
||||||
|
visited: Set<string>,
|
||||||
|
): { points: Point[]; border: Set<Color> } {
|
||||||
|
const points: Point[] = [];
|
||||||
|
const border = new Set<Color>();
|
||||||
|
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<string>): TerritorySets {
|
||||||
|
const black = new Set<string>();
|
||||||
|
const white = new Set<string>();
|
||||||
|
const visited = new Set<string>();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
188
apps/web/src/pages/account.astro
Normal file
188
apps/web/src/pages/account.astro
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
---
|
||||||
|
import Layout from '../layouts/Layout.astro';
|
||||||
|
import { STRINGS } from '../ui/strings';
|
||||||
|
|
||||||
|
const s = STRINGS.account;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title={`${s.title} — ${STRINGS.site.title}`} section="account">
|
||||||
|
<div class="container">
|
||||||
|
<!-- Неавторизован: форма входа (нативная валидация — клавиатурная
|
||||||
|
доступность и подсказки браузера из коробки). Регистрация и
|
||||||
|
восстановление пароля — отдельные страницы (этап 8.1). -->
|
||||||
|
<div id="account-forms" class="card auth-card" hidden>
|
||||||
|
<h1>{s.loginHeading}</h1>
|
||||||
|
<p class="auth-lead">
|
||||||
|
Кабинет нужен только для синхронизации прогресса между устройствами. Без него всё работает
|
||||||
|
так же.
|
||||||
|
</p>
|
||||||
|
<form id="account-login-form">
|
||||||
|
<div class="field">
|
||||||
|
<label for="login-email">{s.emailLabel}</label>
|
||||||
|
<input id="login-email" name="email" type="email" required autocomplete="email" />
|
||||||
|
<span class="error-text">{s.statusInvalid}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="login-password">{s.passwordLabel}</label>
|
||||||
|
<input
|
||||||
|
id="login-password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
<span class="error-text">{s.statusInvalid}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--primary auth-submit" type="submit">{s.loginSubmit}</button>
|
||||||
|
</form>
|
||||||
|
<div class="auth-links">
|
||||||
|
<span><a href="/register.html">{s.registerLink}</a></span>
|
||||||
|
<a href="/password-reset.html">{s.resetLink}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Авторизован: профиль и ручная синхронизация. -->
|
||||||
|
<div id="account-profile" class="card auth-card logged-in" hidden>
|
||||||
|
<h1>{s.title}</h1>
|
||||||
|
<p id="account-email" class="email"></p>
|
||||||
|
<div class="btn-row auth-actions">
|
||||||
|
<button id="account-sync" class="btn btn--primary" type="button">{s.syncButton}</button>
|
||||||
|
<button id="account-logout" class="btn btn--danger" type="button">{s.logoutButton}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="account-status" class="auth-status" role="status" aria-live="polite"></p>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { login, logout, me, syncProgress } from '../lib/sync-client';
|
||||||
|
import type { AuthResult, SyncResult } from '../lib/sync-client';
|
||||||
|
import { format, STRINGS } from '../ui/strings';
|
||||||
|
|
||||||
|
const s = STRINGS.account;
|
||||||
|
|
||||||
|
const forms = document.getElementById('account-forms');
|
||||||
|
const profile = document.getElementById('account-profile');
|
||||||
|
const emailElement = document.getElementById('account-email');
|
||||||
|
const status = document.getElementById('account-status');
|
||||||
|
|
||||||
|
function setStatus(text: string): void {
|
||||||
|
if (status !== null) status.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showProfile(email: string): void {
|
||||||
|
forms?.toggleAttribute('hidden', true);
|
||||||
|
profile?.toggleAttribute('hidden', false);
|
||||||
|
if (emailElement !== null) emailElement.textContent = format(s.loggedInAs, { email });
|
||||||
|
}
|
||||||
|
|
||||||
|
function showForms(): void {
|
||||||
|
profile?.toggleAttribute('hidden', true);
|
||||||
|
forms?.toggleAttribute('hidden', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function authMessage(result: AuthResult): string {
|
||||||
|
switch (result) {
|
||||||
|
case 'unauthorized':
|
||||||
|
return s.statusUnauthorized;
|
||||||
|
case 'invalid':
|
||||||
|
return s.statusInvalid;
|
||||||
|
case 'rate-limited':
|
||||||
|
return s.statusRateLimited;
|
||||||
|
default:
|
||||||
|
return s.statusUnavailable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncMessage(result: SyncResult): string {
|
||||||
|
switch (result) {
|
||||||
|
case 'pushed':
|
||||||
|
return s.syncPushed;
|
||||||
|
case 'pulled':
|
||||||
|
return s.syncPulled;
|
||||||
|
case 'noop':
|
||||||
|
return s.syncNoop;
|
||||||
|
case 'unauthorized':
|
||||||
|
return s.syncUnauthorized;
|
||||||
|
default:
|
||||||
|
return s.syncUnavailable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Тихая синхронизация: итог — только в статус-строку, без алертов. */
|
||||||
|
async function runSync(): Promise<void> {
|
||||||
|
setStatus(syncMessage(await syncProgress()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function field(form: HTMLElement | null, id: string): string {
|
||||||
|
const input = form?.querySelector(`#${id}`);
|
||||||
|
return input instanceof HTMLInputElement ? input.value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('account-login-form')?.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const email = field(form instanceof HTMLElement ? form : null, 'login-email');
|
||||||
|
const password = field(form instanceof HTMLElement ? form : null, 'login-password');
|
||||||
|
void (async () => {
|
||||||
|
const result = await login(email, password);
|
||||||
|
if (result !== 'ok') {
|
||||||
|
setStatus(authMessage(result));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showProfile(email);
|
||||||
|
setStatus(s.statusLoginOk);
|
||||||
|
await runSync(); // авто-синхронизация после входа
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('account-sync')?.addEventListener('click', () => {
|
||||||
|
void runSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('account-logout')?.addEventListener('click', () => {
|
||||||
|
void (async () => {
|
||||||
|
const result = await logout();
|
||||||
|
showForms();
|
||||||
|
setStatus(result === 'ok' ? s.statusLoggedOut : s.statusUnavailable);
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Загрузка: активная сессия → профиль + тихий авто-sync; иначе — формы.
|
||||||
|
void (async () => {
|
||||||
|
const email = await me();
|
||||||
|
if (email === null) {
|
||||||
|
showForms();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showProfile(email);
|
||||||
|
await runSync();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.auth-lead {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-submit {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-actions {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status {
|
||||||
|
max-width: 440px;
|
||||||
|
min-height: 1.5em;
|
||||||
|
margin: 18px auto 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
98
apps/web/src/pages/index.astro
Normal file
98
apps/web/src/pages/index.astro
Normal file
|
|
@ -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];
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title={STRINGS.site.title} description={s.description}>
|
||||||
|
<section class="hero">
|
||||||
|
<div class="container">
|
||||||
|
<div>
|
||||||
|
<span class="hero-kicker">{s.kicker}</span>
|
||||||
|
<h1>{s.headingBefore}<em>{s.headingEm}</em>{s.headingAfter}</h1>
|
||||||
|
<p class="lead">{s.lead}</p>
|
||||||
|
<div class="btn-row">
|
||||||
|
<a class="btn btn--primary" href="/learn.html">{s.startCourse}</a>
|
||||||
|
<a class="btn" href="/play.html">{s.playAi}</a>
|
||||||
|
</div>
|
||||||
|
<div class="hero-meta">
|
||||||
|
<div class="item"><strong>{sectionsCount}</strong><span>{s.metaSections}</span></div>
|
||||||
|
<div class="item"><strong>{tsumegoCount}</strong><span>{s.metaTsumego}</span></div>
|
||||||
|
<div class="item"><strong>{aiLevelsCount}</strong><span>{s.metaLevels}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<figure class="hero-figure">
|
||||||
|
<img src="/assets/hero.jpg" alt={s.heroAlt} width="1024" height="576" />
|
||||||
|
<figcaption>{s.heroCaption}</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="features">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="kicker">{s.featuresKicker}</p>
|
||||||
|
<h2>{s.featuresHeading}</h2>
|
||||||
|
</div>
|
||||||
|
<p>{s.featuresLead}</p>
|
||||||
|
</div>
|
||||||
|
<div class="features-grid">
|
||||||
|
<div class="card feature-card">
|
||||||
|
<span class="icon">一</span>
|
||||||
|
<h3>{s.feature1Title}</h3>
|
||||||
|
<p>{s.feature1Text}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card feature-card">
|
||||||
|
<span class="icon">二</span>
|
||||||
|
<h3>{s.feature2Title}</h3>
|
||||||
|
<p>{s.feature2Text}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card feature-card">
|
||||||
|
<span class="icon">三</span>
|
||||||
|
<h3>{s.feature3Title}</h3>
|
||||||
|
<p>{s.feature3Text}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ink-band">
|
||||||
|
<figure>
|
||||||
|
<img src="/assets/ink.jpg" alt={s.inkAlt} loading="lazy" />
|
||||||
|
<figcaption>{s.inkCaption}</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="home-strip">
|
||||||
|
<figure class="hero-figure">
|
||||||
|
<img src="/assets/stones.jpg" alt={s.stonesAlt} loading="lazy" />
|
||||||
|
</figure>
|
||||||
|
<div class="card quote">
|
||||||
|
<blockquote>{s.quote}</blockquote>
|
||||||
|
<cite>{s.quoteCite}</cite>
|
||||||
|
{
|
||||||
|
firstLesson !== undefined && (
|
||||||
|
<p style="margin-top: 24px">
|
||||||
|
<a class="btn btn--quiet" href={`/learn/${firstLesson.id}.html`}>
|
||||||
|
{format(s.firstLessonLink, { title: firstLesson.data.title.toLowerCase() })}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Layout>
|
||||||
413
apps/web/src/pages/istochniki.astro
Normal file
413
apps/web/src/pages/istochniki.astro
Normal file
|
|
@ -0,0 +1,413 @@
|
||||||
|
---
|
||||||
|
import Layout from '../layouts/Layout.astro';
|
||||||
|
import { STRINGS } from '../ui/strings';
|
||||||
|
|
||||||
|
// Этап 11: страница проверяемых источников. Железное правило владельца:
|
||||||
|
// каждая ссылка — только из docs/audit/*.md (там URL реально открывались
|
||||||
|
// и есть цитаты). Что подтвердить не удалось — честно в последнем разделе.
|
||||||
|
const s = STRINGS.sources;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title={`${s.title} — ${STRINGS.site.title}`}>
|
||||||
|
<div class="page-head">
|
||||||
|
<div class="container">
|
||||||
|
<p class="kicker">{s.kicker}</p>
|
||||||
|
<h1>{s.title}</h1>
|
||||||
|
<p>{s.lead}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container sources-body">
|
||||||
|
<section class="source-section">
|
||||||
|
<h2>{s.sectionRules}</h2>
|
||||||
|
<ul class="source-list">
|
||||||
|
<li>
|
||||||
|
Дамэ — свободный соседний пункт по линиям; диагонали не считаются —
|
||||||
|
<a href="https://www.britgo.org/rules/agashort.html" target="_blank" rel="noopener"
|
||||||
|
>Правила AGA, краткая версия (зеркало BGA)</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Группа без дамэ снимается целиком —
|
||||||
|
<a href="https://senseis.xmp.net/?RulesOfGoIntroductory" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Rules of Go — Introductory</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Атари — группа с одним дамэ —
|
||||||
|
<a href="https://senseis.xmp.net/?Atari" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Atari</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Лестница: серия атари, убегающий получает лишь одно новое дамэ; камень на пути ломает
|
||||||
|
лестницу —
|
||||||
|
<a href="https://senseis.xmp.net/?Ladder" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Ladder</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Лестничная опора: shicho-atari / ladder breaker (рус. «ситё-прерыватель») —
|
||||||
|
<a href="https://www.britgo.org/general/definitions.html" target="_blank" rel="noopener"
|
||||||
|
>Глоссарий BGA</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Сеть (гэта) — захват окружением без атари —
|
||||||
|
<a href="https://senseis.xmp.net/?Geta" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Geta</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Самоубийство запрещено (японские/китайские/AGA правила); исключение — ход, снимающий
|
||||||
|
группу —
|
||||||
|
<a href="https://senseis.xmp.net/?Suicide" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Suicide</a
|
||||||
|
>;
|
||||||
|
<a href="https://www.britgo.org/intro/intro2.html" target="_blank" rel="noopener"
|
||||||
|
>BGA: Introduction to Go, часть 2</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Нелегальный ход по правилам AGA отменяется и засчитывается как пас (правило 8) —
|
||||||
|
<a href="https://www.britgo.org/rules/agashort.html" target="_blank" rel="noopener"
|
||||||
|
>Правила AGA, краткая версия (зеркало BGA)</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Ко: немедленный обратный захват запрещён, сначала ход в другом месте —
|
||||||
|
<a href="https://senseis.xmp.net/?Ko" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Ko</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Ко-угрозы: у кого больше угроз, тот обычно выигрывает ко —
|
||||||
|
<a href="https://senseis.xmp.net/?KoThreat" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Ko Threat</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Говорить «атари» вслух — практика обучающих партий; среди опытных игроков необычно —
|
||||||
|
<a href="https://senseis.xmp.net/?CallingOutAtari" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Calling Out Atari</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="source-section">
|
||||||
|
<h2>{s.sectionLife}</h2>
|
||||||
|
<ul class="source-list">
|
||||||
|
<li>
|
||||||
|
Глаз — пустое пространство, окружённое камнями одного цвета —
|
||||||
|
<a href="https://senseis.xmp.net/?Eye" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Eye</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Два отдельных глаза — группа живёт; один большой глаз — ещё не жизнь —
|
||||||
|
<a href="https://senseis.xmp.net/?TwoEyes" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Two Eyes</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Ложный глаз: часть окружающих камней можно поставить в атари —
|
||||||
|
<a href="https://senseis.xmp.net/?FalseEye" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: False Eye</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Жизненная точка (vital point / накадэ) —
|
||||||
|
<a href="https://senseis.xmp.net/?VitalPoint" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Vital Point</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="source-section">
|
||||||
|
<h2>{s.sectionScoring}</h2>
|
||||||
|
<ul class="source-list">
|
||||||
|
<li>
|
||||||
|
Два паса подряд — конец партии (правило 9) —
|
||||||
|
<a href="https://www.cs.cmu.edu/~wjh/go/rules/AGA.concise.html" target="_blank" rel="noopener"
|
||||||
|
>AGA Concise Rules of Go</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Мёртвые камни снимаются по согласию; при споре игра продолжается (правила 9–10) —
|
||||||
|
<a href="https://www.cs.cmu.edu/~wjh/go/rules/AGA.concise.html" target="_blank" rel="noopener"
|
||||||
|
>AGA Concise Rules of Go</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Площадной подсчёт (китайский/AGA): очки = камни на доске + территория —
|
||||||
|
<a href="https://senseis.xmp.net/?AreaScoring" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Area Scoring</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Китайские правила: ход в свою территорию стоит 0 очков; японские — 1 очко —
|
||||||
|
<a href="https://senseis.xmp.net/?ChineseRules" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Chinese Rules</a
|
||||||
|
>;
|
||||||
|
<a href="https://senseis.xmp.net/?JapaneseRules" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Japanese Rules</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Коми: китайский стандарт 7,5; японский стандарт 6,5 —
|
||||||
|
<a href="https://senseis.xmp.net/?ChineseRules" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Chinese Rules</a
|
||||||
|
>;
|
||||||
|
<a href="https://senseis.xmp.net/?Komi" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Komi</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Дробное коми исключает ничью —
|
||||||
|
<a href="https://senseis.xmp.net/?Komi" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Komi</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="source-section">
|
||||||
|
<h2>{s.sectionConnect}</h2>
|
||||||
|
<ul class="source-list">
|
||||||
|
<li>
|
||||||
|
«Соединяй свои группы и разрезай чужие» — центральная идея —
|
||||||
|
<a href="https://senseis.xmp.net/?Cut" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Cut</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Иккэн (one-point jump) уязвим к разрезанию в зависимости от окружения —
|
||||||
|
<a href="https://senseis.xmp.net/?IkkenTobi" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Ikken Tobi</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Косуми — диагональный ход; изолированное косуми локально неразрезаемо (миаи) —
|
||||||
|
<a href="https://senseis.xmp.net/?Kosumi" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Kosumi</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="source-section">
|
||||||
|
<h2>{s.sectionOpening}</h2>
|
||||||
|
<ul class="source-list">
|
||||||
|
<li>
|
||||||
|
Углы → стороны → центр: в углу территорию ограждают две стены, на стороне три, в центре
|
||||||
|
четыре —
|
||||||
|
<a href="https://senseis.xmp.net/?CornersThenSidesThenCenter" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Corners, Then Sides, Then Center</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Вторая линия — «линия поражения», третья — «линия территории», четвёртая — «линия
|
||||||
|
влияния» —
|
||||||
|
<a href="https://senseis.xmp.net/?KnowYourLines" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Know Your Lines</a
|
||||||
|
>;
|
||||||
|
<a href="https://senseis.xmp.net/?ThirdLine" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Third Line</a
|
||||||
|
>;
|
||||||
|
<a href="https://senseis.xmp.net/?FourthLine" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Fourth Line</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
3-3 (сан-сан): надёжный угол, но низко —
|
||||||
|
<a href="https://senseis.xmp.net/?SanSan" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: San San</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
3-4 (комоку): классический самый частый первый ход (до ~1975) —
|
||||||
|
<a href="https://senseis.xmp.net/?Komoku" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Komoku</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
4-4 (хоси): угол оспаривается вторжением 3-3 —
|
||||||
|
<a href="https://senseis.xmp.net/?Hoshi" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Hoshi</a
|
||||||
|
>;
|
||||||
|
<a href="https://senseis.xmp.net/?44Point33Invasion" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: 4-4 Point 3-3 Invasion</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Мойо — большие рамки, потенциальная территория —
|
||||||
|
<a href="https://senseis.xmp.net/?Moyo" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Moyo</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="source-section">
|
||||||
|
<h2>{s.sectionJoseki}</h2>
|
||||||
|
<ul class="source-list">
|
||||||
|
<li>
|
||||||
|
Дзёсэки — эталонные угловые последовательности с локально равным итогом; оценки меняются
|
||||||
|
со временем, включая влияние ИИ —
|
||||||
|
<a href="https://senseis.xmp.net/?Joseki" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Joseki</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Цукэ-хики (3-4, подход, прижатие-отступление): хики укрепляет угол чёрных — один из
|
||||||
|
первых дзёсэки для новичков —
|
||||||
|
<a
|
||||||
|
href="https://senseis.xmp.net/?34PointHighApproachAttachDrawbackJoseki"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener">Sensei's Library: 3-4 Point, High Approach, Attach-Drawback Joseki</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Вторжение 3-3 под хоси: земля против влияния, вторгшийся живёт ~10 очками в углу —
|
||||||
|
<a href="https://senseis.xmp.net/?44Point33InvasionJoseki" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: 4-4 Point 3-3 Invasion Joseki</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="source-section">
|
||||||
|
<h2>{s.sectionShape}</h2>
|
||||||
|
<ul class="source-list">
|
||||||
|
<li>
|
||||||
|
Тигровая пасть (нэко но као) — форма из трёх камней вокруг пустого пункта —
|
||||||
|
<a href="https://senseis.xmp.net/?TigersMouth" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Tiger's Mouth</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Бамбуковый стык (такэфу): практически неразрезаем —
|
||||||
|
<a href="https://senseis.xmp.net/?BambooJoint" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Bamboo Joint</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Ноби — надёжное вытягивание, добавляет дамэ —
|
||||||
|
<a href="https://senseis.xmp.net/?Nobi" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Nobi</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Пустой треугольник — неэффективная форма (с редкими исключениями) —
|
||||||
|
<a href="https://senseis.xmp.net/?EmptyTriangle" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Empty Triangle</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Данго — «пельменная форма»: бесформенный ком камней без глаз —
|
||||||
|
<a href="https://senseis.xmp.net/?Dango" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Dango</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Перегруппировка (коригатати) — слишком много камней для их работы —
|
||||||
|
<a href="https://senseis.xmp.net/?Korigatachi" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Korigatachi</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="source-section">
|
||||||
|
<h2>{s.sectionYose}</h2>
|
||||||
|
<ul class="source-list">
|
||||||
|
<li>
|
||||||
|
Ёсэ — эндшпиль: ходы у границ стабильных территорий —
|
||||||
|
<a href="https://senseis.xmp.net/?Yose" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Yose</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Сэнтэ требует ответа, готэ отдаёт инициативу —
|
||||||
|
<a href="https://senseis.xmp.net/?Sente" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Sente</a
|
||||||
|
>;
|
||||||
|
<a href="https://senseis.xmp.net/?Gote" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Gote</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Ценность хода считается свингом (дэири) —
|
||||||
|
<a href="https://senseis.xmp.net/?DeiriCounting" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Deiri Counting</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Нейтральные дамэ заполняются в конце партии —
|
||||||
|
<a href="https://senseis.xmp.net/?Dame" target="_blank" rel="noopener"
|
||||||
|
>Sensei's Library: Dame</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="source-section">
|
||||||
|
<h2>{s.unconfirmedHeading}</h2>
|
||||||
|
<ul class="source-list">
|
||||||
|
<li>
|
||||||
|
Приём «соединение снизу по первой линии» (урок 06) — авторитетная страница не открылась.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Числа «угол 2–3 / сторона 4–5 / центр 8–10 камней» (урок 07) — источник не найден; в
|
||||||
|
каноне другие числа (стены 2/3/4; жизнь минимум 6/8/10 камней).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Термин «линия смерти» для первой линии (урок 07) — в авторитетных источниках не
|
||||||
|
подтверждён.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Диапазон «ходы ёсэ приносят 1–3 очка» (урок 10) — не подтверждён; встречаются ёсэ-ходы
|
||||||
|
дороже (ханэ-комбо ~10 очков по дэири).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Суперлатив «самый частый приём ёсэ — ханэ на первой линии» (урок 10) — не подтверждён.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="source-note">{s.note}</p>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Этап 11: списки источников — узкая колонка, спокойные отступы. */
|
||||||
|
.sources-body {
|
||||||
|
max-width: var(--measure);
|
||||||
|
padding-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-section {
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-section:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-list li {
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-note {
|
||||||
|
margin-top: 48px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
78
apps/web/src/pages/learn.astro
Normal file
78
apps/web/src/pages/learn.astro
Normal file
|
|
@ -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));
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title={`${STRINGS.learn.title} — ${STRINGS.site.title}`} section="learn">
|
||||||
|
<div class="page-head">
|
||||||
|
<div class="container">
|
||||||
|
<!-- этап 10: кикер и вводный абзац — текст прямо в разметке до переноса в strings.ts -->
|
||||||
|
<p class="kicker">с нуля до 10 кю</p>
|
||||||
|
<h1>{STRINGS.learn.title}</h1>
|
||||||
|
<p>
|
||||||
|
Десять уроков, каждый — короткая теория и задачи на доске. Идите по порядку: каждый урок
|
||||||
|
опирается на предыдущий. Отметки «пройден» сохраняются на вашем устройстве.
|
||||||
|
</p>
|
||||||
|
<p class="disclaimer">
|
||||||
|
<!-- этап 11: дисклеймер открытого редактирования (требование владельца) -->
|
||||||
|
{STRINGS.chrome.courseDisclaimer}{' '}
|
||||||
|
<a href="/istochniki.html">{STRINGS.chrome.sourcesLink}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="course-grid">
|
||||||
|
{
|
||||||
|
lessons.map((lesson, index) => (
|
||||||
|
<a
|
||||||
|
class="card card--link lesson-card"
|
||||||
|
href={`/learn/${lesson.id}.html`}
|
||||||
|
data-lesson-id={lesson.id}
|
||||||
|
>
|
||||||
|
<span class="lesson-num">{index + 1}</span>
|
||||||
|
<div>
|
||||||
|
<h3>{lesson.data.title}</h3>
|
||||||
|
<p>{lesson.data.summary}</p>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="badge badge--done done-badge" hidden>
|
||||||
|
{STRINGS.learn.lessonDone}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Этап 11: примечание об открытом редактировании — не кричащее. */
|
||||||
|
.disclaimer {
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Пометки «пройдено» — из localStorage, поэтому только на клиенте.
|
||||||
|
import { loadProgress } from '../lib/storage';
|
||||||
|
|
||||||
|
const { progress } = loadProgress();
|
||||||
|
for (const card of document.querySelectorAll('[data-lesson-id]')) {
|
||||||
|
const id = (card as HTMLElement).dataset['lessonId'] ?? '';
|
||||||
|
if (progress.lessons[id]?.done === true) {
|
||||||
|
card.querySelector('.done-badge')?.removeAttribute('hidden');
|
||||||
|
// Класс под зелёную рамку пройденной карточки (этап 10, .lesson-card.is-done).
|
||||||
|
card.classList.add('is-done');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
140
apps/web/src/pages/learn/[id].astro
Normal file
140
apps/web/src/pages/learn/[id].astro
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
// Задачи, встроенные в тело урока элементами <go-task data-task-id="…">:
|
||||||
|
// отдаём их данные клиенту 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(/</g, '\\u003c');
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title={`${lesson.data.title} — ${STRINGS.site.title}`} section="learn">
|
||||||
|
<div class="page-head">
|
||||||
|
<div class="container container--narrow">
|
||||||
|
<!-- этап 10: кикер и подписи навигации — текст прямо в разметке до переноса в strings.ts -->
|
||||||
|
<p class="kicker">урок {index + 1} из {ordered.length}</p>
|
||||||
|
<h1>{lesson.data.title}</h1>
|
||||||
|
<p>{lesson.data.summary}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<article class="lesson-body" data-lesson-id={lesson.id}>
|
||||||
|
<Content />
|
||||||
|
<div class="lesson-nav">
|
||||||
|
{
|
||||||
|
prev ? (
|
||||||
|
<a class="btn btn--quiet" href={`/learn/${prev.id}.html`}>
|
||||||
|
← {prev.data.title}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<a class="btn btn--quiet" href="/learn.html">
|
||||||
|
← Все уроки
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
id="btn-lesson-done"
|
||||||
|
class="btn btn--primary"
|
||||||
|
type="button"
|
||||||
|
data-lesson-id={lesson.id}
|
||||||
|
>
|
||||||
|
{STRINGS.learn.markDone}
|
||||||
|
</button>
|
||||||
|
{
|
||||||
|
next ? (
|
||||||
|
<a class="btn" href={`/learn/${next.id}.html`}>
|
||||||
|
{next.data.title} →
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<a class="btn" href="/tsumego.html">
|
||||||
|
К цумэ-го →
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p id="lesson-done-status" class="lesson-done-status" role="status" aria-live="polite"></p>
|
||||||
|
</article>
|
||||||
|
<script
|
||||||
|
type="application/json"
|
||||||
|
id="go-task-data"
|
||||||
|
data-lesson-id={lesson.id}
|
||||||
|
set:html={island}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { hydrateTasks } from '../../ui/task-widget';
|
||||||
|
import { loadProgress, markLessonDone, saveProgress } from '../../lib/storage';
|
||||||
|
import { STRINGS } from '../../ui/strings';
|
||||||
|
|
||||||
|
hydrateTasks(document);
|
||||||
|
|
||||||
|
// Ручная отметка «урок пройден» (этап 7): для теоретических уроков без
|
||||||
|
// go-task; авто-done по задачам (этап 6) не трогаем.
|
||||||
|
const doneButton = document.getElementById('btn-lesson-done');
|
||||||
|
const doneStatus = document.getElementById('lesson-done-status');
|
||||||
|
const lessonId =
|
||||||
|
doneButton instanceof HTMLElement ? (doneButton.dataset['lessonId'] ?? '') : '';
|
||||||
|
|
||||||
|
function renderDoneState(): void {
|
||||||
|
if (!(doneButton instanceof HTMLButtonElement) || doneStatus === null) return;
|
||||||
|
const { progress } = loadProgress();
|
||||||
|
const done = progress.lessons[lessonId]?.done === true;
|
||||||
|
doneButton.hidden = done;
|
||||||
|
doneStatus.textContent = done ? STRINGS.learn.doneState : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doneButton instanceof HTMLButtonElement) {
|
||||||
|
doneButton.addEventListener('click', () => {
|
||||||
|
const now = new Date();
|
||||||
|
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(now.getDate()).padStart(2, '0');
|
||||||
|
const today = `${now.getFullYear()}-${mm}-${dd}`;
|
||||||
|
const { progress } = loadProgress();
|
||||||
|
saveProgress(markLessonDone(progress, lessonId, today));
|
||||||
|
renderDoneState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
renderDoneState();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Заголовок урока уже показан в page-head; дублирующий H1 из тела
|
||||||
|
markdown не выводим (этап 10). :global — разметка <Content/> не
|
||||||
|
получает scope-атрибут scoped-стилей Astro. */
|
||||||
|
.lesson-body > :global(h1:first-child) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Статус «Пройден» под навигацией урока (этап 10, светлые токены «васи»). */
|
||||||
|
.lesson-done-status {
|
||||||
|
margin: 0.6rem 0 0;
|
||||||
|
color: var(--ok);
|
||||||
|
font-weight: 700;
|
||||||
|
min-height: 1.5em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
187
apps/web/src/pages/password-reset.astro
Normal file
187
apps/web/src/pages/password-reset.astro
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
---
|
||||||
|
import Layout from '../layouts/Layout.astro';
|
||||||
|
import { STRINGS } from '../ui/strings';
|
||||||
|
|
||||||
|
const s = STRINGS.passwordReset;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title={`${s.title} — ${STRINGS.site.title}`} section="account">
|
||||||
|
<div class="container">
|
||||||
|
<!-- Два режима по query (этап 8.1): без ?token= — запрос письма на email;
|
||||||
|
с ?token=… — установка нового пароля по токену из письма. Обе формы
|
||||||
|
в статике, нужную показывает скрипт. -->
|
||||||
|
<div id="reset-request" class="card auth-card" hidden>
|
||||||
|
<h1>{s.requestHeading}</h1>
|
||||||
|
<form id="reset-request-form">
|
||||||
|
<div class="field">
|
||||||
|
<label for="reset-email">{s.emailLabel}</label>
|
||||||
|
<input id="reset-email" name="email" type="email" required autocomplete="email" />
|
||||||
|
<span class="error-text">{s.statusUnavailable}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--primary auth-submit" type="submit">{s.requestSubmit}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="reset-confirm" class="card auth-card" hidden>
|
||||||
|
<h1>{s.confirmHeading}</h1>
|
||||||
|
<form id="reset-confirm-form">
|
||||||
|
<div class="field">
|
||||||
|
<label for="reset-password">{s.passwordLabel}</label>
|
||||||
|
<input
|
||||||
|
id="reset-password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
autocomplete="new-password"
|
||||||
|
aria-describedby="reset-password-hint"
|
||||||
|
/>
|
||||||
|
<p id="reset-password-hint" class="auth-hint">{s.passwordHint}</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="reset-password-repeat">{s.passwordRepeatLabel}</label>
|
||||||
|
<input
|
||||||
|
id="reset-password-repeat"
|
||||||
|
name="password-repeat"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
<span class="error-text">{s.statusPasswordMismatch}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--primary auth-submit" type="submit">{s.confirmSubmit}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="reset-status" class="auth-status" role="status" aria-live="polite"></p>
|
||||||
|
<div class="auth-links auth-links--page">
|
||||||
|
<span id="reset-login-link" hidden><a href="/account.html">{s.loginLink}</a></span>
|
||||||
|
<span id="reset-again-link" hidden>
|
||||||
|
<a href="/password-reset.html">{s.requestAgainLink}</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { confirmPasswordReset, requestPasswordReset } from '../lib/sync-client';
|
||||||
|
import type {
|
||||||
|
PasswordResetConfirmResult,
|
||||||
|
PasswordResetRequestResult,
|
||||||
|
} from '../lib/sync-client';
|
||||||
|
import { STRINGS } from '../ui/strings';
|
||||||
|
|
||||||
|
const s = STRINGS.passwordReset;
|
||||||
|
|
||||||
|
const status = document.getElementById('reset-status');
|
||||||
|
const requestBlock = document.getElementById('reset-request');
|
||||||
|
const confirmBlock = document.getElementById('reset-confirm');
|
||||||
|
const loginLink = document.getElementById('reset-login-link');
|
||||||
|
const againLink = document.getElementById('reset-again-link');
|
||||||
|
|
||||||
|
const token = new URLSearchParams(window.location.search).get('token') ?? '';
|
||||||
|
|
||||||
|
function setStatus(text: string): void {
|
||||||
|
if (status !== null) status.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(element: HTMLElement | null, show: boolean): void {
|
||||||
|
element?.toggleAttribute('hidden', !show);
|
||||||
|
}
|
||||||
|
|
||||||
|
function field(form: HTMLElement | null, id: string): string {
|
||||||
|
const input = form?.querySelector(`#${id}`);
|
||||||
|
return input instanceof HTMLInputElement ? input.value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestMessage(result: PasswordResetRequestResult): string {
|
||||||
|
switch (result) {
|
||||||
|
case 'mail_unavailable':
|
||||||
|
return s.statusMailUnavailable;
|
||||||
|
case 'rate_limited':
|
||||||
|
return s.statusRateLimited;
|
||||||
|
default:
|
||||||
|
return s.statusUnavailable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmMessage(result: PasswordResetConfirmResult): string {
|
||||||
|
switch (result) {
|
||||||
|
case 'token_invalid':
|
||||||
|
return s.statusTokenInvalid;
|
||||||
|
case 'invalid':
|
||||||
|
return s.statusInvalid;
|
||||||
|
case 'rate_limited':
|
||||||
|
return s.statusRateLimited;
|
||||||
|
default:
|
||||||
|
return s.statusUnavailable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('reset-request-form')?.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const email = field(form instanceof HTMLElement ? form : null, 'reset-email');
|
||||||
|
void (async () => {
|
||||||
|
const result = await requestPasswordReset(email);
|
||||||
|
// 200 приходит всегда (без enumeration) — единый текст об успехе.
|
||||||
|
setStatus(result === 'ok' ? s.requestDone : requestMessage(result));
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('reset-confirm-form')?.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const host = form instanceof HTMLElement ? form : null;
|
||||||
|
const password = field(host, 'reset-password');
|
||||||
|
const repeat = field(host, 'reset-password-repeat');
|
||||||
|
if (password !== repeat) {
|
||||||
|
setStatus(s.statusPasswordMismatch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void (async () => {
|
||||||
|
const result = await confirmPasswordReset(token, password);
|
||||||
|
if (result === 'ok') {
|
||||||
|
setStatus(s.confirmDone);
|
||||||
|
toggle(loginLink, true); // «Войти» → /account.html
|
||||||
|
toggle(confirmBlock, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(confirmMessage(result));
|
||||||
|
if (result === 'token_invalid') toggle(againLink, true); // запросить новую ссылку
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Выбор режима по query: ?token=… → новая пара паролей, иначе — email.
|
||||||
|
toggle(requestBlock, token === '');
|
||||||
|
toggle(confirmBlock, token !== '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.auth-hint {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-submit {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status {
|
||||||
|
max-width: 440px;
|
||||||
|
min-height: 1.5em;
|
||||||
|
margin: 18px auto 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ссылки под карточкой: та же колонка, что и .auth-card. */
|
||||||
|
.auth-links--page {
|
||||||
|
max-width: 440px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
175
apps/web/src/pages/play.astro
Normal file
175
apps/web/src/pages/play.astro
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
---
|
||||||
|
import Layout from '../layouts/Layout.astro';
|
||||||
|
import { STRINGS } from '../ui/strings';
|
||||||
|
|
||||||
|
const s = STRINGS;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title={`${s.site.playLink} — ${s.site.title}`} section="play">
|
||||||
|
<div class="page-head">
|
||||||
|
<div class="container">
|
||||||
|
<p class="kicker">партия против компьютера</p>
|
||||||
|
<h1>{s.site.playLink}</h1>
|
||||||
|
<p>
|
||||||
|
Нажмите на пересечение, чтобы поставить камень. ИИ играет по правилам с захватом, запретом
|
||||||
|
самоубийства и простым ко.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="play-layout">
|
||||||
|
<div class="board-wrap">
|
||||||
|
<canvas id="board" role="img" aria-label={s.aria.board}></canvas>
|
||||||
|
<p id="warning" class="warning" hidden></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="play-panel">
|
||||||
|
<div class="card">
|
||||||
|
<h2>{s.panel.settingsHeading}</h2>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="opt-size">{s.panel.size}</label>
|
||||||
|
<select id="opt-size">
|
||||||
|
<option value="9">9×9</option>
|
||||||
|
<option value="13">13×13</option>
|
||||||
|
<option value="19">19×19</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="opt-level">{s.panel.level}</label>
|
||||||
|
<select id="opt-level">
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="2">2</option>
|
||||||
|
<option value="3">3</option>
|
||||||
|
<option value="4">4</option>
|
||||||
|
<option value="5">5</option>
|
||||||
|
<option value="6">6</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="opt-color">{s.panel.playerColor}</label>
|
||||||
|
<select id="opt-color">
|
||||||
|
<option value="black">{s.panel.colorBlack}</option>
|
||||||
|
<option value="white">{s.panel.colorWhite}</option>
|
||||||
|
<option value="random">{s.panel.colorRandom}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="opt-komi">{s.panel.komi}</label>
|
||||||
|
<input id="opt-komi" type="number" step="0.5" min="-50" max="50" value="6.5" />
|
||||||
|
</div>
|
||||||
|
<div class="field field--check">
|
||||||
|
<input id="opt-komi-auto" type="checkbox" checked />
|
||||||
|
<label for="opt-komi-auto">{s.panel.komiAuto}</label>
|
||||||
|
</div>
|
||||||
|
<div class="field field--check">
|
||||||
|
<input id="opt-coords" type="checkbox" checked />
|
||||||
|
<label for="opt-coords">{s.panel.showCoordinates}</label>
|
||||||
|
</div>
|
||||||
|
<div class="field field--check">
|
||||||
|
<input id="opt-numbers" type="checkbox" />
|
||||||
|
<label for="opt-numbers">{s.panel.showMoveNumbers}</label>
|
||||||
|
</div>
|
||||||
|
<div class="field field--check">
|
||||||
|
<input id="opt-hints" type="checkbox" />
|
||||||
|
<label for="opt-hints">{s.panel.hintsEnabled}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>{s.panel.captures}</h2>
|
||||||
|
<!-- Камни — статичная легенда; счёт пишет play-page.ts в #captures. -->
|
||||||
|
<div class="captures" aria-hidden="true">
|
||||||
|
<span class="side"><span class="stone-dot black"></span> {s.panel.capturesBlack}</span>
|
||||||
|
<span class="side"><span class="stone-dot white"></span> {s.panel.capturesWhite}</span>
|
||||||
|
</div>
|
||||||
|
<p id="captures" class="captures-line"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status" class="status-line" data-status role="status"></div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>{s.panel.hintsEnabled}</h2>
|
||||||
|
<ul class="hints-list">
|
||||||
|
<li>Лучший ход подсвечивается на доске, его оценка — в строке статуса.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Управление</h2>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button id="btn-new" class="btn btn--small btn--primary">{s.buttons.newGame}</button>
|
||||||
|
<button id="btn-pass" class="btn btn--small">{s.buttons.pass}</button>
|
||||||
|
<button id="btn-undo" class="btn btn--small btn--quiet">{s.buttons.undo}</button>
|
||||||
|
<button id="btn-resign" class="btn btn--small btn--danger">{s.buttons.resign}</button>
|
||||||
|
<button id="btn-score" class="btn btn--small btn--primary" hidden>
|
||||||
|
{s.buttons.confirmScore}
|
||||||
|
</button>
|
||||||
|
<button id="btn-download" class="btn btn--small">{s.buttons.downloadSgf}</button>
|
||||||
|
<button id="btn-load" class="btn btn--small">{s.buttons.loadSgf}</button>
|
||||||
|
</div>
|
||||||
|
<div id="viewer-bar" class="btn-row viewer-bar" hidden>
|
||||||
|
<button id="btn-prev" class="btn btn--small">{s.buttons.viewerPrev}</button>
|
||||||
|
<button id="btn-next" class="btn btn--small">{s.buttons.viewerNext}</button>
|
||||||
|
<button id="btn-exit-viewer" class="btn btn--small btn--quiet">
|
||||||
|
{s.buttons.viewerExit}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
<input id="sgf-file" type="file" accept=".sgf" aria-label={s.aria.sgfFile} hidden />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { bootPlayPage } from '../ui/play-page';
|
||||||
|
bootPlayPage();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Доска: канвас масштабируется под ширину .board-wrap. */
|
||||||
|
.board-wrap canvas {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: var(--err);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Чекбоксы настроек — строкой, без растягивания на всю ширину поля. */
|
||||||
|
.field--check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field--check input[type='checkbox'] {
|
||||||
|
width: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field--check label {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.captures-line {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-bar {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
153
apps/web/src/pages/progress.astro
Normal file
153
apps/web/src/pages/progress.astro
Normal file
|
|
@ -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(/</g, '\\u003c');
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title={`${s.title} — ${STRINGS.site.title}`} section="progress">
|
||||||
|
<div class="page-head">
|
||||||
|
<div class="container">
|
||||||
|
<!-- этап 10: кикер, вводный абзац, подписи карточек и кнопок — текст прямо
|
||||||
|
в разметке до переноса в strings.ts -->
|
||||||
|
<p class="kicker">тихий счёт упорству</p>
|
||||||
|
<h1>{s.title}</h1>
|
||||||
|
<p>
|
||||||
|
Всё считается локально, на этом устройстве. Ничего не отправляется на сервер без вашей
|
||||||
|
команды — поэтому цифры честные.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="value" id="stat-lessons"></div>
|
||||||
|
<div class="label">уроков пройдено</div>
|
||||||
|
</div>
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="value" id="stat-lesson-tasks"></div>
|
||||||
|
<div class="label">задач в уроках решено</div>
|
||||||
|
</div>
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="value" id="stat-tsumego-tasks"></div>
|
||||||
|
<div class="label">цумэ-го решено</div>
|
||||||
|
</div>
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="value" id="stat-streak"></div>
|
||||||
|
<div class="label">дней подряд</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card course-progress">
|
||||||
|
<h2>Путь по курсу</h2>
|
||||||
|
<div
|
||||||
|
class="progress-track"
|
||||||
|
id="course-progress-track"
|
||||||
|
role="progressbar"
|
||||||
|
aria-label="Прогресс курса"
|
||||||
|
>
|
||||||
|
<div class="progress-fill" id="course-progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<p class="progress-note" id="course-progress-note"></p>
|
||||||
|
<p class="progress-note">
|
||||||
|
Десять минут в день надёжнее трёх часов раз в месяц: Го растёт на регулярности, а не на
|
||||||
|
подвигах.
|
||||||
|
</p>
|
||||||
|
<div class="btn-row">
|
||||||
|
<a class="btn btn--primary" href="/learn.html">Продолжить курс</a>
|
||||||
|
<a class="btn btn--quiet" href="/tsumego.html">К задачам</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="stat-recovered" class="recovered" hidden>{s.recovered}</p>
|
||||||
|
<script type="application/json" id="progress-totals" set:html={island} />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { loadProgress } from '../lib/storage';
|
||||||
|
import { me, syncProgress } from '../lib/sync-client';
|
||||||
|
|
||||||
|
// Тихий авто-sync при активной сессии (этап 8): до отрисовки цифр,
|
||||||
|
// чтобы pulled-прогресс сразу отобразился. Ошибки молча игнорируем —
|
||||||
|
// пометка о недоступности живёт на странице кабинета (ADR-0004).
|
||||||
|
try {
|
||||||
|
if ((await me()) !== null) await syncProgress();
|
||||||
|
} catch {
|
||||||
|
/* sync недоступен — работаем на локальном прогрессе */
|
||||||
|
}
|
||||||
|
|
||||||
|
const totals = JSON.parse(
|
||||||
|
document.getElementById('progress-totals')?.textContent ?? '{}',
|
||||||
|
) as { lessons: number; lessonTasks: number; tsumegoTasks: number };
|
||||||
|
const { progress, recovered } = loadProgress();
|
||||||
|
|
||||||
|
const lessonsDone = Object.values(progress.lessons).filter((l) => l.done).length;
|
||||||
|
// Решённые задачи по видам: id с префиксом lsn- — задачи уроков, tsm- — цумэ-го.
|
||||||
|
const solvedIds = Object.entries(progress.tasks)
|
||||||
|
.filter(([, value]) => value.solved)
|
||||||
|
.map(([id]) => id);
|
||||||
|
const lessonTasksDone = solvedIds.filter((id) => id.startsWith('lsn-')).length;
|
||||||
|
const tsumegoTasksDone = solvedIds.filter((id) => id.startsWith('tsm-')).length;
|
||||||
|
|
||||||
|
// Карточки статистики: в .value — только числа, подписи статичны в разметке
|
||||||
|
// (этап 10; шаблоны STRINGS.progressPage.* заменены карточками .value/.label).
|
||||||
|
const fill = (id: string, done: number, total: number): void => {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (element !== null) element.textContent = `${done} / ${total}`;
|
||||||
|
};
|
||||||
|
fill('stat-lessons', lessonsDone, totals.lessons);
|
||||||
|
fill('stat-lesson-tasks', lessonTasksDone, totals.lessonTasks);
|
||||||
|
fill('stat-tsumego-tasks', tsumegoTasksDone, totals.tsumegoTasks);
|
||||||
|
const streakElement = document.getElementById('stat-streak');
|
||||||
|
if (streakElement !== null) {
|
||||||
|
streakElement.textContent = String(progress.streak.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Полоса «Путь по курсу»: доля пройденных уроков (этап 10).
|
||||||
|
const pct = totals.lessons > 0 ? Math.round((lessonsDone / totals.lessons) * 100) : 0;
|
||||||
|
const track = document.getElementById('course-progress-track');
|
||||||
|
if (track !== null) track.setAttribute('aria-valuenow', String(pct));
|
||||||
|
const bar = document.getElementById('course-progress-fill');
|
||||||
|
if (bar !== null) bar.style.width = `${pct}%`;
|
||||||
|
const note = document.getElementById('course-progress-note');
|
||||||
|
// этап 10: строка прямо в скрипте до переноса в strings.ts
|
||||||
|
if (note !== null) note.textContent = `Курс пройден на ${pct}%`;
|
||||||
|
|
||||||
|
document.getElementById('stat-recovered')?.toggleAttribute('hidden', !recovered);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Четыре карточки статистики в ряд на широких экранах (этап 10). */
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-progress {
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-progress h2 {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-progress .btn-row {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recovered {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
199
apps/web/src/pages/register.astro
Normal file
199
apps/web/src/pages/register.astro
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
---
|
||||||
|
import Layout from '../layouts/Layout.astro';
|
||||||
|
import { STRINGS } from '../ui/strings';
|
||||||
|
|
||||||
|
const s = STRINGS.register;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title={`${s.title} — ${STRINGS.site.title}`} section="account">
|
||||||
|
<div class="container">
|
||||||
|
<!-- Регистрация: email, пароль ×2, автономная SVG-капча (этап 8.1).
|
||||||
|
Нативная валидация — клавиатурная доступность из коробки. -->
|
||||||
|
<div class="card auth-card">
|
||||||
|
<h1>{s.heading}</h1>
|
||||||
|
<p class="auth-lead">
|
||||||
|
Одна учётная запись — чтобы прогресс жил на всех устройствах. Никакой рассылки.
|
||||||
|
</p>
|
||||||
|
<form id="register-form">
|
||||||
|
<div class="field">
|
||||||
|
<label for="register-email">{s.emailLabel}</label>
|
||||||
|
<input id="register-email" name="email" type="email" required autocomplete="email" />
|
||||||
|
<span class="error-text">{s.statusInvalid}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="register-password">{s.passwordLabel}</label>
|
||||||
|
<input
|
||||||
|
id="register-password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
autocomplete="new-password"
|
||||||
|
aria-describedby="register-password-hint"
|
||||||
|
/>
|
||||||
|
<p id="register-password-hint" class="auth-hint">{s.passwordHint}</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="register-password-repeat">{s.passwordRepeatLabel}</label>
|
||||||
|
<input
|
||||||
|
id="register-password-repeat"
|
||||||
|
name="password-repeat"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
<span class="error-text">{s.statusPasswordMismatch}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="register-captcha">{s.captchaLabel}</label>
|
||||||
|
<div class="captcha-row">
|
||||||
|
<img
|
||||||
|
id="register-captcha-img"
|
||||||
|
class="captcha-box"
|
||||||
|
src="/api/captcha.php"
|
||||||
|
alt={s.captchaAlt}
|
||||||
|
width="200"
|
||||||
|
height="60"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
id="register-captcha-refresh"
|
||||||
|
class="btn btn--quiet btn--small"
|
||||||
|
type="button"
|
||||||
|
aria-label={s.captchaRefresh}
|
||||||
|
>
|
||||||
|
↻ {s.captchaRefresh}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="register-captcha"
|
||||||
|
name="captcha"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
<span class="error-text">{s.statusCaptcha}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--primary auth-submit" type="submit">{s.submit}</button>
|
||||||
|
</form>
|
||||||
|
<div class="auth-links">
|
||||||
|
<span><a href="/account.html">{s.loginLink}</a></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="register-status" class="auth-status" role="status" aria-live="polite"></p>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { login, register } from '../lib/sync-client';
|
||||||
|
import type { RegisterResult } from '../lib/sync-client';
|
||||||
|
import { STRINGS } from '../ui/strings';
|
||||||
|
|
||||||
|
const s = STRINGS.register;
|
||||||
|
|
||||||
|
const status = document.getElementById('register-status');
|
||||||
|
const captchaImg = document.getElementById('register-captcha-img');
|
||||||
|
const captchaInput = document.getElementById('register-captcha');
|
||||||
|
|
||||||
|
function setStatus(text: string): void {
|
||||||
|
if (status !== null) status.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Перезапрос картинки с cache-buster; код сжигается любой попыткой. */
|
||||||
|
function refreshCaptcha(): void {
|
||||||
|
if (captchaImg instanceof HTMLImageElement) {
|
||||||
|
captchaImg.src = `/api/captcha.php?t=${Date.now()}`;
|
||||||
|
}
|
||||||
|
if (captchaInput instanceof HTMLInputElement) captchaInput.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerMessage(result: RegisterResult): string {
|
||||||
|
switch (result) {
|
||||||
|
case 'conflict':
|
||||||
|
return s.statusConflict;
|
||||||
|
case 'captcha':
|
||||||
|
return s.statusCaptcha;
|
||||||
|
case 'invalid':
|
||||||
|
return s.statusInvalid;
|
||||||
|
case 'rate-limited':
|
||||||
|
return s.statusRateLimited;
|
||||||
|
default:
|
||||||
|
return s.statusUnavailable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function field(form: HTMLElement | null, id: string): string {
|
||||||
|
const input = form?.querySelector(`#${id}`);
|
||||||
|
return input instanceof HTMLInputElement ? input.value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('register-captcha-refresh')?.addEventListener('click', () => {
|
||||||
|
refreshCaptcha();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('register-form')?.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const host = form instanceof HTMLElement ? form : null;
|
||||||
|
const email = field(host, 'register-email');
|
||||||
|
const password = field(host, 'register-password');
|
||||||
|
const repeat = field(host, 'register-password-repeat');
|
||||||
|
const captcha = field(host, 'register-captcha');
|
||||||
|
if (password !== repeat) {
|
||||||
|
// Клиентская проверка: сервер не дергали, капчу не тратим.
|
||||||
|
setStatus(s.statusPasswordMismatch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void (async () => {
|
||||||
|
const result = await register(email, password, captcha);
|
||||||
|
if (result !== 'ok') {
|
||||||
|
setStatus(registerMessage(result));
|
||||||
|
refreshCaptcha(); // любая попытка сжигает код — показываем новый
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Сервер сессию при регистрации не ставит: авто-вход теми же данными.
|
||||||
|
const entered = await login(email, password);
|
||||||
|
if (entered !== 'ok') {
|
||||||
|
setStatus(entered === 'rate-limited' ? s.statusRateLimited : s.statusUnavailable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.assign('/account.html');
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.auth-lead {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-hint {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-submit {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Отступ поля ввода кода от строки с картинкой. */
|
||||||
|
.captcha-row {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status {
|
||||||
|
max-width: 440px;
|
||||||
|
min-height: 1.5em;
|
||||||
|
margin: 18px auto 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
177
apps/web/src/pages/tsumego.astro
Normal file
177
apps/web/src/pages/tsumego.astro
Normal file
|
|
@ -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(/</g, '\\u003c');
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title={`${s.title} — ${STRINGS.site.title}`} section="tsumego">
|
||||||
|
<div class="page-head">
|
||||||
|
<div class="container">
|
||||||
|
<!-- этап 10: кикер и вводный абзац — текст прямо в разметке до переноса в strings.ts -->
|
||||||
|
<p class="kicker">жизнь и смерть в миниатюре</p>
|
||||||
|
<h1>{s.title}</h1>
|
||||||
|
<p>
|
||||||
|
Задачи на локальную тактику: раскройте задачу и решите её прямо на доске. Фильтры по теме,
|
||||||
|
сложности и статусу помогают добраться до нерешённого.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="card filters">
|
||||||
|
<div class="field">
|
||||||
|
<label for="filter-topic">{s.filterTopic}</label>
|
||||||
|
<select id="filter-topic">
|
||||||
|
<option value="">{s.allTopics}</option>
|
||||||
|
{topics.map((topic) => <option value={topic}>{topic}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="filter-difficulty">{s.filterDifficulty}</label>
|
||||||
|
<select id="filter-difficulty">
|
||||||
|
<option value="">{s.allDifficulties}</option>
|
||||||
|
{[1, 2, 3, 4, 5].map((level) => <option value={level}>{level}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="filter-status">{s.filterStatus}</label>
|
||||||
|
<select id="filter-status">
|
||||||
|
<option value="all">{s.statusAll}</option>
|
||||||
|
<option value="solved">{s.statusSolved}</option>
|
||||||
|
<option value="unsolved">{s.statusUnsolved}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p id="filter-empty" class="empty-state" hidden>{s.empty}</p>
|
||||||
|
<div id="task-list">
|
||||||
|
{
|
||||||
|
tasks.map((task) => (
|
||||||
|
<details
|
||||||
|
class="task-item"
|
||||||
|
data-task-id={task.id}
|
||||||
|
data-topics={task.topics.join('|')}
|
||||||
|
data-difficulty={String(task.difficulty)}
|
||||||
|
>
|
||||||
|
<summary>
|
||||||
|
<span class="task-title">{task.title}</span>
|
||||||
|
<span class="task-meta">
|
||||||
|
{format(s.difficultyLabel, { level: String(task.difficulty) })}
|
||||||
|
<span class="solved-badge" hidden>
|
||||||
|
{s.solvedBadge}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<go-task data-task-id={task.id}>
|
||||||
|
Запасной текст без JS: откройте задачу в браузере с включённым JavaScript.
|
||||||
|
</go-task>
|
||||||
|
</details>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<script type="application/json" id="go-task-data" set:html={island} />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { loadProgress } from '../lib/storage';
|
||||||
|
import { hydrateTasks } from '../ui/task-widget';
|
||||||
|
|
||||||
|
const topicSelect = document.getElementById('filter-topic') as HTMLSelectElement;
|
||||||
|
const difficultySelect = document.getElementById('filter-difficulty') as HTMLSelectElement;
|
||||||
|
const statusSelect = document.getElementById('filter-status') as HTMLSelectElement;
|
||||||
|
const emptyNote = document.getElementById('filter-empty') as HTMLElement;
|
||||||
|
const items = [...document.querySelectorAll<HTMLElement>('.task-item')];
|
||||||
|
|
||||||
|
function applyFilters(): void {
|
||||||
|
const { progress } = loadProgress();
|
||||||
|
const topic = topicSelect.value;
|
||||||
|
const difficulty = difficultySelect.value;
|
||||||
|
const status = statusSelect.value;
|
||||||
|
let visible = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
const id = item.dataset['taskId'] ?? '';
|
||||||
|
const solved = progress.tasks[id]?.solved === true;
|
||||||
|
item.querySelector('.solved-badge')?.toggleAttribute('hidden', !solved);
|
||||||
|
const topics = (item.dataset['topics'] ?? '').split('|');
|
||||||
|
const matchesTopic = topic === '' || topics.includes(topic);
|
||||||
|
const matchesDifficulty = difficulty === '' || item.dataset['difficulty'] === difficulty;
|
||||||
|
const matchesStatus =
|
||||||
|
status === 'all' || (status === 'solved' ? solved : !solved);
|
||||||
|
const show = matchesTopic && matchesDifficulty && matchesStatus;
|
||||||
|
item.toggleAttribute('hidden', !show);
|
||||||
|
if (show) visible += 1;
|
||||||
|
}
|
||||||
|
emptyNote.toggleAttribute('hidden', visible > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const select of [topicSelect, difficultySelect, statusSelect]) {
|
||||||
|
select.addEventListener('change', applyFilters);
|
||||||
|
}
|
||||||
|
for (const item of items) {
|
||||||
|
// Ленивая гидратация: виджет строится при первом раскрытии задачи.
|
||||||
|
item.addEventListener('toggle', () => {
|
||||||
|
if ((item as HTMLDetailsElement).open) hydrateTasks(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
applyFilters();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Список задач — раскрывающиеся карточки (этап 10, светлые токены «васи»). */
|
||||||
|
#task-list {
|
||||||
|
padding-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
padding: 0.9rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item summary {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-meta {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Бейдж «решена» — зелёная пилюля по паттерну .badge--done. */
|
||||||
|
.solved-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--ok-soft);
|
||||||
|
color: var(--ok);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Виджет задачи внутри карточки — без собственной рамки. */
|
||||||
|
.task-item go-task {
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0.5rem 0 0;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
419
apps/web/src/ui/play-page.ts
Normal file
419
apps/web/src/ui/play-page.ts
Normal file
|
|
@ -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<T extends HTMLElement>(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<string, number> {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
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<void> {
|
||||||
|
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); // бот может ходить первым (белые/случайный цвет)
|
||||||
|
}
|
||||||
294
apps/web/src/ui/strings.ts
Normal file
294
apps/web/src/ui/strings.ts
Normal file
|
|
@ -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<MoveError, string>,
|
||||||
|
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, string>): string {
|
||||||
|
return template.replace(/\{(\w+)\}/g, (raw, key: string) => values[key] ?? raw);
|
||||||
|
}
|
||||||
282
apps/web/src/ui/task-widget.ts
Normal file
282
apps/web/src/ui/task-widget.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
/**
|
||||||
|
* Гидратация элементов <go-task data-task-id="…"> (этап 6): канва
|
||||||
|
* packages/board (setupCanvas/render), ввод reduceInput с подтверждением,
|
||||||
|
* статус-строка, кнопки «Сброс»/«Подсказка», запись прогресса (tasks +
|
||||||
|
* streak + lessons.tasksSolved). Данные задач страница отдаёт JSON-островком
|
||||||
|
* <script type="application/json" id="go-task-data"> (опционально с
|
||||||
|
* data-lesson-id на странице урока). Вся механика — lib/task-session.ts,
|
||||||
|
* здесь только DOM. Строки — ui/strings.ts.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
attachPointerInput,
|
||||||
|
computeGeometry,
|
||||||
|
pointToPixel,
|
||||||
|
reduceInput,
|
||||||
|
render,
|
||||||
|
setupCanvas,
|
||||||
|
} from '@go-learn/board';
|
||||||
|
import type { BoardGeometry, InputState } from '@go-learn/board';
|
||||||
|
import type { Point } from '@go-learn/core';
|
||||||
|
import { addCursor, arrowOf, centerCursor, clampCursor, reduceCursor } from '../lib/keyboard.js';
|
||||||
|
import { loadProgress, saveProgress, touchStreak } from '../lib/storage.js';
|
||||||
|
import type { ProgressV1 } from '../lib/storage.js';
|
||||||
|
import { createTaskSession } from '../lib/task-session.js';
|
||||||
|
import type { TaskSession, TaskV1 } from '../lib/task-session.js';
|
||||||
|
import { format, STRINGS } from './strings.js';
|
||||||
|
|
||||||
|
const ISLAND_ID = 'go-task-data';
|
||||||
|
|
||||||
|
interface TaskIsland {
|
||||||
|
readonly tasks: ReadonlyMap<string, TaskV1>;
|
||||||
|
readonly lessonId: string | null;
|
||||||
|
/** id задач урока (kind 'lesson') из островка — для пометки урока done. */
|
||||||
|
readonly lessonTaskIds: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Чтение JSON-островка с задачами; null — островка нет или он битый. */
|
||||||
|
function readIsland(): TaskIsland | null {
|
||||||
|
const element = document.getElementById(ISLAND_ID);
|
||||||
|
if (element === null || element.textContent === null) return null;
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(element.textContent);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof parsed !== 'object' || parsed === null) return null;
|
||||||
|
const rawTasks = (parsed as { tasks?: unknown }).tasks;
|
||||||
|
if (!Array.isArray(rawTasks)) return null;
|
||||||
|
const tasks = new Map<string, TaskV1>();
|
||||||
|
const lessonTaskIds: string[] = [];
|
||||||
|
for (const item of rawTasks as TaskV1[]) {
|
||||||
|
tasks.set(item.id, item);
|
||||||
|
if (item.kind === 'lesson') lessonTaskIds.push(item.id);
|
||||||
|
}
|
||||||
|
return { tasks, lessonId: element.dataset['lessonId'] ?? null, lessonTaskIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Координата в привычной записи: буквы без «I», числа снизу вверх. */
|
||||||
|
function formatCoordinate(size: number, point: Point): string {
|
||||||
|
const letters = 'ABCDEFGHJKLMNOPQRST';
|
||||||
|
return `${letters[point.x] ?? '?'}${size - point.y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Локальная дата 'YYYY-MM-DD' для серии дней (UI-слой, Date допустим). */
|
||||||
|
function todayLocal(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(now.getDate()).padStart(2, '0');
|
||||||
|
return `${now.getFullYear()}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Widget {
|
||||||
|
readonly session: TaskSession;
|
||||||
|
readonly canvas: HTMLCanvasElement;
|
||||||
|
readonly status: HTMLElement;
|
||||||
|
readonly geo: BoardGeometry;
|
||||||
|
input: InputState;
|
||||||
|
hintText: string | null;
|
||||||
|
recorded: boolean; // решение уже записано в прогресс
|
||||||
|
cursor: Point | null; // клавиатурный курсор-крестик (этап 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(widget: Widget): string {
|
||||||
|
const snap = widget.session.snapshot();
|
||||||
|
const parts: string[] = [];
|
||||||
|
switch (snap.status) {
|
||||||
|
case 'solved':
|
||||||
|
parts.push(STRINGS.task.statusSolved);
|
||||||
|
break;
|
||||||
|
case 'wrong':
|
||||||
|
parts.push(format(STRINGS.task.statusWrong, { count: String(snap.attempts) }));
|
||||||
|
break;
|
||||||
|
case 'illegal':
|
||||||
|
parts.push(snap.reason === null ? '' : STRINGS.moveErrors[snap.reason]);
|
||||||
|
break;
|
||||||
|
case 'playing':
|
||||||
|
if (widget.input.pending !== null) parts.push(STRINGS.task.statusPending);
|
||||||
|
else {
|
||||||
|
const color = STRINGS.colorNames[widget.session.task.toPlay];
|
||||||
|
parts.push(format(STRINGS.task.statusPlaying, { color }));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (widget.hintText !== null) parts.push(widget.hintText);
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(widget: Widget): void {
|
||||||
|
const ctx = widget.canvas.getContext('2d');
|
||||||
|
if (ctx === null) return;
|
||||||
|
const snap = widget.session.snapshot();
|
||||||
|
const pending = widget.input.pending;
|
||||||
|
render(ctx, snap.state, {
|
||||||
|
showCoordinates: true,
|
||||||
|
lastMove: snap.lastMove,
|
||||||
|
markup: addCursor(snap.markup, widget.cursor) ?? snap.markup,
|
||||||
|
...(pending !== null && snap.status !== 'solved'
|
||||||
|
? { phantom: { point: pending, color: snap.state.toPlay } }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh(widget: Widget): void {
|
||||||
|
draw(widget);
|
||||||
|
const snap = widget.session.snapshot();
|
||||||
|
widget.status.textContent = statusText(widget);
|
||||||
|
widget.status.classList.toggle('solved', snap.status === 'solved');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Запись прогресса при первом решении в этой сессии виджета. */
|
||||||
|
function recordSolved(widget: Widget, island: TaskIsland): void {
|
||||||
|
const snap = widget.session.snapshot();
|
||||||
|
const task = widget.session.task;
|
||||||
|
if (snap.status !== 'solved' || widget.recorded) return;
|
||||||
|
widget.recorded = true;
|
||||||
|
const { progress } = loadProgress();
|
||||||
|
const prevTask = progress.tasks[task.id];
|
||||||
|
const tasks = {
|
||||||
|
...progress.tasks,
|
||||||
|
[task.id]: { solved: true, attempts: (prevTask?.attempts ?? 0) + snap.attempts },
|
||||||
|
};
|
||||||
|
let next: ProgressV1 = touchStreak({ ...progress, tasks }, todayLocal());
|
||||||
|
if (island.lessonId !== null && task.kind === 'lesson') {
|
||||||
|
const lessonId = island.lessonId;
|
||||||
|
const solved = new Set<string>(next.lessons[lessonId]?.tasksSolved ?? []);
|
||||||
|
solved.add(task.id);
|
||||||
|
const done = island.lessonTaskIds.every((id) => solved.has(id));
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
lessons: { ...next.lessons, [lessonId]: { done, tasksSolved: [...solved] } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
saveProgress(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWidget(element: HTMLElement, task: TaskV1, island: TaskIsland): void {
|
||||||
|
const session = createTaskSession(task);
|
||||||
|
const cssSize = Math.max(240, Math.min(element.clientWidth || 360, 400));
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.setAttribute('role', 'img');
|
||||||
|
canvas.setAttribute('aria-label', `${task.title}. ${STRINGS.aria.boardKeyboard}`);
|
||||||
|
canvas.tabIndex = 0; // клавиатурное управление доской (этап 7)
|
||||||
|
setupCanvas(canvas, cssSize);
|
||||||
|
const status = document.createElement('p');
|
||||||
|
status.className = 'go-task-status';
|
||||||
|
status.setAttribute('role', 'status');
|
||||||
|
status.setAttribute('aria-live', 'polite');
|
||||||
|
const buttons = document.createElement('div');
|
||||||
|
buttons.className = 'go-task-buttons';
|
||||||
|
const resetButton = document.createElement('button');
|
||||||
|
resetButton.className = 'button';
|
||||||
|
resetButton.textContent = STRINGS.task.reset;
|
||||||
|
const hintButton = document.createElement('button');
|
||||||
|
hintButton.className = 'button';
|
||||||
|
hintButton.textContent = STRINGS.task.hint;
|
||||||
|
buttons.append(resetButton, hintButton);
|
||||||
|
element.append(canvas, status, buttons);
|
||||||
|
|
||||||
|
const widget: Widget = {
|
||||||
|
session,
|
||||||
|
canvas,
|
||||||
|
status,
|
||||||
|
geo: computeGeometry(task.size, cssSize, true),
|
||||||
|
input: { pending: null },
|
||||||
|
hintText: null,
|
||||||
|
recorded: false,
|
||||||
|
cursor: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Общий путь tap-ввода (pointer и клавиатура): фантом → подтверждение. */
|
||||||
|
const tapAt = (px: number, py: number): void => {
|
||||||
|
if (session.snapshot().status === 'solved') return;
|
||||||
|
const result = reduceInput(widget.input, { type: 'tap', px, py }, widget.geo);
|
||||||
|
widget.input = result.state;
|
||||||
|
widget.hintText = null;
|
||||||
|
if (result.commit !== null) {
|
||||||
|
session.play(result.commit);
|
||||||
|
recordSolved(widget, island);
|
||||||
|
}
|
||||||
|
refresh(widget);
|
||||||
|
};
|
||||||
|
|
||||||
|
attachPointerInput(canvas, (event) => {
|
||||||
|
if (event.type === 'tap') tapAt(event.px, event.py);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Клавиатура (этап 7): стрелки — курсор, Enter/пробел — tap по курсору,
|
||||||
|
// Escape — отмена фантома. Подписка независима от pointer-ввода.
|
||||||
|
canvas.addEventListener('keydown', (event) => {
|
||||||
|
const arrow = arrowOf(event.key);
|
||||||
|
if (arrow !== null) {
|
||||||
|
event.preventDefault();
|
||||||
|
const current = clampCursor(widget.cursor ?? centerCursor(task.size), task.size);
|
||||||
|
widget.cursor = reduceCursor(current, arrow, task.size);
|
||||||
|
refresh(widget);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
widget.cursor = clampCursor(widget.cursor ?? centerCursor(task.size), task.size);
|
||||||
|
const { x, y } = pointToPixel(widget.geo, widget.cursor);
|
||||||
|
tapAt(x, y);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
widget.input = reduceInput(widget.input, { type: 'cancel' }, widget.geo).state;
|
||||||
|
refresh(widget);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resetButton.addEventListener('click', () => {
|
||||||
|
session.reset();
|
||||||
|
widget.input = { pending: null };
|
||||||
|
widget.hintText = null;
|
||||||
|
refresh(widget);
|
||||||
|
});
|
||||||
|
|
||||||
|
hintButton.addEventListener('click', () => {
|
||||||
|
const hint = session.hint();
|
||||||
|
if (hint.text !== null) widget.hintText = hint.text;
|
||||||
|
else if (hint.point !== null) {
|
||||||
|
widget.hintText = format(STRINGS.task.hintPoint, {
|
||||||
|
point: formatCoordinate(task.size, hint.point),
|
||||||
|
});
|
||||||
|
} else widget.hintText = STRINGS.task.hintNone;
|
||||||
|
refresh(widget);
|
||||||
|
});
|
||||||
|
|
||||||
|
refresh(widget);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateOne(element: HTMLElement, island: TaskIsland): void {
|
||||||
|
element.dataset['hydrated'] = 'true';
|
||||||
|
const taskId = element.dataset['taskId'] ?? '';
|
||||||
|
const task = island.tasks.get(taskId);
|
||||||
|
element.textContent = ''; // убрать запасной текст «без JS»
|
||||||
|
if (task === undefined) {
|
||||||
|
element.textContent = STRINGS.task.broken;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
buildWidget(element, task, island);
|
||||||
|
} catch {
|
||||||
|
element.textContent = STRINGS.task.broken; // битый контент задачи
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Гидратация всех ещё не гидрированных <go-task> внутри root. Страница
|
||||||
|
* урока вызывает с document; страница цумэ-го — с раскрытым <details>
|
||||||
|
* (ленивая гидратация). Без JSON-островка — тихий no-op.
|
||||||
|
*/
|
||||||
|
export function hydrateTasks(root: ParentNode): void {
|
||||||
|
const island = readIsland();
|
||||||
|
if (island === null) return;
|
||||||
|
for (const element of root.querySelectorAll('go-task[data-task-id]')) {
|
||||||
|
if (element instanceof HTMLElement && element.dataset['hydrated'] === undefined) {
|
||||||
|
hydrateOne(element, island);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
344
apps/web/src/wiring.test.ts
Normal file
344
apps/web/src/wiring.test.ts
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
/**
|
||||||
|
* Wiring-тест apps/web (gotcha-green-modules-dead-system): orphan-check —
|
||||||
|
* каждый публичный runtime-экспорт src упоминается минимум в одном другом
|
||||||
|
* файле (ts/astro/test). Входные точки (ai-worker.ts — воркер, play-page.ts
|
||||||
|
* — вызывается из play.astro) экспортов не обязаны иметь внешних: помечены.
|
||||||
|
*/
|
||||||
|
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));
|
||||||
|
|
||||||
|
/** Точки входа, связываемые сборкой (Vite/Astro), а не импортами. */
|
||||||
|
const ENTRY_POINTS = new Set(['ai-worker.ts', 'ui/play-page.ts', 'content.config.ts']);
|
||||||
|
|
||||||
|
function listFiles(dir: string): string[] {
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const path = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) result.push(...listFiles(path));
|
||||||
|
else if (/\.(ts|astro)$/.test(entry.name)) result.push(path);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Имена runtime-экспортов файла (export function/const/class). */
|
||||||
|
function exportedRuntimeNames(content: string): string[] {
|
||||||
|
const names: string[] = [];
|
||||||
|
const pattern = /export\s+(?:async\s+)?(?:function|const|class)\s+(\w+)/g;
|
||||||
|
for (const match of content.matchAll(pattern)) {
|
||||||
|
const name = match[1];
|
||||||
|
if (name !== undefined) names.push(name);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('orphan-check: публичные функции apps/web связаны', () => {
|
||||||
|
it('каждый экспорт упоминается вне файла определения', () => {
|
||||||
|
const files = listFiles(srcDir);
|
||||||
|
const contents = new Map(files.map((file) => [file, readFileSync(file, 'utf8')]));
|
||||||
|
const missing: string[] = [];
|
||||||
|
for (const [file, content] of contents) {
|
||||||
|
if (ENTRY_POINTS.has(file.slice(srcDir.length + 1))) continue;
|
||||||
|
if (file.endsWith('.test.ts') || file.endsWith('.astro')) continue;
|
||||||
|
for (const name of exportedRuntimeNames(content)) {
|
||||||
|
const usage = new RegExp(`\\b${name}\\b`);
|
||||||
|
const usedElsewhere = [...contents].some(
|
||||||
|
([otherFile, other]) => otherFile !== file && usage.test(other),
|
||||||
|
);
|
||||||
|
if (!usedElsewhere) missing.push(`${file}: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('точки входа существуют и помечены', () => {
|
||||||
|
for (const entry of ENTRY_POINTS) {
|
||||||
|
const content = contentsFor(join(srcDir, entry));
|
||||||
|
expect(content.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function contentsFor(file: string): string {
|
||||||
|
return readFileSync(file, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Этап 6: страницы курса, коллекции контента ── */
|
||||||
|
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { existsSync, statSync } from 'node:fs';
|
||||||
|
import { applyMove, buildPosition, parseSgf } from '@go-learn/core';
|
||||||
|
import { goalReached } from './lib/goals.js';
|
||||||
|
import { createTaskSession } from './lib/task-session.js';
|
||||||
|
import type { TaskV1 } from './lib/task-session.js';
|
||||||
|
import { STRINGS } from './ui/strings.js';
|
||||||
|
|
||||||
|
const contentDir = join(srcDir, 'content');
|
||||||
|
const taskFiles = readdirSync(join(contentDir, 'tasks')).filter((file) => file.endsWith('.json'));
|
||||||
|
const allTasks: TaskV1[] = taskFiles.flatMap(
|
||||||
|
(file) => JSON.parse(readFileSync(join(contentDir, 'tasks', file), 'utf8')) as TaskV1[],
|
||||||
|
);
|
||||||
|
|
||||||
|
function targetOf(task: TaskV1): readonly { x: number; y: number }[] {
|
||||||
|
const root = parseSgf(task.sgf).root;
|
||||||
|
return task.goal.type === 'connect' ? root.markup.circles : root.markup.triangles;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('wiring этапа 6: страницы и коллекции существуют', () => {
|
||||||
|
it('страницы и конфиг коллекций на месте', () => {
|
||||||
|
for (const file of [
|
||||||
|
'pages/learn.astro',
|
||||||
|
'pages/learn/[id].astro',
|
||||||
|
'pages/tsumego.astro',
|
||||||
|
'pages/progress.astro',
|
||||||
|
'content.config.ts',
|
||||||
|
'content/lessons/01-dame-i-zahvat.md',
|
||||||
|
'content/tasks/lessons.json',
|
||||||
|
]) {
|
||||||
|
expect(existsSync(join(srcDir, file)), file).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wiring этапа 6: контент валиден движком', () => {
|
||||||
|
it('все 4 типа goal представлены, id с префиксом kind', () => {
|
||||||
|
const goalTypes = new Set(allTasks.map((task) => task.goal.type));
|
||||||
|
expect(goalTypes).toEqual(new Set(['capture-target', 'liberties', 'connect', 'two-eyes']));
|
||||||
|
for (const task of allTasks) {
|
||||||
|
expect(task.id.startsWith(task.kind === 'lesson' ? 'lsn-' : 'tsm-')).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('позиция строится, SGF парсится, разметка цели присутствует', () => {
|
||||||
|
for (const task of allTasks) {
|
||||||
|
const state = buildPosition(task.size, task.setupBlack, task.setupWhite, task.toPlay);
|
||||||
|
expect(
|
||||||
|
state.grid.some((cell) => cell !== 'empty'),
|
||||||
|
task.id,
|
||||||
|
).toBe(true);
|
||||||
|
expect(targetOf(task).length, task.id).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('первый ход каждой вариации легален на стартовой позиции', () => {
|
||||||
|
for (const task of allTasks) {
|
||||||
|
const state = buildPosition(task.size, task.setupBlack, task.setupWhite, task.toPlay);
|
||||||
|
const root = parseSgf(task.sgf).root;
|
||||||
|
for (const child of root.children) {
|
||||||
|
if (child.move?.kind !== 'play') continue;
|
||||||
|
const result = applyMove(state, {
|
||||||
|
kind: 'play',
|
||||||
|
color: state.toPlay,
|
||||||
|
point: child.move.point,
|
||||||
|
});
|
||||||
|
expect(result.ok, `${task.id}: ${JSON.stringify(child.move.point)}`).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goal-предикат не выполнен на стартовой позиции', () => {
|
||||||
|
for (const task of allTasks) {
|
||||||
|
const session = createTaskSession(task);
|
||||||
|
const snap = session.snapshot();
|
||||||
|
const target = task.goal.type === 'connect' ? snap.markup.circles : snap.markup.triangles;
|
||||||
|
expect(goalReached(snap.state, task.goal, target), task.id).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('главная линия каждой задачи достигает goal (интеграция решений)', () => {
|
||||||
|
for (const task of allTasks) {
|
||||||
|
const session = createTaskSession(task);
|
||||||
|
let status = session.snapshot().status;
|
||||||
|
for (let ply = 0; ply < 100 && status !== 'solved'; ply++) {
|
||||||
|
const point = session.hint().point;
|
||||||
|
expect(point, `${task.id}: линия оборвалась без решения`).not.toBeNull();
|
||||||
|
if (point === null) break;
|
||||||
|
status = session.play(point).status;
|
||||||
|
}
|
||||||
|
expect(status, task.id).toBe('solved');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('урок 01 ссылается на существующие task-id, frontmatter по контракту', () => {
|
||||||
|
const md = readFileSync(join(contentDir, 'lessons/01-dame-i-zahvat.md'), 'utf8');
|
||||||
|
const frontmatter = /^---\n([\s\S]*?)\n---/.exec(md)?.[1] ?? '';
|
||||||
|
for (const field of ['title:', 'section:', 'order:', 'summary:']) {
|
||||||
|
expect(frontmatter, field).toContain(field);
|
||||||
|
}
|
||||||
|
const section = /^section: ['"]?(.+?)['"]?$/m.exec(frontmatter)?.[1] ?? '';
|
||||||
|
expect(STRINGS.learn.sections).toContain(section);
|
||||||
|
const ids = new Set(allTasks.map((task) => task.id));
|
||||||
|
const referenced = [...md.matchAll(/data-task-id="([^"]+)"/g)].map((match) => match[1] ?? '');
|
||||||
|
expect(referenced.length).toBeGreaterThan(0);
|
||||||
|
for (const id of referenced) expect(ids.has(id), id).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const webDir = join(srcDir, '..');
|
||||||
|
const distDir = join(webDir, 'dist');
|
||||||
|
let distBuilt = false;
|
||||||
|
|
||||||
|
/** Реальный astro build один раз на прогон (общий для этапов 6–7). */
|
||||||
|
function ensureDistBuild(): void {
|
||||||
|
if (distBuilt) return;
|
||||||
|
execFileSync('npm', ['run', 'build', '--silent'], {
|
||||||
|
cwd: webDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
distBuilt = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('wiring этапа 6: astro build и гидратация go-task', () => {
|
||||||
|
it(
|
||||||
|
'astro build PASS, HTML урока содержит go-task, страницы собраны',
|
||||||
|
{ timeout: 240_000 },
|
||||||
|
() => {
|
||||||
|
ensureDistBuild();
|
||||||
|
const dist = distDir;
|
||||||
|
for (const page of ['learn.html', 'tsumego.html', 'progress.html', 'play.html']) {
|
||||||
|
expect(existsSync(join(dist, page)), page).toBe(true);
|
||||||
|
}
|
||||||
|
const md = readFileSync(join(contentDir, 'lessons/01-dame-i-zahvat.md'), 'utf8');
|
||||||
|
const firstTaskId = /data-task-id="([^"]+)"/.exec(md)?.[1] ?? '';
|
||||||
|
const lessonHtml = readFileSync(join(dist, 'learn/01-dame-i-zahvat.html'), 'utf8');
|
||||||
|
expect(lessonHtml).toContain(`<go-task data-task-id="${firstTaskId}">`);
|
||||||
|
expect(lessonHtml).toContain('id="go-task-data"');
|
||||||
|
// Теория читается без JS: запасной текст внутри go-task на месте.
|
||||||
|
expect(lessonHtml).toContain('Запасной текст');
|
||||||
|
const tsumegoHtml = readFileSync(join(dist, 'tsumego.html'), 'utf8');
|
||||||
|
const firstTsumego = allTasks.find((task) => task.kind === 'tsumego');
|
||||||
|
expect(tsumegoHtml).toContain(firstTsumego?.id ?? '');
|
||||||
|
const learnHtml = readFileSync(join(dist, 'learn.html'), 'utf8');
|
||||||
|
expect(learnHtml).toContain('/learn/01-dame-i-zahvat.html');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Этап 7: .htaccess и бюджет веса страниц уроков ── */
|
||||||
|
|
||||||
|
const PAGE_WEIGHT_BUDGET = 300 * 1024;
|
||||||
|
|
||||||
|
describe('wiring этапа 7: .htaccess попадает в dist с директивами', () => {
|
||||||
|
it(
|
||||||
|
'dist/.htaccess: wasm, deflate, expires, Options -Indexes; без HTTPS-редиректа',
|
||||||
|
{ timeout: 240_000 },
|
||||||
|
() => {
|
||||||
|
ensureDistBuild();
|
||||||
|
const path = join(distDir, '.htaccess');
|
||||||
|
expect(existsSync(path), 'dist/.htaccess').toBe(true);
|
||||||
|
const content = readFileSync(path, 'utf8');
|
||||||
|
for (const directive of [
|
||||||
|
'AddType application/wasm .wasm',
|
||||||
|
'mod_deflate',
|
||||||
|
'DEFLATE text/html',
|
||||||
|
'mod_expires',
|
||||||
|
'ExpiresByType text/html',
|
||||||
|
'immutable',
|
||||||
|
'Options -Indexes',
|
||||||
|
'DirectoryIndex index.html',
|
||||||
|
]) {
|
||||||
|
expect(content, directive).toContain(directive);
|
||||||
|
}
|
||||||
|
// HTTPS-редирект только панелью хостинга (gotcha-https-redirect-proxy).
|
||||||
|
expect(content).not.toContain('RewriteCond %{HTTPS}');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Этап 8: страница кабинета собирается ── */
|
||||||
|
|
||||||
|
describe('wiring этапа 8: dist/account.html с формами кабинета', () => {
|
||||||
|
it(
|
||||||
|
'astro build PASS, dist/account.html содержит форму входа и ссылки, без формы регистрации',
|
||||||
|
{ timeout: 240_000 },
|
||||||
|
() => {
|
||||||
|
ensureDistBuild();
|
||||||
|
const path = join(distDir, 'account.html');
|
||||||
|
expect(existsSync(path), 'dist/account.html').toBe(true);
|
||||||
|
const html = readFileSync(path, 'utf8');
|
||||||
|
for (const marker of [
|
||||||
|
'id="account-login-form"',
|
||||||
|
'id="account-status"',
|
||||||
|
'role="status"',
|
||||||
|
'Зарегистрируйтесь',
|
||||||
|
'href="/register.html"',
|
||||||
|
'href="/password-reset.html"',
|
||||||
|
]) {
|
||||||
|
expect(html, marker).toContain(marker);
|
||||||
|
}
|
||||||
|
// Этап 8.1: регистрация вынесена на /register.html.
|
||||||
|
expect(html).not.toContain('id="account-register-form"');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Этап 8.1: регистрация и восстановление пароля ── */
|
||||||
|
|
||||||
|
describe('wiring этапа 8.1: dist/register.html и dist/password-reset.html', () => {
|
||||||
|
it(
|
||||||
|
'astro build PASS, register.html содержит форму с капчей, password-reset.html — оба режима',
|
||||||
|
{ timeout: 240_000 },
|
||||||
|
() => {
|
||||||
|
ensureDistBuild();
|
||||||
|
const registerPath = join(distDir, 'register.html');
|
||||||
|
expect(existsSync(registerPath), 'dist/register.html').toBe(true);
|
||||||
|
const registerHtml = readFileSync(registerPath, 'utf8');
|
||||||
|
for (const marker of [
|
||||||
|
'id="register-form"',
|
||||||
|
'src="/api/captcha.php"',
|
||||||
|
'id="register-captcha"',
|
||||||
|
'id="register-captcha-refresh"',
|
||||||
|
'id="register-password-repeat"',
|
||||||
|
'role="status"',
|
||||||
|
]) {
|
||||||
|
expect(registerHtml, marker).toContain(marker);
|
||||||
|
}
|
||||||
|
const resetPath = join(distDir, 'password-reset.html');
|
||||||
|
expect(existsSync(resetPath), 'dist/password-reset.html').toBe(true);
|
||||||
|
const resetHtml = readFileSync(resetPath, 'utf8');
|
||||||
|
for (const marker of [
|
||||||
|
'id="reset-request-form"',
|
||||||
|
'id="reset-confirm-form"',
|
||||||
|
'id="reset-status"',
|
||||||
|
'role="status"',
|
||||||
|
]) {
|
||||||
|
expect(resetHtml, marker).toContain(marker);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wiring этапа 7: бюджет веса страниц уроков ≤ 300 КБ', () => {
|
||||||
|
it(
|
||||||
|
'html + локальные ассеты _astro каждой страницы dist/learn/*.html ≤ 300 КБ',
|
||||||
|
{ timeout: 240_000 },
|
||||||
|
() => {
|
||||||
|
ensureDistBuild();
|
||||||
|
const learnDir = join(distDir, 'learn');
|
||||||
|
const pages = readdirSync(learnDir).filter((file) => file.endsWith('.html'));
|
||||||
|
expect(pages.length).toBeGreaterThan(0);
|
||||||
|
const over: string[] = [];
|
||||||
|
for (const page of pages) {
|
||||||
|
const html = readFileSync(join(learnDir, page), 'utf8');
|
||||||
|
let total = statSync(join(learnDir, page)).size;
|
||||||
|
const refs = new Set<string>();
|
||||||
|
for (const match of html.matchAll(/<script[^>]+src="(\/[^"]+)"/g)) {
|
||||||
|
if (match[1] !== undefined) refs.add(match[1]);
|
||||||
|
}
|
||||||
|
for (const match of html.matchAll(/<link[^>]+href="(\/[^"]+)"/g)) {
|
||||||
|
if (match[1] !== undefined) refs.add(match[1]);
|
||||||
|
}
|
||||||
|
for (const ref of refs) {
|
||||||
|
if (!ref.startsWith('/_astro/')) continue;
|
||||||
|
const asset = join(distDir, ref.slice(1));
|
||||||
|
if (existsSync(asset)) total += statSync(asset).size;
|
||||||
|
}
|
||||||
|
if (total > PAGE_WEIGHT_BUDGET) over.push(`${page}: ${total} байт`);
|
||||||
|
}
|
||||||
|
expect(over).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
9
apps/web/tsconfig.json
Normal file
9
apps/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"outDir": "dist-ts",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
1127
docs/INTERFACES.md
Normal file
1127
docs/INTERFACES.md
Normal file
File diff suppressed because it is too large
Load diff
95
docs/PROJECT_STATE.md
Normal file
95
docs/PROJECT_STATE.md
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
# PROJECT_STATE — состояние проекта «Сайт обучения Го»
|
||||||
|
|
||||||
|
Обновлено: 2026-08-07 | Версия: 0.3.1 | Сессия: 11 (этап 11)
|
||||||
|
|
||||||
|
## Этапы (по мастер-промпту)
|
||||||
|
|
||||||
|
| Этап | Статус | Комментарий |
|
||||||
|
| -------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Шаг 0. Методология и каркас | завершено | Триаж — ADR-0001; каркас подтверждён владельцем молчанием (дефолт принят) |
|
||||||
|
| 1. Движок правил (packages/core) | завершено | 43/43 теста, gates зелёные; контракт — docs/INTERFACES.md; выбор алгоритма групп — ADR-0002 (flood fill) |
|
||||||
|
| 2. Доска (Canvas) | завершено | 11 слоёв render, reduceInput, TerritoryMap; тёмная тёплая тема |
|
||||||
|
| 3. ИИ ур. 1–3 (эвристика) | завершено | chooseMove, LEVEL_ATARI_PROBABILITY, детерминизм по seed |
|
||||||
|
| 4. ИИ ур. 4–6 (MCTS, Web Worker) | завершено | UCT c=1.4, бюджеты 300/800/1500 мс, воркер-протокол stateless |
|
||||||
|
| 5. Игровой интерфейс | завершено | /play: партии vs ИИ, подсказки, SGF; hotfix play.html |
|
||||||
|
| 6. Курс + цумэ-го | завершено | 10 уроков, 33 цумэ-го + 7 задач уроков, прогресс/серия дней; 413/413 тестов |
|
||||||
|
| 7. Полировка | завершено | .htaccess, клавиатура/ARIA, reduced-motion, отметка урока, вес ≤300 КБ; 431/431 |
|
||||||
|
| 8. (опц.) PHP+SQLite бэкенд | завершено | Личный кабинет email+пароль (ADR-0004), sync прогресса; smoke.sh зелёный; деплой — docs/deploy.md |
|
||||||
|
| 9. (опц.) KataGo ONNX | заморожен | Не начинать, пока этапы 1–7 не приняты |
|
||||||
|
| 8.1. Кабинет 2.0 | завершено | Регистрация отдельной страницей + SVG-капча, восстановление пароля токеном; smoke 58 проверок |
|
||||||
|
| 10. Дизайн-перенос «васи» | завершено | Светлая тема от Kimi WebSites натянута на каркас; тёмная тема удалена; 467/467; превью — новая версия |
|
||||||
|
| 11. Аудит контента + «Источники» | завершено | 85 утверждений: 70 подтв./10 расх./5 не найдено (docs/audit/); /istochniki.html; 10 правок уроков; 467/467 |
|
||||||
|
|
||||||
|
## Известные проблемы
|
||||||
|
|
||||||
|
- Кабинет в статическом превью показывает «сервер синхронизации
|
||||||
|
недоступен» — by design (API оживает только на хостинге с PHP).
|
||||||
|
- Восстановление пароля — через mail() (этап 8.1); доставляемость на
|
||||||
|
shared-хостинге хрупкая (SPF/DKIM): владельцу проверить тестовое
|
||||||
|
письмо при деплое (шаг в docs/deploy.md). Тема письма — сырой UTF-8
|
||||||
|
без RFC 2047: на части MTA может отображаться некорректно.
|
||||||
|
- PHP-часть вне npm-гейтов: smoke.sh локально (PHP из deb в /tmp) и на
|
||||||
|
хостинге; CI для PHP не настроен.
|
||||||
|
- Риски хостинга от бэкенд-кодера: Secure-cookie за TLS-прокси без
|
||||||
|
HTTPS=on; rate limit по REMOTE_ADDR общий за NAT; WAL на NFS-ФС может
|
||||||
|
быть медленным (busy_timeout=5000 смягчает).
|
||||||
|
- Критерии приёмки v1: №1 (тесты) и №4 (чистый клон/сборка) — зелёные
|
||||||
|
автоматически; №2 (партия vs ур.5), №3 (тайминги ур.3 <50мс, ур.6 <2с),
|
||||||
|
№5 (мобильный Safari) — ручные, ожидают владельца.
|
||||||
|
- Этап 6, ограничения контента v1: все задачи toPlay='black' (parseSgf
|
||||||
|
моделирует от пустой доски с чёрных; виджет умеет инверсию B/W, но
|
||||||
|
контент не использует); мотив снэпбэк невыразим (parseSgf падает на
|
||||||
|
occupied при повторной игре точки); тема «сеть (гэта)» в цумэ-го
|
||||||
|
отсутствует (покрыта текстом урока 2); у two-eyes задач авто-ответ
|
||||||
|
иногда тэнуки (пассивный соперник).
|
||||||
|
- task-session: goal проверяется после КАЖДОГО хода игрока, до авто-ответа
|
||||||
|
(фикс интеграции этапа 6; иначе транзиентные цели liberties недостижимы).
|
||||||
|
- Ссылки ведут на /play.html, сборка с build.format 'file' (hotfix после
|
||||||
|
этапа 5): статические превью-серверы без directory index давали 404 на /play.
|
||||||
|
На Apache с DirectoryIndex работали бы оба варианта, формат 'file' универсальнее.
|
||||||
|
- CI (.github/workflows/ci.yml) написан, но не проверен запуском — репозиторий
|
||||||
|
пока локальный. Первая проверка — после публикации на GitHub.
|
||||||
|
- packages/board без project reference на core (TS6310 при `tsc -b --noEmit`,
|
||||||
|
TS 5.9.3): импорт резолвится через workspace-симлинк. Пересмотреть при
|
||||||
|
обновлении TypeScript или смене скрипта typecheck.
|
||||||
|
- Реальный Worker не принимает cancel во время синхронного счёта (event loop);
|
||||||
|
семантика протокола реализована, но в этапе 5 UI решает: чанкование счёта
|
||||||
|
через setTimeout в воркере или terminate+пересоздание воркера (комментарий
|
||||||
|
в packages/ai/src/worker.ts).
|
||||||
|
- Плейаут MCTS ограничен ~147 сим/300 мс на 9×9: applyMove копирует
|
||||||
|
positionHashes каждый ход (O(n²) в плейауте). Ускорение — только через core,
|
||||||
|
отдельной задачей, если силы ур. 4–6 не хватит на приёмке.
|
||||||
|
- Astro 5.18.2 вместо 7.x: astro 6/7 требуют Node ≥22.12 (hardcoded), у нас
|
||||||
|
Node 20.20.2. Пересмотреть при обновлении Node на машине разработки.
|
||||||
|
- suggestDeadGroups (core) на разрежённых позициях предлагает «мёртвыми»
|
||||||
|
живые группы — грубость заявлена в контракте, ручная корректировка в UI
|
||||||
|
есть; кандидат на уточнение эвристики.
|
||||||
|
- Критерии v1 №2 (партия vs ур.5) и №5 (мобильный Safari) — ручная приёмка
|
||||||
|
владельцем после этапа 7.
|
||||||
|
- Суперко юнит-тест моделирует цикл >2 ходов через сконструированную историю
|
||||||
|
хэшей (настоящее двойное ко громоздко в фикстуре); механизм дополнительно
|
||||||
|
покрыт property-тестом. Отложено: построить настоящую позицию двойного ко
|
||||||
|
на 9×9 — низкий приоритет.
|
||||||
|
- Resign-узлы сериализуются в SGF как пас (в SGF нет хода «сдаюсь») —
|
||||||
|
задокументировано в packages/core/src/sgf.ts, осознанное ограничение.
|
||||||
|
- suggestDeadGroups — грубая эвристика, возможны ложные срабатывания на
|
||||||
|
открытом пространстве; ручная корректировка — за UI (этап 5).
|
||||||
|
- Коми движка 6,5 (19×19) расходится со стандартом китайских правил 7,5
|
||||||
|
(аудит этапа 11, SL ChineseRules/Komi). Урок 05 честно разводит «наши
|
||||||
|
партии» и «стандарт». Владельцу предложено решение (2026-08-07) — ответ
|
||||||
|
«без предпочтений»: оставляем 6,5 как есть; вопрос закрыт до нового
|
||||||
|
решения.
|
||||||
|
|
||||||
|
## Критические долги
|
||||||
|
|
||||||
|
- Нет.
|
||||||
|
|
||||||
|
## Планы (от владельца)
|
||||||
|
|
||||||
|
- Этап 12 (черновик, 2026-08-07): добавить в подвал сайта подпись
|
||||||
|
«сайт сделан sab:core» (формулировка подтверждена владельцем)
|
||||||
|
с логотипом; подпись кликабельна — URL владелец даст позже. Логотип
|
||||||
|
был в соседнем диалоге (сайт с самолётиками) — оркестратору недоступен;
|
||||||
|
владелец загрузит файл в чат. При старте: получить лого (лучше PNG/SVG
|
||||||
|
с прозрачным фоном) и URL; размещение — в футере рядом с privacy-note,
|
||||||
|
спокойный стиль без кричащих элементов.
|
||||||
9
docs/README.md
Normal file
9
docs/README.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Документация — индекс
|
||||||
|
|
||||||
|
Обновлено: 2026-08-05 | Версия: 0.1.0
|
||||||
|
|
||||||
|
- PROJECT_STATE.md — состояние этапов, проблемы, критические долги
|
||||||
|
- INTERFACES.md — контракты между модулями (сначала запись, потом код)
|
||||||
|
- architecture.md — обзор, слои, «Грабли проекта»
|
||||||
|
- decisions/ — ADR (не редактируются; оспаривание — новым ADR)
|
||||||
|
- plans/ — план текущей фазы с блоком «Ключевые решения (не пере-решать)»
|
||||||
27
docs/architecture.md
Normal file
27
docs/architecture.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Архитектура
|
||||||
|
|
||||||
|
Обновлено: 2026-08-05 | Версия: 0.1.0
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
Полностью клиентское приложение. Статическая сборка заливается на шаред-хостинг
|
||||||
|
по FTP. Движок правил и ИИ выполняются в браузере. Подробный дизайн появится
|
||||||
|
с кодом этапов 1–2; здесь фиксируются только решения уровня каркаса.
|
||||||
|
|
||||||
|
## Слои
|
||||||
|
|
||||||
|
- **packages/core** — движок правил. Чистый TypeScript, ноль импортов DOM и
|
||||||
|
воркер-API; Date.now()/Math.random() запрещены — часы и seeded RNG инжектируются.
|
||||||
|
- **(этап 2+) packages/board** — отрисовка Canvas 2D, чистая render-функция.
|
||||||
|
- **(этап 3+) packages/ai** — эвристика и MCTS; в браузере исполняется в Web Worker.
|
||||||
|
- **(этап 5+) apps/web** — Astro-сайт, UI, курс, localStorage-прогресс.
|
||||||
|
|
||||||
|
Правило изоляции: core не импортирует ничего из board/ai/web — проверяется
|
||||||
|
сборкой и lint-границами (этап 1).
|
||||||
|
|
||||||
|
## Грабли проекта
|
||||||
|
|
||||||
|
> Живой раздел: новые грабли — сюда, с источником (литания 0.7).
|
||||||
|
> Копия prestart-litany.md в корне — read-only архив, её не редактируют.
|
||||||
|
|
||||||
|
- Пока пусто.
|
||||||
283
docs/audit/01-03.md
Normal file
283
docs/audit/01-03.md
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
# Аудит уроков 01–03: проверка фактов по авторитетным источникам
|
||||||
|
|
||||||
|
Дата: аудит R1. Метод: каждое утверждение проверено открытием страницы источника
|
||||||
|
(web_open_url), приводятся дословные цитаты. Источники: правила AGA (краткая версия,
|
||||||
|
зеркало BGA), British Go Association (britgo.org), Sensei's Library (senseis.xmp.net),
|
||||||
|
библиотека Российской федерации го (rusgolib.gofederation.ru).
|
||||||
|
|
||||||
|
**Итог: 28 проверяемых утверждений — 24 ПОДТВЕРЖДЕНО (2 из них с оговорками),
|
||||||
|
4 РАСХОЖДЕНИЕ, 0 НЕ НАЙДЕНО.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Урок 01 «Дамэ и захват»
|
||||||
|
|
||||||
|
### 1.1. Дамэ — свободные соседние пункты по линиям; диагонали не считаются
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: правила AGA, краткая версия (зеркало BGA), https://www.britgo.org/rules/agashort.html
|
||||||
|
|
||||||
|
> «A liberty of a stone is a vacant, horizontally or vertically adjacent intersection. <…> The intersections diagonal to the stone are not adjacent and are not counted as liberties of the stone.»
|
||||||
|
> Дополнительно Sensei's Library, https://senseis.xmp.net/?Dame
|
||||||
|
> «dame is an empty point or liberty adjacent to a stone or connected group of stones».
|
||||||
|
|
||||||
|
### 1.2. Нет дамэ — камень/группа снимается с доски
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: Sensei's Library, https://senseis.xmp.net/?RulesOfGoIntroductory
|
||||||
|
|
||||||
|
> «If there are no empty points next to a stone or a string of stones (i.e. no liberties), the stones are removed from the board.»
|
||||||
|
|
||||||
|
### 1.3. Одинокий камень: в центре 4 дамэ, на краю 3, в углу 2
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: правила AGA (краткая версия), https://www.britgo.org/rules/agashort.html
|
||||||
|
|
||||||
|
> «A single stone in the middle of an empty board has four liberties <…> A single stone on a side intersection has a maximum of three liberties; a single stone in the corner has a maximum of two liberties.»
|
||||||
|
> Дополнительно BGA, https://www.britgo.org/intro/intro2.html
|
||||||
|
> «A single stone on the side has three liberties, and a stone in the corner has only two liberties.»
|
||||||
|
|
||||||
|
### 1.4. Группа делит дамы на всех; цепочка из двух камней в центре — 6 дамэ
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО** (правило подтверждено источником; число 6 — прямое следствие: 4+4−2 общих пункта = 6).
|
||||||
|
Источник: правила AGA (краткая версия), https://www.britgo.org/rules/agashort.html
|
||||||
|
|
||||||
|
> «The liberties of a string of stones are the liberties of all the individual stones in that string.»
|
||||||
|
|
||||||
|
### 1.5. Захватывается группа целиком
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: BGA, https://www.britgo.org/intro/intro2.html
|
||||||
|
|
||||||
|
> «As far as capturing is concerned, a string of stones is treated as a single unit. <…> a string is captured when all of its liberties are occupied by enemy stones.»
|
||||||
|
> Дополнительно Sensei's Library: «As a string, the stones stand or fall together.» (https://senseis.xmp.net/?RulesOfGoIntroductory)
|
||||||
|
|
||||||
|
### 1.6. Захваченные камни становятся пленниками
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: правила AGA (краткая версия), https://www.britgo.org/rules/agashort.html
|
||||||
|
|
||||||
|
> «Such stones become prisoners of the capturing player.»
|
||||||
|
> Дополнительно Sensei's Library: «Captured stones are called prisoners or captives.» (https://senseis.xmp.net/?Capture)
|
||||||
|
|
||||||
|
### 1.7. Атари — ситуация, когда у группы осталось ровно одно дамэ
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: Sensei's Library, https://senseis.xmp.net/?Atari
|
||||||
|
|
||||||
|
> «The state of a stone or group of stones that has only one liberty.»
|
||||||
|
|
||||||
|
### 1.8. «Опытные игроки вслух говорят "атари", когда ставят его»
|
||||||
|
|
||||||
|
**Вердикт: РАСХОЖДЕНИЕ.**
|
||||||
|
Источник: Sensei's Library, https://senseis.xmp.net/?CallingOutAtari
|
||||||
|
|
||||||
|
> «It is unusual among experienced players and very unusual at tournaments.»
|
||||||
|
> Как должно быть: вслух говорить «атари» принято в обучающих партиях с новичками; среди
|
||||||
|
> опытных игроков это необычно и многими считается дурным тоном (ранние книги — Korschelt,
|
||||||
|
> Lasker, Smith — описывали это как старый японский этикет). Примечание: глоссарий BGA
|
||||||
|
> (https://www.britgo.org/general/definitions.html) говорит «A verbal warning is often issued
|
||||||
|
> when placing an opponent into ate» — т.е. традиция существует, но как норма поведения
|
||||||
|
> «опытных игроков» утверждение урока неверно.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Урок 02 «Атари, лестница, сеть»
|
||||||
|
|
||||||
|
### 2.1. Атари — одно дамэ; поставить атари = пригрозить захватом на следующий ход
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: глоссарий BGA, https://www.britgo.org/general/definitions.html
|
||||||
|
|
||||||
|
> «Atari (Ate): An immediate threat to capture; a single liberty remains.»
|
||||||
|
|
||||||
|
### 2.2. «Атари… заставляет противника отвечать»
|
||||||
|
|
||||||
|
**Вердикт: РАСХОЖДЕНИЕ (мягкое).**
|
||||||
|
Источник: Sensei's Library, https://senseis.xmp.net/?CallingOutAtari
|
||||||
|
|
||||||
|
> «atari can be ignored quite deliberately to your advantage» (и: «It suggests that atari has to be answered» — отмечено как вредное ложное впечатление).
|
||||||
|
> Как должно быть: атари — это угроза, на которую противник _чаще всего_ отвечает, но отвечать
|
||||||
|
> не обязательно; встречные атари, сброс ценности и т.п. — нормальная часть игры. Впрочем,
|
||||||
|
> сам урок ниже верно советует спрашивать «а что я получу, если он спасётся?», так что
|
||||||
|
> формулировку стоит смягчить, а не удалить. (Оценочное «самое сильное оружие новичка» —
|
||||||
|
> не факт, мнение; не проверялось.)
|
||||||
|
|
||||||
|
### 2.3. Лестница — серия атари, при которой убегающий всякий раз получает лишь один путь/одно новое дамэ; зигзаг через доску
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: Sensei's Library, https://senseis.xmp.net/?Ladder
|
||||||
|
|
||||||
|
> «At each step the attacker plays atari <…> and then the defender runs away, but can only gain one more liberty. <…> ladders normally follow a zigzag pattern across the board».
|
||||||
|
|
||||||
|
### 2.4. Лестница срывается камнем противника на её пути (по диагонали бега)
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: Sensei's Library, https://senseis.xmp.net/?Ladder
|
||||||
|
|
||||||
|
> «Suppose, however, that there is a stone in the path of the ladder <…> The ladder is broken. <…> This is therefore a disaster for Black.»
|
||||||
|
> Дополнительно (рус. терминология): rusgolib.gofederation.ru («Школьная Компьютерра», А. Битман, В. Медведев):
|
||||||
|
> «если на пути движущейся лестницы вдруг окажется камень убегающей стороны, то результат ситё может оказаться неблагоприятным для атакующего».
|
||||||
|
|
||||||
|
### 2.5. Лестничная опора называется «сирэ»
|
||||||
|
|
||||||
|
**Вердикт: РАСХОЖДЕНИЕ (термин).**
|
||||||
|
Термин «сирэ» в го-источниках не обнаружен (поиск по «сирэ го лестница» результатов по игре
|
||||||
|
не дал). Устоявшиеся названия этого камня:
|
||||||
|
|
||||||
|
- англ./яп.: **ladder breaker / shicho-atari**. Глоссарий BGA, https://www.britgo.org/general/definitions.html:
|
||||||
|
> «Shicho-atari: Ladder breaker. A stone played in the path of a potential shicho, threatening to make it fail.»
|
||||||
|
- рус.: **ситё-прерыватель**. rusgolib.gofederation.ru:
|
||||||
|
> «можно с выгодой поставить свой камень — так называемый ситё-прерыватель в направлении движения лестницы».
|
||||||
|
> Как должно быть: «ситё-атари» / «ситё-прерыватель» (ladder breaker). Само описание механизма
|
||||||
|
> в уроке верно, неверен только термин.
|
||||||
|
|
||||||
|
### 2.6. «Лестница либо захватывает, либо катастрофически проигрывает — середины нет»
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: Sensei's Library, https://senseis.xmp.net/?Ladder
|
||||||
|
|
||||||
|
> «we can plainly see that a ladder is little more than a one-sided fight for one side or the other» (проигрышная лестница названа «a disaster» для обеих сторон в примерах).
|
||||||
|
|
||||||
|
### 2.7. У камня на первой линии три дамэ; прижатая к краю лестница кончается захватом
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: правила AGA (краткая версия), https://www.britgo.org/rules/agashort.html
|
||||||
|
|
||||||
|
> «A single stone on a side intersection has a maximum of three liberties».
|
||||||
|
> Источник: Sensei's Library, https://senseis.xmp.net/?Ladder
|
||||||
|
> «The process stops when the defender can run no further (is captured) or gets more than two liberties».
|
||||||
|
|
||||||
|
### 2.8. Сеть (гэта) — тихий захват без атари, заранее закрывающий пути к бегству
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: Sensei's Library, https://senseis.xmp.net/?Geta
|
||||||
|
|
||||||
|
> «A net (also frequently called geta) is a technique where one or more stones are captured by enclosing them in a 'net' of stones which has holes in it, all of which the attacker can block.»
|
||||||
|
> Дополнительно глоссарий BGA: «Geta: A method of capturing an enemy stone; a net trap.»
|
||||||
|
|
||||||
|
### 2.9. «Классическая сеть ставится через один пункт от цели (конём, как в шахматах)»
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО с оговоркой.**
|
||||||
|
Сеть ходом коня (keima) — стандартная форма: Sensei's Library, https://senseis.xmp.net/?Geta
|
||||||
|
|
||||||
|
> «Sometimes the solution is to make a looser net with a keima, when a tight net at a doesn't work» (см. также страницу «Knight's move net» в том же разделе).
|
||||||
|
> Глоссарий BGA: «Keima: Knight's move extension.»
|
||||||
|
> Оговорка: Sensei's называет keima-сеть «более свободной» разновидностью; сети бывают и тесными.
|
||||||
|
> Формулировка «классическая сеть — всегда конём» — допустимое упрощение для новичков,
|
||||||
|
> но не универсальное правило.
|
||||||
|
|
||||||
|
### 2.10. «Если лестница сломана опорой — ищите сеть»
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: rusgolib.gofederation.ru:
|
||||||
|
|
||||||
|
> «белый камень… не удается взять в ситё из-за присутствия ситё-прерывателя в пункте А. В данной ситуации срабатывает другой тактический прием — под названием гэта, или западня.»
|
||||||
|
> (У Sensei's Library та же связка: «Net versus ladder», «Ladder and loose ladder» — https://senseis.xmp.net/?Geta)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Урок 03 «Самоубийство и ко»
|
||||||
|
|
||||||
|
### 3.1. Ход, после которого у своей группы не остаётся дамэ, запрещён
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО с оговоркой.**
|
||||||
|
Источник: правила AGA (краткая версия), https://www.britgo.org/rules/agashort.html
|
||||||
|
|
||||||
|
> «It is illegal for a player to move so as to create a string of his or her own stones which is completely surrounded (without liberties) after any surrounded opposing stones are captured.»
|
||||||
|
> Источник: Sensei's Library, https://senseis.xmp.net/?Suicide
|
||||||
|
> «Suicide moves are forbidden under Japanese rules, Chinese rules, and AGA rules.»
|
||||||
|
> Оговорка: запрет не универсален — «Suicides are allowed in some other rulesets, such as Ing
|
||||||
|
> rules, New Zealand rules and Tromp-Taylor rules.» Для учебного курса (стандартные
|
||||||
|
> японские/AGA-правила) формулировка урока корректна, но можно дать сноску.
|
||||||
|
|
||||||
|
### 3.2. «Такой ход не регистрируется, придётся ходить в другое место»
|
||||||
|
|
||||||
|
**Вердикт: РАСХОЖДЕНИЕ (мягкое).**
|
||||||
|
Источник: правила AGA (краткая версия), https://www.britgo.org/rules/agashort.html, правило 8:
|
||||||
|
|
||||||
|
> «If a player makes an illegal move, it shall be taken back, treated as a pass, and a pass stone exchanged.»
|
||||||
|
> Как должно быть: по правилам AGA нелегальный ход отменяется и засчитывается как **пас**
|
||||||
|
> (а не «переход» в другое место); в японских правилах нарушение правил ведёт к техническому
|
||||||
|
> поражению. «Перехожу в другое место» — лишь любительская практика дружеских игр.
|
||||||
|
|
||||||
|
### 3.3. Исключение: ход в «запретный» пункт легален, если он снимает группу противника
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: BGA, https://www.britgo.org/intro/intro2.html
|
||||||
|
|
||||||
|
> «A player may not self-capture <…> unless, as a result, one or more of the stones surrounding it is captured.»
|
||||||
|
> Дополнительно BGA Quick Reference, https://www.britgo.org/files/rules/GoQuickRef.pdf (§9):
|
||||||
|
> «The only exception to this is if you make a move that gives both players stones or chains with no liberties. If this happens, you take off the other person's stones, but your own stones stay on the board.»
|
||||||
|
|
||||||
|
### 3.4. Ко: пара взаимных захватов одного камня повторялась бы бесконечно без правила
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: Sensei's Library, https://senseis.xmp.net/?Ko
|
||||||
|
|
||||||
|
> «It describes a situation where two alternating single stone captures would repeat the original board position. The alternating captures could repeat indefinitely, preventing the game from ending.»
|
||||||
|
|
||||||
|
### 3.5. «Ко» по-японски — «вечность»
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: правила AGA (краткая версия), https://www.britgo.org/rules/agashort.html
|
||||||
|
|
||||||
|
> «("Ko" is the Japanese Buddhist word for eternity.)»
|
||||||
|
> Дополнительно BGA, https://www.britgo.org/intro/intro2.html:
|
||||||
|
> «This pattern of stones is called ko - a Japanese term meaning eternity.»
|
||||||
|
> Примечание: этимология спорна (Sensei's Library, раздел Ko etymology: «Unresolved»; иероглиф 劫
|
||||||
|
> восходит к санскритскому «кальпа»), но перевод «вечность» — стандартный в англоязычной
|
||||||
|
> литературе, так что утверждение урока допустимо.
|
||||||
|
|
||||||
|
### 3.6. Правило ко: немедленный обратный захват запрещён; сначала ход в другом месте; потом запрет снимается
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: Sensei's Library, https://senseis.xmp.net/?Ko
|
||||||
|
|
||||||
|
> «If one player captures the ko, the opponent is prohibited from recapturing the ko immediately. <…> Instead White has to play elsewhere. <…> the recapture restriction is only valid for the next turn.»
|
||||||
|
> Дополнительно AGA (краткая версия): «After the first capture, the player moving next may not recapture immediately <…> that player must play elsewhere on the board (or pass).»
|
||||||
|
|
||||||
|
### 3.7. Ко-угроза — ход в другом месте, требующий ответа; после ответа можно забрать ко
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: Sensei's Library, https://senseis.xmp.net/?KoThreat
|
||||||
|
|
||||||
|
> «any move that threatens a large follow-up may be considered a ko threat. <…> If they do answer, it will be legal for you to recapture the ko».
|
||||||
|
> Дополнительно глоссарий BGA: «Ko threat: Intervening move (that one hopes will force a reply) before a ko can be recaptured.»
|
||||||
|
|
||||||
|
### 3.8. «Выигрывает ко тот, у кого больше угроз»
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: Sensei's Library, https://senseis.xmp.net/?KoThreat
|
||||||
|
|
||||||
|
> «The player with more ko threats available can often play very aggressively, since he is not afraid to start ko fights and can reasonably expect to win any ko that his opponent starts.»
|
||||||
|
|
||||||
|
### 3.9. «Иногда выгоднее "продать" ко, получив два хода подряд в другом месте»
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: Sensei's Library, https://senseis.xmp.net/?Ko
|
||||||
|
|
||||||
|
> «That way black gets compensation for not winning the ko by getting two moves elsewhere.»
|
||||||
|
> Дополнительно https://senseis.xmp.net/?KoThreat: «you can follow through on your threat and thereby gain compensation for losing the ko».
|
||||||
|
|
||||||
|
### 3.10. «Не отвечайте на мелкие угрозы, если ко дороже»
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: Sensei's Library, https://senseis.xmp.net/?KoThreat
|
||||||
|
|
||||||
|
> «sometimes a ko threat whose value is smaller than the value of the ko itself is described as "not a ko threat"» (принцип сравнения ценности ко и угрозы).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сводка
|
||||||
|
|
||||||
|
| Урок | Подтверждено | Расхождение | Не найдено |
|
||||||
|
| --------- | ------------------- | ------------------------------------------------------ | ---------- |
|
||||||
|
| 01 | 7 | 1 (1.8 — «опытные говорят атари вслух») | 0 |
|
||||||
|
| 02 | 8 (2.9 с оговоркой) | 2 (2.2 — «заставляет отвечать»; 2.5 — термин «сирэ») | 0 |
|
||||||
|
| 03 | 9 (3.1 с оговоркой) | 1 (3.2 — нелегальный ход засчитывается как пас по AGA) | 0 |
|
||||||
|
| **Итого** | **24** | **4** | **0** |
|
||||||
|
|
||||||
|
Оценочные/риторические фразы («атари — самое сильное оружие новичка», «всё в Го построено
|
||||||
|
на подсчёте дамэ», «край доски — лучший друг лестницы») фактическими утверждениями
|
||||||
|
не считались и не вердиктились.
|
||||||
493
docs/audit/04-07.md
Normal file
493
docs/audit/04-07.md
Normal file
|
|
@ -0,0 +1,493 @@
|
||||||
|
# Аудит уроков 04–07
|
||||||
|
|
||||||
|
Дата: 2026 (сеанс аудита). Метод: каждое утверждение сверено с авторитетным
|
||||||
|
источником, страница РЕАЛЬНО открыта инструментом, приведена дословная цитата.
|
||||||
|
Вердикты: ПОДТВЕРЖДЕНО | НЕ НАЙДЕНО | РАСХОЖДЕНИЕ.
|
||||||
|
|
||||||
|
Основные источники: Sensei's Library (senseis.xmp.net), AGA Concise Rules of Go
|
||||||
|
(зеркало cs.cmu.edu, текст предоставлен Fred Hansen для AGA).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Урок 04. Глаза: жизнь и смерть
|
||||||
|
|
||||||
|
### 4.1. Глаз — один или несколько соседних пустых пунктов, окружённых камнями одного цвета
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://senseis.xmp.net/?Eye
|
||||||
|
|
||||||
|
> «In general, what Go players call an eye is _an empty space surrounded by
|
||||||
|
> stones of one colour_, the simplest forms of which are the single point eyes…
|
||||||
|
> However, larger surrounded spaces can also be called eyes. More generally we
|
||||||
|
> call these an eyespace.»
|
||||||
|
|
||||||
|
### 4.2. Ход противника в глаз — самоубийство; сыграть туда можно, лишь сняв внешние дамы (поставив группу в атари)
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://www.cs.cmu.edu/~wjh/go/rules/AGA.concise.html (AGA Concise Rules, правило 5)
|
||||||
|
|
||||||
|
> «It is illegal for a player to move so as to create a string of his or her
|
||||||
|
> own stones which is completely surrounded (without liberties) after any
|
||||||
|
> surrounded opposing stones are captured.»
|
||||||
|
> Источник 2: https://senseis.xmp.net/?TwoEyes
|
||||||
|
> «A white stone placed on either of them would be disconnected from the other
|
||||||
|
> white stones and would have no liberties… Such a move by White would thus be
|
||||||
|
> illegal.»
|
||||||
|
|
||||||
|
### 4.3. Два отдельных глаза — группа неуязвима («вечная жизнь»)
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://senseis.xmp.net/?TwoEyes
|
||||||
|
|
||||||
|
> «To be safe from capture, a group of stones must in general have two eyes…
|
||||||
|
> if a group has at least two separate liberties which cannot be removed, the
|
||||||
|
> group cannot be killed and is alive.»
|
||||||
|
> «Neither of the two circled points can be occupied by White without the
|
||||||
|
> other… it is impossible to remove the black group from the board (without
|
||||||
|
> Black filling one eye).»
|
||||||
|
|
||||||
|
### 4.4. Один большой глаз — ещё не жизнь; пространство нужно разделять на две независимые области
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://senseis.xmp.net/?TwoEyes
|
||||||
|
|
||||||
|
> «Note that sometimes a group may have a single eye with more than one point
|
||||||
|
> in it, but that does not necessarily make the group alive: _separate_ eyes
|
||||||
|
> are required.»
|
||||||
|
|
||||||
|
### 4.5. Ложный глаз: камни вокруг него не связаны (касаются по диагонали), их можно поставить в атари и занять «глаз»
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://senseis.xmp.net/?FalseEye
|
||||||
|
|
||||||
|
> «A false eye is something which looks like an eye, but shouldn't be counted
|
||||||
|
> as part of the group's eye space because some of the surrounding stones can
|
||||||
|
> be put into atari.»
|
||||||
|
> «Notice that the marked black stones have an incomplete connection with the
|
||||||
|
> rest of the group… If White fills up the exterior liberties… the three black
|
||||||
|
> stones are in atari. If Black connects at a, the "eye" disappears.»
|
||||||
|
|
||||||
|
### 4.6. Решение задачи жизни и смерти — жизненная точка (vital point)
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://senseis.xmp.net/?VitalPoint
|
||||||
|
|
||||||
|
> «Vital point can refer to the key point in the context of a life and death
|
||||||
|
> problem. This has been traditionally called a nakade, while "vital point" is
|
||||||
|
> aiming to translate the Japanese term "kyuusho".»
|
||||||
|
|
||||||
|
### 4.7. «Ищите центр симметрии формы — чаще всего он и есть ответ»
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО (как пословица-эвристика, с оговоркой).**
|
||||||
|
Источник: https://senseis.xmp.net/?PlayOnThePointOfSymmetry
|
||||||
|
|
||||||
|
> «When right and left have the same shape, there is a play in the center.»
|
||||||
|
> «Many symmetrical positions have asymmetrical solutions, so this proverb
|
||||||
|
> should only be taken as a suggestion for where to look.»
|
||||||
|
> Оговорка: источник подчёркивает, что пословица — лишь подсказка, а не правило;
|
||||||
|
> в уроке это смягчено словом «чаще всего», что допустимо.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Урок 05. Подсчёт и конец партии
|
||||||
|
|
||||||
|
### 5.1. Два паса подряд — конец партии
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://www.cs.cmu.edu/~wjh/go/rules/AGA.concise.html (правило 9)
|
||||||
|
|
||||||
|
> «Two consecutive passes normally signal the end of the game.»
|
||||||
|
> Источник 2: https://senseis.xmp.net/?Pass
|
||||||
|
> «If both players pass on their consecutive moves the game usually ends; and
|
||||||
|
> then scoring and counting starts.»
|
||||||
|
|
||||||
|
### 5.2. «Пасовать позже — тоже ошибка: ход в свою территорию просто уменьшает её»
|
||||||
|
|
||||||
|
**РАСХОЖДЕНИЕ (в рамках собственной системы правил курса).**
|
||||||
|
Курс объявляет китайские правила (см. 5.5), а при площадном подсчёте ход в свою
|
||||||
|
территорию счёт НЕ меняет; «−1 очко» верно только для японских правил.
|
||||||
|
Источник: https://senseis.xmp.net/?ChineseRules
|
||||||
|
|
||||||
|
> «Cost of moving in one's territory: 0 points»
|
||||||
|
> Источник 2: https://senseis.xmp.net/?JapaneseRules
|
||||||
|
> «Cost of moving in one's territory: 1 point»
|
||||||
|
> Как должно быть: «ход в свою территорию уменьшает её по японским правилам; по
|
||||||
|
> китайским (нашим) — счёт не меняется, но ход впустую тратит время/инициативу».
|
||||||
|
> В тексте урока это противоречит собственному разделу «Подсчёт по-китайски»,
|
||||||
|
> где верно сказано обратное.
|
||||||
|
|
||||||
|
### 5.3. Мёртвые камни убираются с доски по согласию; при несогласии партия продолжается (доигрывание)
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://www.cs.cmu.edu/~wjh/go/rules/AGA.concise.html (правила 9–10)
|
||||||
|
|
||||||
|
> «Any stones which the players agree could not escape capture if the game
|
||||||
|
> continued… are termed dead stones… they are removed from the board as
|
||||||
|
> prisoners… If there is a disagreement over the status of some group or
|
||||||
|
> groups, play is resumed as specified in Rule 10.»
|
||||||
|
> Источник 2: https://senseis.xmp.net/?AreaScoring
|
||||||
|
> «Practically, players can agree upon obviously dead stones and remove them.
|
||||||
|
> After playout or agreement, the score is counted.»
|
||||||
|
> Источник 3: https://senseis.xmp.net/?ChineseRules
|
||||||
|
> «Life and death settled by: Game resumption for online play.»
|
||||||
|
|
||||||
|
### 5.4. Территория — пустые пункты, окружённые живыми камнями одного цвета; каждый пункт — очко
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://www.cs.cmu.edu/~wjh/go/rules/AGA.concise.html (правило 12)
|
||||||
|
|
||||||
|
> «Those empty points on the board which are entirely surrounded by live
|
||||||
|
> stones of a single color are considered the territory of the player of that
|
||||||
|
> color.»
|
||||||
|
|
||||||
|
### 5.5. Китайский (площадной) подсчёт: очки = живые камни на доске + территория
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://senseis.xmp.net/?AreaScoring
|
||||||
|
|
||||||
|
> «In area scoring, your score is the sum of two components — the number of
|
||||||
|
> empty points only your stones surround and the number of your stones on the
|
||||||
|
> board… Area scoring is used in certain rulesets, notably AGA rules, Chinese
|
||||||
|
> Rules…»
|
||||||
|
> Источник 2: https://senseis.xmp.net/?ChineseRules
|
||||||
|
> «Scoring method: Area scoring»
|
||||||
|
|
||||||
|
### 5.6. Коми 6,5 очка на большой доске (при заявленных китайских правилах)
|
||||||
|
|
||||||
|
**РАСХОЖДЕНИЕ.**
|
||||||
|
Стандартное коми китайских правил — 7,5; 6,5 — стандарт японских/корейских
|
||||||
|
правил (территориальный подсчёт).
|
||||||
|
Источник: https://senseis.xmp.net/?ChineseRules
|
||||||
|
|
||||||
|
> «Komi: 7.5»
|
||||||
|
> Источник 2: https://senseis.xmp.net/?Komi
|
||||||
|
> «The usual komi in China, where area scoring is used, was formerly equal to
|
||||||
|
> the 5.5 used in Japan, but was changed to 7.5.»
|
||||||
|
> «Today, the standard komi in Japan, where territory scoring is used, is 6.5
|
||||||
|
> points, introduced September 2002.»
|
||||||
|
> Источник 3: https://senseis.xmp.net/?JapaneseRules
|
||||||
|
> «Komi: 6.5»
|
||||||
|
> Как должно быть: «коми 7,5 очка на большой доске (китайские правила)».
|
||||||
|
|
||||||
|
### 5.7. Коми 5,5 на доске 9×9
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО (как распространённая практика, не единый стандарт).**
|
||||||
|
Источник: https://senseis.xmp.net/?HandicapForSmallerBoardSizes
|
||||||
|
(«Old Japanese Recommendation», Исикура Нобору, журнал Igo Kurabu, ~1985)
|
||||||
|
|
||||||
|
> «Around 1985 there was an article in the Japanese magazine Igo Kurabu by
|
||||||
|
> Ishikura Noboru (now 9p) on komi and handicaps for both 9x9 and 13x13
|
||||||
|
> boards… Difference in strength 0: 9x9 — komi 5.5»
|
||||||
|
> Источник 2 (та же страница, AGA Handicaps 2004):
|
||||||
|
> «Rating Difference 0.0–0.5: 19x19 — 7.5 komi, 13x13 — 5.5 komi,
|
||||||
|
> 9x9 — 5.5 komi»
|
||||||
|
> Оговорка: единого официального стандарта для 9×9 нет (встречаются 6.5 и 7),
|
||||||
|
> но 5.5 — документированная общепринятая практика.
|
||||||
|
|
||||||
|
### 5.8. Пол-очка в коми исключает ничью
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://senseis.xmp.net/?Komi
|
||||||
|
|
||||||
|
> «To prevent a drawn game in the case of jigo, the komi is commonly set to a
|
||||||
|
> fractional value such as 6.5.»
|
||||||
|
|
||||||
|
### 5.9. По китайским правилам ход внутрь своей территории не меняет счёта, по японским — потерял бы очко
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://senseis.xmp.net/?ChineseRules
|
||||||
|
|
||||||
|
> «Cost of moving in one's territory: 0 points»
|
||||||
|
> Источник 2: https://senseis.xmp.net/?JapaneseRules
|
||||||
|
> «Cost of moving in one's territory: 1 point»
|
||||||
|
|
||||||
|
### 5.10. Арифметика примера (чёрные 50, белые 44 + коми 5,5 = 49,5; перевес 0,5)
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО (проверка вычислением).** 30+20=50; 28+16=44; 44+5,5=49,5;
|
||||||
|
50−49,5=0,5. Ошибок нет.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Урок 06. Связь и разрезание
|
||||||
|
|
||||||
|
### 6.1. «Соединяйте свои группы и разрезайте чужие» — главная тактическая идея Го
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://senseis.xmp.net/?Cut
|
||||||
|
|
||||||
|
> «Cutting and connecting is a central concept in Go. You are normally well
|
||||||
|
> advised to keep your own groups connected and cut your opponent's groups
|
||||||
|
> apart.»
|
||||||
|
|
||||||
|
### 6.2. Камни, стоящие вплотную по линии, связаны абсолютно — их нельзя разрезать
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://www.cs.cmu.edu/~wjh/go/rules/AGA.concise.html (правило 5)
|
||||||
|
|
||||||
|
> «Stones of the same color are said to be connected if they are adjacent
|
||||||
|
> along horizontal or vertical lines on the board… Two stones are part of the
|
||||||
|
> same string if they are linked by a chain of connected stones of the same
|
||||||
|
> color.»
|
||||||
|
> (Строка снимается целиком; «разрезать» смежные камни по линии невозможно —
|
||||||
|
> между ними нет пустого пункта.)
|
||||||
|
|
||||||
|
### 6.3. Иккэн (иккэн-тоби) — ход через один пункт (one-point jump)
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://senseis.xmp.net/?IkkenTobi (редирект на «One-point jump»)
|
||||||
|
|
||||||
|
> «Common in running fights, the one-point jump moves out quickly… but is
|
||||||
|
> potentially vulnerable to being cut depending on the surrounding position
|
||||||
|
> (see cutting the one-point jump).»
|
||||||
|
> Источник 2: https://senseis.xmp.net/?Moyo
|
||||||
|
> «…further extended with a one-point-jump (Ikken Tobi)…»
|
||||||
|
|
||||||
|
### 6.4. Связь через один пункт надёжна лишь условно — противник может попытаться вклиниться
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://senseis.xmp.net/?OneSpaceJump
|
||||||
|
|
||||||
|
> «…but is potentially vulnerable to being cut depending on the surrounding
|
||||||
|
> position…»
|
||||||
|
|
||||||
|
### 6.5. Косуми — диагональный ход
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://senseis.xmp.net/?Kosumi
|
||||||
|
|
||||||
|
> «Kosumi is a Japanese go term adopted into English. It is often translated
|
||||||
|
> as diagonal or diagonal move.»
|
||||||
|
|
||||||
|
### 6.6. «Диагональная "связь" (косуми) вообще не связь… противник может разрезать её первым»
|
||||||
|
|
||||||
|
**РАСХОЖДЕНИЕ (частичное).**
|
||||||
|
Верно, что косуми — не физическая связь. Но утверждение «противник может
|
||||||
|
разрезать её первым» как общее правило неверно: изолированное косуми при
|
||||||
|
попеременной игре локально НЕразрезаемо (миаи); разрезание возможно только при
|
||||||
|
подходящем окружении.
|
||||||
|
Источник: https://senseis.xmp.net/?Kosumi
|
||||||
|
|
||||||
|
> «The kosumi is not yet physically connected but with locally alternating
|
||||||
|
> play it can not be cut: if White tries to cut at a, Black plays at b and
|
||||||
|
> becomes connected and vice versa. These two points are prime examples of
|
||||||
|
> miai.»
|
||||||
|
> Источник 2: https://senseis.xmp.net/?Cut
|
||||||
|
> «In a narrow sense, a cut is a method of cutting off what appears to be a
|
||||||
|
> safe connection, such as kosumi, one-space jump or keima… the problem is to
|
||||||
|
> choose the method of cutting according to the surrounding conditions.»
|
||||||
|
> Как должно быть: «косуми — не физическая связь; само по себе оно неразрезаемо
|
||||||
|
> (миаи), но при сильных камнях противника вокруг его можно атаковать в
|
||||||
|
> уязвимые пункты».
|
||||||
|
|
||||||
|
### 6.7. «Захват разрезающего камня — связь восстановится сама собой»
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://senseis.xmp.net/?Cut
|
||||||
|
|
||||||
|
> «The Black stones will only be able to connect again if the cutting stone is
|
||||||
|
> captured.»
|
||||||
|
|
||||||
|
### 6.8. Разрезающий ход должен сам выжить
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://senseis.xmp.net/?Cut
|
||||||
|
|
||||||
|
> «It is worth noting that cutting stones are often themselves separated from
|
||||||
|
> their group, so if you are cutting two groups to capture one of them, you
|
||||||
|
> must make sure that your cutting stones will not get captured first.»
|
||||||
|
|
||||||
|
### 6.9. «Соединение снизу — соединиться по первой линии, под защитой края»
|
||||||
|
|
||||||
|
**НЕ НАЙДЕНО.**
|
||||||
|
Приём «connect underneath» существует (упоминается в литературе по тэсудзи),
|
||||||
|
но открыть авторитетную страницу с дословным подтверждением не удалось
|
||||||
|
(страница Sensei's Library по соединению снизу не открылась за несколько
|
||||||
|
попыток — таймауты).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Урок 07. Углы, стороны, центр
|
||||||
|
|
||||||
|
### 7.1. Край доски работает как готовая стена; порядок дебюта: углы → стороны → центр
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://senseis.xmp.net/?CornersThenSidesThenCenter
|
||||||
|
|
||||||
|
> «Making life, a base or territory is supported by a player's stones and the
|
||||||
|
> edges of a board.»
|
||||||
|
> «It takes only two walls of stones to surround territory in the corners,
|
||||||
|
> and three on the sides, compared to four in the center»
|
||||||
|
> (там же приведена пословица «Corner is gold, side is silver, center is
|
||||||
|
> grass»).
|
||||||
|
> Источник 2: https://senseis.xmp.net/?HighAndLowMoves
|
||||||
|
> «According to traditionally accepted views, the corners and sides of the
|
||||||
|
> board earn the most territory. The center is generally thought to earn the
|
||||||
|
> least.»
|
||||||
|
|
||||||
|
### 7.2. Числа: угол — 2–3 камня, сторона — 4–5, центр — 8–10 для ограждения одинаковой территории
|
||||||
|
|
||||||
|
**НЕ НАЙДЕНО (а каноническая количественная формулировка иная).**
|
||||||
|
Авторитетных источников с числами «2–3 / 4–5 / 8–10 камней» не найдено.
|
||||||
|
Sensei's Library приводит другие канонические числа: 2/3/4 стены (угол/сторона/
|
||||||
|
центр) и минимум 6/8/10 камней для жизни с двумя глазами.
|
||||||
|
Источник: https://senseis.xmp.net/?CornersThenSidesThenCenter
|
||||||
|
|
||||||
|
> «It takes only two walls of stones to surround territory in the corners,
|
||||||
|
> and three on the sides, compared to four in the center»
|
||||||
|
> «8 stones, 16 points — two eyes requires at least 6 stones [угол];
|
||||||
|
> 8 stones, 8 points — 2 eyes requires at least 8 stones [сторона];
|
||||||
|
> 8 stones, 4 points — 2 eyes requires at least 10 stones [центр]»
|
||||||
|
> Рекомендация: заменить числа на канонические («две стены в углу, три на
|
||||||
|
> стороне, четыре в центре»; «для жизни минимум 6/8/10 камней»).
|
||||||
|
|
||||||
|
### 7.3. Первая линия: камень на ней имеет три дамэ; в дебюте туда играть не стоит
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО (по сути); термин «линия смерти» — НЕ НАЙДЕНО.**
|
||||||
|
Про три дамэ: https://www.cs.cmu.edu/~wjh/go/rules/AGA.concise.html (правило 5)
|
||||||
|
|
||||||
|
> «A single stone on a side intersection has a maximum of three liberties; a
|
||||||
|
> single stone in the corner has a maximum of two liberties.»
|
||||||
|
> Про нежелательность первой линии: https://senseis.xmp.net/?KnowYourLines
|
||||||
|
> «The first line is the edge of the board. Playing unsupported stones on the
|
||||||
|
> first line is a typical novice's mistake. Moves on the first line are common
|
||||||
|
> in the endgame, and in life and death.»
|
||||||
|
> Термин «линия смерти» (line of death) в открытых авторитетных источниках не
|
||||||
|
> обнаружен: Sensei's Library именует «линией поражения» вторую линию, первой
|
||||||
|
> линии специального названия не даёт. Термин распространён в русскоязычной
|
||||||
|
> педагогике, но источником не подтверждён.
|
||||||
|
|
||||||
|
### 7.4. Вторая линия — «линия поражения»
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://senseis.xmp.net/?KnowYourLines
|
||||||
|
|
||||||
|
> «Crawling along the second line to make territory is inefficient, therefore
|
||||||
|
> it is also called "the line of defeat".»
|
||||||
|
|
||||||
|
### 7.5. Третья линия — «линия территории»
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://senseis.xmp.net/?ThirdLine
|
||||||
|
|
||||||
|
> «On a 19x19 goban, it is said that the third line is the line of territory.»
|
||||||
|
> Источник 2: https://senseis.xmp.net/?KnowYourLines
|
||||||
|
> «Territorial moves are on the third line, therefore the third line is also
|
||||||
|
> called the "line of territory".»
|
||||||
|
|
||||||
|
### 7.6. Четвёртая линия — «линия влияния»
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://senseis.xmp.net/?FourthLine
|
||||||
|
|
||||||
|
> «On a 19x19 goban, it is said that the fourth line is the line of
|
||||||
|
> influence.»
|
||||||
|
|
||||||
|
### 7.7. Классический баланс дебюта — игра на третьей и четвёртой линиях
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://senseis.xmp.net/?HighAndLowMoves
|
||||||
|
|
||||||
|
> «The concept of High and Low moves is fundamental to Go Strategy. With high
|
||||||
|
> we usually refer to the fourth line, while low refers to the third line.
|
||||||
|
> The proverb says: "High move (4th line) for influence, low move (3rd line)
|
||||||
|
> for territory".»
|
||||||
|
|
||||||
|
### 7.8. 3-3 (сан-сан): надёжно забирает угол, но низко, мало влияния
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://senseis.xmp.net/?SanSan
|
||||||
|
|
||||||
|
> «The 3-3 point, on the third line both horizontally and vertically, is a
|
||||||
|
> good point, in that it makes territory in the corner, but it is still quite
|
||||||
|
> low and makes for a slow development.»
|
||||||
|
> «…a play on the 3-3 point in itself can be considered as decisive: no
|
||||||
|
> immediate further play is required to settle the corner.»
|
||||||
|
|
||||||
|
### 7.9. 3-4 (комоку): самый популярный первый ход; угол почти ваш, остаётся гибкость
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО (с исторической оговоркой).**
|
||||||
|
Источник: https://senseis.xmp.net/?Komoku
|
||||||
|
|
||||||
|
> «Like the 3-3 point, the 3-4 point (komoku) more or less secures the
|
||||||
|
> corner. At the same time, it also starts taking a look outward.»
|
||||||
|
> «The 3-4 point has a very rich history in Go literature as it was the most
|
||||||
|
> common opening play in the corner throughout the classical and modern
|
||||||
|
> periods of Go in Japan, up to about 1975.»
|
||||||
|
> Оговорка: «самый популярный» верно для классического и значительной части
|
||||||
|
> современного периода; в эпоху ИИ (после 2016) первые ходы 4-4 и 3-3 не менее
|
||||||
|
> частотны. Для курса новичков формулировка допустима.
|
||||||
|
|
||||||
|
### 7.10. 4-4 (хоси): звёздный пункт; угол можно оспорить вторжением 3-3; камень сильнее тянется к центру
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://senseis.xmp.net/?Hoshi
|
||||||
|
|
||||||
|
> «Star points (J. hoshi, lit. "star") are the nine points on a 19x19 go
|
||||||
|
> board marked by small dots… the 4-4 point (corner star)…»
|
||||||
|
> Источник 2: https://senseis.xmp.net/?44Point33Invasion
|
||||||
|
> «The 4-4 Point 3-3 invasion is a common and popular joseki… The 3-3 point
|
||||||
|
> is the vital point of the corner, and it is easy for the invader to live
|
||||||
|
> underneath with a corner territory worth ~10 points.»
|
||||||
|
> Источник 3: https://senseis.xmp.net/?Takamoku (сравнение)
|
||||||
|
> «As an opening play, the 4-5 point is more center-oriented than the 3-5
|
||||||
|
> point, and less corner-oriented than the 4-4 point.»
|
||||||
|
|
||||||
|
### 7.11. 3-5 (мокухадзуси) — уклон в сторону; 4-5 (такамоку) — влияние; оба реже
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://senseis.xmp.net/?MokuHazushi
|
||||||
|
|
||||||
|
> «The 3-5 or 5-3 point is a classical first play in the corner. It is not a
|
||||||
|
> common move in modern top-level games… this move is more directed to the
|
||||||
|
> side.»
|
||||||
|
> Источник 2: https://senseis.xmp.net/?Takamoku
|
||||||
|
> «…it does little to protect the corner. The compensation of course is that
|
||||||
|
> it is strong towards the center… he will go for influence and large
|
||||||
|
> frameworks…»
|
||||||
|
> «…has dropped out of fashion with most modern players.»
|
||||||
|
|
||||||
|
### 7.12. Мойо — большие рамки (потенциальная территория)
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://senseis.xmp.net/?Moyo
|
||||||
|
|
||||||
|
> «A framework, or moyo, is an area where one player has a large influence,
|
||||||
|
> and which potentially could become that player's territory.»
|
||||||
|
|
||||||
|
### 7.13. «Центр — это сила, а не земля» (центр не даёт много территории напрямую)
|
||||||
|
|
||||||
|
**ПОДТВЕРЖДЕНО.**
|
||||||
|
Источник: https://senseis.xmp.net/?HighAndLowMoves
|
||||||
|
|
||||||
|
> «According to traditionally accepted views, the corners and sides of the
|
||||||
|
> board earn the most territory. The center is generally thought to earn the
|
||||||
|
> least.»
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итоговая статистика
|
||||||
|
|
||||||
|
- ПОДТВЕРЖДЕНО: 28 утверждений
|
||||||
|
- РАСХОЖДЕНИЕ: 3 утверждения (5.2, 5.6, 6.6)
|
||||||
|
- НЕ НАЙДЕНО: 3 утверждения (6.9, 7.2, термин «линия смерти» в 7.3)
|
||||||
|
|
||||||
|
### Список расхождений
|
||||||
|
|
||||||
|
1. **Урок 05, «ход в свою территорию уменьшает её»** — неверно для заявленных
|
||||||
|
китайских правил (cost = 0), верно только для японских (cost = 1).
|
||||||
|
Внутренне противоречит разделу «Подсчёт по-китайски» того же урока.
|
||||||
|
2. **Урок 05, коми 6,5 на большой доске** — по китайским правилам стандарт 7,5
|
||||||
|
(6,5 — японские/корейские правила).
|
||||||
|
3. **Урок 06, «косуми… противник может разрезать её первым»** — изолированное
|
||||||
|
косуми локально неразрезаемо (миаи); разрезание зависит от окружения.
|
||||||
|
|
||||||
|
### Список «не найдено»
|
||||||
|
|
||||||
|
1. **Урок 06, «соединение снизу по первой линии»** — не удалось открыть
|
||||||
|
авторитетную страницу (таймауты SL).
|
||||||
|
2. **Урок 07, числа «угол 2–3 / сторона 4–5 / центр 8–10 камней»** — источник
|
||||||
|
не найден; каноническая версия SL: 2/3/4 стены и минимум 6/8/10 камней для
|
||||||
|
жизни.
|
||||||
|
3. **Урок 07, термин «линия смерти» для первой линии** — в открытых
|
||||||
|
авторитетных источниках не подтверждён (SL называет «линией поражения»
|
||||||
|
вторую линию).
|
||||||
426
docs/audit/08-10.md
Normal file
426
docs/audit/08-10.md
Normal file
|
|
@ -0,0 +1,426 @@
|
||||||
|
# Аудит уроков 08–10
|
||||||
|
|
||||||
|
Проверены файлы:
|
||||||
|
|
||||||
|
- `apps/web/src/content/lessons/08-bazovye-dzyoseki.md`
|
||||||
|
- `apps/web/src/content/lessons/09-forma.md`
|
||||||
|
- `apps/web/src/content/lessons/10-prostoe-yose.md`
|
||||||
|
|
||||||
|
Метод: каждое фактическое утверждение сверено с авторитетными источниками
|
||||||
|
(Sensei's Library — senseis.xmp.net). Все процитированные страницы реально
|
||||||
|
открыты инструментом web_open_url; исключение помечено явно (страница
|
||||||
|
?33Point многократно не отвечала на web_open_url — таймауты — и была
|
||||||
|
прочитана прямым HTTP-запросом; цитата приведена из фактически полученного
|
||||||
|
текста страницы). gobase.org на момент аудита был недоступен (соединение не
|
||||||
|
устанавливается), поэтому дзёсэки подтверждались по Sensei's Library.
|
||||||
|
|
||||||
|
Вердикты: ПОДТВЕРЖДЕНО | НЕ НАЙДЕНО | РАСХОЖДЕНИЕ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Урок 08. Базовые дзёсэки
|
||||||
|
|
||||||
|
### 8.1. Определение дзёсэки
|
||||||
|
|
||||||
|
**Утверждение:** «Дзёсэки — устоявшаяся "правильная" последовательность
|
||||||
|
ходов в углу... Итог дзёсэки считается равным (или почти равным) для обеих
|
||||||
|
сторон: один получает угол, другой — стену или инициативу».
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
|
||||||
|
Источник: https://senseis.xmp.net/?Joseki
|
||||||
|
|
||||||
|
> "Joseki ... usually referring to standard sequences of moves played out in a
|
||||||
|
> corner that result in a locally even exchange."
|
||||||
|
|
||||||
|
Там же подтверждается размен «угол против влияния/инициативы»:
|
||||||
|
|
||||||
|
> "Proper evaluation of joseki considers the trade-offs between: sente and
|
||||||
|
> gote, territory and influence, speed and solidity..."
|
||||||
|
|
||||||
|
### 8.2. Дзёсэки 1: занятие угла 3-3
|
||||||
|
|
||||||
|
**Утверждение:** «Простейший способ забрать угол — поставить камень прямо на
|
||||||
|
пункт 3-3... Чёрный камень на C3 почти неуязвим: у него сразу задел на
|
||||||
|
угол... белые обычно давят снаружи, а чёрные ползут по второй-третьей линии,
|
||||||
|
выстраивая жизнь. Идея: угол — мой, влияние — ваше».
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
|
||||||
|
Источник: https://senseis.xmp.net/?33Point (страница прочитана прямым
|
||||||
|
HTTP-запросом; web_open_url на неё стабильно отвечал таймаутом)
|
||||||
|
|
||||||
|
> "The 3-3 point ... makes territory in the corner, but it is still quite low
|
||||||
|
> and makes for a slow development. However, a play on the 3-3 point in itself
|
||||||
|
> can be considered as decisive: no immediate further play is required to
|
||||||
|
> settle the corner. ... For White, the shoulder hit at e, taking influence in
|
||||||
|
> the centre, is quite common"
|
||||||
|
|
||||||
|
Размен «земля против влияния» для пункта 3-3 подтверждён также здесь:
|
||||||
|
Источник: https://senseis.xmp.net/?44PointJosekis
|
||||||
|
|
||||||
|
> "The 3-3 invasion emphasizes solid territory in exchange for a significant
|
||||||
|
> loss of influence."
|
||||||
|
|
||||||
|
### 8.3. Дзёсэки 2: «ханэ у подошвы 3-4»
|
||||||
|
|
||||||
|
**Утверждение:** «Чёрные заняли угол ходом 3-4, белые подошли. Классический
|
||||||
|
ответ — ханэ (ход вокруг вражеского камня с изгибом), затем спокойное
|
||||||
|
отступление... Чёрные укрепляют угол, белые получают толщину снаружи.
|
||||||
|
Отступление — не трусость, а способ сохранить форму без разрезов».
|
||||||
|
|
||||||
|
**Вердикт: РАСХОЖДЕНИЕ (атрибуция хода «ханэ»).**
|
||||||
|
|
||||||
|
Определение ханэ в уроке верно — Источник: https://senseis.xmp.net/?Hane
|
||||||
|
|
||||||
|
> "A hane is a move which 'reaches around' one or more of the opponent's
|
||||||
|
> stones."
|
||||||
|
|
||||||
|
Однако классический базовый дзёсэки для 3-4 с описанным итогом («чёрные
|
||||||
|
укрепляют угол, отступление сохраняет форму») — это цукэ-хики
|
||||||
|
(прижатие-отступление), где **ханэ играет атакующая сторона (белые), а не
|
||||||
|
чёрные**. Ответ чёрных на подход — контактный ход (цукэ), а не ханэ.
|
||||||
|
Источник: https://senseis.xmp.net/?34PointHighApproachAttachDrawbackJoseki
|
||||||
|
|
||||||
|
> "The 3-4 point high approach, attach-drawback joseki (Tsuke-Hiki) is one of
|
||||||
|
> the most popular 3-4 point joseki, and it one of the first joseki learned by
|
||||||
|
> beginners. Drawing back (hiki) is solid shape and secures the corner
|
||||||
|
> territory for Black."
|
||||||
|
|
||||||
|
Как должно быть: «белые подошли; чёрные прижимаются (цукэ), белые играют
|
||||||
|
ханэ, чёрные спокойно отступают (хики)». Порядок «ханэ → отступление»
|
||||||
|
существует, но ханэ принадлежит белым. Итоговая оценка позиции в уроке
|
||||||
|
(«чёрные укрепляют угол, белые получают внешнюю позицию») цукэ-хики
|
||||||
|
соответствует — эта часть верна. Дополнительно: в диаграмме урока чёрный
|
||||||
|
камень стоит непосредственно «над» белым (C2 над C3), что геометрически не
|
||||||
|
является ханэ (ханэ — изгиб по диагонали); диаграмма скорее изображает
|
||||||
|
прижатие.
|
||||||
|
|
||||||
|
### 8.4. Дзёсэки 3: вторжение 3-3 под хоси (4-4)
|
||||||
|
|
||||||
|
**Утверждение:** «Если соперник начал с 4-4 (хоси), угол ещё не закрыт —
|
||||||
|
вторгайтесь на 3-3. Дальше развивается типовая борьба: хозяин хоси давит с
|
||||||
|
двух сторон, вторгшийся ползёт по краю и живёт маленькой группой. Хозяин
|
||||||
|
строит внешнюю стену. Итог: земля против влияния, равенство».
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
|
||||||
|
Источник: https://senseis.xmp.net/?44PointJosekis
|
||||||
|
|
||||||
|
> "The 4-4 point (aka hoshi or 'star point') ... Compared with the 3-4 point
|
||||||
|
> or 3-3 point, the corner can still be invaded and the territory is not
|
||||||
|
> secure."
|
||||||
|
|
||||||
|
Источник: https://senseis.xmp.net/?44Point33InvasionJoseki
|
||||||
|
|
||||||
|
> "The 4-4 Point 3-3 invasion is a common and popular joseki ... it emphasizes
|
||||||
|
> territory at the expense of influence. The 3-3 point is the vital point of
|
||||||
|
> the corner, and it is easy for the invader to live underneath with a corner
|
||||||
|
> territory worth ~10 points."
|
||||||
|
|
||||||
|
Построение внешней стены хозяином хоси (традиционный вариант):
|
||||||
|
|
||||||
|
> "Historically, it was common and intuitive for Black to make a wall during
|
||||||
|
> the 3-3 invasion."
|
||||||
|
|
||||||
|
### 8.5. Дзёсэки обновляются; ИИ изменил оценки
|
||||||
|
|
||||||
|
**Утверждение:** «дзёсэки обновляются: профессионалы постоянно находят новые
|
||||||
|
варианты, а с появлением сильных программ многие оценки изменились».
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
|
||||||
|
Источник: https://senseis.xmp.net/?Joseki
|
||||||
|
|
||||||
|
> "Which sequences are considered playable joseki has changed throughout
|
||||||
|
> history, owing to the effects of rule changes (most notably komi), continued
|
||||||
|
> research from professional players, and, most recently, from artificial
|
||||||
|
> intelligence analysis."
|
||||||
|
|
||||||
|
### 8.6. Поправка на доску 9×9
|
||||||
|
|
||||||
|
**Утверждение:** «на 9×9 влияние центра сильнее, чем на 19×19, и многие
|
||||||
|
классические дзёсэки там ведут себя иначе».
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО (по совокупности; дословной формулировки «влияние
|
||||||
|
центра сильнее» в источнике нет).**
|
||||||
|
|
||||||
|
Источник: https://senseis.xmp.net/?9x9Openings (прочитана прямым
|
||||||
|
HTTP-запросом; web_open_url дал таймаут)
|
||||||
|
|
||||||
|
> "The 5-5 point seems to be the most popular opening." (на 9×9; на 19×19
|
||||||
|
> первый ход в тэнгэн/5-5 — редкость, что и показывает повышенную роль центра)
|
||||||
|
|
||||||
|
Источник: https://senseis.xmp.net/?9x9Strategy
|
||||||
|
|
||||||
|
> "You will note that this violates the principle which says Don't Attach When
|
||||||
|
> Attacking, marking the special nature of 9×9 Go."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Урок 09. Форма
|
||||||
|
|
||||||
|
### 9.1. Тигровая пасть
|
||||||
|
|
||||||
|
**Утверждение:** «Тигровая пасть (кошачья пасть) — три камня вокруг пустого
|
||||||
|
пункта... если противник вклинится туда, вы ответите и соединитесь
|
||||||
|
окончательно. Пасть защищает разрез одним камнем».
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
|
||||||
|
Источник: https://senseis.xmp.net/?TigersMouth
|
||||||
|
|
||||||
|
> "This is the reference form of a tiger's mouth, that is formed by two
|
||||||
|
> diagonal moves in different directions. ... The tiger's mouth is most often
|
||||||
|
> used to make a connection known as a hanging connection."
|
||||||
|
|
||||||
|
Японское имя формы на той же странице — 猫の顔 (нэко но као, «кошачья
|
||||||
|
морда»), что оправдывает вариант «кошачья пасть».
|
||||||
|
|
||||||
|
Замечание к иллюстрации (внутренняя несогласованность, не источниковый факт):
|
||||||
|
в ASCII-диаграмме урока нарисованы только два камня (B1 и A2), а в тексте
|
||||||
|
описаны «три камня вокруг пустого пункта» — диаграмму следует дополнить
|
||||||
|
третьим камнем.
|
||||||
|
|
||||||
|
### 9.2. Бамбуковый стык: форма и неразрезаемость
|
||||||
|
|
||||||
|
**Утверждение:** «Бамбук... — два камня вплотную и ещё два через пункт...
|
||||||
|
разрезать бамбук почти невозможно, а тянется он быстро».
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
|
||||||
|
Источник: https://senseis.xmp.net/?BambooJoint
|
||||||
|
|
||||||
|
> "The name, like the 'knuckle' on a stick of bamboo, comes from the strength
|
||||||
|
> of the connection. It is normally impossible to cut through it."
|
||||||
|
> "Why is bamboo joint good? - It is hardly cuttable (but not impossible)"
|
||||||
|
|
||||||
|
Замечание к иллюстрации: в диаграмме урока показана только одна пара камней
|
||||||
|
(B B), вторая пара «через пункт» не нарисована — не соответствует ни
|
||||||
|
собственному тексту урока, ни эталонной форме.
|
||||||
|
|
||||||
|
### 9.3. Бамбуковый стык: японское название «танэки»
|
||||||
|
|
||||||
|
**Утверждение:** «Бамбук (танэки)».
|
||||||
|
|
||||||
|
**Вердикт: РАСХОЖДЕНИЕ.**
|
||||||
|
|
||||||
|
Источник: https://senseis.xmp.net/?BambooJoint (прочитана прямым
|
||||||
|
HTTP-запросом; web_open_url дал таймаут)
|
||||||
|
|
||||||
|
> "Japanese: タケフ (takefu), 二丁ツギ (nichou-tsugi)"
|
||||||
|
|
||||||
|
Как должно быть: японское название бамбукового стыка — «такэфу» (takefu).
|
||||||
|
Термина «танэки» в го-терминологии не существует (поиск «taneki» по
|
||||||
|
источникам результатов не дал). Вероятна опечатка/искажение.
|
||||||
|
|
||||||
|
### 9.4. Вытягивание (ноби)
|
||||||
|
|
||||||
|
**Утверждение:** «Вытягивание (ноби) — простой ход вперёд от своего камня.
|
||||||
|
Скромный, но самый надёжный способ добавить дамы и не создать слабостей».
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
|
||||||
|
Источник: https://senseis.xmp.net/?Nobi
|
||||||
|
|
||||||
|
> "A play directly next to another stone of the same color ... The nobi
|
||||||
|
> advances your stones and creates more liberties for them. ... a nobi is more
|
||||||
|
> solid than alternatives to moving ahead ... since by definition it cannot be
|
||||||
|
> cut"
|
||||||
|
|
||||||
|
### 9.5. Пустой треугольник
|
||||||
|
|
||||||
|
**Утверждение:** «Пустой треугольник — три своих камня треугольником вокруг
|
||||||
|
пустого пункта, который ничего не защищает... Три камня делают работу двух...
|
||||||
|
Поговорка гласит: "пустой треугольник — не ставь никогда"».
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО (с оговоркой источника).**
|
||||||
|
|
||||||
|
Источник: https://senseis.xmp.net/?EmptyTriangle
|
||||||
|
|
||||||
|
> "Most people like to say that the empty triangle is bad, being inefficient
|
||||||
|
> and prone to shortage of liberties."
|
||||||
|
|
||||||
|
Оговорка: тот же источник честно отмечает, что существуют ситуации, где
|
||||||
|
пустой треугольник — лучший ход ("good empty triangle"), поэтому абсолютная
|
||||||
|
формулировка поговорки — педагогическое упрощение, а не закон.
|
||||||
|
|
||||||
|
### 9.6. Столб (данго)
|
||||||
|
|
||||||
|
**Утверждение:** «Столб (данго) — камни, выстроенные в сплошную линию без
|
||||||
|
глаз и без выхода: тяжёлая масса, которую легко атаковать».
|
||||||
|
|
||||||
|
**Вердикт: РАСХОЖДЕНИЕ (описание формы и перевод).**
|
||||||
|
|
||||||
|
Источник: https://senseis.xmp.net/?Dango
|
||||||
|
|
||||||
|
> "Dango is a Japanese go term, meaning 'dumpling shape,' that refers to a
|
||||||
|
> solid mass of stones without eyes and few liberties — a very inefficient
|
||||||
|
> shape. ... This clump of four black stones is the smallest dango."
|
||||||
|
|
||||||
|
Как должно быть: данго — это «пельменная форма», то есть бесформенный
|
||||||
|
ком/куча камней (без глаз, с малым числом дамэ), а не «линия/столб». Суть
|
||||||
|
урока (тяжёлая, неэффективная, уязвимая масса без глаз) верна, но «сплошная
|
||||||
|
линия» — неточное описание: вытянутая линия камней сама по себе данго не
|
||||||
|
является, а перевод «столб» не соответствует значению термина («пельмень»).
|
||||||
|
|
||||||
|
### 9.7. Перегруппировка
|
||||||
|
|
||||||
|
**Утверждение:** «Перегруппировка — слишком много камней в малой области: они
|
||||||
|
мешают друг другу строить глаза и дают противнику лишние точки атаки».
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
|
||||||
|
Источник: https://senseis.xmp.net/?Korigatachi
|
||||||
|
|
||||||
|
> "Overconcentrated shape means too many stones (of one colour) in one part of
|
||||||
|
> the board for the work they do; so shape that is inefficient because it's
|
||||||
|
> too solid."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Урок 10. Простое ёсэ
|
||||||
|
|
||||||
|
### 10.1. Определение ёсэ
|
||||||
|
|
||||||
|
**Утверждение:** «Ёсэ — эндшпиль: стадия, когда большие территории уже
|
||||||
|
очерчены и игроки уточняют границы... Пропустить пару ходов ёсэ — значит
|
||||||
|
проиграть выигранную партию».
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
|
||||||
|
Источник: https://senseis.xmp.net/?Yose
|
||||||
|
|
||||||
|
> "Yose ... are moves that approach fairly stable territory, typically
|
||||||
|
> enlarging one's own territory while reducing the opponent's. ... both in
|
||||||
|
> Japan and the West yose is at times used as a synonym for endgame."
|
||||||
|
|
||||||
|
### 10.2. Ценность ходов ёсэ «по одному-три очка»
|
||||||
|
|
||||||
|
**Утверждение:** «Ходы в ёсэ приносят по одному-три очка».
|
||||||
|
|
||||||
|
**Вердикт: НЕ НАЙДЕНО (для конкретного диапазона).**
|
||||||
|
|
||||||
|
Общий смысл («ходы ёсэ мелкие») подтверждается: Источник:
|
||||||
|
https://senseis.xmp.net/?Yose
|
||||||
|
|
||||||
|
> "Such plays are usually not as large as opening plays or middlegame plays
|
||||||
|
> affecting the life and death of large groups"
|
||||||
|
|
||||||
|
Однако конкретный диапазон «1–3 очка» ни один открытый источник дословно не
|
||||||
|
подтверждает (на https://senseis.xmp.net/?PracticalApproachToYose приведён
|
||||||
|
пример комбинации ханэ стоимостью 10 очков по дэири:
|
||||||
|
|
||||||
|
> "2 * (3 for second line hane + 2 for first line hane) = 10 points"),
|
||||||
|
> так что диапазон стоит считать неточным упрощением: крупные ёсэ-ходы бывают
|
||||||
|
> заметно дороже 3 очков.
|
||||||
|
|
||||||
|
### 10.3. Ханэ на первой линии как приём ёсэ
|
||||||
|
|
||||||
|
**Утверждение:** «Самый частый приём ёсэ — ханэ у края: ход, изгибающийся
|
||||||
|
вокруг камня противника на первой линии... Ханэ отодвигает границу на один
|
||||||
|
пункт в вашу пользу».
|
||||||
|
|
||||||
|
**Вердикт: техника ПОДТВЕРЖДЕНА; суперлатив «самый частый» — НЕ НАЙДЕНО.**
|
||||||
|
|
||||||
|
Источник: https://senseis.xmp.net/?HaneDescend
|
||||||
|
|
||||||
|
> "... is a hane-descend to the first line in the endgame."
|
||||||
|
|
||||||
|
Источник: https://senseis.xmp.net/?PracticalApproachToYose
|
||||||
|
|
||||||
|
> "Plays on the higher lines are larger. So play on the 3rd line first, 2nd
|
||||||
|
> line next, and 1st line last." (ханэ на первой/второй линии — типовые
|
||||||
|
> компоненты подсчёта ценности в ёсэ)
|
||||||
|
|
||||||
|
Утверждение о том, что ханэ первой линии — именно «самый частый» приём ёсэ,
|
||||||
|
источниками не подтверждено.
|
||||||
|
|
||||||
|
### 10.4. Сэнтэ
|
||||||
|
|
||||||
|
**Утверждение:** «Сэнтэ — ход, требующий ответа: после него инициатива
|
||||||
|
остаётся у вас».
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
|
||||||
|
Источник: https://senseis.xmp.net/?Sente
|
||||||
|
|
||||||
|
> "A move is sente if the opponent has to answer it or suffer losses, so the
|
||||||
|
> player who plays it will have sente after the exchange."
|
||||||
|
|
||||||
|
### 10.5. Готэ
|
||||||
|
|
||||||
|
**Утверждение:** «Готэ — ход, на который можно не отвечать: инициатива
|
||||||
|
переходит сопернику».
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО.**
|
||||||
|
|
||||||
|
Источник: https://senseis.xmp.net/?Gote
|
||||||
|
|
||||||
|
> "a move which loses the initiative, since it need not be answered by the
|
||||||
|
> opponent, thus giving him sente"
|
||||||
|
|
||||||
|
### 10.6. «Сначала сэнтэ, потом готэ»
|
||||||
|
|
||||||
|
**Утверждение:** «Правило ёсэ: сначала играйте сэнтэ-ходы, потом готэ.
|
||||||
|
Сэнтэ бесплатен... Готэ-ход "покупает" пункты ценой инициативы — берите его,
|
||||||
|
когда сэнтэ кончились».
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО (как эвристика; источник добавляет оговорку).**
|
||||||
|
|
||||||
|
Источник: https://senseis.xmp.net/?Gote
|
||||||
|
|
||||||
|
> "non-gote moves are to be preferred to gote moves, playing a gote move is
|
||||||
|
> not necessarily a mistake. Informally, a gote move could be the best move if
|
||||||
|
> its value is more than twice that of any available sente sequence."
|
||||||
|
|
||||||
|
### 10.7. Оценка хода через разницу (свинг)
|
||||||
|
|
||||||
|
**Утверждение:** «Если ваш ход даёт +2 очка вам, а ход соперника там же дал
|
||||||
|
бы +2 ему, ценность пункта — 4 очка. Берите самые дорогие пункты первыми».
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО (это дэири-подсчёт).**
|
||||||
|
|
||||||
|
Источник: https://senseis.xmp.net/?DeiriCounting
|
||||||
|
|
||||||
|
> "the deiri value is defined as the swing, i.e. the difference in the counts
|
||||||
|
> of the positions that result when Black or White plays first."
|
||||||
|
> "If Black plays first he scores four points ... If White plays first she
|
||||||
|
> scores two points ... The deiri value is the difference between these two
|
||||||
|
> results (the 'swing'), or 6 points."
|
||||||
|
> "Normally you should play the largest play"
|
||||||
|
|
||||||
|
### 10.8. Дамэ — нейтральные пункты
|
||||||
|
|
||||||
|
**Утверждение:** «Когда останутся только одноочковые размены (дамы —
|
||||||
|
нейтральные пункты), партия близка к концу».
|
||||||
|
|
||||||
|
**Вердикт: ПОДТВЕРЖДЕНО (терминология; см. уточнение).**
|
||||||
|
|
||||||
|
Источник: https://senseis.xmp.net/?Dame
|
||||||
|
|
||||||
|
> "In the endgame, empty points on the board which are not part of either
|
||||||
|
> player's territory and have no prospects of becoming territory. Normally,
|
||||||
|
> remaining neutral points are filled in at the end of the game by alternation
|
||||||
|
> between the players."
|
||||||
|
|
||||||
|
Уточнение: при японском подсчёте нейтральные дамэ вообще не приносят очков
|
||||||
|
(«одноочковые размены» верно лишь при подсчёте по площади); в уроке это
|
||||||
|
допустимое упрощение для начинающих.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сводная статистика
|
||||||
|
|
||||||
|
| Урок | Утверждений | ПОДТВЕРЖДЕНО | НЕ НАЙДЕНО | РАСХОЖДЕНИЕ |
|
||||||
|
| --------- | ----------- | ------------------------------------------------------ | ---------- | ----------- |
|
||||||
|
| 08 | 7 | 6 (одно — по совокупности, без дословной формулировки) | 0 | 1 |
|
||||||
|
| 09 | 7 | 5 | 0 | 2 |
|
||||||
|
| 10 | 9 | 7 | 2 | 0 |
|
||||||
|
| **Итого** | **23** | **18** | **2** | **3** |
|
||||||
|
|
||||||
|
Дополнительно (не входят в статистику, т.к. это внутренние
|
||||||
|
несогласованности иллюстраций, а не источниковые факты):
|
||||||
|
|
||||||
|
- урок 09, «тигровая пасть»: в диаграмме 2 камня вместо описанных трёх;
|
||||||
|
- урок 09, «бамбук»: в диаграмме показана одна пара камней вместо двух пар.
|
||||||
89
docs/decisions/ADR-0001-triage-litany.md
Normal file
89
docs/decisions/ADR-0001-triage-litany.md
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
# ADR-0001. Триаж prestart-litany для профиля M
|
||||||
|
|
||||||
|
- Дата: 2026-08-05
|
||||||
|
- Статус: принят
|
||||||
|
- Ссылки на литанию — слагами/названиями пунктов, не номерами (`gotcha-renumbered-parent`).
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Проект «Сайт обучения Го»: профиль **M** (продукт без своего прода) — код, тесты,
|
||||||
|
релизы, но нет своего сервера/БД (до опциональных этапов 8–9). Полностью клиентский:
|
||||||
|
статическая сборка на шаред-хостинг, движок и ИИ — в браузере. Один разработчик,
|
||||||
|
сессии ведут разные ИИ-модели без общей памяти.
|
||||||
|
|
||||||
|
## Триаж
|
||||||
|
|
||||||
|
| Пункт литании | Вердикт | Причина / условие |
|
||||||
|
| ---------------------------------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 0.2 Минимальное ядро (все 9 пунктов) | IN | Действует всегда, для любого профиля |
|
||||||
|
| 0.3-D/S/A профильные добавки | OUT | Не наш профиль |
|
||||||
|
| 0.3-M профильные добавки | IN | Чистое ядро и детерминизм (II), версионирование локального состояния (I.14), адаптер платформы, CI-матрица ОС (II.11), профиль публичного релиза (V.9), роли (IV) |
|
||||||
|
| 0.3-XL литания целиком | DEFERRED | Активация: старт этапа 8 (PHP+SQLite, свой прод) — до-триаж раздела V обязателен по спеке |
|
||||||
|
| I.1 Модульность / Module Map | IN | packages/* = модули; Module Map в AGENTS.md |
|
||||||
|
| I.2 Сервисный слой | IN (адаптация) | Без backend: контроллер→сервис читается как UI→чистые функции ядра; логика только в packages/core и packages/ai |
|
||||||
|
| I.3 Статусы = конечный автомат | IN | Состояние партии/урока — именованные переходы, прямое присваивание запрещено |
|
||||||
|
| I.4 Аудит-след | OUT | Нет транзакций, денег, сервера. Журнал ходов партии существует как доменная сущность (история с ветвлением), не как аудит |
|
||||||
|
| I.5 Soft delete | OUT | Нет БД и документов |
|
||||||
|
| I.6 Числа/единицы/стандартные форматы | IN | Координаты доски, коми (0.5-шаг), размер доски — объявлены письменно в INTERFACES.md; SGF FF[4] — стандартный формат обмена, берём его |
|
||||||
|
| I.7 Миграции схемы append-only | IN (адаптация) | Схема localStorage: version + миграция при загрузке; БД-миграции — DEFERRED до этапа 8 |
|
||||||
|
| I.8 Внешние вызовы | OUT | Нет внешних сервисов на этапах 1–7. Загрузка весов KataGo (этап 9) — статический файл, не API; адаптер площадки не нужен (свой сайт, не маркетплейс) |
|
||||||
|
| I.9 Парные операции симметричны | IN | Сделать ход ↔ отмена хода; поставить разметку ↔ снять; тесты на пары |
|
||||||
|
| I.10 Без хардкода / доменные параметры | IN | Коми, бюджеты времени ИИ, вероятности уровней — константы/конфиг одним местом (packages/core/src/config или данные уроков) |
|
||||||
|
| I.11 Contract First | IN | INTERFACES.md — контракт движка, протокол postMessage воркера, форматы уроков, схема localStorage. Сначала запись, потом код; удалил сущность — докажи grep'ом |
|
||||||
|
| I.12 Docker-first | OUT | Статика на шаред-хостинге, контейнеров нет |
|
||||||
|
| I.13 Fail-safe | IN | Битый сейв → старт с дефолтов с пометкой; нет SharedArrayBuffer → честный однопоточный фолбэк; фича-флаги (этапы 8–9) по умолчанию выключены |
|
||||||
|
| I.14 Персистентное состояние вне БД | IN | localStorage: version + checksum + атомарность (насколько позволяет API) + миграция + roundtrip-тест — прямо в спеке |
|
||||||
|
| I.15 Права на уровне выборки | OUT | Нет сервера и пользователей |
|
||||||
|
| I.16 Retention / классы данных для AI | IN (частично) | Сборов данных нет по спеке; класс «данные пользователя не покидают браузер» объявлен письменно здесь: прогресс и партии — только localStorage, во внешние сервисы (включая облачные AI) не отправляются |
|
||||||
|
| I.17 Один назначенный писатель | IN | Состояние партии пишет только UI-поток; воркер ИИ — чистый вычислитель, обмен через postMessage |
|
||||||
|
| I.18 Не создавай второй канал доставки | IN | Вся статика (включая веса сети на этапе 9) отдаётся тем же хостингом, что и код |
|
||||||
|
| I.19 Файлы | IN (адаптация) | SGF-файлы — только через File API в браузере, без сервера |
|
||||||
|
| I.20 Автономный/демо-режим | IN | Детерминированные реплеи и тестовые партии — обычные входные данные движка, не ветки if |
|
||||||
|
| II.1 Гейты с первого коммита | IN | prettier + eslint + tsc -b --noEmit + vitest = `npm run gates`; hard-блокирующие. Поведение при провале: стоп, показать гейт и stderr, не чинить молча |
|
||||||
|
| II.2 Правило→хук | OUT | Проект ведётся разными инструментами/моделями, PreToolUse-хуки — фича одного инструмента; машинная проверка — через npm-гейты и CI (`gotcha-two-agent-configs`) |
|
||||||
|
| II.3 Проверяй каждый слой сразу | IN | Прогон gates после каждого слоя каркаса и каждого модуля |
|
||||||
|
| II.4 Тулчейн проверяй фактически | IN | Выполнено при развёртывании: версии сверены через npm view 2026-08-05 и запинены |
|
||||||
|
| II.5 Типы обязательны, функции ≤40 строк | IN | strict без any; превышение 40 строк — с явным обоснованием в комментарии |
|
||||||
|
| II.6 Тесты зеркалят структуру, багфикс приносит тест | IN | AAA; тест = полная жизненная цепочка; для ядра — инварианты алгоритма в docstring + property-based тесты (fast-check, добавляется на этапе 1) |
|
||||||
|
| II.7 Тесты wiring — отдельный класс | IN | Orphan-check по каждой публичной функции ядра + интеграционный тест полной партии — прямо в спеке этапа 1 (`gotcha-green-modules-dead-system`) |
|
||||||
|
| II.8 Тест без боевой среды — спроектирован | IN | Ядро не импортирует DOM и воркер-API (две оси изоляции); Date.now()/Math.random() запрещены в ядре, часы и seeded RNG инжектируются; headless-прогон партий ботом с проверкой метрик |
|
||||||
|
| II.9 Фоновые задачи/очередь | OUT | Нет очереди; Web Worker — не таска в смысле пункта, протокол фиксируется в INTERFACES.md |
|
||||||
|
| II.10 Фронтенд | IN (адаптация) | strict без any; без react-query/zustand/RHF — UI минимальный, состояние своё; строки UI — в один ресурсный файл с первого дня; error boundary на маршрут; бюджеты числом (страница урока ≤ 300 КБ) |
|
||||||
|
| II.11 CI — не только гейты | IN | GitHub Actions: gates + матрица ubuntu+windows + гейт «чистый клон собирается» + npm audit. Настраивается на этапе 1 вместе с первым кодом |
|
||||||
|
| II.12 Лицензионный гейт | IN | Только пермиссивные лицензии зависимостей; THIRD_PARTY_LICENSES.md; цумэ-го — свободная лицензия с атрибуцией (CREDITS.md); лицензия весов KataGo проверяется на этапе 9 |
|
||||||
|
| III.1 Карта навигации | IN | Module Map в AGENTS.md + docs/navigation.md (появится с кодом); крупные файлы — порог + команда генерации |
|
||||||
|
| III.2 Один индекс и один гейт документации | IN (адаптация) | Доки — markdown в docs/, индекс — docs/README.md; гейт ссылок — легковесный скрипт проверки относительных ссылок (в gates), без mkdocs |
|
||||||
|
| III.3 ADR | IN | Этот файл — первый. Существующие ADR не редактируются |
|
||||||
|
| III.4 Ритуал закрытия сессии | IN | Триггер «закрываем сессию»; если сводить нечего — сказать явно |
|
||||||
|
| III.5 Статусы в планах по факту кода | IN | Реестр «Критические долги» в начале roadmap; закрытие долга — двухфазно |
|
||||||
|
| III.6 Версии | IN | SemVer; SoT версии — корневой package.json; версия наблюдаема в подвале сайта; бамп только по явной команде владельца; push тега — отдельное «да» |
|
||||||
|
| IV.1 Модель ветвления | IN | Trunk-based: один разработчик, ревью — сам сверка с гейтами; фиксируется здесь |
|
||||||
|
| IV.2 Делегирование субагентам | IN | Правку 1–3 строк не делегировать; субагентам — правило контекст-экономии и запрет на commit/push |
|
||||||
|
| IV.3 Опасные команды письменно | IN | Список в AGENTS.md; одно «да» = одно действие; escape-вектора (cd && git push, sh -c, eval, xargs) не использовать |
|
||||||
|
| IV.4 Не переспрашивай и не угадывай | IN | Дублируется в мастер-промпте |
|
||||||
|
| IV.5 TodoWrite, планы в docs/plans/ | IN | Двухфазность: фикс отдельным коммитом, доки отдельным |
|
||||||
|
| IV.6 Бюджет точки входа агента | IN | Точка входа — AGENTS.md (инструменто-нейтральная), только то, что нужно каждую сессию; чужие конфиг-каталоги не заводить |
|
||||||
|
| IV.7 Нарушаемое правило — дефект системы | IN | Заморозки и осознанные исключения — письменно в самом правиле |
|
||||||
|
| IV.8 Гигиена сессии | IN | Ссылки на docs/ в ответах; конфиги сторонних сервисов — по актуальной доке (`gotcha-llm-stale-configs`) |
|
||||||
|
| IV.9 Протокол отладки | IN | «Снаружи внутрь»: UI → воркер → движок; зафиксирован в мастер-промпте |
|
||||||
|
| V.1–V.8 Прод/эксплуатация/бэкапы | DEFERRED | Активация: этап 8 (свой backend на шареде). До него прода нет — деплой = FTP-заливка статики |
|
||||||
|
| V.9 Профиль публичного релиза | IN | Сайт публичный: LICENSE, README-витрина со скриншотами (с этапа 7), CREDITS.md, внутренние доки на русском — записано здесь |
|
||||||
|
| V.10 Межпроектные контракты | OUT | Потребителей значений проекта вне его нет |
|
||||||
|
| VI Грабли: [Docker] группа | OUT | Контейнеров нет |
|
||||||
|
| VI Грабли: [инфра/прод] группа | DEFERRED | Активация с этапом 8. Исключения, уже в спеке этапа 7: `gotcha-https-redirect-proxy` (IN — редирект средствами панели), `gotcha-robots-secret` (IN — актуально при открытии индексации) |
|
||||||
|
| VI Грабли: данные и логика, версии, Windows, процесс | IN | Все применимые — постоянный чеклист; особо: `gotcha-gitattributes` (закрыт первым коммитом), `gotcha-non-ascii-toolchain` (файлы для тулчейна — ASCII), `gotcha-green-modules-dead-system` и `gotcha-orphan-config` (wiring-тесты этапа 1), `gotcha-docs-drift` (ритуал закрытия), `gotcha-phantom-tails` (сверка плана с кодом в начале сессии), `gotcha-modal-drag-close` (этап 5, UI) |
|
||||||
|
|
||||||
|
## Компромиссы — что осознанно потеряли
|
||||||
|
|
||||||
|
1. **Нет машинных хуков (II.2 OUT)** — дисциплина опирается на npm-гейты и CI, а не на
|
||||||
|
блокирующие хуки инструмента. Цена: правило можно нарушить в спешке и узнать об этом
|
||||||
|
только на прогоне gates. Принято, потому что проект ведётся разными моделями и
|
||||||
|
инструментами (`gotcha-two-agent-configs`, `gotcha-hook-shell-blind`).
|
||||||
|
2. **Нет Docker-воспроизводимости среды сборки** — воспроизводимость обеспечивается
|
||||||
|
запиненными версиями + engines.node + CI-гейтом «чистый клон собирается».
|
||||||
|
3. **Гейт документации — самодельный скрипт ссылок, не mkdocs --strict** — проще, но
|
||||||
|
ловит только битые относительные ссылки, не полноту nav.
|
||||||
|
4. **Trunk-based без внешнего ревью** — роль ревью выполняют гейты + ритуал закрытия +
|
||||||
|
сверка владельца; ошибка может дойти до main, откат — реверт-коммитом.
|
||||||
|
5. **Этапы 8–9 остаются недо-триаженными** — раздел V литании активируется отдельным
|
||||||
|
до-триажем при старте этапа 8; до тех пор правила прода не действуют.
|
||||||
57
docs/decisions/ADR-0002-group-algorithm.md
Normal file
57
docs/decisions/ADR-0002-group-algorithm.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# ADR-0002: алгоритм поиска групп и дамэ — flood fill
|
||||||
|
|
||||||
|
Дата: 2026-08-05 | Статус: принято (этап 1)
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Движку правил (packages/core) на каждый ход нужно находить группы камней и их
|
||||||
|
дамэ: при проверке захватов, самоубийства, при подсчёте и в эвристике мёртвых
|
||||||
|
групп. План этапа 1 предписывает выбрать между flood fill и union-find по
|
||||||
|
микро-бенчмарку и зафиксировать выбор в ADR.
|
||||||
|
|
||||||
|
## Альтернативы
|
||||||
|
|
||||||
|
1. **Flood fill** — обход группы стеком от стартового камня, дамэ собираются в
|
||||||
|
множество. Простая реализация, локальный обход (только затронутые группы).
|
||||||
|
2. **Union-find** — массив parent на всю доску, объединение соседей одного
|
||||||
|
цвета, дамэ агрегируются по корням. Теоретически эффективен при
|
||||||
|
инкрементальных обновлениях, но наша позиция неизменяема: структуру пришлось
|
||||||
|
бы перестраивать на каждый ход, что обнуляет его главное преимущество.
|
||||||
|
|
||||||
|
## Бенчмарк
|
||||||
|
|
||||||
|
`packages/core/bench/group-bench.mts` (запуск: `npx tsx ...`): оба алгоритма
|
||||||
|
находят все группы и дамэ на случайных позициях 19×19 (плотности 0.35/0.55/0.75,
|
||||||
|
40 позиций × 300 итераций на плотность, детерминированный RNG). Среда:
|
||||||
|
Node.js 20, x86-64. Контрольные суммы результатов совпали.
|
||||||
|
|
||||||
|
| Плотность | flood fill | union-find | u/f |
|
||||||
|
| --------- | ---------- | ---------- | ---- |
|
||||||
|
| 0.35 | 230.4 мс | 373.1 мс | 1.62 |
|
||||||
|
| 0.55 | 288.5 мс | 505.6 мс | 1.75 |
|
||||||
|
| 0.75 | 328.3 мс | 596.8 мс | 1.82 |
|
||||||
|
| Итого | 847.1 мс | 1475.5 мс | 1.74 |
|
||||||
|
|
||||||
|
Union-find проигрывает на всех плотностях (в 1.6–1.8 раза): на маленькой доске
|
||||||
|
(361 клетка) стоимость полного прохода union-find и аллокаций Map/Set по корням
|
||||||
|
не окупается, а flood fill обходится дешёвыми операциями над массивом и одним
|
||||||
|
множеством дамэ на группу.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Оставляем **flood fill** (реализован в `groupAt`, packages/core/src/board.ts).
|
||||||
|
|
||||||
|
## Причины
|
||||||
|
|
||||||
|
- Быстрее на целевом профиле (доски 9–19, обходы только затронутых групп).
|
||||||
|
- Существенно проще код и меньше аллокаций; неизменяемость BoardState всё равно
|
||||||
|
не даёт переиспользовать структуру union-find между ходами.
|
||||||
|
- Производительности достаточно с запасом: полный пересчёт всех групп 19×19 —
|
||||||
|
десятки микросекунд, а движок обходит лишь соседние с ходом группы.
|
||||||
|
|
||||||
|
## Компромисс
|
||||||
|
|
||||||
|
Если этапы 3–4 (ИИ) покажут профилировкой, что пересчёт групп — узкое место
|
||||||
|
(например, массовые симуляции MCTS), решение пересматривается: кандидат —
|
||||||
|
инкрементальный union-find с копированием при записи или кэш групп в узлах
|
||||||
|
симуляции. Точка пересмотра — бенчмарк на реальной нагрузке ИИ, не раньше.
|
||||||
35
docs/decisions/ADR-0003-build-position.md
Normal file
35
docs/decisions/ADR-0003-build-position.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# ADR-0003: buildPosition в core + разметка цели CR вместо MA (этап 6)
|
||||||
|
|
||||||
|
Дата: 2026-08-06. Статус: принято.
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Этап 6 (уроки и цумэ-го) требует стартовать задачу с произвольной позиции.
|
||||||
|
План-фаза 6 фиксировала «packages/* не трогаем», но аудит выявил два
|
||||||
|
пробела контракта:
|
||||||
|
|
||||||
|
1. parseSgf (этап 1) не поддерживает setup-свойства AB/AW — произвольную
|
||||||
|
позицию из SGF не собрать, а конструировать BoardState вручную вне core
|
||||||
|
опасно (positionHashes для суперко считает внутренний hashPosition).
|
||||||
|
2. NodeMarkup не содержит стрелок MA (только LB/TR/SQ/CR) — разметка цели
|
||||||
|
«connect» по контракту была невыразима.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
1. В packages/core добавлена ОДНА аддитивная функция
|
||||||
|
`buildPosition(size, black, white, toPlay)` (board.ts, экспорт через
|
||||||
|
index.ts): валидация (вне доски / дубли / группа без дамэ → Error),
|
||||||
|
корректный positionHashes. Поведение существующих функций не меняется,
|
||||||
|
зона core в остальном заморожена.
|
||||||
|
2. Разметка цели connect в задачах — CR (круги) вместо MA; контракт
|
||||||
|
INTERFACES.md обновлён. SGF задачи хранит позицию данными JSON
|
||||||
|
(setupBlack/setupWhite), а SGF-поле несёт только дерево вариантов
|
||||||
|
(B/W-ходы от стартовой позиции, TR/CR-разметку цели на корневом узле).
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- Позиция задачи: JSON-поля setupBlack/setupWhite + buildPosition; дерево
|
||||||
|
вариантов парсится parseSgf и накатывается applyMove на построенную
|
||||||
|
позицию.
|
||||||
|
- При расширении parseSgf поддержкой AB/AW в будущем формат можно
|
||||||
|
упростить до чистого SGF (миграция контента, отдельной задачей).
|
||||||
43
docs/decisions/ADR-0004-account-email-password.md
Normal file
43
docs/decisions/ADR-0004-account-email-password.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# ADR-0004: личный кабинет email+пароль (этап 8) и до-триаж раздела V
|
||||||
|
|
||||||
|
Дата: 2026-08-06. Статус: принято (решение владельца).
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Владелец заказал «маленький личный кабинет» с хранением прогресса —
|
||||||
|
активация этапа 8 (ОПЦИОНАЛЬНЫЙ по спеке: «только по отдельной команде»).
|
||||||
|
Спека этапа 8 предписывала анонимный токен; владелец выбрал
|
||||||
|
**email + пароль** (ask_user 2026-08-06) — осознанное отступление от
|
||||||
|
«без сбора данных»: email — персональные данные, храним минимум
|
||||||
|
(email + хэш пароля), никакой аналитики/трекинга не добавляется.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
1. Регистрация email+пароль, password_hash/password_verify, сессии
|
||||||
|
PHP native (HttpOnly, SameSite=Lax). Контракт API — INTERFACES.md.
|
||||||
|
2. **Без верификации почты и восстановления пароля**: на shared-хостинге
|
||||||
|
нет гарантии SMTP; потерянный пароль = новая учётка. Это предупреждение
|
||||||
|
показывается в UI при регистрации (зафиксировано в контракте).
|
||||||
|
3. Прогресс — opaque-блоб ProgressV2 на сервере, last-write-wins по
|
||||||
|
updatedAt; localStorage остаётся SoT для неавторизованных.
|
||||||
|
|
||||||
|
## До-триаж раздела V литании (требование мастер-промпта для этапа 8)
|
||||||
|
|
||||||
|
| Пункт | Вердикт | Комментарий |
|
||||||
|
| --------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| V.1 (агент не трогает прод) | IN | Прод-деплой — только команды владельцу блоком; агент не выполняет |
|
||||||
|
| V.2 (деплой-чеклист) | IN | docs/deploy.md: ВСЕ артефакты (dist → web root, server/ → /api, config.php, БД вне web root), smoke = health.php, откат = перезаливка предыдущего dist |
|
||||||
|
| V.3 (один скрипт деплоя) | OUT | Нет SSH/контейнеров, деплой по FTP; чек-лист V.2 покрывает |
|
||||||
|
| V.4 (секрет-гигиена) | IN | config.php с путём БД — вне репо (.gitignore), в репо config.example.php с плейсхолдерами [SECRET]; секретов в коде нет |
|
||||||
|
| V.5 (канонические пути) | IN | Пути на хостинге фиксируются в docs/deploy.md письменно при первом деплое (владельцем) |
|
||||||
|
| V.6 (наблюдаемость) | IN (минимум) | health.php {"status":"ok"}; внешний наблюдатель/алерты — OUT (нет сервера в нашем управлении) |
|
||||||
|
| V.7 (бэкап) | IN (минимум) | Бэкап = копия SQLite-файла средствами панели хостинга; процедура restore в deploy.md; автоматика — OUT |
|
||||||
|
| V.8 (приёмка/ворота) | IN | Приёмка этапа 8 = smoke.sh зелёный локально + ручной прогон на хостинге владельцем |
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- В превью (статический хостинг) API недоступен — UI кабинета обязан
|
||||||
|
деградировать тихо («синхронизация недоступна»), без алертов.
|
||||||
|
- CI-гейты не запускают PHP (в среде его нет у GitHub runner по
|
||||||
|
умолчанию — есть: shivammathur/setup-php при желании; в v1 smoke.sh
|
||||||
|
локальный/на хостинге, зафиксировано в PROJECT_STATE).
|
||||||
101
docs/deploy.md
Normal file
101
docs/deploy.md
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# Деплой на shared-хостинг (чек-лист, V.2)
|
||||||
|
|
||||||
|
Дата: 2026-08-06. Выполняет ВЛАДЕЛЕЦ руками (V.1: агент прод не трогает).
|
||||||
|
Проходить целиком, частичные варианты запрещены (gotcha-partial-build).
|
||||||
|
|
||||||
|
## Артефакты деплоя (ВСЕ, их ТРИ)
|
||||||
|
|
||||||
|
1. **Статика сайта**: `apps/web/dist/` → web root хостинга (например,
|
||||||
|
`public_html/`). Сборка: `npm ci && npm run build` в `apps/web`
|
||||||
|
(Node 20+, локально) либо взять готовый dist из превью-версии.
|
||||||
|
2. **API**: содержимое `server/` раскладывается по web root ЧАСТЯМИ
|
||||||
|
(не целиком!): `server/api/` → `public_html/api/`,
|
||||||
|
`server/lib/` → `public_html/lib/`. Эндпоинты оказываются доступны
|
||||||
|
как `/api/*.php`. Пути жёсткие: PHP-файлы ищут lib как `../lib/`
|
||||||
|
(smoke-тест локально поднимает docroot = server/, поэтому на проде
|
||||||
|
web root = dist + содержимое server/).
|
||||||
|
3. **Конфиг + БД**: скопировать `server/config.example.php` →
|
||||||
|
`public_html/config.php` (именно в КОРЕНЬ web root — db.php ищет его
|
||||||
|
как `__DIR__.'/../config.php'` от lib/), выставить `db_path` — путь
|
||||||
|
ВНЕ web root (например, `/home/<user>/go-learn.sqlite`). Сам файл БД
|
||||||
|
создастся сам при первом обращении (права на каталог — записываемые
|
||||||
|
для PHP). Также выставить `base_url` (полный URL сайта — идёт в письма
|
||||||
|
восстановления пароля) и `mail_from` (отправитель писем).
|
||||||
|
|
||||||
|
## Итоговая структура на хостинге
|
||||||
|
|
||||||
|
```text
|
||||||
|
public_html/
|
||||||
|
├── index.html, learn.html, play.html, … ← из dist (артефакт 1)
|
||||||
|
├── _astro/, assets/ ← из dist
|
||||||
|
├── .htaccess ← из dist + блок запрета ниже
|
||||||
|
├── api/ ← server/api/ (артефакт 2)
|
||||||
|
│ ├── health.php, register.php, login.php, logout.php, me.php,
|
||||||
|
│ ├── progress.php, captcha.php
|
||||||
|
│ └── password-reset/ (request.php, confirm.php)
|
||||||
|
├── lib/ ← server/lib/
|
||||||
|
│ └── auth.php, db.php, http.php, ratelimit.php
|
||||||
|
└── config.php ← созданный вручную (артефакт 3)
|
||||||
|
```
|
||||||
|
|
||||||
|
В корневой `.htaccess` (из dist) дописать блок из server/.htaccess:
|
||||||
|
|
||||||
|
```apache
|
||||||
|
<IfModule mod_authz_core.c>
|
||||||
|
<FilesMatch "(^config\.php$|\.sqlite(-wal|-shm)?$)">
|
||||||
|
Require all denied
|
||||||
|
</FilesMatch>
|
||||||
|
</IfModule>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Порядок (каждый шаг — отдельное действие)
|
||||||
|
|
||||||
|
0. Проверки хостинга: PHP ≥ 8.0, модули pdo_sqlite, sqlite3
|
||||||
|
(phpinfo в панели или `php -v`/`php -m` по SSH, если есть).
|
||||||
|
Нет pdo_sqlite — стоп, вопрос хостеру.
|
||||||
|
1. Залить dist (артефакт 1).
|
||||||
|
2. Залить server/ (артефакт 2) и создать config.php (артефакт 3).
|
||||||
|
3. Smoke: открыть `/api/health.php` → ожидается `{"status":"ok"}`.
|
||||||
|
Не 200/не JSON — смотреть логи PHP в панели, НЕ править код на глазах.
|
||||||
|
4. Smoke вручную: регистрация на странице /register.html (с капчей)
|
||||||
|
→ вход → «Синхронизировать прогресс» → статус «Прогресс отправлен».
|
||||||
|
4.1. Проверка почты (этап 8.1): «Забыли пароль?» на /account.html →
|
||||||
|
письмо со ссылкой приходит и ссылка ведёт на
|
||||||
|
/password-reset.html?token=…. Не приходит — смотреть настройки
|
||||||
|
SPF/DKIM в панели хостинга; до решения восстановление считать
|
||||||
|
нерабочим (риск в PROJECT_STATE).
|
||||||
|
5. Проверить, что БД-файл появился по пути из config.php и НЕ доступен
|
||||||
|
по HTTP (попытка открыть его URL → 403/404).
|
||||||
|
6. HTTPS — включать ТОЛЬКО панелью хостинга (в .htaccess редиректа нет
|
||||||
|
намеренно, gotcha-https-redirect-proxy). После включения — повторить
|
||||||
|
шаги 3–4 по HTTPS.
|
||||||
|
|
||||||
|
## Канонические пути (V.5) — заполнить при первом деплое
|
||||||
|
|
||||||
|
- Web root: `____________`
|
||||||
|
- URL сайта: `____________`
|
||||||
|
- Путь БД (вне web root): `____________`
|
||||||
|
- Дата первого деплоя: `____________`
|
||||||
|
|
||||||
|
## Откат
|
||||||
|
|
||||||
|
Откат = перезаливка предыдущего dist и (при изменении server/) предыдущего
|
||||||
|
server/. Схема БД этапа 8 аддитивна (init-only), обратной миграции нет;
|
||||||
|
перед любым изменением схемы в будущем — дамп БД ДО деплоя (V.3).
|
||||||
|
|
||||||
|
## Бэкап и restore (V.7, минимум)
|
||||||
|
|
||||||
|
- Бэкап = копия файла SQLite (из панели файлового менеджера/SSH) +
|
||||||
|
config.php. Периодичность — после заметного роста числа пользователей
|
||||||
|
(ручная, SoT расписания — этот файл).
|
||||||
|
- Restore: остановить запись (выключить сайт в панели) → вернуть файл БД
|
||||||
|
на путь из config.php → smoke шаг 3–4. Проверка restore выполняется на
|
||||||
|
первом же бэкапе: распаковать копию под другим именем и открыть
|
||||||
|
`sqlite3 <файл> "SELECT count(*) FROM users;"` (или через панель).
|
||||||
|
|
||||||
|
## Чего НЕ делать
|
||||||
|
|
||||||
|
- Не класть config.php и БД в web root.
|
||||||
|
- Не включать HTTPS-редирект в .htaccess (только панель).
|
||||||
|
- Не править PHP/БД на проде напрямую: правка → локальный smoke.sh →
|
||||||
|
перезаливка.
|
||||||
116
docs/design/kimi-export/cabinet.html
Normal file
116
docs/design/kimi-export/cabinet.html
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ru" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Кабинет — Иго · школа Го</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Вход в кабинет школы Го: синхронизация прогресса между устройствами."
|
||||||
|
/>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=PT+Sans:wght@400;700&family=PT+Serif:wght@400;700&family=PT+Mono&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="css/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="container">
|
||||||
|
<a class="logo" href="index.html"><span class="kanji">碁</span> Иго · школа Го</a>
|
||||||
|
<nav class="main-nav" aria-label="Основное меню">
|
||||||
|
<ul>
|
||||||
|
<li><a href="learn.html">Курс</a></li>
|
||||||
|
<li><a href="tsumego.html">Цумэ-го</a></li>
|
||||||
|
<li><a href="play.html">Играть</a></li>
|
||||||
|
<li><a href="progress.html">Прогресс</a></li>
|
||||||
|
<li><a href="cabinet.html" aria-current="page">Кабинет</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="menu-toggle" type="button" aria-label="Открыть меню" aria-expanded="false">
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="container">
|
||||||
|
<!-- состояние: гость -->
|
||||||
|
<div class="card auth-card" data-logged-out>
|
||||||
|
<h1>Вход в кабинет</h1>
|
||||||
|
<p style="text-align: center; color: var(--ink-soft); font-size: 0.95rem">
|
||||||
|
Кабинет нужен только для синхронизации прогресса между устройствами. Без него всё
|
||||||
|
работает так же.
|
||||||
|
</p>
|
||||||
|
<form data-login-form novalidate>
|
||||||
|
<div class="field">
|
||||||
|
<label for="email">Электронная почта</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
<span class="error-text">Введите корректный адрес почты.</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">Пароль</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
<span class="error-text">Пароль не может быть пустым.</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--primary" type="submit" style="width: 100%">Войти</button>
|
||||||
|
</form>
|
||||||
|
<div class="auth-links">
|
||||||
|
<span>Нет аккаунта? <a href="register.html">Зарегистрируйтесь</a></span>
|
||||||
|
<a href="register.html">Забыли пароль?</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- состояние: вы вошли -->
|
||||||
|
<div class="card auth-card logged-in" data-logged-in hidden>
|
||||||
|
<h1>Вы вошли</h1>
|
||||||
|
<p class="email" data-user-email>you@example.com</p>
|
||||||
|
<p style="color: var(--ink-soft); font-size: 0.95rem">
|
||||||
|
Прогресс пока на этом устройстве. Нажмите синхронизацию, чтобы забрать его с собой.
|
||||||
|
</p>
|
||||||
|
<div class="btn-row" style="justify-content: center">
|
||||||
|
<button class="btn btn--primary" type="button" data-sync>
|
||||||
|
Синхронизировать прогресс
|
||||||
|
</button>
|
||||||
|
<button class="btn btn--danger" type="button" data-logout>Выйти</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="container">
|
||||||
|
<span class="privacy-note"
|
||||||
|
>◎ Без сбора данных: прогресс хранится только на вашем устройстве.</span
|
||||||
|
>
|
||||||
|
<nav aria-label="Меню в подвале">
|
||||||
|
<ul>
|
||||||
|
<li><a href="learn.html">Курс</a></li>
|
||||||
|
<li><a href="tsumego.html">Цумэ-го</a></li>
|
||||||
|
<li><a href="play.html">Играть</a></li>
|
||||||
|
<li><a href="cabinet.html">Кабинет</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<span>© 2026 Иго</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
166
docs/design/kimi-export/index.html
Normal file
166
docs/design/kimi-export/index.html
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ru" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Иго — научитесь играть в Го с нуля</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Камерная школа Го (вэйци, бадук) на русском: курс с нуля до 10 кю, цумэ-го с проверкой, партии против ИИ шести уровней."
|
||||||
|
/>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=PT+Sans:wght@400;700&family=PT+Serif:wght@400;700&family=PT+Mono&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="css/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="container">
|
||||||
|
<a class="logo" href="index.html"><span class="kanji">碁</span> Иго</a>
|
||||||
|
<nav class="main-nav" aria-label="Основное меню">
|
||||||
|
<ul>
|
||||||
|
<li><a href="learn.html">Курс</a></li>
|
||||||
|
<li><a href="tsumego.html">Цумэ-го</a></li>
|
||||||
|
<li><a href="play.html">Играть</a></li>
|
||||||
|
<li><a href="progress.html">Прогресс</a></li>
|
||||||
|
<li><a href="cabinet.html">Кабинет</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="menu-toggle" type="button" aria-label="Открыть меню" aria-expanded="false">
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="hero">
|
||||||
|
<div class="container">
|
||||||
|
<div>
|
||||||
|
<span class="hero-kicker">вэйци · бадук · го</span>
|
||||||
|
<h1>Научитесь играть <em>в Го</em> с нуля</h1>
|
||||||
|
<p class="lead">
|
||||||
|
Спокойный курс без регистрации-принуждения: теория, задачи с проверкой на доске и
|
||||||
|
партии против компьютера — от первых камней до уверенного 10 кю.
|
||||||
|
</p>
|
||||||
|
<div class="btn-row">
|
||||||
|
<a class="btn btn--primary" href="learn.html">Начать курс</a>
|
||||||
|
<a class="btn" href="play.html">Сыграть с ИИ</a>
|
||||||
|
</div>
|
||||||
|
<div class="hero-meta">
|
||||||
|
<div class="item"><strong>10</strong><span>разделов курса</span></div>
|
||||||
|
<div class="item"><strong>12</strong><span>задач цумэ-го</span></div>
|
||||||
|
<div class="item"><strong>6</strong><span>уровней ИИ</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<figure class="hero-figure">
|
||||||
|
<span class="hero-chip"><span class="dot"></span> Ваш ход — E5</span>
|
||||||
|
<img
|
||||||
|
src="assets/hero.jpg"
|
||||||
|
alt="Деревянный гобан с несколькими чёрными и белыми камнями в начальной позиции"
|
||||||
|
width="1024"
|
||||||
|
height="576"
|
||||||
|
/>
|
||||||
|
<figcaption>Гобан, сланец и ракушка — три тысячи лет той же партии.</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="features">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="kicker">как это работает</p>
|
||||||
|
<h2>Как устроено обучение</h2>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Три простые вещи, которые вместе дают рост: теория малыми дозами, задачи до
|
||||||
|
автоматизма и живая практика.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="features-grid">
|
||||||
|
<div class="card feature-card">
|
||||||
|
<span class="icon">一</span>
|
||||||
|
<h3>С нуля, по шагам</h3>
|
||||||
|
<p>
|
||||||
|
Десять разделов: от дамэ и атари до дзёсэки и ёсэ. Короткие уроки, схемы позиций,
|
||||||
|
никакой воды — только то, что работает на доске.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="card feature-card">
|
||||||
|
<span class="icon">二</span>
|
||||||
|
<h3>Задачи с проверкой</h3>
|
||||||
|
<p>
|
||||||
|
Жизнь и смерть, сэмэаи, лестницы. Фильтры по теме и сложности, решённые отмечаются
|
||||||
|
автоматически — видно, что уже в опоре.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="card feature-card">
|
||||||
|
<span class="icon">三</span>
|
||||||
|
<h3>Партии с ИИ, 6 уровней</h3>
|
||||||
|
<p>
|
||||||
|
Доски 9×9, 13×13 и 19×19, коми, отмена хода и подсказки трёх лучших ходов. Начните с
|
||||||
|
первого уровня — он терпелив.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ink-band">
|
||||||
|
<figure>
|
||||||
|
<img
|
||||||
|
src="assets/ink.jpg"
|
||||||
|
alt="Позиция на углу гобана в манере тушевой живописи суми-э"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<figcaption>
|
||||||
|
Угол доски, тушь и пустое пространство — эстетика ма, в которой учится глаз.
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="home-strip">
|
||||||
|
<figure class="hero-figure">
|
||||||
|
<img
|
||||||
|
src="assets/stones.jpg"
|
||||||
|
alt="Чёрные сланцевые и белые ракушечные камни Го в деревянных чашах"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</figure>
|
||||||
|
<div class="card quote">
|
||||||
|
<blockquote>
|
||||||
|
«Го — это игра, которую легко выучить и невозможно исчерпать».
|
||||||
|
</blockquote>
|
||||||
|
<cite>— старая пословица. Проверьте на себе: первый урок занимает десять минут.</cite>
|
||||||
|
<p style="margin-top: 24px">
|
||||||
|
<a class="btn btn--quiet" href="lesson.html">Первый урок: дамэ и захват →</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="container">
|
||||||
|
<span class="privacy-note"
|
||||||
|
>◎ Без сбора данных: прогресс хранится только на вашем устройстве.</span
|
||||||
|
>
|
||||||
|
<nav aria-label="Меню в подвале">
|
||||||
|
<ul>
|
||||||
|
<li><a href="learn.html">Курс</a></li>
|
||||||
|
<li><a href="tsumego.html">Цумэ-го</a></li>
|
||||||
|
<li><a href="play.html">Играть</a></li>
|
||||||
|
<li><a href="cabinet.html">Кабинет</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<span>© 2026 Иго</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
193
docs/design/kimi-export/learn.html
Normal file
193
docs/design/kimi-export/learn.html
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ru" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Курс — Иго · школа Го</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Программа курса Го с нуля до 10 кю: десять разделов от дамэ и захвата до простого ёсэ."
|
||||||
|
/>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=PT+Sans:wght@400;700&family=PT+Serif:wght@400;700&family=PT+Mono&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="css/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="container">
|
||||||
|
<a class="logo" href="index.html"><span class="kanji">碁</span> Иго · школа Го</a>
|
||||||
|
<nav class="main-nav" aria-label="Основное меню">
|
||||||
|
<ul>
|
||||||
|
<li><a href="learn.html" aria-current="page">Курс</a></li>
|
||||||
|
<li><a href="tsumego.html">Цумэ-го</a></li>
|
||||||
|
<li><a href="play.html">Играть</a></li>
|
||||||
|
<li><a href="progress.html">Прогресс</a></li>
|
||||||
|
<li><a href="cabinet.html">Кабинет</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="menu-toggle" type="button" aria-label="Открыть меню" aria-expanded="false">
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="page-head">
|
||||||
|
<div class="container">
|
||||||
|
<p class="kicker">с нуля до 10 кю</p>
|
||||||
|
<h1>Программа курса</h1>
|
||||||
|
<p>
|
||||||
|
Десять разделов, каждый — короткая теория и задачи на доске. Идите по порядку: каждый
|
||||||
|
раздел опирается на предыдущий. Отметки «пройден» сохраняются на вашем устройстве.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="course-grid">
|
||||||
|
<a class="card card--link lesson-card" href="lesson.html" data-lesson-card="dame">
|
||||||
|
<span class="lesson-num">1</span>
|
||||||
|
<div>
|
||||||
|
<h3>Дамэ и захват</h3>
|
||||||
|
<p>
|
||||||
|
Дыхания группы, постановка камня на доску, как камни снимаются с доски. Самый первый
|
||||||
|
урок — с него начинается всё.
|
||||||
|
</p>
|
||||||
|
<div class="meta"><span class="badge">10 мин</span><span data-done-slot></span></div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="card card--link lesson-card" href="lesson.html" data-lesson-card="atari">
|
||||||
|
<span class="lesson-num">2</span>
|
||||||
|
<div>
|
||||||
|
<h3>Атари, лестница, сеть</h3>
|
||||||
|
<p>
|
||||||
|
Угроза захвата и три способа её реализовать: прямое атари, ситэтя (лестница) и гэтэ
|
||||||
|
(сеть).
|
||||||
|
</p>
|
||||||
|
<div class="meta"><span class="badge">15 мин</span><span data-done-slot></span></div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="card card--link lesson-card" href="lesson.html" data-lesson-card="ko">
|
||||||
|
<span class="lesson-num">3</span>
|
||||||
|
<div>
|
||||||
|
<h3>Самоубийство и ко</h3>
|
||||||
|
<p>
|
||||||
|
Запрещённые пункты, правило ко и ко-угрозы. Почему партия не может повторяться
|
||||||
|
бесконечно.
|
||||||
|
</p>
|
||||||
|
<div class="meta"><span class="badge">12 мин</span><span data-done-slot></span></div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="card card--link lesson-card" href="lesson.html" data-lesson-card="eyes">
|
||||||
|
<span class="lesson-num">4</span>
|
||||||
|
<div>
|
||||||
|
<h3>Глаза: жизнь и смерть</h3>
|
||||||
|
<p>
|
||||||
|
Два глаза — жизнь. Настоящие и ложные глаза, базовые формы: прямая четвёрка, гнутая
|
||||||
|
тройка, косяк.
|
||||||
|
</p>
|
||||||
|
<div class="meta"><span class="badge">20 мин</span><span data-done-slot></span></div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="card card--link lesson-card" href="lesson.html" data-lesson-card="scoring">
|
||||||
|
<span class="lesson-num">5</span>
|
||||||
|
<div>
|
||||||
|
<h3>Подсчёт и конец партии</h3>
|
||||||
|
<p>
|
||||||
|
Территория и пленники, коми, два паса, мёртвые камни. Как понять, кто победил, не
|
||||||
|
считая на пальцах.
|
||||||
|
</p>
|
||||||
|
<div class="meta"><span class="badge">15 мин</span><span data-done-slot></span></div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="card card--link lesson-card" href="lesson.html" data-lesson-card="connect">
|
||||||
|
<span class="lesson-num">6</span>
|
||||||
|
<div>
|
||||||
|
<h3>Связь и разрезание</h3>
|
||||||
|
<p>Крепкие и тонкие связи, точки разрезания, защита своих слабостей и атака чужих.</p>
|
||||||
|
<div class="meta"><span class="badge">15 мин</span><span data-done-slot></span></div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="card card--link lesson-card" href="lesson.html" data-lesson-card="corners">
|
||||||
|
<span class="lesson-num">7</span>
|
||||||
|
<div>
|
||||||
|
<h3>Углы, стороны, центр</h3>
|
||||||
|
<p>
|
||||||
|
Почему партия начинается в углах, ценность третьей и четвёртой линии, первые
|
||||||
|
принципы фусэки.
|
||||||
|
</p>
|
||||||
|
<div class="meta"><span class="badge">15 мин</span><span data-done-slot></span></div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="card card--link lesson-card" href="lesson.html" data-lesson-card="joseki">
|
||||||
|
<span class="lesson-num">8</span>
|
||||||
|
<div>
|
||||||
|
<h3>Базовые дзёсэки</h3>
|
||||||
|
<p>
|
||||||
|
Установленные последовательности в углу: хоси-кэима, комоку и три простейших дзёсэки
|
||||||
|
наизусть.
|
||||||
|
</p>
|
||||||
|
<div class="meta"><span class="badge">25 мин</span><span data-done-slot></span></div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="card card--link lesson-card" href="lesson.html" data-lesson-card="shape">
|
||||||
|
<span class="lesson-num">9</span>
|
||||||
|
<div>
|
||||||
|
<h3>Форма</h3>
|
||||||
|
<p>
|
||||||
|
Хорошие и плохие формы: тигровая пасть, бамбуковый стык, пустой треугольник и почему
|
||||||
|
его избегают.
|
||||||
|
</p>
|
||||||
|
<div class="meta"><span class="badge">20 мин</span><span data-done-slot></span></div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="card card--link lesson-card" href="lesson.html" data-lesson-card="yose">
|
||||||
|
<span class="lesson-num">10</span>
|
||||||
|
<div>
|
||||||
|
<h3>Простое ёсэ</h3>
|
||||||
|
<p>
|
||||||
|
Эндшпиль без боли: сэнтэ и готэ, ценность ходов, типичные завершения на границе
|
||||||
|
территорий.
|
||||||
|
</p>
|
||||||
|
<div class="meta"><span class="badge">20 мин</span><span data-done-slot></span></div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="container">
|
||||||
|
<span class="privacy-note"
|
||||||
|
>◎ Без сбора данных: прогресс хранится только на вашем устройстве.</span
|
||||||
|
>
|
||||||
|
<nav aria-label="Меню в подвале">
|
||||||
|
<ul>
|
||||||
|
<li><a href="learn.html">Курс</a></li>
|
||||||
|
<li><a href="tsumego.html">Цумэ-го</a></li>
|
||||||
|
<li><a href="play.html">Играть</a></li>
|
||||||
|
<li><a href="cabinet.html">Кабинет</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<span>© 2026 Иго</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
184
docs/design/kimi-export/lesson.html
Normal file
184
docs/design/kimi-export/lesson.html
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ru" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Урок 1. Дамэ и захват — Иго · школа Го</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Первый урок курса Го: дыхания группы, дамэ, постановка и снятие камней с доски."
|
||||||
|
/>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=PT+Sans:wght@400;700&family=PT+Serif:wght@400;700&family=PT+Mono&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="css/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="container">
|
||||||
|
<a class="logo" href="index.html"><span class="kanji">碁</span> Иго · школа Го</a>
|
||||||
|
<nav class="main-nav" aria-label="Основное меню">
|
||||||
|
<ul>
|
||||||
|
<li><a href="learn.html" aria-current="page">Курс</a></li>
|
||||||
|
<li><a href="tsumego.html">Цумэ-го</a></li>
|
||||||
|
<li><a href="play.html">Играть</a></li>
|
||||||
|
<li><a href="progress.html">Прогресс</a></li>
|
||||||
|
<li><a href="cabinet.html">Кабинет</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="menu-toggle" type="button" aria-label="Открыть меню" aria-expanded="false">
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="page-head">
|
||||||
|
<div class="container container--narrow">
|
||||||
|
<p class="kicker">урок 1 из 10</p>
|
||||||
|
<h1>Дамэ и захват</h1>
|
||||||
|
<p>
|
||||||
|
Десять минут — и вы будете знать главное правило Го. Всё остальное в игре растёт из
|
||||||
|
него.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<article class="lesson-body">
|
||||||
|
<h2>Камень живёт, пока дышит</h2>
|
||||||
|
<p>
|
||||||
|
Камни ставятся на <strong>пересечения линий</strong>, а не в клетки. Поставленный камень
|
||||||
|
не двигается — он либо остаётся на доске до конца партии, либо снимается при захвате.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Соседние пункты по линиям — это <strong>дамэ</strong>, дыхания камня. У камня в центре
|
||||||
|
четыре дыхания, на стороне — три, в углу — два.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<pre>
|
||||||
|
· ─ · ─ ·
|
||||||
|
│ │ │
|
||||||
|
· ─ ○ ─ · у белого камня
|
||||||
|
│ │ │ четыре дамэ: слева, справа,
|
||||||
|
· ─ · ─ · сверху и снизу</pre>
|
||||||
|
<p class="diagram-caption">Диаграмма 1. Четыре дыхания камня в центре доски.</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Камни одного цвета, стоящие рядом по линиям, образуют <strong>группу</strong> — они
|
||||||
|
делят дыхания и живут или умирают вместе.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Атари и захват</h2>
|
||||||
|
<p>
|
||||||
|
Когда у группы остаётся последнее дыхание, говорят <strong>«атари»</strong> —
|
||||||
|
предупреждение об угрозе. Если противник занимает последнее дамэ, группа
|
||||||
|
<strong>снимается с доски</strong> и становится пленниками.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<pre>
|
||||||
|
· ─ ● ─ ·
|
||||||
|
│ │ │
|
||||||
|
● ─ ○ ─ · белый в атари: осталось
|
||||||
|
│ │ │ одно дамэ справа
|
||||||
|
· ─ ● ─ ·</pre>
|
||||||
|
<p class="diagram-caption">Диаграмма 2. Чёрные поставили белого в атари.</p>
|
||||||
|
|
||||||
|
<pre>
|
||||||
|
· ─ ● ─ ·
|
||||||
|
│ │ │
|
||||||
|
● ─ ● ← · чёрные заняли последнее дамэ —
|
||||||
|
│ │ │ белый камень снят с доски
|
||||||
|
· ─ ● ─ ·</pre>
|
||||||
|
<p class="diagram-caption">Диаграмма 3. Захват: пленник уходит в чашу чёрных.</p>
|
||||||
|
|
||||||
|
<div class="try-it">
|
||||||
|
<div
|
||||||
|
class="goban-static"
|
||||||
|
data-size="9"
|
||||||
|
data-black="d4,c5,e5,d6"
|
||||||
|
data-white="d5"
|
||||||
|
data-mark="e4"
|
||||||
|
></div>
|
||||||
|
<div>
|
||||||
|
<p class="label">Попробуйте сами</p>
|
||||||
|
<p>
|
||||||
|
Белый камень на D5 в атари. Куда чёрным поставить камень, чтобы снять его с доски?
|
||||||
|
Отметка подсказывает район — найдите точное пересечение, а затем
|
||||||
|
<a href="play.html">проверьте себя в игре против ИИ</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Зачем это знать</h2>
|
||||||
|
<p>
|
||||||
|
Вся тактика Го — игра с дыханиями: вы сокращаете дамэ чужих групп и бережёте свои.
|
||||||
|
Лестница, сеть, сэмэаи, жизнь и смерть — всё это про дыхания, которые мы разберём в
|
||||||
|
следующих уроках.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Запомните три числа: <strong>4–3–2</strong>. Столько дыханий у одинокого камня в центре,
|
||||||
|
на стороне и в углу. Поэтому угол и умирает быстрее всех — и поэтому же там проще всего
|
||||||
|
строить территорию, но об этом в седьмом уроке.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="try-it">
|
||||||
|
<div
|
||||||
|
class="goban-static"
|
||||||
|
data-size="9"
|
||||||
|
data-black="b2,c2"
|
||||||
|
data-white="b3,c3,a2"
|
||||||
|
data-last="a2"
|
||||||
|
></div>
|
||||||
|
<div>
|
||||||
|
<p class="label">Попробуйте сами</p>
|
||||||
|
<p>
|
||||||
|
Два белых камня стоят группой. Сколько дамэ у этой группы? Посчитайте все пустые
|
||||||
|
соседние пункты обоих камней — общие дыхания считаются один раз.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Кратко</h2>
|
||||||
|
<p>
|
||||||
|
Камни ставятся на пересечения и не двигаются. Дыхания (дамэ) — соседние пустые пункты.
|
||||||
|
Последнее дамэ занято — группа снята. Дальше:
|
||||||
|
<a href="lesson.html">атари, лестница и сеть</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="lesson-nav">
|
||||||
|
<a class="btn btn--quiet" href="learn.html">← Все разделы</a>
|
||||||
|
<button class="btn btn--primary" type="button" data-lesson-done="dame">
|
||||||
|
Отметить пройденным
|
||||||
|
</button>
|
||||||
|
<a class="btn" href="lesson.html">Урок 2: атари →</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="container">
|
||||||
|
<span class="privacy-note"
|
||||||
|
>◎ Без сбора данных: прогресс хранится только на вашем устройстве.</span
|
||||||
|
>
|
||||||
|
<nav aria-label="Меню в подвале">
|
||||||
|
<ul>
|
||||||
|
<li><a href="learn.html">Курс</a></li>
|
||||||
|
<li><a href="tsumego.html">Цумэ-го</a></li>
|
||||||
|
<li><a href="play.html">Играть</a></li>
|
||||||
|
<li><a href="cabinet.html">Кабинет</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<span>© 2026 Иго</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
161
docs/design/kimi-export/play.html
Normal file
161
docs/design/kimi-export/play.html
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ru" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Играть против ИИ — Иго · школа Го</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Партия Го против компьютера: доски 9×9, 13×13 и 19×19, шесть уровней ИИ, подсказки трёх лучших ходов."
|
||||||
|
/>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=PT+Sans:wght@400;700&family=PT+Serif:wght@400;700&family=PT+Mono&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="css/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="container">
|
||||||
|
<a class="logo" href="index.html"><span class="kanji">碁</span> Иго · школа Го</a>
|
||||||
|
<nav class="main-nav" aria-label="Основное меню">
|
||||||
|
<ul>
|
||||||
|
<li><a href="learn.html">Курс</a></li>
|
||||||
|
<li><a href="tsumego.html">Цумэ-го</a></li>
|
||||||
|
<li><a href="play.html" aria-current="page">Играть</a></li>
|
||||||
|
<li><a href="progress.html">Прогресс</a></li>
|
||||||
|
<li><a href="cabinet.html">Кабинет</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="menu-toggle" type="button" aria-label="Открыть меню" aria-expanded="false">
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="page-head">
|
||||||
|
<div class="container">
|
||||||
|
<p class="kicker">партия против компьютера</p>
|
||||||
|
<h1>Играть</h1>
|
||||||
|
<p>
|
||||||
|
Нажмите на пересечение, чтобы поставить камень. ИИ играет по правилам с захватом,
|
||||||
|
запретом самоубийства и простым ко.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="play-layout">
|
||||||
|
<div class="board-wrap">
|
||||||
|
<svg id="goban" role="application" aria-label="Игровая доска Го"></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="play-panel">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Настройки партии</h2>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="board-size">Доска</label>
|
||||||
|
<select id="board-size" name="board-size">
|
||||||
|
<option value="9" selected>9 × 9</option>
|
||||||
|
<option value="13">13 × 13</option>
|
||||||
|
<option value="19">19 × 19</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="ai-level">Уровень ИИ</label>
|
||||||
|
<select id="ai-level" name="ai-level">
|
||||||
|
<option value="1">1 — новичок</option>
|
||||||
|
<option value="2" selected>2 — ученик</option>
|
||||||
|
<option value="3">3 — любитель</option>
|
||||||
|
<option value="4">4 — крепкий</option>
|
||||||
|
<option value="5">5 — сильный</option>
|
||||||
|
<option value="6">6 — мастер</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="color">Ваш цвет</label>
|
||||||
|
<select id="color" name="color">
|
||||||
|
<option value="black" selected>Чёрные</option>
|
||||||
|
<option value="white">Белые</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="komi">Коми</label>
|
||||||
|
<select id="komi" name="komi">
|
||||||
|
<option value="0">0</option>
|
||||||
|
<option value="5.5">5,5</option>
|
||||||
|
<option value="6.5" selected>6,5</option>
|
||||||
|
<option value="7.5">7,5</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Пленники</h2>
|
||||||
|
<div class="captures">
|
||||||
|
<span class="side"
|
||||||
|
><span class="stone-dot black"></span> Чёрные взяли:
|
||||||
|
<strong data-captures="black">0</strong></span
|
||||||
|
>
|
||||||
|
<span class="side"
|
||||||
|
><span class="stone-dot white"></span> Белые взяли:
|
||||||
|
<strong data-captures="white">0</strong></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-line" data-status role="status">Новая партия. Ваш ход.</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Три лучших хода</h2>
|
||||||
|
<ul class="hints-list" data-hints></ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Управление</h2>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn--small" type="button" data-action="pass">Пас</button>
|
||||||
|
<button class="btn btn--small btn--quiet" type="button" data-action="undo">
|
||||||
|
Отменить ход
|
||||||
|
</button>
|
||||||
|
<button class="btn btn--small btn--danger" type="button" data-action="resign">
|
||||||
|
Сдаться
|
||||||
|
</button>
|
||||||
|
<button class="btn btn--small btn--primary" type="button" data-action="new">
|
||||||
|
Новая партия
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="container">
|
||||||
|
<span class="privacy-note"
|
||||||
|
>◎ Без сбора данных: прогресс хранится только на вашем устройстве.</span
|
||||||
|
>
|
||||||
|
<nav aria-label="Меню в подвале">
|
||||||
|
<ul>
|
||||||
|
<li><a href="learn.html">Курс</a></li>
|
||||||
|
<li><a href="tsumego.html">Цумэ-го</a></li>
|
||||||
|
<li><a href="play.html">Играть</a></li>
|
||||||
|
<li><a href="cabinet.html">Кабинет</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<span>© 2026 Иго</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="js/main.js"></script>
|
||||||
|
<script src="js/go.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
105
docs/design/kimi-export/progress.html
Normal file
105
docs/design/kimi-export/progress.html
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ru" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Прогресс — Иго · школа Го</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Ваш прогресс в школе Го: пройденные уроки, решённые задачи, серия дней."
|
||||||
|
/>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=PT+Sans:wght@400;700&family=PT+Serif:wght@400;700&family=PT+Mono&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="css/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="container">
|
||||||
|
<a class="logo" href="index.html"><span class="kanji">碁</span> Иго · школа Го</a>
|
||||||
|
<nav class="main-nav" aria-label="Основное меню">
|
||||||
|
<ul>
|
||||||
|
<li><a href="learn.html">Курс</a></li>
|
||||||
|
<li><a href="tsumego.html">Цумэ-го</a></li>
|
||||||
|
<li><a href="play.html">Играть</a></li>
|
||||||
|
<li><a href="progress.html" aria-current="page">Прогресс</a></li>
|
||||||
|
<li><a href="cabinet.html">Кабинет</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="menu-toggle" type="button" aria-label="Открыть меню" aria-expanded="false">
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="page-head">
|
||||||
|
<div class="container">
|
||||||
|
<p class="kicker">тихий счёт упорству</p>
|
||||||
|
<h1>Прогресс</h1>
|
||||||
|
<p>
|
||||||
|
Всё считается локально, на этом устройстве. Ничего не отправляется на сервер — поэтому
|
||||||
|
цифры честные.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container" data-stats data-total-lessons="10" data-total-problems="12">
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="value" data-stat="lessons">0 / 10</div>
|
||||||
|
<div class="label">уроков пройдено</div>
|
||||||
|
</div>
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="value" data-stat="problems">0 / 12</div>
|
||||||
|
<div class="label">задач решено</div>
|
||||||
|
</div>
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="value" data-stat="streak">1</div>
|
||||||
|
<div class="label">дней подряд</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="max-width: 640px">
|
||||||
|
<h2 style="margin-bottom: 14px">Путь по курсу</h2>
|
||||||
|
<div class="progress-track" role="progressbar" aria-label="Прогресс курса">
|
||||||
|
<div class="progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<p class="progress-note" data-stat="pct">Курс пройден на 0%</p>
|
||||||
|
<p class="progress-note">
|
||||||
|
Десять минут в день надёжнее трёх часов раз в месяц: Го растёт на регулярности, а не на
|
||||||
|
подвигах.
|
||||||
|
</p>
|
||||||
|
<div class="btn-row" style="margin-top: 16px">
|
||||||
|
<a class="btn btn--primary" href="learn.html">Продолжить курс</a>
|
||||||
|
<a class="btn btn--quiet" href="tsumego.html">К задачам</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="container">
|
||||||
|
<span class="privacy-note"
|
||||||
|
>◎ Без сбора данных: прогресс хранится только на вашем устройстве.</span
|
||||||
|
>
|
||||||
|
<nav aria-label="Меню в подвале">
|
||||||
|
<ul>
|
||||||
|
<li><a href="learn.html">Курс</a></li>
|
||||||
|
<li><a href="tsumego.html">Цумэ-го</a></li>
|
||||||
|
<li><a href="play.html">Играть</a></li>
|
||||||
|
<li><a href="cabinet.html">Кабинет</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<span>© 2026 Иго</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
135
docs/design/kimi-export/register.html
Normal file
135
docs/design/kimi-export/register.html
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ru" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Регистрация — Иго · школа Го</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Регистрация в школе Го для синхронизации прогресса между устройствами."
|
||||||
|
/>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=PT+Sans:wght@400;700&family=PT+Serif:wght@400;700&family=PT+Mono&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="css/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="container">
|
||||||
|
<a class="logo" href="index.html"><span class="kanji">碁</span> Иго · школа Го</a>
|
||||||
|
<nav class="main-nav" aria-label="Основное меню">
|
||||||
|
<ul>
|
||||||
|
<li><a href="learn.html">Курс</a></li>
|
||||||
|
<li><a href="tsumego.html">Цумэ-го</a></li>
|
||||||
|
<li><a href="play.html">Играть</a></li>
|
||||||
|
<li><a href="progress.html">Прогресс</a></li>
|
||||||
|
<li><a href="cabinet.html" aria-current="page">Кабинет</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="menu-toggle" type="button" aria-label="Открыть меню" aria-expanded="false">
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="container">
|
||||||
|
<div class="card auth-card">
|
||||||
|
<h1>Регистрация</h1>
|
||||||
|
<p style="text-align: center; color: var(--ink-soft); font-size: 0.95rem">
|
||||||
|
Одна учётная запись — чтобы прогресс жил на всех устройствах. Никакой рассылки.
|
||||||
|
</p>
|
||||||
|
<form data-login-form novalidate>
|
||||||
|
<div class="field">
|
||||||
|
<label for="email">Электронная почта</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
<span class="error-text">Введите корректный адрес почты.</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">Пароль</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="минимум 8 символов"
|
||||||
|
/>
|
||||||
|
<span class="error-text">Пароль не может быть пустым.</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="password2">Пароль ещё раз</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password2"
|
||||||
|
name="password2"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="повторите пароль"
|
||||||
|
/>
|
||||||
|
<span class="error-text">Пароли не совпадают.</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="captcha">Код с картинки</label>
|
||||||
|
<div class="captcha-row">
|
||||||
|
<span class="captcha-box" data-captcha></span>
|
||||||
|
<button
|
||||||
|
class="btn btn--quiet btn--small"
|
||||||
|
type="button"
|
||||||
|
data-captcha-refresh
|
||||||
|
aria-label="Обновить код"
|
||||||
|
>
|
||||||
|
↻ обновить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="captcha"
|
||||||
|
name="captcha"
|
||||||
|
inputmode="text"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="символы с картинки"
|
||||||
|
style="margin-top: 10px"
|
||||||
|
/>
|
||||||
|
<span class="error-text">Введите код с картинки.</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--primary" type="submit" style="width: 100%">
|
||||||
|
Создать аккаунт
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div class="auth-links">
|
||||||
|
<span>Уже есть аккаунт? <a href="cabinet.html">Войдите</a></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="container">
|
||||||
|
<span class="privacy-note"
|
||||||
|
>◎ Без сбора данных: прогресс хранится только на вашем устройстве.</span
|
||||||
|
>
|
||||||
|
<nav aria-label="Меню в подвале">
|
||||||
|
<ul>
|
||||||
|
<li><a href="learn.html">Курс</a></li>
|
||||||
|
<li><a href="tsumego.html">Цумэ-го</a></li>
|
||||||
|
<li><a href="play.html">Играть</a></li>
|
||||||
|
<li><a href="cabinet.html">Кабинет</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<span>© 2026 Иго</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1363
docs/design/kimi-export/style.css
Normal file
1363
docs/design/kimi-export/style.css
Normal file
File diff suppressed because it is too large
Load diff
340
docs/design/kimi-export/tsumego.html
Normal file
340
docs/design/kimi-export/tsumego.html
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ru" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Цумэ-го — Иго · школа Го</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Задачи цумэ-го с фильтрами по теме и сложности: жизнь и смерть, сэмэаи, лестницы, захват."
|
||||||
|
/>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=PT+Sans:wght@400;700&family=PT+Serif:wght@400;700&family=PT+Mono&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="css/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="container">
|
||||||
|
<a class="logo" href="index.html"><span class="kanji">碁</span> Иго · школа Го</a>
|
||||||
|
<nav class="main-nav" aria-label="Основное меню">
|
||||||
|
<ul>
|
||||||
|
<li><a href="learn.html">Курс</a></li>
|
||||||
|
<li><a href="tsumego.html" aria-current="page">Цумэ-го</a></li>
|
||||||
|
<li><a href="play.html">Играть</a></li>
|
||||||
|
<li><a href="progress.html">Прогресс</a></li>
|
||||||
|
<li><a href="cabinet.html">Кабинет</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="menu-toggle" type="button" aria-label="Открыть меню" aria-expanded="false">
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="page-head">
|
||||||
|
<div class="container">
|
||||||
|
<p class="kicker">жизнь и смерть в миниатюре</p>
|
||||||
|
<h1>Цумэ-го</h1>
|
||||||
|
<p>
|
||||||
|
Задачи на локальную тактику. Ход чёрных — если не сказано иное. Решайте в уме, затем
|
||||||
|
проверяйте на доске в разделе «Играть».
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<form class="card filters" data-tsumego-filters aria-label="Фильтры задач">
|
||||||
|
<div class="field">
|
||||||
|
<label for="f-theme">Тема</label>
|
||||||
|
<select id="f-theme" name="theme">
|
||||||
|
<option value="all">Все темы</option>
|
||||||
|
<option value="life">Жизнь и смерть</option>
|
||||||
|
<option value="capture">Захват</option>
|
||||||
|
<option value="ladder">Лестницы</option>
|
||||||
|
<option value="semeai">Сэмэаи</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="f-diff">Сложность</label>
|
||||||
|
<select id="f-diff" name="difficulty">
|
||||||
|
<option value="all">Любая</option>
|
||||||
|
<option value="1">★ 1 — разминка</option>
|
||||||
|
<option value="2">★ 2 — легко</option>
|
||||||
|
<option value="3">★ 3 — средне</option>
|
||||||
|
<option value="4">★ 4 — трудно</option>
|
||||||
|
<option value="5">★ 5 — для упёртых</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="f-status">Статус</label>
|
||||||
|
<select id="f-status" name="status">
|
||||||
|
<option value="all">Все</option>
|
||||||
|
<option value="unsolved">Нерешённые</option>
|
||||||
|
<option value="solved">Решённые</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<button class="btn btn--quiet btn--small" type="reset">Сбросить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="problems-grid">
|
||||||
|
<div
|
||||||
|
class="card problem-card"
|
||||||
|
data-problem="p01"
|
||||||
|
data-theme="capture"
|
||||||
|
data-difficulty="1"
|
||||||
|
>
|
||||||
|
<div class="thumb">
|
||||||
|
<div
|
||||||
|
class="goban-static"
|
||||||
|
data-size="9"
|
||||||
|
data-black="d4,c5,e5"
|
||||||
|
data-white="d5"
|
||||||
|
data-mark="d6"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<h3>Последнее дыхание</h3>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="stars">★<span class="off">★★★★</span></span
|
||||||
|
><span data-solved-slot></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="card problem-card"
|
||||||
|
data-problem="p02"
|
||||||
|
data-theme="capture"
|
||||||
|
data-difficulty="1"
|
||||||
|
>
|
||||||
|
<div class="thumb">
|
||||||
|
<div
|
||||||
|
class="goban-static"
|
||||||
|
data-size="9"
|
||||||
|
data-black="b2,c3,b4"
|
||||||
|
data-white="b3"
|
||||||
|
data-mark="a3"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<h3>Камень у края</h3>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="stars">★<span class="off">★★★★</span></span
|
||||||
|
><span data-solved-slot></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card problem-card" data-problem="p03" data-theme="life" data-difficulty="2">
|
||||||
|
<div class="thumb">
|
||||||
|
<div
|
||||||
|
class="goban-static"
|
||||||
|
data-size="9"
|
||||||
|
data-black="b2,c2,d2,b3,d3,b4,c4"
|
||||||
|
data-white="c3,d4"
|
||||||
|
data-mark="e3"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<h3>Один глаз или два?</h3>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="stars">★★<span class="off">★★★</span></span
|
||||||
|
><span data-solved-slot></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card problem-card" data-problem="p04" data-theme="ladder" data-difficulty="2">
|
||||||
|
<div class="thumb">
|
||||||
|
<div
|
||||||
|
class="goban-static"
|
||||||
|
data-size="9"
|
||||||
|
data-black="e5,f4"
|
||||||
|
data-white="e4,f5,g6"
|
||||||
|
data-mark="d4"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<h3>Лестница работает?</h3>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="stars">★★<span class="off">★★★</span></span
|
||||||
|
><span data-solved-slot></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card problem-card" data-problem="p05" data-theme="life" data-difficulty="3">
|
||||||
|
<div class="thumb">
|
||||||
|
<div
|
||||||
|
class="goban-static"
|
||||||
|
data-size="9"
|
||||||
|
data-black="b2,c2,d2,e2,b3,e3"
|
||||||
|
data-white="c3,d3,b4,c4,d4,e4"
|
||||||
|
data-mark="c5"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<h3>Гнутая четвёрка</h3>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="stars">★★★<span class="off">★★</span></span
|
||||||
|
><span data-solved-slot></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card problem-card" data-problem="p06" data-theme="semeai" data-difficulty="3">
|
||||||
|
<div class="thumb">
|
||||||
|
<div
|
||||||
|
class="goban-static"
|
||||||
|
data-size="9"
|
||||||
|
data-black="d3,d4,d5,e5"
|
||||||
|
data-white="e3,e4,f4,f5"
|
||||||
|
data-mark="c4"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<h3>Гонка на захват</h3>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="stars">★★★<span class="off">★★</span></span
|
||||||
|
><span data-solved-slot></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="card problem-card"
|
||||||
|
data-problem="p07"
|
||||||
|
data-theme="capture"
|
||||||
|
data-difficulty="3"
|
||||||
|
>
|
||||||
|
<div class="thumb">
|
||||||
|
<div
|
||||||
|
class="goban-static"
|
||||||
|
data-size="9"
|
||||||
|
data-black="c4,d3,e4,d5"
|
||||||
|
data-white="d4,c5"
|
||||||
|
data-mark="e5"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<h3>Сеть вместо лестницы</h3>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="stars">★★★<span class="off">★★</span></span
|
||||||
|
><span data-solved-slot></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card problem-card" data-problem="p08" data-theme="ladder" data-difficulty="4">
|
||||||
|
<div class="thumb">
|
||||||
|
<div
|
||||||
|
class="goban-static"
|
||||||
|
data-size="9"
|
||||||
|
data-black="e5,f4,g3"
|
||||||
|
data-white="e4,f5,c7"
|
||||||
|
data-mark="d3"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<h3>Лестница с приманкой</h3>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="stars">★★★★<span class="off">★</span></span
|
||||||
|
><span data-solved-slot></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card problem-card" data-problem="p09" data-theme="semeai" data-difficulty="4">
|
||||||
|
<div class="thumb">
|
||||||
|
<div
|
||||||
|
class="goban-static"
|
||||||
|
data-size="9"
|
||||||
|
data-black="c3,d3,e3,c4,e4"
|
||||||
|
data-white="c5,d5,e5,d4"
|
||||||
|
data-mark="f4"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<h3>Четыре против трёх</h3>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="stars">★★★★<span class="off">★</span></span
|
||||||
|
><span data-solved-slot></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card problem-card" data-problem="p10" data-theme="life" data-difficulty="5">
|
||||||
|
<div class="thumb">
|
||||||
|
<div
|
||||||
|
class="goban-static"
|
||||||
|
data-size="9"
|
||||||
|
data-black="b2,c2,d2,e2,f2,b3,f3"
|
||||||
|
data-white="c3,d3,e3,b4,c4,d4,e4,f4"
|
||||||
|
data-mark="g3"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<h3>Шесть в углу живут?</h3>
|
||||||
|
<div class="meta"><span class="stars">★★★★★</span><span data-solved-slot></span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="card problem-card"
|
||||||
|
data-problem="p11"
|
||||||
|
data-theme="capture"
|
||||||
|
data-difficulty="2"
|
||||||
|
>
|
||||||
|
<div class="thumb">
|
||||||
|
<div
|
||||||
|
class="goban-static"
|
||||||
|
data-size="9"
|
||||||
|
data-black="d6,e5"
|
||||||
|
data-white="d5,e6,f6"
|
||||||
|
data-mark="c5"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<h3>Двойное атари</h3>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="stars">★★<span class="off">★★★</span></span
|
||||||
|
><span data-solved-slot></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card problem-card" data-problem="p12" data-theme="life" data-difficulty="1">
|
||||||
|
<div class="thumb">
|
||||||
|
<div
|
||||||
|
class="goban-static"
|
||||||
|
data-size="9"
|
||||||
|
data-black="b2,c2,d2,b4,c4,d4"
|
||||||
|
data-white="b3,c3,d3"
|
||||||
|
data-mark="e3"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<h3>Прямая тройка</h3>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="stars">★<span class="off">★★★★</span></span
|
||||||
|
><span data-solved-slot></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="empty-state" data-empty-state style="display: none">
|
||||||
|
<span class="glyph">○ ●</span>
|
||||||
|
<p>
|
||||||
|
<strong>Под фильтры ничего не подошло.</strong><br />Ослабьте условия — или отметьтесь в
|
||||||
|
разделе «Прогресс», если решили всё.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="container">
|
||||||
|
<span class="privacy-note"
|
||||||
|
>◎ Без сбора данных: прогресс хранится только на вашем устройстве.</span
|
||||||
|
>
|
||||||
|
<nav aria-label="Меню в подвале">
|
||||||
|
<ul>
|
||||||
|
<li><a href="learn.html">Курс</a></li>
|
||||||
|
<li><a href="tsumego.html">Цумэ-го</a></li>
|
||||||
|
<li><a href="play.html">Играть</a></li>
|
||||||
|
<li><a href="cabinet.html">Кабинет</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<span>© 2026 Иго</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
61
docs/design/oauth-vk-yandex.md
Normal file
61
docs/design/oauth-vk-yandex.md
Normal file
|
|
@ -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/<provider>/start.php` → 302 на
|
||||||
|
авторизацию провайдера (state = random в сессии, anti-CSRF);
|
||||||
|
`GET /api/oauth/<provider>/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 сессия.
|
||||||
62
docs/plan-phase-6.md
Normal file
62
docs/plan-phase-6.md
Normal file
|
|
@ -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-элемент `<go-task data-task-id="…">`,
|
||||||
|
гидратация клиентским скриптом страницы. Никаких 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 важнее количества сверх минимума;
|
||||||
|
валидационный тест — обязательный страховочный гейт.
|
||||||
40
docs/plans/plan-phase-1.md
Normal file
40
docs/plans/plan-phase-1.md
Normal file
|
|
@ -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 не переходим без этого.
|
||||||
59
docs/plans/plan-phase-10.md
Normal file
59
docs/plans/plan-phase-10.md
Normal file
|
|
@ -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 — в бюджете.
|
||||||
60
docs/plans/plan-phase-11.md
Normal file
60
docs/plans/plan-phase-11.md
Normal file
|
|
@ -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 могут быть медленными/недоступными — тогда
|
||||||
|
честный «не найдено» по части утверждений.
|
||||||
70
docs/plans/plan-phase-2.md
Normal file
70
docs/plans/plan-phase-2.md
Normal file
|
|
@ -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-страница не нужна.
|
||||||
53
docs/plans/plan-phase-3.md
Normal file
53
docs/plans/plan-phase-3.md
Normal file
|
|
@ -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).
|
||||||
81
docs/plans/plan-phase-4.md
Normal file
81
docs/plans/plan-phase-4.md
Normal file
|
|
@ -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).
|
||||||
82
docs/plans/plan-phase-5.md
Normal file
82
docs/plans/plan-phase-5.md
Normal file
|
|
@ -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.
|
||||||
62
docs/plans/plan-phase-6.md
Normal file
62
docs/plans/plan-phase-6.md
Normal file
|
|
@ -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-элемент `<go-task data-task-id="…">`,
|
||||||
|
гидратация клиентским скриптом страницы. Никаких 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 важнее количества сверх минимума;
|
||||||
|
валидационный тест — обязательный страховочный гейт.
|
||||||
50
docs/plans/plan-phase-7.md
Normal file
50
docs/plans/plan-phase-7.md
Normal file
|
|
@ -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 при жалобах на подсчёт).
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue