27 KiB
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 (для больших чисел инкремental-игр), а не number. Публичные API принимают DecimalSource = Decimal | number | string. Только шаг тика (deltaTime в Game.tick/Behaviour.tick) — обычный number.
Модель поведения (Behaviour)
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<Resources>; // снимок текущих запасов
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))
tick(deltaTime: number) {
time += deltaTime;
// 1. Топологический порядок: источник тикает раньше потребителя.
for (const behaviour of orderByDependency(nodes)) {
behaviour.tick(deltaTime, resolve, fairShare);
snapshots[behaviour.id] = behaviour.getStorage(); // фиксируем "после этого тика"
}
}
-
Топологическая сортировка (
src/utils/behaviourSort.ts, DFS поinputLinks) гарантирует, что производитель тикает раньше своего потребителя — благодаря этому ресурс, произведённый в этом тике, может быть потреблён по всей цепочке в тот же самый тик, а не с задержкой в один тик.- Циклические связи: если граф содержит цикл (A тянет у B, B тянет у A), DFS обрывает цикл в первом же уже-посещённом узле и просто использует порядок обхода как есть — без ошибки, но без гарантии полной синхронности: одно из рёбер цикла всегда будет видеть состояние источника с предыдущего тика, а не текущего. На практике для DAG (обычный ресурсный граф без циклов) это не проявляется.
-
resolve(id)— поискBehaviourпо егоid(неUnit.id!) среди всех узлов игры. -
Честное разделение (
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<Resources>, outputRates: Partial<Resources> (оба — ставки за один цикл, не в секунду).
Алгоритм 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
Три свойства, которые важно понимать при отладке/расширении:
-
"Забрать всё сразу, а не по чуть-чуть".
reserveCyclesокругляется ВВЕРХ (ceil), а не пропорционально прошедшей доле цикла. Это специально: как только у источника появляется достаточно ресурса, конвертер выгребает его одним рывком (весь нужный на текущий+грядущий циклы объём), а не тянет дробно по мере хода времени. Визуально в UI это выглядит так, будто буфер конвертера "телепортируется" с 0 сразу на полную ставку в момент, когда источник произвёл ресурс. -
cycleTimeне может "врать". ПрогрессnewCycleTimeограничивается снизу для КАЖДОГО входного ресурса формулойhave * cycleLength / rate— то есть таймер физически не может показывать больше прогресса, чем оправдано самым дефицитным ингредиентом. Если ресурсов не хватает,cycleTimeпросто перестаёт расти (стоит на месте) до следующего тика, когда источник, возможно, произведёт ещё. Никакого "долга" не копится: если конвертер голодал 50 тиков подряд, при появлении ресурса он не обработает мгновенно 50 циклов —cycleTimeвсё это время не рос, а как только ресурс появился, продвинется ровно настолько, насколько хватит того, что реально забрано. -
Один вызов
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<Resources>.
Алгоритм 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)
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<Record<Resource, DecimalSource>>; // по умолчанию {}
rules: {
cycleLength: DecimalSource;
inputRates?: Partial<Record<Resource, DecimalSource>>;
outputRates?: Partial<Record<Resource, DecimalSource>>;
}
}
type SerializedStorage = {
kind: 'storage';
capacity?: DecimalSource; // по умолчанию бесконечность
acceptsResources: Resource[];
items: Partial<Record<Resource, DecimalSource>>;
}
Пример из src/prefabs/game.ts (полный рабочий граф — лес → лесопилка → два склада, второй склад забирает камень с той же связи, что и первый, демонстрируя fairShare):
{
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().
Как расширять движок
Добавить новый ресурс
- Добавить строку в union
Resource(src/types/resource.ts). - Добавить запись в
ResourceDescriptions(src/ui/resource.ts) —label/color/iconдля UI. Это не часть движка, но без неё UI упадёт при попытке отрисовать сокет/буфер с новым ресурсом (обращение по ключу без проверки).
Добавить новый тип Behaviour
- Отнаследоваться от
Behaviour(src/engine/behaviours/base.ts), реализовать все абстрактные члены:inputs,outputs,take,give,getStorage,reset,tick. - В
tick()— если поведение вообще что-то тянет черезinputLinks, обязательно ограничивать запрос через переданныйfairShare(sourceId, resource)(см.Converter/Storageкак образец), иначе честное деление между несколькими потребителями не будет работать для этого нового типа. - Добавить новый
kind-литерал в типSerializedBehaviour(объединение вsrc/types/serialization.ts) и обработать его в обоихswitchвsrc/engine/saveLoad.ts(serializeBehaviourиdeserializeBehaviour) — если забыть один из двух, поведение либо не сохранится, либо не восстановится при загрузке, без ошибки компиляции (проверьте оба места вручную). - Если нужна собственная визуализация внутри карточки (как
ConverterCardInfoдля прогресс-бара конвертера) — расширитьBehaviourCard.tsxусловием поbehaviour.kind, аналогично существующемуbehaviour.kind === 'converter' && <ConverterCardInfo .../>.
Подводные камни
Decimalне бесконечно точен для обычных чисел. Для величин масштабаlayer === 0(не гипер-больших чисел)break_eternity.jsвнутри использует обычный JSdouble— привычные артефакты вроде0.1 + 0.1 + 0.1 !== 0.3воспроизводятся и здесь. Сменаnumber→Decimal/строка это не лечит. Для отображения использовать.toStringWithDecimalPlaces(n), а не сырой.toString().- Много маленьких тиков ≠ побитово один большой тик, только математически эквивалентны — расхождение исключительно из-за многократного накопления
deltaTime(см. выше), не из-за самого алгоритмаConverter/Storage. Для офлайн-прогресса предпочитать один большойtick(суммарное_время). fairShareне защищает от общего перерасхода, если реализация новогоBehaviourигнорирует его и вызываетtake()напрямую без ограничения — на это нет проверки времени компиляции.- Порядок узлов в
nodesвлияет на результат только в графах с циклами (см. раздел про топосортировку); для обычного ациклического графа результат от порядка не зависит. - Тип
Resources = Record<Resource, Decimal>(неPartial), но фактически везде в рантайме используется какPartial<Resources>— обращение по ключу может вернуть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 — движок можно использовать (или перенести под другой рендер) без этого слоя.