Прежняя git-история утрачена при переносе проекта на машину владельца (снапшот без .git). Хэши коммитов в docs/reports/* относятся к утраченной истории. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1127 lines
65 KiB
Markdown
1127 lines
65 KiB
Markdown
# INTERFACES — контракты между модулями
|
||
|
||
Обновлено: 2026-08-05 | Статус: контракт core реализован (этап 1)
|
||
|
||
> Правило: изменение контракта — сначала запись сюда, потом код. Код, нарушающий
|
||
> контракт без обновления файла, — дефект. Удалил сущность из контракта — докажи
|
||
> grep'ом, что её никто не читает.
|
||
|
||
## Единицы и система координат (объявлено один раз, I.6)
|
||
|
||
- Координаты доски: целые, 0-based, `(x, y)`, x — слева направо, y — сверху вниз.
|
||
- Размеры досок: 9, 13, 19. Коми: 6.5 (19×19), 5.5 (9×9 и 13×13) — решение владельца 2026-08-05.
|
||
- Подсчёт: китайские правила, площадь (Tromp-Taylor).
|
||
- Формат обмена партиями: SGF FF[4].
|
||
|
||
## Контракт движка правил (packages/core)
|
||
|
||
Зафиксировано в этапе 1 до написания кода. Все структуры неизменяемы (`readonly`),
|
||
операции возвращают новые объекты. Движок — чистый TS: без импортов DOM/worker API,
|
||
без `Date.now()`/`Math.random()`; время и случайность — только через инжектируемые
|
||
`Clock`/`Rng` (сам core в этапе 1 их не использует, типы объявлены для этапов 3–4).
|
||
|
||
### Базовые типы
|
||
|
||
```ts
|
||
export type BoardSize = 9 | 13 | 19;
|
||
export type Color = 'black' | 'white';
|
||
export type CellState = 'empty' | 'black' | 'white';
|
||
|
||
export interface Point {
|
||
readonly x: number; // 0-based, слева направо
|
||
readonly y: number; // 0-based, сверху вниз
|
||
}
|
||
|
||
export type Move =
|
||
| { readonly kind: 'play'; readonly color: Color; readonly point: Point }
|
||
| { readonly kind: 'pass'; readonly color: Color }
|
||
| { readonly kind: 'resign'; readonly color: Color };
|
||
```
|
||
|
||
### Позиция и правила
|
||
|
||
```ts
|
||
export interface BoardState {
|
||
readonly size: BoardSize;
|
||
/** Строго типизированная структура: обычный Array с readonly-типом поверх,
|
||
* длина size*size, индекс клетки = y*size + x (три состояния клетки). */
|
||
readonly grid: ReadonlyArray<CellState>;
|
||
readonly toPlay: Color;
|
||
/** Сколько камней снял каждый цвет за партию. */
|
||
readonly captures: { readonly black: number; readonly white: number };
|
||
/** Точка простого ко (запрет немедленного обратного захвата) или null. */
|
||
readonly koPoint: Point | null;
|
||
/** Хэши «позиция + toPlay» всех предыдущих позиций (позиционное суперко). */
|
||
readonly positionHashes: ReadonlyArray<string>;
|
||
/** true после двух пасов подряд или resign. */
|
||
readonly over: boolean;
|
||
}
|
||
|
||
/** Машиночитаемая причина отказа — для ветвления UI (подсказки, звуки). */
|
||
export type MoveError =
|
||
'out-of-bounds' | 'occupied' | 'suicide' | 'ko' | 'superko' | 'game-over' | 'wrong-turn';
|
||
|
||
export type MoveResult =
|
||
| { readonly ok: true; readonly state: BoardState; readonly captured: ReadonlyArray<Point> }
|
||
| {
|
||
readonly ok: false;
|
||
readonly state: BoardState;
|
||
readonly reason: MoveError;
|
||
readonly error: string; // человекочитаемое сообщение (ru)
|
||
};
|
||
|
||
export function createBoard(size: BoardSize): BoardState;
|
||
|
||
/** Неизменяемая постановка хода: возвращает новый BoardState.
|
||
* При ok=false state — исходное состояние без изменений.
|
||
* Правила: захват групп без дамэ; самоубийство запрещено, кроме хода,
|
||
* который захватывает чужую группу; простое ко (koPoint); позиционное
|
||
* суперко — запрет повтора любой предыдущей позиции (хэш позиция+toPlay);
|
||
* pass/resign всегда легальны (если партия не окончена); два паса подряд
|
||
* или resign → over: true. */
|
||
export function applyMove(state: BoardState, move: Move): MoveResult;
|
||
|
||
/** Группа в точке: камни и число дамэ; null — клетка пуста или вне доски. */
|
||
export function groupAt(
|
||
state: BoardState,
|
||
point: Point,
|
||
): { readonly stones: ReadonlyArray<Point>; readonly liberties: number } | null;
|
||
|
||
/** Ключ точки для множеств ("x,y") — формат для dead в scorePosition. */
|
||
export function pointKey(point: Point): string;
|
||
```
|
||
|
||
### Подсчёт (китайские правила, площадь, Tromp-Taylor)
|
||
|
||
```ts
|
||
export interface ScoreOptions {
|
||
readonly komi: number;
|
||
/** Мёртвые камни (ключи pointKey, "x,y"): снимаются с доски перед подсчётом. */
|
||
readonly dead: ReadonlySet<string>;
|
||
}
|
||
|
||
export interface ScoreResult {
|
||
readonly black: number; // площадь чёрных: камни + территория
|
||
readonly white: number; // площадь белых: камни + территория (без коми)
|
||
readonly komi: number;
|
||
/** black − white − komi. */
|
||
readonly margin: number;
|
||
/** margin > 0 → 'black', margin < 0 → 'white', margin === 0 → 'draw'. */
|
||
readonly winner: Color | 'draw';
|
||
/** Нейтральные пункты (дамэ, сэки): никому не засчитываются. */
|
||
readonly neutral: ReadonlyArray<Point>;
|
||
}
|
||
|
||
export function scorePosition(state: BoardState, options: ScoreOptions): ScoreResult;
|
||
|
||
/** Эвристика «мёртвая группа»: группа без двух глаз в чужой территории.
|
||
* Грубая; ручная корректировка — за UI. Возвращает список групп. */
|
||
export function suggestDeadGroups(state: BoardState): ReadonlyArray<ReadonlyArray<Point>>;
|
||
|
||
/** Коми по умолчанию: 19×19 → 6.5, 9×9 и 13×13 → 5.5. */
|
||
export function defaultKomi(size: BoardSize): number;
|
||
```
|
||
|
||
### История партии (неизменяемое дерево)
|
||
|
||
```ts
|
||
export interface Label {
|
||
readonly point: Point;
|
||
readonly text: string;
|
||
}
|
||
|
||
export interface NodeMarkup {
|
||
readonly labels: ReadonlyArray<Label>; // LB
|
||
readonly triangles: ReadonlyArray<Point>; // TR
|
||
readonly squares: ReadonlyArray<Point>; // SQ
|
||
readonly circles: ReadonlyArray<Point>; // CR
|
||
}
|
||
|
||
export interface GameNode {
|
||
readonly id: number;
|
||
readonly move: Move | null; // null — корень
|
||
readonly state: BoardState; // позиция ПОСЛЕ хода узла
|
||
readonly comment: string; // '' — нет
|
||
readonly markup: NodeMarkup;
|
||
readonly children: ReadonlyArray<GameNode>;
|
||
}
|
||
|
||
export interface GameMeta {
|
||
readonly blackName: string; // '' — нет
|
||
readonly whiteName: string;
|
||
readonly result: string | null; // RE, напр. "B+R", "W+3.5", "Draw"
|
||
}
|
||
|
||
export interface GameTree {
|
||
readonly size: BoardSize;
|
||
readonly komi: number;
|
||
readonly root: GameNode;
|
||
readonly currentId: number;
|
||
readonly nextId: number; // id для следующего узла
|
||
readonly meta: GameMeta;
|
||
}
|
||
|
||
export interface CreateGameOptions {
|
||
readonly size: BoardSize;
|
||
readonly komi?: number; // по умолчанию defaultKomi(size)
|
||
readonly blackName?: string;
|
||
readonly whiteName?: string;
|
||
}
|
||
|
||
export function createGame(options: CreateGameOptions): GameTree;
|
||
|
||
export type AppendResult =
|
||
| { readonly ok: true; readonly tree: GameTree; readonly nodeId: number }
|
||
| {
|
||
readonly ok: false;
|
||
readonly tree: GameTree;
|
||
readonly reason: MoveError | 'node-not-found';
|
||
readonly error: string;
|
||
};
|
||
|
||
/** Ход от текущего узла; если у текущего уже есть children — это ветвление.
|
||
* Нелегальный ход не меняет дерево (ok=false, tree — исходное).
|
||
* При resign дополнительно проставляет meta.result ("B+R"/"W+R"). */
|
||
export function appendMove(tree: GameTree, move: Move): AppendResult;
|
||
|
||
/** Переход к узлу по id; если узла нет — возвращает дерево без изменений. */
|
||
export function goToNode(tree: GameTree, nodeId: number): GameTree;
|
||
```
|
||
|
||
### SGF FF[4]
|
||
|
||
```ts
|
||
/** Ошибка парсинга SGF; offset — смещение в исходном тексте. */
|
||
export class SgfError extends Error {
|
||
readonly offset: number;
|
||
}
|
||
|
||
/** GM[1] (иное → SgfError), SZ (9/13/19), KM, PB/PW, RE, C, B/W
|
||
* (пустое значение = pass), вариации, LB/TR/SQ/CR.
|
||
* Эскейпинг значений: `\]` → `]`, `\\` → `\`, прочее `\x` → `x`.
|
||
* Нелегальный ход в SGF → SgfError с offset этого хода. */
|
||
export function parseSgf(text: string): GameTree;
|
||
|
||
/** Сериализация с эскейпингом `]` и `\` в значениях.
|
||
* Roundtrip: parseSgf(serializeSgf(tree)) даёт эквивалентное дерево. */
|
||
export function serializeSgf(tree: GameTree): string;
|
||
```
|
||
|
||
### Инжектируемые часы и RNG
|
||
|
||
```ts
|
||
export interface Rng {
|
||
next(): number; // равномерное [0, 1)
|
||
}
|
||
|
||
export interface Clock {
|
||
now(): number; // миллисекунды
|
||
}
|
||
|
||
/** Детерминированный PRNG (mulberry32): одинаковый seed → одинаковая
|
||
* последовательность. Единственный разрешённый источник случайности
|
||
* в тестах и движке. */
|
||
export function createSeededRng(seed: number): Rng;
|
||
```
|
||
|
||
### Дополнение этапа 6 (ADR-0003)
|
||
|
||
```ts
|
||
/** Произвольная стартовая позиция для задач. Валидация: точки в пределах
|
||
* доски, без дублей, каждая группа с ≥1 дамэ; нарушение → Error.
|
||
* captures = 0, koPoint = null, positionHashes = [хэш позиции]. */
|
||
export function buildPosition(
|
||
size: BoardSize,
|
||
black: ReadonlyArray<Point>,
|
||
white: ReadonlyArray<Point>,
|
||
toPlay: Color,
|
||
): BoardState;
|
||
```
|
||
|
||
## Контракт отрисовки доски (packages/board)
|
||
|
||
Статус: контракт board реализован (этап 2). Зафиксирован: 2026-08-05. Canvas 2D;
|
||
вся геометрия и ввод — чистые функции, тестируемые без DOM. Канва исполняет
|
||
draw-команды; DOM-слой — тонкий адаптер.
|
||
|
||
### Геометрия (чистая, без DOM)
|
||
|
||
```ts
|
||
export interface BoardGeometry {
|
||
readonly size: BoardSize;
|
||
readonly pixelSize: number; // логический квадрат канвы в CSS px
|
||
readonly padding: number; // поля (под координаты, если включены)
|
||
readonly cell: number; // шаг сетки в CSS px
|
||
}
|
||
export function computeGeometry(
|
||
size: BoardSize,
|
||
pixelSize: number,
|
||
showCoordinates: boolean,
|
||
): BoardGeometry;
|
||
/** Центр пересечения в CSS px. */
|
||
export function pointToPixel(geo: BoardGeometry, p: Point): { x: number; y: number };
|
||
/** Обратное преобразование; null — курсор дальше половины клетки от любого пересечения. */
|
||
export function pixelToPoint(geo: BoardGeometry, px: number, py: number): Point | null;
|
||
/** Хоси: 19×19 — 9 точек (3-3..15-15 в 1-based), 13×13 — 5, 9×9 — 5. */
|
||
export function hoshiPoints(size: BoardSize): readonly Point[];
|
||
```
|
||
|
||
### Рендер (чистая функция состояния)
|
||
|
||
`render` не хранит состояния и не читает ничего, кроме аргументов: каждый кадр
|
||
рисуется целиком из `state` + `options`. Порядок слоёв: фон доски → заливка
|
||
территории → сетка → хоси → координаты → разметка (SQ/TR/CR) → камни →
|
||
метка последнего хода → номера ходов → фантомный камень → подписи LB.
|
||
|
||
```ts
|
||
export interface BoardTheme {
|
||
readonly boardBackground: string;
|
||
readonly lineColor: string;
|
||
readonly blackStone: string;
|
||
readonly whiteStone: string;
|
||
readonly blackStoneEdge: string;
|
||
readonly whiteStoneEdge: string;
|
||
readonly coordinateColor: string;
|
||
readonly markupColor: string;
|
||
readonly lastMoveMarker: string;
|
||
readonly phantomOpacity: number; // 0..1
|
||
readonly territoryBlack: string; // заливка с альфой
|
||
readonly territoryWhite: string;
|
||
}
|
||
/** Заливка территории при подсчёте: ключи pointKey ("x,y") по цветам. */
|
||
export interface TerritoryMap {
|
||
readonly black: ReadonlySet<string>;
|
||
readonly white: ReadonlySet<string>;
|
||
}
|
||
export interface RenderOptions {
|
||
readonly theme?: Partial<BoardTheme>; // дефолт — тёмная тема
|
||
readonly showCoordinates?: boolean; // дефолт true
|
||
readonly showMoveNumbers?: boolean; // дефолт false; номера — из истории ходов
|
||
readonly moveNumbers?: ReadonlyMap<string, number>; // ключ "x,y" → номер хода
|
||
readonly lastMove?: Point | null;
|
||
readonly phantom?: { readonly point: Point; readonly color: Color } | null;
|
||
readonly markup?: NodeMarkup; // из @go-learn/core
|
||
readonly territory?: TerritoryMap; // заливка при подсчёте
|
||
readonly dead?: ReadonlySet<string>; // ключи "x,y"; помеченные мёртвые — приглушаются
|
||
}
|
||
export function render(
|
||
ctx: CanvasRenderingContext2D,
|
||
state: BoardState,
|
||
options?: RenderOptions,
|
||
): void;
|
||
```
|
||
|
||
Координаты: буквы без «I» (A–H, J–T) снизу, числа слева (1 внизу). Шрифт —
|
||
system-ui, размер от cell.
|
||
|
||
### Тач-ввод с подтверждением (чистый редьюсер)
|
||
|
||
Первый тап/клик — фантом (pending); второй тап по той же точке или `confirm` —
|
||
ход (`commit`); тап по другой точке — перенос фантома; `cancel` — сброс.
|
||
Редьюсер не знает о DOM; DOM-адаптер переводит pointer-события в InputEvent.
|
||
|
||
```ts
|
||
export type InputEvent =
|
||
| { readonly type: 'tap'; readonly px: number; readonly py: number }
|
||
| { readonly type: 'confirm' }
|
||
| { readonly type: 'cancel' };
|
||
export interface InputState {
|
||
readonly pending: Point | null;
|
||
}
|
||
export interface InputResult {
|
||
readonly state: InputState;
|
||
readonly commit: Point | null; // не-null — пользователь подтвердил ход
|
||
}
|
||
export function reduceInput(state: InputState, event: InputEvent, geo: BoardGeometry): InputResult;
|
||
```
|
||
|
||
### DOM-адаптер (тонкий, без логики; юнит-тестами не покрывается)
|
||
|
||
```ts
|
||
/** Выставляет canvas.width/height = cssSize × devicePixelRatio и
|
||
* ctx.setTransform(dpr, …). Возвращает dpr. */
|
||
export function setupCanvas(canvas: HTMLCanvasElement, cssSize: number): number;
|
||
/** Подписывается на pointer-события канвы и вызывает onEvent с InputEvent.
|
||
* Возвращает функцию отписки. */
|
||
export function attachPointerInput(
|
||
canvas: HTMLCanvasElement,
|
||
onEvent: (event: InputEvent) => void,
|
||
): () => void;
|
||
```
|
||
|
||
### Класс данных пакета
|
||
|
||
packages/board импортирует типы из @go-learn/core, но не импортирует DOM-зависимый
|
||
код в geometry/render/input (CanvasRenderingContext2D — только как тип параметра).
|
||
Date.now()/Math.random() запрещены и здесь.
|
||
|
||
## Контракт ИИ ур. 1–3 (packages/ai)
|
||
|
||
Статус: контракт ai реализован (этап 3). Зафиксирован: 2026-08-05.
|
||
Эвристический бот, синхронный чистый
|
||
вычислитель: без DOM, без воркер-API, без собственного состояния. Случайность —
|
||
только через переданный seeded `Rng` из @go-learn/core (детерминизм =
|
||
воспроизводимые партии). Легальность ходов проверяется через applyMove из core —
|
||
никакого дублирования правил.
|
||
|
||
```ts
|
||
import type { BoardState, Move, Point, Rng } from '@go-learn/core';
|
||
|
||
export type AiLevel = 1 | 2 | 3;
|
||
|
||
/** Вероятность применения приоритетов «спасти своё атари» / «забрать чужое»
|
||
* на каждом уровне (ур.1 ≈ 0.3, ур.3 ≈ 0.9 — по спеке). */
|
||
export const LEVEL_ATARI_PROBABILITY: Readonly<Record<AiLevel, number>>;
|
||
|
||
export interface AiMoveRequest {
|
||
readonly state: BoardState; // ходит state.toPlay
|
||
readonly level: AiLevel;
|
||
readonly rng: Rng; // seeded снаружи
|
||
readonly lastOpponentMove?: Point | null; // для приоритета «вблизи хода противника»
|
||
}
|
||
|
||
export type AiReason =
|
||
| 'save-atari' // спас свою группу в атари
|
||
| 'capture-atari' // забрал чужую группу в атари
|
||
| 'nearby' // взвешенный ход вблизи lastOpponentMove (Chebyshev ≤ 2)
|
||
| 'random' // случайный легальный
|
||
| 'pass'; // нет кандидатов
|
||
|
||
export interface AiMoveResult {
|
||
readonly move: Move; // kind 'play' | 'pass'; resign на ур. 1–3 не генерируется
|
||
readonly reason: AiReason;
|
||
}
|
||
|
||
/** Вызов при state.over === true — программная ошибка: кидает Error. */
|
||
export function chooseMove(request: AiMoveRequest): AiMoveResult;
|
||
```
|
||
|
||
Порядок приоритетов (спека этапа 3):
|
||
|
||
1. **save-atari** — своя группа с 1 дамэ → ход, после которого у группы >1 дамэ
|
||
(включая контрзахват). Применяется с вероятностью p(level); при «промахе»
|
||
ролла — переход к следующему приоритету.
|
||
2. **capture-atari** — чужая группа с 1 дамэ → легальный ход в последнее дамэ.
|
||
Та же вероятность p(level).
|
||
3. **nearby** — взвешенный случайный среди легальных кандидатов в радиусе
|
||
Chebyshev ≤ 2 от lastOpponentMove (если он передан и кандидаты есть).
|
||
4. **random** — случайный из оставшихся легальных кандидатов.
|
||
5. **pass** — только если кандидатов не осталось.
|
||
|
||
Фильтр кандидатов (применяется ко всем приоритетам): легальный ход, не
|
||
заполняющий свой глаз — простая эвристика: все ортогональные соседи точки —
|
||
свои камни, и ход ничего не захватывает (захват не может быть заполнением глаза).
|
||
|
||
## Контракт MCTS ИИ ур. 4–6 (packages/ai, этап 4)
|
||
|
||
Статус: контракт MCTS + протокол воркера реализован (этап 4). Зафиксирован: 2026-08-05 (этап 4). Движок MCTS — чистый вычислитель в packages/ai:
|
||
без DOM и воркер-API (воркер-обёртка — отдельный тонкий модуль). Однопоточный код
|
||
(SharedArrayBuffer требует COOP/COEP — на шаред-хостинге может не быть). Часы и
|
||
случайность инжектируются (Clock/Rng из core) — реплеи MCTS воспроизводимы.
|
||
|
||
### Движок MCTS (чистый)
|
||
|
||
```ts
|
||
export type MctsLevel = 4 | 5 | 6;
|
||
|
||
/** Бюджет по времени: ур.4 — 300 мс, ур.5 — 800 мс (дефолт между уровнями,
|
||
* спека задаёт только крайние), ур.6 — 1500 мс. */
|
||
export const LEVEL_TIME_BUDGET_MS: Readonly<Record<MctsLevel, number>>;
|
||
|
||
export interface MctsRequest {
|
||
readonly state: BoardState; // ходит state.toPlay
|
||
readonly komi: number; // для оценки плейаута (scorePosition)
|
||
readonly level: MctsLevel;
|
||
readonly rng: Rng; // seeded снаружи
|
||
readonly clock: Clock; // бюджет по времени
|
||
readonly maxSimulations?: number; // override для тестов/отладки: игнорирует
|
||
// тайм-бюджет, крутит ровно N симуляций
|
||
readonly shouldStop?: () => boolean; // отмена: проверяется между симуляциями
|
||
}
|
||
|
||
export interface CandidateScore {
|
||
readonly move: Move; // play или pass
|
||
readonly winRate: number; // 0..1 по визитам
|
||
readonly visits: number;
|
||
}
|
||
|
||
export interface MctsResult {
|
||
readonly move: Move; // лучший по визитам (UCT — внутри дерева)
|
||
readonly topMoves: readonly CandidateScore[]; // до 3 лучших — режим подсказки этапа 5
|
||
readonly simulations: number;
|
||
readonly elapsedMs: number; // по инжектированным часам
|
||
}
|
||
|
||
/** Вызов при state.over === true — программная ошибка: кидает Error. */
|
||
export function chooseMoveMcts(request: MctsRequest): MctsResult;
|
||
```
|
||
|
||
Правила движка:
|
||
|
||
- Дерево: UCT (c = 1.4), ленивая экспансия легальных детей (легальность — только
|
||
applyMove из core), pass — легальный узел.
|
||
- Плейаут — «лёгкая» политика (НЕ chooseMove этапа 3 — тот сканирует доску
|
||
целиком и слишком дорог): дешёвая реакция на атари вокруг последнего хода,
|
||
иначе случайные точки с фильтром своего глаза и лимитом попыток; конец —
|
||
два паса или кап ходов size²×2. Оценка — scorePosition с komi запроса.
|
||
- Таргет 9×9 и 13×13; на 19×19 работает, но честно слабее (предупреждение — UI
|
||
этапа 5, движок обязан просто укладываться в бюджет).
|
||
- Бюджет: остановка по clock.now() >= deadline ИЛИ shouldStop(), что раньше;
|
||
при maxSimulations — ровно N итераций (детерминизм тестов).
|
||
- При shouldStop() до первой завершённой симуляции — ход всё равно возвращается
|
||
(лучший из оценённых или легальный random с reason-эквивалентом; обязан быть
|
||
легальным).
|
||
|
||
### Протокол postMessage воркера (этап 4)
|
||
|
||
Сериализация позиции — СПИСОКОМ ХОДОВ от начала партии (компактно, воркер
|
||
пересобирает BoardState через applyMove и заодно валидирует; расхождение —
|
||
error-ответ). Типы сообщений:
|
||
|
||
```ts
|
||
export interface WorkerChooseRequest {
|
||
readonly type: 'choose';
|
||
readonly requestId: number;
|
||
readonly size: BoardSize;
|
||
readonly komi: number;
|
||
readonly moves: readonly Move[]; // вся партия от пустой доски
|
||
readonly level: MctsLevel;
|
||
readonly seed: number; // seed воркера (партия + requestId — забота UI)
|
||
readonly timeBudgetMs: number;
|
||
}
|
||
export interface WorkerCancelRequest {
|
||
readonly type: 'cancel';
|
||
readonly requestId: number;
|
||
}
|
||
export type WorkerRequest = WorkerChooseRequest | WorkerCancelRequest;
|
||
|
||
export type WorkerResponse =
|
||
| {
|
||
readonly type: 'result';
|
||
readonly requestId: number;
|
||
readonly move: Move;
|
||
readonly topMoves: readonly CandidateScore[];
|
||
readonly simulations: number;
|
||
readonly elapsedMs: number;
|
||
}
|
||
| { readonly type: 'error'; readonly requestId: number; readonly message: string };
|
||
```
|
||
|
||
- Воркер одноразовый на партию или переиспользуемый — решение UI этапа 5;
|
||
протокол stateless: каждый choose несёт полный список ходов.
|
||
- cancel: воркер прерывает вычисление активного requestId и отвечает result с
|
||
лучшим найденным ходом (НЕ молчит, НЕ error).
|
||
- Модуль packages/ai/src/worker.ts — тонкая обёртка: парсинг → пересборка
|
||
позиции → chooseMoveMcts → ответ. Абстракция порта:
|
||
`interface MessagePort { postMessage(msg: WorkerResponse): void; }` —
|
||
протокол тестируется через фейк-порт без реального Worker.
|
||
|
||
## Форматы данных уроков и задач (этап 6)
|
||
|
||
Статус: реализовано (этап 6, 2026-08-06). Уточнение: goal проверяется
|
||
после каждого хода игрока, ДО авто-ответа соперника (фикс интеграции,
|
||
tsm-liberties-escape).
|
||
|
||
Зафиксировано: 2026-08-06 (этап 6). Зона реализации — apps/web (страницы,
|
||
контент, lib); packages/* не меняются, кроме одной аддитивной функции
|
||
buildPosition в core (ADR-0003). Весь контент проекта — оригинальный,
|
||
авторство команды проекта, фиксируется в CREDITS.md (ADR-0001, I.16).
|
||
|
||
### Разделы курса (фиксированный список, порядок = порядок прохождения)
|
||
|
||
1. «Дамэ и захват» 2. «Атари, лестница, сеть» 3. «Самоубийство и ко»
|
||
2. «Глаза: жизнь и смерть» 5. «Подсчёт и конец партии» 6. «Связь и разрезание»
|
||
3. «Углы, стороны, центр» 8. «Базовые дзёсэки» 9. «Форма» 10. «Простое ёсэ».
|
||
|
||
### Урок (Markdown)
|
||
|
||
- Файлы: `apps/web/src/content/lessons/<id>.md`, коллекция `lessons`
|
||
(Astro content layer, glob loader); `<id>` — kebab-case, он же slug страницы.
|
||
- Frontmatter (обязателен целиком):
|
||
|
||
```yaml
|
||
---
|
||
title: string # название урока
|
||
section: string # один из 10 разделов выше, точное совпадение
|
||
order: number # порядок внутри раздела, 1-based
|
||
summary: string # 1–2 предложения для карточки урока
|
||
---
|
||
```
|
||
|
||
- Тело — Markdown-теория, обязана читаться без JS (критерий этапа 7 уже
|
||
держим в уме). Интерактивные шаги вставляются прямо в текст элементом
|
||
`<go-task data-task-id="<task-id>">запасной текст без JS</go-task>`;
|
||
порядок элементов в теле = порядок шагов урока. Никаких remark-плагинов:
|
||
элемент — обычный raw HTML в Markdown, виджет гидрируется клиентским
|
||
скриптом страницы урока.
|
||
- Минимум контента: по уроку на каждый из 10 разделов; в уроках с
|
||
интерактивом — ≥1 `<go-task>` (kind: 'lesson').
|
||
|
||
### Задача (интерактивный шаг урока и цумэ-го)
|
||
|
||
- Файлы: `apps/web/src/content/tasks/*.json`, один файл — массив TaskV1
|
||
(например, по теме). Коллекция `tasks` (glob loader, JSON).
|
||
|
||
```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; // доска задачи (цумэ-го — обычно 9)
|
||
readonly toPlay: PlayerColor; // чей ход первым в задаче
|
||
readonly setupBlack: readonly Point[]; // стартовые камни чёрных (ADR-0003)
|
||
readonly setupWhite: readonly Point[]; // стартовые камни белых
|
||
readonly sgf: string; // дерево вариантов от стартовой позиции + TR/CR цели
|
||
readonly goal: GoalSpec;
|
||
readonly hint?: string; // текстовая подсказка (кнопка «Подсказка»)
|
||
}
|
||
|
||
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 формальных глаза
|
||
```
|
||
|
||
- Позиция задачи: поля `setupBlack`/`setupWhite` + `buildPosition` из core
|
||
(ADR-0003). SGF задачи: дерево вариантов от стартовой позиции; цель
|
||
размечена `TR` (для
|
||
capture-target / liberties / two-eyes) или `CR` (для connect; MA
|
||
парсером не поддерживается, ADR-0003). Дерево
|
||
вариантов в том же SGF — допустимые линии «ход игрока → ответ соперника →
|
||
…»; варианты — контент для автоответов и подсветки, НЕ критерий успеха.
|
||
- Критерий успеха — ТОЛЬКО goal-предикат, вычисленный движком на текущей
|
||
позиции (apps/web/src/lib/goals.ts, чистые функции над экспортами
|
||
@go-learn/core). После каждого хода игрока предикат проверяется заново;
|
||
выполнен → задача решена, даже если линия шла не по дереву (I.6: условие
|
||
успеха проверяется движком, не хардкодом координат).
|
||
- Ход игрока вне вариантов дерева → статус «неверно», ход авто-отменяется,
|
||
attempts++. Ход из дерева → авто-ответ соперника (дочерний узел варианта),
|
||
затем проверка goal. Кнопки виджета: «Сброс», «Подсказка» (hint или
|
||
первый ход ведущего варианта).
|
||
- Формальный глаз (упрощение v1): пустая область из 1–3 смежных пустых
|
||
пунктов, у которой все ортогональные соседи за её пределами — камни одного
|
||
цвета (или край доски). two-eyes = у группы цели ≥2 различных таких глаза.
|
||
Ложный глаз в v1 не отличаем — зафиксированное упрощение; уроки про ложный
|
||
глаз используют capture-target. Уточнение — только через ADR.
|
||
- Цумэ-го: ≥30 задач kind: 'tsumego', разброс difficulty 1–5 (в каждой
|
||
градации ≥3), темы из topics уроков (атари, лестница, сеть, захват, связь,
|
||
разрезание, глаза, жизнь и смерть, ёсэ, дзёсэки).
|
||
|
||
### Прогресс курса (localStorage, этап 6)
|
||
|
||
Ключ `go-learn:progress`, тот же конверт (version + FNV-1a checksum), что и
|
||
у настроек; правила чтения/миграции/записи — те же (битое → дефолты +
|
||
recovered, запись только через save-функции).
|
||
|
||
```ts
|
||
export interface ProgressV1 {
|
||
readonly lessons: Readonly<Record<string, LessonProgress>>; // key = lesson id
|
||
readonly tasks: Readonly<Record<string, TaskProgress>>; // key = task id
|
||
readonly streak: StreakV1;
|
||
}
|
||
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; // дней подряд
|
||
}
|
||
```
|
||
|
||
API (apps/web/src/lib/storage.ts, дополнение):
|
||
|
||
```ts
|
||
export function loadProgress(): { progress: ProgressV1; recovered: boolean };
|
||
export function saveProgress(progress: ProgressV1): void;
|
||
export const DEFAULT_PROGRESS: ProgressV1;
|
||
/** Чистая функция серии дней: вчера → current+1, сегодня → без изменений,
|
||
* иначе → 1. today — 'YYYY-MM-DD' локальной даты. */
|
||
export function touchStreak(progress: ProgressV1, today: string): ProgressV1;
|
||
```
|
||
|
||
Roundtrip-тест обязателен (save → load идентичность; битый
|
||
JSON/checksum/version → дефолты + recovered: true). Серия дней обновляется
|
||
при первом решении задачи за день (через touchStreak).
|
||
|
||
### Страницы этапа 6 (apps/web)
|
||
|
||
- `/learn` — программа по разделам (фиксированный порядок), карточки уроков
|
||
(title, summary, пометка «пройден»).
|
||
- `/learn/<id>` — урок: Markdown-теория + гидрированные go-task виджеты
|
||
(канва packages/board, ввод reduceInput, статус-строка, Сброс/Подсказка);
|
||
без JS видна вся теория и запасной текст задач.
|
||
- `/tsumego` — список задач, фильтры: тема, сложность, статус
|
||
(все/решенные/нерешенные); переход к задаче — `/tsumego` с раскрытым
|
||
виджетом или отдельная подстраница (выбор исполнителя, зафиксировать в
|
||
plan-phase-6).
|
||
- `/progress` — «Мой прогресс»: уроки пройдено X/Y, задачи решено X/Y
|
||
(отдельно уроки/цумэ-го), серия дней; recovered-пометка при битом
|
||
хранилище.
|
||
- Навигация Layout.astro: Курс, Цумэ-го, Прогресс, Играть. Все новые строки
|
||
UI — только через ui/strings.ts.
|
||
|
||
## Схема localStorage (этапы 5–6)
|
||
|
||
Статус: реализовано (этап 5) — apps/web/src/lib/storage.ts; сигнатуры
|
||
loadSettings/saveSettings дополнены необязательным параметром StorageLike
|
||
(инжекция для тестов и отсутствия localStorage), поведение по контракту.
|
||
|
||
Зафиксировано: 2026-08-05 (этап 5). Класс данных: настройки и прогресс не
|
||
покидают браузер, во внешние сервисы не отправляются (ADR-0001, I.16).
|
||
Битое состояние не роняет приложение: старт с дефолтов с пометкой recovered.
|
||
|
||
### Конверт (все ключи `go-learn:*`)
|
||
|
||
```ts
|
||
export interface StorageEnvelope<T> {
|
||
readonly version: number; // версия схемы payload
|
||
readonly checksum: string; // FNV-1a (hex) от JSON.stringify(payload)
|
||
readonly payload: T;
|
||
}
|
||
```
|
||
|
||
- Чтение: нет ключа → дефолты (recovered: false); битый JSON / несовпадение
|
||
checksum / неизвестная version → дефолты + recovered: true (в UI — тихая
|
||
пометка, без алертов).
|
||
- Миграция: при появлении V2 — функция migrate(payload, fromVersion) до
|
||
applyDefaults; односторонняя, в момент загрузки.
|
||
- Запись — только через save-функции (они же считают checksum); прямой
|
||
localStorage.setItem вне storage-модуля запрещён.
|
||
|
||
### Ключ `go-learn:settings`, version: 1
|
||
|
||
```ts
|
||
export interface SettingsV1 {
|
||
readonly size: BoardSize; // дефолт 9
|
||
readonly level: 1 | 2 | 3 | 4 | 5 | 6; // дефолт 2
|
||
readonly playerColor: 'black' | 'white' | 'random'; // дефолт 'black'
|
||
readonly komi: number | 'auto'; // дефолт 'auto' → defaultKomi(size)
|
||
readonly showCoordinates: boolean; // дефолт true
|
||
readonly showMoveNumbers: boolean; // дефолт false
|
||
readonly hintsEnabled: boolean; // дефолт false; смысл — ур. 4+
|
||
}
|
||
```
|
||
|
||
API модуля хранения (apps/web/src/lib/storage.ts):
|
||
|
||
```ts
|
||
export function loadSettings(): { settings: SettingsV1; recovered: boolean };
|
||
export function saveSettings(settings: SettingsV1): void;
|
||
export function checksumOf(json: string): string; // FNV-1a hex
|
||
export const DEFAULT_SETTINGS: SettingsV1;
|
||
```
|
||
|
||
Roundtrip-тест обязателен: save → load даёт идентичные настройки; подпорченный
|
||
JSON/checksum/version → дефолты + recovered: true.
|
||
|
||
## Клиент ИИ для UI (apps/web, этап 5)
|
||
|
||
Статус: реализовано (этап 5) — apps/web/src/lib/ai-client.ts (+ entry
|
||
apps/web/src/ai-worker.ts). Дополнение к контракту: у AiClient есть
|
||
`readonly fallback: boolean` — флаг перехода на синхронный фолбэк
|
||
(предусмотрен текстом контракта: «флаг в ответе/состоянии для пометки в UI»).
|
||
|
||
Зафиксировано: 2026-08-05. Тонкий модуль над протоколом воркера этапа 4.
|
||
|
||
```ts
|
||
export interface AiClient {
|
||
/** moves — вся партия от пустой доски (протокол stateless). */
|
||
choose(request: {
|
||
size: BoardSize;
|
||
komi: number;
|
||
moves: readonly Move[];
|
||
level: 1 | 2 | 3 | 4 | 5 | 6;
|
||
seed: number;
|
||
}): Promise<AiAnswer>;
|
||
/** Отмена активного запроса: terminate + пересоздание воркера
|
||
* (реальный Worker не примет cancel во время синхронного счёта —
|
||
* известная проблема, PROJECT_STATE). Promise старого запроса
|
||
* отклоняется с AiCancelledError. */
|
||
cancel(): void;
|
||
}
|
||
export interface AiAnswer {
|
||
readonly move: Move;
|
||
readonly topMoves?: readonly CandidateScore[]; // есть на ур. 4–6
|
||
readonly simulations?: number;
|
||
}
|
||
export class AiCancelledError extends Error {}
|
||
export function createAiClient(): AiClient;
|
||
```
|
||
|
||
- Ур. 1–3 — синхронный chooseMove этапа 3 (мгновенно, воркер не нужен),
|
||
ур. 4–6 — Web Worker (chooseMoveMcts через worker.ts); seed формирует
|
||
клиент (seed партии + requestId).
|
||
- Fail-safe: Worker недоступен/упал → синхронный фолбэк в основном потоке
|
||
(ур. 4–6 подморозит UI на бюджет) + письменная пометка в UI; не молчим.
|
||
- Режим подсказки (ур. 4+, hintsEnabled): topMoves последнего ответа —
|
||
разметка на доске + оценка; дельта — изменение winRate лучшего хода между
|
||
запросами (упрощение зафиксировано в plan-phase-5).
|
||
|
||
## Полировка: .htaccess, a11y, бюджет веса (этап 7)
|
||
|
||
Зафиксировано: 2026-08-06 (этап 7). Зона — apps/web (pages, ui, lib,
|
||
public/, Layout, strings) + wiring-тесты; packages/* не меняются, новых
|
||
зависимостей нет.
|
||
|
||
### .htaccess (apps/web/public/.htaccess → dist/.htaccess)
|
||
|
||
Директивы фиксированы (Apache shared hosting, ADR-0001):
|
||
|
||
- `AddType application/wasm .wasm` — иначе WASM не стартанёт (нужен этапу 9,
|
||
ставим сейчас).
|
||
- mod_deflate: gzip для text/html, text/css, application/javascript,
|
||
application/json, image/svg+xml.
|
||
- mod_expires: `/_astro/` — 1 год + `immutable` (имена файлов с хэшем);
|
||
`text/html` — `no-cache, must-revalidate` (через ExpiresDefault «access
|
||
0 seconds» + заголовок Cache-Control); прочее — 1 час.
|
||
- `Options -Indexes`, `DirectoryIndex index.html`.
|
||
- HTTPS-редиректа в файле НЕТ и быть не должно (комментарий в файле:
|
||
только панелью хостинга; RewriteCond %{HTTPS} за SSL-прокси = вечный
|
||
редирект, gotcha-https-redirect-proxy).
|
||
- Wiring-тест: после astro build файл dist/.htaccess существует и содержит
|
||
AddType wasm, deflate, expires, `Options -Indexes`; НЕ содержит
|
||
`RewriteCond %{HTTPS}`.
|
||
|
||
### Доступность (a11y)
|
||
|
||
- Клавиатурное управление доской (страница /play и виджет go-task): канва
|
||
focusable (`tabindex="0"`, aria-label из strings). Стрелки двигают
|
||
курсор-крестик по пересечениям (старт — центр доски), Enter/Space —
|
||
эквивалент tap по курсору через reduceInput (первое нажатие — фантом,
|
||
второе — ход; как тач), Escape — cancel. Курсор виден на доске
|
||
(маркер через существующие RenderOptions/фантом).
|
||
- Чистый редьюсер курсора — apps/web/src/lib/keyboard.ts:
|
||
`reduceCursor(cursor: Point, key: 'up'|'down'|'left'|'right', size: BoardSize): Point`
|
||
(кламп по краям) + юнит-тесты. DOM-подписка keydown — в play-page.ts и
|
||
task-widget.ts (тонкая, без логики).
|
||
- Статусные строки (статус партии, статус задачи, сообщения прогресса) —
|
||
`role="status" aria-live="polite"`.
|
||
- `@media (prefers-reduced-motion: reduce)`: все transition/animation
|
||
отключены (глобальный блок в Layout).
|
||
- Контраст основного текста и кнопок ≥ 4.5:1 к фону (проверить --text и
|
||
--muted на --bg/--surface; при недоборе осветлить --muted, фикс в одном
|
||
месте — :root). Видимый :focus-visible outline (accent).
|
||
- Все новые строки (подсказки клавиатуры, aria-labels) — через strings.ts.
|
||
|
||
### Отметка «урок пройден» (дополнение этапа 6)
|
||
|
||
На странице урока — кнопка «Отметить пройденным» / состояние «Пройден»:
|
||
запись `lessons[id].done = true` через saveProgress (+ touchStreak),
|
||
чтение — loadProgress. Чистый помощник
|
||
`markLessonDone(progress, lessonId, today): ProgressV1` в storage.ts +
|
||
тест. Это закрывает теоретические уроки 7–9 (без go-task).
|
||
|
||
### Бюджет веса страницы урока
|
||
|
||
≤ 300 КБ по сети на страницу урока (спека этапа 7). Wiring-тест: для
|
||
каждого dist/learn/*.html сумма байт = html + все локальные
|
||
script src / link href ассеты из _astro → ≤ 300·1024 (gzip-вес
|
||
оцениваем по сырым байтам × — берём сырые, с запасом). При превышении —
|
||
сначала уменьшать страницу (ленивая гидратация, разделение чанков), не
|
||
ослаблять бюджет без ADR.
|
||
|
||
## Личный кабинет и синхронизация прогресса (этап 8)
|
||
|
||
Зафиксировано: 2026-08-06 (этап 8). Активирован отдельной командой
|
||
владельца; модель учётки — email + пароль по решению владельца
|
||
(ADR-0004, отступление от «анонимного токена» спеки). До-триаж раздела V
|
||
литании — там же. Зоны: server/ (PHP API) и apps/web (кабинет + sync).
|
||
Новых npm-зависимостей нет; PHP — без фреймворка, синтаксис ≥ PHP 8.0
|
||
(хостинги), локальная проверка на PHP 8.2.
|
||
|
||
### Хранилище (SQLite, PDO)
|
||
|
||
- БД SQLite вне web root; путь — config.php (не в репо; в репо —
|
||
server/config.example.php с плейсхолдерами). PRAGMA journal_mode=WAL,
|
||
busy_timeout=5000, foreign_keys=ON.
|
||
- Схема (миграция init при первом обращении, idempotent):
|
||
users(id INTEGER PK, email TEXT UNIQUE COLLATE NOCASE,
|
||
pass_hash TEXT, created_at TEXT ISO);
|
||
progress(user_id INTEGER PK REFERENCES users, payload TEXT JSON,
|
||
updated_at TEXT ISO);
|
||
rate_log(ip TEXT, action TEXT, ts INTEGER); индекс (ip, action, ts).
|
||
- Prepared statements ВЕЗДЕ (никакой конкатенации). Лимит тела запроса
|
||
256 КБ (413 сверх). JSON-only ответы, charset utf-8.
|
||
|
||
### API (server/api/_.php; на хостинге — /api/_)
|
||
|
||
- `POST /api/register.php` {email, password} → 201 {ok:true};
|
||
422 (невалидный email / пароль < 8); 409 (email занят).
|
||
password_hash (PASSWORD_DEFAULT), password_verify.
|
||
- `POST /api/login.php` {email, password} → 200 {ok:true} + сессия;
|
||
401 (неверная пара). Сессия — PHP native, cookie HttpOnly,
|
||
SameSite=Lax, Secure при HTTPS.
|
||
- `POST /api/logout.php` → 200 {ok:true}, сессия уничтожена.
|
||
- `GET /api/me.php` → 200 {email} / 401.
|
||
- `GET /api/progress.php` → 200 {payload: ProgressV2, updatedAt} /
|
||
204 (прогресса нет) / 401.
|
||
- `PUT /api/progress.php` {payload} → 200 {updatedAt} / 401 / 413 / 422
|
||
(payload не объект). Хранит payload как есть (opaque), updated_at —
|
||
серверное время.
|
||
- `GET /api/health.php` → 200 {"status":"ok"} (V.6, без текста
|
||
исключений наружу).
|
||
- Мутирующие эндпоинты требуют заголовок `X-GoLearn-Client: web`
|
||
(CSRF-мерa вместе с SameSite=Lax); иначе 403.
|
||
- Rate limiting по IP из rate_log: login/register — 10 попыток за 10 мин
|
||
на IP → 429 {error:"rate_limited"}.
|
||
- Общий код — server/lib/ (db.php, http.php, auth.php, ratelimit.php).
|
||
- Заголовки безопасности на все ответы API: X-Content-Type-Options:
|
||
nosniff, Cache-Control: no-store.
|
||
|
||
### Прогресс V2 и синхронизация (apps/web)
|
||
|
||
- ProgressV2 = ProgressV1 + `readonly updatedAt: string` (ISO локального
|
||
изменения). Миграция V1→V2 в storage.ts (migrate до applyDefaults по
|
||
контракту конверта): updatedAt = момент миграции; roundtrip-тест
|
||
V1→V2 обязателен. Каждый saveProgress обновляет updatedAt (Clock
|
||
инжектируется параметром, не Date.now в чистых функциях).
|
||
- sync-client.ts: `syncProgress(): Promise<SyncResult>` — GET; 401 →
|
||
'unauthorized'; недоступен/404 → 'unavailable' (тихая деградация, UI
|
||
помечает «синхронизация недоступна», НЕ алерт); сервер новее
|
||
(updated_at > local.updatedAt) → принять серверный payload
|
||
('pulled'); локальный новее → PUT ('pushed'); равны → 'noop'.
|
||
- Страница /account («Кабинет»): формы регистрации/входа (email+пароль),
|
||
состояние «вы вошли как …», кнопки «Синхронизировать», «Выйти»;
|
||
статус синхронизации через role="status". Вход выполняется fetch'ем
|
||
на /api/*.php относительно текущего origin. В навигацию — «Кабинет».
|
||
- Текст-оговорка в UI: восстановления пароля нет (нет отправки почты,
|
||
ADR-0004) — предупреждение при регистрации.
|
||
- Строки — strings.ts; клавиатура/a11y — как на прочих страницах.
|
||
|
||
### Тесты этапа 8
|
||
|
||
- server/tests/smoke.sh: поднимает `php -S 127.0.0.1:8099` на server/,
|
||
прогон curl: register 201 → повтор 409 → login 200 → cookie →
|
||
PUT progress → GET progress (совпадение payload) → неверный пароль
|
||
401 → rate limit 429 (11 попыток) → health 200 {"status":"ok"} →
|
||
POST без X-GoLearn-Client → 403. Очищает временную БД.
|
||
- vitest (apps/web): migrate V1→V2, roundtrip, sync-merge логика
|
||
(pulled/pushed/noop/unavailable) как чистая функция mergeDecision +
|
||
fetch-моки.
|
||
|
||
## Кабинет 2.0: регистрация отдельной страницей, восстановление пароля, капча (этап 8.1)
|
||
|
||
Зафиксировано: 2026-08-06. Решения владельца (ask_user): своя автономная
|
||
капча; OAuth (VK ID / Яндекс ID) — позже отдельным этапом, дизайн —
|
||
docs/design/oauth-vk-yandex.md.
|
||
|
||
### UX регистрации/входа
|
||
|
||
- /account.html — страница ВХОДА: форма входа; под кнопкой «Войти»
|
||
ссылка «Нет аккаунта? Зарегистрируйтесь» → /register.html; ниже —
|
||
ссылка «Забыли пароль?» → /password-reset.html.
|
||
- /register.html — отдельная страница регистрации: email, пароль (≥8),
|
||
повтор пароля, капча (картинка + поле + кнопка «обновить капчу»),
|
||
предупреждение о невозможности восстановления УБРАТЬ (появилось
|
||
восстановление); после успеха — авто-вход и редирект на /account.html.
|
||
- /password-reset.html — два режима по query: без token — форма «email»
|
||
(запрос письма); с ?token= — форма «новый пароль ×2». Статусы через
|
||
role="status", без алертов.
|
||
- Поведение авторизованного на /account — как в этапе 8 (sync/выйти).
|
||
|
||
### Капча (автономная, без внешних сервисов и GD)
|
||
|
||
- `GET /api/captcha.php` → 200 image/svg+xml: 5 символов (алфавит без
|
||
неоднозначных: без 0/O, 1/I/l), каждый glyph — случайный поворот/
|
||
сдвиг/размер, 3–5 шумовых кривых; в сессию пишется
|
||
sha256(strtolower(code)) + expires (10 мин). SVG, НЕ GD: на части
|
||
хостингов GD нет, а SVG рендерит любой браузер.
|
||
- Проверка — одноразовая: любая попытка (успех/ошибка) сжигает код;
|
||
новая попытка = новая капча (клиент обновляет картинку после любой
|
||
ошибки регистрации). register.php принимает поле captcha; неверная/
|
||
истёкшая/отсутствующая → 422 {error:"captcha"}.
|
||
- Капча только на register (login защищён rate limit).
|
||
|
||
### Восстановление пароля
|
||
|
||
- Таблица password_resets(id INTEGER PK, email TEXT, token_hash TEXT,
|
||
expires_at INTEGER); аддитивно в init-миграцию.
|
||
- `POST /api/password-reset/request.php` {email} → ВСЕГДА 200 {ok:true}
|
||
(не раскрываем существование учётки). Если email есть: токен
|
||
bin2hex(random_bytes(32)), в БД — sha256(token), expires = +1 час;
|
||
письмо mail() со ссылкой {base_url}/password-reset.html?token=…
|
||
(base_url и mail_from — config.php). Сбой mail() → 503
|
||
{error:"mail_unavailable"} (это отказ сервиса, не enumeration).
|
||
Rate limit: 5 за 10 мин на IP → 429.
|
||
- `POST /api/password-reset/confirm.php` {token, password} → 200
|
||
{ok:true} (пароль обновлён password_hash, токен удалён, просроченные
|
||
токены чистятся) | 400 {error:"token_invalid"} | 422 (пароль <8).
|
||
Rate limit: 10 за 10 мин на IP. Оба — с X-GoLearn-Client.
|
||
- Письмо — plain text, From: mail_from, тема «Восстановление пароля —
|
||
Го»; в тексте ссылка и срок 1 час. Доставляемость на shared-хостинге
|
||
хрупкая (SPF/DKIM панели) — риск в PROJECT_STATE, проверка — шаг в
|
||
deploy.md.
|
||
|
||
### Тесты этапа 8.1
|
||
|
||
- smoke.sh += : captcha.php → 200 image/svg+xml + Set-Cookie сессии;
|
||
register без/с неверной капчей → 422 captcha; полный цикл с верной
|
||
капчей (код извлекается из сессии тестовым хуком: при env
|
||
GOLEARN_TEST=1 captcha.php дополнительно отдаёт заголовок
|
||
X-Captcha-Debug с кодом — ТОЛЬКО в тестовом режиме, в проде env
|
||
отсутствует); reset request на несуществующий email → 200; request на
|
||
существующий → 200 (mail() в тесте: GOLEARN_TEST=1 пишет письмо в
|
||
файл /tmp вместо mail()); confirm неверный токен → 400; confirm
|
||
верный → 200 → login новым паролем 200; request ×6 → 429.
|
||
- vitest: страницы register/password-reset собраны, формы на месте
|
||
(wiring); существующие гейты не падают.
|
||
|
||
## Дизайн-перенос: светлая тема «васи» от Kimi WebSites (этап 10)
|
||
|
||
Источник: переносной пакет дизайна (пользовательский экспорт Kimi WebSites),
|
||
эталон сохранён в `docs/design/kimi-export/` (style.css + 8 HTML-страниц —
|
||
только как визуальная референс-копия, не подключается к сборке). Этап —
|
||
РЕДИЗАЙН: меняются разметка страниц и стили, НЕ меняются логика
|
||
(`apps/web/src/lib/**`, `apps/web/src/ui/*.ts`, `packages/**` кроме темы
|
||
доски), контент (`src/content/**`), API и тесты.
|
||
|
||
### Дизайн-токены (CSS custom properties, :root в Layout.astro)
|
||
|
||
Точно как в `docs/design/kimi-export/style.css`, КРОМЕ трёх сознательных
|
||
отклонений ради контраста WCAG AA ≥ 4.5:1 (замерено):
|
||
|
||
- `--ink-faint: #6e675c` (было #a29885 — 2.67:1 на бумаге, недопустимо
|
||
для мелкого текста подсказок).
|
||
- `--terra: #a84e2c` (было #c25a33 — 4.09:1; кикеры — мелкий bold-текст).
|
||
- `--ok: #46734a` (было #4a7a4e — 4.24:1 на --ok-soft в бейдже «пройден»).
|
||
|
||
Остальные токены без изменений: paper #faf7f1, paper-2 #f2ede3,
|
||
paper-3 #e9e2d3, surface #ffffff, ink #23201b, ink-soft #6b6154,
|
||
accent #3d4f7c, accent-strong #2e3d63, accent-soft #e9ecf5,
|
||
terra-soft #f6e8e0, line #e3dbc9, board #e6c48a, board-line #8a6a3f,
|
||
stone-black #211e1a, stone-white #f6f2e8, err #b0492f, err-soft #f8e8e2,
|
||
focus #2e3d63, star = terra, radius/radius-sm/radius-pill, shadow-sm/md/lg,
|
||
measure 62ch, font-head/body/mono, ease. Старые токены (--bg, --surface-2,
|
||
--text, --muted, --border, тёмные --accent*) УДАЛЯЮТСЯ — тёмная тема уходит
|
||
полностью.
|
||
|
||
### Каркас Layout.astro
|
||
|
||
- Глобальные стили = адаптированная копия `kimi-export/style.css` целиком
|
||
(база, шапка, кнопки, бейджи, карточки, формы, подвал, page-head, hero,
|
||
курс, урок, цумэ-го, игра, прогресс, кабинет, reveal, адаптив,
|
||
reduced-motion) плюс сохранённые правила нашего каркаса:
|
||
`[hidden]{display:none !important}`, стили `go-task` (переведены на
|
||
светлые токены: фон surface, рамка line, статус solved — ok).
|
||
- Шапка: logo с канji 碁 в круге + «Иго · школа Го», nav с пунктами
|
||
Курс/Цумэ-го/Играть/Прогресс/Кабинет (ссылки /learn.html, /tsumego.html,
|
||
/play.html, /progress.html, /account.html), menu-toggle ☰ для мобильных
|
||
(переключает .main-nav.is-open, маленький inline-script в Layout,
|
||
aria-expanded синхронизируется).
|
||
- Проп `section?: 'learn' | 'tsumego' | 'play' | 'progress' | 'account'` —
|
||
Layout ставит `aria-current="page"` соответствующему пункту nav. Проп
|
||
опционален (обратная совместимость: страницы без пропа собираются).
|
||
- Подвал: privacy-note «◎ Прогресс — на вашем устройстве; кабинет нужен
|
||
только для синхронизации между устройствами.», nav (те же разделы),
|
||
© 2026 Иго.
|
||
- Шрифты: Google Fonts PT Sans 400/700 + PT Serif 400/700 + PT Mono с
|
||
preconnect и display=swap; фолбэки уже в токенах font-*. Канji 碁
|
||
рендерится системным шрифтом.
|
||
- В head добавить meta description на страницах (уже частично есть).
|
||
|
||
### Тема доски (packages/board/src/render.ts DEFAULT_THEME)
|
||
|
||
Светлая, под токены: boardBackground #e6c48a, lineColor #8a6a3f,
|
||
blackStone #211e1a, whiteStone #f6f2e8, blackStoneEdge #00000000→ не
|
||
трогаем семантику: edge для чёрного #4a453c, для белого #b9ac93,
|
||
coordinateColor #8a6a3f, markupColor #a84e2c (terra), lastMoveMarker
|
||
#3d4f7c (accent). Если render.test.ts опирается на DEFAULT_THEME —
|
||
тесты поправить под новые константы (это допустимое изменение тестов:
|
||
константы темы — часть дизайна).
|
||
|
||
### Изображения
|
||
|
||
`apps/web/public/assets/hero.jpg` (1024×576, 53КБ), `ink.jpg`
|
||
(1400×933, 118КБ, loading="lazy"), `stones.jpg` (800×533, 24КБ,
|
||
loading="lazy") — уже в репозитории. Используются только на главной
|
||
(hero-figure, ink-band, home-strip). Все с осмысленным alt.
|
||
|
||
### Страницы — соответствие паттернам дизайна
|
||
|
||
- index.astro: hero (kicker, h1 с em, lead, btn-row, hero-meta с ЧЕСТНЫМИ
|
||
числами: 10 разделов, реальное кол-во задач цумэ-го из коллекции,
|
||
6 уровней ИИ), features (3 карточки), ink-band, home-strip с цитатой.
|
||
Тексты — из STRINGS/существующих, новые короткие допускаются.
|
||
- learn.astro: page-head + course-grid карточек lesson-card (номер,
|
||
название, описание, бейдж времени если есть, слот «пройден»).
|
||
Существующая JS-логика отметок сохраняется.
|
||
- learn/[id].astro: page-head (kicker «урок N из 10»), article.lesson-body
|
||
(контент из markdown), lesson-nav внизу. Кнопка «Отметить пройденным»
|
||
сохраняет поведение.
|
||
- tsumego.astro: page-head + form.card.filters (существующие select'ы и их
|
||
id/JS не меняются) + сетка задач. Существующие <details> задачи можно
|
||
оставить (стилизовать как card) — структура островка не ломается.
|
||
- play.astro: page-head + play-layout (board-wrap с канвасом/SVG +
|
||
aside.play-panel карточки: настройки, пленники, status-line, подсказки,
|
||
управление). Все id/data-атрибуты и ui/play-page.ts не меняются.
|
||
- progress.astro: page-head + stats-grid stat-card + progress-track/fill.
|
||
Существующие data-атрибуты и JS сохраняются.
|
||
- account.astro / register.astro / password-reset.astro: паттерн
|
||
.card.auth-card (register — с captcha-row; password-reset не было в
|
||
дизайне — используем тот же auth-card). Все формы, id, data-атрибуты,
|
||
скрипты sync-client — без изменений.
|
||
|
||
### Приёмка этапа 10
|
||
|
||
- `npm run gates` зелёный (467+ тестов; меняться могут только
|
||
константные ожидания темы доски и wiring-ожидания разметки, если
|
||
проверяли старые классы, — правки минимальны и точечны).
|
||
- Контраст ключевых пар ≥ 4.5:1 (проверка скриптом/расчётом — пары из
|
||
списка выше).
|
||
- Вес: каждая страница ≤ 300КБ собственных ресурсов (HTML+CSS+JS+img
|
||
без lazy); index ≤ 300КБ с hero, lazy-картинки не считаются.
|
||
- Визуальная проверка превью: новая версия сайта отдельно от рабочей.
|
||
|
||
## Аудит контента, страница «Источники», дисклеймер (этап 11)
|
||
|
||
### Железное правило (требование владельца)
|
||
|
||
Никаких выдуманных источников и фактов. Каждый источник — только URL,
|
||
который агент РЕАЛЬНО открыл инструментом и из которого приведена цитата,
|
||
подтверждающая утверждение. Не удалось подтвердить — пишем «не найдено».
|
||
Выдуманный URL/цитата = брак всей работы агента.
|
||
|
||
### Формат отчёта аудита (docs/audit/NN.md)
|
||
|
||
По каждому проверяемому утверждению урока:
|
||
|
||
- **Утверждение** (дословно или близко к тексту урока, файл/секция).
|
||
- **Вердикт**: ПОДТВЕРЖДЕНО | НЕ НАЙДЕНО | РАСХОЖДЕНИЕ.
|
||
- **Источник**: URL + дословная цитата (1–2 строки) + название ресурса.
|
||
Только для ПОДТВЕРЖДЕНО и РАСХОЖДЕНИЕ.
|
||
- При РАСХОЖДЕНИИ — как должно быть по источнику.
|
||
|
||
Авторитетность (по убыванию): официальные правила (AGA/BGA/федерации),
|
||
Sensei's Library, gobase.org, Wikipedia (как вторичный), книги
|
||
известных авторов в открытом доступе. Блоги/форумы — не источники.
|
||
|
||
### Страница «Источники» (/istochniki.html)
|
||
|
||
- Обычная страница Layout (page-head, container), title «Источники».
|
||
- Структура: по темам курса — проверяемый факт одной строкой + ссылка на
|
||
источник (название ресурса). Без оценок и прозы — «голые факты».
|
||
- Отдельный честный раздел: «Что пока не удалось подтвердить» — список
|
||
утверждений курса без найденного источника (если такие есть).
|
||
- Ссылка на страницу — из футера (nav) и со страницы курса.
|
||
|
||
### Дисклеймер
|
||
|
||
- На странице /learn.html под вводным абзацем — примечание:
|
||
«Курс создан с помощью ИИ и находится в открытом редактировании:
|
||
сверяйте спорные места с источниками» (ссылка на /istochniki.html).
|
||
- В футере nav добавить пункт «Источники».
|
||
- Стиль — .progress-note / текстовое примечание, без кричащих баннеров.
|
||
|
||
### Приёмка
|
||
|
||
- Оркестратор выборочно перепроверяет ≥5 URL из отчётов через
|
||
web_open_url — все обязаны открываться и содержать цитату.
|
||
- Гейты зелёные; страница источников ≤ бюджета веса; новая версия превью.
|