# Resource Graph Engine — документация движка Этот файл описывает **логику симуляции** (пакет `src/engine`, `src/types`, `src/utils`), а не конкретную игру. Он написан как справочник для разработки НА БАЗЕ этого движка (в том числе с помощью Claude) — здесь зафиксированы точные контракты, инварианты и неочевидные архитектурные решения, которые нужно знать, чтобы не сломать существующую логику при добавлении нового контента. Слой представления (`src/components`, `src/ui`, `App.tsx`) — это ОДНА конкретная демо-визуализация (React + SVG + автолэйаут ELK). Движок от неё не зависит: `Game`/`Behaviour`/сериализация не импортируют ничего из React/DOM. Полностью описан отдельным разделом в конце. ## Ключевые понятия | Термин | Где определён | Смысл | |---|---|---| | `Resource` | `src/types/resource.ts` | Строковый union-тип названий ресурсов (`'tree' \| 'wood' \| 'stone'` на момент написания). Добавление нового ресурса — это просто расширение union'а. | | `Unit` | `src/types/unit.ts` | Узел мира: `{ id, name, behaviour }`. `id` — идентификатор *слота в мире* (для UI/сохранения расположения), `name` — отображаемое имя. | | `Behaviour` | `src/engine/behaviours/base.ts` | Абстрактный класс — фактическая симулируемая сущность. **У `Behaviour` есть СВОЙ `id`, отдельный от `Unit.id`.** Связи (`inputLinks`) ссылаются на `Behaviour.id`, а не на `Unit.id`. | | `Game` | `src/engine/game.ts` | Контейнер: `time: Decimal` + `nodes: Unit[]`, метод `tick(deltaTime)` — единственная точка входа симуляции. | Важный нюанс, который легко перепутать: в `prefabs/game.ts` узел с `Unit.id = 'sawmill'` содержит `Behaviour` с `id: 'sawmill.1'`; связи других узлов указывают `target: 'sawmill.1'` (id поведения), не `'sawmill'`. Все числа в симуляции — `Decimal` из [`break_eternity.js`](https://github.com/Patashu/break_eternity.js) (для больших чисел инкремental-игр), а не `number`. Публичные API принимают `DecimalSource = Decimal | number | string`. Только шаг тика (`deltaTime` в `Game.tick`/`Behaviour.tick`) — обычный `number`. ## Модель поведения (`Behaviour`) ```ts abstract class Behaviour { readonly id: string; readonly kind: string; // 'converter' | 'storage' | ... readonly inputLinks: BehaviourLink[]; // BehaviourLink = { target: string, resource: Resource } abstract get inputs(): Resource[]; // какие ресурсы принимает (для сокетов/UI) abstract get outputs(): Resource[]; // какие ресурсы отдаёт (для сокетов/UI) abstract take(resource, amount): Decimal; // публичный: забрать (ограничено наличием) protected abstract give(resource, amount): void; // защищённый: положить (без ограничений) abstract getStorage(): Partial; // снимок текущих запасов abstract reset(): void; // обнулить запасы (не связи/id) abstract tick( deltaTime: number, resolve: (id: string) => Behaviour | undefined, fairShare: (sourceId: string, resource: Resource) => Decimal, ): void; } ``` **`take()` — единственный публичный способ отобрать ресурс.** Всегда возвращает `min(amount, реально_доступное)`, никогда не уходит в минус. `give()` защищённый — положить ресурс в поведение может только оно само (изнутри `tick()`, после `take()` у источника). Это значит: **толкать (push) ресурс в чужое поведение нельзя** — можно только объявить связь (`inputLinks`) и на своём тике *вытягивать (pull)* у источника. ### Односвязность входа (важное ограничение) `inputLinks` — плоский список `{target, resource}`. Оба текущих поведения ищут связь через `inputLinks.find(l => l.resource === X)` — **берётся первая** подходящая по ресурсу связь. Следствие: **один вход поведения может брать один и тот же ресурс только из ОДНОГО источника.** Если нужно смешивать один и тот же ресурс из нескольких источников в одном поведении — текущий код этого не поддерживает, нужно расширять `find` на что-то вроде списка/суммирования. Fan-*out* (один источник → много потребителей одного ресурса) поддерживается полностью, см. ниже. ### Порядок связи `resource` `BehaviourLink.resource` используется дважды одним и тем же значением: и как ключ в собственных `inputRates`/`acceptsResources` этого поведения, и как аргумент `source.take(link.resource, ...)`. **Переименования ресурса через связь нет** — если A принимает `wood` от B, у B тоже должно быть `wood` (не `plank`, не что-то ещё). ## Тик игры (`Game.tick(deltaTime)`) ```ts tick(deltaTime: number) { time += deltaTime; // 1. Топологический порядок: источник тикает раньше потребителя. for (const behaviour of orderByDependency(nodes)) { behaviour.tick(deltaTime, resolve, fairShare); snapshots[behaviour.id] = behaviour.getStorage(); // фиксируем "после этого тика" } } ``` 1. **Топологическая сортировка** (`src/utils/behaviourSort.ts`, DFS по `inputLinks`) гарантирует, что производитель тикает раньше своего потребителя — благодаря этому ресурс, произведённый в этом тике, может быть **потреблён по всей цепочке в тот же самый тик**, а не с задержкой в один тик. - **Циклические связи**: если граф содержит цикл (A тянет у B, B тянет у A), DFS обрывает цикл в первом же уже-посещённом узле и просто использует порядок обхода как есть — без ошибки, но без гарантии полной синхронности: одно из рёбер цикла всегда будет видеть состояние источника с *предыдущего* тика, а не текущего. На практике для DAG (обычный ресурсный граф без циклов) это не проявляется. 2. **`resolve(id)`** — поиск `Behaviour` по его `id` (не `Unit.id`!) среди всех узлов игры. 3. **Честное разделение (`fairShare`)** решает проблему конкуренции: если несколько поведений связаны с одним и тем же (`source`, `resource`), при заборе ресурса "кто первый в порядке тика — тот и съел всё" было бы недетерминированным-по-смыслу (хотя технически воспроизводимым) багом. Вместо этого: - Перед началом тика подсчитывается **`claimantCounts`** — сколько *разных* поведений ссылаются на каждую пару (`target`, `resource`). Это статическая информация (связи не меняются после создания), пересчитывается заново каждый тик, но не может измениться в рамках одной игры без ручного редактирования `inputLinks`. - После того как поведение отработало свой `tick()`, его текущий запас (`getStorage()`) сохраняется в `snapshots` — этот снимок **фиксирован до конца тика**, даже когда несколько потребителей после этого будут его "есть". - `fairShare(sourceId, resource)` возвращает: - `Decimal.dInf` (без ограничения), если претендент один или ни одного; - `snapshot[resource] / claimantCount`, если претендентов несколько. - Каждый потребитель обязан ограничивать свой запрос к источнику через `min(желаемое, fairShare(...))`, ПЕРЕД вызовом `source.take(...)`. - **Это буквальное равное деление, не max-min fairness.** Если один потребитель хочет меньше своей доли — остаток НЕ перераспределяется другим в этот же тик, а просто остаётся у источника до следующего. Пример (один источник даёт 10, два потребителя хотят 10 и 3): первый получит ровно 5 (свою долю), второй — все свои 3; 2 единицы останутся у источника. - **Реализация нового `Behaviour` обязана сама вызывать `fairShare` и ограничивать запрос** — это не гарантируется автоматически на уровне `take()`. ## `Converter` (`src/engine/behaviours/converter.ts`) Узел с рецептом: раз в `cycleLength` секунд (по игровому времени, не по количеству тиков) атомарно списывает `inputRates` и начисляет `outputRates` в собственный буфер (`storage`). Поля: `cycleTime: Decimal` (прогресс внутри текущего цикла, мутируется), `cycleLength: Decimal`, `inputRates: Partial`, `outputRates: Partial` (оба — ставки *за один цикл*, не в секунду). ### Алгоритм `tick(deltaTime, resolve, fairShare)` ``` idealCycleTime = cycleTime + deltaTime reserveCycles = ceil(idealCycleTime / cycleLength) // сколько циклов "хочется" покрыть запасом наперёд newCycleTime = idealCycleTime для каждого (resource, rate) из inputRates (rate != 0): target = rate * reserveCycles // сколько нужно НАКОПИТЬ, чтобы не тормозить shortfall = max(0, target - storage[resource]) если shortfall > 0 И есть связь на resource: source = resolve(link.target) request = min(shortfall, fairShare(link.target, link.resource)) give(resource, source.take(link.resource, request)) // забираем ОДНИМ куском, не по чуть-чуть // прогресс не может опережать САМЫЙ дефицитный ингредиент: newCycleTime = min(newCycleTime, storage[resource] * cycleLength / rate) cycleTime = newCycleTime affordableCycles = floor(cycleTime / cycleLength) если affordableCycles > 0: списать affordableCycles * inputRates[*] из storage начислить affordableCycles * outputRates[*] в storage cycleTime -= affordableCycles * cycleLength ``` Три свойства, которые важно понимать при отладке/расширении: 1. **"Забрать всё сразу, а не по чуть-чуть".** `reserveCycles` округляется ВВЕРХ (`ceil`), а не пропорционально прошедшей доле цикла. Это специально: как только у источника появляется достаточно ресурса, конвертер выгребает его одним рывком (весь нужный на текущий+грядущий циклы объём), а не тянет дробно по мере хода времени. Визуально в UI это выглядит так, будто буфер конвертера "телепортируется" с 0 сразу на полную ставку в момент, когда источник произвёл ресурс. 2. **`cycleTime` не может "врать".** Прогресс `newCycleTime` ограничивается снизу для КАЖДОГО входного ресурса формулой `have * cycleLength / rate` — то есть таймер физически не может показывать больше прогресса, чем оправдано самым дефицитным ингредиентом. Если ресурсов не хватает, `cycleTime` просто перестаёт расти (стоит на месте) до следующего тика, когда источник, возможно, произведёт ещё. **Никакого "долга" не копится**: если конвертер голодал 50 тиков подряд, при появлении ресурса он не обработает мгновенно 50 циклов — `cycleTime` всё это время не рос, а как только ресурс появился, продвинется ровно настолько, насколько хватит того, что реально забрано. 3. **Один вызов `tick()` может закрыть НЕСКОЛЬКО циклов сразу**, если `deltaTime` большой (например, при офлайн-прогрессе одним большим тиком) и ресурсов хватает — `affordableCycles` не ограничен единицей. Это специально сделано так, чтобы `game.tick(1000)` (один большой шаг) и 10 000×`game.tick(0.1)` (много маленьких) давали **математически тот же результат** (с точностью до погрешности накопления `number` при многократном сложении `deltaTime`, см. раздел "Подводные камни"). Из этого следует практическая рекомендация: **офлайн-прогресс лучше считать одним большим `tick()`**, а не циклом из множества маленьких — так быстрее (не зависит от числа "прошедших" циклов) и точнее. Пустые `inputRates`/`outputRates` — нормальный случай: чистый источник (пустые входы) или чистый потребитель/поглотитель (пустые выходы). ## `Storage` (`src/engine/behaviours/storage.ts`) Пассивный буфер **без собственной скорости**: и приём, и выдача ресурса ограничены только противоположной стороной. Поля: `capacity: Decimal | undefined` (лимит, **на каждый ресурс отдельно**, не общий на всё хранилище), `acceptsResources: Resource[]` (какие ресурсы вообще может держать), `items: Partial`. ### Алгоритм `tick(deltaTime, resolve, fairShare)` `deltaTime` не используется — скорость не зависит от времени. ``` для каждого resource из acceptsResources: если нет связи на этот resource — пропустить source = resolve(link.target) room = capacity ? max(0, capacity - items[resource]) : Infinity request = min(room, fairShare(link.target, link.resource)) give(resource, source.take(link.resource, request)) ``` То есть на каждом тике `Storage` пытается забрать у источника **столько, сколько есть места** (или сколько источник готов дать — что меньше), одним куском, без ограничения по скорости. С другой стороны, любой узел, который свяжется со `Storage` как со своим источником, вызовет `storage.take()`, которое отдаст **сколько попросят, ограничено только фактическим наличием** — тоже без собственного лимита скорости `Storage`. `capacity` не задан (`undefined`) → ёмкость бесконечна для всех принимаемых ресурсов. ## Данные и сериализация (`src/types/serialization.ts`, `src/engine/saveLoad.ts`) ```ts type SerializedGame = { time: DecimalSource; nodes: SerializedNode[]; // { id, name, behaviour } } type SerializedBehaviour = { id: string; inputLinks: { pins: { target: string, resource: Resource }[] }; } & (SerializedConverter | SerializedStorage); type SerializedConverter = { kind: 'converter'; cycleTime?: DecimalSource; // по умолчанию 0 storage?: Partial>; // по умолчанию {} rules: { cycleLength: DecimalSource; inputRates?: Partial>; outputRates?: Partial>; } } type SerializedStorage = { kind: 'storage'; capacity?: DecimalSource; // по умолчанию бесконечность acceptsResources: Resource[]; items: Partial>; } ``` Пример из `src/prefabs/game.ts` (полный рабочий граф — лес → лесопилка → два склада, второй склад забирает камень с той же связи, что и первый, демонстрируя `fairShare`): ```ts { id: 'forest', name: 'Лес', behaviour: { id: 'forest.1', kind: 'converter', inputLinks: { pins: [] }, rules: { cycleLength: 1, outputRates: { tree: 1, stone: 0.1 } }, }, }, { id: 'sawmill', name: 'Лесопилка', behaviour: { id: 'sawmill.1', kind: 'converter', inputLinks: { pins: [{ resource: 'tree', target: 'forest.1' }] }, rules: { cycleLength: 1.8, inputRates: { tree: 1 }, outputRates: { wood: 2 } }, }, }, { id: 'shop', name: 'Магазин', behaviour: { id: 'shop.1', kind: 'storage', inputLinks: { pins: [ { resource: 'wood', target: 'sawmill.1' }, { resource: 'stone', target: 'forest.1' }, // делит 'stone' с shop2 через fairShare ] }, acceptsResources: ['wood', 'stone'], items: {}, }, }, ``` API верхнего уровня: `serializeBehaviour`/`deserializeBehaviour` (диспетчер по `kind`), `serialize`/`deserialize` (весь `Game`), `serializeToString`/`deserializeFromString` (JSON), `save`/`load` (localStorage, ключ `` `${saveParams.path}_incgane` `` — `saveParams.path` можно менять, чтобы держать несколько независимых сохранений/слотов). `Game.reset()` обнуляет `time` и вызывает `reset()` у каждого поведения (обнуляет только запасы — `cycleTime`, связи, ставки и id не трогает). `Game.clone()` — неглубокая копия с новым `Object.create`, нужна исключительно чтобы React увидел изменение ссылки после мутирующего `tick()`/`reset()`. ## Как расширять движок ### Добавить новый ресурс 1. Добавить строку в union `Resource` (`src/types/resource.ts`). 2. Добавить запись в `ResourceDescriptions` (`src/ui/resource.ts`) — `label`/`color`/`icon` для UI. Это **не часть движка**, но без неё UI упадёт при попытке отрисовать сокет/буфер с новым ресурсом (обращение по ключу без проверки). ### Добавить новый тип `Behaviour` 1. Отнаследоваться от `Behaviour` (`src/engine/behaviours/base.ts`), реализовать все абстрактные члены: `inputs`, `outputs`, `take`, `give`, `getStorage`, `reset`, `tick`. 2. В `tick()` — если поведение вообще что-то тянет через `inputLinks`, обязательно ограничивать запрос через переданный `fairShare(sourceId, resource)` (см. `Converter`/`Storage` как образец), иначе честное деление между несколькими потребителями не будет работать для этого нового типа. 3. Добавить новый `kind`-литерал в тип `SerializedBehaviour` (объединение в `src/types/serialization.ts`) и обработать его в обоих `switch` в `src/engine/saveLoad.ts` (`serializeBehaviour` и `deserializeBehaviour`) — если забыть один из двух, поведение либо не сохранится, либо не восстановится при загрузке, без ошибки компиляции (проверьте оба места вручную). 4. Если нужна собственная визуализация внутри карточки (как `ConverterCardInfo` для прогресс-бара конвертера) — расширить `BehaviourCard.tsx` условием по `behaviour.kind`, аналогично существующему `behaviour.kind === 'converter' && `. ## Подводные камни - **`Decimal` не бесконечно точен для обычных чисел.** Для величин масштаба `layer === 0` (не гипер-больших чисел) `break_eternity.js` внутри использует обычный JS `double` — привычные артефакты вроде `0.1 + 0.1 + 0.1 !== 0.3` воспроизводятся и здесь. Смена `number` → `Decimal`/строка это не лечит. Для отображения использовать `.toStringWithDecimalPlaces(n)`, а не сырой `.toString()`. - **Много маленьких тиков ≠ побитово один большой тик**, только математически эквивалентны — расхождение исключительно из-за многократного накопления `deltaTime` (см. выше), не из-за самого алгоритма `Converter`/`Storage`. Для офлайн-прогресса предпочитать один большой `tick(суммарное_время)`. - **`fairShare` не защищает от общего перерасхода**, если реализация нового `Behaviour` игнорирует его и вызывает `take()` напрямую без ограничения — на это нет проверки времени компиляции. - **Порядок узлов в `nodes` влияет на результат только в графах с циклами** (см. раздел про топосортировку); для обычного ациклического графа результат от порядка не зависит. - Тип `Resources = Record` (не `Partial`), но фактически везде в рантайме используется как `Partial` — обращение по ключу может вернуть `undefined`, поэтому по всему движку принят паттерн `storage[resource] ?? Decimal.dZero`. ## Слой представления (не часть движка, но идёт в комплекте) - `src/ui/graph.ts` — раскладка графа на плоскости через `elkjs` (слоистый layout, направление `LEFT`), асинхронно (`build2dNodes`). - `src/ui/resource.ts` — `ResourceDescriptions`: цвет/иконка/подпись на ресурс, для сокетов и буферов. - `src/components/GameCanvas.tsx` — измеряет реальные центры карточек/сокетов через DOM (`getBoundingClientRect`) после раскладки, чтобы линии связей (`LinkLine.tsx`) рисовались точно между сокетами, а не между условными координатами узла. - `src/components/BehaviourCard.tsx` — карточка узла: сокеты, буферы (`getDisplayInfo().storages`), для конвертера — прогресс-бар (`ConverterCardInfo.tsx`, использует `cycleTime.div(cycleLength)`), плюс отладочный YAML-дамп `info.raw`. - `src/App.tsx` — хранит единственный `Game` в `useState`, персистит в localStorage при каждом изменении, `handleTick` вызывает `game.tick(1/10)` + `setGame(game.clone())` (тик размером `0.1` игровой секунды на клик). Ничего в `src/engine`/`src/types`/`src/utils` не импортирует из `src/components`/`src/ui`/React — движок можно использовать (или перенести под другой рендер) без этого слоя.