Files
incremental-game-engine/resource-graph-vite
..
2026-09-04 14:13:47 +03:00
2026-09-04 13:54:54 +03:00
2026-09-04 13:54:54 +03:00
2026-09-04 13:54:54 +03:00
2026-09-04 13:54:54 +03:00
2026-09-04 13:54:54 +03:00
2026-09-04 13:54:54 +03:00
2026-09-04 13:54:54 +03:00
2026-09-04 13:54:54 +03:00
2026-09-04 13:54:54 +03:00

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();  // фиксируем "после этого тика"
    }
}
  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<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

Три свойства, которые важно понимать при отладке/расширении:

  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<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().

Как расширять движок

Добавить новый ресурс

  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' && <ConverterCardInfo .../>.

Подводные камни

  • 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<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 — движок можно использовать (или перенести под другой рендер) без этого слоя.