# Save and restore closure-backed combat runtimes

Use this recipe when an existing project wants JGengine's combat runtimes but
keeps its own save system, clock, entities, and authority. The runtimes expose
detached plain state; no JGengine store or
`GameContext` is required.

## Install and focused imports

```sh
bun add @jgengine/core
```

```ts
import {
  createMagazine,
  type MagazineSnapshot,
} from "@jgengine/core/combat/magazine";
import {
  createStats,
  type StatsSnapshot,
} from "@jgengine/core/stats/statModifiers";
```

## Caller-owned save data

```ts
type VehicleStat = "speed" | "handling";

interface VehicleRuntimeSave {
  magazine: MagazineSnapshot;
  stats: StatsSnapshot<VehicleStat>;
}

interface ExistingSave {
  nowMs: number;
  vehicle: VehicleRuntimeSave;
  sharedAmmo: number;
}
```

The project owns this schema and decides when it is written, replicated,
rolled back, replayed, or transferred to a worker.

## Run and snapshot

```ts
let nowMs = 4_000;
let sharedAmmo = 18;

const magazine = createMagazine({
  capacity: 6,
  reloadMs: 900,
  reserve: {
    current: () => sharedAmmo,
    spend(amount) {
      if (amount > sharedAmmo) return false;
      sharedAmmo -= amount;
      return true;
    },
    gain(amount) {
      sharedAmmo += amount;
    },
  },
});

const stats = createStats<VehicleStat>(
  { speed: 12, handling: 8 },
  { now: () => nowMs },
);
stats.addSource("boost", { speed: { multiply: 1.25 } }, { expiresAtMs: 7_000 });

magazine.fire(2);
magazine.startReload();
magazine.tick(0.3);

const save: ExistingSave = {
  nowMs,
  vehicle: {
    magazine: magazine.snapshot(),
    stats: stats.snapshot(),
  },
  sharedAmmo,
};

const serialized = JSON.stringify(save);
```

Snapshots are detached from the live closures. Mutating or continuing the
runtime after this point does not rewrite `save`.

## Restore and resume

```ts
const decoded = JSON.parse(serialized) as ExistingSave;
nowMs = decoded.nowMs;
sharedAmmo = decoded.sharedAmmo; // restore caller-owned reserve first

const resumedMagazine = createMagazine({
  capacity: 6,
  reloadMs: 900,
  loaded: 0,
  reserve: {
    current: () => sharedAmmo,
    spend(amount) {
      if (amount > sharedAmmo) return false;
      sharedAmmo -= amount;
      return true;
    },
    gain(amount) {
      sharedAmmo += amount;
    },
  },
});
if (!resumedMagazine.restore(decoded.vehicle.magazine)) {
  throw new Error("magazine save does not match its reserve configuration");
}

const resumedStats = createStats<VehicleStat>(
  { speed: 0, handling: 0 },
  { now: () => nowMs },
);
resumedStats.restore(decoded.vehicle.stats);

resumedMagazine.tick(0.6); // finishes the same in-progress 900ms reload
nowMs = 7_000;
resumedStats.get("speed"); // injected clock expires the boost deterministically
```

For a self-managed numeric magazine reserve, the magazine snapshot contains
and restores that reserve directly. For a caller-owned `MagazineReserve`, save
and restore the caller's reserve first; `restore` returns `false` rather than
partially changing loaded/reload state when it cannot reconcile the reserve.

## Retune without reconstructing the magazine

Equipment or upgrades can change capacity and reload duration in place:

```ts
if (!magazine.retune({ capacity: 8, reloadMs: 750 })) {
  throw new Error("capacity would strand loaded rounds");
}
```

Growing capacity never grants ammo. Retuning preserves loaded rounds, shared
reserve identity/value, and elapsed reload milliseconds. A shorter duration
can put `reloadFraction()` at 1; only the next positive `tick` completes the
reload and draws reserve. Save the current tuning in the caller's equipment
schema and supply it when recreating the runtime before restoring its snapshot.

Shrinking below the loaded count rejects the entire tuning by default. Choose
`overflow: "return-to-reserve"` to return exactly the excess to a finite reserve
with `gain`, or `overflow: "discard"` to explicitly destroy those rounds. Returning
to an infinite reserve or a read/spend-only adapter rejects without changing
capacity, duration, or ammo.

## Abilities, shields, and meters

Ability kits, shields, and meters use the same save boundary:

```ts
const saved = JSON.stringify({
  abilities: kit.state(),
  shield: shield.state(),
  heat: heat.state(),
});
const decoded = JSON.parse(saved);
if (!kit.restore(decoded.abilities)) throw new Error("ability slot/group ids differ");
if (!shield.restore(decoded.shield)) throw new Error("external shield pool max differs");
heat.restore(decoded.heat);
```

`AbilityKit.state()` contains charges, separate recharge/flash/group timers,
and retuned cooldowns/costs. `state(slotId, resourceAvailable?)` and `snapshot()`
remain HUD views. Rebuild with the same slot/group definitions; save any bound
resource separately. Restore never spends it.

`RegenShield.state()` includes mutable bounds, pool value, grace timing, the
active delay override, and the last external-pool observation. Restore writes
the pool value, preserving even damage waiting to be observed on the next tick;
the caller's pool must already have the saved max. Use `refill(amount)` for
pickups; numeric `restore(amount)` remains a deprecated compatibility alias.

`EventMeter.state()` and `AccumulatorMeter.state()` contain value, the hold
latch, and the decay-delay timer. Rebuild with the same meter configuration.
For caller-owned visibility policy, `heat.tick(dt, !sourceVisible)` pauses both
decay and its delay while seen; omitting the second argument advances normally.

## Ownership

The existing project owns save versioning, entity identity, clock value,
reserve state, networking/rollback policy, content, and when ticks occur.
JGengine owns the closure convenience methods, state encoding shape,
detached copies, and deterministic continuation once the caller supplies the
same config and clock.

## Common traps

- Re-supply current tuning such as magazine capacity/reload duration
  and immutable configuration such as the stat id set when rebuilding a runtime; snapshots contain mutable
  state, not content catalogs.
- Restore externally-owned reserve data before calling `Magazine.restore`.
- Persist the injected clock alongside expiring modifier snapshots or convert
  expiry timestamps in the project's migration layer.
- Do not keep a live snapshot object as mutable state. Restore it into a runtime
  and take a fresh snapshot for the next save.
- Check the boolean returned by `Magazine.restore`; reserve-kind mismatches and
  unreconcilable external reserves are rejected without partial magazine state.
