Прежняя git-история утрачена при переносе проекта на машину владельца (снапшот без .git). Хэши коммитов в docs/reports/* относятся к утраченной истории. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
65 KiB
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).
Базовые типы
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 };
Позиция и правила
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)
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;
История партии (неизменяемое дерево)
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]
/** Ошибка парсинга 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
export interface Rng {
next(): number; // равномерное [0, 1)
}
export interface Clock {
now(): number; // миллисекунды
}
/** Детерминированный PRNG (mulberry32): одинаковый seed → одинаковая
* последовательность. Единственный разрешённый источник случайности
* в тестах и движке. */
export function createSeededRng(seed: number): Rng;
Дополнение этапа 6 (ADR-0003)
/** Произвольная стартовая позиция для задач. Валидация: точки в пределах
* доски, без дублей, каждая группа с ≥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)
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.
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.
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-адаптер (тонкий, без логики; юнит-тестами не покрывается)
/** Выставляет 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 —
никакого дублирования правил.
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):
- save-atari — своя группа с 1 дамэ → ход, после которого у группы >1 дамэ (включая контрзахват). Применяется с вероятностью p(level); при «промахе» ролла — переход к следующему приоритету.
- capture-atari — чужая группа с 1 дамэ → легальный ход в последнее дамэ. Та же вероятность p(level).
- nearby — взвешенный случайный среди легальных кандидатов в радиусе Chebyshev ≤ 2 от lastOpponentMove (если он передан и кандидаты есть).
- random — случайный из оставшихся легальных кандидатов.
- 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 (чистый)
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-ответ). Типы сообщений:
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).
Разделы курса (фиксированный список, порядок = порядок прохождения)
- «Дамэ и захват» 2. «Атари, лестница, сеть» 3. «Самоубийство и ко»
- «Глаза: жизнь и смерть» 5. «Подсчёт и конец партии» 6. «Связь и разрезание»
- «Углы, стороны, центр» 8. «Базовые дзёсэки» 9. «Форма» 10. «Простое ёсэ».
Урок (Markdown)
- Файлы:
apps/web/src/content/lessons/<id>.md, коллекцияlessons(Astro content layer, glob loader);<id>— kebab-case, он же slug страницы. - Frontmatter (обязателен целиком):
---
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).
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-функции).
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, дополнение):
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:*)
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
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):
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.
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 не меняются) + сетка задач. Существующие задачи можно оставить (стилизовать как 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 — все обязаны открываться и содержать цитату.
- Гейты зелёные; страница источников ≤ бюджета веса; новая версия превью.