From f56e79ee947afa2c148054d5b4c3d07746ecdf4e Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Fri, 4 Sep 2026 13:10:47 +0300 Subject: [PATCH] engine moved to a separate node.js module --- .claude/scheduled_tasks.lock | 1 + README.md | 264 +++++++++++++++++++++++---- package-lock.json | 20 +- package.json | 5 +- src/App.tsx | 6 +- src/components/BehaviourCard.tsx | 5 +- src/components/ConverterCardInfo.tsx | 2 +- src/components/GameCanvas.tsx | 2 +- src/components/LinkLine.tsx | 3 +- src/engine.ts.none | 242 ------------------------ src/engine/behaviours/base.ts | 49 ----- src/engine/behaviours/converter.ts | 118 ------------ src/engine/behaviours/storage.ts | 78 -------- src/engine/game.ts | 42 ----- src/engine/index.ts | 0 src/engine/saveLoad.ts | 97 ---------- src/prefabs/game.ts | 20 +- src/saveLoad.ts | 25 +++ src/types/behaviour.ts | 23 --- src/types/index.ts | 4 - src/types/resource.ts | 5 - src/types/serialization.ts | 46 ----- src/types/unit.ts | 7 - src/ui/graph.ts | 3 +- src/ui/resource.ts | 2 +- src/utils/behaviourSort.ts | 25 --- vite.config.ts | 23 +++ 27 files changed, 331 insertions(+), 786 deletions(-) create mode 100644 .claude/scheduled_tasks.lock delete mode 100644 src/engine.ts.none delete mode 100644 src/engine/behaviours/base.ts delete mode 100644 src/engine/behaviours/converter.ts delete mode 100644 src/engine/behaviours/storage.ts delete mode 100644 src/engine/game.ts delete mode 100644 src/engine/index.ts delete mode 100644 src/engine/saveLoad.ts create mode 100644 src/saveLoad.ts delete mode 100644 src/types/behaviour.ts delete mode 100644 src/types/index.ts delete mode 100644 src/types/resource.ts delete mode 100644 src/types/serialization.ts delete mode 100644 src/types/unit.ts delete mode 100644 src/utils/behaviourSort.ts diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..440989d --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"d232f3c1-b7d4-4616-b6d5-259dc502a2e7","pid":83268,"procStart":"134326544516295730","acquiredAt":1788511492089} \ No newline at end of file diff --git a/README.md b/README.md index bd945ae..bb2df1e 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,245 @@ -# Граф ресурсов — тиковая симуляция (Vite + React + TypeScript) +# Resource Graph Engine — документация движка -## Запуск +Этот файл описывает **логику симуляции** (пакет `src/engine`, `src/types`, `src/utils`), а не конкретную игру. Он написан как справочник для разработки НА БАЗЕ этого движка (в том числе с помощью Claude) — здесь зафиксированы точные контракты, инварианты и неочевидные архитектурные решения, которые нужно знать, чтобы не сломать существующую логику при добавлении нового контента. -```bash -npm install -npm run dev +Слой представления (`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; +} ``` -Откроется адрес вида `http://localhost:5173`. +**`take()` — единственный публичный способ отобрать ресурс.** Всегда возвращает `min(amount, реально_доступное)`, никогда не уходит в минус. `give()` защищённый — положить ресурс в поведение может только оно само (изнутри `tick()`, после `take()` у источника). Это значит: **толкать (push) ресурс в чужое поведение нельзя** — можно только объявить связь (`inputLinks`) и на своём тике *вытягивать (pull)* у источника. -## Сборка +### Односвязность входа (важное ограничение) -```bash -npm run build # tsc -b && vite build -> dist/ -npm run preview # локальный просмотр собранного dist/ +`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 (обычный ресурсный граф без циклов) это не проявляется. -- `src/engine.ts` — ядро симуляции: типы (`SimNode`, `SimEdge`, `Behavior`, - `Topology`, `Buffers`), функция `tick()` (один детерминированный тик) и - `resetBuffers()`. Модуль не зависит от React/DOM — логика переносима на - другой стек (Rust/C#/GDScript) по этой же спецификации. -- `src/storage.ts` — чтение/запись localStorage (`incsim.topology.v1`, - `incsim.buffers.v1`, `incsim.tickcount.v1`). -- `src/App.tsx` — состояние приложения (topology/buffers/tickCount/selected), - все обработчики мутаций, эффекты персистентности. -- `src/components/` — `Topbar`, `WorldCanvas` (холст с узлами и SVG-связями), - `NodeCard` (перетаскиваемая карточка узла), `EdgeLines` (SVG-стрелки), - `Sidebar`, `NodeEditor`, `EdgeEditor`, `RecipeEditor`. +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()`. -- **Storage** — пассивный буфер с ёмкостью. -- **Converter** — единственный активный узел: раз в `period` тиков атомарно - списывает `inputs` и начисляет `outputs` в собственном буфере. - `source`/`consumer` — его частные случаи с пустым списком входов/выходов. -- **Edge** — перенос ресурса между буферами узлов; конкуренция при - fan-in/fan-out разрешается по `priority` (выше — раньше), порядок - обработки узлов/рёбер фиксирован — тик полностью детерминирован. +## `Converter` (`src/engine/behaviours/converter.ts`) -Reset обнуляет буферы и таймеры конвертеров, настройки узлов/связей не трогает. +Узел с рецептом: раз в `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 — движок можно использовать (или перенести под другой рендер) без этого слоя. diff --git a/package-lock.json b/package-lock.json index 6fe1ffb..52bb3c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { - "name": "resource-graph-sim", + "name": "@incgame/resource-graph-sim", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "resource-graph-sim", + "name": "@incgame/resource-graph-sim", "version": "0.1.0", "dependencies": { + "@incgame/engine": "file:../engine", "break_eternity.js": "^2.1.3", "elkjs": "^0.12.0", "react": "^18.3.1", @@ -22,6 +23,17 @@ "vite": "^5.4.10" } }, + "../engine": { + "name": "@incgame/engine", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "break_eternity.js": "^2.1.3" + }, + "devDependencies": { + "typescript": "^5.6.3" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -695,6 +707,10 @@ "node": ">=12" } }, + "node_modules/@incgame/engine": { + "resolved": "../engine", + "link": true + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/package.json b/package.json index 3c3740d..181492f 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "resource-graph-sim", + "name": "@incgame/resource-graph-sim", "private": true, "version": "0.1.0", "type": "module", @@ -13,7 +13,8 @@ "elkjs": "^0.12.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "yaml": "^2.9.0" + "yaml": "^2.9.0", + "@incgame/engine": "file:../engine" }, "devDependencies": { "@types/react": "^18.3.12", diff --git a/src/App.tsx b/src/App.tsx index b9db7ce..80ca738 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from 'react'; import Topbar from './components/Topbar'; import { GameCanvas } from './components/GameCanvas'; -import { deserialize, load, save } from './engine/saveLoad'; +import { load, save } from './saveLoad'; import { defaultGame } from './prefabs/game'; -import { Game } from './engine/game'; +import { Game, deserializeGame } from '@incgame/engine'; export type Selection = { kind: 'node'; id: string } | null; @@ -37,7 +37,7 @@ export default function App() { if (!ok) return; - setGame(deserialize(defaultGame())); + setGame(deserializeGame(defaultGame())); } // function handleAddNode(kind: NodeKind) { diff --git a/src/components/BehaviourCard.tsx b/src/components/BehaviourCard.tsx index 384d96a..391d378 100644 --- a/src/components/BehaviourCard.tsx +++ b/src/components/BehaviourCard.tsx @@ -1,11 +1,8 @@ import { forwardRef } from 'react'; -import { Resource } from '../types'; import { GraphNode } from '../ui/graph'; import { ResourceDescriptions } from '../ui/resource'; -import { Game } from '../engine/game'; import { ConverterCardInfo } from './ConverterCardInfo'; -import { Converter } from '../engine/behaviours/converter'; -import { Behaviour } from '../engine/behaviours/base'; +import { Game, Behaviour, Converter, Resource } from '@incgame/engine'; import * as yaml from 'yaml'; diff --git a/src/components/ConverterCardInfo.tsx b/src/components/ConverterCardInfo.tsx index d098a07..65aa0b0 100644 --- a/src/components/ConverterCardInfo.tsx +++ b/src/components/ConverterCardInfo.tsx @@ -1,4 +1,4 @@ -import { Converter } from "../engine/behaviours/converter"; +import { Converter } from "@incgame/engine"; type Props = { converter: Converter; diff --git a/src/components/GameCanvas.tsx b/src/components/GameCanvas.tsx index d3594bb..a94118d 100644 --- a/src/components/GameCanvas.tsx +++ b/src/components/GameCanvas.tsx @@ -3,7 +3,7 @@ import { BehaviourCard } from './BehaviourCard'; import { build2dNodes, GraphNode } from '../ui/graph'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { LinkLine } from './LinkLine'; -import { Game } from '../engine/game'; +import { Game } from '@incgame/engine'; interface Props { game: Game; diff --git a/src/components/LinkLine.tsx b/src/components/LinkLine.tsx index 9f37121..251b0bd 100644 --- a/src/components/LinkLine.tsx +++ b/src/components/LinkLine.tsx @@ -1,5 +1,4 @@ -import { Game } from "../engine/game"; -import { Resource } from "../types"; +import { Game, Resource } from "@incgame/engine"; import { GraphNode } from "../ui/graph"; type Props = { diff --git a/src/engine.ts.none b/src/engine.ts.none deleted file mode 100644 index 96406cf..0000000 --- a/src/engine.ts.none +++ /dev/null @@ -1,242 +0,0 @@ -// for Claude: ignore this file - -/** - * engine.ts — ядро симуляции. - * - * - Storage — пассивный буфер с ёмкостью, сам ничего не делает. - * - Converter — единственный активный узел: раз в `period` тиков атомарно - * списывает `inputs` и начисляет `outputs` в СВОИХ ЖЕ буферах. - * source / consumer — частные случаи converter с пустым списком - * входов или выходов соответственно. - * - Edge (транспорт) — переносит ресурс между буферами двух узлов. - * Порядок обработки рёбер фиксирован (priority desc, id asc) — - * разрешает конкуренцию при fan-in/fan-out детерминированно. - * - * Модуль не содержит DOM/React-зависимостей — чистые данные и функции, - * поэтому логику легко перенести на другой язык/платформу по этой же - * спецификации (см. обсуждение — Rust/C#/GDScript как нативные цели). - */ - -export type ResourceType = 'wood' | 'iron' | 'stone' | 'gold' | 'knowledge'; - -export interface ResourceMeta { - id: ResourceType; - label: string; - color: string; - icon: string; -} - -export const RESOURCES: ResourceMeta[] = [ - { id: 'wood', label: 'Дерево', color: '#b98249', icon: '🌲' }, - { id: 'iron', label: 'Железо', color: '#9aa7b0', icon: '⛓' }, - { id: 'stone', label: 'Камень', color: '#8a8a8a', icon: '🪨' }, - { id: 'gold', label: 'Золото', color: '#d9b64e', icon: '💰' }, - { id: 'knowledge', label: 'Знание', color: '#7d8bf0', icon: '📖' }, -]; - -export type NodeKind = 'source' | 'storage' | 'converter' | 'consumer'; - -export interface RecipeLine { - resource: ResourceType; - amount: number; -} - -export type Behavior = - | { kind: 'storage' } - | { kind: 'source'; resource: ResourceType; rate: number } - | { kind: 'consumer'; resource: ResourceType; rate: number } - | { - kind: 'converter'; - period: number; - timer: number; - inputs: RecipeLine[]; - outputs: RecipeLine[]; - }; - -export interface SimNode { - label: string; - x: number; - y: number; - actor: Actor; -} - -export type Actor = { - id: string; - behavior: Behavior; -} - -export interface SimEdge { - id: string; - from: string; - to: string; - resource: ResourceType; - rate: number; - priority: number; -} - -export interface Topology { - nextId: number; - nodes: SimNode[]; - edges: SimEdge[]; -} - -/** buffers[nodeId][resource] = amount */ -export type Buffers = Record>>; - -export const NODE_W = 150; -export const NODE_H = 74; - -export function kindLabel(kind: NodeKind): string { - return { source: 'Источник', storage: 'Склад', converter: 'Конвертер', consumer: 'Потребитель' }[kind]; -} - -/** Глубокая копия чисто-JSON-данных (topology/buffers сериализуемы, structuredClone не требуется). */ -export function cloneJSON(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; -} - -function getBuf(buffers: Buffers, nodeId: string, r: ResourceType): number { - return buffers[nodeId]?.[r] ?? 0; -} - -function setBuf(buffers: Buffers, nodeId: string, r: ResourceType, v: number): void { - if (!buffers[nodeId]) buffers[nodeId] = {}; - buffers[nodeId][r] = v; -} - -function capOf(node: SimNode): number { - return node.capacity === null || node.capacity === undefined ? Infinity : node.capacity; -} - -/** - * Выполняет ровно один детерминированный тик (мутирует topology.*.timer и buffers). - * Порядок фаз фиксирован и не зависит от порядка вставки узлов/рёбер в массивах. - */ -export function tick(topology: Topology, buffers: Buffers): void { - const nodesById = new Map(topology.nodes.map((n) => [n.id, n])); - const sortedNodes = [...topology.nodes].sort((a, b) => a.id.localeCompare(b.id)); - - // Фаза 1: источники — генерация "из ничего" в собственный буфер. - for (const node of sortedNodes) { - if (node.behavior.kind === 'source') { - const { resource, rate } = node.behavior; - const cur = getBuf(buffers, node.id, resource); - setBuf(buffers, node.id, resource, Math.min(capOf(node), cur + rate)); - } - } - - // Фаза 2: конвертеры. Blocking: при нехватке сырья таймер не сбрасывается, - // попытка повторяется каждый следующий тик. - for (const node of sortedNodes) { - if (node.behavior.kind === 'converter') { - const b = node.behavior; - b.timer += 1; - if (b.timer >= b.period) { - const canRun = b.inputs.every((line) => getBuf(buffers, node.id, line.resource) >= line.amount); - if (canRun) { - for (const line of b.inputs) { - setBuf(buffers, node.id, line.resource, getBuf(buffers, node.id, line.resource) - line.amount); - } - for (const line of b.outputs) { - const next = getBuf(buffers, node.id, line.resource) + line.amount; - setBuf(buffers, node.id, line.resource, Math.min(capOf(node), next)); - } - b.timer = 0; - } - } - } - } - - // Фаза 3: транспорт по рёбрам, порядок (priority desc, id asc). - const sortedEdges = [...topology.edges].sort( - (a, b) => b.priority - a.priority || a.id.localeCompare(b.id) - ); - for (const edge of sortedEdges) { - const fromNode = nodesById.get(edge.from); - const toNode = nodesById.get(edge.to); - if (!fromNode || !toNode) continue; - const available = getBuf(buffers, edge.from, edge.resource); - const freeSpace = capOf(toNode) - getBuf(buffers, edge.to, edge.resource); - const amount = Math.max(0, Math.min(edge.rate, available, freeSpace)); - if (amount > 0) { - setBuf(buffers, edge.from, edge.resource, available - amount); - setBuf(buffers, edge.to, edge.resource, getBuf(buffers, edge.to, edge.resource) + amount); - } - } - - // Фаза 4: потребители — безвозвратное поглощение. - for (const node of sortedNodes) { - if (node.behavior.kind === 'consumer') { - const { resource, rate } = node.behavior; - const cur = getBuf(buffers, node.id, resource); - const amount = Math.min(cur, rate); - setBuf(buffers, node.id, resource, cur - amount); - } - } -} - -/** Обнуляет все буферы и таймеры конвертеров. Настройки узлов/связей не трогает. */ -export function resetBuffers(topology: Topology): Buffers { - const buffers: Buffers = {}; - for (const node of topology.nodes) { - buffers[node.id] = {}; - if (node.behavior.kind === 'converter') node.behavior.timer = 0; - } - return buffers; -} - -export function defaultTopology(): Topology { - return { - nextId: 100, - nodes: [ - { id: 'src-wood', label: 'Лесопилка', x: 40, y: 40, capacity: null, - behavior: { kind: 'source', resource: 'wood', rate: 5 } }, - { id: 'src-stone', label: 'Каменоломня', x: 40, y: 180, capacity: null, - behavior: { kind: 'source', resource: 'stone', rate: 3 } }, - { id: 'src-iron', label: 'Рудник', x: 40, y: 320, capacity: null, - behavior: { kind: 'source', resource: 'iron', rate: 2 } }, - - { id: 'store-wood', label: 'Склад дерева', x: 300, y: 40, capacity: 100, - behavior: { kind: 'storage' } }, - { id: 'store-stone', label: 'Склад камня', x: 300, y: 180, capacity: 100, - behavior: { kind: 'storage' } }, - { id: 'store-iron', label: 'Склад железа', x: 300, y: 320, capacity: 100, - behavior: { kind: 'storage' } }, - - { id: 'conv-craft', label: 'Мастерская', x: 560, y: 110, capacity: 50, - behavior: { kind: 'converter', period: 2, timer: 0, - inputs: [{ resource: 'wood', amount: 3 }, { resource: 'stone', amount: 2 }], - outputs: [{ resource: 'gold', amount: 1 }] } }, - { id: 'conv-forge', label: 'Кузница', x: 560, y: 320, capacity: 50, - behavior: { kind: 'converter', period: 2, timer: 0, - inputs: [{ resource: 'iron', amount: 2 }, { resource: 'wood', amount: 1 }], - outputs: [{ resource: 'gold', amount: 2 }] } }, - - { id: 'vault', label: 'Хранилище золота', x: 820, y: 210, capacity: 200, - behavior: { kind: 'storage' } }, - - { id: 'conv-research', label: 'Лаборатория', x: 1060, y: 210, capacity: 30, - behavior: { kind: 'converter', period: 3, timer: 0, - inputs: [{ resource: 'gold', amount: 5 }], - outputs: [{ resource: 'knowledge', amount: 1 }] } }, - { id: 'store-knowledge', label: 'Архив знаний', x: 1300, y: 210, capacity: null, - behavior: { kind: 'storage' } }, - ], - edges: [ - { id: 'e1', from: 'src-wood', to: 'store-wood', resource: 'wood', rate: 5, priority: 0 }, - { id: 'e2', from: 'src-stone', to: 'store-stone', resource: 'stone', rate: 3, priority: 0 }, - { id: 'e3', from: 'src-iron', to: 'store-iron', resource: 'iron', rate: 2, priority: 0 }, - - { id: 'e4', from: 'store-wood', to: 'conv-craft', resource: 'wood', rate: 3, priority: 1 }, - { id: 'e5', from: 'store-stone', to: 'conv-craft', resource: 'stone', rate: 2, priority: 1 }, - { id: 'e6', from: 'store-wood', to: 'conv-forge', resource: 'wood', rate: 1, priority: 0 }, - { id: 'e7', from: 'store-iron', to: 'conv-forge', resource: 'iron', rate: 2, priority: 1 }, - - { id: 'e8', from: 'conv-craft', to: 'vault', resource: 'gold', rate: 5, priority: 0 }, - { id: 'e9', from: 'conv-forge', to: 'vault', resource: 'gold', rate: 5, priority: 0 }, - - { id: 'e10', from: 'vault', to: 'conv-research', resource: 'gold', rate: 5, priority: 0 }, - { id: 'e11', from: 'conv-research', to: 'store-knowledge', resource: 'knowledge', rate: 5, priority: 0 }, - ], - }; -} diff --git a/src/engine/behaviours/base.ts b/src/engine/behaviours/base.ts deleted file mode 100644 index 65433cf..0000000 --- a/src/engine/behaviours/base.ts +++ /dev/null @@ -1,49 +0,0 @@ -import Decimal, { DecimalSource } from "break_eternity.js"; -import { BehaviourDisplayInfo, BehaviourLink, BehaviourSocket, Resource, SerializedBehaviour } from "../../types"; - -export abstract class Behaviour { - public readonly id: string; - public readonly kind: string; - public readonly inputLinks: BehaviourLink[]; - - constructor(source: SerializedBehaviour) { - this.id = source.id; - this.kind = source.kind; - this.inputLinks = source.inputLinks.pins; - } - - public getDisplayInfo(): BehaviourDisplayInfo { - return { - storages: {}, - sockets: [ - ...this.inputs.map((input, index) => ({ - type: 'input', - index, - resource: input, - } satisfies BehaviourSocket)), - ...this.outputs.map((output, index) => ({ - type: 'output', - index, - resource: output, - } satisfies BehaviourSocket)), - ], - }; - } - - public abstract get inputs(): Resource[]; - public abstract get outputs(): Resource[]; - - /** Withdraws up to `amount` of `resource` from this behaviour's storage, returning how much was actually taken. */ - public abstract take(resource: Resource, amount: DecimalSource): Decimal; - - protected abstract give(resource: Resource, amount: DecimalSource): void; - - abstract reset(): void; - - /** - * Advances this behaviour by `deltaTime`. Must pull whatever inputs it needs (via `resolve`) - * before advancing its own cycle, so that a cycle only completes once enough input was actually - * available — otherwise it stalls and tries again on a later tick. - */ - abstract tick(deltaTime: number, resolve: (id: string) => Behaviour | undefined): void; -} diff --git a/src/engine/behaviours/converter.ts b/src/engine/behaviours/converter.ts deleted file mode 100644 index 541b135..0000000 --- a/src/engine/behaviours/converter.ts +++ /dev/null @@ -1,118 +0,0 @@ -import Decimal, { DecimalSource } from "break_eternity.js"; -import { BehaviourDisplayInfo, Resource, Resources, SerializedBehaviour, SerializedConverter } from "../../types"; -import { Behaviour } from "./base"; - -export class Converter extends Behaviour { - protected readonly storage: Partial; - public cycleTime: Decimal; - public readonly cycleLength: Decimal; - public readonly inputRates: Partial; - public readonly outputRates: Partial; - - constructor(source: SerializedBehaviour & SerializedConverter) { - if (source.kind !== 'converter') - throw new Error('Invalid source'); - super(source); - - this.cycleTime = new Decimal(source.cycleTime); - this.cycleLength = new Decimal(source.rules.cycleLength); - - this.storage = {}; - for (const [res, amount] of Object.entries(source.storage ?? {})) - this.storage[res as Resource] = new Decimal(amount); - - this.inputRates = {}; - for (const [res, rate] of Object.entries(source.rules.inputRates ?? {})) - this.inputRates[res as Resource] = new Decimal(rate); - - this.outputRates = {}; - for (const [res, rate] of Object.entries(source.rules.outputRates ?? {})) - this.outputRates[res as Resource] = new Decimal(rate); - } - - public get inputs(): Resource[] { - return Object.keys(this.inputRates) as Resource[]; - } - - public get outputs(): Resource[] { - return Object.keys(this.outputRates) as Resource[]; - } - - /** - * A converter grabs a full batch of each input the moment enough is available — reserving - * ahead for however many cycles (including the one currently underway) the elapsed time could - * reach, rather than trickling resources in proportionally as time passes. If the source can't - * supply the full batch, `cycleTime` only advances as far as what was actually secured - * justifies: a converter can't be "cooking" a batch it doesn't have the ingredients for, so it - * stalls proportionally to whatever is actually missing rather than free-running on elapsed - * time alone. - */ - public tick(deltaTime: number, resolve: (id: string) => Behaviour | undefined): void { - const idealCycleTime = this.cycleTime.add(deltaTime); - const reserveCycles = idealCycleTime.div(this.cycleLength).ceil(); - let newCycleTime = idealCycleTime; - - for (const [res, rate] of Object.entries(this.inputRates) as [Resource, Decimal][]) { - if (rate.eq(0)) - continue; - - const target = rate.mul(reserveCycles); - const have = this.storage[res] ?? Decimal.dZero; - const shortfall = target.sub(have).max(0); - if (shortfall.gt(0)) { - const link = this.inputLinks.find((l) => l.resource === res); - if (link) { - const source = resolve(link.target); - if (source) - this.give(res, source.take(link.resource, shortfall)); - } - } - - const haveNow = this.storage[res] ?? Decimal.dZero; - newCycleTime = newCycleTime.min(haveNow.mul(this.cycleLength).div(rate)); - } - - this.cycleTime = newCycleTime; - const affordableCycles = this.cycleTime.div(this.cycleLength).floor(); - - if (affordableCycles.gt(0)) { - for (const [res, rate] of Object.entries(this.inputRates) as [Resource, Decimal][]) - this.take(res, rate.mul(affordableCycles)); - for (const [res, rate] of Object.entries(this.outputRates) as [Resource, Decimal][]) - this.give(res, rate.mul(affordableCycles)); - this.cycleTime = this.cycleTime.sub(affordableCycles.mul(this.cycleLength)); - } - } - - public take(resource: Resource, amount: DecimalSource): Decimal { - const available = this.storage[resource] ?? Decimal.dZero; - const taken = available.min(amount); - this.storage[resource] = available.sub(taken); - return taken; - } - - protected give(resource: Resource, amount: DecimalSource): void { - this.storage[resource] = (this.storage[resource] ?? Decimal.dZero).add(amount); - } - - public getStorage(): Partial { - return { ...this.storage }; - } - - public getDisplayInfo(): BehaviourDisplayInfo { - const info = super.getDisplayInfo(); - info.storages['Буфер'] = { ...this.storage } as Resources; - return { - ...info, - raw: { - inputRates: this.inputRates, - outputRates: this.outputRates, - }, - } as any; - } - - public reset() { - for (const res of Object.keys(this.storage)) - delete (this.storage[res as Resource]); - } -} diff --git a/src/engine/behaviours/storage.ts b/src/engine/behaviours/storage.ts deleted file mode 100644 index 31dd4b8..0000000 --- a/src/engine/behaviours/storage.ts +++ /dev/null @@ -1,78 +0,0 @@ -import Decimal, { DecimalSource } from "break_eternity.js"; -import { BehaviourDisplayInfo, Resource, Resources, SerializedBehaviour, SerializedStorage } from "../../types"; -import { Behaviour } from "./base"; - -export class Storage extends Behaviour { - public readonly capacity: Decimal | undefined; - public readonly acceptsResources: Resource[]; - public readonly items: Partial; - - constructor(source: SerializedBehaviour & SerializedStorage) { - if (source.kind !== 'storage') - throw new Error('Invalid source'); - super(source); - - this.capacity = source.capacity ? new Decimal(source.capacity) : undefined; - this.acceptsResources = source.acceptsResources; - this.items = {}; - for (const [res, amount] of Object.entries(source.items ?? {})) - this.items[res as Resource] = new Decimal(amount); - } - - public get inputs(): Resource[] { - return this.acceptsResources; - } - - public get outputs(): Resource[] { - return this.acceptsResources; - } - - /** - * A storage has no rate of its own — it pulls as much as its linked source is willing to give - * (up to whatever room its own capacity leaves), and later hands out as much as whoever calls - * `take` asks for (up to whatever it actually has). The speed in either direction is entirely - * up to the counterpart, not this behaviour. - */ - public tick(_deltaTime: number, resolve: (id: string) => Behaviour | undefined): void { - for (const resource of this.acceptsResources) { - const link = this.inputLinks.find((l) => l.resource === resource); - if (!link) - continue; - - const source = resolve(link.target); - if (!source) - continue; - - const have = this.items[resource] ?? Decimal.dZero; - const room = this.capacity ? this.capacity.sub(have).max(0) : Decimal.dInf; - if (room.gt(0)) - this.give(resource, source.take(link.resource, room)); - } - } - - public take(resource: Resource, amount: DecimalSource): Decimal { - const available = this.items[resource] ?? Decimal.dZero; - const taken = available.min(amount); - this.items[resource] = available.sub(taken); - return taken; - } - - protected give(resource: Resource, amount: DecimalSource): void { - this.items[resource] = (this.items[resource] ?? Decimal.dZero).add(amount); - } - - public getStorage(): Partial { - return { ...this.items }; - } - - public getDisplayInfo(): BehaviourDisplayInfo { - const info = super.getDisplayInfo(); - info.storages['Хранилище'] = { ...this.items } as Resources; - return info; - } - - public reset() { - for (const res of Object.keys(this.items)) - delete (this.items[res as Resource]); - } -} diff --git a/src/engine/game.ts b/src/engine/game.ts deleted file mode 100644 index 427f23c..0000000 --- a/src/engine/game.ts +++ /dev/null @@ -1,42 +0,0 @@ -import Decimal from "break_eternity.js"; -import { deserializeBehaviour } from "./saveLoad"; -import { SerializedGame } from "../types"; -import { Unit } from '../types'; -import { orderByDependency } from "../utils/behaviourSort"; - -export class Game { - public time: Decimal; - public readonly nodes: Unit[]; - - constructor(source: SerializedGame) { - this.time = new Decimal(source.time); - this.nodes = source.nodes.map((node) => ({ - id: node.id, - name: node.name, - behaviour: deserializeBehaviour(node.behaviour), - })); - } - - public tick(deltaTime: number) { - this.time = this.time.add(deltaTime); - - const behavioursById = new Map(this.nodes.map((node) => [node.behaviour.id, node.behaviour])); - const resolve = (id: string) => behavioursById.get(id); - - // Producers must tick (and so fill their storage) before whatever pulls from them, - // so resources can flow all the way down a chain within a single tick. - for (const behaviour of orderByDependency(this.nodes.map((node) => node.behaviour))) - behaviour.tick(deltaTime, resolve); - } - - public reset() { - this.time = Decimal.dZero; - for (const node of this.nodes) - node.behaviour.reset(); - } - - /** Shallow copy with a new identity, so React sees a change after in-place mutations like `tick`. */ - public clone(): Game { - return Object.assign(Object.create(Game.prototype), this); - } -} diff --git a/src/engine/index.ts b/src/engine/index.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/engine/saveLoad.ts b/src/engine/saveLoad.ts deleted file mode 100644 index 268f0c1..0000000 --- a/src/engine/saveLoad.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { defaultGame } from "../prefabs/game"; -import { SerializedBehaviour, SerializedConverter, SerializedGame, SerializedStorage } from "../types"; -import { Behaviour } from "./behaviours/base"; -import { Converter } from "./behaviours/converter"; -import { Storage } from "./behaviours/storage"; -import { Game } from "./game"; - -export const saveParams = { - path: 'default', -} - -export function serializeBehaviour(behaviour: Behaviour): SerializedBehaviour { - const base = { - id: behaviour.id, - kind: behaviour.kind, - inputLinks: { pins: behaviour.inputLinks }, - }; - - switch (behaviour.kind) { - case 'converter': { - const converter = behaviour as Converter; - return { - ...base, - cycleTime: converter.cycleTime, - storage: converter.getStorage(), - rules: { - cycleLength: converter.cycleLength, - inputRates: converter.inputRates, - outputRates: converter.outputRates, - } - } as (SerializedBehaviour & SerializedConverter);; - } - case 'storage': - const storage = behaviour as Storage; - return { - ...base, - capacity: storage.capacity, - acceptsResources: storage.acceptsResources, - items: storage.getStorage(), - } as (SerializedBehaviour & SerializedStorage); - default: - throw new Error('Unknown behaviour'); - } -} - -export function deserializeBehaviour(value: SerializedBehaviour): Behaviour { - switch (value.kind) { - case 'converter': - return new Converter(value); - case 'storage': - return new Storage(value); - default: - throw new Error('Unknown behaviour'); - } -} - -export function serialize(game: Game): SerializedGame { - return { - time: game.time, - nodes: game.nodes.map((node) => ({ - id: node.id, - name: node.name, - behaviour: serializeBehaviour(node.behaviour), - })), - }; -} - -export function deserialize(value: SerializedGame): Game { - return new Game(value); -} - -export function serializeToString(game: Game): string { - return JSON.stringify(serialize(game)); -} - -export function deserializeFromString(value: string): Game { - return deserialize(JSON.parse(value)); -} - -const KEY_GAME = 'incgane'; - -export function save(game: Game) { - localStorage.setItem(`${saveParams.path}_${KEY_GAME}`, serializeToString(game)); -} - -export function load(): Game { - try { - const raw = localStorage.getItem(`${saveParams.path}_${KEY_GAME}`); - if (raw) - return deserializeFromString(raw); - } catch (e) { - console.warn('Не удалось прочитать игру из localStorage', e); - } - const fresh = deserialize(defaultGame()); - save(fresh); - return fresh; -} \ No newline at end of file diff --git a/src/prefabs/game.ts b/src/prefabs/game.ts index 3497179..45973e3 100644 --- a/src/prefabs/game.ts +++ b/src/prefabs/game.ts @@ -1,4 +1,4 @@ -import { SerializedGame } from "../types"; +import { SerializedGame } from "@incgame/engine"; export const defaultGame: () => SerializedGame = () => ({ time: 0, @@ -59,5 +59,23 @@ export const defaultGame: () => SerializedGame = () => ({ items: {}, }, }, + { + id: 'shop2', + name: 'Магазин 2', + behaviour: { + id: 'shop.2', + kind: 'storage', + inputLinks: { + pins: [ + { + resource: 'stone', + target: 'forest.1', + } + ] + }, + acceptsResources: ['stone'], + items: {}, + }, + }, ], }); diff --git a/src/saveLoad.ts b/src/saveLoad.ts new file mode 100644 index 0000000..720960a --- /dev/null +++ b/src/saveLoad.ts @@ -0,0 +1,25 @@ +import { defaultGame } from "./prefabs/game"; +import { Game, serializeGameToString, deserializeGame, deserializeGameFromString } from '@incgame/engine'; + +export const saveParams = { + path: 'default', +} + +const KEY_GAME = 'incgame'; + +export function save(game: Game) { + localStorage.setItem(`${saveParams.path}_${KEY_GAME}`, serializeGameToString(game)); +} + +export function load(): Game { + try { + const raw = localStorage.getItem(`${saveParams.path}_${KEY_GAME}`); + if (raw) + return deserializeGameFromString(raw); + } catch (e) { + console.warn('Не удалось прочитать игру из localStorage', e); + } + const fresh = deserializeGame(defaultGame()); + save(fresh); + return fresh; +} diff --git a/src/types/behaviour.ts b/src/types/behaviour.ts deleted file mode 100644 index 67e881b..0000000 --- a/src/types/behaviour.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Resource, Resources } from "./resource"; - -export type BehaviourResourcePin = { - resource: Resource; -} - -export type BehaviourSocket = { - type: 'input' | 'output'; - index: number; // within its type - resource: Resource; -} - -export type BehaviourLink = { - target: string, - resource: Resource; -} - -export type BehaviourDisplayInfo = { - storages: Record; - sockets: BehaviourSocket[]; - - [x: string]: unknown; -} diff --git a/src/types/index.ts b/src/types/index.ts deleted file mode 100644 index 06703a4..0000000 --- a/src/types/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './behaviour'; -export * from './unit'; -export * from './resource'; -export * from './serialization'; diff --git a/src/types/resource.ts b/src/types/resource.ts deleted file mode 100644 index aaf57d9..0000000 --- a/src/types/resource.ts +++ /dev/null @@ -1,5 +0,0 @@ -import Decimal from "break_eternity.js"; - -export type Resource = 'tree' | 'wood' | 'stone'; - -export type Resources = Record; diff --git a/src/types/serialization.ts b/src/types/serialization.ts deleted file mode 100644 index 59983da..0000000 --- a/src/types/serialization.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Unit } from './unit'; -import { DecimalSource } from "break_eternity.js"; -import { Resource } from "./resource"; - -export type SerializedInputLink = { - target: SerializedBehaviour['id'], - resource: Resource, -} - -export type SerializedInputLinks = { - pins: SerializedInputLink[]; -} - -export type SerializedConverter = { - kind: 'converter'; - cycleTime?: DecimalSource; - storage?: Partial>; - rules: { - cycleLength: DecimalSource; - inputRates?: Partial>; - outputRates?: Partial>; - } -} - -export type SerializedStorage = { - kind: 'storage', - capacity?: DecimalSource; - acceptsResources: Resource[]; - items: Partial>; -} - -export type SerializedBehaviour = { - id: string; - inputLinks: SerializedInputLinks; -} & (SerializedConverter | SerializedStorage); - -export type SerializedNode = { - id: Unit['id'], - name: Unit['name'], - behaviour: SerializedBehaviour, -} - -export type SerializedGame = { - time: DecimalSource; - nodes: SerializedNode[]; -} diff --git a/src/types/unit.ts b/src/types/unit.ts deleted file mode 100644 index 2129fbb..0000000 --- a/src/types/unit.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Behaviour } from "../engine/behaviours/base"; - -export type Unit = { - id: string; - name: string; - behaviour: Behaviour; -} diff --git a/src/ui/graph.ts b/src/ui/graph.ts index dd14280..1b824b5 100644 --- a/src/ui/graph.ts +++ b/src/ui/graph.ts @@ -1,6 +1,5 @@ import ELK, { ElkNode } from "elkjs/lib/elk.bundled.js"; -import { Unit } from "../types"; -import { Behaviour } from "../engine/behaviours/base"; +import { Unit, Behaviour } from "@incgame/engine"; const elk = new ELK(); diff --git a/src/ui/resource.ts b/src/ui/resource.ts index 304a517..7a65308 100644 --- a/src/ui/resource.ts +++ b/src/ui/resource.ts @@ -1,4 +1,4 @@ -import { Resource } from "../types"; +import { Resource } from "@incgame/engine"; export type ResourceDescription = { id: Resource; diff --git a/src/utils/behaviourSort.ts b/src/utils/behaviourSort.ts deleted file mode 100644 index 18b486b..0000000 --- a/src/utils/behaviourSort.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Behaviour } from "../engine/behaviours/base"; - -/** Orders behaviours so that whatever a behaviour's inputs link to comes before it (a dependency cycle just falls back to encounter order). */ -export function orderByDependency(behaviours: Behaviour[]): Behaviour[] { - const byId = new Map(behaviours.map((behaviour) => [behaviour.id, behaviour])); - const visited = new Set(); - const order: Behaviour[] = []; - - function visit(behaviour: Behaviour) { - if (visited.has(behaviour.id)) - return; - visited.add(behaviour.id); - for (const inputLink of behaviour.inputLinks) { - const dependency = byId.get(inputLink.target); - if (dependency) - visit(dependency); - } - order.push(behaviour); - } - - for (const behaviour of behaviours) - visit(behaviour); - - return order; -} diff --git a/vite.config.ts b/vite.config.ts index 0466183..54ff11d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,29 @@ +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +const rootDir = path.dirname(fileURLToPath(import.meta.url)); +const engineDir = path.resolve(rootDir, '../engine'); + export default defineConfig({ plugins: [react()], + resolve: { + // Follow the node_modules symlink to the real sibling directory, so Vite's dev server + // watches and transforms the actual engine source files (not a frozen pre-bundled copy). + preserveSymlinks: false, + }, + optimizeDeps: { + // Treat @incgame/engine as first-party source, not a vendor dependency to pre-bundle once + // and cache — otherwise edits to the engine package wouldn't trigger HMR until a full + // dev-server restart. + exclude: ['@incgame/engine'], + }, + server: { + fs: { + // The engine package lives outside this project's root (a sibling directory reached + // through a node_modules symlink); allow Vite to serve/watch files from there too. + allow: [rootDir, engineDir], + }, + }, });