chore: восстановление репозитория из снапшота v0.3.1
Прежняя git-история утрачена при переносе проекта на машину владельца (снапшот без .git). Хэши коммитов в docs/reports/* относятся к утраченной истории. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
1c85091186
184 changed files with 33303 additions and 0 deletions
18
apps/web/src/ai-worker.ts
Normal file
18
apps/web/src/ai-worker.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* Точка входа Web Worker ИИ ур. 4–6 (этап 5). Подключение по комментарию
|
||||
* packages/ai/src/worker.ts: `self.onmessage = (e) => createWorkerHandler(self)(e.data)`.
|
||||
* Сборка — Vite: new Worker(new URL('./ai-worker.ts', import.meta.url), { type: 'module' })
|
||||
* (см. lib/ai-client.ts, defaultWorkerFactory).
|
||||
*/
|
||||
import { createWorkerHandler } from '@go-learn/ai';
|
||||
import type { WorkerRequest, WorkerResponse } from '@go-learn/ai';
|
||||
|
||||
/** Минимальный срез DedicatedWorkerGlobalScope, нужный обработчику. */
|
||||
interface WorkerScope {
|
||||
onmessage: ((event: { data: WorkerRequest }) => void) | null;
|
||||
postMessage(message: WorkerResponse): void;
|
||||
}
|
||||
|
||||
const scope = self as unknown as WorkerScope;
|
||||
const handle = createWorkerHandler(scope);
|
||||
scope.onmessage = (event): void => handle(event.data);
|
||||
53
apps/web/src/content.config.ts
Normal file
53
apps/web/src/content.config.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* Коллекции контента (этап 6): уроки (Markdown) и задачи (JSON — массив
|
||||
* TaskV1 на файл). Контракт — docs/INTERFACES.md, «Форматы данных уроков и
|
||||
* задач»: zod-схемы повторяют frontmatter урока и TaskV1.
|
||||
*/
|
||||
import { defineCollection } from 'astro:content';
|
||||
import { glob } from 'astro/loaders';
|
||||
import { z } from 'astro:schema';
|
||||
|
||||
const pointSchema = z.object({
|
||||
x: z.number().int().min(0),
|
||||
y: z.number().int().min(0),
|
||||
});
|
||||
|
||||
const goalSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('capture-target') }),
|
||||
z.object({ type: z.literal('liberties'), count: z.number().int().min(1) }),
|
||||
z.object({ type: z.literal('connect') }),
|
||||
z.object({ type: z.literal('two-eyes') }),
|
||||
]);
|
||||
|
||||
const taskSchema = z.object({
|
||||
id: z.string().regex(/^(lsn|tsm)-[a-z0-9]+(-[a-z0-9]+)*$/),
|
||||
kind: z.enum(['lesson', 'tsumego']),
|
||||
title: z.string().min(1),
|
||||
topics: z.array(z.string().min(1)),
|
||||
difficulty: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5)]),
|
||||
size: z.union([z.literal(9), z.literal(13), z.literal(19)]),
|
||||
toPlay: z.enum(['black', 'white']),
|
||||
setupBlack: z.array(pointSchema),
|
||||
setupWhite: z.array(pointSchema),
|
||||
sgf: z.string().min(1),
|
||||
goal: goalSchema,
|
||||
hint: z.string().optional(),
|
||||
});
|
||||
|
||||
const lessonSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
section: z.string().min(1),
|
||||
order: z.number().int().min(1),
|
||||
summary: z.string().min(1),
|
||||
});
|
||||
|
||||
export const collections = {
|
||||
lessons: defineCollection({
|
||||
loader: glob({ pattern: '*.md', base: './src/content/lessons' }),
|
||||
schema: lessonSchema,
|
||||
}),
|
||||
tasks: defineCollection({
|
||||
loader: glob({ pattern: '*.json', base: './src/content/tasks' }),
|
||||
schema: z.array(taskSchema),
|
||||
}),
|
||||
};
|
||||
345
apps/web/src/content/content.test.ts
Normal file
345
apps/web/src/content/content.test.ts
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
/**
|
||||
* Валидация контента этапа 6: задачи (TaskV1 + SGF-деревья решений) и уроки.
|
||||
* Читает файлы через node:fs, движок — только @go-learn/core.
|
||||
*
|
||||
* Проверки задач: обязательные поля, уникальность id, префикс id по kind,
|
||||
* легальность позиции (buildPosition), парсинг SGF, разметка цели на корне
|
||||
* (TR — capture-target/liberties/two-eyes, CR — connect) на камнях нужного
|
||||
* цвета, легальность каждой вариации от позиции задачи (applyMove),
|
||||
* чередование цветов от toPlay. Сводные: ≥30 цумэ-го, ≥3 на каждую
|
||||
* градацию difficulty 1–5.
|
||||
*
|
||||
* Проверки уроков: frontmatter (title/section/order/summary), section из
|
||||
* программы курса, существование data-task-id, покрытие всех 10 разделов,
|
||||
* объём текста 300–700 слов.
|
||||
*/
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { BoardSize, BoardState, CellState, Color, GameNode, Point } from '@go-learn/core';
|
||||
import { applyMove, buildPosition, cellAt, opposite, parseSgf } from '@go-learn/core';
|
||||
|
||||
type Goal =
|
||||
| { readonly type: 'capture-target' }
|
||||
| { readonly type: 'liberties'; readonly count: number }
|
||||
| { readonly type: 'connect' }
|
||||
| { readonly type: 'two-eyes' };
|
||||
|
||||
interface TaskV1 {
|
||||
readonly id: string;
|
||||
readonly kind: 'lesson' | 'tsumego';
|
||||
readonly title: string;
|
||||
readonly topics: readonly string[];
|
||||
readonly difficulty: number;
|
||||
readonly size: BoardSize;
|
||||
readonly toPlay: Color;
|
||||
readonly setupBlack: readonly Point[];
|
||||
readonly setupWhite: readonly Point[];
|
||||
readonly sgf: string;
|
||||
readonly goal: Goal;
|
||||
readonly hint: string;
|
||||
}
|
||||
|
||||
const SECTIONS: readonly string[] = [
|
||||
'Дамэ и захват',
|
||||
'Атари, лестница, сеть',
|
||||
'Самоубийство и ко',
|
||||
'Глаза: жизнь и смерть',
|
||||
'Подсчёт и конец партии',
|
||||
'Связь и разрезание',
|
||||
'Углы, стороны, центр',
|
||||
'Базовые дзёсэки',
|
||||
'Форма',
|
||||
'Простое ёсэ',
|
||||
];
|
||||
|
||||
/** Разделы, в уроках которых обязателен интерактив (уроки 1–6 и 10). */
|
||||
const INTERACTIVE_SECTIONS: readonly string[] = [
|
||||
'Дамэ и захват',
|
||||
'Атари, лестница, сеть',
|
||||
'Самоубийство и ко',
|
||||
'Глаза: жизнь и смерть',
|
||||
'Подсчёт и конец партии',
|
||||
'Связь и разрезание',
|
||||
'Простое ёсэ',
|
||||
];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function fail(ctx: string, message: string): never {
|
||||
throw new Error(`${ctx}: ${message}`);
|
||||
}
|
||||
|
||||
function parsePoints(value: unknown, ctx: string): Point[] {
|
||||
if (!Array.isArray(value)) fail(ctx, 'ожидался массив точек');
|
||||
return value.map((item, index) => {
|
||||
if (!isRecord(item)) fail(`${ctx}[${index}]`, 'точка — не объект');
|
||||
const { x, y } = item;
|
||||
if (!Number.isInteger(x) || !Number.isInteger(y)) {
|
||||
fail(`${ctx}[${index}]`, 'координаты x/y должны быть целыми');
|
||||
}
|
||||
return { x: x as number, y: y as number };
|
||||
});
|
||||
}
|
||||
|
||||
function parseGoal(value: unknown, ctx: string): Goal {
|
||||
if (!isRecord(value)) fail(ctx, 'goal — не объект');
|
||||
const type = value['type'];
|
||||
if (type === 'capture-target' || type === 'connect' || type === 'two-eyes') {
|
||||
return { type };
|
||||
}
|
||||
if (type === 'liberties') {
|
||||
const count = value['count'];
|
||||
if (!Number.isInteger(count) || (count as number) < 1) {
|
||||
fail(ctx, 'goal.count должен быть целым ≥ 1');
|
||||
}
|
||||
return { type, count: count as number };
|
||||
}
|
||||
return fail(ctx, `неизвестный goal.type «${String(type)}»`);
|
||||
}
|
||||
|
||||
function parseTask(raw: unknown, ctx: string): TaskV1 {
|
||||
if (!isRecord(raw)) fail(ctx, 'задача — не объект');
|
||||
const { id, kind, title, topics, difficulty, size, toPlay, sgf, hint } = raw;
|
||||
if (typeof id !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) {
|
||||
fail(ctx, 'id должен быть kebab-case');
|
||||
}
|
||||
if (kind !== 'lesson' && kind !== 'tsumego') fail(ctx, 'kind должен быть lesson|tsumego');
|
||||
const prefix = kind === 'lesson' ? 'lsn-' : 'tsm-';
|
||||
if (!id.startsWith(prefix)) fail(ctx, `id должен начинаться с «${prefix}»`);
|
||||
if (typeof title !== 'string' || title.trim() === '') fail(ctx, 'пустой title');
|
||||
if (
|
||||
!Array.isArray(topics) ||
|
||||
topics.length === 0 ||
|
||||
!topics.every((t) => typeof t === 'string' && t.trim() !== '')
|
||||
) {
|
||||
fail(ctx, 'topics должен быть непустым массивом строк');
|
||||
}
|
||||
if (!Number.isInteger(difficulty) || (difficulty as number) < 1 || (difficulty as number) > 5) {
|
||||
fail(ctx, 'difficulty должен быть целым 1–5');
|
||||
}
|
||||
if (size !== 9 && size !== 13 && size !== 19) fail(ctx, 'size должен быть 9|13|19');
|
||||
if (toPlay !== 'black' && toPlay !== 'white') fail(ctx, 'toPlay должен быть black|white');
|
||||
if (typeof sgf !== 'string' || sgf.trim() === '') fail(ctx, 'пустой sgf');
|
||||
if (typeof hint !== 'string' || hint.trim() === '') fail(ctx, 'пустой hint');
|
||||
const goal = parseGoal(raw['goal'], ctx);
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
title,
|
||||
topics: topics as string[],
|
||||
difficulty: difficulty as number,
|
||||
size,
|
||||
toPlay,
|
||||
setupBlack: parsePoints(raw['setupBlack'], `${ctx}.setupBlack`),
|
||||
setupWhite: parsePoints(raw['setupWhite'], `${ctx}.setupWhite`),
|
||||
sgf,
|
||||
goal,
|
||||
hint,
|
||||
};
|
||||
}
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const tasksDir = join(here, 'tasks');
|
||||
const lessonsDir = join(here, 'lessons');
|
||||
|
||||
const loadErrors: string[] = [];
|
||||
const allTasks: TaskV1[] = [];
|
||||
const taskFiles = readdirSync(tasksDir)
|
||||
.filter((name) => name.endsWith('.json'))
|
||||
.sort();
|
||||
for (const file of taskFiles) {
|
||||
try {
|
||||
const raw: unknown = JSON.parse(readFileSync(join(tasksDir, file), 'utf8'));
|
||||
if (!Array.isArray(raw)) fail(file, 'корень JSON — не массив');
|
||||
raw.forEach((item, index) => {
|
||||
allTasks.push(parseTask(item, `${file}[${index}]`));
|
||||
});
|
||||
} catch (error) {
|
||||
loadErrors.push(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
describe('задачи (tasks/*.json)', () => {
|
||||
it('все файлы парсятся как массивы TaskV1', () => {
|
||||
expect(loadErrors).toEqual([]);
|
||||
expect(taskFiles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('id уникальны', () => {
|
||||
const seen = new Set<string>();
|
||||
const duplicates: string[] = [];
|
||||
for (const task of allTasks) {
|
||||
if (seen.has(task.id)) duplicates.push(task.id);
|
||||
seen.add(task.id);
|
||||
}
|
||||
expect(duplicates).toEqual([]);
|
||||
});
|
||||
|
||||
for (const task of allTasks) {
|
||||
describe(task.id, () => {
|
||||
it('позиция легальна (buildPosition не бросает)', () => {
|
||||
expect(() =>
|
||||
buildPosition(task.size, task.setupBlack, task.setupWhite, task.toPlay),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('SGF парсится, размер совпадает', () => {
|
||||
const tree = parseSgf(task.sgf);
|
||||
expect(tree.size).toBe(task.size);
|
||||
});
|
||||
|
||||
it('на корне есть разметка цели на камнях нужного цвета', () => {
|
||||
const tree = parseSgf(task.sgf);
|
||||
const position = buildPosition(task.size, task.setupBlack, task.setupWhite, task.toPlay);
|
||||
const marked =
|
||||
task.goal.type === 'connect' ? tree.root.markup.circles : tree.root.markup.triangles;
|
||||
expect(marked.length).toBeGreaterThan(0);
|
||||
const want: CellState =
|
||||
task.goal.type === 'capture-target' ? opposite(task.toPlay) : task.toPlay;
|
||||
for (const point of marked) {
|
||||
expect(cellAt(position, point)).toBe(want);
|
||||
}
|
||||
});
|
||||
|
||||
it('каждая вариация легальна от позиции задачи, цвета чередуются', () => {
|
||||
const tree = parseSgf(task.sgf);
|
||||
const initial = buildPosition(task.size, task.setupBlack, task.setupWhite, task.toPlay);
|
||||
expect(tree.root.children.length).toBeGreaterThan(0);
|
||||
const walk = (
|
||||
node: GameNode,
|
||||
state: BoardState,
|
||||
expectColor: Color,
|
||||
path: string,
|
||||
): void => {
|
||||
node.children.forEach((child, index) => {
|
||||
const childPath = `${path}/${index}`;
|
||||
expect(child.move, `${task.id}${childPath}: узел без хода`).not.toBeNull();
|
||||
if (child.move === null) return;
|
||||
expect(child.move.color, `${task.id}${childPath}: нарушено чередование`).toBe(
|
||||
expectColor,
|
||||
);
|
||||
const result = applyMove(state, child.move);
|
||||
expect(
|
||||
result.ok,
|
||||
`${task.id}${childPath}: нелегальный ход ${result.ok === false ? result.error : ''}`,
|
||||
).toBe(true);
|
||||
if (result.ok) walk(child, result.state, opposite(expectColor), childPath);
|
||||
});
|
||||
};
|
||||
walk(tree.root, initial, task.toPlay, '');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it('не менее 30 цумэ-го', () => {
|
||||
expect(allTasks.filter((task) => task.kind === 'tsumego').length).toBeGreaterThanOrEqual(30);
|
||||
});
|
||||
|
||||
it('на каждую градацию difficulty 1–5 не менее 3 цумэ-го', () => {
|
||||
for (let difficulty = 1; difficulty <= 5; difficulty += 1) {
|
||||
const count = allTasks.filter(
|
||||
(task) => task.kind === 'tsumego' && task.difficulty === difficulty,
|
||||
).length;
|
||||
expect(count, `difficulty ${difficulty}`).toBeGreaterThanOrEqual(3);
|
||||
}
|
||||
});
|
||||
|
||||
it('задачи уроков — на доске 9×9', () => {
|
||||
for (const task of allTasks.filter((item) => item.kind === 'lesson')) {
|
||||
expect(task.size, task.id).toBe(9);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
interface LessonDoc {
|
||||
readonly file: string;
|
||||
readonly frontmatter: Record<string, string> | null;
|
||||
readonly body: string;
|
||||
}
|
||||
|
||||
function parseFrontmatter(text: string): Record<string, string> | null {
|
||||
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
||||
if (match === null) return null;
|
||||
const out: Record<string, string> = {};
|
||||
for (const line of (match[1] ?? '').split(/\r?\n/)) {
|
||||
const kv = /^([A-Za-z]+):\s*(.*)$/.exec(line);
|
||||
if (kv !== null) {
|
||||
let value = (kv[2] ?? '').trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
out[kv[1] ?? ''] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const lessonFiles = readdirSync(lessonsDir)
|
||||
.filter((name) => name.endsWith('.md'))
|
||||
.sort();
|
||||
const lessons: LessonDoc[] = lessonFiles.map((file) => {
|
||||
const text = readFileSync(join(lessonsDir, file), 'utf8');
|
||||
return { file, frontmatter: parseFrontmatter(text), body: text };
|
||||
});
|
||||
|
||||
describe('уроки (lessons/*.md)', () => {
|
||||
it('уроков не менее 10', () => {
|
||||
expect(lessons.length).toBeGreaterThanOrEqual(10);
|
||||
});
|
||||
|
||||
for (const lesson of lessons) {
|
||||
describe(lesson.file, () => {
|
||||
it('есть frontmatter с title/section/order/summary', () => {
|
||||
const fm = lesson.frontmatter;
|
||||
expect(fm, 'frontmatter не найден').not.toBeNull();
|
||||
if (fm === null) return;
|
||||
expect(fm['title'] ?? '', 'title').not.toBe('');
|
||||
expect(fm['section'] ?? '', 'section').not.toBe('');
|
||||
expect(fm['summary'] ?? '', 'summary').not.toBe('');
|
||||
expect(Number.isInteger(Number(fm['order'])), 'order — целое число').toBe(true);
|
||||
});
|
||||
|
||||
it('section входит в программу курса', () => {
|
||||
expect(SECTIONS).toContain(lesson.frontmatter?.['section']);
|
||||
});
|
||||
|
||||
it('объём текста 300–700 слов', () => {
|
||||
const withoutFrontmatter = lesson.body.replace(/^---\r?\n[\s\S]*?\r?\n---/, '');
|
||||
const words = withoutFrontmatter.split(/\s+/).filter((word) => /\p{L}/u.test(word));
|
||||
expect(words.length).toBeGreaterThanOrEqual(300);
|
||||
expect(words.length).toBeLessThanOrEqual(700);
|
||||
});
|
||||
|
||||
it('все data-task-id существуют среди задач', () => {
|
||||
const ids = new Set(allTasks.map((task) => task.id));
|
||||
const refs = [...lesson.body.matchAll(/data-task-id="([^"]+)"/g)].map(
|
||||
(match) => match[1] ?? '',
|
||||
);
|
||||
for (const ref of refs) {
|
||||
expect(ids.has(ref), `нет задачи «${ref}»`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('разделы 1–6 и 10 содержат интерактивную задачу', () => {
|
||||
const section = lesson.frontmatter?.['section'] ?? '';
|
||||
if (INTERACTIVE_SECTIONS.includes(section)) {
|
||||
expect(lesson.body).toMatch(/<go-task data-task-id="lsn-/);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it('все 10 разделов программы покрыты уроками', () => {
|
||||
const covered = new Set(lessons.map((lesson) => lesson.frontmatter?.['section'] ?? ''));
|
||||
for (const section of SECTIONS) {
|
||||
expect(covered.has(section), `нет урока по разделу «${section}»`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
74
apps/web/src/content/lessons/01-dame-i-zahvat.md
Normal file
74
apps/web/src/content/lessons/01-dame-i-zahvat.md
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
---
|
||||
title: 'Дамэ и захват'
|
||||
section: 'Дамэ и захват'
|
||||
order: 1
|
||||
summary: 'Что такое дамэ (дыхания) группы, как считать их и как происходит захват камней. Основа всех правил Го.'
|
||||
---
|
||||
|
||||
# Дамэ и захват
|
||||
|
||||
Камень в Го «живёт», пока у него есть **дамэ** — свободные соседние пункты по
|
||||
линиям доски (по горизонтали и вертикали, диагонали не считаются). Дамэ — это
|
||||
«дыхание» камня: как только последнее дамэ исчезает, камень снимается с доски.
|
||||
|
||||
## Как считать дамэ
|
||||
|
||||
У одинокого камня в центре доски четыре дамэ: сверху, снизу, слева и справа.
|
||||
На краю доски — три, в углу — всего два:
|
||||
|
||||
```
|
||||
A B C A B C
|
||||
1 . . . 1 X . .
|
||||
2 . X . 2 . . .
|
||||
3 . . . 3 . . .
|
||||
центр: 4 угол A1: 2 (B1 и A2)
|
||||
```
|
||||
|
||||
Камни одного цвета, стоящие рядом по линиям, образуют **группу**. Группа
|
||||
делит дамэ на всех: у цепочки из двух камней в центре уже шесть дамэ.
|
||||
Захватывается всегда группа целиком — нельзя снять «половину» цепочки.
|
||||
|
||||
## Захват
|
||||
|
||||
Когда вы ставите камень так, что у группы противника не остаётся ни одного
|
||||
дамэ, эта группа немедленно снимается с доски и становится вашими пленниками.
|
||||
|
||||
```
|
||||
A B C
|
||||
1 . B .
|
||||
2 B W .
|
||||
3 . . .
|
||||
Белый B2 в атari: осталось одно дамэ — B3.
|
||||
```
|
||||
|
||||
Ситуация, когда у группы осталось ровно одно дамэ, называется **атари** —
|
||||
это предупреждение: «ещё один ход, и камни снимут». В обучающих партиях с
|
||||
новичками «атари» принято говорить вслух — так учатся замечать угрозы;
|
||||
опытные игроки вслух его обычно не объявляют. Начинающим полезно замечать
|
||||
атари самостоятельно, пересчитывая дамэ после каждого хода.
|
||||
|
||||
## Почему это важно
|
||||
|
||||
Всё в Го построено на подсчёте дамэ: и борьба за жизнь группы, и захваты,
|
||||
и гонки «кто кого первым снимет». Простейшая тактика звучит так: у группы
|
||||
противника мало дамэ — сокращайте их; у вашей группы мало дамэ — добавляйте
|
||||
(продлевайтесь, соединяйтесь с друзьями).
|
||||
|
||||
Типичная ошибка новичка — не заметить, что его собственная группа уже в
|
||||
атари, и потерять её. После каждого хода противника спрашивайте себя:
|
||||
«Сколько теперь дамэ у моих групп?»
|
||||
|
||||
## Практика
|
||||
|
||||
В задаче ниже белый камень окружён с трёх сторон и у него осталось одно
|
||||
дамэ. Найдите этот пункт и поставьте туда чёрный камень — белый будет
|
||||
захвачен.
|
||||
|
||||
<go-task data-task-id="lsn-dame-capture">Запасной текст: попробуйте решить задачу (нужен включённый JavaScript).</go-task>
|
||||
|
||||
## Запомнить
|
||||
|
||||
- Дамэ — свободные соседние пункты камня или группы.
|
||||
- Нет дамэ — камни снимаются с доски.
|
||||
- Одно дамэ — атари, группа под угрозой.
|
||||
- Группа делит дамэ на все свои камни.
|
||||
83
apps/web/src/content/lessons/02-atari-lestnitsa-set.md
Normal file
83
apps/web/src/content/lessons/02-atari-lestnitsa-set.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
---
|
||||
title: 'Атари, лестница, сеть'
|
||||
section: 'Атари, лестница, сеть'
|
||||
order: 1
|
||||
summary: 'Три базовых тактических приёма: атари как угроза, лестница как погоня по диагонали и сеть как тихий захват без атари.'
|
||||
---
|
||||
|
||||
# Атари, лестница, сеть
|
||||
|
||||
## Атари
|
||||
|
||||
**Атари** — позиция, в которой у группы осталось одно дамэ. Поставить атари —
|
||||
значит пригрозить захватом на следующий ход. Атари — самое сильное оружие
|
||||
новичка: оно часто вынуждает противника отвечать. Впрочем, отвечать не
|
||||
обязательно: сильный игрок может сознательно проигнорировать атари ради
|
||||
большего хода в другом месте. Но помните: атари ради атари —
|
||||
плохая привычка. Каждый раз спрашивайте себя: «А что я получу, если он
|
||||
спасётся?»
|
||||
|
||||
## Лестница
|
||||
|
||||
Если загонять камень атари за атари так, что у него всякий раз остаётся
|
||||
ровно один путь к бегству, получается **лестница** — зигзагообразная погоня
|
||||
через всю доску:
|
||||
|
||||
```
|
||||
A B C D E
|
||||
1 . . W B .
|
||||
2 . B W . .
|
||||
3 B . . . .
|
||||
лестница бежит по диагонали к углу
|
||||
```
|
||||
|
||||
Лестница работает, если на её пути нет камней противника. Если по диагонали
|
||||
бега стоит хоть один вражеский камень (**ситё-прерыватель** — «лестничная
|
||||
опора», англ. ladder breaker, ход ситё-атари), погоня срывается: убегающая
|
||||
группа соединяется с опорой и получает новые дамэ. Поэтому перед тем как начать лестницу, прочитайте её до конца:
|
||||
проследите зигзаг глазами до края доски. Лестница либо захватывает, либо
|
||||
катастрофически проигрывает — середины нет.
|
||||
|
||||
Край доски — лучший друг лестницы: у камня на первой линии всего три дамэ,
|
||||
и убегать ему некуда. Погоня, прижатая к краю, заканчивается захватом
|
||||
гарантированно.
|
||||
|
||||
## Сеть
|
||||
|
||||
Когда лестница не работает, камень часто можно поймать **сетью** (гэта):
|
||||
тихим ходом, который не ставит атари, но заранее закрывает все пути
|
||||
к бегству:
|
||||
|
||||
```
|
||||
A B C D
|
||||
1 . B . .
|
||||
2 W . . .
|
||||
3 . . B .
|
||||
чёрный D-подобный ход накрывает белый камень сетью
|
||||
```
|
||||
|
||||
Классическая сеть ставится через один пункт от цели (конём, как в шахматах).
|
||||
Куда бы белый ни продлился, следующее атари оставляет его без дамэ.
|
||||
Признак того, что пора думать о сети: у камня два дамэ, а прямое атари
|
||||
ведёт в лестницу с вражеской опорой на пути.
|
||||
|
||||
## Как выбирать приём
|
||||
|
||||
1. Сначала посмотрите на лестницу: чистый ли путь?
|
||||
2. Если лестница сломана опорой — ищите сеть.
|
||||
3. Если не работает ни то, ни другое — не гонитесь: слабый камень противника
|
||||
никуда не денется, займитесь своими группами.
|
||||
|
||||
## Практика
|
||||
|
||||
В задаче чёрные загоняют белый камень лестницей прямо на край доски.
|
||||
Проведите всю погоню до захвата.
|
||||
|
||||
<go-task data-task-id="lsn-ladder">Запасной текст: попробуйте решить задачу (нужен включённый JavaScript).</go-task>
|
||||
|
||||
## Запомнить
|
||||
|
||||
- Атари — угроза захвата в один ход.
|
||||
- Лестница — серия атари, оставляющих один путь; проверяйте диагональ на
|
||||
наличие опоры.
|
||||
- Сеть — тихий захват без атари, когда лестница не проходит.
|
||||
78
apps/web/src/content/lessons/03-samoubiystvo-i-ko.md
Normal file
78
apps/web/src/content/lessons/03-samoubiystvo-i-ko.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
---
|
||||
title: 'Самоубийство и ко'
|
||||
section: 'Самоубийство и ко'
|
||||
order: 1
|
||||
summary: 'Почему нельзя ставить камень в пункт без дамэ, когда такой ход всё же легален, и как работает правило ко.'
|
||||
---
|
||||
|
||||
# Самоубийство и ко
|
||||
|
||||
## Запрет самоубийства
|
||||
|
||||
Ход, после которого у вашей собственной группы не остаётся ни одного дамэ,
|
||||
**запрещён**. Нельзя «пожертвовать» камень, просто поставив его в полностью
|
||||
окружённый пункт: по официальным правилам (например, AGA) такой ход
|
||||
отменяется и засчитывается как пас, а на этом сайте движок просто не даст
|
||||
поставить такой камень.
|
||||
|
||||
```
|
||||
A B C
|
||||
1 . W .
|
||||
2 W . W точка B2 для чёрных — под запретом:
|
||||
3 . W . чёрный камень там сразу останется без дамэ
|
||||
```
|
||||
|
||||
## Важное исключение: захват
|
||||
|
||||
Если ход в «запретный» пункт **снимает** группу противника, он легален:
|
||||
снятые камни освобождают пункты, и у вашего камня появляются дамэ. Поэтому
|
||||
ход, выглядящий как самоубийство, иногда оказывается решающим ударом.
|
||||
Всегда проверяйте: не в атари ли группа, внутрь которой вы ставите камень?
|
||||
|
||||
## Правило ко
|
||||
|
||||
Представьте форму, где чёрные снимают один белый камень, а белые могут
|
||||
тут же снять в ответ один чёрный — и позиция возвращается к исходной.
|
||||
Без ограничения такая пара захватов повторялась бы бесконечно. Поэтому
|
||||
действует правило **ко** (по-японски — «вечность»):
|
||||
|
||||
> Немедленно возвращать захваченный камень нельзя. Сначала нужно сделать
|
||||
> ход в другом месте доски.
|
||||
|
||||
```
|
||||
A B C D
|
||||
2 . B . .
|
||||
3 B W . B чёрные снимают W на C3 — белые не могут
|
||||
4 . W W . ответить захватом сразу: это ко
|
||||
```
|
||||
|
||||
После того как соперник сходил в другую точку, запрет снимается: если он не
|
||||
закрыл ко, вы можете вернуться и выиграть его.
|
||||
|
||||
## Ко-борьба
|
||||
|
||||
Из правила ко рождается целая стратегия. Ход в другом месте, который требует
|
||||
ответа, называется **ко-угрозой**. Выигрывает ко тот, у кого больше угроз:
|
||||
вы ставите угрозу, соперник отвечает, вы забираете ко обратно. Ценность ко
|
||||
и ценность угроз нужно сравнивать: иногда выгоднее «продать» ко, получив
|
||||
два хода подряд в другом месте.
|
||||
|
||||
Для начинающего достаточно трёх правил:
|
||||
|
||||
1. Начав ко, знайте, зачем: выигрыш ко должен чего-то стоить.
|
||||
2. Перед ко посчитайте свои ко-угрозы — хотя бы одна должна быть.
|
||||
3. Не отвечайте на мелкие угрозы, если ко дороже.
|
||||
|
||||
## Практика
|
||||
|
||||
В задаче чёрные снимают белый камень, образуя ко. Белые не могут вернуть
|
||||
камень немедленно и ходят в сторону — после этого чёрные спокойно закрывают
|
||||
ко соединением.
|
||||
|
||||
<go-task data-task-id="lsn-ko">Запасной текст: попробуйте решить задачу (нужен включённый JavaScript).</go-task>
|
||||
|
||||
## Запомнить
|
||||
|
||||
- Ход без дамэ запрещён — если только он не захватывает.
|
||||
- Ко запрещает немедленный обратный захват одного камня.
|
||||
- Ко-угроза — способ «купить» право вернуться в ко.
|
||||
81
apps/web/src/content/lessons/04-glaza-zhizn-i-smert.md
Normal file
81
apps/web/src/content/lessons/04-glaza-zhizn-i-smert.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
---
|
||||
title: 'Глаза: жизнь и смерть'
|
||||
section: 'Глаза: жизнь и смерть'
|
||||
order: 1
|
||||
summary: 'Что такое глаз, почему два глаза дают вечную жизнь, и как отличить настоящий глаз от ложного.'
|
||||
---
|
||||
|
||||
# Глаза: жизнь и смерть
|
||||
|
||||
## Что такое глаз
|
||||
|
||||
**Глаз** — это один или несколько соседних пустых пунктов, полностью
|
||||
окружённых камнями одного цвета:
|
||||
|
||||
```
|
||||
A B C
|
||||
1 B B .
|
||||
2 B . B точка B2 — глаз чёрных
|
||||
3 B B .
|
||||
```
|
||||
|
||||
Противник не может просто поставить камень в глаз: это самоубийство. Единственный
|
||||
способ сыграть туда — сначала снять все внешние дамэ группы, то есть поставить
|
||||
её в атари.
|
||||
|
||||
## Два глаза — жизнь
|
||||
|
||||
Если у группы есть **два отдельных глаза**, она неуязвима. Чтобы захватить
|
||||
группу, противнику нужно заполнить оба глаза, но ход в первый глаз —
|
||||
самоубийство, пока не заполнен второй, и наоборот. Логическая ловушка
|
||||
замыкается: группа с двумя глазами живёт вечно, сколько бы камней ни
|
||||
теснилось вокруг.
|
||||
|
||||
Отсюда главная цель обороны в Го: **построить два глаза**. И главная цель
|
||||
нападения: не дать их построить.
|
||||
|
||||
Один большой глаз — это ещё не жизнь. Если всё внутреннее пространство группы
|
||||
связно, противник рано или поздно займёт его ключевую точку. Поэтому
|
||||
пространство нужно **разделять** на две независимые области.
|
||||
|
||||
## Ложный глаз
|
||||
|
||||
Не всякий глаз настоящий. Посмотрите на форму:
|
||||
|
||||
```
|
||||
A B C
|
||||
1 . W .
|
||||
2 W . W
|
||||
3 . W .
|
||||
```
|
||||
|
||||
Точка B2 выглядит как глаз белых, но четыре белых камня вокруг неё
|
||||
**не связаны** между собой — они касаются друг друга только по диагонали.
|
||||
Чёрные могут поставить атари на один из них, а затем занять «глаз».
|
||||
Такой глаз называется **ложным**: он есть на бумаге, но не даёт жизни.
|
||||
|
||||
Признак настоящего глаза: все камни вокруг него соединены в одну крепкую
|
||||
группу, и противник не может ни разрезать их, ни поставить в атари.
|
||||
Прежде чем рассчитывать на глаз, проверьте диагонали: именно по диагоналям
|
||||
глаз чаще всего оказывается ложным.
|
||||
|
||||
## Жизнь и смерть в задачах
|
||||
|
||||
Типичная задача на жизнь и смерть формулируется так: «чёрные ходят и живут»
|
||||
или «чёрные ходят и убивают». Решение почти всегда — **жизненная точка**:
|
||||
единственный пункт, который делит пространство на два глаза (для жизни) или
|
||||
мешает противнику это сделать (для убийства). Ищите центр симметрии формы —
|
||||
чаще всего он и есть ответ.
|
||||
|
||||
## Практика
|
||||
|
||||
Белые рассчитывают на глаз, но их камни вокруг него не связаны — глаз ложный.
|
||||
Найдите ход, который разрушает иллюзию и захватывает отмеченные камни.
|
||||
|
||||
<go-task data-task-id="lsn-false-eye">Запасной текст: попробуйте решить задачу (нужен включённый JavaScript).</go-task>
|
||||
|
||||
## Запомнить
|
||||
|
||||
- Глаз — пустое пространство, окружённое одним цветом.
|
||||
- Два глаза = вечная жизнь; один глаз = смерть под вопросом.
|
||||
- Ложный глаз не защищает: проверяйте связность камней вокруг него.
|
||||
76
apps/web/src/content/lessons/05-podschyot-i-konets-partii.md
Normal file
76
apps/web/src/content/lessons/05-podschyot-i-konets-partii.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
---
|
||||
title: 'Подсчёт и конец партии'
|
||||
section: 'Подсчёт и конец партии'
|
||||
order: 1
|
||||
summary: 'Как заканчивается партия (два паса), что такое территория и как подсчитывается результат по китайским правилам.'
|
||||
---
|
||||
|
||||
# Подсчёт и конец партии
|
||||
|
||||
## Когда партия заканчивается
|
||||
|
||||
Партия в Го не заканчивается матом — она заканчивается согласием. Когда оба
|
||||
игрока считают, что выгодных ходов не осталось, они пасуют. **Два паса
|
||||
подряд — конец партии.**
|
||||
|
||||
Перед пасом проверьте:
|
||||
|
||||
- все ли границы территорий закрыты;
|
||||
- нет ли незакрытых дамэ между группами, через которые противник может
|
||||
разрезать;
|
||||
- не осталось ли ваших камней в атари, которые ещё можно спасти, и чужих —
|
||||
которые можно добрать.
|
||||
|
||||
Пасовать раньше времени — отдавать очки. Пасовать позже — тоже ошибка:
|
||||
по японским правилам ход в свою территорию стоит очко, а по китайским
|
||||
(нашим) счёт не меняется, но ход впустую отдаёт инициативу.
|
||||
|
||||
## Мёртвые камни
|
||||
|
||||
После двух пасов игроки договариваются, какие группы **мёртвы** — обречены,
|
||||
хотя формально стоят на доске. Мёртвые камни убираются с доски как пленники.
|
||||
Если игроки не согласны — партия просто продолжается, и спорная группа
|
||||
доказывает свою судьбу игрой. Это честное правило: «сомневаешься — играй».
|
||||
|
||||
## Что такое территория
|
||||
|
||||
**Территория** — пустые пункты, окружённые живыми камнями одного цвета так,
|
||||
что противник не может вторгнуться с надеждой выжить. Каждый такой пункт —
|
||||
очко.
|
||||
|
||||
## Подсчёт по-китайски (площадь)
|
||||
|
||||
Мы используем китайские правила: очки каждого игрока — это
|
||||
|
||||
> **его живые камни на доске + его территория.**
|
||||
|
||||
Белые дополнительно получают **коми** — компенсацию за второй ход
|
||||
(в наших партиях — 6,5 очка на большой доске и 5,5 — на доске 9×9; стандарт
|
||||
китайских правил на большой доске — 7,5; пол-очка исключает ничью).
|
||||
|
||||
Пример. У чёрных: 30 камней на доске и 20 пунктов территории — 50 очков.
|
||||
У белых: 28 камней и 16 пунктов территории — 44, плюс коми 5,5 — итого 49,5.
|
||||
Чёрные выиграли с перевесом 0,5 очка.
|
||||
|
||||
Обратите внимание: по китайским правилам ход внутрь своей территории не
|
||||
меняет счёта (камень занимает пункт территории), а по японским — потерял бы
|
||||
очко. Поэтому в конце партии по нашим правилам можно безопасно «доукрепляться».
|
||||
|
||||
## Оценка позиции по ходу партии
|
||||
|
||||
Грубая прикидка «кто выигрывает» — полезный навык: посчитайте очевидную
|
||||
территорию обоих, добавьте коми белым, сравните. Если вы впереди — играйте
|
||||
проще и надёжнее; если позади — ищите более острые варианты.
|
||||
|
||||
## Практика
|
||||
|
||||
Перед подсчётом нужно прибрать доску: два белых камня внутри чёрной
|
||||
территории в атари. Снимите их — иначе при подсчёте начнутся споры.
|
||||
|
||||
<go-task data-task-id="lsn-endgame-capture">Запасной текст: попробуйте решить задачу (нужен включённый JavaScript).</go-task>
|
||||
|
||||
## Запомнить
|
||||
|
||||
- Два паса подряд заканчивают партию.
|
||||
- Счёт: камни + территория; белые получают коми.
|
||||
- Мёртвые камни снимаются по согласию; спор решается доигрыванием.
|
||||
75
apps/web/src/content/lessons/06-svyaz-i-razrezanie.md
Normal file
75
apps/web/src/content/lessons/06-svyaz-i-razrezanie.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
---
|
||||
title: 'Связь и разрезание'
|
||||
section: 'Связь и разрезание'
|
||||
order: 1
|
||||
summary: 'Почему связанные группы сильнее, как соединять свои камни надёжно и как разрезать камни противника.'
|
||||
---
|
||||
|
||||
# Связь и разрезание
|
||||
|
||||
## Сила связи
|
||||
|
||||
Камни, соединённые в одну группу, делят дамэ и строят глаза вместе.
|
||||
Две слабые группы по три дамэ — две проблемы; одна связанная группа с шестью
|
||||
дамэ — крепость. Поэтому **соединяйте свои группы и разрезайте чужие** —
|
||||
это называют главной тактической идеей Го.
|
||||
|
||||
## Надёжные и ненадёжные связи
|
||||
|
||||
Камни, стоящие вплотную по линии, связаны абсолютно — их нельзя разрезать.
|
||||
Но многие «красивые» формы связаны лишь условно:
|
||||
|
||||
```
|
||||
A B C D
|
||||
1 B . B . ход через один пункт (иккэн):
|
||||
2 . . . . белые могут попытаться вклиниться в B1
|
||||
```
|
||||
|
||||
Связь через один пункт надёжна, только если вы готовы ответить соединением
|
||||
на вклинивание. Диагональный ход (косуми) — тоже не абсолютная связь, но
|
||||
сам по себе он локально неразрезаем: на разрез в один пункт вы отвечаете
|
||||
соединением в другой (миаи). Уязвимо косуми лишь тогда, когда вокруг уже
|
||||
стоят сильные камни противника.
|
||||
|
||||
## Типичные способы соединения
|
||||
|
||||
- **Прямое соединение** — поставить камень в разрыв цепочки.
|
||||
- **Соединение снизу** — соединиться по первой линии, под защитой края.
|
||||
- **Захват разрезающего камня** — если вклинившийся камень слаб, снимите его:
|
||||
связь восстановится сама собой.
|
||||
|
||||
Выбирая, соединяться ли, спросите: «Что случится, если меня разрежут?»
|
||||
Если обе части живут сами — не тратьте ход. Если одна погибнет — соединяйтесь
|
||||
немедленно.
|
||||
|
||||
## Как разрезать
|
||||
|
||||
Разрезание — зеркальный приём: ищите **точку разреза** — пункт, через который
|
||||
противник соединял бы свои группы. Часто это единственный пункт между двумя
|
||||
диагонально стоящими камнями. Но помните: разрезающий ход должен сам выжить.
|
||||
Разрезать и тут же потерять разрезающий камень — значит подарить противнику
|
||||
и связь, и материал.
|
||||
|
||||
## Когда связь важнее территории
|
||||
|
||||
Новички жертвуют связью ради пары пунктов. Почти всегда это ошибка:
|
||||
разрезанная группа бежит, отдаёт инициативу и тащит за собой потери по всей
|
||||
доске. Сильные игроки говорят: «Соединяйся — и получишь ход в другом месте».
|
||||
|
||||
Полезно и обратное наблюдение: если противник потратил ход на разрезание,
|
||||
а обе ваши половины спокойно живут, — он просто подарил вам темп. Ценность
|
||||
разреза и связи всегда измеряется судьбой групп, а не самим фактом
|
||||
«разрезал» или «соединил».
|
||||
|
||||
## Практика
|
||||
|
||||
Белый камень вклинился между двумя чёрными группами, но он в атари. Снимите
|
||||
его — и группы соединятся сами.
|
||||
|
||||
<go-task data-task-id="lsn-connect">Запасной текст: попробуйте решить задачу (нужен включённый JavaScript).</go-task>
|
||||
|
||||
## Запомнить
|
||||
|
||||
- Связанная группа сильнее суммы частей.
|
||||
- Косуми само по себе неразрезаемо, но уязвимо при сильных камнях вокруг.
|
||||
- Разрезая, позаботьтесь о жизни разрезающего камня.
|
||||
70
apps/web/src/content/lessons/07-ugly-storony-tsentr.md
Normal file
70
apps/web/src/content/lessons/07-ugly-storony-tsentr.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
---
|
||||
title: 'Углы, стороны, центр'
|
||||
section: 'Углы, стороны, центр'
|
||||
order: 1
|
||||
summary: 'Почему партия начинается с углов, как край доски работает готовой стеной, и что такое линии доски.'
|
||||
---
|
||||
|
||||
# Углы, стороны, центр
|
||||
|
||||
## Неравенство частей доски
|
||||
|
||||
Территория в Го строится у краёв доски, потому что сам край работает как
|
||||
готовая стена. Сравните, сколько стен нужно построить, чтобы оградить
|
||||
территорию:
|
||||
|
||||
- **Угол**: два края доски — уже две готовые стены, достроить остаётся две.
|
||||
- **Сторона**: один край — одна готовая стена, строить придётся три.
|
||||
- **Центр**: краёв нет — нужны все четыре стены.
|
||||
|
||||
Отсюда знаменитый порядок начала партии: **сначала углы, потом стороны,
|
||||
потом центр**. Новичок, тянущийся в центр с первых ходов, строит забор
|
||||
посреди поля — дорого и бесполезно.
|
||||
|
||||
## Линии доски
|
||||
|
||||
У линий доски есть имена и характер:
|
||||
|
||||
- **Первая линия** — край доски: у камня на ней всего три дамэ, в дебюте
|
||||
туда играть не стоит.
|
||||
- **Вторая линия** — «линия поражения»: территория есть, но мала и уязвима.
|
||||
- **Третья линия** — «линия территории»: оптимум для ограждения земли.
|
||||
- **Четвёртая линия** — «линия влияния»: территории меньше, зато больше
|
||||
давления на центр и стороны.
|
||||
|
||||
Классический баланс дебюта — игра на третьей и четвёртой линиях.
|
||||
|
||||
## Ключевые пункты угла
|
||||
|
||||
- **3-3** (сан-сан): камень глубоко в углу. Надёжно забирает угол, но низко,
|
||||
мало влияния.
|
||||
- **3-4** (комоку): самый популярный первый ход. Угол почти ваш, остаётся
|
||||
гибкость.
|
||||
- **4-4** (хоси): звёздный пункт. Угол можно оспорить (вторжение 3-3), зато
|
||||
камень сильнее тянется к центру.
|
||||
- **3-5** (мокухадзуси) и **4-5** (такамоку) — реже, с уклоном в сторону
|
||||
и влияние соответственно.
|
||||
|
||||
## Что делает центр полезным
|
||||
|
||||
Центр не даёт много территории напрямую, но камни в центре:
|
||||
|
||||
- перерезают пути бегства и соединения;
|
||||
- усиливают вторжения и погони (лестницы!);
|
||||
- превращают соседние стороны в большие рамки (**мойо**).
|
||||
|
||||
Центр — это сила, а не земля. Играть туда стоит, когда углы и стороны уже
|
||||
заняты и началась борьба.
|
||||
|
||||
## Практический рецепт для новичка
|
||||
|
||||
1. Первые ходы — в свободные углы (3-4 или 4-4).
|
||||
2. Дальше — подходы к углам и задержки по сторонам.
|
||||
3. В центр — только с конкретной целью: атака, спасение, перерезание.
|
||||
4. Никогда не играйте на первой-второй линии в дебюте без причины.
|
||||
|
||||
## Запомнить
|
||||
|
||||
- Край доски — бесплатная стена; центр её лишён.
|
||||
- Порядок дебюта: углы → стороны → центр.
|
||||
- Третья линия — территория, четвёртая — влияние.
|
||||
82
apps/web/src/content/lessons/08-bazovye-dzyoseki.md
Normal file
82
apps/web/src/content/lessons/08-bazovye-dzyoseki.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
---
|
||||
title: 'Базовые дзёсэки'
|
||||
section: 'Базовые дзёсэки'
|
||||
order: 1
|
||||
summary: 'Что такое дзёсэки, зачем их учить, и три простейших угловых образца: занятие угла 3-3, цукэ-хики и вторжение 3-3.'
|
||||
---
|
||||
|
||||
# Базовые дзёсэки
|
||||
|
||||
## Что такое дзёсэки
|
||||
|
||||
**Дзёсэки** — устоявшаяся «правильная» последовательность ходов в углу,
|
||||
проверенная поколениями игроков. Итог дзёсэки считается равным (или
|
||||
почти равным) для обеих сторон: один получает угол, другой — стену или
|
||||
инициативу.
|
||||
|
||||
Зачем учить дзёсэки новичку? Не для зазубривания, а чтобы:
|
||||
|
||||
- не проигрывать угол за пять ходов из-за типовой ошибки;
|
||||
- понимать типичные формы: где слабость, где разрез, где жизненная точка;
|
||||
- освободить внимание для борьбы в других местах доски.
|
||||
|
||||
Важно: дзёсэки — не догма. «Правильный» ход зависит от всей доски; на 9×9
|
||||
влияние центра сильнее, чем на 19×19, и многие классические дзёсэки там
|
||||
ведут себя иначе. Учите идею, а не только порядок ходов.
|
||||
|
||||
## Дзёсэки 1: занятие угла 3-3
|
||||
|
||||
Простейший способ забрать угол — поставить камень прямо на пункт 3-3.
|
||||
Соперник прижмёт сверху, вы ответите по краю:
|
||||
|
||||
```
|
||||
A B C D E
|
||||
1 . . . . .
|
||||
2 . . W . .
|
||||
3 . . B . .
|
||||
4 . . . . .
|
||||
```
|
||||
|
||||
Чёрный камень на C3 почти неуязвим: у него сразу задел на угол.
|
||||
Дальше белые обычно давят снаружи, а чёрные ползут по второй-третьей линии,
|
||||
выстраивая жизнь. Идея: **угол — мой, влияние — ваше**.
|
||||
|
||||
## Дзёсэки 2: цукэ-хики — прижатие и отступление
|
||||
|
||||
Чёрные заняли угол ходом 3-4, белые подошли к нему. Классический образец —
|
||||
**цукэ-хики**: чёрные прижимаются к подошедшему камню (**цукэ**), белые
|
||||
отвечают **ханэ** (изгибом вокруг чёрного камня), а чёрные спокойно
|
||||
отступают (**хики**):
|
||||
|
||||
```
|
||||
A B C D E
|
||||
1 . . . . .
|
||||
2 . . B . .
|
||||
3 . B W . .
|
||||
4 . . . . .
|
||||
```
|
||||
|
||||
Хики укрепляет угол чёрных, белые получают внешнюю позицию — это один из
|
||||
первых дзёсэки, которые стоит узнать новичку. Ключевой урок:
|
||||
**отступление — не трусость**, а способ сохранить форму без разрезов.
|
||||
|
||||
## Дзёсэки 3: вторжение 3-3 под хоси
|
||||
|
||||
Если соперник начал с 4-4 (хоси), угол ещё не закрыт — вторгайтесь на 3-3.
|
||||
Дальше развивается типовая борьба: хозяин хоси давит с двух сторон,
|
||||
вторгшийся ползёт по краю и живёт маленькой группой. Хозяин строит внешнюю
|
||||
стену. Итог: земля против влияния, равенство.
|
||||
|
||||
## Как относиться к дзёсэки на практике
|
||||
|
||||
1. Знайте два-три образца для каждого углового пункта — этого хватит надолго.
|
||||
2. После каждой партии смотрите: не вышел ли угол «криво»? Если да —
|
||||
найдите, где отступили от образца.
|
||||
3. Помните, что дзёсэки обновляются: профессионалы постоянно находят новые
|
||||
варианты, а с появлением сильных программ многие оценки изменились.
|
||||
|
||||
## Запомнить
|
||||
|
||||
- Дзёсэки — эталонные угловые размены, равные для обеих сторон.
|
||||
- Учите смысл ходов, а не только их порядок.
|
||||
- На малой доске классические дзёсэки работают с поправкой на центр.
|
||||
84
apps/web/src/content/lessons/09-forma.md
Normal file
84
apps/web/src/content/lessons/09-forma.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
---
|
||||
title: 'Форма'
|
||||
section: 'Форма'
|
||||
order: 1
|
||||
summary: 'Хорошие и плохие формы камней: тигровая пасть, бамбук, пустой треугольник, данго. Как форма влияет на эффективность.'
|
||||
---
|
||||
|
||||
# Форма
|
||||
|
||||
## Что такое форма
|
||||
|
||||
Два игрока могут поставить одинаковое число камней, но у одного они будут
|
||||
работать на все сто процентов, а у другого — вполсилы. Разница — в **форме**:
|
||||
в том, насколько камни связаны, гибки и лишены слабостей. Хорошая форма
|
||||
экономит ходы; плохая форма тащит лишний вес.
|
||||
|
||||
## Хорошие формы
|
||||
|
||||
**Тигровая пасть (кошачья пасть)** — три камня вокруг пустого пункта:
|
||||
|
||||
```
|
||||
A B C
|
||||
1 . B .
|
||||
2 B . .
|
||||
3 . B .
|
||||
```
|
||||
|
||||
Пустой пункт — почти соединение: если противник вклинится туда, вы ответите
|
||||
и соединитесь окончательно. Пасть защищает разрез одним камнем.
|
||||
|
||||
**Бамбук (такэфу)** — два камня вплотную и ещё два через пункт:
|
||||
|
||||
```
|
||||
A B C D E
|
||||
1 B B . B B
|
||||
2 . . . . .
|
||||
```
|
||||
|
||||
Связь короткая, пружинящая: разрезать бамбук почти невозможно, а тянется он
|
||||
быстро.
|
||||
|
||||
**Вытягивание (ноби)** — простой ход вперёд от своего камня. Скромный, но
|
||||
самый надёжный способ добавить дамэ и не создать слабостей.
|
||||
|
||||
## Плохие формы
|
||||
|
||||
**Пустой треугольник** — три своих камня треугольником вокруг пустого пункта,
|
||||
который ничего не защищает:
|
||||
|
||||
```
|
||||
A B C
|
||||
1 . B .
|
||||
2 B B .
|
||||
```
|
||||
|
||||
Три камня делают работу двух: тот же пункт можно было прикрыть экономнее.
|
||||
Поговорка гласит: «пустой треугольник — не ставь никогда».
|
||||
|
||||
**Данго** — «пельменная форма»: бесформенный ком камней без глаз и с малым
|
||||
числом дамэ. Такая масса тяжела: её легко атаковать, а камни в ней работают
|
||||
плохо. Данго — признак того, что вас гонят, а вы просто соединяетесь, не
|
||||
думая.
|
||||
|
||||
**Перегруппировка** — слишком много камней в малой области: они мешают друг
|
||||
другу строить глаза и дают противнику лишние точки атаки.
|
||||
|
||||
## Как вырабатывать вкус формы
|
||||
|
||||
1. После каждого хода спрашивайте: «Не пустой ли это треугольник?»
|
||||
2. Предпочитайте ходы, которые делают две вещи сразу: соединяют и строят
|
||||
глаз, атакуют и укрепляются.
|
||||
3. Смотрите на свои группы целиком: есть ли у них «головы» (выход наружу),
|
||||
«ноги» (опора у края) и место для глаз?
|
||||
4. Пересматривайте свои партии и отмечайте тяжёлые формы — со временем они
|
||||
начнут сами резать глаз.
|
||||
|
||||
Форма — это эстетика, проверенная логикой: красивый ход в Го почти всегда
|
||||
оказывается и сильным.
|
||||
|
||||
## Запомнить
|
||||
|
||||
- Тигровая пасть и бамбук — образцы экономной защиты.
|
||||
- Пустой треугольник и данго — типичные ошибки новичка.
|
||||
- Хорошая форма = связность + гибкость + потенциал глаз.
|
||||
73
apps/web/src/content/lessons/10-prostoe-yose.md
Normal file
73
apps/web/src/content/lessons/10-prostoe-yose.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
---
|
||||
title: 'Простое ёсэ'
|
||||
section: 'Простое ёсэ'
|
||||
order: 1
|
||||
summary: 'Эндшпиль по-гошному: ханэ на первой линии, сэнтэ и готэ, почему мелкие ходы в конце решают партию.'
|
||||
---
|
||||
|
||||
# Простое ёсэ
|
||||
|
||||
## Что такое ёсэ
|
||||
|
||||
**Ёсэ** — эндшпиль: стадия, когда большие территории уже очерчены и игроки
|
||||
уточняют границы. Ходы ёсэ обычно мельче ходов миттельшпиля, но среди них
|
||||
бывают и дорогие: комбинация ханэ у края может стоить около десяти очков.
|
||||
Партия между равными часто решается именно здесь. Пропустить пару ходов
|
||||
ёсэ — значит проиграть выигранную партию.
|
||||
|
||||
## Ханэ на первой линии
|
||||
|
||||
Классический приём ёсэ — **ханэ у края**: ход, изгибающийся вокруг камня
|
||||
противника на первой линии:
|
||||
|
||||
```
|
||||
A B C D
|
||||
8 B W . .
|
||||
9 B W . .
|
||||
```
|
||||
|
||||
Ханэ отодвигает границу на один пункт в вашу пользу. Если соперник ответит —
|
||||
вы сохранили инициативу; если нет — вы продолжаете ползти дальше. Пара ханэ
|
||||
«с двух сторон» одной и той же границы стоит уже несколько очков.
|
||||
|
||||
## Сэнтэ и готэ
|
||||
|
||||
- **Сэнтэ** — ход, требующий ответа: после него инициатива (право ходить
|
||||
первым в новом месте) остаётся у вас.
|
||||
- **Готэ** — ход, на который можно не отвечать: инициатива переходит
|
||||
сопернику.
|
||||
|
||||
Правило ёсэ: **сначала играйте сэнтэ-ходы, потом готэ.** Сэнтэ бесплатен:
|
||||
соперник ответит, и вы снова выбираете, куда ходить. Готэ-ход «покупает»
|
||||
пункты ценой инициативы — берите его, когда сэнтэ кончились.
|
||||
|
||||
## Как оценивать ходы ёсэ
|
||||
|
||||
Считайте разницу «мы сыграли здесь / соперник сыграл здесь». Если ваш ход
|
||||
даёт +2 очка вам, а ход соперника там же дал бы +2 ему, ценность пункта —
|
||||
4 очка. Берите самые дорогие пункты первыми. Когда останутся только
|
||||
одноочковые размены (**дамэ** — нейтральные пункты), партия близка к концу.
|
||||
|
||||
## Типичные ошибки новичка в ёсэ
|
||||
|
||||
1. Играть в свою территорию «для надёжности» — ход ничего не защищает,
|
||||
а инициативу отдаёт.
|
||||
2. Отвечать на готэ-угрозы — соперник даром получает темп.
|
||||
3. Не добрать последние атари: мёртвые на вид камни иногда спасаются
|
||||
на одну темповую ошибку.
|
||||
|
||||
Хорошая привычка на 9×9 — за десяток ходов до конца мысленно прикинуть
|
||||
счёт: это подскажет, нужен ли спокойный ёсэ или пора рисковать.
|
||||
|
||||
## Практика
|
||||
|
||||
Чёрные прижимают белых к самому краю: краевая погоня в духе ёсэ заканчивается
|
||||
захватом двух камней. Найдите последовательность до конца.
|
||||
|
||||
<go-task data-task-id="lsn-yose">Запасной текст: попробуйте решить задачу (нужен включённый JavaScript).</go-task>
|
||||
|
||||
## Запомнить
|
||||
|
||||
- Ёсэ — игра за очки на границах; мелочей не бывает.
|
||||
- Сэнтэ раньше готэ; считайте ценность каждого пункта.
|
||||
- Ханэ на первой линии — рабочая лошадка эндшпиля.
|
||||
284
apps/web/src/content/tasks/lessons.json
Normal file
284
apps/web/src/content/tasks/lessons.json
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
[
|
||||
{
|
||||
"id": "lsn-dame-capture",
|
||||
"kind": "lesson",
|
||||
"title": "Последнее дамэ",
|
||||
"topics": ["атари", "захват"],
|
||||
"difficulty": 1,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 3,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 3
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 4,
|
||||
"y": 4
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[ee];B[ef])",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "У белого камня осталось одно дамэ — займите его."
|
||||
},
|
||||
{
|
||||
"id": "lsn-ladder",
|
||||
"kind": "lesson",
|
||||
"title": "Лестница у края",
|
||||
"topics": ["лестница", "атари"],
|
||||
"difficulty": 2,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 2
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 2,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[cb];B[db];W[ca];B[ba];W[da];B[ea])",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "Гоните камень к краю: каждое атари оставляет белым один путь."
|
||||
},
|
||||
{
|
||||
"id": "lsn-ko",
|
||||
"kind": "lesson",
|
||||
"title": "Захват в ко",
|
||||
"topics": ["захват"],
|
||||
"difficulty": 2,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 3,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 4
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 4,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 5
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 5
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 6
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[ee];B[ef];W[aa];B[ee])",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "Снимите камень — белые не смогут вернуть его сразу: это ко."
|
||||
},
|
||||
{
|
||||
"id": "lsn-false-eye",
|
||||
"kind": "lesson",
|
||||
"title": "Ложный глаз",
|
||||
"topics": ["глаза", "захват"],
|
||||
"difficulty": 2,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 0
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 2
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[bc][cb];B[bb];W[ii])",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "Камни вокруг «глаза» не связаны — займите центр и снимите два камня."
|
||||
},
|
||||
{
|
||||
"id": "lsn-endgame-capture",
|
||||
"kind": "lesson",
|
||||
"title": "Чистка территории",
|
||||
"topics": ["захват", "ёсэ"],
|
||||
"difficulty": 1,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 3,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 4
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 3,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 3
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[dd][ed];B[ee])",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "Два белых камня в атари — доберите их перед подсчётом."
|
||||
},
|
||||
{
|
||||
"id": "lsn-connect",
|
||||
"kind": "lesson",
|
||||
"title": "Соединение через разрез",
|
||||
"topics": ["связь", "разрезание"],
|
||||
"difficulty": 2,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 2,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 3
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 3,
|
||||
"y": 3
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]CR[dc][de];B[cd];W[bd])",
|
||||
"goal": {
|
||||
"type": "connect"
|
||||
},
|
||||
"hint": "Снимите разрезающий камень — группы соединятся сами."
|
||||
},
|
||||
{
|
||||
"id": "lsn-yose",
|
||||
"kind": "lesson",
|
||||
"title": "Ёсэ у края",
|
||||
"topics": ["ёсэ", "захват"],
|
||||
"difficulty": 2,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 1,
|
||||
"y": 7
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 7
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 8
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[ah][ai];B[ag];W[bi];B[ci])",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "Прижмите белых к краю: они не убегут."
|
||||
}
|
||||
]
|
||||
751
apps/web/src/content/tasks/tsumego-atari-zahvat.json
Normal file
751
apps/web/src/content/tasks/tsumego-atari-zahvat.json
Normal file
|
|
@ -0,0 +1,751 @@
|
|||
[
|
||||
{
|
||||
"id": "tsm-atari-corner",
|
||||
"kind": "tsumego",
|
||||
"title": "Атари в углу",
|
||||
"topics": ["атари", "захват"],
|
||||
"difficulty": 1,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 1,
|
||||
"y": 0
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[aa];B[ab])",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "У камня в углу всего два дамэ, одно уже занято."
|
||||
},
|
||||
{
|
||||
"id": "tsm-atari-edge",
|
||||
"kind": "tsumego",
|
||||
"title": "Атари на краю",
|
||||
"topics": ["атари"],
|
||||
"difficulty": 1,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 4
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 4
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[ae];B[af])",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "Осталось одно дамэ."
|
||||
},
|
||||
{
|
||||
"id": "tsm-atari-center",
|
||||
"kind": "tsumego",
|
||||
"title": "Одинокий камень",
|
||||
"topics": ["атари", "захват"],
|
||||
"difficulty": 1,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 3,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 3
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 4,
|
||||
"y": 4
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[ee];B[ef])",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "Закройте последнее дамэ."
|
||||
},
|
||||
{
|
||||
"id": "tsm-capture-pair",
|
||||
"kind": "tsumego",
|
||||
"title": "Два камня в сети",
|
||||
"topics": ["захват"],
|
||||
"difficulty": 1,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 3,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 5
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 5
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 4,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 5
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[ee][ef];B[eg])",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "У группы из двух камней одно общее дамэ."
|
||||
},
|
||||
{
|
||||
"id": "tsm-capture-edge-pair",
|
||||
"kind": "tsumego",
|
||||
"title": "Пара у края",
|
||||
"topics": ["захват", "атари"],
|
||||
"difficulty": 1,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 1,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 2
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 4
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[ad][ae];B[af])",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "Край доски не даёт лишних дамэ."
|
||||
},
|
||||
{
|
||||
"id": "tsm-ladder-edge",
|
||||
"kind": "tsumego",
|
||||
"title": "Лестница к углу",
|
||||
"topics": ["лестница"],
|
||||
"difficulty": 2,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 5,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 2
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 6,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[gb];B[hb];W[ga];B[fa];W[ha];B[ia])",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "Атари за атари — белые упираются в угол."
|
||||
},
|
||||
{
|
||||
"id": "tsm-double-atari",
|
||||
"kind": "tsumego",
|
||||
"title": "Двойное атари",
|
||||
"topics": ["атари", "захват"],
|
||||
"difficulty": 2,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 4,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 5
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 3,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 4
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[dc];B[dd];W[ce];B[cc])",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "Один ход — два атари; белые спасут лишь один камень."
|
||||
},
|
||||
{
|
||||
"id": "tsm-liberties-escape",
|
||||
"kind": "tsumego",
|
||||
"title": "Выход из атари",
|
||||
"topics": ["атари"],
|
||||
"difficulty": 2,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 4,
|
||||
"y": 4
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 3,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 5
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[ee];B[fe];W[ge])",
|
||||
"goal": {
|
||||
"type": "liberties",
|
||||
"count": 3
|
||||
},
|
||||
"hint": "Вытяните камень — пусть у группы станет три дамэ."
|
||||
},
|
||||
{
|
||||
"id": "tsm-kill-corner",
|
||||
"kind": "tsumego",
|
||||
"title": "Угол без глаз",
|
||||
"topics": ["захват", "жизнь и смерть"],
|
||||
"difficulty": 2,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 2,
|
||||
"y": 0
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 0
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[aa][ba];B[bb];W[ab];B[ac])",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "Займите точку глаза — белым некуда бежать."
|
||||
},
|
||||
{
|
||||
"id": "tsm-ladder-down",
|
||||
"kind": "tsumego",
|
||||
"title": "Лестница вниз",
|
||||
"topics": ["лестница"],
|
||||
"difficulty": 3,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 1,
|
||||
"y": 7
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 6
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 2,
|
||||
"y": 7
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[ch];B[dh];W[ci];B[bi];W[di];B[ei])",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "Та же лестница, но к нижнему краю."
|
||||
},
|
||||
{
|
||||
"id": "tsm-capture-three-corner",
|
||||
"kind": "tsumego",
|
||||
"title": "Тройка в углу",
|
||||
"topics": ["захват", "жизнь и смерть"],
|
||||
"difficulty": 3,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 2,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 2
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[aa][ba][bb];B[bc];W[ii];B[ab])",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "Белые не могут выйти: любое продление — самоубийство."
|
||||
},
|
||||
{
|
||||
"id": "tsm-liberties-wall",
|
||||
"kind": "tsumego",
|
||||
"title": "Прорвите стену",
|
||||
"topics": ["атари", "жизнь и смерть"],
|
||||
"difficulty": 3,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 4,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 3
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 3,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 3
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[ec][ed];B[ee];W[de])",
|
||||
"goal": {
|
||||
"type": "liberties",
|
||||
"count": 4
|
||||
},
|
||||
"hint": "Вытяните группу вниз — нужно четыре дамэ."
|
||||
},
|
||||
{
|
||||
"id": "tsm-ko-fight",
|
||||
"kind": "tsumego",
|
||||
"title": "Выигранное ко",
|
||||
"topics": ["захват"],
|
||||
"difficulty": 4,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 6,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 7,
|
||||
"y": 4
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 6,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 5
|
||||
},
|
||||
{
|
||||
"x": 7,
|
||||
"y": 5
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 6
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[ge];B[gf];W[aa];B[ge])",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "Начните ко — у белых нет угроз, закройте его."
|
||||
},
|
||||
{
|
||||
"id": "tsm-double-atari-save",
|
||||
"kind": "tsumego",
|
||||
"title": "Двойная угроза",
|
||||
"topics": ["атари", "захват"],
|
||||
"difficulty": 4,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 2,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 5
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 5
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 3,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 5
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[dd];B[de](;W[dg])(;W[ii]))",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "Захват и атари одним ходом — белые не спасут оба камня."
|
||||
},
|
||||
{
|
||||
"id": "tsm-corner-chase",
|
||||
"kind": "tsumego",
|
||||
"title": "Погоня по краю",
|
||||
"topics": ["захват", "жизнь и смерть"],
|
||||
"difficulty": 4,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 1,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 5
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 3
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 5
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 5
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[ae][af][bf];B[bg];W[ag];B[ah])",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "Сокращайте дамэ по очереди — край не оставит места."
|
||||
},
|
||||
{
|
||||
"id": "tsm-capture-race",
|
||||
"kind": "tsumego",
|
||||
"title": "Гонка захватов",
|
||||
"topics": ["захват", "жизнь и смерть"],
|
||||
"difficulty": 4,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 6,
|
||||
"y": 8
|
||||
},
|
||||
{
|
||||
"x": 7,
|
||||
"y": 8
|
||||
},
|
||||
{
|
||||
"x": 7,
|
||||
"y": 6
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 8,
|
||||
"y": 8
|
||||
},
|
||||
{
|
||||
"x": 8,
|
||||
"y": 7
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[ih][ii];B[hh];W[ig];B[if])",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "У белых два дамэ, у вас темп — начинайте первым."
|
||||
},
|
||||
{
|
||||
"id": "tsm-ko-threat",
|
||||
"kind": "tsumego",
|
||||
"title": "Ко с угрозой",
|
||||
"topics": ["захват"],
|
||||
"difficulty": 5,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 2,
|
||||
"y": 5
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 5
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 3,
|
||||
"y": 5
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 6
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 6
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 7
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[df];B[dg];W[ii];B[df])",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "Захватите ко, переждите угрозу и закройте его."
|
||||
},
|
||||
{
|
||||
"id": "tsm-chase-six",
|
||||
"kind": "tsumego",
|
||||
"title": "Шесть камней к краю",
|
||||
"topics": ["захват", "жизнь и смерть"],
|
||||
"difficulty": 5,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 5
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 5
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[ab][ac][ad][ae][af];B[ag];W[aa];B[ba])",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "Выберите правильный конец — второй ведёт в угол."
|
||||
},
|
||||
{
|
||||
"id": "tsm-liberties-five",
|
||||
"kind": "tsumego",
|
||||
"title": "Пять дамэ",
|
||||
"topics": ["атари", "жизнь и смерть"],
|
||||
"difficulty": 5,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 4,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 5
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 3,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 5
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 5
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[ee][ef];B[ed];W[eg];B[ec])",
|
||||
"goal": {
|
||||
"type": "liberties",
|
||||
"count": 5
|
||||
},
|
||||
"hint": "Одного продления мало — вытягивайтесь дважды."
|
||||
}
|
||||
]
|
||||
432
apps/web/src/content/tasks/tsumego-glaza.json
Normal file
432
apps/web/src/content/tasks/tsumego-glaza.json
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
[
|
||||
{
|
||||
"id": "tsm-eyes-three",
|
||||
"kind": "tsumego",
|
||||
"title": "Три в ряд",
|
||||
"topics": ["глаза", "жизнь и смерть"],
|
||||
"difficulty": 1,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 4
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 3,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 5
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 5
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 5
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[ba][ac][cc][be];B[bc])",
|
||||
"goal": {
|
||||
"type": "two-eyes"
|
||||
},
|
||||
"hint": "Центральная точка делит пространство на два глаза."
|
||||
},
|
||||
{
|
||||
"id": "tsm-eyes-corner-five",
|
||||
"kind": "tsumego",
|
||||
"title": "Пять точек в углу",
|
||||
"topics": ["глаза", "жизнь и смерть"],
|
||||
"difficulty": 3,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 0
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 0
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[bb][db][cd];B[ba])",
|
||||
"goal": {
|
||||
"type": "two-eyes"
|
||||
},
|
||||
"hint": "Разделите пространство на глаз и глаз."
|
||||
},
|
||||
{
|
||||
"id": "tsm-eyes-side-resist",
|
||||
"kind": "tsumego",
|
||||
"title": "Два глаза на стороне",
|
||||
"topics": ["глаза", "жизнь и смерть"],
|
||||
"difficulty": 4,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 0
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 5,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 2
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[ab][cb][ea];B[ba];W[ca];B[da])",
|
||||
"goal": {
|
||||
"type": "two-eyes"
|
||||
},
|
||||
"hint": "Разделите пространство; на вторжение отвечайте захватом."
|
||||
},
|
||||
{
|
||||
"id": "tsm-eyes-bottom-resist",
|
||||
"kind": "tsumego",
|
||||
"title": "Глаза на нижнем краю",
|
||||
"topics": ["глаза", "жизнь и смерть"],
|
||||
"difficulty": 4,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 7
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 7
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 7
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 7
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 7
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 8
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 6
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 6
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 6
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 6
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 6
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 7
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 8
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[ah][ch][ei];B[ci];W[bi];B[ai])",
|
||||
"goal": {
|
||||
"type": "two-eyes"
|
||||
},
|
||||
"hint": "Правильное деление даёт два глаза даже после вторжения."
|
||||
},
|
||||
{
|
||||
"id": "tsm-eyes-tee",
|
||||
"kind": "tsumego",
|
||||
"title": "Т-образное пространство",
|
||||
"topics": ["глаза", "жизнь и смерть"],
|
||||
"difficulty": 5,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 2,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 0
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 1,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 7,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 7,
|
||||
"y": 0
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[db][fb][ed];B[eb])",
|
||||
"goal": {
|
||||
"type": "two-eyes"
|
||||
},
|
||||
"hint": "Центральная точка превращает Т-форму в два глаза."
|
||||
}
|
||||
]
|
||||
254
apps/web/src/content/tasks/tsumego-svyaz.json
Normal file
254
apps/web/src/content/tasks/tsumego-svyaz.json
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
[
|
||||
{
|
||||
"id": "tsm-connect-gap",
|
||||
"kind": "tsumego",
|
||||
"title": "Закройте щель",
|
||||
"topics": ["связь"],
|
||||
"difficulty": 1,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 2,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 4
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 1,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 3
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]CR[cc][ce];B[cd])",
|
||||
"goal": {
|
||||
"type": "connect"
|
||||
},
|
||||
"hint": "Соедините свои камни до того, как белые разрежут."
|
||||
},
|
||||
{
|
||||
"id": "tsm-connect-cut-stone",
|
||||
"kind": "tsumego",
|
||||
"title": "Снимите разрезающий камень",
|
||||
"topics": ["связь", "разрезание"],
|
||||
"difficulty": 2,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 2,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 5
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 3
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 2,
|
||||
"y": 3
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]CR[cc][ce];B[dd];W[ed];B[cd])",
|
||||
"goal": {
|
||||
"type": "connect"
|
||||
},
|
||||
"hint": "Сначала снимите вклинившийся камень, потом соединитесь."
|
||||
},
|
||||
{
|
||||
"id": "tsm-cut-wedge",
|
||||
"kind": "tsumego",
|
||||
"title": "Вклинивание",
|
||||
"topics": ["разрезание", "захват"],
|
||||
"difficulty": 2,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 4,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 3
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 3,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 3
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[fd];B[ed])",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "Встаньте между белыми камнями — слабый погибнет."
|
||||
},
|
||||
{
|
||||
"id": "tsm-connect-choice",
|
||||
"kind": "tsumego",
|
||||
"title": "Единственная связь",
|
||||
"topics": ["связь"],
|
||||
"difficulty": 3,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 1,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 3
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 2,
|
||||
"y": 2
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]CR[bc][dd];B[cd];W[ce])",
|
||||
"goal": {
|
||||
"type": "connect"
|
||||
},
|
||||
"hint": "Соединяйтесь снизу — сверху стоит белый камень."
|
||||
},
|
||||
{
|
||||
"id": "tsm-connect-atari",
|
||||
"kind": "tsumego",
|
||||
"title": "Связь с атари",
|
||||
"topics": ["связь", "атари"],
|
||||
"difficulty": 3,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 3,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 5,
|
||||
"y": 4
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 4,
|
||||
"y": 4
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]CR[dd][fe];B[ed](;W[ef])(;W[ii];B[ef]))",
|
||||
"goal": {
|
||||
"type": "connect"
|
||||
},
|
||||
"hint": "Ход соединяет группы и одновременно ставит атари."
|
||||
},
|
||||
{
|
||||
"id": "tsm-connect-through",
|
||||
"kind": "tsumego",
|
||||
"title": "Соединение сквозь клин",
|
||||
"topics": ["связь", "разрезание"],
|
||||
"difficulty": 5,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 3,
|
||||
"y": 1
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 2
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 5
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 6
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 4
|
||||
},
|
||||
{
|
||||
"x": 4,
|
||||
"y": 2
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 3,
|
||||
"y": 3
|
||||
},
|
||||
{
|
||||
"x": 3,
|
||||
"y": 4
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]CR[db][dg];B[cd];W[ed];B[fd];W[ii];B[dd];W[ih];B[de])",
|
||||
"goal": {
|
||||
"type": "connect"
|
||||
},
|
||||
"hint": "Загоните клин в атари, доберите — и заполните брешь."
|
||||
}
|
||||
]
|
||||
108
apps/web/src/content/tasks/tsumego-yose-joseki.json
Normal file
108
apps/web/src/content/tasks/tsumego-yose-joseki.json
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
[
|
||||
{
|
||||
"id": "tsm-yose-edge",
|
||||
"kind": "tsumego",
|
||||
"title": "Дожим на первой линии",
|
||||
"topics": ["ёсэ", "захват"],
|
||||
"difficulty": 2,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 7,
|
||||
"y": 6
|
||||
},
|
||||
{
|
||||
"x": 7,
|
||||
"y": 7
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 8,
|
||||
"y": 6
|
||||
},
|
||||
{
|
||||
"x": 8,
|
||||
"y": 7
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[ig][ih];B[if];W[ii];B[hi])",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "Край помогает: у белых всё меньше дамэ."
|
||||
},
|
||||
{
|
||||
"id": "tsm-joseki-corner",
|
||||
"kind": "tsumego",
|
||||
"title": "Наказание в углу 3-3",
|
||||
"topics": ["дзёсэки", "захват"],
|
||||
"difficulty": 2,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 2,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 1,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[aa][ba][ab];B[ac])",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "Белые ошиблись в дзёсэки — у группы одно дамэ."
|
||||
},
|
||||
{
|
||||
"id": "tsm-yose-hane",
|
||||
"kind": "tsumego",
|
||||
"title": "Ханэ в углу",
|
||||
"topics": ["ёсэ", "захват"],
|
||||
"difficulty": 3,
|
||||
"size": 9,
|
||||
"toPlay": "black",
|
||||
"setupBlack": [
|
||||
{
|
||||
"x": 7,
|
||||
"y": 8
|
||||
},
|
||||
{
|
||||
"x": 6,
|
||||
"y": 7
|
||||
}
|
||||
],
|
||||
"setupWhite": [
|
||||
{
|
||||
"x": 8,
|
||||
"y": 7
|
||||
},
|
||||
{
|
||||
"x": 8,
|
||||
"y": 8
|
||||
}
|
||||
],
|
||||
"sgf": "(;GM[1]SZ[9]TR[ih][ii];B[ig];W[hh];B[hg])",
|
||||
"goal": {
|
||||
"type": "capture-target"
|
||||
},
|
||||
"hint": "Прижмите к краю и доберите."
|
||||
}
|
||||
]
|
||||
9
apps/web/src/env.d.ts
vendored
Normal file
9
apps/web/src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/// <reference types="astro/client" />
|
||||
|
||||
// tsc -b не видит сгенерированные типы контента Astro (каталог .astro/ в
|
||||
// .gitignore и не попадает в include), поэтому модуль astro:content объявлен
|
||||
// здесь через реальный entry-point пакета astro. Проверяется только
|
||||
// content.config.ts — страницы .astro tsc не типизирует.
|
||||
declare module 'astro:content' {
|
||||
export { defineCollection } from 'astro/content/config';
|
||||
}
|
||||
1489
apps/web/src/layouts/Layout.astro
Normal file
1489
apps/web/src/layouts/Layout.astro
Normal file
File diff suppressed because it is too large
Load diff
117
apps/web/src/lib/ai-client.test.ts
Normal file
117
apps/web/src/lib/ai-client.test.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* Тесты lib/ai-client.ts: маршрутизация ур. 1–3 в синхронный движок,
|
||||
* ур. 4–6 в воркер (фейк-воркер через инжекцию фабрики), cancel →
|
||||
* AiCancelledError, фолбэк при выбросе конструктора и при падении воркера.
|
||||
*/
|
||||
import type { WorkerChooseRequest, WorkerRequest, WorkerResponse } from '@go-learn/ai';
|
||||
import type { Move } from '@go-learn/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AiChooseRequest, WorkerLike } from './ai-client.js';
|
||||
import { AiCancelledError, createAiClient } from './ai-client.js';
|
||||
|
||||
const PASS: Move = { kind: 'pass', color: 'white' };
|
||||
|
||||
function baseRequest(level: AiChooseRequest['level']): AiChooseRequest {
|
||||
return { size: 9, komi: 5.5, moves: [], level, seed: 42 };
|
||||
}
|
||||
|
||||
/** Фейк-воркер: режимы — мгновенный result, молчание (для cancel), падение. */
|
||||
function fakeWorker(mode: 'result' | 'silent' | 'crash'): WorkerLike & {
|
||||
sent: WorkerRequest[];
|
||||
terminated: boolean;
|
||||
} {
|
||||
const self: WorkerLike & { sent: WorkerRequest[]; terminated: boolean } = {
|
||||
onmessage: null,
|
||||
onerror: null,
|
||||
sent: [],
|
||||
terminated: false,
|
||||
postMessage(message: WorkerRequest): void {
|
||||
self.sent.push(message);
|
||||
if (mode === 'crash') {
|
||||
self.onerror?.(new Error('boom'));
|
||||
return;
|
||||
}
|
||||
if (mode === 'result' && message.type === 'choose') {
|
||||
const response: WorkerResponse = {
|
||||
type: 'result',
|
||||
requestId: message.requestId,
|
||||
move: PASS,
|
||||
topMoves: [{ move: PASS, winRate: 0.5, visits: 10 }],
|
||||
simulations: 10,
|
||||
elapsedMs: 1,
|
||||
};
|
||||
queueMicrotask(() => self.onmessage?.({ data: response }));
|
||||
}
|
||||
},
|
||||
terminate(): void {
|
||||
self.terminated = true;
|
||||
},
|
||||
};
|
||||
return self;
|
||||
}
|
||||
|
||||
describe('ai-client: маршрутизация по уровням', () => {
|
||||
it('ур. 1–3 идут синхронно, воркер не создаётся', async () => {
|
||||
let created = 0;
|
||||
const client = createAiClient({
|
||||
createWorker: () => {
|
||||
created += 1;
|
||||
return fakeWorker('result');
|
||||
},
|
||||
});
|
||||
for (const level of [1, 2, 3] as const) {
|
||||
const answer = await client.choose(baseRequest(level));
|
||||
expect(answer.move.kind === 'play' || answer.move.kind === 'pass').toBe(true);
|
||||
}
|
||||
expect(created).toBe(0);
|
||||
expect(client.fallback).toBe(false);
|
||||
});
|
||||
|
||||
it('ур. 4–6 уходят в воркер с seed = seed партии + requestId', async () => {
|
||||
const worker = fakeWorker('result');
|
||||
const client = createAiClient({ createWorker: () => worker });
|
||||
const answer = await client.choose(baseRequest(5));
|
||||
expect(answer.move).toEqual(PASS);
|
||||
expect(answer.simulations).toBe(10);
|
||||
const sent = worker.sent[0] as WorkerChooseRequest;
|
||||
expect(sent.type).toBe('choose');
|
||||
expect(sent.level).toBe(5);
|
||||
expect(sent.seed).toBe(42 + sent.requestId);
|
||||
expect(sent.timeBudgetMs).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ai-client: cancel и фолбэк', () => {
|
||||
it('cancel отклоняет активный запрос с AiCancelledError и гасит воркер', async () => {
|
||||
const worker = fakeWorker('silent');
|
||||
const client = createAiClient({ createWorker: () => worker });
|
||||
const promise = client.choose(baseRequest(4));
|
||||
client.cancel();
|
||||
await expect(promise).rejects.toBeInstanceOf(AiCancelledError);
|
||||
expect(worker.terminated).toBe(true);
|
||||
});
|
||||
|
||||
it('конструктор воркера кинул → синхронный фолбэк + флаг fallback', async () => {
|
||||
let ticks = 0;
|
||||
const client = createAiClient({
|
||||
createWorker: () => {
|
||||
throw new Error('no workers');
|
||||
},
|
||||
clock: { now: () => (ticks += 1000) },
|
||||
});
|
||||
const answer = await client.choose(baseRequest(4));
|
||||
expect(answer.move.kind === 'play' || answer.move.kind === 'pass').toBe(true);
|
||||
expect(client.fallback).toBe(true);
|
||||
});
|
||||
|
||||
it('падение воркера во время запроса → фолбэк достраивает ответ', async () => {
|
||||
let ticks = 0;
|
||||
const client = createAiClient({
|
||||
createWorker: () => fakeWorker('crash'),
|
||||
clock: { now: () => (ticks += 1000) },
|
||||
});
|
||||
const answer = await client.choose(baseRequest(6));
|
||||
expect(answer.move.kind === 'play' || answer.move.kind === 'pass').toBe(true);
|
||||
expect(client.fallback).toBe(true);
|
||||
});
|
||||
});
|
||||
207
apps/web/src/lib/ai-client.ts
Normal file
207
apps/web/src/lib/ai-client.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
/**
|
||||
* Клиент ИИ для UI (контракт — docs/INTERFACES.md, «Клиент ИИ для UI»).
|
||||
* Ур. 1–3 — синхронный chooseMove этапа 3 (воркер не нужен), ур. 4–6 —
|
||||
* Web Worker с протоколом этапа 4. seed воркера = seed партии + requestId.
|
||||
* Fail-safe: конструктор Worker кинул / воркер упал → синхронный фолбэк
|
||||
* chooseMoveMcts в основном потоке + флаг fallback (для пометки в UI).
|
||||
* Воркер инжектируется фабрикой (в тестах — фейк-воркер).
|
||||
*/
|
||||
import { chooseMove, chooseMoveMcts, LEVEL_TIME_BUDGET_MS, rebuildGameState } from '@go-learn/ai';
|
||||
import type { CandidateScore, MctsLevel, WorkerRequest, WorkerResponse } from '@go-learn/ai';
|
||||
import { createSeededRng } from '@go-learn/core';
|
||||
import type { BoardSize, Clock, Move, Point } from '@go-learn/core';
|
||||
|
||||
export interface AiAnswer {
|
||||
readonly move: Move;
|
||||
readonly topMoves?: readonly CandidateScore[];
|
||||
readonly simulations?: number;
|
||||
}
|
||||
|
||||
export interface AiChooseRequest {
|
||||
readonly size: BoardSize;
|
||||
readonly komi: number;
|
||||
readonly moves: readonly Move[];
|
||||
readonly level: 1 | 2 | 3 | 4 | 5 | 6;
|
||||
readonly seed: number;
|
||||
}
|
||||
|
||||
export class AiCancelledError extends Error {
|
||||
constructor() {
|
||||
super('запрос ИИ отменён');
|
||||
this.name = 'AiCancelledError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface AiClient {
|
||||
/** true после перехода на синхронный фолбэк (Worker недоступен/упал). */
|
||||
readonly fallback: boolean;
|
||||
choose(request: AiChooseRequest): Promise<AiAnswer>;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
/** Минимальный интерфейс воркера (структурно совместим с DOM Worker). */
|
||||
export interface WorkerLike {
|
||||
onmessage: ((event: { data: WorkerResponse }) => void) | null;
|
||||
onerror: ((event: unknown) => void) | null;
|
||||
postMessage(message: WorkerRequest): void;
|
||||
terminate(): void;
|
||||
}
|
||||
|
||||
export interface AiClientDeps {
|
||||
readonly createWorker?: () => WorkerLike;
|
||||
readonly clock?: Clock; // часы фолбэка (в тестах — фейк)
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
readonly requestId: number;
|
||||
readonly request: AiChooseRequest;
|
||||
readonly seed: number;
|
||||
readonly resolve: (answer: AiAnswer) => void;
|
||||
readonly reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
function defaultWorkerFactory(): WorkerLike {
|
||||
const worker = new Worker(new URL('../ai-worker.ts', import.meta.url), { type: 'module' });
|
||||
return worker as unknown as WorkerLike;
|
||||
}
|
||||
|
||||
function lastOpponentPoint(moves: readonly Move[]): Point | null {
|
||||
for (let index = moves.length - 1; index >= 0; index -= 1) {
|
||||
const move = moves[index];
|
||||
if (move !== undefined && move.kind === 'play') return move.point;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createAiClient(deps: AiClientDeps = {}): AiClient {
|
||||
const factory = deps.createWorker ?? defaultWorkerFactory;
|
||||
const clock: Clock = deps.clock ?? { now: () => performance.now() };
|
||||
let worker: WorkerLike | null = null;
|
||||
let broken = false;
|
||||
let pending: PendingRequest | null = null;
|
||||
let nextRequestId = 1;
|
||||
|
||||
/** Синхронный фолбэк ур. 4–6 в основном потоке; помечает fallback. */
|
||||
function fallbackChoose(request: AiChooseRequest, seed: number): AiAnswer {
|
||||
broken = true;
|
||||
const level = request.level as MctsLevel;
|
||||
const rebuilt = rebuildGameState(request.size, request.moves);
|
||||
if (!rebuilt.ok) throw new Error(rebuilt.message);
|
||||
const result = chooseMoveMcts({
|
||||
state: rebuilt.state,
|
||||
komi: request.komi,
|
||||
level,
|
||||
rng: createSeededRng(seed),
|
||||
clock,
|
||||
});
|
||||
return { move: result.move, topMoves: result.topMoves, simulations: result.simulations };
|
||||
}
|
||||
|
||||
function settlePending(response: WorkerResponse): void {
|
||||
const current = pending;
|
||||
if (current === null || response.requestId !== current.requestId) return;
|
||||
pending = null;
|
||||
if (response.type === 'error') {
|
||||
current.reject(new Error(response.message));
|
||||
return;
|
||||
}
|
||||
current.resolve({
|
||||
move: response.move,
|
||||
topMoves: response.topMoves,
|
||||
simulations: response.simulations,
|
||||
});
|
||||
}
|
||||
|
||||
/** Падение воркера: активный запрос достраиваем синхронным фолбэком. */
|
||||
function handleWorkerCrash(): void {
|
||||
const current = pending;
|
||||
if (worker !== null) worker.terminate();
|
||||
worker = null;
|
||||
if (current === null) {
|
||||
broken = true;
|
||||
return;
|
||||
}
|
||||
pending = null;
|
||||
try {
|
||||
current.resolve(fallbackChoose(current.request, current.seed));
|
||||
} catch (error) {
|
||||
current.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureWorker(): WorkerLike | null {
|
||||
if (broken) return null;
|
||||
if (worker !== null) return worker;
|
||||
try {
|
||||
worker = factory();
|
||||
} catch {
|
||||
broken = true;
|
||||
return null;
|
||||
}
|
||||
worker.onmessage = (event): void => settlePending(event.data);
|
||||
worker.onerror = (): void => handleWorkerCrash();
|
||||
return worker;
|
||||
}
|
||||
|
||||
/** Ур. 1–3: мгновенный синхронный ход эвристики этапа 3. */
|
||||
function heuristicChoose(request: AiChooseRequest, seed: number): AiAnswer {
|
||||
const rebuilt = rebuildGameState(request.size, request.moves);
|
||||
if (!rebuilt.ok) throw new Error(rebuilt.message);
|
||||
const result = chooseMove({
|
||||
state: rebuilt.state,
|
||||
level: request.level as 1 | 2 | 3,
|
||||
rng: createSeededRng(seed),
|
||||
lastOpponentMove: lastOpponentPoint(request.moves),
|
||||
});
|
||||
return { move: result.move };
|
||||
}
|
||||
|
||||
function workerChoose(
|
||||
request: AiChooseRequest,
|
||||
requestId: number,
|
||||
seed: number,
|
||||
): Promise<AiAnswer> {
|
||||
const active = ensureWorker();
|
||||
if (active === null) return Promise.resolve(fallbackChoose(request, seed));
|
||||
const level = request.level as MctsLevel;
|
||||
return new Promise<AiAnswer>((resolve, reject) => {
|
||||
pending = { requestId, request, seed, resolve, reject };
|
||||
active.postMessage({
|
||||
type: 'choose',
|
||||
requestId,
|
||||
size: request.size,
|
||||
komi: request.komi,
|
||||
moves: request.moves,
|
||||
level,
|
||||
seed,
|
||||
timeBudgetMs: LEVEL_TIME_BUDGET_MS[level],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
get fallback(): boolean {
|
||||
return broken;
|
||||
},
|
||||
choose(request: AiChooseRequest): Promise<AiAnswer> {
|
||||
const requestId = nextRequestId;
|
||||
nextRequestId += 1;
|
||||
const seed = (request.seed + requestId) >>> 0;
|
||||
if (request.level <= 3) {
|
||||
try {
|
||||
return Promise.resolve(heuristicChoose(request, seed));
|
||||
} catch (error) {
|
||||
return Promise.reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
return workerChoose(request, requestId, seed);
|
||||
},
|
||||
cancel(): void {
|
||||
const current = pending;
|
||||
pending = null;
|
||||
if (worker !== null) worker.terminate();
|
||||
worker = null; // пересоздание — лениво при следующем choose
|
||||
if (current !== null) current.reject(new AiCancelledError());
|
||||
},
|
||||
};
|
||||
}
|
||||
198
apps/web/src/lib/game-store.test.ts
Normal file
198
apps/web/src/lib/game-store.test.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
/**
|
||||
* Тесты lib/game-store.ts (чистый стор, без DOM): новая партия, ход через
|
||||
* pending→confirm, отказ с reason, пас×2 → подсчёт, toggle мёртвых, resign,
|
||||
* undo пары ходов, просмотр SGF с навигацией. Геометрия — настоящая из
|
||||
* @go-learn/board; ИИ — фейк-клиент.
|
||||
*/
|
||||
import { computeGeometry, pointToPixel } from '@go-learn/board';
|
||||
import type { BoardGeometry } from '@go-learn/board';
|
||||
import { findNode } from '@go-learn/core';
|
||||
import type { Move, Point } from '@go-learn/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AiAnswer, AiChooseRequest, AiClient } from './ai-client.js';
|
||||
import { createGameStore } from './game-store.js';
|
||||
import type { GameStore } from './game-store.js';
|
||||
import { DEFAULT_SETTINGS } from './storage.js';
|
||||
import type { SettingsV1 } from './storage.js';
|
||||
|
||||
/** Фейк-клиент ИИ: по умолчанию всегда пасует цветом, чей ход. */
|
||||
function fakeAi(handler?: (request: AiChooseRequest) => AiAnswer): AiClient & {
|
||||
calls: AiChooseRequest[];
|
||||
} {
|
||||
const calls: AiChooseRequest[] = [];
|
||||
return {
|
||||
calls,
|
||||
fallback: false,
|
||||
choose(request: AiChooseRequest): Promise<AiAnswer> {
|
||||
calls.push(request);
|
||||
if (handler !== undefined) return Promise.resolve(handler(request));
|
||||
const color = request.moves.length % 2 === 0 ? 'black' : 'white';
|
||||
return Promise.resolve({ move: { kind: 'pass', color } });
|
||||
},
|
||||
cancel(): void {},
|
||||
};
|
||||
}
|
||||
|
||||
function makeStore(
|
||||
ai: AiClient,
|
||||
settings: SettingsV1 = DEFAULT_SETTINGS,
|
||||
seed = 7,
|
||||
): { store: GameStore; geo: BoardGeometry } {
|
||||
const store = createGameStore({ ai, seed, initialSettings: settings });
|
||||
const geo = computeGeometry(settings.size, 450, true);
|
||||
store.setGeometry(geo);
|
||||
return { store, geo };
|
||||
}
|
||||
|
||||
async function playAt(store: GameStore, geo: BoardGeometry, point: Point): Promise<void> {
|
||||
const pixel = pointToPixel(geo, point);
|
||||
await store.tapPoint(pixel.x, pixel.y);
|
||||
await store.confirm();
|
||||
}
|
||||
|
||||
function currentMove(store: GameStore): Move | null {
|
||||
const state = store.getState();
|
||||
return findNode(state.tree.root, state.tree.currentId)?.move ?? null;
|
||||
}
|
||||
|
||||
describe('game-store: новая партия и ходы', () => {
|
||||
it('начальное состояние — пустая партия, фаза игра', () => {
|
||||
const { store } = makeStore(fakeAi());
|
||||
const state = store.getState();
|
||||
expect(state.phase).toBe('playing');
|
||||
expect(state.tree.size).toBe(9);
|
||||
expect(state.tree.currentId).toBe(0);
|
||||
expect(state.playerColor).toBe('black');
|
||||
});
|
||||
|
||||
it('ход через pending → confirm: фантом, затем ход и ответ бота', async () => {
|
||||
const { store, geo } = makeStore(fakeAi());
|
||||
const pixel = pointToPixel(geo, { x: 2, y: 2 });
|
||||
await store.tapPoint(pixel.x, pixel.y);
|
||||
expect(store.getState().pending).toEqual({ x: 2, y: 2 });
|
||||
await store.confirm();
|
||||
const state = store.getState();
|
||||
expect(state.pending).toBeNull();
|
||||
expect(currentMove(store)).toEqual({ kind: 'pass', color: 'white' });
|
||||
expect(state.botThinking).toBe(false);
|
||||
expect(state.lastError).toBeNull();
|
||||
});
|
||||
|
||||
it('нелегальный ход → lastError с reason, дерево не меняется', async () => {
|
||||
const { store, geo } = makeStore(fakeAi());
|
||||
await playAt(store, geo, { x: 2, y: 2 });
|
||||
const before = store.getState().tree;
|
||||
await playAt(store, geo, { x: 2, y: 2 });
|
||||
expect(store.getState().lastError).toBe('occupied');
|
||||
expect(store.getState().tree).toBe(before);
|
||||
});
|
||||
|
||||
it('первый тап переносит фантом, тап по другой точке — новый фантом', async () => {
|
||||
const { store, geo } = makeStore(fakeAi());
|
||||
const a = pointToPixel(geo, { x: 1, y: 1 });
|
||||
const b = pointToPixel(geo, { x: 3, y: 3 });
|
||||
await store.tapPoint(a.x, a.y);
|
||||
await store.tapPoint(b.x, b.y);
|
||||
expect(store.getState().pending).toEqual({ x: 3, y: 3 });
|
||||
store.cancelPending();
|
||||
expect(store.getState().pending).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('game-store: окончание партии и подсчёт', () => {
|
||||
it('пас × 2 → фаза подсчёта с предзаполненным итогом', async () => {
|
||||
const { store } = makeStore(fakeAi());
|
||||
await store.pass();
|
||||
const state = store.getState();
|
||||
expect(state.phase).toBe('scoring');
|
||||
expect(state.score).not.toBeNull();
|
||||
expect(state.score?.winner).toBe('white'); // пустая доска: белые выигрывают коми
|
||||
expect(state.tree.meta.result).toBe('W+5.5');
|
||||
});
|
||||
|
||||
it('toggleDead группы меняет итог подсчёта', async () => {
|
||||
const { store, geo } = makeStore(fakeAi());
|
||||
await playAt(store, geo, { x: 0, y: 0 });
|
||||
await playAt(store, geo, { x: 0, y: 1 });
|
||||
await store.pass();
|
||||
const before = store.getState();
|
||||
// Эвристика ядра предложила пару (0,0)+(0,1) мёртвой → префилл dead.
|
||||
expect(before.dead.size).toBe(2);
|
||||
expect(before.score?.black).toBe(0);
|
||||
store.toggleDead({ x: 0, y: 0 }); // снимает отметку со всей группы
|
||||
const after = store.getState();
|
||||
expect(after.dead.size).toBe(0);
|
||||
expect(after.score?.black).toBe(81); // камни + вся территория снова чёрные
|
||||
});
|
||||
|
||||
it('resign → meta.result W+R при сдаче чёрных', async () => {
|
||||
const { store } = makeStore(fakeAi());
|
||||
store.resign();
|
||||
const state = store.getState();
|
||||
expect(state.tree.meta.result).toBe('W+R');
|
||||
expect(findNode(state.tree.root, state.tree.currentId)?.state.over).toBe(true);
|
||||
});
|
||||
|
||||
it('undo откатывает пару ходов (игрок + ответ бота)', async () => {
|
||||
const botPlays = (): AiAnswer => ({
|
||||
move: { kind: 'play', color: 'white', point: { x: 4, y: 4 } },
|
||||
});
|
||||
const { store, geo } = makeStore(fakeAi(botPlays));
|
||||
await playAt(store, geo, { x: 2, y: 2 });
|
||||
expect(store.getState().tree.currentId).toBe(2);
|
||||
store.undo();
|
||||
expect(store.getState().tree.currentId).toBe(0);
|
||||
await playAt(store, geo, { x: 5, y: 5 });
|
||||
store.undo();
|
||||
expect(store.getState().tree.currentId).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('game-store: цвета и бот первым', () => {
|
||||
it('playerColor white → бот ходит первым за чёрных', async () => {
|
||||
const ai = fakeAi();
|
||||
const { store } = makeStore(ai, { ...DEFAULT_SETTINGS, playerColor: 'white' });
|
||||
await store.newGame({ ...DEFAULT_SETTINGS, playerColor: 'white' });
|
||||
expect(currentMove(store)).toEqual({ kind: 'pass', color: 'black' });
|
||||
expect(ai.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('random с чётным seed → чёрные, без запроса к ИИ на старте', () => {
|
||||
const ai = fakeAi();
|
||||
const { store } = makeStore(ai, { ...DEFAULT_SETTINGS, playerColor: 'random' }, 8);
|
||||
expect(store.getState().playerColor).toBe('black');
|
||||
expect(ai.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('game-store: просмотр SGF', () => {
|
||||
const SGF = '(;FF[4]GM[1]SZ[9]KM[5.5];B[aa];W[bb];B[cc])';
|
||||
|
||||
it('loadSgfViewer + навигация вперёд/назад по главной линии', async () => {
|
||||
const { store, geo } = makeStore(fakeAi());
|
||||
await playAt(store, geo, { x: 7, y: 7 });
|
||||
const gameTree = store.getState().tree;
|
||||
expect(store.loadSgfViewer(SGF)).toBe(true);
|
||||
expect(store.getState().phase).toBe('viewer');
|
||||
expect(store.getState().tree.currentId).toBe(0);
|
||||
store.viewerNext();
|
||||
expect(currentMove(store)).toEqual({ kind: 'play', color: 'black', point: { x: 0, y: 0 } });
|
||||
store.viewerNext();
|
||||
store.viewerNext();
|
||||
expect(store.getState().tree.currentId).toBe(3);
|
||||
store.viewerNext(); // дальше конца — без изменений
|
||||
expect(store.getState().tree.currentId).toBe(3);
|
||||
store.viewerPrev();
|
||||
expect(store.getState().tree.currentId).toBe(2);
|
||||
store.exitViewer();
|
||||
expect(store.getState().phase).toBe('playing');
|
||||
expect(store.getState().tree).toBe(gameTree);
|
||||
});
|
||||
|
||||
it('битый SGF → false и sgfError, фаза не меняется', () => {
|
||||
const { store } = makeStore(fakeAi());
|
||||
expect(store.loadSgfViewer('(не sgf')).toBe(false);
|
||||
expect(store.getState().sgfError).not.toBeNull();
|
||||
expect(store.getState().phase).toBe('playing');
|
||||
});
|
||||
});
|
||||
374
apps/web/src/lib/game-store.ts
Normal file
374
apps/web/src/lib/game-store.ts
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
/**
|
||||
* Состояние партии для страницы /play: чистый стор без DOM и без строк UI.
|
||||
* Дерево партии — GameTree из @go-learn/core; тач-ввод — reduceInput из
|
||||
* @go-learn/board (геометрию вычисляет и подсовывает страница). Ход бота —
|
||||
* асинхронно через AiClient с защитой от гонки (requestId). Строки
|
||||
* статусов собирает страница из ui/strings.ts по кодам состояния.
|
||||
*/
|
||||
import type { BoardGeometry } from '@go-learn/board';
|
||||
import { reduceInput } from '@go-learn/board';
|
||||
import type { InputState } from '@go-learn/board';
|
||||
import {
|
||||
appendMove,
|
||||
createGame,
|
||||
defaultKomi,
|
||||
findNode,
|
||||
goToNode,
|
||||
groupAt,
|
||||
parseSgf,
|
||||
pointKey,
|
||||
scorePosition,
|
||||
suggestDeadGroups,
|
||||
} from '@go-learn/core';
|
||||
import type {
|
||||
Color,
|
||||
GameNode,
|
||||
GameTree,
|
||||
Move,
|
||||
MoveError,
|
||||
NodeMarkup,
|
||||
Point,
|
||||
ScoreResult,
|
||||
} from '@go-learn/core';
|
||||
import { AiCancelledError } from './ai-client.js';
|
||||
import type { AiAnswer, AiClient } from './ai-client.js';
|
||||
import type { SettingsV1 } from './storage.js';
|
||||
|
||||
export type GamePhase = 'playing' | 'scoring' | 'viewer';
|
||||
|
||||
export interface HintState {
|
||||
readonly markup: NodeMarkup; // круги топ-3 + подписи winRate %
|
||||
readonly bestWinRate: number; // 0..1 лучшего хода последнего ответа
|
||||
readonly delta: number | null; // изменение к прошлой оценке; null — первая
|
||||
}
|
||||
|
||||
export interface GameStoreState {
|
||||
readonly phase: GamePhase;
|
||||
readonly tree: GameTree;
|
||||
readonly settings: SettingsV1;
|
||||
readonly playerColor: Color; // 'random' разрешён в конкретный цвет
|
||||
readonly pending: Point | null;
|
||||
readonly lastError: MoveError | null;
|
||||
readonly botThinking: boolean;
|
||||
readonly botFailed: boolean; // ИИ вернул нелегальный ход / ошибку
|
||||
readonly aiFallback: boolean; // клиент ИИ перешёл на синхронный фолбэк
|
||||
readonly dead: ReadonlySet<string>;
|
||||
readonly score: ScoreResult | null;
|
||||
readonly hint: HintState | null;
|
||||
readonly sgfError: string | null;
|
||||
}
|
||||
|
||||
export interface GameStoreDeps {
|
||||
readonly ai: AiClient;
|
||||
readonly seed: number; // seed партии (генерирует страница)
|
||||
readonly initialSettings: SettingsV1;
|
||||
}
|
||||
|
||||
export interface GameStore {
|
||||
getState(): GameStoreState;
|
||||
subscribe(listener: () => void): () => void;
|
||||
setGeometry(geometry: BoardGeometry): void;
|
||||
/** Визуальные настройки (координаты, номера, подсказки) без новой партии. */
|
||||
applySettings(settings: SettingsV1): void;
|
||||
newGame(settings: SettingsV1): Promise<void>;
|
||||
tapPoint(px: number, py: number): Promise<void>;
|
||||
confirm(): Promise<void>;
|
||||
cancelPending(): void;
|
||||
pass(): Promise<void>;
|
||||
resign(): void;
|
||||
undo(): void;
|
||||
toggleDead(point: Point): void;
|
||||
computeScore(): void;
|
||||
loadSgfViewer(text: string): boolean;
|
||||
viewerPrev(): void;
|
||||
viewerNext(): void;
|
||||
exitViewer(): void;
|
||||
}
|
||||
|
||||
/** Текущий узел дерева (currentId гарантированно существует). */
|
||||
function currentNode(tree: GameTree): GameNode {
|
||||
const node = findNode(tree.root, tree.currentId);
|
||||
if (node === null) throw new Error('game-store: текущий узел не найден');
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Родитель узла id; null — корень или узел не найден. */
|
||||
function parentOf(root: GameNode, id: number): GameNode | null {
|
||||
for (const child of root.children) {
|
||||
if (child.id === id) return root;
|
||||
const found = parentOf(child, id);
|
||||
if (found !== null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Список ходов от корня до текущего узла (протокол ИИ stateless). */
|
||||
function movesFromRoot(tree: GameTree): Move[] {
|
||||
const moves: Move[] = [];
|
||||
const walk = (node: GameNode): boolean => {
|
||||
if (node.id === tree.currentId) return true;
|
||||
for (const child of node.children) {
|
||||
if (walk(child)) {
|
||||
if (child.move !== null) moves.push(child.move);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
walk(tree.root);
|
||||
return moves.reverse();
|
||||
}
|
||||
|
||||
/** Разметка подсказки: круги топ-3, подписи — winRate в процентах. */
|
||||
function hintMarkup(answer: AiAnswer): NodeMarkup {
|
||||
const top = (answer.topMoves ?? []).slice(0, 3);
|
||||
const circles: Point[] = [];
|
||||
const labels: { point: Point; text: string }[] = [];
|
||||
for (const candidate of top) {
|
||||
if (candidate.move.kind !== 'play') continue;
|
||||
circles.push(candidate.move.point);
|
||||
labels.push({
|
||||
point: candidate.move.point,
|
||||
text: `${Math.round(candidate.winRate * 100)}%`,
|
||||
});
|
||||
}
|
||||
return { labels, triangles: [], squares: [], circles };
|
||||
}
|
||||
|
||||
export function createGameStore(deps: GameStoreDeps): GameStore {
|
||||
let state: GameStoreState;
|
||||
let geometry: BoardGeometry | null = null;
|
||||
let input: InputState = { pending: null };
|
||||
let requestCounter = 0;
|
||||
let prevBestWinRate: number | null = null;
|
||||
let savedGame: { tree: GameTree; playerColor: Color; settings: SettingsV1 } | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function patch(part: Partial<GameStoreState>): void {
|
||||
state = { ...state, ...part };
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function applyTree(next: GameTree, lastError: MoveError | null): void {
|
||||
patch({ tree: next, lastError, pending: null, hint: null });
|
||||
input = { pending: null };
|
||||
}
|
||||
|
||||
/** Подсчёт с текущими dead; заодно проставляет meta.result для SGF. */
|
||||
function scoreNow(tree: GameTree, dead: ReadonlySet<string>): ScoreResult {
|
||||
const score = scorePosition(currentNode(tree).state, { komi: tree.komi, dead });
|
||||
const result =
|
||||
score.margin === 0 ? 'Draw' : score.margin > 0 ? `B+${score.margin}` : `W+${-score.margin}`;
|
||||
patch({ tree: { ...tree, meta: { ...tree.meta, result } }, score });
|
||||
return score;
|
||||
}
|
||||
|
||||
function enterScoring(tree: GameTree): void {
|
||||
const dead = new Set<string>();
|
||||
for (const group of suggestDeadGroups(currentNode(tree).state)) {
|
||||
for (const stone of group) dead.add(pointKey(stone));
|
||||
}
|
||||
patch({ phase: 'scoring', dead });
|
||||
scoreNow(tree, dead);
|
||||
}
|
||||
|
||||
/** Ход бота: асинхронно, защита от гонки номером запроса. */
|
||||
async function requestBotMove(tree: GameTree): Promise<void> {
|
||||
const board = currentNode(tree).state;
|
||||
if (board.over || board.toPlay === state.playerColor || state.phase !== 'playing') return;
|
||||
const id = ++requestCounter;
|
||||
patch({ botThinking: true });
|
||||
try {
|
||||
const answer = await deps.ai.choose({
|
||||
size: tree.size,
|
||||
komi: tree.komi,
|
||||
moves: movesFromRoot(tree),
|
||||
level: state.settings.level,
|
||||
seed: deps.seed,
|
||||
});
|
||||
if (id !== requestCounter || state.phase !== 'playing') return;
|
||||
applyBotAnswer(tree, answer);
|
||||
} catch (error) {
|
||||
if (error instanceof AiCancelledError) return;
|
||||
if (id === requestCounter) patch({ botFailed: true });
|
||||
} finally {
|
||||
if (id === requestCounter) patch({ botThinking: false, aiFallback: deps.ai.fallback });
|
||||
}
|
||||
}
|
||||
|
||||
function applyBotAnswer(tree: GameTree, answer: AiAnswer): void {
|
||||
const appended = appendMove(tree, answer.move);
|
||||
if (!appended.ok) {
|
||||
patch({ botFailed: true });
|
||||
return;
|
||||
}
|
||||
const hint = buildHint(answer);
|
||||
patch({ tree: appended.tree, hint, lastError: null });
|
||||
if (currentNode(appended.tree).state.over) enterScoring(appended.tree);
|
||||
}
|
||||
|
||||
/** Подсказка ур. 4+: разметка + дельта winRate лучшего хода. */
|
||||
function buildHint(answer: AiAnswer): HintState | null {
|
||||
const top = answer.topMoves?.[0];
|
||||
if (!state.settings.hintsEnabled || top === undefined) return null;
|
||||
const delta = prevBestWinRate === null ? null : top.winRate - prevBestWinRate;
|
||||
prevBestWinRate = top.winRate;
|
||||
return { markup: hintMarkup(answer), bestWinRate: top.winRate, delta };
|
||||
}
|
||||
|
||||
/** Общий путь хода игрока/бота: append + реакция на окончание/ответ бота. */
|
||||
async function playMove(move: Move): Promise<void> {
|
||||
if (state.phase !== 'playing' || state.botThinking) return;
|
||||
const appended = appendMove(state.tree, move);
|
||||
if (!appended.ok) {
|
||||
if (appended.reason !== 'node-not-found') patch({ lastError: appended.reason });
|
||||
return;
|
||||
}
|
||||
applyTree(appended.tree, null);
|
||||
const over = currentNode(appended.tree).state.over;
|
||||
if (over && move.kind !== 'resign') enterScoring(appended.tree);
|
||||
if (!over) await requestBotMove(appended.tree);
|
||||
}
|
||||
|
||||
function resolveColor(settings: SettingsV1): Color {
|
||||
if (settings.playerColor !== 'random') return settings.playerColor;
|
||||
// Детерминированно от seed партии — воспроизводимо в тестах.
|
||||
return deps.seed % 2 === 0 ? 'black' : 'white';
|
||||
}
|
||||
|
||||
function freshState(settings: SettingsV1): GameStoreState {
|
||||
const komi = settings.komi === 'auto' ? defaultKomi(settings.size) : settings.komi;
|
||||
return {
|
||||
phase: 'playing',
|
||||
tree: createGame({ size: settings.size, komi }),
|
||||
settings,
|
||||
playerColor: resolveColor(settings),
|
||||
pending: null,
|
||||
lastError: null,
|
||||
botThinking: false,
|
||||
botFailed: false,
|
||||
aiFallback: deps.ai.fallback,
|
||||
dead: new Set<string>(),
|
||||
score: null,
|
||||
hint: null,
|
||||
sgfError: null,
|
||||
};
|
||||
}
|
||||
|
||||
state = freshState(deps.initialSettings);
|
||||
|
||||
async function handleInput(event: Parameters<typeof reduceInput>[1]): Promise<void> {
|
||||
if (geometry === null || state.phase !== 'playing' || state.botThinking) return;
|
||||
const board = currentNode(state.tree).state;
|
||||
if (board.over || board.toPlay !== state.playerColor) return;
|
||||
const result = reduceInput(input, event, geometry);
|
||||
input = result.state;
|
||||
patch({ pending: result.state.pending });
|
||||
if (result.commit !== null) {
|
||||
await playMove({ kind: 'play', color: state.playerColor, point: result.commit });
|
||||
}
|
||||
}
|
||||
|
||||
function undoSteps(): number {
|
||||
const current = currentNode(state.tree);
|
||||
if (current.move === null) return 0;
|
||||
// Бот ещё не ответил (ход игрока — текущий) → откат одного хода.
|
||||
return current.move.color === state.playerColor ? 1 : 2;
|
||||
}
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
setGeometry(next: BoardGeometry): void {
|
||||
geometry = next;
|
||||
},
|
||||
applySettings(settings: SettingsV1): void {
|
||||
patch({ settings });
|
||||
},
|
||||
async newGame(settings: SettingsV1): Promise<void> {
|
||||
deps.ai.cancel();
|
||||
requestCounter += 1;
|
||||
prevBestWinRate = null;
|
||||
input = { pending: null };
|
||||
state = { ...freshState(settings), aiFallback: deps.ai.fallback };
|
||||
for (const listener of listeners) listener();
|
||||
await requestBotMove(state.tree);
|
||||
},
|
||||
tapPoint: (px: number, py: number) => handleInput({ type: 'tap', px, py }),
|
||||
confirm: () => handleInput({ type: 'confirm' }),
|
||||
cancelPending(): void {
|
||||
input = { pending: null };
|
||||
patch({ pending: null });
|
||||
},
|
||||
pass: () => playMove({ kind: 'pass', color: state.playerColor }),
|
||||
resign(): void {
|
||||
void playMove({ kind: 'resign', color: state.playerColor });
|
||||
},
|
||||
undo(): void {
|
||||
if (state.phase !== 'playing') return;
|
||||
if (state.botThinking) {
|
||||
deps.ai.cancel();
|
||||
requestCounter += 1;
|
||||
patch({ botThinking: false });
|
||||
}
|
||||
let tree = state.tree;
|
||||
for (let step = undoSteps(); step > 0; step -= 1) {
|
||||
const parent = parentOf(tree.root, tree.currentId);
|
||||
if (parent === null) break;
|
||||
tree = goToNode(tree, parent.id);
|
||||
}
|
||||
applyTree(tree, null);
|
||||
patch({ dead: new Set<string>(), score: null, botFailed: false });
|
||||
},
|
||||
toggleDead(point: Point): void {
|
||||
if (state.phase !== 'scoring') return;
|
||||
const group = groupAt(currentNode(state.tree).state, point);
|
||||
if (group === null) return;
|
||||
const dead = new Set(state.dead);
|
||||
const keys = group.stones.map(pointKey);
|
||||
const removing = keys.every((key) => dead.has(key));
|
||||
for (const key of keys) {
|
||||
if (removing) dead.delete(key);
|
||||
else dead.add(key);
|
||||
}
|
||||
patch({ dead });
|
||||
scoreNow(state.tree, dead);
|
||||
},
|
||||
computeScore(): void {
|
||||
if (state.phase !== 'scoring') return;
|
||||
scoreNow(state.tree, state.dead);
|
||||
},
|
||||
loadSgfViewer(text: string): boolean {
|
||||
try {
|
||||
const parsed = parseSgf(text);
|
||||
const tree = goToNode(parsed, parsed.root.id); // просмотр с начала
|
||||
savedGame = { tree: state.tree, playerColor: state.playerColor, settings: state.settings };
|
||||
deps.ai.cancel();
|
||||
requestCounter += 1;
|
||||
patch({ phase: 'viewer', tree, botThinking: false, sgfError: null });
|
||||
return true;
|
||||
} catch (error) {
|
||||
patch({ sgfError: error instanceof Error ? error.message : String(error) });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
viewerPrev(): void {
|
||||
if (state.phase !== 'viewer') return;
|
||||
const parent = parentOf(state.tree.root, state.tree.currentId);
|
||||
if (parent !== null) patch({ tree: goToNode(state.tree, parent.id) });
|
||||
},
|
||||
viewerNext(): void {
|
||||
if (state.phase !== 'viewer') return;
|
||||
const child = currentNode(state.tree).children[0];
|
||||
if (child !== undefined) patch({ tree: goToNode(state.tree, child.id) });
|
||||
},
|
||||
exitViewer(): void {
|
||||
if (state.phase !== 'viewer' || savedGame === null) return;
|
||||
patch({ phase: 'playing', tree: savedGame.tree, playerColor: savedGame.playerColor });
|
||||
savedGame = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
120
apps/web/src/lib/goals.test.ts
Normal file
120
apps/web/src/lib/goals.test.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* Тесты lib/goals.ts: все 4 предиката целей + формальный глаз v1
|
||||
* (позитив/негатив: область 4 пунктов — не глаз; область у края — глаз).
|
||||
* Позиции строятся buildPosition, ходы — applyMove из @go-learn/core.
|
||||
*/
|
||||
import { applyMove, buildPosition } from '@go-learn/core';
|
||||
import type { BoardState, Point } from '@go-learn/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { goalReached } from './goals.js';
|
||||
|
||||
function p(x: number, y: number): Point {
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/** Ход чёрных/белых, падает с ошибкой теста при нелегальности. */
|
||||
function play(state: BoardState, color: 'black' | 'white', point: Point): BoardState {
|
||||
const result = applyMove(state, { kind: 'play', color, point });
|
||||
if (!result.ok) throw new Error(`тест: ход нелегален: ${result.error}`);
|
||||
return result.state;
|
||||
}
|
||||
|
||||
describe('goals: capture-target', () => {
|
||||
// Белый камень (4,4) в атари: чёрные (3,4),(5,4),(4,3), дамэ — (4,5).
|
||||
const start = buildPosition(9, [p(3, 4), p(5, 4), p(4, 3)], [p(4, 4)], 'black');
|
||||
const target = [p(4, 4)];
|
||||
|
||||
it('цель на доске → не выполнено; захват → выполнено', () => {
|
||||
expect(goalReached(start, { type: 'capture-target' }, target)).toBe(false);
|
||||
const after = play(start, 'black', p(4, 5));
|
||||
expect(goalReached(after, { type: 'capture-target' }, target)).toBe(true);
|
||||
});
|
||||
|
||||
it('пустой target → false', () => {
|
||||
expect(goalReached(start, { type: 'capture-target' }, [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('goals: liberties', () => {
|
||||
// Чёрный (2,2) прижат белыми (1,2),(3,2),(2,1): одно дамэ (2,3).
|
||||
const start = buildPosition(9, [p(2, 2)], [p(1, 2), p(3, 2), p(2, 1)], 'black');
|
||||
const target = [p(2, 2)];
|
||||
|
||||
it('группа с 1 дамэ < count=2 → false', () => {
|
||||
expect(goalReached(start, { type: 'liberties', count: 2 }, target)).toBe(false);
|
||||
});
|
||||
|
||||
it('после выхода (2,3) у группы 3 дамэ ≥ 2 → true', () => {
|
||||
const after = play(start, 'black', p(2, 3));
|
||||
expect(goalReached(after, { type: 'liberties', count: 2 }, target)).toBe(true);
|
||||
expect(goalReached(after, { type: 'liberties', count: 4 }, target)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('goals: connect', () => {
|
||||
// Два чёрных камня (4,2) и (4,4), соединяются ходом (4,3).
|
||||
const start = buildPosition(9, [p(4, 2), p(4, 4)], [], 'black');
|
||||
const target = [p(4, 2), p(4, 4)];
|
||||
|
||||
it('разные группы → false; после соединения → true', () => {
|
||||
expect(goalReached(start, { type: 'connect' }, target)).toBe(false);
|
||||
const after = play(start, 'black', p(4, 3));
|
||||
expect(goalReached(after, { type: 'connect' }, target)).toBe(true);
|
||||
});
|
||||
|
||||
it('камни разных цветов не могут быть одной группой → false', () => {
|
||||
const mixed = buildPosition(9, [p(4, 2)], [p(4, 4)], 'black');
|
||||
expect(goalReached(mixed, { type: 'connect' }, target)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('goals: two-eyes', () => {
|
||||
// Кольцо чёрных вокруг линии из 3 пустых пунктов (3,4),(4,4),(5,4):
|
||||
// один формальный глаз (область из 3 пунктов). Ход (4,4) делит область
|
||||
// на два глаза по 1 пункту.
|
||||
const ring = [p(2, 4), p(6, 4), p(3, 3), p(4, 3), p(5, 3), p(3, 5), p(4, 5), p(5, 5)];
|
||||
const start = buildPosition(9, ring, [], 'black');
|
||||
const target = [p(4, 3)];
|
||||
|
||||
it('один глаз из 3 пунктов → false', () => {
|
||||
expect(goalReached(start, { type: 'two-eyes' }, target)).toBe(false);
|
||||
});
|
||||
|
||||
it('после разделения области — два глаза → true', () => {
|
||||
const after = play(start, 'black', p(4, 4));
|
||||
expect(goalReached(after, { type: 'two-eyes' }, target)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('goals: формальный глаз v1 (границы определения)', () => {
|
||||
it('область из 4 пустых пунктов — не глаз', () => {
|
||||
// Кольцо вокруг линии из 4 пунктов (3,4)..(6,4): формальных глаз нет.
|
||||
const ring4 = [
|
||||
p(2, 4),
|
||||
p(7, 4),
|
||||
p(3, 3),
|
||||
p(4, 3),
|
||||
p(5, 3),
|
||||
p(6, 3),
|
||||
p(3, 5),
|
||||
p(4, 5),
|
||||
p(5, 5),
|
||||
p(6, 5),
|
||||
];
|
||||
const state = buildPosition(9, ring4, [], 'black');
|
||||
expect(goalReached(state, { type: 'two-eyes' }, [p(4, 3)])).toBe(false);
|
||||
});
|
||||
|
||||
it('глаз у края доски засчитывается (край = «свой» сосед)', () => {
|
||||
// Группа чёрных у левого края с двумя глазами: (0,0) и (0,2).
|
||||
const black = [p(1, 0), p(1, 1), p(0, 1), p(1, 2), p(1, 3), p(0, 3)];
|
||||
const state = buildPosition(9, black, [], 'white');
|
||||
expect(goalReached(state, { type: 'two-eyes' }, [p(1, 1)])).toBe(true);
|
||||
});
|
||||
|
||||
it('область, смежная с двумя цветами, — не глаз', () => {
|
||||
// Пустой пункт (0,0) между чёрным (1,0) и белым (0,1) — не глаз.
|
||||
const state = buildPosition(9, [p(1, 0)], [p(0, 1)], 'black');
|
||||
expect(goalReached(state, { type: 'two-eyes' }, [p(1, 0)])).toBe(false);
|
||||
});
|
||||
});
|
||||
117
apps/web/src/lib/goals.ts
Normal file
117
apps/web/src/lib/goals.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* Предикаты целей задач (уроки и цумэ-го, этап 6). Контракт —
|
||||
* docs/INTERFACES.md, «Форматы данных уроков и задач». Чистые функции над
|
||||
* экспортами @go-learn/core: без DOM, без состояния. Критерий успеха задачи —
|
||||
* ТОЛЬКО эти предикаты, вычисленные движком на текущей позиции (I.6).
|
||||
*/
|
||||
import { cellAt, groupAt, pointKey } from '@go-learn/core';
|
||||
import type { BoardState, Color, Group, Point } from '@go-learn/core';
|
||||
|
||||
/** Спецификация цели задачи (TaskV1.goal). Разметка цели в SGF:
|
||||
* TR — capture-target / liberties / two-eyes, CR — connect (ADR-0003). */
|
||||
export type GoalSpec =
|
||||
| { readonly type: 'capture-target' } // камни с TR сняты с доски
|
||||
| { readonly type: 'liberties'; readonly count: number } // группа цели (TR) ≥ count дамэ
|
||||
| { readonly type: 'connect' } // все CR-камни оказались в одной группе
|
||||
| { readonly type: 'two-eyes' }; // у группы цели (TR) ≥2 формальных глаза
|
||||
|
||||
/** Ортогональные соседи в пределах доски (в core не экспортируется neighbors). */
|
||||
function neighborsOf(state: BoardState, point: Point): Point[] {
|
||||
const candidates: Point[] = [
|
||||
{ x: point.x - 1, y: point.y },
|
||||
{ x: point.x + 1, y: point.y },
|
||||
{ x: point.x, y: point.y - 1 },
|
||||
{ x: point.x, y: point.y + 1 },
|
||||
];
|
||||
return candidates.filter((p) => p.x >= 0 && p.x < state.size && p.y >= 0 && p.y < state.size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Формальный глаз (упрощение v1): пустая область из 1–3 смежных пустых
|
||||
* пунктов, у которой все ортогональные соседи за её пределами — камни одного
|
||||
* цвета (или край доски). Ложный глаз в v1 не отличаем — зафиксированное
|
||||
* упрощение контракта. Возвращает цвет глаза или null.
|
||||
*/
|
||||
function formalEyeColor(state: BoardState, region: ReadonlyArray<Point>): Color | null {
|
||||
if (region.length === 0 || region.length > 3) return null;
|
||||
const inside = new Set(region.map(pointKey));
|
||||
let color: Color | null = null;
|
||||
for (const point of region) {
|
||||
for (const next of neighborsOf(state, point)) {
|
||||
if (inside.has(pointKey(next))) continue;
|
||||
const cell = cellAt(state, next);
|
||||
if (cell === 'empty') return null;
|
||||
if (color === null) color = cell;
|
||||
else if (cell !== color) return null;
|
||||
}
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
/** Число формальных глаз группы: пустые области, смежные с группой. */
|
||||
function countFormalEyes(state: BoardState, group: Group): number {
|
||||
const first = group.stones[0];
|
||||
if (first === undefined) return 0;
|
||||
const color = cellAt(state, first);
|
||||
if (color === 'empty') return 0;
|
||||
const visited = new Set<string>();
|
||||
let eyes = 0;
|
||||
for (const stone of group.stones) {
|
||||
for (const seed of neighborsOf(state, stone)) {
|
||||
if (cellAt(state, seed) !== 'empty' || visited.has(pointKey(seed))) continue;
|
||||
// Flood fill пустой области от seed.
|
||||
const region: Point[] = [];
|
||||
const stack: Point[] = [seed];
|
||||
visited.add(pointKey(seed));
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop() as Point;
|
||||
region.push(current);
|
||||
for (const next of neighborsOf(state, current)) {
|
||||
const key = pointKey(next);
|
||||
if (cellAt(state, next) === 'empty' && !visited.has(key)) {
|
||||
visited.add(key);
|
||||
stack.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (formalEyeColor(state, region) === color) eyes += 1;
|
||||
}
|
||||
}
|
||||
return eyes;
|
||||
}
|
||||
|
||||
/** Группы камней цели, оставшиеся на доске (дедупликация по камням группы). */
|
||||
function targetGroups(state: BoardState, target: readonly Point[]): Group[] {
|
||||
const seen = new Set<string>();
|
||||
const groups: Group[] = [];
|
||||
for (const point of target) {
|
||||
if (seen.has(pointKey(point))) continue;
|
||||
const group = groupAt(state, point);
|
||||
if (group === null) continue;
|
||||
for (const stone of group.stones) seen.add(pointKey(stone));
|
||||
groups.push(group);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполнена ли цель на текущей позиции. target — разметка из корня SGF
|
||||
* (TR или CR в зависимости от типа цели). Пустой target → false.
|
||||
*/
|
||||
export function goalReached(state: BoardState, goal: GoalSpec, target: readonly Point[]): boolean {
|
||||
if (target.length === 0) return false;
|
||||
switch (goal.type) {
|
||||
case 'capture-target':
|
||||
return target.every((point) => cellAt(state, point) === 'empty');
|
||||
case 'liberties':
|
||||
return targetGroups(state, target).some((group) => group.liberties >= goal.count);
|
||||
case 'connect': {
|
||||
const groups = targetGroups(state, target);
|
||||
if (groups.length !== 1) return false;
|
||||
const stones = new Set(groups[0]?.stones.map(pointKey) ?? []);
|
||||
return target.every((point) => stones.has(pointKey(point)));
|
||||
}
|
||||
case 'two-eyes':
|
||||
return targetGroups(state, target).some((group) => countFormalEyes(state, group) >= 2);
|
||||
}
|
||||
}
|
||||
95
apps/web/src/lib/keyboard.test.ts
Normal file
95
apps/web/src/lib/keyboard.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* Тесты lib/keyboard.ts: кламп курсора по краям, старт с центра, маппинг
|
||||
* клавиш, слияние курсор-маркера с разметкой. Чистые функции, без DOM.
|
||||
*/
|
||||
import type { NodeMarkup } from '@go-learn/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { addCursor, arrowOf, centerCursor, clampCursor, reduceCursor } from './keyboard.js';
|
||||
|
||||
describe('keyboard: reduceCursor', () => {
|
||||
it('двигает курсор на одно пересечение в каждую сторону', () => {
|
||||
const start = { x: 4, y: 4 };
|
||||
expect(reduceCursor(start, 'up', 9)).toEqual({ x: 4, y: 3 });
|
||||
expect(reduceCursor(start, 'down', 9)).toEqual({ x: 4, y: 5 });
|
||||
expect(reduceCursor(start, 'left', 9)).toEqual({ x: 3, y: 4 });
|
||||
expect(reduceCursor(start, 'right', 9)).toEqual({ x: 5, y: 4 });
|
||||
});
|
||||
|
||||
it('клампит по всем краям доски', () => {
|
||||
expect(reduceCursor({ x: 0, y: 0 }, 'up', 9)).toEqual({ x: 0, y: 0 });
|
||||
expect(reduceCursor({ x: 0, y: 0 }, 'left', 9)).toEqual({ x: 0, y: 0 });
|
||||
expect(reduceCursor({ x: 8, y: 8 }, 'down', 9)).toEqual({ x: 8, y: 8 });
|
||||
expect(reduceCursor({ x: 8, y: 8 }, 'right', 9)).toEqual({ x: 8, y: 8 });
|
||||
});
|
||||
|
||||
it('кламп по размеру доски (13 и 19)', () => {
|
||||
expect(reduceCursor({ x: 12, y: 0 }, 'right', 13)).toEqual({ x: 12, y: 0 });
|
||||
expect(reduceCursor({ x: 0, y: 18 }, 'down', 19)).toEqual({ x: 0, y: 18 });
|
||||
});
|
||||
|
||||
it('не мутирует исходную точку', () => {
|
||||
const start = { x: 4, y: 4 };
|
||||
reduceCursor(start, 'up', 9);
|
||||
expect(start).toEqual({ x: 4, y: 4 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyboard: centerCursor и clampCursor', () => {
|
||||
it('старт — центр доски', () => {
|
||||
expect(centerCursor(9)).toEqual({ x: 4, y: 4 });
|
||||
expect(centerCursor(13)).toEqual({ x: 6, y: 6 });
|
||||
expect(centerCursor(19)).toEqual({ x: 9, y: 9 });
|
||||
});
|
||||
|
||||
it('курсор за пределами новой доски прижимается к краю', () => {
|
||||
expect(clampCursor({ x: 9, y: 18 }, 9)).toEqual({ x: 8, y: 8 });
|
||||
expect(clampCursor({ x: 4, y: 4 }, 9)).toEqual({ x: 4, y: 4 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyboard: arrowOf', () => {
|
||||
it('стрелки маппятся в направления', () => {
|
||||
expect(arrowOf('ArrowUp')).toBe('up');
|
||||
expect(arrowOf('ArrowDown')).toBe('down');
|
||||
expect(arrowOf('ArrowLeft')).toBe('left');
|
||||
expect(arrowOf('ArrowRight')).toBe('right');
|
||||
});
|
||||
|
||||
it('прочие клавиши → null', () => {
|
||||
expect(arrowOf('Enter')).toBeNull();
|
||||
expect(arrowOf(' ')).toBeNull();
|
||||
expect(arrowOf('Escape')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyboard: addCursor', () => {
|
||||
const base: NodeMarkup = {
|
||||
labels: [],
|
||||
triangles: [{ x: 1, y: 1 }],
|
||||
squares: [],
|
||||
circles: [{ x: 2, y: 2 }],
|
||||
};
|
||||
|
||||
it('cursor === null → разметка без изменений (та же ссылка)', () => {
|
||||
expect(addCursor(base, null)).toBe(base);
|
||||
expect(addCursor(undefined, null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('курсор добавляется квадратом, прочая разметка сохраняется', () => {
|
||||
const merged = addCursor(base, { x: 4, y: 4 });
|
||||
expect(merged?.squares).toEqual([{ x: 4, y: 4 }]);
|
||||
expect(merged?.triangles).toEqual(base.triangles);
|
||||
expect(merged?.circles).toEqual(base.circles);
|
||||
});
|
||||
|
||||
it('без базовой разметки — курсор на пустой разметке', () => {
|
||||
const merged = addCursor(undefined, { x: 0, y: 0 });
|
||||
expect(merged?.squares).toEqual([{ x: 0, y: 0 }]);
|
||||
expect(merged?.labels).toEqual([]);
|
||||
});
|
||||
|
||||
it('не мутирует базовую разметку', () => {
|
||||
addCursor(base, { x: 4, y: 4 });
|
||||
expect(base.squares).toEqual([]);
|
||||
});
|
||||
});
|
||||
71
apps/web/src/lib/keyboard.ts
Normal file
71
apps/web/src/lib/keyboard.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Клавиатурный ввод доски (этап 7, контракт INTERFACES.md «Полировка»):
|
||||
* чистый редьюсер курсора-крестика, без DOM. Курсор стартует с центра доски,
|
||||
* стрелки двигают его с клампом по краям; подтверждение хода идёт через
|
||||
* существующий reduceInput (tap по курсору → фантом → повтор → commit) —
|
||||
* это делают тонкие DOM-подписки в ui/play-page.ts и ui/task-widget.ts.
|
||||
*/
|
||||
import type { BoardSize, NodeMarkup, Point } from '@go-learn/core';
|
||||
|
||||
export type CursorKey = 'up' | 'down' | 'left' | 'right';
|
||||
|
||||
/** KeyboardEvent.key → направление стрелки; null — клавиша не стрелка. */
|
||||
export function arrowOf(key: string): CursorKey | null {
|
||||
switch (key) {
|
||||
case 'ArrowUp':
|
||||
return 'up';
|
||||
case 'ArrowDown':
|
||||
return 'down';
|
||||
case 'ArrowLeft':
|
||||
return 'left';
|
||||
case 'ArrowRight':
|
||||
return 'right';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Стартовая позиция курсора — центр доски. */
|
||||
export function centerCursor(size: BoardSize): Point {
|
||||
const center = Math.floor(size / 2);
|
||||
return { x: center, y: center };
|
||||
}
|
||||
|
||||
const DELTAS: Readonly<Record<CursorKey, Point>> = {
|
||||
up: { x: 0, y: -1 },
|
||||
down: { x: 0, y: 1 },
|
||||
left: { x: -1, y: 0 },
|
||||
right: { x: 1, y: 0 },
|
||||
};
|
||||
|
||||
/** Сдвиг курсора на одно пересечение с клампом по краям доски. */
|
||||
export function reduceCursor(cursor: Point, key: CursorKey, size: BoardSize): Point {
|
||||
const delta = DELTAS[key];
|
||||
return {
|
||||
x: Math.min(size - 1, Math.max(0, cursor.x + delta.x)),
|
||||
y: Math.min(size - 1, Math.max(0, cursor.y + delta.y)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Прижатие курсора к доске (нужно после смены размера доски). */
|
||||
export function clampCursor(cursor: Point, size: BoardSize): Point {
|
||||
return {
|
||||
x: Math.min(size - 1, Math.max(0, cursor.x)),
|
||||
y: Math.min(size - 1, Math.max(0, cursor.y)),
|
||||
};
|
||||
}
|
||||
|
||||
const EMPTY_MARKUP: NodeMarkup = { labels: [], triangles: [], squares: [], circles: [] };
|
||||
|
||||
/**
|
||||
* Курсор — квадратный маркер поверх существующей разметки (подсказки, цели
|
||||
* задачи, разметка SGF). cursor === null → разметка без изменений.
|
||||
*/
|
||||
export function addCursor(
|
||||
base: NodeMarkup | undefined,
|
||||
cursor: Point | null,
|
||||
): NodeMarkup | undefined {
|
||||
if (cursor === null) return base;
|
||||
const markup = base ?? EMPTY_MARKUP;
|
||||
return { ...markup, squares: [...markup.squares, cursor] };
|
||||
}
|
||||
337
apps/web/src/lib/storage.test.ts
Normal file
337
apps/web/src/lib/storage.test.ts
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
/**
|
||||
* Тесты lib/storage.ts: roundtrip, порча JSON/checksum/version → дефолты +
|
||||
* recovered, стабильность checksumOf. Хранилище — in-memory заглушка
|
||||
* (Node 20 без localStorage), инжектируется вторым аргументом.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Clock, ProgressV1, ProgressV2, StorageEnvelope, StorageLike } from './storage.js';
|
||||
import {
|
||||
checksumOf,
|
||||
DEFAULT_PROGRESS,
|
||||
DEFAULT_SETTINGS,
|
||||
isProgressPayload,
|
||||
loadProgress,
|
||||
loadSettings,
|
||||
markLessonDone,
|
||||
PROGRESS_KEY,
|
||||
PROGRESS_VERSION,
|
||||
saveProgress,
|
||||
saveSettings,
|
||||
SETTINGS_KEY,
|
||||
SETTINGS_VERSION,
|
||||
touchStreak,
|
||||
writeProgressSnapshot,
|
||||
} from './storage.js';
|
||||
|
||||
function memoryStorage(initial: Record<string, string> = {}): StorageLike & {
|
||||
data: Record<string, string>;
|
||||
} {
|
||||
const data: Record<string, string> = { ...initial };
|
||||
return {
|
||||
data,
|
||||
getItem: (key) => (key in data ? (data[key] ?? null) : null),
|
||||
setItem: (key, value): void => {
|
||||
data[key] = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('storage: roundtrip и дефолты', () => {
|
||||
it('нет ключа → дефолты без recovered', () => {
|
||||
const { settings, recovered } = loadSettings(memoryStorage());
|
||||
expect(settings).toEqual(DEFAULT_SETTINGS);
|
||||
expect(recovered).toBe(false);
|
||||
});
|
||||
|
||||
it('save → load даёт идентичные настройки', () => {
|
||||
const storage = memoryStorage();
|
||||
const custom = {
|
||||
...DEFAULT_SETTINGS,
|
||||
size: 19 as const,
|
||||
level: 5 as const,
|
||||
komi: 7.5,
|
||||
hintsEnabled: true,
|
||||
};
|
||||
saveSettings(custom, storage);
|
||||
const { settings, recovered } = loadSettings(storage);
|
||||
expect(settings).toEqual(custom);
|
||||
expect(recovered).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storage: порча данных', () => {
|
||||
it('битый JSON → дефолты + recovered', () => {
|
||||
const storage = memoryStorage({ [SETTINGS_KEY]: '{не json' });
|
||||
const { settings, recovered } = loadSettings(storage);
|
||||
expect(settings).toEqual(DEFAULT_SETTINGS);
|
||||
expect(recovered).toBe(true);
|
||||
});
|
||||
|
||||
it('несовпадение checksum → дефолты + recovered', () => {
|
||||
const good = JSON.stringify(DEFAULT_SETTINGS);
|
||||
const envelope: StorageEnvelope<unknown> = {
|
||||
version: SETTINGS_VERSION,
|
||||
checksum: checksumOf(good),
|
||||
payload: { ...DEFAULT_SETTINGS, level: 6 },
|
||||
};
|
||||
const storage = memoryStorage({ [SETTINGS_KEY]: JSON.stringify(envelope) });
|
||||
const { settings, recovered } = loadSettings(storage);
|
||||
expect(settings).toEqual(DEFAULT_SETTINGS);
|
||||
expect(recovered).toBe(true);
|
||||
});
|
||||
|
||||
it('неизвестная version → дефолты + recovered', () => {
|
||||
const envelope: StorageEnvelope<unknown> = {
|
||||
version: 99,
|
||||
checksum: checksumOf(JSON.stringify(DEFAULT_SETTINGS)),
|
||||
payload: DEFAULT_SETTINGS,
|
||||
};
|
||||
const storage = memoryStorage({ [SETTINGS_KEY]: JSON.stringify(envelope) });
|
||||
const { settings, recovered } = loadSettings(storage);
|
||||
expect(settings).toEqual(DEFAULT_SETTINGS);
|
||||
expect(recovered).toBe(true);
|
||||
});
|
||||
|
||||
it('payload неверной схемы → дефолты + recovered', () => {
|
||||
const payload = { ...DEFAULT_SETTINGS, level: 42 };
|
||||
const envelope: StorageEnvelope<unknown> = {
|
||||
version: SETTINGS_VERSION,
|
||||
checksum: checksumOf(JSON.stringify(payload)),
|
||||
payload,
|
||||
};
|
||||
const storage = memoryStorage({ [SETTINGS_KEY]: JSON.stringify(envelope) });
|
||||
const { settings, recovered } = loadSettings(storage);
|
||||
expect(settings).toEqual(DEFAULT_SETTINGS);
|
||||
expect(recovered).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checksumOf', () => {
|
||||
it('стабилен для одинакового входа и различен для разного', () => {
|
||||
const json = JSON.stringify(DEFAULT_SETTINGS);
|
||||
expect(checksumOf(json)).toBe(checksumOf(json));
|
||||
expect(checksumOf(json)).not.toBe(checksumOf(json + ' '));
|
||||
expect(checksumOf(json)).toMatch(/^[0-9a-f]+$/);
|
||||
});
|
||||
});
|
||||
|
||||
/* ── Прогресс курса (этапы 6 и 8) ── */
|
||||
|
||||
/** Фиксированные часы для детерминированных тестов (этап 8, Clock-инжекция). */
|
||||
const FIXED_ISO = '2026-08-06T12:00:00.000Z';
|
||||
const fixedClock: Clock = () => FIXED_ISO;
|
||||
|
||||
const SAMPLE_PROGRESS_V1: ProgressV1 = {
|
||||
lessons: { 'dame-intro': { done: true, tasksSolved: ['lsn-dame-1', 'lsn-dame-2'] } },
|
||||
tasks: {
|
||||
'lsn-dame-1': { solved: true, attempts: 2 },
|
||||
'tsm-atari-1': { solved: false, attempts: 3 },
|
||||
},
|
||||
streak: { lastDay: '2026-08-05', current: 4 },
|
||||
};
|
||||
|
||||
const SAMPLE_PROGRESS: ProgressV2 = { ...SAMPLE_PROGRESS_V1, updatedAt: FIXED_ISO };
|
||||
|
||||
describe('storage: прогресс, roundtrip и дефолты', () => {
|
||||
it('нет ключа → дефолты без recovered', () => {
|
||||
const { progress, recovered } = loadProgress(memoryStorage());
|
||||
expect(progress).toEqual(DEFAULT_PROGRESS);
|
||||
expect(recovered).toBe(false);
|
||||
});
|
||||
|
||||
it('roundtrip V2: save (фикс-часы) → load даёт идентичный прогресс', () => {
|
||||
const storage = memoryStorage();
|
||||
saveProgress(SAMPLE_PROGRESS_V1, storage, fixedClock);
|
||||
const { progress, recovered } = loadProgress(storage, fixedClock);
|
||||
expect(progress).toEqual(SAMPLE_PROGRESS);
|
||||
expect(recovered).toBe(false);
|
||||
});
|
||||
|
||||
it('saveProgress перевыставляет updatedAt при каждой записи (Clock-инжекция)', () => {
|
||||
const storage = memoryStorage();
|
||||
saveProgress(SAMPLE_PROGRESS, storage, () => '2026-08-07T08:30:00.000Z');
|
||||
const { progress } = loadProgress(storage, fixedClock);
|
||||
expect(progress.updatedAt).toBe('2026-08-07T08:30:00.000Z');
|
||||
});
|
||||
|
||||
it('writeProgressSnapshot НЕ сдвигает updatedAt (снимок sync-клиента)', () => {
|
||||
const storage = memoryStorage();
|
||||
writeProgressSnapshot(SAMPLE_PROGRESS, storage);
|
||||
const { progress } = loadProgress(storage, fixedClock);
|
||||
expect(progress).toEqual(SAMPLE_PROGRESS);
|
||||
});
|
||||
|
||||
it('миграция V1→V2: updatedAt = момент миграции, поля сохранены', () => {
|
||||
const envelope: StorageEnvelope<unknown> = {
|
||||
version: PROGRESS_VERSION,
|
||||
checksum: checksumOf(JSON.stringify(SAMPLE_PROGRESS_V1)),
|
||||
payload: SAMPLE_PROGRESS_V1,
|
||||
};
|
||||
const storage = memoryStorage({ [PROGRESS_KEY]: JSON.stringify(envelope) });
|
||||
const { progress, recovered } = loadProgress(storage, fixedClock);
|
||||
expect(progress).toEqual({ ...SAMPLE_PROGRESS_V1, updatedAt: FIXED_ISO });
|
||||
expect(recovered).toBe(false);
|
||||
// Roundtrip мигрированного прогресса: запись → чтение без изменений.
|
||||
saveProgress(progress, storage, fixedClock);
|
||||
expect(loadProgress(storage, fixedClock).progress).toEqual(progress);
|
||||
});
|
||||
|
||||
it('испорченный updatedAt (не строка) → дефолты + recovered', () => {
|
||||
const payload = { ...SAMPLE_PROGRESS_V1, updatedAt: 42 };
|
||||
const envelope: StorageEnvelope<unknown> = {
|
||||
version: PROGRESS_VERSION,
|
||||
checksum: checksumOf(JSON.stringify(payload)),
|
||||
payload,
|
||||
};
|
||||
const storage = memoryStorage({ [PROGRESS_KEY]: JSON.stringify(envelope) });
|
||||
const { progress, recovered } = loadProgress(storage, fixedClock);
|
||||
expect(progress).toEqual(DEFAULT_PROGRESS);
|
||||
expect(recovered).toBe(true);
|
||||
});
|
||||
|
||||
it('битый JSON → дефолты + recovered', () => {
|
||||
const storage = memoryStorage({ [PROGRESS_KEY]: '{не json' });
|
||||
const { progress, recovered } = loadProgress(storage);
|
||||
expect(progress).toEqual(DEFAULT_PROGRESS);
|
||||
expect(recovered).toBe(true);
|
||||
});
|
||||
|
||||
it('несовпадение checksum → дефолты + recovered', () => {
|
||||
const envelope: StorageEnvelope<unknown> = {
|
||||
version: PROGRESS_VERSION,
|
||||
checksum: 'deadbeef',
|
||||
payload: SAMPLE_PROGRESS,
|
||||
};
|
||||
const storage = memoryStorage({ [PROGRESS_KEY]: JSON.stringify(envelope) });
|
||||
const { progress, recovered } = loadProgress(storage);
|
||||
expect(progress).toEqual(DEFAULT_PROGRESS);
|
||||
expect(recovered).toBe(true);
|
||||
});
|
||||
|
||||
it('неизвестная version → дефолты + recovered', () => {
|
||||
const envelope: StorageEnvelope<unknown> = {
|
||||
version: 99,
|
||||
checksum: checksumOf(JSON.stringify(SAMPLE_PROGRESS)),
|
||||
payload: SAMPLE_PROGRESS,
|
||||
};
|
||||
const storage = memoryStorage({ [PROGRESS_KEY]: JSON.stringify(envelope) });
|
||||
const { progress, recovered } = loadProgress(storage);
|
||||
expect(progress).toEqual(DEFAULT_PROGRESS);
|
||||
expect(recovered).toBe(true);
|
||||
});
|
||||
|
||||
it('payload неверной схемы → дефолты + recovered', () => {
|
||||
const payload = { ...SAMPLE_PROGRESS, streak: { lastDay: 42 } };
|
||||
const envelope: StorageEnvelope<unknown> = {
|
||||
version: PROGRESS_VERSION,
|
||||
checksum: checksumOf(JSON.stringify(payload)),
|
||||
payload,
|
||||
};
|
||||
const storage = memoryStorage({ [PROGRESS_KEY]: JSON.stringify(envelope) });
|
||||
const { progress, recovered } = loadProgress(storage);
|
||||
expect(progress).toEqual(DEFAULT_PROGRESS);
|
||||
expect(recovered).toBe(true);
|
||||
});
|
||||
|
||||
it('ключи настроек и прогресса не пересекаются', () => {
|
||||
const storage = memoryStorage();
|
||||
saveSettings(DEFAULT_SETTINGS, storage);
|
||||
saveProgress(SAMPLE_PROGRESS, storage, fixedClock);
|
||||
expect(loadSettings(storage).settings).toEqual(DEFAULT_SETTINGS);
|
||||
expect(loadProgress(storage, fixedClock).progress).toEqual(SAMPLE_PROGRESS);
|
||||
});
|
||||
|
||||
it('isProgressPayload: валидный прогресс проходит, мусор — нет', () => {
|
||||
expect(isProgressPayload(SAMPLE_PROGRESS_V1)).toBe(true);
|
||||
expect(isProgressPayload(SAMPLE_PROGRESS)).toBe(true);
|
||||
expect(isProgressPayload(null)).toBe(false);
|
||||
expect(isProgressPayload({ lessons: {} })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storage: touchStreak', () => {
|
||||
const base: ProgressV1 = {
|
||||
...DEFAULT_PROGRESS,
|
||||
streak: { lastDay: '2026-08-04', current: 3 },
|
||||
};
|
||||
|
||||
it('вчера → current+1 и lastDay = today', () => {
|
||||
const next = touchStreak(base, '2026-08-05');
|
||||
expect(next.streak).toEqual({ lastDay: '2026-08-05', current: 4 });
|
||||
});
|
||||
|
||||
it('сегодня → без изменений (тот же объект)', () => {
|
||||
expect(touchStreak(base, '2026-08-04')).toBe(base);
|
||||
});
|
||||
|
||||
it('разрыв (позавчера и раньше) → 1', () => {
|
||||
const next = touchStreak(base, '2026-08-07');
|
||||
expect(next.streak).toEqual({ lastDay: '2026-08-07', current: 1 });
|
||||
});
|
||||
|
||||
it('пустой lastDay (первый день) → 1', () => {
|
||||
const next = touchStreak(DEFAULT_PROGRESS, '2026-08-05');
|
||||
expect(next.streak).toEqual({ lastDay: '2026-08-05', current: 1 });
|
||||
});
|
||||
|
||||
it('переход через границу месяца считается корректно', () => {
|
||||
const prev: ProgressV1 = {
|
||||
...DEFAULT_PROGRESS,
|
||||
streak: { lastDay: '2026-07-31', current: 9 },
|
||||
};
|
||||
expect(touchStreak(prev, '2026-08-01').streak).toEqual({
|
||||
lastDay: '2026-08-01',
|
||||
current: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('битая today → серия сбрасывается в 1', () => {
|
||||
const next = touchStreak(base, 'не дата');
|
||||
expect(next.streak).toEqual({ lastDay: 'не дата', current: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('storage: markLessonDone', () => {
|
||||
const withTasks: ProgressV1 = {
|
||||
...DEFAULT_PROGRESS,
|
||||
lessons: { '01-dame-i-zahvat': { done: false, tasksSolved: ['lsn-01-a'] } },
|
||||
};
|
||||
|
||||
it('новый урок → done=true, пустой tasksSolved, streak тронут', () => {
|
||||
const next = markLessonDone(DEFAULT_PROGRESS, '07-ugly-storony-tsentr', '2026-08-06');
|
||||
expect(next.lessons['07-ugly-storony-tsentr']).toEqual({ done: true, tasksSolved: [] });
|
||||
expect(next.streak).toEqual({ lastDay: '2026-08-06', current: 1 });
|
||||
});
|
||||
|
||||
it('tasksSolved авто-прогресса (этап 6) сохраняются', () => {
|
||||
const next = markLessonDone(withTasks, '01-dame-i-zahvat', '2026-08-06');
|
||||
expect(next.lessons['01-dame-i-zahvat']).toEqual({
|
||||
done: true,
|
||||
tasksSolved: ['lsn-01-a'],
|
||||
});
|
||||
});
|
||||
|
||||
it('повторная отметка в тот же день не меняет запись урока', () => {
|
||||
const done: ProgressV1 = {
|
||||
...DEFAULT_PROGRESS,
|
||||
lessons: { '01-dame-i-zahvat': { done: true, tasksSolved: ['lsn-01-a'] } },
|
||||
streak: { lastDay: '2026-08-05', current: 2 },
|
||||
};
|
||||
const same = markLessonDone(done, '01-dame-i-zahvat', '2026-08-05');
|
||||
expect(same.lessons['01-dame-i-zahvat']).toEqual(done.lessons['01-dame-i-zahvat']);
|
||||
expect(same.streak).toEqual({ lastDay: '2026-08-05', current: 2 });
|
||||
const nextDay = markLessonDone(done, '01-dame-i-zahvat', '2026-08-06');
|
||||
expect(nextDay.streak).toEqual({ lastDay: '2026-08-06', current: 3 });
|
||||
});
|
||||
|
||||
it('прогресс других уроков и задач не затрагивается', () => {
|
||||
const base: ProgressV1 = {
|
||||
lessons: { '01-dame-i-zahvat': { done: true, tasksSolved: [] } },
|
||||
tasks: { 'lsn-01-a': { solved: true, attempts: 1 } },
|
||||
streak: { lastDay: '', current: 0 },
|
||||
};
|
||||
const next = markLessonDone(base, '02-atari-lestnitsa-set', '2026-08-06');
|
||||
expect(next.lessons['01-dame-i-zahvat']).toEqual({ done: true, tasksSolved: [] });
|
||||
expect(next.tasks).toEqual(base.tasks);
|
||||
});
|
||||
});
|
||||
342
apps/web/src/lib/storage.ts
Normal file
342
apps/web/src/lib/storage.ts
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
/**
|
||||
* Хранение настроек в localStorage (контракт — docs/INTERFACES.md, «Схема
|
||||
* localStorage»). Конверт {version, checksum, payload}; checksum — FNV-1a hex
|
||||
* от JSON.stringify(payload). Битое состояние не роняет приложение: чтение
|
||||
* возвращает дефолты с пометкой recovered. Запись — только через
|
||||
* saveSettings. DOM не используется напрямую: хранилище инжектируется
|
||||
* (в браузере — глобальный localStorage, в тестах — заглушка).
|
||||
*/
|
||||
import type { BoardSize } from '@go-learn/core';
|
||||
|
||||
export interface StorageEnvelope<T> {
|
||||
readonly version: number;
|
||||
readonly checksum: string;
|
||||
readonly payload: T;
|
||||
}
|
||||
|
||||
export interface SettingsV1 {
|
||||
readonly size: BoardSize;
|
||||
readonly level: 1 | 2 | 3 | 4 | 5 | 6;
|
||||
readonly playerColor: 'black' | 'white' | 'random';
|
||||
readonly komi: number | 'auto';
|
||||
readonly showCoordinates: boolean;
|
||||
readonly showMoveNumbers: boolean;
|
||||
readonly hintsEnabled: boolean;
|
||||
}
|
||||
|
||||
/** Минимальный интерфейс хранилища (совместим с DOM Storage). */
|
||||
export interface StorageLike {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
}
|
||||
|
||||
export const SETTINGS_KEY = 'go-learn:settings';
|
||||
export const SETTINGS_VERSION = 1;
|
||||
|
||||
export const DEFAULT_SETTINGS: SettingsV1 = {
|
||||
size: 9,
|
||||
level: 2,
|
||||
playerColor: 'black',
|
||||
komi: 'auto',
|
||||
showCoordinates: true,
|
||||
showMoveNumbers: false,
|
||||
hintsEnabled: false,
|
||||
};
|
||||
|
||||
/** FNV-1a (32-бит), hex без ведущих нулей — стабилен для одинакового json. */
|
||||
export function checksumOf(json: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < json.length; index += 1) {
|
||||
hash ^= json.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(16);
|
||||
}
|
||||
|
||||
/** Хранилище по умолчанию — глобальный localStorage; null, если недоступен. */
|
||||
function defaultStorage(): StorageLike | null {
|
||||
try {
|
||||
const candidate = (globalThis as { localStorage?: StorageLike }).localStorage;
|
||||
return candidate ?? null;
|
||||
} catch {
|
||||
return null; // доступ к localStorage может кидать (приватный режим и т.п.)
|
||||
}
|
||||
}
|
||||
|
||||
function isBoardSize(value: unknown): value is BoardSize {
|
||||
return value === 9 || value === 13 || value === 19;
|
||||
}
|
||||
|
||||
function isLevel(value: unknown): value is SettingsV1['level'] {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 6;
|
||||
}
|
||||
|
||||
/** Структурная валидация payload: неизвестные поля игнорируются. */
|
||||
function isSettingsV1(value: unknown): value is SettingsV1 {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
const colors = ['black', 'white', 'random'];
|
||||
return (
|
||||
isBoardSize(record['size']) &&
|
||||
isLevel(record['level']) &&
|
||||
colors.includes(record['playerColor'] as string) &&
|
||||
(record['komi'] === 'auto' || typeof record['komi'] === 'number') &&
|
||||
typeof record['showCoordinates'] === 'boolean' &&
|
||||
typeof record['showMoveNumbers'] === 'boolean' &&
|
||||
typeof record['hintsEnabled'] === 'boolean'
|
||||
);
|
||||
}
|
||||
|
||||
/** Разбор конверта; null — любая порча (JSON, checksum, version, схема). */
|
||||
function parseEnvelope<T>(
|
||||
raw: string,
|
||||
version: number,
|
||||
guard: (value: unknown) => value is T,
|
||||
): T | null {
|
||||
let envelope: StorageEnvelope<unknown>;
|
||||
try {
|
||||
envelope = JSON.parse(raw) as StorageEnvelope<unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof envelope !== 'object' || envelope === null) return null;
|
||||
if (envelope.version !== version) return null;
|
||||
if (checksumOf(JSON.stringify(envelope.payload)) !== envelope.checksum) return null;
|
||||
return guard(envelope.payload) ? envelope.payload : null;
|
||||
}
|
||||
|
||||
/** Общая запись конверта; без хранилища — тихий no-op. */
|
||||
function writeEnvelope<T>(key: string, version: number, payload: T, storage?: StorageLike): void {
|
||||
const store = storage ?? defaultStorage();
|
||||
if (store === null) return;
|
||||
const payloadJson = JSON.stringify(payload);
|
||||
const envelope: StorageEnvelope<T> = {
|
||||
version,
|
||||
checksum: checksumOf(payloadJson),
|
||||
payload,
|
||||
};
|
||||
store.setItem(key, JSON.stringify(envelope));
|
||||
}
|
||||
|
||||
/**
|
||||
* Чтение настроек: нет ключа → дефолты (recovered: false); битый JSON /
|
||||
* несовпадение checksum / неизвестная version → дефолты + recovered: true.
|
||||
*/
|
||||
export function loadSettings(storage?: StorageLike): { settings: SettingsV1; recovered: boolean } {
|
||||
const store = storage ?? defaultStorage();
|
||||
if (store === null) return { settings: DEFAULT_SETTINGS, recovered: false };
|
||||
const raw = store.getItem(SETTINGS_KEY);
|
||||
if (raw === null) return { settings: DEFAULT_SETTINGS, recovered: false };
|
||||
const parsed = parseEnvelope(raw, SETTINGS_VERSION, isSettingsV1);
|
||||
if (parsed === null) return { settings: DEFAULT_SETTINGS, recovered: true };
|
||||
return { settings: parsed, recovered: false };
|
||||
}
|
||||
|
||||
/** Запись настроек с пересчётом checksum; без хранилища — тихий no-op. */
|
||||
export function saveSettings(settings: SettingsV1, storage?: StorageLike): void {
|
||||
writeEnvelope(SETTINGS_KEY, SETTINGS_VERSION, settings, storage);
|
||||
}
|
||||
|
||||
/* ── Прогресс курса (этап 6, контракт «Прогресс курса» в INTERFACES.md) ── */
|
||||
|
||||
export interface LessonProgress {
|
||||
readonly done: boolean;
|
||||
readonly tasksSolved: readonly string[];
|
||||
}
|
||||
|
||||
export interface TaskProgress {
|
||||
readonly solved: boolean;
|
||||
readonly attempts: number;
|
||||
}
|
||||
|
||||
export interface StreakV1 {
|
||||
readonly lastDay: string; // 'YYYY-MM-DD' последней активности
|
||||
readonly current: number; // дней подряд
|
||||
}
|
||||
|
||||
export interface ProgressV1 {
|
||||
readonly lessons: Readonly<Record<string, LessonProgress>>; // key = lesson id
|
||||
readonly tasks: Readonly<Record<string, TaskProgress>>; // key = task id
|
||||
readonly streak: StreakV1;
|
||||
}
|
||||
|
||||
/* ── Прогресс V2 и синхронизация (этап 8, INTERFACES.md «Личный кабинет») ── */
|
||||
|
||||
/**
|
||||
* Источник метки времени (ISO). Инжектируется параметром: чистые функции
|
||||
* сами Date/Date.now не вызывают (контракт этапа 8).
|
||||
*/
|
||||
export type Clock = () => string;
|
||||
|
||||
/** Часы по умолчанию — текущее время в ISO (UTC-суффикс Z). */
|
||||
const defaultClock: Clock = () => new Date().toISOString();
|
||||
|
||||
/**
|
||||
* ProgressV2 = ProgressV1 + updatedAt (ISO локального изменения). Пустая
|
||||
* строка — «локальных изменений ещё не было» (дефолты без ключа): sync-client
|
||||
* трактует её как отсутствие локальной метки. Ключ и конверт — те же, что у
|
||||
* V1; миграция V1→V2 — при чтении (updatedAt = момент миграции), до дефолтов.
|
||||
*/
|
||||
export interface ProgressV2 extends ProgressV1 {
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
export const PROGRESS_KEY = 'go-learn:progress';
|
||||
export const PROGRESS_VERSION = 1;
|
||||
|
||||
export const DEFAULT_PROGRESS: ProgressV2 = {
|
||||
lessons: {},
|
||||
tasks: {},
|
||||
streak: { lastDay: '', current: 0 },
|
||||
updatedAt: '',
|
||||
};
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
||||
}
|
||||
|
||||
function isLessonProgress(value: unknown): value is LessonProgress {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
return typeof record['done'] === 'boolean' && isStringArray(record['tasksSolved']);
|
||||
}
|
||||
|
||||
function isTaskProgress(value: unknown): value is TaskProgress {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
return typeof record['solved'] === 'boolean' && typeof record['attempts'] === 'number';
|
||||
}
|
||||
|
||||
function isRecordOf<T>(value: unknown, guard: (item: unknown) => item is T): boolean {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
||||
return Object.values(value).every((item) => guard(item));
|
||||
}
|
||||
|
||||
/** Структурная валидация payload прогресса (V1-поля; updatedAt не требуется). */
|
||||
function isProgressV1(value: unknown): value is ProgressV1 {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
const streak = record['streak'];
|
||||
if (typeof streak !== 'object' || streak === null) return false;
|
||||
const streakRecord = streak as Record<string, unknown>;
|
||||
return (
|
||||
isRecordOf(record['lessons'], isLessonProgress) &&
|
||||
isRecordOf(record['tasks'], isTaskProgress) &&
|
||||
typeof streakRecord['lastDay'] === 'string' &&
|
||||
typeof streakRecord['current'] === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Публичная проверка формы прогресса — для sync-client (валидация payload,
|
||||
* пришедшего с сервера, до локальной записи). updatedAt не проверяется:
|
||||
* серверная метка подставляется отдельно.
|
||||
*/
|
||||
export function isProgressPayload(value: unknown): value is ProgressV1 {
|
||||
return isProgressV1(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Миграция V1→V2: payload без updatedAt получает updatedAt = clock()
|
||||
* (момент миграции); payload с updatedAt-строкой — уже V2. null — updatedAt
|
||||
* испорчен (не строка): дальше сработают дефолты с recovered.
|
||||
*/
|
||||
function migrateProgressV2(payload: ProgressV1, clock: Clock): ProgressV2 | null {
|
||||
const updatedAt = (payload as unknown as Record<string, unknown>)['updatedAt'];
|
||||
if (updatedAt === undefined) return { ...payload, updatedAt: clock() };
|
||||
if (typeof updatedAt === 'string') return payload as ProgressV2;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Чтение прогресса: нет ключа → дефолты (recovered: false); payload V1 →
|
||||
* миграция в V2 (до дефолтов, контракт конверта); битый JSON / несовпадение
|
||||
* checksum / неизвестная version / испорченный updatedAt → дефолты +
|
||||
* recovered: true. Clock инжектируется для детерминированных тестов.
|
||||
*/
|
||||
export function loadProgress(
|
||||
storage?: StorageLike,
|
||||
clock: Clock = defaultClock,
|
||||
): {
|
||||
progress: ProgressV2;
|
||||
recovered: boolean;
|
||||
} {
|
||||
const store = storage ?? defaultStorage();
|
||||
if (store === null) return { progress: DEFAULT_PROGRESS, recovered: false };
|
||||
const raw = store.getItem(PROGRESS_KEY);
|
||||
if (raw === null) return { progress: DEFAULT_PROGRESS, recovered: false };
|
||||
const parsed = parseEnvelope(raw, PROGRESS_VERSION, isProgressV1);
|
||||
if (parsed === null) return { progress: DEFAULT_PROGRESS, recovered: true };
|
||||
const migrated = migrateProgressV2(parsed, clock);
|
||||
if (migrated === null) return { progress: DEFAULT_PROGRESS, recovered: true };
|
||||
return { progress: migrated, recovered: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Запись прогресса: updatedAt перевыставляется в clock() при КАЖДОЙ записи
|
||||
* (контракт этапа 8) — через saveProgress проходят все сохранители
|
||||
* (авто-прогресс задач, markLessonDone-страница урока). Чистые функции
|
||||
* (touchStreak, markLessonDone) updatedAt не трогают: штамп — здесь.
|
||||
* Без хранилища — тихий no-op.
|
||||
*/
|
||||
export function saveProgress(
|
||||
progress: ProgressV1,
|
||||
storage?: StorageLike,
|
||||
clock: Clock = defaultClock,
|
||||
): void {
|
||||
writeEnvelope(PROGRESS_KEY, PROGRESS_VERSION, { ...progress, updatedAt: clock() }, storage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Прямая запись снимка V2 БЕЗ сдвига updatedAt. Только для sync-client
|
||||
* (pull и подтверждение push): серверная метка сохраняется как есть, иначе
|
||||
* следующая сверка увидела бы «локальное изменение» и ушла бы в ложный push.
|
||||
* Обычные правки прогресса — через saveProgress.
|
||||
*/
|
||||
export function writeProgressSnapshot(progress: ProgressV2, storage?: StorageLike): void {
|
||||
writeEnvelope(PROGRESS_KEY, PROGRESS_VERSION, progress, storage);
|
||||
}
|
||||
|
||||
/** Вчерашняя дата относительно 'YYYY-MM-DD' (UTC-арифметика, без DST). */
|
||||
function previousDay(today: string): string | null {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(today);
|
||||
if (match === null) return null;
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
const time = Date.UTC(year, month - 1, day);
|
||||
const date = new Date(time);
|
||||
// Отсекаем невалидные даты вида 2026-02-31 (Date их «прокручивает»).
|
||||
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1) return null;
|
||||
const prev = new Date(time - 86_400_000);
|
||||
const yyyy = prev.getUTCFullYear();
|
||||
const mm = String(prev.getUTCMonth() + 1).padStart(2, '0');
|
||||
const dd = String(prev.getUTCDate()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Серия дней (чистая): lastDay == today → без изменений; lastDay == вчера →
|
||||
* current+1; иначе (разрыв, пустая или битая дата) → 1. today — 'YYYY-MM-DD'
|
||||
* локальной даты.
|
||||
*/
|
||||
export function touchStreak(progress: ProgressV1, today: string): ProgressV1 {
|
||||
const { streak } = progress;
|
||||
if (streak.lastDay === today) return progress;
|
||||
const current = previousDay(today) === streak.lastDay ? streak.current + 1 : 1;
|
||||
return { ...progress, streak: { lastDay: today, current } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ручная отметка «урок пройден» (этап 7, чистая): lessons[id].done = true,
|
||||
* tasksSolved сохраняются, серия дней — через touchStreak. today — 'YYYY-MM-DD'
|
||||
* локальной даты. Уже пройденный урок — без изменений записи урока.
|
||||
*/
|
||||
export function markLessonDone(progress: ProgressV1, lessonId: string, today: string): ProgressV1 {
|
||||
const lesson = progress.lessons[lessonId];
|
||||
const lessons = {
|
||||
...progress.lessons,
|
||||
[lessonId]: { done: true, tasksSolved: lesson?.tasksSolved ?? [] },
|
||||
};
|
||||
return touchStreak({ ...progress, lessons }, today);
|
||||
}
|
||||
420
apps/web/src/lib/sync-client.test.ts
Normal file
420
apps/web/src/lib/sync-client.test.ts
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
/**
|
||||
* Тесты lib/sync-client.ts (этап 8): mergeDecision — все ветки чистой
|
||||
* функции; syncProgress и auth-обёртки — fetch-моки (Node 20: глобальный
|
||||
* Response доступен). Хранилище — in-memory заглушка, как в storage.test.ts.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { ProgressV2, StorageLike } from './storage.js';
|
||||
import { loadProgress, saveProgress } from './storage.js';
|
||||
import type { FetchLike } from './sync-client.js';
|
||||
import {
|
||||
confirmPasswordReset,
|
||||
login,
|
||||
logout,
|
||||
me,
|
||||
mergeDecision,
|
||||
register,
|
||||
requestPasswordReset,
|
||||
syncProgress,
|
||||
} from './sync-client.js';
|
||||
|
||||
function memoryStorage(initial: Record<string, string> = {}): StorageLike & {
|
||||
data: Record<string, string>;
|
||||
} {
|
||||
const data: Record<string, string> = { ...initial };
|
||||
return {
|
||||
data,
|
||||
getItem: (key) => (key in data ? (data[key] ?? null) : null),
|
||||
setItem: (key, value): void => {
|
||||
data[key] = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** fetch-мок из таблицы «url+method → Response»; throw — сетевая ошибка. */
|
||||
function fetchMock(handler: (url: string, init?: RequestInit) => Response): FetchLike {
|
||||
return vi.fn((url: string, init?: RequestInit) => Promise.resolve(handler(url, init)));
|
||||
}
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const LOCAL_ISO = '2026-08-06T12:00:00.000Z';
|
||||
const REMOTE_NEWER_ISO = '2026-08-06T13:00:00.000Z';
|
||||
const REMOTE_OLDER_ISO = '2026-08-06T11:00:00.000Z';
|
||||
|
||||
const SAMPLE: ProgressV2 = {
|
||||
lessons: { '01-dame-i-zahvat': { done: true, tasksSolved: ['lsn-01-a'] } },
|
||||
tasks: { 'lsn-01-a': { solved: true, attempts: 1 } },
|
||||
streak: { lastDay: '2026-08-06', current: 1 },
|
||||
updatedAt: LOCAL_ISO,
|
||||
};
|
||||
|
||||
describe('mergeDecision: все ветки', () => {
|
||||
it('сервер новее → pull', () => {
|
||||
expect(mergeDecision(LOCAL_ISO, { updatedAt: REMOTE_NEWER_ISO })).toBe('pull');
|
||||
});
|
||||
|
||||
it('локаль новее → push', () => {
|
||||
expect(mergeDecision(LOCAL_ISO, { updatedAt: REMOTE_OLDER_ISO })).toBe('push');
|
||||
});
|
||||
|
||||
it('метки равны → noop', () => {
|
||||
expect(mergeDecision(LOCAL_ISO, { updatedAt: LOCAL_ISO })).toBe('noop');
|
||||
});
|
||||
|
||||
it('обе метки отсутствуют → noop', () => {
|
||||
expect(mergeDecision(null, null)).toBe('noop');
|
||||
});
|
||||
|
||||
it('сервера нет (null), локаль есть → push', () => {
|
||||
expect(mergeDecision(LOCAL_ISO, null)).toBe('push');
|
||||
});
|
||||
|
||||
it('локали нет, сервер есть → pull', () => {
|
||||
expect(mergeDecision(null, { updatedAt: REMOTE_NEWER_ISO })).toBe('pull');
|
||||
});
|
||||
|
||||
it('remote unauthorized → unauthorized', () => {
|
||||
expect(mergeDecision(LOCAL_ISO, 'unauthorized')).toBe('unauthorized');
|
||||
expect(mergeDecision(null, 'unauthorized')).toBe('unauthorized');
|
||||
});
|
||||
|
||||
it('remote unavailable → unavailable', () => {
|
||||
expect(mergeDecision(LOCAL_ISO, 'unavailable')).toBe('unavailable');
|
||||
expect(mergeDecision(null, 'unavailable')).toBe('unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncProgress: GET-ветки', () => {
|
||||
it('401 → unauthorized, локаль не тронута', async () => {
|
||||
const storage = memoryStorage();
|
||||
saveProgress(SAMPLE, storage, () => LOCAL_ISO);
|
||||
const result = await syncProgress({
|
||||
storage,
|
||||
fetch: fetchMock(() => new Response(null, { status: 401 })),
|
||||
});
|
||||
expect(result).toBe('unauthorized');
|
||||
expect(loadProgress(storage).progress).toEqual(SAMPLE);
|
||||
});
|
||||
|
||||
it('сетевая ошибка → unavailable', async () => {
|
||||
const doFetch: FetchLike = () => Promise.reject(new Error('network down'));
|
||||
expect(await syncProgress({ storage: memoryStorage(), fetch: doFetch })).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('404 и 500 → unavailable', async () => {
|
||||
for (const status of [404, 500]) {
|
||||
const result = await syncProgress({
|
||||
storage: memoryStorage(),
|
||||
fetch: fetchMock(() => new Response(null, { status })),
|
||||
});
|
||||
expect(result).toBe('unavailable');
|
||||
}
|
||||
});
|
||||
|
||||
it('200 с битым payload → unavailable, локаль не тронута', async () => {
|
||||
const storage = memoryStorage();
|
||||
saveProgress(SAMPLE, storage, () => LOCAL_ISO);
|
||||
const result = await syncProgress({
|
||||
storage,
|
||||
fetch: fetchMock(() => jsonResponse(200, { payload: { junk: 1 }, updatedAt: LOCAL_ISO })),
|
||||
});
|
||||
expect(result).toBe('unavailable');
|
||||
expect(loadProgress(storage).progress).toEqual(SAMPLE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncProgress: pull / push / noop', () => {
|
||||
it('сервер новее → pulled, локаль перезаписана серверным снимком', async () => {
|
||||
const storage = memoryStorage();
|
||||
saveProgress(SAMPLE, storage, () => LOCAL_ISO);
|
||||
const remotePayload: ProgressV2 = {
|
||||
...SAMPLE,
|
||||
streak: { lastDay: '2026-08-06', current: 7 },
|
||||
updatedAt: REMOTE_NEWER_ISO,
|
||||
};
|
||||
const put = vi.fn();
|
||||
const doFetch: FetchLike = (url, init) => {
|
||||
if (init?.method === 'PUT') {
|
||||
put(url, init);
|
||||
return Promise.resolve(jsonResponse(200, { updatedAt: REMOTE_NEWER_ISO }));
|
||||
}
|
||||
return Promise.resolve(
|
||||
jsonResponse(200, { payload: remotePayload, updatedAt: REMOTE_NEWER_ISO }),
|
||||
);
|
||||
};
|
||||
const result = await syncProgress({ storage, fetch: doFetch });
|
||||
expect(result).toBe('pulled');
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
// Локаль = серверный payload с серверной меткой (без сдвига updatedAt).
|
||||
expect(loadProgress(storage).progress).toEqual(remotePayload);
|
||||
});
|
||||
|
||||
it('локаль новее → pushed, PUT с заголовком X-GoLearn-Client и {payload}', async () => {
|
||||
const storage = memoryStorage();
|
||||
saveProgress(SAMPLE, storage, () => LOCAL_ISO);
|
||||
const put = vi.fn((_url: string, _init?: RequestInit) =>
|
||||
jsonResponse(200, { updatedAt: REMOTE_OLDER_ISO }),
|
||||
);
|
||||
const doFetch: FetchLike = (url, init) => {
|
||||
if (init?.method === 'PUT') return Promise.resolve(put(url, init));
|
||||
return Promise.resolve(jsonResponse(200, { payload: SAMPLE, updatedAt: REMOTE_OLDER_ISO }));
|
||||
};
|
||||
const result = await syncProgress({ storage, fetch: doFetch });
|
||||
expect(result).toBe('pushed');
|
||||
expect(put).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = put.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe('/api/progress.php');
|
||||
expect((init.headers as Record<string, string>)['X-GoLearn-Client']).toBe('web');
|
||||
expect(JSON.parse(init.body as string)).toEqual({ payload: SAMPLE });
|
||||
// Серверная метка из ответа PUT принята локально → следующая сверка noop.
|
||||
expect(loadProgress(storage).progress.updatedAt).toBe(REMOTE_OLDER_ISO);
|
||||
});
|
||||
|
||||
it('метки равны → noop, PUT не вызывается, локаль не меняется', async () => {
|
||||
const storage = memoryStorage();
|
||||
saveProgress(SAMPLE, storage, () => LOCAL_ISO);
|
||||
const put = vi.fn();
|
||||
const doFetch: FetchLike = (url, init) => {
|
||||
if (init?.method === 'PUT') {
|
||||
put();
|
||||
return Promise.resolve(jsonResponse(200, { updatedAt: LOCAL_ISO }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(200, { payload: SAMPLE, updatedAt: LOCAL_ISO }));
|
||||
};
|
||||
const result = await syncProgress({ storage, fetch: doFetch });
|
||||
expect(result).toBe('noop');
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
expect(loadProgress(storage).progress).toEqual(SAMPLE);
|
||||
});
|
||||
|
||||
it('204 (прогресса нет) + локаль есть → pushed', async () => {
|
||||
const storage = memoryStorage();
|
||||
saveProgress(SAMPLE, storage, () => LOCAL_ISO);
|
||||
const doFetch: FetchLike = (_url, init) => {
|
||||
if (init?.method === 'PUT')
|
||||
return Promise.resolve(jsonResponse(200, { updatedAt: LOCAL_ISO }));
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
};
|
||||
expect(await syncProgress({ storage, fetch: doFetch })).toBe('pushed');
|
||||
});
|
||||
|
||||
it('204 + локали нет → noop', async () => {
|
||||
const doFetch: FetchLike = () => Promise.resolve(new Response(null, { status: 204 }));
|
||||
expect(await syncProgress({ storage: memoryStorage(), fetch: doFetch })).toBe('noop');
|
||||
});
|
||||
|
||||
it('пустая локаль + сервер есть → pulled', async () => {
|
||||
const storage = memoryStorage();
|
||||
const doFetch: FetchLike = () =>
|
||||
Promise.resolve(jsonResponse(200, { payload: SAMPLE, updatedAt: LOCAL_ISO }));
|
||||
expect(await syncProgress({ storage, fetch: doFetch })).toBe('pulled');
|
||||
expect(loadProgress(storage).progress).toEqual(SAMPLE);
|
||||
});
|
||||
|
||||
it('PUT отвечает 401 → unauthorized', async () => {
|
||||
const storage = memoryStorage();
|
||||
saveProgress(SAMPLE, storage, () => LOCAL_ISO);
|
||||
const doFetch: FetchLike = (_url, init) => {
|
||||
if (init?.method === 'PUT') return Promise.resolve(new Response(null, { status: 401 }));
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
};
|
||||
expect(await syncProgress({ storage, fetch: doFetch })).toBe('unauthorized');
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth-обёртки', () => {
|
||||
const credentials = ['user@example.com', 'password-8', 'AB3CD'] as const;
|
||||
|
||||
it('register: 201 → ok, тело с captcha и заголовок X-GoLearn-Client', async () => {
|
||||
const doFetch = vi.fn((url: string, init?: RequestInit) => {
|
||||
expect(url).toBe('/api/register.php');
|
||||
expect((init?.headers as Record<string, string>)['X-GoLearn-Client']).toBe('web');
|
||||
expect(JSON.parse(init?.body as string)).toEqual({
|
||||
email: credentials[0],
|
||||
password: credentials[1],
|
||||
captcha: credentials[2],
|
||||
});
|
||||
return Promise.resolve(jsonResponse(201, { ok: true }));
|
||||
});
|
||||
expect(await register(credentials[0], credentials[1], credentials[2], doFetch)).toBe('ok');
|
||||
});
|
||||
|
||||
it('register: 409 → conflict, 422 → invalid, 429 → rate-limited, сбой → unavailable', async () => {
|
||||
expect(
|
||||
await register(
|
||||
...credentials,
|
||||
fetchMock(() => new Response(null, { status: 409 })),
|
||||
),
|
||||
).toBe('conflict');
|
||||
expect(
|
||||
await register(
|
||||
...credentials,
|
||||
fetchMock(() => jsonResponse(422, { error: 'invalid' })),
|
||||
),
|
||||
).toBe('invalid');
|
||||
expect(
|
||||
await register(
|
||||
...credentials,
|
||||
fetchMock(() => new Response(null, { status: 422 })),
|
||||
),
|
||||
).toBe('invalid');
|
||||
expect(
|
||||
await register(
|
||||
...credentials,
|
||||
fetchMock(() => new Response(null, { status: 429 })),
|
||||
),
|
||||
).toBe('rate-limited');
|
||||
expect(await register(...credentials, () => Promise.reject(new Error('down')))).toBe(
|
||||
'unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
it('register: 422 {error:"captcha"} → captcha', async () => {
|
||||
expect(
|
||||
await register(
|
||||
...credentials,
|
||||
fetchMock(() => jsonResponse(422, { error: 'captcha' })),
|
||||
),
|
||||
).toBe('captcha');
|
||||
});
|
||||
|
||||
it('login: 200 → ok; 401 → unauthorized; 422 → invalid; 429 → rate-limited', async () => {
|
||||
expect(
|
||||
await login(
|
||||
credentials[0],
|
||||
credentials[1],
|
||||
fetchMock(() => jsonResponse(200, { ok: true })),
|
||||
),
|
||||
).toBe('ok');
|
||||
expect(
|
||||
await login(
|
||||
credentials[0],
|
||||
credentials[1],
|
||||
fetchMock(() => new Response(null, { status: 401 })),
|
||||
),
|
||||
).toBe('unauthorized');
|
||||
expect(
|
||||
await login(
|
||||
credentials[0],
|
||||
credentials[1],
|
||||
fetchMock(() => new Response(null, { status: 422 })),
|
||||
),
|
||||
).toBe('invalid');
|
||||
expect(
|
||||
await login(
|
||||
credentials[0],
|
||||
credentials[1],
|
||||
fetchMock(() => new Response(null, { status: 429 })),
|
||||
),
|
||||
).toBe('rate-limited');
|
||||
expect(
|
||||
await login(credentials[0], credentials[1], () => Promise.reject(new Error('down'))),
|
||||
).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('me: 200 → email; 401 → null; сбой → null', async () => {
|
||||
expect(await me(fetchMock(() => jsonResponse(200, { email: credentials[0] })))).toBe(
|
||||
credentials[0],
|
||||
);
|
||||
expect(await me(fetchMock(() => new Response(null, { status: 401 })))).toBeNull();
|
||||
expect(await me(() => Promise.reject(new Error('down')))).toBeNull();
|
||||
});
|
||||
|
||||
it('logout: 200 → ok; 500 → unavailable', async () => {
|
||||
expect(await logout(fetchMock(() => jsonResponse(200, { ok: true })))).toBe('ok');
|
||||
expect(await logout(fetchMock(() => new Response(null, { status: 500 })))).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('requestPasswordReset: url, тело и заголовок X-GoLearn-Client', async () => {
|
||||
const doFetch = vi.fn((url: string, init?: RequestInit) => {
|
||||
expect(url).toBe('/api/password-reset/request.php');
|
||||
expect((init?.headers as Record<string, string>)['X-GoLearn-Client']).toBe('web');
|
||||
expect(JSON.parse(init?.body as string)).toEqual({ email: credentials[0] });
|
||||
return Promise.resolve(jsonResponse(200, { ok: true }));
|
||||
});
|
||||
expect(await requestPasswordReset(credentials[0], doFetch)).toBe('ok');
|
||||
});
|
||||
|
||||
it('requestPasswordReset: 200 → ok, 503 → mail_unavailable, 429 → rate_limited, сбой → unavailable', async () => {
|
||||
expect(
|
||||
await requestPasswordReset(
|
||||
credentials[0],
|
||||
fetchMock(() => jsonResponse(200, { ok: true })),
|
||||
),
|
||||
).toBe('ok');
|
||||
expect(
|
||||
await requestPasswordReset(
|
||||
credentials[0],
|
||||
fetchMock(() => jsonResponse(503, { error: 'mail_unavailable' })),
|
||||
),
|
||||
).toBe('mail_unavailable');
|
||||
expect(
|
||||
await requestPasswordReset(
|
||||
credentials[0],
|
||||
fetchMock(() => new Response(null, { status: 429 })),
|
||||
),
|
||||
).toBe('rate_limited');
|
||||
expect(
|
||||
await requestPasswordReset(
|
||||
credentials[0],
|
||||
fetchMock(() => new Response(null, { status: 500 })),
|
||||
),
|
||||
).toBe('unavailable');
|
||||
expect(
|
||||
await requestPasswordReset(credentials[0], () => Promise.reject(new Error('down'))),
|
||||
).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('confirmPasswordReset: url, тело с token+password и заголовок X-GoLearn-Client', async () => {
|
||||
const doFetch = vi.fn((url: string, init?: RequestInit) => {
|
||||
expect(url).toBe('/api/password-reset/confirm.php');
|
||||
expect((init?.headers as Record<string, string>)['X-GoLearn-Client']).toBe('web');
|
||||
expect(JSON.parse(init?.body as string)).toEqual({
|
||||
token: 'token-hex',
|
||||
password: credentials[1],
|
||||
});
|
||||
return Promise.resolve(jsonResponse(200, { ok: true }));
|
||||
});
|
||||
expect(await confirmPasswordReset('token-hex', credentials[1], doFetch)).toBe('ok');
|
||||
});
|
||||
|
||||
it('confirmPasswordReset: 400 → token_invalid, 422 → invalid, 429 → rate_limited, сбой → unavailable', async () => {
|
||||
expect(
|
||||
await confirmPasswordReset(
|
||||
'token-hex',
|
||||
credentials[1],
|
||||
fetchMock(() => jsonResponse(400, { error: 'token_invalid' })),
|
||||
),
|
||||
).toBe('token_invalid');
|
||||
expect(
|
||||
await confirmPasswordReset(
|
||||
'token-hex',
|
||||
credentials[1],
|
||||
fetchMock(() => new Response(null, { status: 422 })),
|
||||
),
|
||||
).toBe('invalid');
|
||||
expect(
|
||||
await confirmPasswordReset(
|
||||
'token-hex',
|
||||
credentials[1],
|
||||
fetchMock(() => new Response(null, { status: 429 })),
|
||||
),
|
||||
).toBe('rate_limited');
|
||||
expect(
|
||||
await confirmPasswordReset(
|
||||
'token-hex',
|
||||
credentials[1],
|
||||
fetchMock(() => new Response(null, { status: 500 })),
|
||||
),
|
||||
).toBe('unavailable');
|
||||
expect(
|
||||
await confirmPasswordReset('token-hex', credentials[1], () => Promise.reject(new Error('x'))),
|
||||
).toBe('unavailable');
|
||||
});
|
||||
});
|
||||
296
apps/web/src/lib/sync-client.ts
Normal file
296
apps/web/src/lib/sync-client.ts
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
/**
|
||||
* Клиент личного кабинета и синхронизации прогресса (этап 8, контракт —
|
||||
* docs/INTERFACES.md «Личный кабинет и синхронизация прогресса», ADR-0004).
|
||||
* Fetch API, базовый путь '/api' — тот же origin, сессия — cookie
|
||||
* (credentials: 'same-origin'). Тихая деградация: сетевая ошибка, 404 или
|
||||
* 5xx → 'unavailable', UI пишет статус-строку, алертов нет. Мутирующие
|
||||
* запросы несут заголовок X-GoLearn-Client: web (CSRF-мера сервера).
|
||||
*/
|
||||
import type { Clock, ProgressV2, StorageLike } from './storage.js';
|
||||
import { isProgressPayload, loadProgress, writeProgressSnapshot } from './storage.js';
|
||||
|
||||
/** Совместимый с fetch интерфейс; инжектируется в тестах. */
|
||||
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
const API_BASE = '/api';
|
||||
const CLIENT_HEADER = { 'X-GoLearn-Client': 'web' };
|
||||
const JSON_HEADERS = { ...CLIENT_HEADER, 'Content-Type': 'application/json' };
|
||||
|
||||
/** Глобальный fetch; null — среда без fetch (старый браузер/тесты). */
|
||||
function defaultFetch(): FetchLike | null {
|
||||
const candidate = (globalThis as { fetch?: FetchLike }).fetch;
|
||||
return candidate !== undefined ? candidate.bind(globalThis) : null;
|
||||
}
|
||||
|
||||
/* ── Решение о слиянии (чистая функция) ── */
|
||||
|
||||
/** Ответ сервера о прогрессе, сведённый к метке; либо исход запроса. */
|
||||
export type RemoteState = { updatedAt: string } | null | 'unauthorized' | 'unavailable';
|
||||
|
||||
export type MergeAction = 'pull' | 'push' | 'noop' | 'unauthorized' | 'unavailable';
|
||||
|
||||
/** Сравнение ISO-меток: по времени, при невалидных — лексикографически. */
|
||||
function compareIso(a: string, b: string): number {
|
||||
const timeA = Date.parse(a);
|
||||
const timeB = Date.parse(b);
|
||||
if (!Number.isNaN(timeA) && !Number.isNaN(timeB)) return timeA - timeB;
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Решение о слиянии: сервер новее → 'pull'; локаль новее или сервера нет
|
||||
* (null) при наличии локальной метки → 'push'; обе метки отсутствуют или
|
||||
* равны → 'noop'. Сбои запроса прозрачно пробрасываются наружу.
|
||||
*/
|
||||
export function mergeDecision(localUpdatedAt: string | null, remote: RemoteState): MergeAction {
|
||||
if (remote === 'unauthorized') return 'unauthorized';
|
||||
if (remote === 'unavailable') return 'unavailable';
|
||||
if (remote === null) return localUpdatedAt === null ? 'noop' : 'push';
|
||||
if (localUpdatedAt === null) return 'pull';
|
||||
const order = compareIso(remote.updatedAt, localUpdatedAt);
|
||||
if (order > 0) return 'pull';
|
||||
if (order < 0) return 'push';
|
||||
return 'noop';
|
||||
}
|
||||
|
||||
/* ── Синхронизация прогресса ── */
|
||||
|
||||
export type SyncResult = 'pulled' | 'pushed' | 'noop' | 'unauthorized' | 'unavailable';
|
||||
|
||||
/** Зависимости syncProgress; в браузере — дефолты (localStorage, fetch). */
|
||||
export interface SyncDeps {
|
||||
readonly storage?: StorageLike;
|
||||
readonly fetch?: FetchLike;
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
/** Серверный ответ GET /progress: payload + серверная метка; null — 204. */
|
||||
type RemoteProgress = { payload: ProgressV2; updatedAt: string } | null;
|
||||
|
||||
async function fetchRemoteProgress(
|
||||
doFetch: FetchLike,
|
||||
): Promise<RemoteProgress | 'unauthorized' | 'unavailable'> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await doFetch(`${API_BASE}/progress.php`, { credentials: 'same-origin' });
|
||||
} catch {
|
||||
return 'unavailable'; // сетевая ошибка — тихая деградация
|
||||
}
|
||||
if (response.status === 401) return 'unauthorized';
|
||||
if (response.status === 204) return null; // прогресса на сервере нет
|
||||
if (response.status !== 200) return 'unavailable'; // 404/5xx и прочие
|
||||
try {
|
||||
const body = (await response.json()) as { payload?: unknown; updatedAt?: unknown };
|
||||
if (typeof body.updatedAt !== 'string' || !isProgressPayload(body.payload)) {
|
||||
return 'unavailable'; // ответ не по контракту — локаль не трогаем
|
||||
}
|
||||
return { payload: { ...body.payload, updatedAt: body.updatedAt }, updatedAt: body.updatedAt };
|
||||
} catch {
|
||||
return 'unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
async function pushProgress(
|
||||
doFetch: FetchLike,
|
||||
progress: ProgressV2,
|
||||
storage?: StorageLike,
|
||||
): Promise<SyncResult> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await doFetch(`${API_BASE}/progress.php`, {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ payload: progress }),
|
||||
});
|
||||
} catch {
|
||||
return 'unavailable';
|
||||
}
|
||||
if (response.status === 401) return 'unauthorized';
|
||||
if (!response.ok) return 'unavailable'; // 403/413/422/5xx
|
||||
// Сервер хранит свою метку updated_at: принимаем её локально снимком БЕЗ
|
||||
// сдвига updatedAt — иначе следующая сверка увидит расхождение меток и
|
||||
// уйдёт в ложный pull/push-цикл.
|
||||
try {
|
||||
const body = (await response.json()) as { updatedAt?: unknown };
|
||||
if (typeof body.updatedAt === 'string') {
|
||||
writeProgressSnapshot({ ...progress, updatedAt: body.updatedAt }, storage);
|
||||
}
|
||||
} catch {
|
||||
// Тело без updatedAt — оставляем локальную метку, синхронизация уже состоялась.
|
||||
}
|
||||
return 'pushed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Синхронизация прогресса: GET → mergeDecision → pull (снимок с серверной
|
||||
* меткой, без сдвига updatedAt) / push (PUT {payload}) / noop. Локальное
|
||||
* хранилище остаётся SoT для неавторизованных (401 → 'unauthorized', локаль
|
||||
* не меняется).
|
||||
*/
|
||||
export async function syncProgress(deps: SyncDeps = {}): Promise<SyncResult> {
|
||||
const doFetch = deps.fetch ?? defaultFetch();
|
||||
if (doFetch === null) return 'unavailable';
|
||||
const remote = await fetchRemoteProgress(doFetch);
|
||||
if (remote === 'unauthorized' || remote === 'unavailable') return remote;
|
||||
const { progress } = loadProgress(deps.storage, deps.clock);
|
||||
// Пустая метка дефолтов = «локальных изменений не было» → null для сверки.
|
||||
const localUpdatedAt = progress.updatedAt === '' ? null : progress.updatedAt;
|
||||
const decision = mergeDecision(localUpdatedAt, remote);
|
||||
switch (decision) {
|
||||
case 'pull':
|
||||
// remote !== null гарантирован решением 'pull'.
|
||||
if (remote !== null) writeProgressSnapshot(remote.payload, deps.storage);
|
||||
return 'pulled';
|
||||
case 'push':
|
||||
return pushProgress(doFetch, progress, deps.storage);
|
||||
case 'noop':
|
||||
return 'noop';
|
||||
default:
|
||||
return decision; // 'unauthorized' | 'unavailable' — недостижимо после GET
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Регистрация, вход, сессия (тонкие fetch-обёртки) ── */
|
||||
|
||||
export type AuthResult =
|
||||
'ok' | 'conflict' | 'unauthorized' | 'invalid' | 'rate-limited' | 'unavailable';
|
||||
|
||||
/** Исход регистрации: как AuthResult + 'captcha' (422 {error:"captcha"}). */
|
||||
export type RegisterResult = AuthResult | 'captcha';
|
||||
|
||||
/** Исход запроса письма восстановления (этап 8.1). */
|
||||
export type PasswordResetRequestResult = 'ok' | 'mail_unavailable' | 'rate_limited' | 'unavailable';
|
||||
|
||||
/** Исход подтверждения нового пароля по токену (этап 8.1). */
|
||||
export type PasswordResetConfirmResult =
|
||||
'ok' | 'token_invalid' | 'invalid' | 'rate_limited' | 'unavailable';
|
||||
|
||||
async function postAuth(
|
||||
doFetch: FetchLike,
|
||||
endpoint: string,
|
||||
body: Record<string, string>,
|
||||
): Promise<Response | 'unavailable'> {
|
||||
try {
|
||||
return await doFetch(`${API_BASE}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch {
|
||||
return 'unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/register.php (этап 8.1 — с капчей): 201 → ok; 409 email занят;
|
||||
* 422 {error:"captcha"} → captcha (код сжигается любой попыткой — клиент
|
||||
* обновляет картинку); прочие 422 → invalid; 429 лимит.
|
||||
*/
|
||||
export async function register(
|
||||
email: string,
|
||||
password: string,
|
||||
captcha: string,
|
||||
fetchImpl?: FetchLike,
|
||||
): Promise<RegisterResult> {
|
||||
const doFetch = fetchImpl ?? defaultFetch();
|
||||
if (doFetch === null) return 'unavailable';
|
||||
const response = await postAuth(doFetch, 'register.php', { email, password, captcha });
|
||||
if (response === 'unavailable') return 'unavailable';
|
||||
if (response.status === 201) return 'ok';
|
||||
if (response.status === 409) return 'conflict';
|
||||
if (response.status === 422) {
|
||||
try {
|
||||
const body = (await response.json()) as { error?: unknown };
|
||||
if (body.error === 'captcha') return 'captcha';
|
||||
} catch {
|
||||
// Тело не по контракту — считаем общей ошибкой валидации.
|
||||
}
|
||||
return 'invalid';
|
||||
}
|
||||
if (response.status === 429) return 'rate-limited';
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
/** POST /api/login.php: 200 → ok (+сессия-cookie); 401 неверная пара; 422; 429. */
|
||||
export async function login(
|
||||
email: string,
|
||||
password: string,
|
||||
fetchImpl?: FetchLike,
|
||||
): Promise<AuthResult> {
|
||||
const doFetch = fetchImpl ?? defaultFetch();
|
||||
if (doFetch === null) return 'unavailable';
|
||||
const response = await postAuth(doFetch, 'login.php', { email, password });
|
||||
if (response === 'unavailable') return 'unavailable';
|
||||
if (response.status === 200) return 'ok';
|
||||
if (response.status === 401) return 'unauthorized';
|
||||
if (response.status === 422) return 'invalid';
|
||||
if (response.status === 429) return 'rate-limited';
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
/** POST /api/logout.php: 200 → ok (сессия уничтожена сервером). */
|
||||
export async function logout(fetchImpl?: FetchLike): Promise<'ok' | 'unavailable'> {
|
||||
const doFetch = fetchImpl ?? defaultFetch();
|
||||
if (doFetch === null) return 'unavailable';
|
||||
const response = await postAuth(doFetch, 'logout.php', {});
|
||||
if (response === 'unavailable') return 'unavailable';
|
||||
return response.status === 200 ? 'ok' : 'unavailable';
|
||||
}
|
||||
|
||||
/* ── Восстановление пароля (этап 8.1) ── */
|
||||
|
||||
/**
|
||||
* POST /api/password-reset/request.php {email}: 200 → ok (всегда, даже для
|
||||
* незарегистрированного email — без enumeration); 503 → mail_unavailable;
|
||||
* 429 → rate_limited.
|
||||
*/
|
||||
export async function requestPasswordReset(
|
||||
email: string,
|
||||
fetchImpl?: FetchLike,
|
||||
): Promise<PasswordResetRequestResult> {
|
||||
const doFetch = fetchImpl ?? defaultFetch();
|
||||
if (doFetch === null) return 'unavailable';
|
||||
const response = await postAuth(doFetch, 'password-reset/request.php', { email });
|
||||
if (response === 'unavailable') return 'unavailable';
|
||||
if (response.status === 200) return 'ok';
|
||||
if (response.status === 503) return 'mail_unavailable';
|
||||
if (response.status === 429) return 'rate_limited';
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/password-reset/confirm.php {token, password}: 200 → ok (пароль
|
||||
* обновлён); 400 → token_invalid (ссылка недействительна/истекла);
|
||||
* 422 → invalid (пароль <8); 429 → rate_limited.
|
||||
*/
|
||||
export async function confirmPasswordReset(
|
||||
token: string,
|
||||
password: string,
|
||||
fetchImpl?: FetchLike,
|
||||
): Promise<PasswordResetConfirmResult> {
|
||||
const doFetch = fetchImpl ?? defaultFetch();
|
||||
if (doFetch === null) return 'unavailable';
|
||||
const response = await postAuth(doFetch, 'password-reset/confirm.php', { token, password });
|
||||
if (response === 'unavailable') return 'unavailable';
|
||||
if (response.status === 200) return 'ok';
|
||||
if (response.status === 400) return 'token_invalid';
|
||||
if (response.status === 422) return 'invalid';
|
||||
if (response.status === 429) return 'rate_limited';
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
/** GET /api/me.php: email при активной сессии; null — 401 или любой сбой. */
|
||||
export async function me(fetchImpl?: FetchLike): Promise<string | null> {
|
||||
const doFetch = fetchImpl ?? defaultFetch();
|
||||
if (doFetch === null) return null;
|
||||
try {
|
||||
const response = await doFetch(`${API_BASE}/me.php`, { credentials: 'same-origin' });
|
||||
if (response.status !== 200) return null;
|
||||
const body = (await response.json()) as { email?: unknown };
|
||||
return typeof body.email === 'string' ? body.email : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
156
apps/web/src/lib/task-session.test.ts
Normal file
156
apps/web/src/lib/task-session.test.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* Тесты lib/task-session.ts: init (buildPosition + дерево SGF), легальный
|
||||
* ход вне дерева → откат + attempts, нелегальный → 'illegal' + reason,
|
||||
* ход из дерева → авто-ответ соперника → проверка goalReached, reset, hint.
|
||||
*/
|
||||
import { cellAt } from '@go-learn/core';
|
||||
import type { Point } from '@go-learn/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createTaskSession } from './task-session.js';
|
||||
import type { TaskV1 } from './task-session.js';
|
||||
|
||||
function p(x: number, y: number): Point {
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/** Белый (4,4) в атари; чёрные забирают ходом (4,5). Цель — TR (4,4). */
|
||||
const CAPTURE_TASK: TaskV1 = {
|
||||
id: 'lsn-test-capture',
|
||||
kind: 'lesson',
|
||||
title: 'Забрать камень',
|
||||
topics: ['захват'],
|
||||
difficulty: 1,
|
||||
size: 9,
|
||||
toPlay: 'black',
|
||||
setupBlack: [p(3, 4), p(5, 4), p(4, 3)],
|
||||
setupWhite: [p(4, 4)],
|
||||
sgf: '(;GM[1]SZ[9]TR[ee](;B[ef]))',
|
||||
goal: { type: 'capture-target' },
|
||||
hint: 'У белого камня одно дамэ.',
|
||||
};
|
||||
|
||||
/** Три чёрных камня (4,2),(4,4),(4,6); соединить все (CR). Дерево:
|
||||
* B(4,3) → авто-ответ W(0,0) → B(4,5). */
|
||||
const CONNECT_TASK: TaskV1 = {
|
||||
id: 'tsm-test-connect',
|
||||
kind: 'tsumego',
|
||||
title: 'Соединить три камня',
|
||||
topics: ['связь'],
|
||||
difficulty: 2,
|
||||
size: 9,
|
||||
toPlay: 'black',
|
||||
setupBlack: [p(4, 2), p(4, 4), p(4, 6)],
|
||||
setupWhite: [],
|
||||
sgf: '(;GM[1]SZ[9]CR[ec][ee][eg];B[ed];W[aa];B[ef])',
|
||||
goal: { type: 'connect' },
|
||||
};
|
||||
|
||||
describe('task-session: init', () => {
|
||||
it('стартовая позиция из setup-полей, статус playing, цель из разметки', () => {
|
||||
const session = createTaskSession(CAPTURE_TASK);
|
||||
const snap = session.snapshot();
|
||||
expect(snap.status).toBe('playing');
|
||||
expect(snap.attempts).toBe(0);
|
||||
expect(snap.state.toPlay).toBe('black');
|
||||
expect(cellAt(snap.state, p(4, 4))).toBe('white');
|
||||
expect(snap.markup.triangles).toEqual([p(4, 4)]);
|
||||
expect(snap.markup.circles).toEqual([]);
|
||||
});
|
||||
|
||||
it('цвет игрока — toPlay задачи (белые)', () => {
|
||||
const whiteTask: TaskV1 = {
|
||||
...CAPTURE_TASK,
|
||||
id: 'tsm-test-white',
|
||||
toPlay: 'white',
|
||||
setupBlack: [p(4, 4)],
|
||||
setupWhite: [p(3, 4), p(5, 4), p(4, 3)],
|
||||
sgf: '(;GM[1]SZ[9]TR[ee](;W[ef]))',
|
||||
};
|
||||
const session = createTaskSession(whiteTask);
|
||||
expect(session.snapshot().state.toPlay).toBe('white');
|
||||
expect(session.play(p(4, 5)).status).toBe('solved');
|
||||
});
|
||||
|
||||
it('битый контент (группа без дамэ / плохой SGF) → Error', () => {
|
||||
expect(() =>
|
||||
createTaskSession({ ...CAPTURE_TASK, setupBlack: [...CAPTURE_TASK.setupBlack, p(4, 5)] }),
|
||||
).toThrow();
|
||||
expect(() => createTaskSession({ ...CAPTURE_TASK, sgf: '(;GM[2])' })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('task-session: play', () => {
|
||||
it('нелегальный ход → illegal + reason, позиция не меняется', () => {
|
||||
const session = createTaskSession(CAPTURE_TASK);
|
||||
const snap = session.play(p(3, 4)); // занято чёрным
|
||||
expect(snap.status).toBe('illegal');
|
||||
expect(snap.reason).toBe('occupied');
|
||||
expect(cellAt(snap.state, p(4, 4))).toBe('white');
|
||||
expect(snap.attempts).toBe(0);
|
||||
});
|
||||
|
||||
it('легальный ход вне дерева → wrong + откат + attempts++', () => {
|
||||
const session = createTaskSession(CAPTURE_TASK);
|
||||
const snap = session.play(p(0, 0));
|
||||
expect(snap.status).toBe('wrong');
|
||||
expect(snap.reason).toBeNull();
|
||||
expect(snap.attempts).toBe(1);
|
||||
expect(cellAt(snap.state, p(0, 0))).toBe('empty'); // откат
|
||||
expect(cellAt(snap.state, p(4, 4))).toBe('white');
|
||||
// повторная ошибка накапливается, после неё правильный ход решает
|
||||
expect(session.play(p(1, 1)).attempts).toBe(2);
|
||||
expect(session.play(p(4, 5)).status).toBe('solved');
|
||||
});
|
||||
|
||||
it('ход из дерева → цель достигнута → solved, камень снят', () => {
|
||||
const session = createTaskSession(CAPTURE_TASK);
|
||||
const snap = session.play(p(4, 5));
|
||||
expect(snap.status).toBe('solved');
|
||||
expect(cellAt(snap.state, p(4, 4))).toBe('empty');
|
||||
expect(snap.lastMove).toEqual(p(4, 5));
|
||||
});
|
||||
|
||||
it('после solved ходы игнорируются', () => {
|
||||
const session = createTaskSession(CAPTURE_TASK);
|
||||
session.play(p(4, 5));
|
||||
const snap = session.play(p(0, 0));
|
||||
expect(snap.status).toBe('solved');
|
||||
expect(cellAt(snap.state, p(0, 0))).toBe('empty');
|
||||
});
|
||||
|
||||
it('ход из дерева без цели → playing + авто-ответ соперника', () => {
|
||||
const session = createTaskSession(CONNECT_TASK);
|
||||
const snap = session.play(p(4, 3));
|
||||
expect(snap.status).toBe('playing'); // (4,6) ещё не соединён
|
||||
expect(cellAt(snap.state, p(0, 0))).toBe('white'); // авто-ответ
|
||||
expect(snap.lastMove).toEqual(p(0, 0));
|
||||
expect(snap.state.toPlay).toBe('black');
|
||||
expect(session.play(p(4, 5)).status).toBe('solved');
|
||||
});
|
||||
});
|
||||
|
||||
describe('task-session: reset и hint', () => {
|
||||
it('reset возвращает к стартовой позиции и обнуляет attempts', () => {
|
||||
const session = createTaskSession(CAPTURE_TASK);
|
||||
session.play(p(0, 0));
|
||||
session.play(p(4, 5));
|
||||
const snap = session.reset();
|
||||
expect(snap.status).toBe('playing');
|
||||
expect(snap.attempts).toBe(0);
|
||||
expect(snap.lastMove).toBeNull();
|
||||
expect(cellAt(snap.state, p(4, 4))).toBe('white');
|
||||
expect(session.play(p(4, 5)).status).toBe('solved');
|
||||
});
|
||||
|
||||
it('hint: текст из task.hint + первый ход ведущего варианта', () => {
|
||||
const session = createTaskSession(CAPTURE_TASK);
|
||||
expect(session.hint()).toEqual({ text: 'У белого камня одно дамэ.', point: p(4, 5) });
|
||||
});
|
||||
|
||||
it('hint без task.hint → только точка первого варианта', () => {
|
||||
const task: TaskV1 = { ...CAPTURE_TASK, id: 'lsn-test-nohint' };
|
||||
delete (task as { hint?: string }).hint;
|
||||
const session = createTaskSession(task);
|
||||
expect(session.hint()).toEqual({ text: null, point: p(4, 5) });
|
||||
});
|
||||
});
|
||||
187
apps/web/src/lib/task-session.ts
Normal file
187
apps/web/src/lib/task-session.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* Машина состояния задачи (урок/цумэ-го, этап 6). Чистая логика без DOM:
|
||||
* позиция — buildPosition из @go-learn/core (ADR-0003), дерево вариантов —
|
||||
* parseSgf, критерий успеха — ТОЛЬКО goalReached (goals.ts), а не сравнение
|
||||
* с координатами решения. Ход игрока вне дерева → откат + attempts++.
|
||||
* Контракт — docs/INTERFACES.md, «Форматы данных уроков и задач».
|
||||
*/
|
||||
import { applyMove, buildPosition, parseSgf, pointKey } from '@go-learn/core';
|
||||
import type {
|
||||
BoardSize,
|
||||
BoardState,
|
||||
Color,
|
||||
GameNode,
|
||||
MoveError,
|
||||
NodeMarkup,
|
||||
Point,
|
||||
} from '@go-learn/core';
|
||||
import { goalReached } from './goals.js';
|
||||
import type { GoalSpec } from './goals.js';
|
||||
|
||||
/** Задача (контракт TaskV1). JSON-файлы коллекции tasks валидируются
|
||||
* zod-схемой той же формы (apps/web/src/content.config.ts). */
|
||||
export interface TaskV1 {
|
||||
readonly id: string; // уникальный kebab-case, префикс kind: 'lsn-*' | 'tsm-*'
|
||||
readonly kind: 'lesson' | 'tsumego';
|
||||
readonly title: string;
|
||||
readonly topics: readonly string[];
|
||||
readonly difficulty: 1 | 2 | 3 | 4 | 5;
|
||||
readonly size: BoardSize;
|
||||
readonly toPlay: Color; // цвет игрока: игрок ходит только им
|
||||
readonly setupBlack: readonly Point[];
|
||||
readonly setupWhite: readonly Point[];
|
||||
readonly sgf: string; // дерево вариантов от стартовой позиции + TR/CR цели
|
||||
readonly goal: GoalSpec;
|
||||
readonly hint?: string;
|
||||
}
|
||||
|
||||
export type TaskStatus = 'playing' | 'solved' | 'wrong' | 'illegal';
|
||||
|
||||
/** Неизменяемый снимок состояния сессии — то, что рисует виджет. */
|
||||
export interface TaskSnapshot {
|
||||
readonly state: BoardState; // текущая позиция (после авто-ответа)
|
||||
readonly status: TaskStatus;
|
||||
readonly reason: MoveError | null; // при status 'illegal'
|
||||
readonly attempts: number; // неверные ходы (вне дерева) с последнего сброса
|
||||
readonly lastMove: Point | null;
|
||||
readonly markup: NodeMarkup; // разметка цели с корня SGF (TR/CR)
|
||||
}
|
||||
|
||||
/** Подсказка: текст из task.hint и/или первый ход ведущего варианта. */
|
||||
export interface TaskHint {
|
||||
readonly text: string | null;
|
||||
readonly point: Point | null;
|
||||
}
|
||||
|
||||
export interface TaskSession {
|
||||
readonly task: TaskV1;
|
||||
snapshot(): TaskSnapshot;
|
||||
play(point: Point): TaskSnapshot;
|
||||
reset(): TaskSnapshot;
|
||||
hint(): TaskHint;
|
||||
}
|
||||
|
||||
/** Разметка цели по типу: TR, для connect — CR (ADR-0003). */
|
||||
function targetPoints(root: GameNode, task: TaskV1): readonly Point[] {
|
||||
return task.goal.type === 'connect' ? root.markup.circles : root.markup.triangles;
|
||||
}
|
||||
|
||||
/** Дочерний узел-вариант с ходом в точку (или пас — не ищем, игрок ходит). */
|
||||
function childWithMove(node: GameNode, point: Point): GameNode | null {
|
||||
const key = pointKey(point);
|
||||
for (const child of node.children) {
|
||||
if (child.move?.kind === 'play' && pointKey(child.move.point) === key) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализация SGF под parseSgf: парсер моделирует ходы от пустой доски
|
||||
* начиная с чёрных (свойство PL не поддерживается), поэтому для задач с
|
||||
* toPlay 'white' меняем B↔W в свойствах ходов. Структура дерева, координаты
|
||||
* и разметка сохраняются; при проигрывании цвет каждого хода берётся из
|
||||
* state.toPlay, так что инверсия цветов в дереве безопасна. Ограничение:
|
||||
* комментарии C[...] не должны содержать подстрок вида "B["/"W[".
|
||||
*/
|
||||
function normalizedSgf(task: TaskV1): string {
|
||||
if (task.toPlay === 'black') return task.sgf;
|
||||
return task.sgf.replace(/([BW])\[/g, (_raw, letter: string) => (letter === 'B' ? 'W[' : 'B['));
|
||||
}
|
||||
|
||||
/**
|
||||
* Сессия задачи. Бросает Error при битом контенте (невалидная позиция
|
||||
* buildPosition или SgfError parseSgf) — вызывающий код (виджет) ловит.
|
||||
*/
|
||||
export function createTaskSession(task: TaskV1): TaskSession {
|
||||
const initial = buildPosition(task.size, task.setupBlack, task.setupWhite, task.toPlay);
|
||||
const root = parseSgf(normalizedSgf(task)).root;
|
||||
const target = targetPoints(root, task);
|
||||
|
||||
let state = initial;
|
||||
let node = root;
|
||||
let status: TaskStatus = 'playing';
|
||||
let reason: MoveError | null = null;
|
||||
let attempts = 0;
|
||||
let lastMove: Point | null = null;
|
||||
|
||||
function snapshot(): TaskSnapshot {
|
||||
return { state, status, reason, attempts, lastMove, markup: root.markup };
|
||||
}
|
||||
|
||||
/** Авто-ответ соперника: первый дочерний узел текущего, если это ход.
|
||||
* Цвет — state.toPlay (цвета в дереве могут быть инвертированы
|
||||
* нормализацией для toPlay 'white', поэтому на них не опираемся). */
|
||||
function applyOpponentReply(): void {
|
||||
const reply = node.children[0];
|
||||
if (reply === undefined) return;
|
||||
const move = reply.move;
|
||||
if (move === null || move.kind === 'resign') return;
|
||||
const result = applyMove(
|
||||
state,
|
||||
move.kind === 'play'
|
||||
? { kind: 'play', color: state.toPlay, point: move.point }
|
||||
: { kind: 'pass', color: state.toPlay },
|
||||
);
|
||||
// Нелегальный ответ — дефект контента; защищаемся: остаёмся на ходу игрока.
|
||||
if (!result.ok) return;
|
||||
state = result.state;
|
||||
node = reply;
|
||||
if (move.kind === 'play') lastMove = move.point;
|
||||
}
|
||||
|
||||
function play(point: Point): TaskSnapshot {
|
||||
if (status === 'solved') return snapshot();
|
||||
const result = applyMove(state, { kind: 'play', color: state.toPlay, point });
|
||||
if (!result.ok) {
|
||||
status = 'illegal';
|
||||
reason = result.reason;
|
||||
return snapshot();
|
||||
}
|
||||
const child = childWithMove(node, point);
|
||||
if (child === null) {
|
||||
// Легальный ход вне дерева вариантов: откат, attempts++.
|
||||
status = 'wrong';
|
||||
reason = null;
|
||||
attempts += 1;
|
||||
return snapshot();
|
||||
}
|
||||
state = result.state;
|
||||
node = child;
|
||||
lastMove = point;
|
||||
reason = null;
|
||||
// Контракт: goal проверяется после КАЖДОГО хода игрока — до авто-ответа
|
||||
// (транзиентные цели вроде liberties могут достигаться ходом игрока и
|
||||
// «сгорать» от ответа соперника, tsm-liberties-escape).
|
||||
if (goalReached(state, task.goal, target)) {
|
||||
status = 'solved';
|
||||
return snapshot();
|
||||
}
|
||||
applyOpponentReply();
|
||||
status = goalReached(state, task.goal, target) ? 'solved' : 'playing';
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function reset(): TaskSnapshot {
|
||||
state = initial;
|
||||
node = root;
|
||||
status = 'playing';
|
||||
reason = null;
|
||||
attempts = 0;
|
||||
lastMove = null;
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function hint(): TaskHint {
|
||||
const text = task.hint ?? null;
|
||||
let point: Point | null = null;
|
||||
for (const child of node.children) {
|
||||
if (child.move?.kind === 'play') {
|
||||
point = child.move.point;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { text, point };
|
||||
}
|
||||
|
||||
return { task, snapshot, play, reset, hint };
|
||||
}
|
||||
48
apps/web/src/lib/territory.test.ts
Normal file
48
apps/web/src/lib/territory.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Тесты lib/territory.ts: регион одного цвета — территория владельца,
|
||||
* регион на границе двух цветов — нейтральный, мёртвые камни сняты.
|
||||
*/
|
||||
import { applyMove, createBoard, pointKey } from '@go-learn/core';
|
||||
import type { BoardState, Color, Point } from '@go-learn/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { territorySets } from './territory.js';
|
||||
|
||||
function playSequence(size: 9 | 13 | 19, plays: [Color, Point][]): BoardState {
|
||||
let state = createBoard(size);
|
||||
for (const [color, point] of plays) {
|
||||
const result = applyMove(state, { kind: 'play', color, point });
|
||||
if (result.ok) state = result.state;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
describe('territorySets', () => {
|
||||
it('пустая доска — один нейтральный регион без владельца', () => {
|
||||
const sets = territorySets(createBoard(9), new Set());
|
||||
expect(sets.black.size).toBe(0);
|
||||
expect(sets.white.size).toBe(0);
|
||||
});
|
||||
|
||||
it('регион вокруг одного камня — территория его цвета', () => {
|
||||
const state = playSequence(9, [
|
||||
['black', { x: 4, y: 4 }],
|
||||
['white', { x: 4, y: 4 }], // нелегален, пропускается
|
||||
]);
|
||||
const sets = territorySets(state, new Set());
|
||||
expect(sets.black.size).toBe(80);
|
||||
expect(sets.white.size).toBe(0);
|
||||
});
|
||||
|
||||
it('мёртвый камень снят: регион пересчитывается без него', () => {
|
||||
const state = playSequence(9, [
|
||||
['black', { x: 0, y: 0 }],
|
||||
['white', { x: 8, y: 8 }],
|
||||
]);
|
||||
const alive = territorySets(state, new Set());
|
||||
expect(alive.black.size + alive.white.size).toBe(0); // общий регион нейтрален
|
||||
const dead = new Set([pointKey({ x: 8, y: 8 })]);
|
||||
const sets = territorySets(state, dead);
|
||||
expect(sets.black.size).toBe(80); // клетка снятого камня — территория чёрных
|
||||
expect(sets.white.size).toBe(0);
|
||||
});
|
||||
});
|
||||
85
apps/web/src/lib/territory.ts
Normal file
85
apps/web/src/lib/territory.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Карта территории для заливки при подсчёте (RenderOptions.territory из
|
||||
* @go-learn/board): пустые регионы после снятия мёртвых камней; регион,
|
||||
* граничащий только с одним цветом, — его территория, иначе нейтральная.
|
||||
* Чистая функция, без DOM. Логика зеркалит scorePosition ядра, но нужна
|
||||
* пометочная карта (ScoreResult отдаёт только суммы и neutral).
|
||||
*/
|
||||
import { cellAt, pointKey } from '@go-learn/core';
|
||||
import type { BoardState, Color, Point } from '@go-learn/core';
|
||||
|
||||
/** Ортогональные соседи в пределах доски (core neighbors не экспортирован). */
|
||||
function neighborPoints(state: BoardState, point: Point): Point[] {
|
||||
const result: Point[] = [];
|
||||
const deltas = [
|
||||
{ x: 1, y: 0 },
|
||||
{ x: -1, y: 0 },
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 0, y: -1 },
|
||||
];
|
||||
for (const delta of deltas) {
|
||||
const next = { x: point.x + delta.x, y: point.y + delta.y };
|
||||
if (next.x >= 0 && next.x < state.size && next.y >= 0 && next.y < state.size) {
|
||||
result.push(next);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Эффективная клетка: мёртвые камни считаются снятыми (пустыми). */
|
||||
function effectiveCell(state: BoardState, dead: ReadonlySet<string>, point: Point): string {
|
||||
const cell = cellAt(state, point);
|
||||
if (cell === 'empty') return 'empty';
|
||||
return dead.has(pointKey(point)) ? 'empty' : (cell as string);
|
||||
}
|
||||
|
||||
export interface TerritorySets {
|
||||
readonly black: Set<string>;
|
||||
readonly white: Set<string>;
|
||||
}
|
||||
|
||||
/** Пометка одного пустого региона от стартовой точки (BFS). */
|
||||
function floodRegion(
|
||||
state: BoardState,
|
||||
dead: ReadonlySet<string>,
|
||||
start: Point,
|
||||
visited: Set<string>,
|
||||
): { points: Point[]; border: Set<Color> } {
|
||||
const points: Point[] = [];
|
||||
const border = new Set<Color>();
|
||||
const queue: Point[] = [start];
|
||||
visited.add(pointKey(start));
|
||||
while (queue.length > 0) {
|
||||
const point = queue.pop() as Point;
|
||||
points.push(point);
|
||||
for (const next of neighborPoints(state, point)) {
|
||||
const cell = effectiveCell(state, dead, next);
|
||||
if (cell === 'empty' && !visited.has(pointKey(next))) {
|
||||
visited.add(pointKey(next));
|
||||
queue.push(next);
|
||||
} else if (cell === 'black' || cell === 'white') {
|
||||
border.add(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { points, border };
|
||||
}
|
||||
|
||||
/** Карта территории: ключи pointKey по цветам-владельцам регионов. */
|
||||
export function territorySets(state: BoardState, dead: ReadonlySet<string>): TerritorySets {
|
||||
const black = new Set<string>();
|
||||
const white = new Set<string>();
|
||||
const visited = new Set<string>();
|
||||
for (let y = 0; y < state.size; y += 1) {
|
||||
for (let x = 0; x < state.size; x += 1) {
|
||||
const point = { x, y };
|
||||
if (visited.has(pointKey(point))) continue;
|
||||
if (effectiveCell(state, dead, point) !== 'empty') continue;
|
||||
const region = floodRegion(state, dead, point, visited);
|
||||
const target = region.border.size === 1 ? [...region.border][0] : undefined;
|
||||
const sink = target === 'black' ? black : target === 'white' ? white : null;
|
||||
if (sink !== null) for (const p of region.points) sink.add(pointKey(p));
|
||||
}
|
||||
}
|
||||
return { black, white };
|
||||
}
|
||||
188
apps/web/src/pages/account.astro
Normal file
188
apps/web/src/pages/account.astro
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { STRINGS } from '../ui/strings';
|
||||
|
||||
const s = STRINGS.account;
|
||||
---
|
||||
|
||||
<Layout title={`${s.title} — ${STRINGS.site.title}`} section="account">
|
||||
<div class="container">
|
||||
<!-- Неавторизован: форма входа (нативная валидация — клавиатурная
|
||||
доступность и подсказки браузера из коробки). Регистрация и
|
||||
восстановление пароля — отдельные страницы (этап 8.1). -->
|
||||
<div id="account-forms" class="card auth-card" hidden>
|
||||
<h1>{s.loginHeading}</h1>
|
||||
<p class="auth-lead">
|
||||
Кабинет нужен только для синхронизации прогресса между устройствами. Без него всё работает
|
||||
так же.
|
||||
</p>
|
||||
<form id="account-login-form">
|
||||
<div class="field">
|
||||
<label for="login-email">{s.emailLabel}</label>
|
||||
<input id="login-email" name="email" type="email" required autocomplete="email" />
|
||||
<span class="error-text">{s.statusInvalid}</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="login-password">{s.passwordLabel}</label>
|
||||
<input
|
||||
id="login-password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<span class="error-text">{s.statusInvalid}</span>
|
||||
</div>
|
||||
<button class="btn btn--primary auth-submit" type="submit">{s.loginSubmit}</button>
|
||||
</form>
|
||||
<div class="auth-links">
|
||||
<span><a href="/register.html">{s.registerLink}</a></span>
|
||||
<a href="/password-reset.html">{s.resetLink}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Авторизован: профиль и ручная синхронизация. -->
|
||||
<div id="account-profile" class="card auth-card logged-in" hidden>
|
||||
<h1>{s.title}</h1>
|
||||
<p id="account-email" class="email"></p>
|
||||
<div class="btn-row auth-actions">
|
||||
<button id="account-sync" class="btn btn--primary" type="button">{s.syncButton}</button>
|
||||
<button id="account-logout" class="btn btn--danger" type="button">{s.logoutButton}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="account-status" class="auth-status" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { login, logout, me, syncProgress } from '../lib/sync-client';
|
||||
import type { AuthResult, SyncResult } from '../lib/sync-client';
|
||||
import { format, STRINGS } from '../ui/strings';
|
||||
|
||||
const s = STRINGS.account;
|
||||
|
||||
const forms = document.getElementById('account-forms');
|
||||
const profile = document.getElementById('account-profile');
|
||||
const emailElement = document.getElementById('account-email');
|
||||
const status = document.getElementById('account-status');
|
||||
|
||||
function setStatus(text: string): void {
|
||||
if (status !== null) status.textContent = text;
|
||||
}
|
||||
|
||||
function showProfile(email: string): void {
|
||||
forms?.toggleAttribute('hidden', true);
|
||||
profile?.toggleAttribute('hidden', false);
|
||||
if (emailElement !== null) emailElement.textContent = format(s.loggedInAs, { email });
|
||||
}
|
||||
|
||||
function showForms(): void {
|
||||
profile?.toggleAttribute('hidden', true);
|
||||
forms?.toggleAttribute('hidden', false);
|
||||
}
|
||||
|
||||
function authMessage(result: AuthResult): string {
|
||||
switch (result) {
|
||||
case 'unauthorized':
|
||||
return s.statusUnauthorized;
|
||||
case 'invalid':
|
||||
return s.statusInvalid;
|
||||
case 'rate-limited':
|
||||
return s.statusRateLimited;
|
||||
default:
|
||||
return s.statusUnavailable;
|
||||
}
|
||||
}
|
||||
|
||||
function syncMessage(result: SyncResult): string {
|
||||
switch (result) {
|
||||
case 'pushed':
|
||||
return s.syncPushed;
|
||||
case 'pulled':
|
||||
return s.syncPulled;
|
||||
case 'noop':
|
||||
return s.syncNoop;
|
||||
case 'unauthorized':
|
||||
return s.syncUnauthorized;
|
||||
default:
|
||||
return s.syncUnavailable;
|
||||
}
|
||||
}
|
||||
|
||||
/** Тихая синхронизация: итог — только в статус-строку, без алертов. */
|
||||
async function runSync(): Promise<void> {
|
||||
setStatus(syncMessage(await syncProgress()));
|
||||
}
|
||||
|
||||
function field(form: HTMLElement | null, id: string): string {
|
||||
const input = form?.querySelector(`#${id}`);
|
||||
return input instanceof HTMLInputElement ? input.value.trim() : '';
|
||||
}
|
||||
|
||||
document.getElementById('account-login-form')?.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const email = field(form instanceof HTMLElement ? form : null, 'login-email');
|
||||
const password = field(form instanceof HTMLElement ? form : null, 'login-password');
|
||||
void (async () => {
|
||||
const result = await login(email, password);
|
||||
if (result !== 'ok') {
|
||||
setStatus(authMessage(result));
|
||||
return;
|
||||
}
|
||||
showProfile(email);
|
||||
setStatus(s.statusLoginOk);
|
||||
await runSync(); // авто-синхронизация после входа
|
||||
})();
|
||||
});
|
||||
|
||||
document.getElementById('account-sync')?.addEventListener('click', () => {
|
||||
void runSync();
|
||||
});
|
||||
|
||||
document.getElementById('account-logout')?.addEventListener('click', () => {
|
||||
void (async () => {
|
||||
const result = await logout();
|
||||
showForms();
|
||||
setStatus(result === 'ok' ? s.statusLoggedOut : s.statusUnavailable);
|
||||
})();
|
||||
});
|
||||
|
||||
// Загрузка: активная сессия → профиль + тихий авто-sync; иначе — формы.
|
||||
void (async () => {
|
||||
const email = await me();
|
||||
if (email === null) {
|
||||
showForms();
|
||||
return;
|
||||
}
|
||||
showProfile(email);
|
||||
await runSync();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.auth-lead {
|
||||
margin: 0 0 18px;
|
||||
text-align: center;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.auth-submit {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.auth-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auth-status {
|
||||
max-width: 440px;
|
||||
min-height: 1.5em;
|
||||
margin: 18px auto 0;
|
||||
text-align: center;
|
||||
color: var(--accent-strong);
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
98
apps/web/src/pages/index.astro
Normal file
98
apps/web/src/pages/index.astro
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { format, STRINGS } from '../ui/strings';
|
||||
|
||||
const s = STRINGS.home;
|
||||
|
||||
// Честные числа hero-meta: разделы — из STRINGS.learn.sections,
|
||||
// задачи цумэ-го — из коллекции, уровни ИИ — 1..6 (селект страницы игры).
|
||||
const sectionsCount = STRINGS.learn.sections.length;
|
||||
const allTasks = (await getCollection('tasks')).flatMap((entry) => entry.data);
|
||||
const tsumegoCount = allTasks.filter((task) => task.kind === 'tsumego').length;
|
||||
const aiLevelsCount = 6;
|
||||
|
||||
// Ссылка цитатного блока — на первый урок курса (по order во frontmatter).
|
||||
const lessons = await getCollection('lessons');
|
||||
const firstLesson = [...lessons].sort((a, b) => a.data.order - b.data.order)[0];
|
||||
---
|
||||
|
||||
<Layout title={STRINGS.site.title} description={s.description}>
|
||||
<section class="hero">
|
||||
<div class="container">
|
||||
<div>
|
||||
<span class="hero-kicker">{s.kicker}</span>
|
||||
<h1>{s.headingBefore}<em>{s.headingEm}</em>{s.headingAfter}</h1>
|
||||
<p class="lead">{s.lead}</p>
|
||||
<div class="btn-row">
|
||||
<a class="btn btn--primary" href="/learn.html">{s.startCourse}</a>
|
||||
<a class="btn" href="/play.html">{s.playAi}</a>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<div class="item"><strong>{sectionsCount}</strong><span>{s.metaSections}</span></div>
|
||||
<div class="item"><strong>{tsumegoCount}</strong><span>{s.metaTsumego}</span></div>
|
||||
<div class="item"><strong>{aiLevelsCount}</strong><span>{s.metaLevels}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<figure class="hero-figure">
|
||||
<img src="/assets/hero.jpg" alt={s.heroAlt} width="1024" height="576" />
|
||||
<figcaption>{s.heroCaption}</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="features">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="kicker">{s.featuresKicker}</p>
|
||||
<h2>{s.featuresHeading}</h2>
|
||||
</div>
|
||||
<p>{s.featuresLead}</p>
|
||||
</div>
|
||||
<div class="features-grid">
|
||||
<div class="card feature-card">
|
||||
<span class="icon">一</span>
|
||||
<h3>{s.feature1Title}</h3>
|
||||
<p>{s.feature1Text}</p>
|
||||
</div>
|
||||
<div class="card feature-card">
|
||||
<span class="icon">二</span>
|
||||
<h3>{s.feature2Title}</h3>
|
||||
<p>{s.feature2Text}</p>
|
||||
</div>
|
||||
<div class="card feature-card">
|
||||
<span class="icon">三</span>
|
||||
<h3>{s.feature3Title}</h3>
|
||||
<p>{s.feature3Text}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ink-band">
|
||||
<figure>
|
||||
<img src="/assets/ink.jpg" alt={s.inkAlt} loading="lazy" />
|
||||
<figcaption>{s.inkCaption}</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
<div class="home-strip">
|
||||
<figure class="hero-figure">
|
||||
<img src="/assets/stones.jpg" alt={s.stonesAlt} loading="lazy" />
|
||||
</figure>
|
||||
<div class="card quote">
|
||||
<blockquote>{s.quote}</blockquote>
|
||||
<cite>{s.quoteCite}</cite>
|
||||
{
|
||||
firstLesson !== undefined && (
|
||||
<p style="margin-top: 24px">
|
||||
<a class="btn btn--quiet" href={`/learn/${firstLesson.id}.html`}>
|
||||
{format(s.firstLessonLink, { title: firstLesson.data.title.toLowerCase() })}
|
||||
</a>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
413
apps/web/src/pages/istochniki.astro
Normal file
413
apps/web/src/pages/istochniki.astro
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { STRINGS } from '../ui/strings';
|
||||
|
||||
// Этап 11: страница проверяемых источников. Железное правило владельца:
|
||||
// каждая ссылка — только из docs/audit/*.md (там URL реально открывались
|
||||
// и есть цитаты). Что подтвердить не удалось — честно в последнем разделе.
|
||||
const s = STRINGS.sources;
|
||||
---
|
||||
|
||||
<Layout title={`${s.title} — ${STRINGS.site.title}`}>
|
||||
<div class="page-head">
|
||||
<div class="container">
|
||||
<p class="kicker">{s.kicker}</p>
|
||||
<h1>{s.title}</h1>
|
||||
<p>{s.lead}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container sources-body">
|
||||
<section class="source-section">
|
||||
<h2>{s.sectionRules}</h2>
|
||||
<ul class="source-list">
|
||||
<li>
|
||||
Дамэ — свободный соседний пункт по линиям; диагонали не считаются —
|
||||
<a href="https://www.britgo.org/rules/agashort.html" target="_blank" rel="noopener"
|
||||
>Правила AGA, краткая версия (зеркало BGA)</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Группа без дамэ снимается целиком —
|
||||
<a href="https://senseis.xmp.net/?RulesOfGoIntroductory" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Rules of Go — Introductory</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Атари — группа с одним дамэ —
|
||||
<a href="https://senseis.xmp.net/?Atari" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Atari</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Лестница: серия атари, убегающий получает лишь одно новое дамэ; камень на пути ломает
|
||||
лестницу —
|
||||
<a href="https://senseis.xmp.net/?Ladder" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Ladder</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Лестничная опора: shicho-atari / ladder breaker (рус. «ситё-прерыватель») —
|
||||
<a href="https://www.britgo.org/general/definitions.html" target="_blank" rel="noopener"
|
||||
>Глоссарий BGA</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Сеть (гэта) — захват окружением без атари —
|
||||
<a href="https://senseis.xmp.net/?Geta" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Geta</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Самоубийство запрещено (японские/китайские/AGA правила); исключение — ход, снимающий
|
||||
группу —
|
||||
<a href="https://senseis.xmp.net/?Suicide" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Suicide</a
|
||||
>;
|
||||
<a href="https://www.britgo.org/intro/intro2.html" target="_blank" rel="noopener"
|
||||
>BGA: Introduction to Go, часть 2</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Нелегальный ход по правилам AGA отменяется и засчитывается как пас (правило 8) —
|
||||
<a href="https://www.britgo.org/rules/agashort.html" target="_blank" rel="noopener"
|
||||
>Правила AGA, краткая версия (зеркало BGA)</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Ко: немедленный обратный захват запрещён, сначала ход в другом месте —
|
||||
<a href="https://senseis.xmp.net/?Ko" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Ko</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Ко-угрозы: у кого больше угроз, тот обычно выигрывает ко —
|
||||
<a href="https://senseis.xmp.net/?KoThreat" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Ko Threat</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Говорить «атари» вслух — практика обучающих партий; среди опытных игроков необычно —
|
||||
<a href="https://senseis.xmp.net/?CallingOutAtari" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Calling Out Atari</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="source-section">
|
||||
<h2>{s.sectionLife}</h2>
|
||||
<ul class="source-list">
|
||||
<li>
|
||||
Глаз — пустое пространство, окружённое камнями одного цвета —
|
||||
<a href="https://senseis.xmp.net/?Eye" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Eye</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Два отдельных глаза — группа живёт; один большой глаз — ещё не жизнь —
|
||||
<a href="https://senseis.xmp.net/?TwoEyes" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Two Eyes</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Ложный глаз: часть окружающих камней можно поставить в атари —
|
||||
<a href="https://senseis.xmp.net/?FalseEye" target="_blank" rel="noopener"
|
||||
>Sensei's Library: False Eye</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Жизненная точка (vital point / накадэ) —
|
||||
<a href="https://senseis.xmp.net/?VitalPoint" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Vital Point</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="source-section">
|
||||
<h2>{s.sectionScoring}</h2>
|
||||
<ul class="source-list">
|
||||
<li>
|
||||
Два паса подряд — конец партии (правило 9) —
|
||||
<a href="https://www.cs.cmu.edu/~wjh/go/rules/AGA.concise.html" target="_blank" rel="noopener"
|
||||
>AGA Concise Rules of Go</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Мёртвые камни снимаются по согласию; при споре игра продолжается (правила 9–10) —
|
||||
<a href="https://www.cs.cmu.edu/~wjh/go/rules/AGA.concise.html" target="_blank" rel="noopener"
|
||||
>AGA Concise Rules of Go</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Площадной подсчёт (китайский/AGA): очки = камни на доске + территория —
|
||||
<a href="https://senseis.xmp.net/?AreaScoring" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Area Scoring</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Китайские правила: ход в свою территорию стоит 0 очков; японские — 1 очко —
|
||||
<a href="https://senseis.xmp.net/?ChineseRules" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Chinese Rules</a
|
||||
>;
|
||||
<a href="https://senseis.xmp.net/?JapaneseRules" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Japanese Rules</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Коми: китайский стандарт 7,5; японский стандарт 6,5 —
|
||||
<a href="https://senseis.xmp.net/?ChineseRules" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Chinese Rules</a
|
||||
>;
|
||||
<a href="https://senseis.xmp.net/?Komi" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Komi</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Дробное коми исключает ничью —
|
||||
<a href="https://senseis.xmp.net/?Komi" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Komi</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="source-section">
|
||||
<h2>{s.sectionConnect}</h2>
|
||||
<ul class="source-list">
|
||||
<li>
|
||||
«Соединяй свои группы и разрезай чужие» — центральная идея —
|
||||
<a href="https://senseis.xmp.net/?Cut" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Cut</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Иккэн (one-point jump) уязвим к разрезанию в зависимости от окружения —
|
||||
<a href="https://senseis.xmp.net/?IkkenTobi" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Ikken Tobi</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Косуми — диагональный ход; изолированное косуми локально неразрезаемо (миаи) —
|
||||
<a href="https://senseis.xmp.net/?Kosumi" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Kosumi</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="source-section">
|
||||
<h2>{s.sectionOpening}</h2>
|
||||
<ul class="source-list">
|
||||
<li>
|
||||
Углы → стороны → центр: в углу территорию ограждают две стены, на стороне три, в центре
|
||||
четыре —
|
||||
<a href="https://senseis.xmp.net/?CornersThenSidesThenCenter" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Corners, Then Sides, Then Center</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Вторая линия — «линия поражения», третья — «линия территории», четвёртая — «линия
|
||||
влияния» —
|
||||
<a href="https://senseis.xmp.net/?KnowYourLines" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Know Your Lines</a
|
||||
>;
|
||||
<a href="https://senseis.xmp.net/?ThirdLine" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Third Line</a
|
||||
>;
|
||||
<a href="https://senseis.xmp.net/?FourthLine" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Fourth Line</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
3-3 (сан-сан): надёжный угол, но низко —
|
||||
<a href="https://senseis.xmp.net/?SanSan" target="_blank" rel="noopener"
|
||||
>Sensei's Library: San San</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
3-4 (комоку): классический самый частый первый ход (до ~1975) —
|
||||
<a href="https://senseis.xmp.net/?Komoku" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Komoku</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
4-4 (хоси): угол оспаривается вторжением 3-3 —
|
||||
<a href="https://senseis.xmp.net/?Hoshi" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Hoshi</a
|
||||
>;
|
||||
<a href="https://senseis.xmp.net/?44Point33Invasion" target="_blank" rel="noopener"
|
||||
>Sensei's Library: 4-4 Point 3-3 Invasion</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Мойо — большие рамки, потенциальная территория —
|
||||
<a href="https://senseis.xmp.net/?Moyo" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Moyo</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="source-section">
|
||||
<h2>{s.sectionJoseki}</h2>
|
||||
<ul class="source-list">
|
||||
<li>
|
||||
Дзёсэки — эталонные угловые последовательности с локально равным итогом; оценки меняются
|
||||
со временем, включая влияние ИИ —
|
||||
<a href="https://senseis.xmp.net/?Joseki" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Joseki</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Цукэ-хики (3-4, подход, прижатие-отступление): хики укрепляет угол чёрных — один из
|
||||
первых дзёсэки для новичков —
|
||||
<a
|
||||
href="https://senseis.xmp.net/?34PointHighApproachAttachDrawbackJoseki"
|
||||
target="_blank"
|
||||
rel="noopener">Sensei's Library: 3-4 Point, High Approach, Attach-Drawback Joseki</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Вторжение 3-3 под хоси: земля против влияния, вторгшийся живёт ~10 очками в углу —
|
||||
<a href="https://senseis.xmp.net/?44Point33InvasionJoseki" target="_blank" rel="noopener"
|
||||
>Sensei's Library: 4-4 Point 3-3 Invasion Joseki</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="source-section">
|
||||
<h2>{s.sectionShape}</h2>
|
||||
<ul class="source-list">
|
||||
<li>
|
||||
Тигровая пасть (нэко но као) — форма из трёх камней вокруг пустого пункта —
|
||||
<a href="https://senseis.xmp.net/?TigersMouth" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Tiger's Mouth</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Бамбуковый стык (такэфу): практически неразрезаем —
|
||||
<a href="https://senseis.xmp.net/?BambooJoint" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Bamboo Joint</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Ноби — надёжное вытягивание, добавляет дамэ —
|
||||
<a href="https://senseis.xmp.net/?Nobi" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Nobi</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Пустой треугольник — неэффективная форма (с редкими исключениями) —
|
||||
<a href="https://senseis.xmp.net/?EmptyTriangle" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Empty Triangle</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Данго — «пельменная форма»: бесформенный ком камней без глаз —
|
||||
<a href="https://senseis.xmp.net/?Dango" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Dango</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Перегруппировка (коригатати) — слишком много камней для их работы —
|
||||
<a href="https://senseis.xmp.net/?Korigatachi" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Korigatachi</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="source-section">
|
||||
<h2>{s.sectionYose}</h2>
|
||||
<ul class="source-list">
|
||||
<li>
|
||||
Ёсэ — эндшпиль: ходы у границ стабильных территорий —
|
||||
<a href="https://senseis.xmp.net/?Yose" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Yose</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Сэнтэ требует ответа, готэ отдаёт инициативу —
|
||||
<a href="https://senseis.xmp.net/?Sente" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Sente</a
|
||||
>;
|
||||
<a href="https://senseis.xmp.net/?Gote" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Gote</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Ценность хода считается свингом (дэири) —
|
||||
<a href="https://senseis.xmp.net/?DeiriCounting" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Deiri Counting</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Нейтральные дамэ заполняются в конце партии —
|
||||
<a href="https://senseis.xmp.net/?Dame" target="_blank" rel="noopener"
|
||||
>Sensei's Library: Dame</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="source-section">
|
||||
<h2>{s.unconfirmedHeading}</h2>
|
||||
<ul class="source-list">
|
||||
<li>
|
||||
Приём «соединение снизу по первой линии» (урок 06) — авторитетная страница не открылась.
|
||||
</li>
|
||||
<li>
|
||||
Числа «угол 2–3 / сторона 4–5 / центр 8–10 камней» (урок 07) — источник не найден; в
|
||||
каноне другие числа (стены 2/3/4; жизнь минимум 6/8/10 камней).
|
||||
</li>
|
||||
<li>
|
||||
Термин «линия смерти» для первой линии (урок 07) — в авторитетных источниках не
|
||||
подтверждён.
|
||||
</li>
|
||||
<li>
|
||||
Диапазон «ходы ёсэ приносят 1–3 очка» (урок 10) — не подтверждён; встречаются ёсэ-ходы
|
||||
дороже (ханэ-комбо ~10 очков по дэири).
|
||||
</li>
|
||||
<li>
|
||||
Суперлатив «самый частый приём ёсэ — ханэ на первой линии» (урок 10) — не подтверждён.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p class="source-note">{s.note}</p>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
/* Этап 11: списки источников — узкая колонка, спокойные отступы. */
|
||||
.sources-body {
|
||||
max-width: var(--measure);
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
.source-section {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.source-section:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.source-list {
|
||||
margin: 0;
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
|
||||
.source-list li {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.source-note {
|
||||
margin-top: 48px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
</style>
|
||||
78
apps/web/src/pages/learn.astro
Normal file
78
apps/web/src/pages/learn.astro
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { STRINGS } from '../ui/strings';
|
||||
|
||||
// Плоская сетка карточек (этап 10): разделы курса совпадают с уроками 1:1.
|
||||
// Порядок прохождения = числовой префикс id файла (01-…10-…): поле order во
|
||||
// frontmatter сейчас у всех 1, поэтому сортируем по id.
|
||||
const lessons = (await getCollection('lessons')).sort((a, b) => a.id.localeCompare(b.id));
|
||||
---
|
||||
|
||||
<Layout title={`${STRINGS.learn.title} — ${STRINGS.site.title}`} section="learn">
|
||||
<div class="page-head">
|
||||
<div class="container">
|
||||
<!-- этап 10: кикер и вводный абзац — текст прямо в разметке до переноса в strings.ts -->
|
||||
<p class="kicker">с нуля до 10 кю</p>
|
||||
<h1>{STRINGS.learn.title}</h1>
|
||||
<p>
|
||||
Десять уроков, каждый — короткая теория и задачи на доске. Идите по порядку: каждый урок
|
||||
опирается на предыдущий. Отметки «пройден» сохраняются на вашем устройстве.
|
||||
</p>
|
||||
<p class="disclaimer">
|
||||
<!-- этап 11: дисклеймер открытого редактирования (требование владельца) -->
|
||||
{STRINGS.chrome.courseDisclaimer}{' '}
|
||||
<a href="/istochniki.html">{STRINGS.chrome.sourcesLink}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="course-grid">
|
||||
{
|
||||
lessons.map((lesson, index) => (
|
||||
<a
|
||||
class="card card--link lesson-card"
|
||||
href={`/learn/${lesson.id}.html`}
|
||||
data-lesson-id={lesson.id}
|
||||
>
|
||||
<span class="lesson-num">{index + 1}</span>
|
||||
<div>
|
||||
<h3>{lesson.data.title}</h3>
|
||||
<p>{lesson.data.summary}</p>
|
||||
<div class="meta">
|
||||
<span class="badge badge--done done-badge" hidden>
|
||||
{STRINGS.learn.lessonDone}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
/* Этап 11: примечание об открытом редактировании — не кричащее. */
|
||||
.disclaimer {
|
||||
margin-top: 14px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Пометки «пройдено» — из localStorage, поэтому только на клиенте.
|
||||
import { loadProgress } from '../lib/storage';
|
||||
|
||||
const { progress } = loadProgress();
|
||||
for (const card of document.querySelectorAll('[data-lesson-id]')) {
|
||||
const id = (card as HTMLElement).dataset['lessonId'] ?? '';
|
||||
if (progress.lessons[id]?.done === true) {
|
||||
card.querySelector('.done-badge')?.removeAttribute('hidden');
|
||||
// Класс под зелёную рамку пройденной карточки (этап 10, .lesson-card.is-done).
|
||||
card.classList.add('is-done');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
140
apps/web/src/pages/learn/[id].astro
Normal file
140
apps/web/src/pages/learn/[id].astro
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import { STRINGS } from '../../ui/strings';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const lessons = await getCollection('lessons');
|
||||
return lessons.map((lesson) => ({ params: { id: lesson.id }, props: { lesson } }));
|
||||
}
|
||||
|
||||
const { lesson } = Astro.props;
|
||||
const { Content } = await render(lesson);
|
||||
|
||||
// Навигация prev/next и кикер «урок N из M» — по порядку прохождения (этап 10).
|
||||
// Порядок = числовой префикс id файла: поле order во frontmatter у всех 1.
|
||||
const ordered = (await getCollection('lessons')).sort((a, b) => a.id.localeCompare(b.id));
|
||||
const index = ordered.findIndex((entry) => entry.id === lesson.id);
|
||||
const prev = index > 0 ? ordered[index - 1] : undefined;
|
||||
const next = index >= 0 && index < ordered.length - 1 ? ordered[index + 1] : undefined;
|
||||
|
||||
// Задачи, встроенные в тело урока элементами <go-task data-task-id="…">:
|
||||
// отдаём их данные клиенту JSON-островком для гидратации виджетов.
|
||||
const taskIds = [...(lesson.body ?? '').matchAll(/data-task-id="([^"]+)"/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
const allTasks = (await getCollection('tasks')).flatMap((entry) => entry.data);
|
||||
const tasks = taskIds
|
||||
.map((id) => allTasks.find((task) => task.id === id))
|
||||
.filter((task) => task !== undefined);
|
||||
const island = JSON.stringify({ tasks }).replace(/</g, '\\u003c');
|
||||
---
|
||||
|
||||
<Layout title={`${lesson.data.title} — ${STRINGS.site.title}`} section="learn">
|
||||
<div class="page-head">
|
||||
<div class="container container--narrow">
|
||||
<!-- этап 10: кикер и подписи навигации — текст прямо в разметке до переноса в strings.ts -->
|
||||
<p class="kicker">урок {index + 1} из {ordered.length}</p>
|
||||
<h1>{lesson.data.title}</h1>
|
||||
<p>{lesson.data.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<article class="lesson-body" data-lesson-id={lesson.id}>
|
||||
<Content />
|
||||
<div class="lesson-nav">
|
||||
{
|
||||
prev ? (
|
||||
<a class="btn btn--quiet" href={`/learn/${prev.id}.html`}>
|
||||
← {prev.data.title}
|
||||
</a>
|
||||
) : (
|
||||
<a class="btn btn--quiet" href="/learn.html">
|
||||
← Все уроки
|
||||
</a>
|
||||
)
|
||||
}
|
||||
<button
|
||||
id="btn-lesson-done"
|
||||
class="btn btn--primary"
|
||||
type="button"
|
||||
data-lesson-id={lesson.id}
|
||||
>
|
||||
{STRINGS.learn.markDone}
|
||||
</button>
|
||||
{
|
||||
next ? (
|
||||
<a class="btn" href={`/learn/${next.id}.html`}>
|
||||
{next.data.title} →
|
||||
</a>
|
||||
) : (
|
||||
<a class="btn" href="/tsumego.html">
|
||||
К цумэ-го →
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<p id="lesson-done-status" class="lesson-done-status" role="status" aria-live="polite"></p>
|
||||
</article>
|
||||
<script
|
||||
type="application/json"
|
||||
id="go-task-data"
|
||||
data-lesson-id={lesson.id}
|
||||
set:html={island}
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { hydrateTasks } from '../../ui/task-widget';
|
||||
import { loadProgress, markLessonDone, saveProgress } from '../../lib/storage';
|
||||
import { STRINGS } from '../../ui/strings';
|
||||
|
||||
hydrateTasks(document);
|
||||
|
||||
// Ручная отметка «урок пройден» (этап 7): для теоретических уроков без
|
||||
// go-task; авто-done по задачам (этап 6) не трогаем.
|
||||
const doneButton = document.getElementById('btn-lesson-done');
|
||||
const doneStatus = document.getElementById('lesson-done-status');
|
||||
const lessonId =
|
||||
doneButton instanceof HTMLElement ? (doneButton.dataset['lessonId'] ?? '') : '';
|
||||
|
||||
function renderDoneState(): void {
|
||||
if (!(doneButton instanceof HTMLButtonElement) || doneStatus === null) return;
|
||||
const { progress } = loadProgress();
|
||||
const done = progress.lessons[lessonId]?.done === true;
|
||||
doneButton.hidden = done;
|
||||
doneStatus.textContent = done ? STRINGS.learn.doneState : '';
|
||||
}
|
||||
|
||||
if (doneButton instanceof HTMLButtonElement) {
|
||||
doneButton.addEventListener('click', () => {
|
||||
const now = new Date();
|
||||
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(now.getDate()).padStart(2, '0');
|
||||
const today = `${now.getFullYear()}-${mm}-${dd}`;
|
||||
const { progress } = loadProgress();
|
||||
saveProgress(markLessonDone(progress, lessonId, today));
|
||||
renderDoneState();
|
||||
});
|
||||
}
|
||||
renderDoneState();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Заголовок урока уже показан в page-head; дублирующий H1 из тела
|
||||
markdown не выводим (этап 10). :global — разметка <Content/> не
|
||||
получает scope-атрибут scoped-стилей Astro. */
|
||||
.lesson-body > :global(h1:first-child) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Статус «Пройден» под навигацией урока (этап 10, светлые токены «васи»). */
|
||||
.lesson-done-status {
|
||||
margin: 0.6rem 0 0;
|
||||
color: var(--ok);
|
||||
font-weight: 700;
|
||||
min-height: 1.5em;
|
||||
}
|
||||
</style>
|
||||
187
apps/web/src/pages/password-reset.astro
Normal file
187
apps/web/src/pages/password-reset.astro
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { STRINGS } from '../ui/strings';
|
||||
|
||||
const s = STRINGS.passwordReset;
|
||||
---
|
||||
|
||||
<Layout title={`${s.title} — ${STRINGS.site.title}`} section="account">
|
||||
<div class="container">
|
||||
<!-- Два режима по query (этап 8.1): без ?token= — запрос письма на email;
|
||||
с ?token=… — установка нового пароля по токену из письма. Обе формы
|
||||
в статике, нужную показывает скрипт. -->
|
||||
<div id="reset-request" class="card auth-card" hidden>
|
||||
<h1>{s.requestHeading}</h1>
|
||||
<form id="reset-request-form">
|
||||
<div class="field">
|
||||
<label for="reset-email">{s.emailLabel}</label>
|
||||
<input id="reset-email" name="email" type="email" required autocomplete="email" />
|
||||
<span class="error-text">{s.statusUnavailable}</span>
|
||||
</div>
|
||||
<button class="btn btn--primary auth-submit" type="submit">{s.requestSubmit}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="reset-confirm" class="card auth-card" hidden>
|
||||
<h1>{s.confirmHeading}</h1>
|
||||
<form id="reset-confirm-form">
|
||||
<div class="field">
|
||||
<label for="reset-password">{s.passwordLabel}</label>
|
||||
<input
|
||||
id="reset-password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
aria-describedby="reset-password-hint"
|
||||
/>
|
||||
<p id="reset-password-hint" class="auth-hint">{s.passwordHint}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="reset-password-repeat">{s.passwordRepeatLabel}</label>
|
||||
<input
|
||||
id="reset-password-repeat"
|
||||
name="password-repeat"
|
||||
type="password"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<span class="error-text">{s.statusPasswordMismatch}</span>
|
||||
</div>
|
||||
<button class="btn btn--primary auth-submit" type="submit">{s.confirmSubmit}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p id="reset-status" class="auth-status" role="status" aria-live="polite"></p>
|
||||
<div class="auth-links auth-links--page">
|
||||
<span id="reset-login-link" hidden><a href="/account.html">{s.loginLink}</a></span>
|
||||
<span id="reset-again-link" hidden>
|
||||
<a href="/password-reset.html">{s.requestAgainLink}</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { confirmPasswordReset, requestPasswordReset } from '../lib/sync-client';
|
||||
import type {
|
||||
PasswordResetConfirmResult,
|
||||
PasswordResetRequestResult,
|
||||
} from '../lib/sync-client';
|
||||
import { STRINGS } from '../ui/strings';
|
||||
|
||||
const s = STRINGS.passwordReset;
|
||||
|
||||
const status = document.getElementById('reset-status');
|
||||
const requestBlock = document.getElementById('reset-request');
|
||||
const confirmBlock = document.getElementById('reset-confirm');
|
||||
const loginLink = document.getElementById('reset-login-link');
|
||||
const againLink = document.getElementById('reset-again-link');
|
||||
|
||||
const token = new URLSearchParams(window.location.search).get('token') ?? '';
|
||||
|
||||
function setStatus(text: string): void {
|
||||
if (status !== null) status.textContent = text;
|
||||
}
|
||||
|
||||
function toggle(element: HTMLElement | null, show: boolean): void {
|
||||
element?.toggleAttribute('hidden', !show);
|
||||
}
|
||||
|
||||
function field(form: HTMLElement | null, id: string): string {
|
||||
const input = form?.querySelector(`#${id}`);
|
||||
return input instanceof HTMLInputElement ? input.value.trim() : '';
|
||||
}
|
||||
|
||||
function requestMessage(result: PasswordResetRequestResult): string {
|
||||
switch (result) {
|
||||
case 'mail_unavailable':
|
||||
return s.statusMailUnavailable;
|
||||
case 'rate_limited':
|
||||
return s.statusRateLimited;
|
||||
default:
|
||||
return s.statusUnavailable;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmMessage(result: PasswordResetConfirmResult): string {
|
||||
switch (result) {
|
||||
case 'token_invalid':
|
||||
return s.statusTokenInvalid;
|
||||
case 'invalid':
|
||||
return s.statusInvalid;
|
||||
case 'rate_limited':
|
||||
return s.statusRateLimited;
|
||||
default:
|
||||
return s.statusUnavailable;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('reset-request-form')?.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const email = field(form instanceof HTMLElement ? form : null, 'reset-email');
|
||||
void (async () => {
|
||||
const result = await requestPasswordReset(email);
|
||||
// 200 приходит всегда (без enumeration) — единый текст об успехе.
|
||||
setStatus(result === 'ok' ? s.requestDone : requestMessage(result));
|
||||
})();
|
||||
});
|
||||
|
||||
document.getElementById('reset-confirm-form')?.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const host = form instanceof HTMLElement ? form : null;
|
||||
const password = field(host, 'reset-password');
|
||||
const repeat = field(host, 'reset-password-repeat');
|
||||
if (password !== repeat) {
|
||||
setStatus(s.statusPasswordMismatch);
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
const result = await confirmPasswordReset(token, password);
|
||||
if (result === 'ok') {
|
||||
setStatus(s.confirmDone);
|
||||
toggle(loginLink, true); // «Войти» → /account.html
|
||||
toggle(confirmBlock, false);
|
||||
return;
|
||||
}
|
||||
setStatus(confirmMessage(result));
|
||||
if (result === 'token_invalid') toggle(againLink, true); // запросить новую ссылку
|
||||
})();
|
||||
});
|
||||
|
||||
// Выбор режима по query: ?token=… → новая пара паролей, иначе — email.
|
||||
toggle(requestBlock, token === '');
|
||||
toggle(confirmBlock, token !== '');
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.auth-hint {
|
||||
margin: 6px 0 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.auth-submit {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.auth-status {
|
||||
max-width: 440px;
|
||||
min-height: 1.5em;
|
||||
margin: 18px auto 0;
|
||||
text-align: center;
|
||||
color: var(--accent-strong);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Ссылки под карточкой: та же колонка, что и .auth-card. */
|
||||
.auth-links--page {
|
||||
max-width: 440px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
</style>
|
||||
175
apps/web/src/pages/play.astro
Normal file
175
apps/web/src/pages/play.astro
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { STRINGS } from '../ui/strings';
|
||||
|
||||
const s = STRINGS;
|
||||
---
|
||||
|
||||
<Layout title={`${s.site.playLink} — ${s.site.title}`} section="play">
|
||||
<div class="page-head">
|
||||
<div class="container">
|
||||
<p class="kicker">партия против компьютера</p>
|
||||
<h1>{s.site.playLink}</h1>
|
||||
<p>
|
||||
Нажмите на пересечение, чтобы поставить камень. ИИ играет по правилам с захватом, запретом
|
||||
самоубийства и простым ко.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="play-layout">
|
||||
<div class="board-wrap">
|
||||
<canvas id="board" role="img" aria-label={s.aria.board}></canvas>
|
||||
<p id="warning" class="warning" hidden></p>
|
||||
</div>
|
||||
|
||||
<aside class="play-panel">
|
||||
<div class="card">
|
||||
<h2>{s.panel.settingsHeading}</h2>
|
||||
<div class="settings-grid">
|
||||
<div class="field">
|
||||
<label for="opt-size">{s.panel.size}</label>
|
||||
<select id="opt-size">
|
||||
<option value="9">9×9</option>
|
||||
<option value="13">13×13</option>
|
||||
<option value="19">19×19</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="opt-level">{s.panel.level}</label>
|
||||
<select id="opt-level">
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5">5</option>
|
||||
<option value="6">6</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="opt-color">{s.panel.playerColor}</label>
|
||||
<select id="opt-color">
|
||||
<option value="black">{s.panel.colorBlack}</option>
|
||||
<option value="white">{s.panel.colorWhite}</option>
|
||||
<option value="random">{s.panel.colorRandom}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="opt-komi">{s.panel.komi}</label>
|
||||
<input id="opt-komi" type="number" step="0.5" min="-50" max="50" value="6.5" />
|
||||
</div>
|
||||
<div class="field field--check">
|
||||
<input id="opt-komi-auto" type="checkbox" checked />
|
||||
<label for="opt-komi-auto">{s.panel.komiAuto}</label>
|
||||
</div>
|
||||
<div class="field field--check">
|
||||
<input id="opt-coords" type="checkbox" checked />
|
||||
<label for="opt-coords">{s.panel.showCoordinates}</label>
|
||||
</div>
|
||||
<div class="field field--check">
|
||||
<input id="opt-numbers" type="checkbox" />
|
||||
<label for="opt-numbers">{s.panel.showMoveNumbers}</label>
|
||||
</div>
|
||||
<div class="field field--check">
|
||||
<input id="opt-hints" type="checkbox" />
|
||||
<label for="opt-hints">{s.panel.hintsEnabled}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>{s.panel.captures}</h2>
|
||||
<!-- Камни — статичная легенда; счёт пишет play-page.ts в #captures. -->
|
||||
<div class="captures" aria-hidden="true">
|
||||
<span class="side"><span class="stone-dot black"></span> {s.panel.capturesBlack}</span>
|
||||
<span class="side"><span class="stone-dot white"></span> {s.panel.capturesWhite}</span>
|
||||
</div>
|
||||
<p id="captures" class="captures-line"></p>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status-line" data-status role="status"></div>
|
||||
|
||||
<div class="card">
|
||||
<h2>{s.panel.hintsEnabled}</h2>
|
||||
<ul class="hints-list">
|
||||
<li>Лучший ход подсвечивается на доске, его оценка — в строке статуса.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Управление</h2>
|
||||
<div class="btn-row">
|
||||
<button id="btn-new" class="btn btn--small btn--primary">{s.buttons.newGame}</button>
|
||||
<button id="btn-pass" class="btn btn--small">{s.buttons.pass}</button>
|
||||
<button id="btn-undo" class="btn btn--small btn--quiet">{s.buttons.undo}</button>
|
||||
<button id="btn-resign" class="btn btn--small btn--danger">{s.buttons.resign}</button>
|
||||
<button id="btn-score" class="btn btn--small btn--primary" hidden>
|
||||
{s.buttons.confirmScore}
|
||||
</button>
|
||||
<button id="btn-download" class="btn btn--small">{s.buttons.downloadSgf}</button>
|
||||
<button id="btn-load" class="btn btn--small">{s.buttons.loadSgf}</button>
|
||||
</div>
|
||||
<div id="viewer-bar" class="btn-row viewer-bar" hidden>
|
||||
<button id="btn-prev" class="btn btn--small">{s.buttons.viewerPrev}</button>
|
||||
<button id="btn-next" class="btn btn--small">{s.buttons.viewerNext}</button>
|
||||
<button id="btn-exit-viewer" class="btn btn--small btn--quiet">
|
||||
{s.buttons.viewerExit}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<input id="sgf-file" type="file" accept=".sgf" aria-label={s.aria.sgfFile} hidden />
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { bootPlayPage } from '../ui/play-page';
|
||||
bootPlayPage();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Доска: канвас масштабируется под ширину .board-wrap. */
|
||||
.board-wrap canvas {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 12px;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.warning {
|
||||
margin: 12px 0 0;
|
||||
color: var(--err);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Чекбоксы настроек — строкой, без растягивания на всю ширину поля. */
|
||||
.field--check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field--check input[type='checkbox'] {
|
||||
width: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.field--check label {
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.captures-line {
|
||||
margin: 10px 0 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.viewer-bar {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
153
apps/web/src/pages/progress.astro
Normal file
153
apps/web/src/pages/progress.astro
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { STRINGS } from '../ui/strings';
|
||||
|
||||
const s = STRINGS.progressPage;
|
||||
const lessons = await getCollection('lessons');
|
||||
const allTasks = (await getCollection('tasks')).flatMap((entry) => entry.data);
|
||||
const totals = {
|
||||
lessons: lessons.length,
|
||||
lessonTasks: allTasks.filter((task) => task.kind === 'lesson').length,
|
||||
tsumegoTasks: allTasks.filter((task) => task.kind === 'tsumego').length,
|
||||
};
|
||||
const island = JSON.stringify(totals).replace(/</g, '\\u003c');
|
||||
---
|
||||
|
||||
<Layout title={`${s.title} — ${STRINGS.site.title}`} section="progress">
|
||||
<div class="page-head">
|
||||
<div class="container">
|
||||
<!-- этап 10: кикер, вводный абзац, подписи карточек и кнопок — текст прямо
|
||||
в разметке до переноса в strings.ts -->
|
||||
<p class="kicker">тихий счёт упорству</p>
|
||||
<h1>{s.title}</h1>
|
||||
<p>
|
||||
Всё считается локально, на этом устройстве. Ничего не отправляется на сервер без вашей
|
||||
команды — поэтому цифры честные.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="stats-grid">
|
||||
<div class="card stat-card">
|
||||
<div class="value" id="stat-lessons"></div>
|
||||
<div class="label">уроков пройдено</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="value" id="stat-lesson-tasks"></div>
|
||||
<div class="label">задач в уроках решено</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="value" id="stat-tsumego-tasks"></div>
|
||||
<div class="label">цумэ-го решено</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="value" id="stat-streak"></div>
|
||||
<div class="label">дней подряд</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card course-progress">
|
||||
<h2>Путь по курсу</h2>
|
||||
<div
|
||||
class="progress-track"
|
||||
id="course-progress-track"
|
||||
role="progressbar"
|
||||
aria-label="Прогресс курса"
|
||||
>
|
||||
<div class="progress-fill" id="course-progress-fill"></div>
|
||||
</div>
|
||||
<p class="progress-note" id="course-progress-note"></p>
|
||||
<p class="progress-note">
|
||||
Десять минут в день надёжнее трёх часов раз в месяц: Го растёт на регулярности, а не на
|
||||
подвигах.
|
||||
</p>
|
||||
<div class="btn-row">
|
||||
<a class="btn btn--primary" href="/learn.html">Продолжить курс</a>
|
||||
<a class="btn btn--quiet" href="/tsumego.html">К задачам</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="stat-recovered" class="recovered" hidden>{s.recovered}</p>
|
||||
<script type="application/json" id="progress-totals" set:html={island} />
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { loadProgress } from '../lib/storage';
|
||||
import { me, syncProgress } from '../lib/sync-client';
|
||||
|
||||
// Тихий авто-sync при активной сессии (этап 8): до отрисовки цифр,
|
||||
// чтобы pulled-прогресс сразу отобразился. Ошибки молча игнорируем —
|
||||
// пометка о недоступности живёт на странице кабинета (ADR-0004).
|
||||
try {
|
||||
if ((await me()) !== null) await syncProgress();
|
||||
} catch {
|
||||
/* sync недоступен — работаем на локальном прогрессе */
|
||||
}
|
||||
|
||||
const totals = JSON.parse(
|
||||
document.getElementById('progress-totals')?.textContent ?? '{}',
|
||||
) as { lessons: number; lessonTasks: number; tsumegoTasks: number };
|
||||
const { progress, recovered } = loadProgress();
|
||||
|
||||
const lessonsDone = Object.values(progress.lessons).filter((l) => l.done).length;
|
||||
// Решённые задачи по видам: id с префиксом lsn- — задачи уроков, tsm- — цумэ-го.
|
||||
const solvedIds = Object.entries(progress.tasks)
|
||||
.filter(([, value]) => value.solved)
|
||||
.map(([id]) => id);
|
||||
const lessonTasksDone = solvedIds.filter((id) => id.startsWith('lsn-')).length;
|
||||
const tsumegoTasksDone = solvedIds.filter((id) => id.startsWith('tsm-')).length;
|
||||
|
||||
// Карточки статистики: в .value — только числа, подписи статичны в разметке
|
||||
// (этап 10; шаблоны STRINGS.progressPage.* заменены карточками .value/.label).
|
||||
const fill = (id: string, done: number, total: number): void => {
|
||||
const element = document.getElementById(id);
|
||||
if (element !== null) element.textContent = `${done} / ${total}`;
|
||||
};
|
||||
fill('stat-lessons', lessonsDone, totals.lessons);
|
||||
fill('stat-lesson-tasks', lessonTasksDone, totals.lessonTasks);
|
||||
fill('stat-tsumego-tasks', tsumegoTasksDone, totals.tsumegoTasks);
|
||||
const streakElement = document.getElementById('stat-streak');
|
||||
if (streakElement !== null) {
|
||||
streakElement.textContent = String(progress.streak.current);
|
||||
}
|
||||
|
||||
// Полоса «Путь по курсу»: доля пройденных уроков (этап 10).
|
||||
const pct = totals.lessons > 0 ? Math.round((lessonsDone / totals.lessons) * 100) : 0;
|
||||
const track = document.getElementById('course-progress-track');
|
||||
if (track !== null) track.setAttribute('aria-valuenow', String(pct));
|
||||
const bar = document.getElementById('course-progress-fill');
|
||||
if (bar !== null) bar.style.width = `${pct}%`;
|
||||
const note = document.getElementById('course-progress-note');
|
||||
// этап 10: строка прямо в скрипте до переноса в strings.ts
|
||||
if (note !== null) note.textContent = `Курс пройден на ${pct}%`;
|
||||
|
||||
document.getElementById('stat-recovered')?.toggleAttribute('hidden', !recovered);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Четыре карточки статистики в ряд на широких экранах (этап 10). */
|
||||
@media (min-width: 900px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.course-progress {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.course-progress h2 {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.course-progress .btn-row {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.recovered {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
</style>
|
||||
199
apps/web/src/pages/register.astro
Normal file
199
apps/web/src/pages/register.astro
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { STRINGS } from '../ui/strings';
|
||||
|
||||
const s = STRINGS.register;
|
||||
---
|
||||
|
||||
<Layout title={`${s.title} — ${STRINGS.site.title}`} section="account">
|
||||
<div class="container">
|
||||
<!-- Регистрация: email, пароль ×2, автономная SVG-капча (этап 8.1).
|
||||
Нативная валидация — клавиатурная доступность из коробки. -->
|
||||
<div class="card auth-card">
|
||||
<h1>{s.heading}</h1>
|
||||
<p class="auth-lead">
|
||||
Одна учётная запись — чтобы прогресс жил на всех устройствах. Никакой рассылки.
|
||||
</p>
|
||||
<form id="register-form">
|
||||
<div class="field">
|
||||
<label for="register-email">{s.emailLabel}</label>
|
||||
<input id="register-email" name="email" type="email" required autocomplete="email" />
|
||||
<span class="error-text">{s.statusInvalid}</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="register-password">{s.passwordLabel}</label>
|
||||
<input
|
||||
id="register-password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
aria-describedby="register-password-hint"
|
||||
/>
|
||||
<p id="register-password-hint" class="auth-hint">{s.passwordHint}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="register-password-repeat">{s.passwordRepeatLabel}</label>
|
||||
<input
|
||||
id="register-password-repeat"
|
||||
name="password-repeat"
|
||||
type="password"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<span class="error-text">{s.statusPasswordMismatch}</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="register-captcha">{s.captchaLabel}</label>
|
||||
<div class="captcha-row">
|
||||
<img
|
||||
id="register-captcha-img"
|
||||
class="captcha-box"
|
||||
src="/api/captcha.php"
|
||||
alt={s.captchaAlt}
|
||||
width="200"
|
||||
height="60"
|
||||
/>
|
||||
<button
|
||||
id="register-captcha-refresh"
|
||||
class="btn btn--quiet btn--small"
|
||||
type="button"
|
||||
aria-label={s.captchaRefresh}
|
||||
>
|
||||
↻ {s.captchaRefresh}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
id="register-captcha"
|
||||
name="captcha"
|
||||
type="text"
|
||||
required
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<span class="error-text">{s.statusCaptcha}</span>
|
||||
</div>
|
||||
<button class="btn btn--primary auth-submit" type="submit">{s.submit}</button>
|
||||
</form>
|
||||
<div class="auth-links">
|
||||
<span><a href="/account.html">{s.loginLink}</a></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="register-status" class="auth-status" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { login, register } from '../lib/sync-client';
|
||||
import type { RegisterResult } from '../lib/sync-client';
|
||||
import { STRINGS } from '../ui/strings';
|
||||
|
||||
const s = STRINGS.register;
|
||||
|
||||
const status = document.getElementById('register-status');
|
||||
const captchaImg = document.getElementById('register-captcha-img');
|
||||
const captchaInput = document.getElementById('register-captcha');
|
||||
|
||||
function setStatus(text: string): void {
|
||||
if (status !== null) status.textContent = text;
|
||||
}
|
||||
|
||||
/** Перезапрос картинки с cache-buster; код сжигается любой попыткой. */
|
||||
function refreshCaptcha(): void {
|
||||
if (captchaImg instanceof HTMLImageElement) {
|
||||
captchaImg.src = `/api/captcha.php?t=${Date.now()}`;
|
||||
}
|
||||
if (captchaInput instanceof HTMLInputElement) captchaInput.value = '';
|
||||
}
|
||||
|
||||
function registerMessage(result: RegisterResult): string {
|
||||
switch (result) {
|
||||
case 'conflict':
|
||||
return s.statusConflict;
|
||||
case 'captcha':
|
||||
return s.statusCaptcha;
|
||||
case 'invalid':
|
||||
return s.statusInvalid;
|
||||
case 'rate-limited':
|
||||
return s.statusRateLimited;
|
||||
default:
|
||||
return s.statusUnavailable;
|
||||
}
|
||||
}
|
||||
|
||||
function field(form: HTMLElement | null, id: string): string {
|
||||
const input = form?.querySelector(`#${id}`);
|
||||
return input instanceof HTMLInputElement ? input.value.trim() : '';
|
||||
}
|
||||
|
||||
document.getElementById('register-captcha-refresh')?.addEventListener('click', () => {
|
||||
refreshCaptcha();
|
||||
});
|
||||
|
||||
document.getElementById('register-form')?.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const host = form instanceof HTMLElement ? form : null;
|
||||
const email = field(host, 'register-email');
|
||||
const password = field(host, 'register-password');
|
||||
const repeat = field(host, 'register-password-repeat');
|
||||
const captcha = field(host, 'register-captcha');
|
||||
if (password !== repeat) {
|
||||
// Клиентская проверка: сервер не дергали, капчу не тратим.
|
||||
setStatus(s.statusPasswordMismatch);
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
const result = await register(email, password, captcha);
|
||||
if (result !== 'ok') {
|
||||
setStatus(registerMessage(result));
|
||||
refreshCaptcha(); // любая попытка сжигает код — показываем новый
|
||||
return;
|
||||
}
|
||||
// Сервер сессию при регистрации не ставит: авто-вход теми же данными.
|
||||
const entered = await login(email, password);
|
||||
if (entered !== 'ok') {
|
||||
setStatus(entered === 'rate-limited' ? s.statusRateLimited : s.statusUnavailable);
|
||||
return;
|
||||
}
|
||||
window.location.assign('/account.html');
|
||||
})();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.auth-lead {
|
||||
margin: 0 0 18px;
|
||||
text-align: center;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.auth-hint {
|
||||
margin: 6px 0 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.auth-submit {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Отступ поля ввода кода от строки с картинкой. */
|
||||
.captcha-row {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.auth-status {
|
||||
max-width: 440px;
|
||||
min-height: 1.5em;
|
||||
margin: 18px auto 0;
|
||||
text-align: center;
|
||||
color: var(--accent-strong);
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
177
apps/web/src/pages/tsumego.astro
Normal file
177
apps/web/src/pages/tsumego.astro
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { format, STRINGS } from '../ui/strings';
|
||||
|
||||
const s = STRINGS.tsumego;
|
||||
const allTasks = (await getCollection('tasks')).flatMap((entry) => entry.data);
|
||||
const tasks = allTasks.filter((task) => task.kind === 'tsumego');
|
||||
const topics = [...new Set(tasks.flatMap((task) => task.topics))].sort((a, b) =>
|
||||
a.localeCompare(b, 'ru'),
|
||||
);
|
||||
const island = JSON.stringify({ tasks }).replace(/</g, '\\u003c');
|
||||
---
|
||||
|
||||
<Layout title={`${s.title} — ${STRINGS.site.title}`} section="tsumego">
|
||||
<div class="page-head">
|
||||
<div class="container">
|
||||
<!-- этап 10: кикер и вводный абзац — текст прямо в разметке до переноса в strings.ts -->
|
||||
<p class="kicker">жизнь и смерть в миниатюре</p>
|
||||
<h1>{s.title}</h1>
|
||||
<p>
|
||||
Задачи на локальную тактику: раскройте задачу и решите её прямо на доске. Фильтры по теме,
|
||||
сложности и статусу помогают добраться до нерешённого.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="card filters">
|
||||
<div class="field">
|
||||
<label for="filter-topic">{s.filterTopic}</label>
|
||||
<select id="filter-topic">
|
||||
<option value="">{s.allTopics}</option>
|
||||
{topics.map((topic) => <option value={topic}>{topic}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="filter-difficulty">{s.filterDifficulty}</label>
|
||||
<select id="filter-difficulty">
|
||||
<option value="">{s.allDifficulties}</option>
|
||||
{[1, 2, 3, 4, 5].map((level) => <option value={level}>{level}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="filter-status">{s.filterStatus}</label>
|
||||
<select id="filter-status">
|
||||
<option value="all">{s.statusAll}</option>
|
||||
<option value="solved">{s.statusSolved}</option>
|
||||
<option value="unsolved">{s.statusUnsolved}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p id="filter-empty" class="empty-state" hidden>{s.empty}</p>
|
||||
<div id="task-list">
|
||||
{
|
||||
tasks.map((task) => (
|
||||
<details
|
||||
class="task-item"
|
||||
data-task-id={task.id}
|
||||
data-topics={task.topics.join('|')}
|
||||
data-difficulty={String(task.difficulty)}
|
||||
>
|
||||
<summary>
|
||||
<span class="task-title">{task.title}</span>
|
||||
<span class="task-meta">
|
||||
{format(s.difficultyLabel, { level: String(task.difficulty) })}
|
||||
<span class="solved-badge" hidden>
|
||||
{s.solvedBadge}
|
||||
</span>
|
||||
</span>
|
||||
</summary>
|
||||
<go-task data-task-id={task.id}>
|
||||
Запасной текст без JS: откройте задачу в браузере с включённым JavaScript.
|
||||
</go-task>
|
||||
</details>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<script type="application/json" id="go-task-data" set:html={island} />
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { loadProgress } from '../lib/storage';
|
||||
import { hydrateTasks } from '../ui/task-widget';
|
||||
|
||||
const topicSelect = document.getElementById('filter-topic') as HTMLSelectElement;
|
||||
const difficultySelect = document.getElementById('filter-difficulty') as HTMLSelectElement;
|
||||
const statusSelect = document.getElementById('filter-status') as HTMLSelectElement;
|
||||
const emptyNote = document.getElementById('filter-empty') as HTMLElement;
|
||||
const items = [...document.querySelectorAll<HTMLElement>('.task-item')];
|
||||
|
||||
function applyFilters(): void {
|
||||
const { progress } = loadProgress();
|
||||
const topic = topicSelect.value;
|
||||
const difficulty = difficultySelect.value;
|
||||
const status = statusSelect.value;
|
||||
let visible = 0;
|
||||
for (const item of items) {
|
||||
const id = item.dataset['taskId'] ?? '';
|
||||
const solved = progress.tasks[id]?.solved === true;
|
||||
item.querySelector('.solved-badge')?.toggleAttribute('hidden', !solved);
|
||||
const topics = (item.dataset['topics'] ?? '').split('|');
|
||||
const matchesTopic = topic === '' || topics.includes(topic);
|
||||
const matchesDifficulty = difficulty === '' || item.dataset['difficulty'] === difficulty;
|
||||
const matchesStatus =
|
||||
status === 'all' || (status === 'solved' ? solved : !solved);
|
||||
const show = matchesTopic && matchesDifficulty && matchesStatus;
|
||||
item.toggleAttribute('hidden', !show);
|
||||
if (show) visible += 1;
|
||||
}
|
||||
emptyNote.toggleAttribute('hidden', visible > 0);
|
||||
}
|
||||
|
||||
for (const select of [topicSelect, difficultySelect, statusSelect]) {
|
||||
select.addEventListener('change', applyFilters);
|
||||
}
|
||||
for (const item of items) {
|
||||
// Ленивая гидратация: виджет строится при первом раскрытии задачи.
|
||||
item.addEventListener('toggle', () => {
|
||||
if ((item as HTMLDetailsElement).open) hydrateTasks(item);
|
||||
});
|
||||
}
|
||||
applyFilters();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Список задач — раскрывающиеся карточки (этап 10, светлые токены «васи»). */
|
||||
#task-list {
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 0.9rem;
|
||||
padding: 0.9rem 1.25rem;
|
||||
}
|
||||
|
||||
.task-item summary {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Бейдж «решена» — зелёная пилюля по паттерну .badge--done. */
|
||||
.solved-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 0.5rem;
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--ok-soft);
|
||||
color: var(--ok);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Виджет задачи внутри карточки — без собственной рамки. */
|
||||
.task-item go-task {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0.5rem 0 0;
|
||||
background: none;
|
||||
}
|
||||
</style>
|
||||
419
apps/web/src/ui/play-page.ts
Normal file
419
apps/web/src/ui/play-page.ts
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
/**
|
||||
* DOM-привязка страницы /play: канва (render из @go-learn/board), панель
|
||||
* управления и статус-строка ↔ lib/game-store.ts. Вся логика партии — в
|
||||
* сторе; здесь только DOM: события, чтение контролов, отрисовка кадра.
|
||||
* Строки — только из ui/strings.ts.
|
||||
*/
|
||||
import {
|
||||
attachPointerInput,
|
||||
computeGeometry,
|
||||
pixelToPoint,
|
||||
pointToPixel,
|
||||
render,
|
||||
setupCanvas,
|
||||
} from '@go-learn/board';
|
||||
import type { BoardGeometry, RenderOptions } from '@go-learn/board';
|
||||
import { findNode, pointKey, serializeSgf } from '@go-learn/core';
|
||||
import type { BoardSize, GameNode, GameTree, Point } from '@go-learn/core';
|
||||
import { createAiClient } from '../lib/ai-client.js';
|
||||
import { createGameStore } from '../lib/game-store.js';
|
||||
import type { GameStore, GameStoreState } from '../lib/game-store.js';
|
||||
import { addCursor, arrowOf, centerCursor, clampCursor, reduceCursor } from '../lib/keyboard.js';
|
||||
import { loadSettings, saveSettings } from '../lib/storage.js';
|
||||
import type { SettingsV1 } from '../lib/storage.js';
|
||||
import { territorySets } from '../lib/territory.js';
|
||||
import { format, STRINGS } from './strings.js';
|
||||
|
||||
interface PageElements {
|
||||
canvas: HTMLCanvasElement;
|
||||
status: HTMLElement;
|
||||
warning: HTMLElement;
|
||||
captures: HTMLElement;
|
||||
optSize: HTMLSelectElement;
|
||||
optLevel: HTMLSelectElement;
|
||||
optColor: HTMLSelectElement;
|
||||
optKomiAuto: HTMLInputElement;
|
||||
optKomi: HTMLInputElement;
|
||||
optCoords: HTMLInputElement;
|
||||
optNumbers: HTMLInputElement;
|
||||
optHints: HTMLInputElement;
|
||||
btnNew: HTMLButtonElement;
|
||||
btnPass: HTMLButtonElement;
|
||||
btnResign: HTMLButtonElement;
|
||||
btnUndo: HTMLButtonElement;
|
||||
btnScore: HTMLButtonElement;
|
||||
btnDownload: HTMLButtonElement;
|
||||
btnLoad: HTMLButtonElement;
|
||||
btnPrev: HTMLButtonElement;
|
||||
btnNext: HTMLButtonElement;
|
||||
btnExitViewer: HTMLButtonElement;
|
||||
viewerBar: HTMLElement;
|
||||
sgfFile: HTMLInputElement;
|
||||
}
|
||||
|
||||
interface PageState {
|
||||
geo: BoardGeometry | null;
|
||||
recoveredNote: boolean; // настройки были восстановлены из дефолтов
|
||||
cursor: Point | null; // клавиатурный курсор-крестик (этап 7)
|
||||
}
|
||||
|
||||
function byId<T extends HTMLElement>(id: string): T {
|
||||
const element = document.getElementById(id);
|
||||
if (element === null) throw new Error(`play-page: элемент #${id} не найден`);
|
||||
return element as T;
|
||||
}
|
||||
|
||||
function collectElements(): PageElements {
|
||||
return {
|
||||
canvas: byId('board'),
|
||||
status: byId('status'),
|
||||
warning: byId('warning'),
|
||||
captures: byId('captures'),
|
||||
optSize: byId('opt-size'),
|
||||
optLevel: byId('opt-level'),
|
||||
optColor: byId('opt-color'),
|
||||
optKomiAuto: byId('opt-komi-auto'),
|
||||
optKomi: byId('opt-komi'),
|
||||
optCoords: byId('opt-coords'),
|
||||
optNumbers: byId('opt-numbers'),
|
||||
optHints: byId('opt-hints'),
|
||||
btnNew: byId('btn-new'),
|
||||
btnPass: byId('btn-pass'),
|
||||
btnResign: byId('btn-resign'),
|
||||
btnUndo: byId('btn-undo'),
|
||||
btnScore: byId('btn-score'),
|
||||
btnDownload: byId('btn-download'),
|
||||
btnLoad: byId('btn-load'),
|
||||
btnPrev: byId('btn-prev'),
|
||||
btnNext: byId('btn-next'),
|
||||
btnExitViewer: byId('btn-exit-viewer'),
|
||||
viewerBar: byId('viewer-bar'),
|
||||
sgfFile: byId('sgf-file'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Черновик настроек из контролов панели. */
|
||||
function readDraft(els: PageElements): SettingsV1 {
|
||||
return {
|
||||
size: Number(els.optSize.value) as BoardSize,
|
||||
level: Number(els.optLevel.value) as SettingsV1['level'],
|
||||
playerColor: els.optColor.value as SettingsV1['playerColor'],
|
||||
komi: els.optKomiAuto.checked ? 'auto' : Number(els.optKomi.value),
|
||||
showCoordinates: els.optCoords.checked,
|
||||
showMoveNumbers: els.optNumbers.checked,
|
||||
hintsEnabled: els.optHints.checked,
|
||||
};
|
||||
}
|
||||
|
||||
function syncControls(els: PageElements, settings: SettingsV1): void {
|
||||
els.optSize.value = String(settings.size);
|
||||
els.optLevel.value = String(settings.level);
|
||||
els.optColor.value = settings.playerColor;
|
||||
els.optKomiAuto.checked = settings.komi === 'auto';
|
||||
els.optKomi.value = settings.komi === 'auto' ? '6.5' : String(settings.komi);
|
||||
els.optCoords.checked = settings.showCoordinates;
|
||||
els.optNumbers.checked = settings.showMoveNumbers;
|
||||
els.optHints.checked = settings.hintsEnabled;
|
||||
}
|
||||
|
||||
/** Узлы главной линии от корня до текущего (включительно). */
|
||||
function mainPath(tree: GameTree): GameNode[] {
|
||||
const path: GameNode[] = [];
|
||||
const walk = (node: GameNode): boolean => {
|
||||
path.push(node);
|
||||
if (node.id === tree.currentId) return true;
|
||||
for (const child of node.children) if (walk(child)) return true;
|
||||
path.pop();
|
||||
return false;
|
||||
};
|
||||
walk(tree.root);
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Номера ходов для render: ключ pointKey → номер по главной линии. */
|
||||
function moveNumberMap(tree: GameTree): Map<string, number> {
|
||||
const map = new Map<string, number>();
|
||||
let number = 0;
|
||||
for (const node of mainPath(tree)) {
|
||||
if (node.move?.kind !== 'play') continue;
|
||||
number += 1;
|
||||
map.set(pointKey(node.move.point), number);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function renderOptionsFor(
|
||||
els: PageElements,
|
||||
state: GameStoreState,
|
||||
node: GameNode,
|
||||
page: PageState,
|
||||
): RenderOptions {
|
||||
const scoring = state.phase === 'scoring';
|
||||
const baseMarkup =
|
||||
state.phase === 'viewer'
|
||||
? node.markup
|
||||
: state.phase === 'playing' && state.hint !== null
|
||||
? state.hint.markup
|
||||
: undefined;
|
||||
const markup = addCursor(baseMarkup, page.cursor);
|
||||
return {
|
||||
showCoordinates: els.optCoords.checked,
|
||||
showMoveNumbers: els.optNumbers.checked,
|
||||
lastMove: node.move?.kind === 'play' ? node.move.point : null,
|
||||
...(els.optNumbers.checked ? { moveNumbers: moveNumberMap(state.tree) } : {}),
|
||||
...(state.phase === 'playing' && state.pending !== null
|
||||
? { phantom: { point: state.pending, color: state.playerColor } }
|
||||
: {}),
|
||||
...(markup !== undefined ? { markup } : {}),
|
||||
...(scoring ? { dead: state.dead, territory: territorySets(node.state, state.dead) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function draw(els: PageElements, store: GameStore, page: PageState): void {
|
||||
const ctx = els.canvas.getContext('2d');
|
||||
if (ctx === null || page.geo === null) return;
|
||||
const state = store.getState();
|
||||
const node = findNode(state.tree.root, state.tree.currentId);
|
||||
if (node === null) return;
|
||||
render(ctx, node.state, renderOptionsFor(els, state, node, page));
|
||||
}
|
||||
|
||||
/** Размер канвы — по ширине колонки (адаптивность), квадрат. */
|
||||
function fitCanvas(els: PageElements, store: GameStore, page: PageState): void {
|
||||
const column = els.canvas.parentElement;
|
||||
const width = Math.min(column?.clientWidth ?? 480, 720);
|
||||
const cssSize = Math.max(280, Math.floor(width));
|
||||
setupCanvas(els.canvas, cssSize);
|
||||
page.geo = computeGeometry(store.getState().tree.size, cssSize, els.optCoords.checked);
|
||||
store.setGeometry(page.geo);
|
||||
draw(els, store, page);
|
||||
}
|
||||
|
||||
function playingStatus(els: PageElements, state: GameStoreState): string {
|
||||
const node = findNode(state.tree.root, state.tree.currentId);
|
||||
if (node === null) return '';
|
||||
if (node.state.over && state.tree.meta.result !== null) {
|
||||
return format(STRINGS.status.gameOver, { result: state.tree.meta.result });
|
||||
}
|
||||
if (state.botFailed) return STRINGS.status.botMoveError;
|
||||
if (state.botThinking) return STRINGS.status.turnBot;
|
||||
if (state.lastError !== null) return STRINGS.moveErrors[state.lastError];
|
||||
if (state.pending !== null) return STRINGS.status.pendingConfirm;
|
||||
const color = STRINGS.colorNames[state.playerColor];
|
||||
return format(STRINGS.status.turnPlayer, { color });
|
||||
}
|
||||
|
||||
function hintStatus(state: GameStoreState): string | null {
|
||||
if (state.phase !== 'playing' || state.hint === null) return null;
|
||||
const winRate = String(Math.round(state.hint.bestWinRate * 100));
|
||||
if (state.hint.delta === null) return format(STRINGS.status.hintFirst, { winRate });
|
||||
const percent = Math.round(state.hint.delta * 100);
|
||||
const delta = percent > 0 ? `+${percent}%` : `${percent}%`;
|
||||
return format(STRINGS.status.hintDelta, { winRate, delta });
|
||||
}
|
||||
|
||||
function viewerStatus(state: GameStoreState): string {
|
||||
const index = mainPath(state.tree).length - 1;
|
||||
let total = 0;
|
||||
let node: GameNode | undefined = state.tree.root;
|
||||
while ((node = node.children[0]) !== undefined) total += 1;
|
||||
const values = { index: String(index), total: String(total) };
|
||||
return index === 0
|
||||
? format(STRINGS.status.viewerStart, values)
|
||||
: format(STRINGS.status.viewerMode, values);
|
||||
}
|
||||
|
||||
function statusText(els: PageElements, state: GameStoreState, page: PageState): string {
|
||||
const parts: string[] = [];
|
||||
if (state.sgfError !== null) {
|
||||
parts.push(format(STRINGS.status.sgfError, { message: state.sgfError }));
|
||||
}
|
||||
if (state.phase === 'viewer') parts.push(viewerStatus(state));
|
||||
else if (state.phase === 'scoring') parts.push(scoringStatus(state));
|
||||
else parts.push(playingStatus(els, state));
|
||||
const hint = hintStatus(state);
|
||||
if (hint !== null) parts.push(hint);
|
||||
if (state.aiFallback) parts.push(STRINGS.status.aiFallback);
|
||||
if (page.recoveredNote) parts.push(STRINGS.status.settingsRecovered);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function scoringStatus(state: GameStoreState): string {
|
||||
const result = state.tree.meta.result;
|
||||
if (result === null || state.score === null) return STRINGS.status.scoringPick;
|
||||
const margin = String(Math.abs(state.score.margin));
|
||||
return format(STRINGS.status.scoreResult, { result, margin });
|
||||
}
|
||||
|
||||
function refreshControls(els: PageElements, state: GameStoreState): void {
|
||||
const node = findNode(state.tree.root, state.tree.currentId);
|
||||
const playing = state.phase === 'playing';
|
||||
const blocked = !playing || state.botThinking || node === null || node.state.over;
|
||||
const playerTurn = node !== null && node.state.toPlay === state.playerColor;
|
||||
els.btnPass.disabled = blocked || !playerTurn;
|
||||
els.btnResign.disabled = blocked || !playerTurn;
|
||||
els.btnUndo.disabled = !playing || state.tree.currentId === 0;
|
||||
els.btnScore.hidden = state.phase !== 'scoring';
|
||||
els.viewerBar.hidden = state.phase !== 'viewer';
|
||||
els.btnPrev.disabled = state.phase !== 'viewer' || state.tree.currentId === 0;
|
||||
els.btnNext.disabled = state.phase !== 'viewer' || (node?.children.length ?? 0) === 0;
|
||||
}
|
||||
|
||||
function refreshPanel(els: PageElements, state: GameStoreState): void {
|
||||
const level = Number(els.optLevel.value);
|
||||
els.optHints.disabled = level < 4;
|
||||
els.optHints.title = level < 4 ? STRINGS.warnings.hintsUnavailable : '';
|
||||
const bigBoard = els.optSize.value === '19' && level >= 4;
|
||||
els.warning.hidden = !bigBoard;
|
||||
els.warning.textContent = bigBoard ? STRINGS.warnings.bigBoardMcts : '';
|
||||
const node = findNode(state.tree.root, state.tree.currentId);
|
||||
if (node !== null) {
|
||||
const c = node.state.captures;
|
||||
els.captures.textContent =
|
||||
`${STRINGS.panel.captures}: ${STRINGS.panel.capturesBlack} ${c.black}, ` +
|
||||
`${STRINGS.panel.capturesWhite} ${c.white}`;
|
||||
}
|
||||
}
|
||||
|
||||
function refresh(els: PageElements, store: GameStore, page: PageState): void {
|
||||
const state = store.getState();
|
||||
fitCanvas(els, store, page);
|
||||
els.status.textContent = statusText(els, state, page);
|
||||
refreshControls(els, state);
|
||||
refreshPanel(els, state);
|
||||
}
|
||||
|
||||
function pad2(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
/** Имя файла SGF: go-learn-YYYYMMDD-HHmm.sgf (дата — UI-слой, допустимо). */
|
||||
function sgfFilename(now: Date): string {
|
||||
const day = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}`;
|
||||
return `go-learn-${day}-${pad2(now.getHours())}${pad2(now.getMinutes())}.sgf`;
|
||||
}
|
||||
|
||||
function downloadSgf(tree: GameTree): void {
|
||||
const blob = new Blob([serializeSgf(tree)], { type: 'application/x-go-sgf' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = sgfFilename(new Date());
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function bindCanvas(els: PageElements, store: GameStore, page: PageState): void {
|
||||
attachPointerInput(els.canvas, (event) => {
|
||||
if (event.type !== 'tap' || page.geo === null) return;
|
||||
if (store.getState().phase === 'scoring') {
|
||||
const point = pixelToPoint(page.geo, event.px, event.py);
|
||||
if (point !== null) store.toggleDead(point);
|
||||
return;
|
||||
}
|
||||
void store.tapPoint(event.px, event.py);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Клавиатурный ввод (этап 7): стрелки двигают курсор-крестик, Enter/пробел —
|
||||
* tap по курсору через reduceInput стора (первое нажатие — фантом, второе —
|
||||
* ход), Escape — отмена фантома. Pointer-ввод независим: общий InputState
|
||||
* живёт в сторе, эта подписка лишь шлёт те же события.
|
||||
*/
|
||||
function bindKeyboard(els: PageElements, store: GameStore, page: PageState): void {
|
||||
els.canvas.tabIndex = 0;
|
||||
els.canvas.setAttribute('aria-label', `${STRINGS.aria.board}. ${STRINGS.aria.boardKeyboard}`);
|
||||
els.status.setAttribute('aria-live', 'polite');
|
||||
els.canvas.addEventListener('keydown', (event) => {
|
||||
const size = store.getState().tree.size;
|
||||
const arrow = arrowOf(event.key);
|
||||
if (arrow !== null) {
|
||||
event.preventDefault();
|
||||
const current = clampCursor(page.cursor ?? centerCursor(size), size);
|
||||
page.cursor = reduceCursor(current, arrow, size);
|
||||
draw(els, store, page);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
if (page.geo === null) return;
|
||||
page.cursor = clampCursor(page.cursor ?? centerCursor(size), size);
|
||||
if (store.getState().phase === 'scoring') {
|
||||
store.toggleDead(page.cursor);
|
||||
return;
|
||||
}
|
||||
const { x, y } = pointToPixel(page.geo, page.cursor);
|
||||
void store.tapPoint(x, y);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
store.cancelPending();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindSettings(els: PageElements, store: GameStore, page: PageState): void {
|
||||
const persist = (): void => {
|
||||
saveSettings(readDraft(els));
|
||||
refresh(els, store, page);
|
||||
};
|
||||
for (const select of [els.optSize, els.optLevel, els.optColor]) {
|
||||
select.addEventListener('change', persist);
|
||||
}
|
||||
for (const input of [els.optKomiAuto, els.optKomi]) {
|
||||
input.addEventListener('change', persist);
|
||||
}
|
||||
for (const checkbox of [els.optCoords, els.optNumbers, els.optHints]) {
|
||||
checkbox.addEventListener('change', () => {
|
||||
const draft = readDraft(els);
|
||||
saveSettings(draft);
|
||||
store.applySettings(draft); // визуальные настройки — сразу, без новой партии
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function bindButtons(els: PageElements, store: GameStore, page: PageState): void {
|
||||
els.btnNew.addEventListener('click', () => {
|
||||
const draft = readDraft(els);
|
||||
saveSettings(draft);
|
||||
page.recoveredNote = false;
|
||||
void store.newGame(draft);
|
||||
});
|
||||
els.btnPass.addEventListener('click', () => void store.pass());
|
||||
els.btnResign.addEventListener('click', () => store.resign());
|
||||
els.btnUndo.addEventListener('click', () => store.undo());
|
||||
els.btnScore.addEventListener('click', () => store.computeScore());
|
||||
els.btnDownload.addEventListener('click', () => downloadSgf(store.getState().tree));
|
||||
els.btnLoad.addEventListener('click', () => els.sgfFile.click());
|
||||
els.btnPrev.addEventListener('click', () => store.viewerPrev());
|
||||
els.btnNext.addEventListener('click', () => store.viewerNext());
|
||||
els.btnExitViewer.addEventListener('click', () => store.exitViewer());
|
||||
els.sgfFile.addEventListener('change', () => void loadSgfFile(els, store));
|
||||
}
|
||||
|
||||
async function loadSgfFile(els: PageElements, store: GameStore): Promise<void> {
|
||||
const file = els.sgfFile.files?.[0];
|
||||
els.sgfFile.value = '';
|
||||
if (file === undefined) return;
|
||||
store.loadSgfViewer(await file.text());
|
||||
}
|
||||
|
||||
/** Точка входа страницы /play (вызывается из play.astro). */
|
||||
export function bootPlayPage(): void {
|
||||
const els = collectElements();
|
||||
const { settings, recovered } = loadSettings();
|
||||
const seed = Math.floor(Math.random() * 2 ** 31);
|
||||
const store = createGameStore({ ai: createAiClient(), seed, initialSettings: settings });
|
||||
const page: PageState = { geo: null, recoveredNote: recovered, cursor: null };
|
||||
syncControls(els, settings);
|
||||
bindCanvas(els, store, page);
|
||||
bindKeyboard(els, store, page);
|
||||
bindSettings(els, store, page);
|
||||
bindButtons(els, store, page);
|
||||
store.subscribe(() => refresh(els, store, page));
|
||||
window.addEventListener('resize', () => refresh(els, store, page));
|
||||
refresh(els, store, page);
|
||||
void store.newGame(settings); // бот может ходить первым (белые/случайный цвет)
|
||||
}
|
||||
294
apps/web/src/ui/strings.ts
Normal file
294
apps/web/src/ui/strings.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
/**
|
||||
* Все строки UI сайта — единственный источник (спека: ни одной строки UI
|
||||
* вне этого файла). Значения — шаблоны с плейсхолдерами {name}; подстановка
|
||||
* — функцией format() отсюда же, чтобы страницы не собирали строки вручную.
|
||||
*/
|
||||
import type { MoveError } from '@go-learn/core';
|
||||
|
||||
export const STRINGS = {
|
||||
site: {
|
||||
title: 'Го — обучение и игра',
|
||||
tagline:
|
||||
'Интерактивный курс игры Го и партии против ИИ прямо в браузере. ' +
|
||||
'Без регистрации: настройки и прогресс хранятся только на вашем устройстве.',
|
||||
playLink: 'Играть',
|
||||
/** Название бренда в шапке (этап 10, светлая тема «васи»). */
|
||||
brand: 'Иго · школа Го',
|
||||
},
|
||||
/** Шапка и подвал каркаса (этап 10). */
|
||||
chrome: {
|
||||
navAriaLabel: 'Основное меню',
|
||||
footerNavAriaLabel: 'Меню в подвале',
|
||||
menuOpen: 'Открыть меню',
|
||||
menuClose: 'Закрыть меню',
|
||||
privacyNote:
|
||||
'◎ Прогресс — на вашем устройстве; кабинет нужен только для синхронизации между устройствами.',
|
||||
copyright: '© 2026 Иго',
|
||||
sourcesLink: 'Источники',
|
||||
courseDisclaimer:
|
||||
'Курс создан с помощью ИИ и находится в открытом редактировании — сверяйте спорные места с источниками.',
|
||||
},
|
||||
/** Главная страница (этап 10, паттерн docs/design/kimi-export/index.html). */
|
||||
home: {
|
||||
description:
|
||||
'Камерная школа Го (вэйци, бадук) на русском: курс с нуля до 10 кю, ' +
|
||||
'цумэ-го с проверкой, партии против ИИ шести уровней.',
|
||||
kicker: 'вэйци · бадук · го',
|
||||
headingBefore: 'Научитесь играть ',
|
||||
headingEm: 'в\u00a0Го',
|
||||
headingAfter: ' с\u00a0нуля',
|
||||
lead:
|
||||
'Спокойный курс без регистрации-принуждения: теория, задачи с проверкой ' +
|
||||
'на доске и партии против компьютера — от первых камней до уверенного 10\u00a0кю.',
|
||||
startCourse: 'Начать курс',
|
||||
playAi: 'Сыграть с ИИ',
|
||||
metaSections: 'разделов курса',
|
||||
metaTsumego: 'задач цумэ-го',
|
||||
metaLevels: 'уровней ИИ',
|
||||
heroChip: 'Ваш ход — E5',
|
||||
heroAlt: 'Деревянный гобан с несколькими чёрными и белыми камнями в начальной позиции',
|
||||
heroCaption: 'Гобан, сланец и ракушка — три тысячи лет той же партии.',
|
||||
featuresKicker: 'как это работает',
|
||||
featuresHeading: 'Как устроено обучение',
|
||||
featuresLead:
|
||||
'Три простые вещи, которые вместе дают рост: теория малыми дозами, ' +
|
||||
'задачи до автоматизма и живая практика.',
|
||||
feature1Title: 'С нуля, по шагам',
|
||||
feature1Text:
|
||||
'Десять разделов: от дамэ и атари до дзёсэки и ёсэ. Короткие уроки, схемы ' +
|
||||
'позиций, никакой воды — только то, что работает на доске.',
|
||||
feature2Title: 'Задачи с проверкой',
|
||||
feature2Text:
|
||||
'Жизнь и смерть, сэмэаи, лестницы. Фильтры по теме и сложности, решённые ' +
|
||||
'отмечаются автоматически — видно, что уже в опоре.',
|
||||
feature3Title: 'Партии с ИИ, 6 уровней',
|
||||
feature3Text:
|
||||
'Доски 9×9, 13×13 и 19×19, коми, отмена хода и подсказки трёх лучших ходов. ' +
|
||||
'Начните с первого уровня — он терпелив.',
|
||||
inkAlt: 'Позиция на углу гобана в манере тушевой живописи суми-э',
|
||||
inkCaption: 'Угол доски, тушь и пустое пространство — эстетика ма, в которой учится глаз.',
|
||||
stonesAlt: 'Чёрные сланцевые и белые ракушечные камни Го в деревянных чашах',
|
||||
quote: '«Го — это игра, которую легко выучить и невозможно исчерпать».',
|
||||
quoteCite: '— старая пословица. Проверьте на себе: первый урок занимает десять минут.',
|
||||
firstLessonLink: 'Первый урок: {title} →',
|
||||
},
|
||||
buttons: {
|
||||
newGame: 'Новая партия',
|
||||
pass: 'Пас',
|
||||
resign: 'Сдаться',
|
||||
undo: 'Отменить ход',
|
||||
downloadSgf: 'Скачать SGF',
|
||||
loadSgf: 'Загрузить SGF',
|
||||
viewerPrev: 'Назад',
|
||||
viewerNext: 'Вперёд',
|
||||
viewerExit: 'Выйти из просмотра',
|
||||
confirmScore: 'Завершить подсчёт',
|
||||
},
|
||||
panel: {
|
||||
settingsHeading: 'Настройки партии',
|
||||
size: 'Размер доски',
|
||||
level: 'Уровень ИИ',
|
||||
playerColor: 'Ваш цвет',
|
||||
colorBlack: 'Чёрные',
|
||||
colorWhite: 'Белые',
|
||||
colorRandom: 'Случайно',
|
||||
komi: 'Коми',
|
||||
komiAuto: 'Авто',
|
||||
showCoordinates: 'Координаты',
|
||||
showMoveNumbers: 'Номера ходов',
|
||||
hintsEnabled: 'Подсказки (ур. 4+)',
|
||||
captures: 'Пленники',
|
||||
capturesBlack: 'чёрные взяли',
|
||||
capturesWhite: 'белые взяли',
|
||||
},
|
||||
status: {
|
||||
turnPlayer: 'Ваш ход ({color}).',
|
||||
turnBot: 'Думает ИИ…',
|
||||
pendingConfirm: 'Нажмите ещё раз, чтобы подтвердить ход.',
|
||||
gameOver: 'Партия окончена: {result}.',
|
||||
resignNote: 'Сдача.',
|
||||
scoringPick: 'Подсчёт: отметьте мёртвые группы кликом, затем завершите подсчёт.',
|
||||
scoreResult: 'Итог: {result} (разница {margin}).',
|
||||
viewerMode: 'Просмотр SGF: ход {index} из {total}.',
|
||||
viewerStart: 'Просмотр SGF: начальная позиция, ходов {total}.',
|
||||
sgfLoaded: 'SGF загружен.',
|
||||
sgfError: 'Не удалось прочитать SGF: {message}',
|
||||
settingsRecovered: 'Настройки были повреждены — восстановлены значения по умолчанию.',
|
||||
aiFallback: 'Web Worker недоступен: ИИ считает в основном потоке (возможны паузы).',
|
||||
hintDelta: 'Оценка лучшего хода: {winRate}% ({delta} к прошлой оценке).',
|
||||
hintFirst: 'Оценка лучшего хода: {winRate}%.',
|
||||
botMoveError: 'ИИ вернул нелегальный ход — партия остановлена.',
|
||||
},
|
||||
colorNames: {
|
||||
black: 'чёрные',
|
||||
white: 'белые',
|
||||
},
|
||||
nav: {
|
||||
learn: 'Курс',
|
||||
tsumego: 'Цумэ-го',
|
||||
progress: 'Прогресс',
|
||||
play: 'Играть',
|
||||
account: 'Кабинет',
|
||||
},
|
||||
account: {
|
||||
title: 'Кабинет',
|
||||
loginHeading: 'Вход',
|
||||
emailLabel: 'Email',
|
||||
passwordLabel: 'Пароль',
|
||||
loginSubmit: 'Войти',
|
||||
registerLink: 'Нет аккаунта? Зарегистрируйтесь',
|
||||
resetLink: 'Забыли пароль?',
|
||||
loggedInAs: 'Вы вошли как {email}',
|
||||
syncButton: 'Синхронизировать прогресс',
|
||||
logoutButton: 'Выйти',
|
||||
statusLoggedOut: 'Вы вышли из кабинета.',
|
||||
statusLoginOk: 'Вы вошли.',
|
||||
statusUnauthorized: 'Неверная пара email/пароль.',
|
||||
statusInvalid: 'Проверьте email и пароль (минимум 8 символов).',
|
||||
statusRateLimited: 'Слишком много попыток — повторите позже.',
|
||||
statusUnavailable: 'Сервер кабинета недоступен — попробуйте позже.',
|
||||
syncPushed: 'Прогресс отправлен',
|
||||
syncPulled: 'Прогресс загружен с сервера',
|
||||
syncNoop: 'Уже синхронизировано',
|
||||
syncUnavailable: 'Сервер синхронизации недоступен — прогресс хранится локально',
|
||||
syncUnauthorized: 'Сессия истекла — войдите снова.',
|
||||
},
|
||||
register: {
|
||||
title: 'Регистрация',
|
||||
heading: 'Регистрация',
|
||||
emailLabel: 'Email',
|
||||
passwordLabel: 'Пароль',
|
||||
passwordHint: 'Минимум 8 символов.',
|
||||
passwordRepeatLabel: 'Повторите пароль',
|
||||
captchaLabel: 'Код с картинки',
|
||||
captchaAlt: 'Капча: пять символов',
|
||||
captchaRefresh: 'Обновить капчу',
|
||||
submit: 'Зарегистрироваться',
|
||||
loginLink: 'Уже есть аккаунт? Войти',
|
||||
statusPasswordMismatch: 'Пароли не совпадают.',
|
||||
statusConflict: 'Этот email уже зарегистрирован — попробуйте войти.',
|
||||
statusCaptcha: 'Неверный код с картинки — попробуйте ещё раз.',
|
||||
statusInvalid: 'Проверьте email и пароль (минимум 8 символов).',
|
||||
statusRateLimited: 'Слишком много попыток — повторите позже.',
|
||||
statusUnavailable: 'Сервер кабинета недоступен — попробуйте позже.',
|
||||
},
|
||||
passwordReset: {
|
||||
title: 'Восстановление пароля',
|
||||
requestHeading: 'Запросить ссылку для восстановления',
|
||||
emailLabel: 'Email',
|
||||
requestSubmit: 'Отправить письмо',
|
||||
requestDone: 'Если такой email зарегистрирован, письмо со ссылкой отправлено.',
|
||||
statusMailUnavailable: 'Отправка письма сейчас недоступна, попробуйте позже.',
|
||||
statusRateLimited: 'Слишком много попыток — повторите позже.',
|
||||
statusUnavailable: 'Сервер кабинета недоступен — попробуйте позже.',
|
||||
confirmHeading: 'Новый пароль',
|
||||
passwordLabel: 'Новый пароль',
|
||||
passwordHint: 'Минимум 8 символов.',
|
||||
passwordRepeatLabel: 'Повторите пароль',
|
||||
confirmSubmit: 'Обновить пароль',
|
||||
statusPasswordMismatch: 'Пароли не совпадают.',
|
||||
confirmDone: 'Пароль обновлён.',
|
||||
loginLink: 'Войти',
|
||||
statusTokenInvalid: 'Ссылка недействительна или истекла — запросите новую.',
|
||||
requestAgainLink: 'Запросить новую ссылку',
|
||||
statusInvalid: 'Пароль должен быть не короче 8 символов.',
|
||||
},
|
||||
learn: {
|
||||
title: 'Курс',
|
||||
lessonDone: 'пройден',
|
||||
markDone: 'Отметить пройденным',
|
||||
doneState: 'Пройден',
|
||||
/** Разделы курса — фиксированный список, порядок = порядок прохождения
|
||||
* (контракт INTERFACES.md, «Разделы курса»). */
|
||||
sections: [
|
||||
'Дамэ и захват',
|
||||
'Атари, лестница, сеть',
|
||||
'Самоубийство и ко',
|
||||
'Глаза: жизнь и смерть',
|
||||
'Подсчёт и конец партии',
|
||||
'Связь и разрезание',
|
||||
'Углы, стороны, центр',
|
||||
'Базовые дзёсэки',
|
||||
'Форма',
|
||||
'Простое ёсэ',
|
||||
],
|
||||
},
|
||||
task: {
|
||||
reset: 'Сброс',
|
||||
hint: 'Подсказка',
|
||||
statusPlaying: 'Ваш ход ({color}).',
|
||||
statusPending: 'Нажмите ещё раз, чтобы подтвердить ход.',
|
||||
statusSolved: 'Задача решена!',
|
||||
statusWrong: 'Неверно, попробуйте ещё раз. Ошибок: {count}.',
|
||||
hintPoint: 'Попробуйте ход {point}.',
|
||||
hintNone: 'Для этой задачи подсказки нет.',
|
||||
broken: 'Не удалось загрузить задачу.',
|
||||
},
|
||||
tsumego: {
|
||||
title: 'Цумэ-го',
|
||||
filterTopic: 'Тема',
|
||||
filterDifficulty: 'Сложность',
|
||||
filterStatus: 'Статус',
|
||||
allTopics: 'Все темы',
|
||||
allDifficulties: 'Любая',
|
||||
statusAll: 'Все',
|
||||
statusSolved: 'Решённые',
|
||||
statusUnsolved: 'Нерешённые',
|
||||
difficultyLabel: 'сложность {level}',
|
||||
solvedBadge: 'решена',
|
||||
empty: 'Нет задач по выбранным фильтрам.',
|
||||
},
|
||||
progressPage: {
|
||||
title: 'Мой прогресс',
|
||||
lessons: 'Уроков пройдено: {done} из {total}',
|
||||
lessonTasks: 'Задач в уроках решено: {done} из {total}',
|
||||
tsumegoTasks: 'Цумэ-го решено: {done} из {total}',
|
||||
streak: 'Серия дней подряд с решёнными задачами: {count}',
|
||||
recovered: 'Сохранённый прогресс был повреждён — начат заново.',
|
||||
},
|
||||
/** Страница «Источники» (этап 11): проверяемые факты по итогам аудита. */
|
||||
sources: {
|
||||
title: 'Источники',
|
||||
kicker: 'каждый факт — со ссылкой',
|
||||
lead:
|
||||
'Один проверяемый факт — одна строка и ссылка на первоисточник. ' +
|
||||
'Ссылки открываются в новой вкладке.',
|
||||
sectionRules: 'Правила и базовые термины (уроки 01–03)',
|
||||
sectionLife: 'Жизнь и смерть (урок 04)',
|
||||
sectionScoring: 'Подсчёт и конец партии (урок 05)',
|
||||
sectionConnect: 'Связь и разрезание (урок 06)',
|
||||
sectionOpening: 'Дебют: углы, стороны, линии (урок 07)',
|
||||
sectionJoseki: 'Дзёсэки (урок 08)',
|
||||
sectionShape: 'Форма (урок 09)',
|
||||
sectionYose: 'Ёсэ (урок 10)',
|
||||
unconfirmedHeading: 'Что пока не удалось подтвердить',
|
||||
note:
|
||||
'Страница собрана по итогам аудита контента от 2026-08-07; полные ' +
|
||||
'отчёты лежат в репозитории (docs/audit/).',
|
||||
},
|
||||
moveErrors: {
|
||||
'out-of-bounds': 'Точка за пределами доски.',
|
||||
occupied: 'Пересечение уже занято.',
|
||||
suicide: 'Самоубийственный ход запрещён.',
|
||||
ko: 'Запрет ко: нельзя немедленно повторять позицию.',
|
||||
superko: 'Позиция уже встречалась в партии (суперко).',
|
||||
'game-over': 'Партия уже завершена.',
|
||||
'wrong-turn': 'Сейчас ход другого цвета.',
|
||||
} satisfies Record<MoveError, string>,
|
||||
warnings: {
|
||||
bigBoardMcts: 'На 19×19 ИИ уровней 4–6 играет заметно слабее (таргет движка — 9×9 и 13×13).',
|
||||
hintsUnavailable: 'Подсказки доступны только на уровнях ИИ 4–6.',
|
||||
},
|
||||
aria: {
|
||||
board: 'Игровая доска Го',
|
||||
boardKeyboard:
|
||||
'Управление с клавиатуры: стрелки двигают курсор, Enter или пробел — ' +
|
||||
'ход (первое нажатие — фантом, второе — подтверждение), Escape — отмена.',
|
||||
sgfFile: 'Выберите файл SGF',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** Подстановка {name} в шаблон; неизвестные ключи остаются как есть. */
|
||||
export function format(template: string, values: Record<string, string>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (raw, key: string) => values[key] ?? raw);
|
||||
}
|
||||
282
apps/web/src/ui/task-widget.ts
Normal file
282
apps/web/src/ui/task-widget.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
/**
|
||||
* Гидратация элементов <go-task data-task-id="…"> (этап 6): канва
|
||||
* packages/board (setupCanvas/render), ввод reduceInput с подтверждением,
|
||||
* статус-строка, кнопки «Сброс»/«Подсказка», запись прогресса (tasks +
|
||||
* streak + lessons.tasksSolved). Данные задач страница отдаёт JSON-островком
|
||||
* <script type="application/json" id="go-task-data"> (опционально с
|
||||
* data-lesson-id на странице урока). Вся механика — lib/task-session.ts,
|
||||
* здесь только DOM. Строки — ui/strings.ts.
|
||||
*/
|
||||
import {
|
||||
attachPointerInput,
|
||||
computeGeometry,
|
||||
pointToPixel,
|
||||
reduceInput,
|
||||
render,
|
||||
setupCanvas,
|
||||
} from '@go-learn/board';
|
||||
import type { BoardGeometry, InputState } from '@go-learn/board';
|
||||
import type { Point } from '@go-learn/core';
|
||||
import { addCursor, arrowOf, centerCursor, clampCursor, reduceCursor } from '../lib/keyboard.js';
|
||||
import { loadProgress, saveProgress, touchStreak } from '../lib/storage.js';
|
||||
import type { ProgressV1 } from '../lib/storage.js';
|
||||
import { createTaskSession } from '../lib/task-session.js';
|
||||
import type { TaskSession, TaskV1 } from '../lib/task-session.js';
|
||||
import { format, STRINGS } from './strings.js';
|
||||
|
||||
const ISLAND_ID = 'go-task-data';
|
||||
|
||||
interface TaskIsland {
|
||||
readonly tasks: ReadonlyMap<string, TaskV1>;
|
||||
readonly lessonId: string | null;
|
||||
/** id задач урока (kind 'lesson') из островка — для пометки урока done. */
|
||||
readonly lessonTaskIds: readonly string[];
|
||||
}
|
||||
|
||||
/** Чтение JSON-островка с задачами; null — островка нет или он битый. */
|
||||
function readIsland(): TaskIsland | null {
|
||||
const element = document.getElementById(ISLAND_ID);
|
||||
if (element === null || element.textContent === null) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(element.textContent);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) return null;
|
||||
const rawTasks = (parsed as { tasks?: unknown }).tasks;
|
||||
if (!Array.isArray(rawTasks)) return null;
|
||||
const tasks = new Map<string, TaskV1>();
|
||||
const lessonTaskIds: string[] = [];
|
||||
for (const item of rawTasks as TaskV1[]) {
|
||||
tasks.set(item.id, item);
|
||||
if (item.kind === 'lesson') lessonTaskIds.push(item.id);
|
||||
}
|
||||
return { tasks, lessonId: element.dataset['lessonId'] ?? null, lessonTaskIds };
|
||||
}
|
||||
|
||||
/** Координата в привычной записи: буквы без «I», числа снизу вверх. */
|
||||
function formatCoordinate(size: number, point: Point): string {
|
||||
const letters = 'ABCDEFGHJKLMNOPQRST';
|
||||
return `${letters[point.x] ?? '?'}${size - point.y}`;
|
||||
}
|
||||
|
||||
/** Локальная дата 'YYYY-MM-DD' для серии дней (UI-слой, Date допустим). */
|
||||
function todayLocal(): string {
|
||||
const now = new Date();
|
||||
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(now.getDate()).padStart(2, '0');
|
||||
return `${now.getFullYear()}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
interface Widget {
|
||||
readonly session: TaskSession;
|
||||
readonly canvas: HTMLCanvasElement;
|
||||
readonly status: HTMLElement;
|
||||
readonly geo: BoardGeometry;
|
||||
input: InputState;
|
||||
hintText: string | null;
|
||||
recorded: boolean; // решение уже записано в прогресс
|
||||
cursor: Point | null; // клавиатурный курсор-крестик (этап 7)
|
||||
}
|
||||
|
||||
function statusText(widget: Widget): string {
|
||||
const snap = widget.session.snapshot();
|
||||
const parts: string[] = [];
|
||||
switch (snap.status) {
|
||||
case 'solved':
|
||||
parts.push(STRINGS.task.statusSolved);
|
||||
break;
|
||||
case 'wrong':
|
||||
parts.push(format(STRINGS.task.statusWrong, { count: String(snap.attempts) }));
|
||||
break;
|
||||
case 'illegal':
|
||||
parts.push(snap.reason === null ? '' : STRINGS.moveErrors[snap.reason]);
|
||||
break;
|
||||
case 'playing':
|
||||
if (widget.input.pending !== null) parts.push(STRINGS.task.statusPending);
|
||||
else {
|
||||
const color = STRINGS.colorNames[widget.session.task.toPlay];
|
||||
parts.push(format(STRINGS.task.statusPlaying, { color }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (widget.hintText !== null) parts.push(widget.hintText);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function draw(widget: Widget): void {
|
||||
const ctx = widget.canvas.getContext('2d');
|
||||
if (ctx === null) return;
|
||||
const snap = widget.session.snapshot();
|
||||
const pending = widget.input.pending;
|
||||
render(ctx, snap.state, {
|
||||
showCoordinates: true,
|
||||
lastMove: snap.lastMove,
|
||||
markup: addCursor(snap.markup, widget.cursor) ?? snap.markup,
|
||||
...(pending !== null && snap.status !== 'solved'
|
||||
? { phantom: { point: pending, color: snap.state.toPlay } }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function refresh(widget: Widget): void {
|
||||
draw(widget);
|
||||
const snap = widget.session.snapshot();
|
||||
widget.status.textContent = statusText(widget);
|
||||
widget.status.classList.toggle('solved', snap.status === 'solved');
|
||||
}
|
||||
|
||||
/** Запись прогресса при первом решении в этой сессии виджета. */
|
||||
function recordSolved(widget: Widget, island: TaskIsland): void {
|
||||
const snap = widget.session.snapshot();
|
||||
const task = widget.session.task;
|
||||
if (snap.status !== 'solved' || widget.recorded) return;
|
||||
widget.recorded = true;
|
||||
const { progress } = loadProgress();
|
||||
const prevTask = progress.tasks[task.id];
|
||||
const tasks = {
|
||||
...progress.tasks,
|
||||
[task.id]: { solved: true, attempts: (prevTask?.attempts ?? 0) + snap.attempts },
|
||||
};
|
||||
let next: ProgressV1 = touchStreak({ ...progress, tasks }, todayLocal());
|
||||
if (island.lessonId !== null && task.kind === 'lesson') {
|
||||
const lessonId = island.lessonId;
|
||||
const solved = new Set<string>(next.lessons[lessonId]?.tasksSolved ?? []);
|
||||
solved.add(task.id);
|
||||
const done = island.lessonTaskIds.every((id) => solved.has(id));
|
||||
next = {
|
||||
...next,
|
||||
lessons: { ...next.lessons, [lessonId]: { done, tasksSolved: [...solved] } },
|
||||
};
|
||||
}
|
||||
saveProgress(next);
|
||||
}
|
||||
|
||||
function buildWidget(element: HTMLElement, task: TaskV1, island: TaskIsland): void {
|
||||
const session = createTaskSession(task);
|
||||
const cssSize = Math.max(240, Math.min(element.clientWidth || 360, 400));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.setAttribute('role', 'img');
|
||||
canvas.setAttribute('aria-label', `${task.title}. ${STRINGS.aria.boardKeyboard}`);
|
||||
canvas.tabIndex = 0; // клавиатурное управление доской (этап 7)
|
||||
setupCanvas(canvas, cssSize);
|
||||
const status = document.createElement('p');
|
||||
status.className = 'go-task-status';
|
||||
status.setAttribute('role', 'status');
|
||||
status.setAttribute('aria-live', 'polite');
|
||||
const buttons = document.createElement('div');
|
||||
buttons.className = 'go-task-buttons';
|
||||
const resetButton = document.createElement('button');
|
||||
resetButton.className = 'button';
|
||||
resetButton.textContent = STRINGS.task.reset;
|
||||
const hintButton = document.createElement('button');
|
||||
hintButton.className = 'button';
|
||||
hintButton.textContent = STRINGS.task.hint;
|
||||
buttons.append(resetButton, hintButton);
|
||||
element.append(canvas, status, buttons);
|
||||
|
||||
const widget: Widget = {
|
||||
session,
|
||||
canvas,
|
||||
status,
|
||||
geo: computeGeometry(task.size, cssSize, true),
|
||||
input: { pending: null },
|
||||
hintText: null,
|
||||
recorded: false,
|
||||
cursor: null,
|
||||
};
|
||||
|
||||
/** Общий путь tap-ввода (pointer и клавиатура): фантом → подтверждение. */
|
||||
const tapAt = (px: number, py: number): void => {
|
||||
if (session.snapshot().status === 'solved') return;
|
||||
const result = reduceInput(widget.input, { type: 'tap', px, py }, widget.geo);
|
||||
widget.input = result.state;
|
||||
widget.hintText = null;
|
||||
if (result.commit !== null) {
|
||||
session.play(result.commit);
|
||||
recordSolved(widget, island);
|
||||
}
|
||||
refresh(widget);
|
||||
};
|
||||
|
||||
attachPointerInput(canvas, (event) => {
|
||||
if (event.type === 'tap') tapAt(event.px, event.py);
|
||||
});
|
||||
|
||||
// Клавиатура (этап 7): стрелки — курсор, Enter/пробел — tap по курсору,
|
||||
// Escape — отмена фантома. Подписка независима от pointer-ввода.
|
||||
canvas.addEventListener('keydown', (event) => {
|
||||
const arrow = arrowOf(event.key);
|
||||
if (arrow !== null) {
|
||||
event.preventDefault();
|
||||
const current = clampCursor(widget.cursor ?? centerCursor(task.size), task.size);
|
||||
widget.cursor = reduceCursor(current, arrow, task.size);
|
||||
refresh(widget);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
widget.cursor = clampCursor(widget.cursor ?? centerCursor(task.size), task.size);
|
||||
const { x, y } = pointToPixel(widget.geo, widget.cursor);
|
||||
tapAt(x, y);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
widget.input = reduceInput(widget.input, { type: 'cancel' }, widget.geo).state;
|
||||
refresh(widget);
|
||||
}
|
||||
});
|
||||
|
||||
resetButton.addEventListener('click', () => {
|
||||
session.reset();
|
||||
widget.input = { pending: null };
|
||||
widget.hintText = null;
|
||||
refresh(widget);
|
||||
});
|
||||
|
||||
hintButton.addEventListener('click', () => {
|
||||
const hint = session.hint();
|
||||
if (hint.text !== null) widget.hintText = hint.text;
|
||||
else if (hint.point !== null) {
|
||||
widget.hintText = format(STRINGS.task.hintPoint, {
|
||||
point: formatCoordinate(task.size, hint.point),
|
||||
});
|
||||
} else widget.hintText = STRINGS.task.hintNone;
|
||||
refresh(widget);
|
||||
});
|
||||
|
||||
refresh(widget);
|
||||
}
|
||||
|
||||
function hydrateOne(element: HTMLElement, island: TaskIsland): void {
|
||||
element.dataset['hydrated'] = 'true';
|
||||
const taskId = element.dataset['taskId'] ?? '';
|
||||
const task = island.tasks.get(taskId);
|
||||
element.textContent = ''; // убрать запасной текст «без JS»
|
||||
if (task === undefined) {
|
||||
element.textContent = STRINGS.task.broken;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
buildWidget(element, task, island);
|
||||
} catch {
|
||||
element.textContent = STRINGS.task.broken; // битый контент задачи
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Гидратация всех ещё не гидрированных <go-task> внутри root. Страница
|
||||
* урока вызывает с document; страница цумэ-го — с раскрытым <details>
|
||||
* (ленивая гидратация). Без JSON-островка — тихий no-op.
|
||||
*/
|
||||
export function hydrateTasks(root: ParentNode): void {
|
||||
const island = readIsland();
|
||||
if (island === null) return;
|
||||
for (const element of root.querySelectorAll('go-task[data-task-id]')) {
|
||||
if (element instanceof HTMLElement && element.dataset['hydrated'] === undefined) {
|
||||
hydrateOne(element, island);
|
||||
}
|
||||
}
|
||||
}
|
||||
344
apps/web/src/wiring.test.ts
Normal file
344
apps/web/src/wiring.test.ts
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
/**
|
||||
* Wiring-тест apps/web (gotcha-green-modules-dead-system): orphan-check —
|
||||
* каждый публичный runtime-экспорт src упоминается минимум в одном другом
|
||||
* файле (ts/astro/test). Входные точки (ai-worker.ts — воркер, play-page.ts
|
||||
* — вызывается из play.astro) экспортов не обязаны иметь внешних: помечены.
|
||||
*/
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const srcDir = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Точки входа, связываемые сборкой (Vite/Astro), а не импортами. */
|
||||
const ENTRY_POINTS = new Set(['ai-worker.ts', 'ui/play-page.ts', 'content.config.ts']);
|
||||
|
||||
function listFiles(dir: string): string[] {
|
||||
const result: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) result.push(...listFiles(path));
|
||||
else if (/\.(ts|astro)$/.test(entry.name)) result.push(path);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Имена runtime-экспортов файла (export function/const/class). */
|
||||
function exportedRuntimeNames(content: string): string[] {
|
||||
const names: string[] = [];
|
||||
const pattern = /export\s+(?:async\s+)?(?:function|const|class)\s+(\w+)/g;
|
||||
for (const match of content.matchAll(pattern)) {
|
||||
const name = match[1];
|
||||
if (name !== undefined) names.push(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
describe('orphan-check: публичные функции apps/web связаны', () => {
|
||||
it('каждый экспорт упоминается вне файла определения', () => {
|
||||
const files = listFiles(srcDir);
|
||||
const contents = new Map(files.map((file) => [file, readFileSync(file, 'utf8')]));
|
||||
const missing: string[] = [];
|
||||
for (const [file, content] of contents) {
|
||||
if (ENTRY_POINTS.has(file.slice(srcDir.length + 1))) continue;
|
||||
if (file.endsWith('.test.ts') || file.endsWith('.astro')) continue;
|
||||
for (const name of exportedRuntimeNames(content)) {
|
||||
const usage = new RegExp(`\\b${name}\\b`);
|
||||
const usedElsewhere = [...contents].some(
|
||||
([otherFile, other]) => otherFile !== file && usage.test(other),
|
||||
);
|
||||
if (!usedElsewhere) missing.push(`${file}: ${name}`);
|
||||
}
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('точки входа существуют и помечены', () => {
|
||||
for (const entry of ENTRY_POINTS) {
|
||||
const content = contentsFor(join(srcDir, entry));
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function contentsFor(file: string): string {
|
||||
return readFileSync(file, 'utf8');
|
||||
}
|
||||
|
||||
/* ── Этап 6: страницы курса, коллекции контента ── */
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { applyMove, buildPosition, parseSgf } from '@go-learn/core';
|
||||
import { goalReached } from './lib/goals.js';
|
||||
import { createTaskSession } from './lib/task-session.js';
|
||||
import type { TaskV1 } from './lib/task-session.js';
|
||||
import { STRINGS } from './ui/strings.js';
|
||||
|
||||
const contentDir = join(srcDir, 'content');
|
||||
const taskFiles = readdirSync(join(contentDir, 'tasks')).filter((file) => file.endsWith('.json'));
|
||||
const allTasks: TaskV1[] = taskFiles.flatMap(
|
||||
(file) => JSON.parse(readFileSync(join(contentDir, 'tasks', file), 'utf8')) as TaskV1[],
|
||||
);
|
||||
|
||||
function targetOf(task: TaskV1): readonly { x: number; y: number }[] {
|
||||
const root = parseSgf(task.sgf).root;
|
||||
return task.goal.type === 'connect' ? root.markup.circles : root.markup.triangles;
|
||||
}
|
||||
|
||||
describe('wiring этапа 6: страницы и коллекции существуют', () => {
|
||||
it('страницы и конфиг коллекций на месте', () => {
|
||||
for (const file of [
|
||||
'pages/learn.astro',
|
||||
'pages/learn/[id].astro',
|
||||
'pages/tsumego.astro',
|
||||
'pages/progress.astro',
|
||||
'content.config.ts',
|
||||
'content/lessons/01-dame-i-zahvat.md',
|
||||
'content/tasks/lessons.json',
|
||||
]) {
|
||||
expect(existsSync(join(srcDir, file)), file).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('wiring этапа 6: контент валиден движком', () => {
|
||||
it('все 4 типа goal представлены, id с префиксом kind', () => {
|
||||
const goalTypes = new Set(allTasks.map((task) => task.goal.type));
|
||||
expect(goalTypes).toEqual(new Set(['capture-target', 'liberties', 'connect', 'two-eyes']));
|
||||
for (const task of allTasks) {
|
||||
expect(task.id.startsWith(task.kind === 'lesson' ? 'lsn-' : 'tsm-')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('позиция строится, SGF парсится, разметка цели присутствует', () => {
|
||||
for (const task of allTasks) {
|
||||
const state = buildPosition(task.size, task.setupBlack, task.setupWhite, task.toPlay);
|
||||
expect(
|
||||
state.grid.some((cell) => cell !== 'empty'),
|
||||
task.id,
|
||||
).toBe(true);
|
||||
expect(targetOf(task).length, task.id).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('первый ход каждой вариации легален на стартовой позиции', () => {
|
||||
for (const task of allTasks) {
|
||||
const state = buildPosition(task.size, task.setupBlack, task.setupWhite, task.toPlay);
|
||||
const root = parseSgf(task.sgf).root;
|
||||
for (const child of root.children) {
|
||||
if (child.move?.kind !== 'play') continue;
|
||||
const result = applyMove(state, {
|
||||
kind: 'play',
|
||||
color: state.toPlay,
|
||||
point: child.move.point,
|
||||
});
|
||||
expect(result.ok, `${task.id}: ${JSON.stringify(child.move.point)}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('goal-предикат не выполнен на стартовой позиции', () => {
|
||||
for (const task of allTasks) {
|
||||
const session = createTaskSession(task);
|
||||
const snap = session.snapshot();
|
||||
const target = task.goal.type === 'connect' ? snap.markup.circles : snap.markup.triangles;
|
||||
expect(goalReached(snap.state, task.goal, target), task.id).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('главная линия каждой задачи достигает goal (интеграция решений)', () => {
|
||||
for (const task of allTasks) {
|
||||
const session = createTaskSession(task);
|
||||
let status = session.snapshot().status;
|
||||
for (let ply = 0; ply < 100 && status !== 'solved'; ply++) {
|
||||
const point = session.hint().point;
|
||||
expect(point, `${task.id}: линия оборвалась без решения`).not.toBeNull();
|
||||
if (point === null) break;
|
||||
status = session.play(point).status;
|
||||
}
|
||||
expect(status, task.id).toBe('solved');
|
||||
}
|
||||
});
|
||||
|
||||
it('урок 01 ссылается на существующие task-id, frontmatter по контракту', () => {
|
||||
const md = readFileSync(join(contentDir, 'lessons/01-dame-i-zahvat.md'), 'utf8');
|
||||
const frontmatter = /^---\n([\s\S]*?)\n---/.exec(md)?.[1] ?? '';
|
||||
for (const field of ['title:', 'section:', 'order:', 'summary:']) {
|
||||
expect(frontmatter, field).toContain(field);
|
||||
}
|
||||
const section = /^section: ['"]?(.+?)['"]?$/m.exec(frontmatter)?.[1] ?? '';
|
||||
expect(STRINGS.learn.sections).toContain(section);
|
||||
const ids = new Set(allTasks.map((task) => task.id));
|
||||
const referenced = [...md.matchAll(/data-task-id="([^"]+)"/g)].map((match) => match[1] ?? '');
|
||||
expect(referenced.length).toBeGreaterThan(0);
|
||||
for (const id of referenced) expect(ids.has(id), id).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
const webDir = join(srcDir, '..');
|
||||
const distDir = join(webDir, 'dist');
|
||||
let distBuilt = false;
|
||||
|
||||
/** Реальный astro build один раз на прогон (общий для этапов 6–7). */
|
||||
function ensureDistBuild(): void {
|
||||
if (distBuilt) return;
|
||||
execFileSync('npm', ['run', 'build', '--silent'], {
|
||||
cwd: webDir,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
distBuilt = true;
|
||||
}
|
||||
|
||||
describe('wiring этапа 6: astro build и гидратация go-task', () => {
|
||||
it(
|
||||
'astro build PASS, HTML урока содержит go-task, страницы собраны',
|
||||
{ timeout: 240_000 },
|
||||
() => {
|
||||
ensureDistBuild();
|
||||
const dist = distDir;
|
||||
for (const page of ['learn.html', 'tsumego.html', 'progress.html', 'play.html']) {
|
||||
expect(existsSync(join(dist, page)), page).toBe(true);
|
||||
}
|
||||
const md = readFileSync(join(contentDir, 'lessons/01-dame-i-zahvat.md'), 'utf8');
|
||||
const firstTaskId = /data-task-id="([^"]+)"/.exec(md)?.[1] ?? '';
|
||||
const lessonHtml = readFileSync(join(dist, 'learn/01-dame-i-zahvat.html'), 'utf8');
|
||||
expect(lessonHtml).toContain(`<go-task data-task-id="${firstTaskId}">`);
|
||||
expect(lessonHtml).toContain('id="go-task-data"');
|
||||
// Теория читается без JS: запасной текст внутри go-task на месте.
|
||||
expect(lessonHtml).toContain('Запасной текст');
|
||||
const tsumegoHtml = readFileSync(join(dist, 'tsumego.html'), 'utf8');
|
||||
const firstTsumego = allTasks.find((task) => task.kind === 'tsumego');
|
||||
expect(tsumegoHtml).toContain(firstTsumego?.id ?? '');
|
||||
const learnHtml = readFileSync(join(dist, 'learn.html'), 'utf8');
|
||||
expect(learnHtml).toContain('/learn/01-dame-i-zahvat.html');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/* ── Этап 7: .htaccess и бюджет веса страниц уроков ── */
|
||||
|
||||
const PAGE_WEIGHT_BUDGET = 300 * 1024;
|
||||
|
||||
describe('wiring этапа 7: .htaccess попадает в dist с директивами', () => {
|
||||
it(
|
||||
'dist/.htaccess: wasm, deflate, expires, Options -Indexes; без HTTPS-редиректа',
|
||||
{ timeout: 240_000 },
|
||||
() => {
|
||||
ensureDistBuild();
|
||||
const path = join(distDir, '.htaccess');
|
||||
expect(existsSync(path), 'dist/.htaccess').toBe(true);
|
||||
const content = readFileSync(path, 'utf8');
|
||||
for (const directive of [
|
||||
'AddType application/wasm .wasm',
|
||||
'mod_deflate',
|
||||
'DEFLATE text/html',
|
||||
'mod_expires',
|
||||
'ExpiresByType text/html',
|
||||
'immutable',
|
||||
'Options -Indexes',
|
||||
'DirectoryIndex index.html',
|
||||
]) {
|
||||
expect(content, directive).toContain(directive);
|
||||
}
|
||||
// HTTPS-редирект только панелью хостинга (gotcha-https-redirect-proxy).
|
||||
expect(content).not.toContain('RewriteCond %{HTTPS}');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/* ── Этап 8: страница кабинета собирается ── */
|
||||
|
||||
describe('wiring этапа 8: dist/account.html с формами кабинета', () => {
|
||||
it(
|
||||
'astro build PASS, dist/account.html содержит форму входа и ссылки, без формы регистрации',
|
||||
{ timeout: 240_000 },
|
||||
() => {
|
||||
ensureDistBuild();
|
||||
const path = join(distDir, 'account.html');
|
||||
expect(existsSync(path), 'dist/account.html').toBe(true);
|
||||
const html = readFileSync(path, 'utf8');
|
||||
for (const marker of [
|
||||
'id="account-login-form"',
|
||||
'id="account-status"',
|
||||
'role="status"',
|
||||
'Зарегистрируйтесь',
|
||||
'href="/register.html"',
|
||||
'href="/password-reset.html"',
|
||||
]) {
|
||||
expect(html, marker).toContain(marker);
|
||||
}
|
||||
// Этап 8.1: регистрация вынесена на /register.html.
|
||||
expect(html).not.toContain('id="account-register-form"');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/* ── Этап 8.1: регистрация и восстановление пароля ── */
|
||||
|
||||
describe('wiring этапа 8.1: dist/register.html и dist/password-reset.html', () => {
|
||||
it(
|
||||
'astro build PASS, register.html содержит форму с капчей, password-reset.html — оба режима',
|
||||
{ timeout: 240_000 },
|
||||
() => {
|
||||
ensureDistBuild();
|
||||
const registerPath = join(distDir, 'register.html');
|
||||
expect(existsSync(registerPath), 'dist/register.html').toBe(true);
|
||||
const registerHtml = readFileSync(registerPath, 'utf8');
|
||||
for (const marker of [
|
||||
'id="register-form"',
|
||||
'src="/api/captcha.php"',
|
||||
'id="register-captcha"',
|
||||
'id="register-captcha-refresh"',
|
||||
'id="register-password-repeat"',
|
||||
'role="status"',
|
||||
]) {
|
||||
expect(registerHtml, marker).toContain(marker);
|
||||
}
|
||||
const resetPath = join(distDir, 'password-reset.html');
|
||||
expect(existsSync(resetPath), 'dist/password-reset.html').toBe(true);
|
||||
const resetHtml = readFileSync(resetPath, 'utf8');
|
||||
for (const marker of [
|
||||
'id="reset-request-form"',
|
||||
'id="reset-confirm-form"',
|
||||
'id="reset-status"',
|
||||
'role="status"',
|
||||
]) {
|
||||
expect(resetHtml, marker).toContain(marker);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('wiring этапа 7: бюджет веса страниц уроков ≤ 300 КБ', () => {
|
||||
it(
|
||||
'html + локальные ассеты _astro каждой страницы dist/learn/*.html ≤ 300 КБ',
|
||||
{ timeout: 240_000 },
|
||||
() => {
|
||||
ensureDistBuild();
|
||||
const learnDir = join(distDir, 'learn');
|
||||
const pages = readdirSync(learnDir).filter((file) => file.endsWith('.html'));
|
||||
expect(pages.length).toBeGreaterThan(0);
|
||||
const over: string[] = [];
|
||||
for (const page of pages) {
|
||||
const html = readFileSync(join(learnDir, page), 'utf8');
|
||||
let total = statSync(join(learnDir, page)).size;
|
||||
const refs = new Set<string>();
|
||||
for (const match of html.matchAll(/<script[^>]+src="(\/[^"]+)"/g)) {
|
||||
if (match[1] !== undefined) refs.add(match[1]);
|
||||
}
|
||||
for (const match of html.matchAll(/<link[^>]+href="(\/[^"]+)"/g)) {
|
||||
if (match[1] !== undefined) refs.add(match[1]);
|
||||
}
|
||||
for (const ref of refs) {
|
||||
if (!ref.startsWith('/_astro/')) continue;
|
||||
const asset = join(distDir, ref.slice(1));
|
||||
if (existsSync(asset)) total += statSync(asset).size;
|
||||
}
|
||||
if (total > PAGE_WEIGHT_BUDGET) over.push(`${page}: ${total} байт`);
|
||||
}
|
||||
expect(over).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue