chore: восстановление репозитория из снапшота v0.3.1

Прежняя git-история утрачена при переносе проекта на машину владельца
(снапшот без .git). Хэши коммитов в docs/reports/* относятся к утраченной
истории.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
sab.code.lab 2026-08-07 09:34:02 +02:00
commit 1c85091186
184 changed files with 33303 additions and 0 deletions

17
apps/web/astro.config.mjs Normal file
View 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
View 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
View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -0,0 +1,3 @@
# Владелец (2026-08-07): сайт не для индексации поисковиками.
User-agent: *
Disallow: /

18
apps/web/src/ai-worker.ts Normal file
View file

@ -0,0 +1,18 @@
/**
* Точка входа Web Worker ИИ ур. 46 (этап 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);

View 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),
}),
};

View 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 15.
*
* Проверки уроков: frontmatter (title/section/order/summary), section из
* программы курса, существование data-task-id, покрытие всех 10 разделов,
* объём текста 300700 слов.
*/
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[] = [
'Дамэ и захват',
'Атари, лестница, сеть',
'Самоубийство и ко',
'Глаза: жизнь и смерть',
'Подсчёт и конец партии',
'Связь и разрезание',
'Углы, стороны, центр',
'Базовые дзёсэки',
'Форма',
'Простое ёсэ',
];
/** Разделы, в уроках которых обязателен интерактив (уроки 16 и 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 должен быть целым 15');
}
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 15 не менее 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('объём текста 300700 слов', () => {
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('разделы 16 и 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);
}
});
});

View 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>
## Запомнить
- Дамэ — свободные соседние пункты камня или группы.
- Нет дамэ — камни снимаются с доски.
- Одно дамэ — атари, группа под угрозой.
- Группа делит дамэ на все свои камни.

View 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>
## Запомнить
- Атари — угроза захвата в один ход.
- Лестница — серия атари, оставляющих один путь; проверяйте диагональ на
наличие опоры.
- Сеть — тихий захват без атари, когда лестница не проходит.

View 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>
## Запомнить
- Ход без дамэ запрещён — если только он не захватывает.
- Ко запрещает немедленный обратный захват одного камня.
- Ко-угроза — способ «купить» право вернуться в ко.

View 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>
## Запомнить
- Глаз — пустое пространство, окружённое одним цветом.
- Два глаза = вечная жизнь; один глаз = смерть под вопросом.
- Ложный глаз не защищает: проверяйте связность камней вокруг него.

View 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>
## Запомнить
- Два паса подряд заканчивают партию.
- Счёт: камни + территория; белые получают коми.
- Мёртвые камни снимаются по согласию; спор решается доигрыванием.

View 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>
## Запомнить
- Связанная группа сильнее суммы частей.
- Косуми само по себе неразрезаемо, но уязвимо при сильных камнях вокруг.
- Разрезая, позаботьтесь о жизни разрезающего камня.

View 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. Никогда не играйте на первой-второй линии в дебюте без причины.
## Запомнить
- Край доски — бесплатная стена; центр её лишён.
- Порядок дебюта: углы → стороны → центр.
- Третья линия — территория, четвёртая — влияние.

View 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. Помните, что дзёсэки обновляются: профессионалы постоянно находят новые
варианты, а с появлением сильных программ многие оценки изменились.
## Запомнить
- Дзёсэки — эталонные угловые размены, равные для обеих сторон.
- Учите смысл ходов, а не только их порядок.
- На малой доске классические дзёсэки работают с поправкой на центр.

View 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. Пересматривайте свои партии и отмечайте тяжёлые формы — со временем они
начнут сами резать глаз.
Форма — это эстетика, проверенная логикой: красивый ход в Го почти всегда
оказывается и сильным.
## Запомнить
- Тигровая пасть и бамбук — образцы экономной защиты.
- Пустой треугольник и данго — типичные ошибки новичка.
- Хорошая форма = связность + гибкость + потенциал глаз.

View 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>
## Запомнить
- Ёсэ — игра за очки на границах; мелочей не бывает.
- Сэнтэ раньше готэ; считайте ценность каждого пункта.
- Ханэ на первой линии — рабочая лошадка эндшпиля.

View 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": "Прижмите белых к краю: они не убегут."
}
]

View 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": "Одного продления мало — вытягивайтесь дважды."
}
]

View 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": "Центральная точка превращает Т-форму в два глаза."
}
]

View 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": "Загоните клин в атари, доберите — и заполните брешь."
}
]

View 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
View 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';
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,117 @@
/**
* Тесты lib/ai-client.ts: маршрутизация ур. 13 в синхронный движок,
* ур. 46 в воркер (фейк-воркер через инжекцию фабрики), 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('ур. 13 идут синхронно, воркер не создаётся', 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('ур. 46 уходят в воркер с 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);
});
});

View file

@ -0,0 +1,207 @@
/**
* Клиент ИИ для UI (контракт docs/INTERFACES.md, «Клиент ИИ для UI»).
* Ур. 13 синхронный chooseMove этапа 3 (воркер не нужен), ур. 46
* 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;
/** Синхронный фолбэк ур. 46 в основном потоке; помечает 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;
}
/** Ур. 13: мгновенный синхронный ход эвристики этапа 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());
},
};
}

View file

@ -0,0 +1,198 @@
/**
* Тесты lib/game-store.ts (чистый стор, без DOM): новая партия, ход через
* pendingconfirm, отказ с 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');
});
});

View 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;
},
};
}

View 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
View 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): пустая область из 13 смежных пустых
* пунктов, у которой все ортогональные соседи за её пределами камни одного
* цвета (или край доски). Ложный глаз в 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);
}
}

View 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([]);
});
});

View 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] };
}

View 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
View 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; миграция V1V2 при чтении (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);
}
/**
* Миграция V1V2: 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);
}

View 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');
});
});

View 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;
}
}

View 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) });
});
});

View 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' меняем BW в свойствах ходов. Структура дерева, координаты
* и разметка сохраняются; при проигрывании цвет каждого хода берётся из
* 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 };
}

View 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);
});
});

View 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 };
}

View 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>

View 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>

View 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>
Мёртвые камни снимаются по согласию; при споре игра продолжается (правила 910) —
<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>
Числа «угол 23 / сторона 45 / центр 810 камней» (урок 07) — источник не найден; в
каноне другие числа (стены 2/3/4; жизнь минимум 6/8/10 камней).
</li>
<li>
Термин «линия смерти» для первой линии (урок 07) — в авторитетных источниках не
подтверждён.
</li>
<li>
Диапазон «ходы ёсэ приносят 13 очка» (урок 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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: 'Правила и базовые термины (уроки 0103)',
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 ИИ уровней 46 играет заметно слабее (таргет движка — 9×9 и 13×13).',
hintsUnavailable: 'Подсказки доступны только на уровнях ИИ 46.',
},
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);
}

View 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
View 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 один раз на прогон (общий для этапов 67). */
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
View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"outDir": "dist-ts",
"types": ["node"]
},
"include": ["src"]
}