@jgengine/core 0.26.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/dist/game/playableGame.d.ts +19 -2
- package/dist/gameplay.d.ts +5 -0
- package/dist/gameplay.js +5 -0
- package/dist/inventory/carryLoad.d.ts +48 -0
- package/dist/inventory/carryLoad.js +44 -0
- package/dist/inventory/equipment.d.ts +95 -0
- package/dist/inventory/equipment.js +169 -0
- package/dist/inventory/inventoryModel.d.ts +15 -6
- package/dist/inventory/inventoryModel.js +54 -5
- package/dist/inventory/shapedGrid.d.ts +26 -0
- package/dist/inventory/shapedGrid.js +47 -0
- package/dist/loot/lootGen.d.ts +186 -0
- package/dist/loot/lootGen.js +437 -0
- package/dist/loot/lootWeapon.d.ts +35 -0
- package/dist/loot/lootWeapon.js +70 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +37 -1
- package/dist/nav/laneGraph.d.ts +178 -0
- package/dist/nav/laneGraph.js +671 -0
- package/dist/nav/traffic.d.ts +223 -0
- package/dist/nav/traffic.js +919 -0
- package/dist/physics/kinematicVehicle.d.ts +36 -0
- package/dist/physics/kinematicVehicle.js +15 -1
- package/dist/physics/vehicleDamage.d.ts +136 -0
- package/dist/physics/vehicleDamage.js +214 -0
- package/dist/physics/vehicleParts.d.ts +62 -0
- package/dist/physics/vehicleParts.js +65 -0
- package/dist/progression/gearScore.d.ts +56 -0
- package/dist/progression/gearScore.js +59 -0
- package/dist/progression/skillTree.d.ts +129 -0
- package/dist/progression/skillTree.js +265 -0
- package/dist/progression/xpCurve.d.ts +60 -0
- package/dist/progression/xpCurve.js +141 -0
- package/dist/scene/builtinSceneKinds.js +2 -0
- package/dist/world/decalKind.d.ts +48 -0
- package/dist/world/decalKind.js +86 -0
- package/dist/world/features.d.ts +3 -1
- package/dist/world/features.js +1 -0
- package/dist/world/grassKind.d.ts +5 -0
- package/dist/world/grassKind.js +3 -0
- package/dist/world/terraform.d.ts +8 -0
- package/dist/world.d.ts +5 -1
- package/dist/world.js +4 -0
- package/package.json +1 -1
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-combat/SKILL.md +3 -1
- package/skills/jgengine-gameplay/SKILL.md +31 -0
- package/skills/jgengine-gameplay/api.md +150 -6
- package/skills/jgengine-gameplay/capabilities.md +48 -3
- package/skills/jgengine-gameplay/reference-systems.md +118 -0
- package/skills/jgengine-world/SKILL.md +4 -0
- package/skills/jgengine-world/api.md +115 -5
- package/skills/jgengine-world/capabilities.md +20 -0
- package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -26,6 +26,50 @@ At publish, rename this heading to the new version and mirror the entries into
|
|
|
26
26
|
`packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
|
|
27
27
|
-->
|
|
28
28
|
|
|
29
|
+
## 0.27.0
|
|
30
|
+
|
|
31
|
+
### Migrate
|
|
32
|
+
|
|
33
|
+
- **Bump lockstep SDK packages to `^0.27.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.24.0`; `@jgengine/github` is `0.14.0`.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- `rollLoot` (`@jgengine/core/loot/lootGen`) rolls a named item from a caller `LootCatalog`: bases with mount slots, parts, affix pools, weighted rarity tiers, manufacturer personalities and a `levelScale` curve. Stats are `base × rarity.statScale × levelScale`, then part stats/multipliers, maker `bias`/`scale`, then additive and multiplicative affixes; additive affix rolls scale with level, multiplicative ones do not.
|
|
38
|
+
- `createLootRoller` wraps a validated catalog with roll counts per rarity, `snapshot()`/`restore()`/`reset()` and an optional devtools probe; `assertLootCatalog` rejects duplicate ids, unresolvable pools, empty item slots and a non-ascending level curve at construction.
|
|
39
|
+
- `validateLootItem` returns coded issues (`unknown-base`, `manufacturer-base`, `missing-slot-part`, `part-category`, `unknown-affix`, `empty-name`, …) and `compareLootItems` returns sorted per-stat deltas plus a weighted score with caller-named `lowerIsBetter` stats.
|
|
40
|
+
- `lootWeaponDefinition` (`@jgengine/core/loot/lootWeapon`) projects a rolled item onto a `WeaponDefinition` through a caller stat map, so a generated gun runs in `createWeaponSystem` after `assertWeaponDefinition`.
|
|
41
|
+
- `defineXpCurve` (`@jgengine/core/gameplay`) resolves one authored growth rule (`flat`, `linear`, `power`, `geometric`, `table`) plus a `maxLevel` cap into per-level thresholds, lifetime totals, a plain-words rule string and a `leveling` track.
|
|
42
|
+
- `createSkillTree` spends and refunds points over authored nodes with `cost`, `requires`, `excludes`, passives, actives and multi-node `synergies`; `resolved()` returns the stat modifiers and actions, and state snapshots/restores as `{ points, ranks }`.
|
|
43
|
+
- `gearScore` / `compareGearSets` score a whole loadout from stat weights, level, rarity and slot coverage.
|
|
44
|
+
- `lootStatContributions` and `scoreLootStats` expose the weight policy `compareLootItems` already used.
|
|
45
|
+
- `createEquipment` (`@jgengine/core/inventory/equipment`) wears gear by slot: slot defs declare accepted kinds and the slots they block, item defs declare the slots an item may occupy, a filled slot swaps and hands back the replaced item, and a two-handed item and the slot it blocks cannot both be filled. `state()` / `restore()` round-trip the worn set; a devtools probe reports filled slots.
|
|
46
|
+
- `characterLoad` and `canCarry` (`@jgengine/core/inventory/carryLoad`) weigh carried inventories plus worn gear against a capacity and hand back the shared encumbrance bands (tier, `moveMultiplier`, sprint/jump gates, `remaining`), so a stash left out of `carried` adds no weight.
|
|
47
|
+
- `fitShapedItem` (`@jgengine/core/inventory/shapedGrid`) places a shaped item at its first fit or names why not: `too-large` (with both sizes), `no-space`, `budget-exceeded`.
|
|
48
|
+
- `InventorySet.snapshot()` / `restore()` / `reset()` (`@jgengine/core/inventory/inventoryModel`) save and reload every declared inventory as detached plain data, so a bag and a stash never share live slots.
|
|
49
|
+
- `splitStack` takes an optional stack `limit`, rejecting `over-stack` instead of merging past the max stack.
|
|
50
|
+
- `createLaneGraph` and `laneGraphFromDocument` (`@jgengine/core/world`) read an authored lane graph out of the scene document: `traffic_lane` paths are one directed span each, `road` paths (the street bake) become a lane per direction split where streets cross, `traffic_intersection` markers carry signal phases and right-of-way in `meta`, and `traffic_parking` markers place kerbside spots.
|
|
51
|
+
- `createTraffic` (`@jgengine/core/world`) runs cars on that graph: IDM car following and braking, one car at a time through each intersection box behind a signal phase or first-come right-of-way, lane changes across authored neighbours, parking that reserves a spot, deterministic injected variety, `snapshot`/`restore`, and a devtools probe.
|
|
52
|
+
- `Traffic.setDemand(count)` is the fleet target; the time-of-day curve stays the caller's policy.
|
|
53
|
+
- `applyVehicleParts` (`@jgengine/core/world`) composes data-driven tuning parts onto a `KinematicVehicleTuning` — stat multipliers/addends applied in install order plus an optional tire grip curve — and returns the composed tuning, the installed ids, and a `VehicleFeel` (top speed, drive, brakes, turn rate, handbrake grip, and the grip curve's peak/slide). `withPart` swaps a part within its slot and `feelOf` reads the numbers out of any tuning.
|
|
54
|
+
- `createVehicleDamage` (`@jgengine/core/world`) is fixed-zone crash damage wired to handling and visuals: impacts accumulate into discrete stages, each stage costs grip/drive/brakes/top speed/drag and pulls the steering (`effect`), and each zone shows one bounded dent (`deformation`) that never exceeds its authored `deform`. Parts can detach at a set stage, a total impulse can disable the vehicle, and `snapshot`/`restore` resume mid-crash. `damagedTuning` and `damagedAxis` fold the effect into a tuning or an input, and `modifiers()` carries it as per-tick overrides.
|
|
55
|
+
- `KinematicVehicleTuning.weightTransfer` (`@jgengine/core/world`) adds load-transfer grip: longitudinal transfer under braking/traction and lateral transfer under cornering cut the tire grip curve (tire load sensitivity), so braking into a corner holds less slide than coasting through it. Each tick reports `weightTransfer` and `loadGrip`; omitting the block keeps the previous flat-grip model exactly.
|
|
56
|
+
- `KinematicVehicleModifiers.gripScale` scales the grip curve for one tick, mirroring `VehicleDynamicsModifiers.gripScale` so a damage layer drives either sim.
|
|
57
|
+
- `decal` scene kind (`@jgengine/core/world/decalKind`) and its renderer (`@jgengine/shell/scene/DecalRenderer`): dirt, cracks, paint and blood decals authored as placed markers, draped on the live terrain or standing on an authored tilt, with a deterministic procedural texture per style+seed.
|
|
58
|
+
- `TerrainMaterialLayer.material` (`@jgengine/core/world/terraform`): a material library name whose scanned color map paints that terrain layer. The runtime samples up to four layer maps at each layer's `tiling` and blends them through the authored mask weights (`createTerrainDetailMaterial`'s `layerMaps`), so painted terrain blends between authored layers instead of tiling one flat map.
|
|
59
|
+
- `grass_field` volumes now paint a ground-cover mat under their blades (`groundCover` rule, `@jgengine/shell/terrain/GrassCover`), so an authored patch reads as turf rather than bare ground with stubble. `GrassEnvironmentConfig.groundCover` does the same for `grass()` patches.
|
|
60
|
+
- `ModelConfig.wind` (`@jgengine/core/game/playableGame`) and `applyModelWind` (`@jgengine/shell/render/modelWind`): per-model foliage sway with height-ramped travel, shared gust clock and per-model strength/speed/direction.
|
|
61
|
+
|
|
62
|
+
### Fixed
|
|
63
|
+
|
|
64
|
+
- The player shell re-renders only for the ctx slices it reads — the possessed entity and the touch control mode — instead of on every `ctx` write. Subscribing the shell root to the raw ctx version reconciled the canvas, the whole actor tree and the HUD on each per-frame entity pose write, which is what `subscribeMembership` and the markers' imperative `useFrame` pose reads exist to avoid (#625). Scrap Signal: 86.6 ms → 16.7 ms per frame at the same 621 draws; Harbor Heat: 102.8 ms → 46.2 ms at the same ~7.2k draws.
|
|
65
|
+
- The `ocean()` environment feature passes the live ground to its shader, so the sea shades shallow→deep and its shoreline foam band lands where the bed meets the surface.
|
|
66
|
+
|
|
67
|
+
### Numbers
|
|
68
|
+
|
|
69
|
+
- Test catalog (60/25/10/4/1 rarity weights) over 10,000 drops at level 20, fixed seed: common 59.41% (target 60%), uncommon 25.66% (25%), rare 9.58% (10%), epic 4.20% (4%), legendary 1.15% (1%); every tier inside its 4σ binomial bound. All 10,000 items validated with zero issues and composed names (e.g. `Voss Rare Long Keen Lightframe of Reach`, `Tallow Long Lightframe`, `Voss Snub Lightframe of Steadying`).
|
|
70
|
+
- Level curve `[[1,1],[15,1.6],[30,2.4],[60,4]]`: a level-60 roll's base and additive affix values are 4× the same seed at level 1; maker personalities move the same roll (`Voss` +power/−cadence, `Tallow` the reverse).
|
|
71
|
+
- A rolled epic projects to a hitscan `WeaponDefinition` (rate, damage, integer magazine capacity, spread, range), passes `assertWeaponDefinition`, and its first `tick` fires one shot and one impact through `createDamageRuntime` with the rolled capacity spent.
|
|
72
|
+
|
|
29
73
|
## 0.26.0
|
|
30
74
|
|
|
31
75
|
### Migrate
|
|
@@ -250,9 +250,15 @@ export interface ModelConfig {
|
|
|
250
250
|
material?: ModelMaterialOverride;
|
|
251
251
|
/** `false` keeps this model's exported material params and skips the engine's default painted surface detail. Default true. */
|
|
252
252
|
surfaceDetail?: boolean;
|
|
253
|
+
/**
|
|
254
|
+
* Per-model wind sway for foliage: trunk and standing geometry stay planted while the upper
|
|
255
|
+
* canopy drifts in the shared gust field. `strength` is metres of tip travel, `speed` the gust
|
|
256
|
+
* rate, `height` the world height over which sway ramps in (defaults to the model's own height).
|
|
257
|
+
* `false` or omitted keeps the model rigid. Compiles into each cloned material.
|
|
258
|
+
*/
|
|
259
|
+
wind?: ModelWindConfig | false;
|
|
253
260
|
/** Reusable authored surfaces available to this model's named slot assignments. */
|
|
254
|
-
materialAssets?: readonly MaterialAsset[];
|
|
255
|
-
/** Sparse per-slot assignments; unselected imported materials keep their original appearance. */
|
|
261
|
+
materialAssets?: readonly MaterialAsset[]; /** Sparse per-slot assignments; unselected imported materials keep their original appearance. */
|
|
256
262
|
materialAssignments?: readonly MaterialAssignment[];
|
|
257
263
|
/** Hide named visual subtrees on this instance before placement measurement. Exact runtime names or unique original imported names resolve without removing bones or changing the cached model; missing or ambiguous names warn and leave existing visibility unchanged. Malformed lists reject before model or texture allocation. Omit or pass [] to retain imported visibility. */
|
|
258
264
|
hiddenNodes?: readonly string[];
|
|
@@ -282,6 +288,17 @@ export interface ModelConfig {
|
|
|
282
288
|
/** Tuning for the procedural part-motion driver when any part carries a `role`; omit for defaults. */
|
|
283
289
|
partMotion?: PartMotionParams;
|
|
284
290
|
}
|
|
291
|
+
/** Wind sway for a foliage model; see {@link ModelConfig.wind}. */
|
|
292
|
+
export interface ModelWindConfig {
|
|
293
|
+
/** Tip travel in metres. Default 0.35. */
|
|
294
|
+
strength?: number;
|
|
295
|
+
/** Gust rate multiplier. Default 1. */
|
|
296
|
+
speed?: number;
|
|
297
|
+
/** World height over which sway ramps from the base to full tip travel, in metres. Defaults to the model's measured height. */
|
|
298
|
+
height?: number;
|
|
299
|
+
/** Wind heading as a two-axis direction; defaults to the engine's shared gust direction. */
|
|
300
|
+
direction?: readonly [number, number];
|
|
301
|
+
}
|
|
285
302
|
/** Bone names and limits for {@link ModelConfig.ik}. */
|
|
286
303
|
export interface ModelIkConfig {
|
|
287
304
|
/** Thigh → shin → foot chains; omit to find them by bone name. `[]` turns foot IK off. */
|
package/dist/gameplay.d.ts
CHANGED
|
@@ -81,7 +81,12 @@ export { computeEffectiveStats, createModularItem, install, isComplete, missingR
|
|
|
81
81
|
export { createItemUse, type ItemUseHandler, type ItemUseInput } from "./item/use.js";
|
|
82
82
|
export { createUseBehaviorRegistry, type BehaviorConfig, type BehaviorState, type ComposedUse, type CompositionResult, type SerializedBehaviorState, type UseBehaviorContext, type UseBehaviorDef, type UseBehaviorOutcome, type UseBehaviorRef, type UseBehaviorRegistry, type UseBehaviorRejection, } from "./item/useBehavior.js";
|
|
83
83
|
export { createWeaponStats } from "./item/weapon.js";
|
|
84
|
+
export { MAX_LOOT_AFFIXES, assertLootCatalog, compareLootItems, createLootRoller, lootLevelScale, lootStatContributions, rollLoot, scoreLootStats, validateLootItem, type LootBaseDef, type LootCatalog, type LootComparison, type LootComparisonOptions, type LootInstalledPart, type LootIssueCode, type LootItem, type LootManufacturerDef, type LootPartDef, type LootRollOptions, type LootRoller, type LootRollerOptions, type LootRollerState, type LootStatDelta, type LootValidationIssue, } from "./loot/lootGen.js";
|
|
85
|
+
export { lootWeaponDefinition, type LootWeaponProjection, type LootWeaponStatMap } from "./loot/lootWeapon.js";
|
|
86
|
+
export { compareGearSets, gearScore, type GearScore, type GearScoreOptions, type GearSlotItem, } from "./progression/gearScore.js";
|
|
87
|
+
export { assertSkillTreeConfig, createSkillTree, type SkillActiveDef, type SkillNodeDef, type SkillRequirement, type SkillResolution, type SkillSpendReason, type SkillSpendResult, type SkillSynergyDef, type SkillTree, type SkillTreeConfig, type SkillTreeState, } from "./progression/skillTree.js";
|
|
84
88
|
export { createStatGraph, statModifierContributions, type StatContribution, type StatContributionStep, type StatDeriveContext, type StatDerivedDef, type StatExplanation, type StatGraph, type StatGraphDef, type StatInputDef, type StatModEntry, type StatOp, type StatSheet, type StatSheetState, } from "./progression/statGraph.js";
|
|
89
|
+
export { assertXpCurve, defineXpCurve, type XpCurve, type XpCurveDef, type XpGrowthRule, type XpLevelStep, } from "./progression/xpCurve.js";
|
|
85
90
|
export { type CellGrid } from "./puzzle/cellGrid.js";
|
|
86
91
|
export { type ShapeTable } from "./puzzle/fallingPiece.js";
|
|
87
92
|
export { createNameGenerator } from "./random/nameGen.js";
|
package/dist/gameplay.js
CHANGED
|
@@ -81,7 +81,12 @@ export { computeEffectiveStats, createModularItem, install, isComplete, missingR
|
|
|
81
81
|
export { createItemUse } from "./item/use.js";
|
|
82
82
|
export { createUseBehaviorRegistry, } from "./item/useBehavior.js";
|
|
83
83
|
export { createWeaponStats } from "./item/weapon.js";
|
|
84
|
+
export { MAX_LOOT_AFFIXES, assertLootCatalog, compareLootItems, createLootRoller, lootLevelScale, lootStatContributions, rollLoot, scoreLootStats, validateLootItem, } from "./loot/lootGen.js";
|
|
85
|
+
export { lootWeaponDefinition } from "./loot/lootWeapon.js";
|
|
86
|
+
export { compareGearSets, gearScore, } from "./progression/gearScore.js";
|
|
87
|
+
export { assertSkillTreeConfig, createSkillTree, } from "./progression/skillTree.js";
|
|
84
88
|
export { createStatGraph, statModifierContributions, } from "./progression/statGraph.js";
|
|
89
|
+
export { assertXpCurve, defineXpCurve, } from "./progression/xpCurve.js";
|
|
85
90
|
export {} from "./puzzle/cellGrid.js";
|
|
86
91
|
export {} from "./puzzle/fallingPiece.js";
|
|
87
92
|
export { createNameGenerator } from "./random/nameGen.js";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type EncumbranceConfig, type EncumbranceState, type LoadEntry } from "./encumbrance.js";
|
|
2
|
+
import type { InventoryState } from "./inventoryModel.js";
|
|
3
|
+
/** Per-item mass in the caller's weight unit; an unknown item weighs 0. */
|
|
4
|
+
export type WeightLookup = (itemId: string) => number;
|
|
5
|
+
/**
|
|
6
|
+
* Every filled stack in the given inventories as load entries, in slot order. Only the inventories
|
|
7
|
+
* the caller passes count, so a stash left out of the list adds no weight.
|
|
8
|
+
*
|
|
9
|
+
* @capability carry-load read carried stacks out of one or more inventories for a weight query
|
|
10
|
+
*/
|
|
11
|
+
export declare function carriedEntries(states: readonly InventoryState[]): LoadEntry[];
|
|
12
|
+
/** Everything a {@link characterLoad} reading takes: carried inventories, worn gear and the capacity they are measured against. */
|
|
13
|
+
export interface CharacterLoadInput {
|
|
14
|
+
/** Carried inventories (bag, rig, pockets); a stash left out does not weigh anything. */
|
|
15
|
+
carried?: readonly InventoryState[];
|
|
16
|
+
/** Worn item ids, one unit each — `Equipment.worn()` mapped to `itemId`. */
|
|
17
|
+
worn?: readonly string[];
|
|
18
|
+
weightOf: WeightLookup;
|
|
19
|
+
/** Carrying capacity in the same unit as `weightOf`; the caller adds level and gear bonuses. */
|
|
20
|
+
capacity: number;
|
|
21
|
+
/** Fraction of worn gear weight that counts against capacity (default `1`). */
|
|
22
|
+
wornFactor?: number;
|
|
23
|
+
encumbrance?: EncumbranceConfig;
|
|
24
|
+
}
|
|
25
|
+
/** A carrier's load: the encumbrance state, how it splits into carried and worn, and capacity left. */
|
|
26
|
+
export interface CharacterLoad extends EncumbranceState {
|
|
27
|
+
/** Mass of the carried inventories alone. */
|
|
28
|
+
carriedMass: number;
|
|
29
|
+
/** Mass worn gear adds after `wornFactor`. */
|
|
30
|
+
wornMass: number;
|
|
31
|
+
/** Capacity left before the carrier is immobile; negative when overloaded. */
|
|
32
|
+
remaining: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Weigh a character: carried stacks plus worn gear against a capacity, resolved into the shared
|
|
36
|
+
* encumbrance bands. Movement reads `moveMultiplier`, `canSprint`, `canJump` and `immobile`; pickup
|
|
37
|
+
* gates read `remaining` or {@link canCarry}. The stash is not part of `carried`, so it adds nothing.
|
|
38
|
+
*
|
|
39
|
+
* @capability carry-encumbrance weigh carried inventories plus worn gear against a capacity and hand back movement penalties
|
|
40
|
+
*/
|
|
41
|
+
export declare function characterLoad(input: CharacterLoadInput): CharacterLoad;
|
|
42
|
+
/**
|
|
43
|
+
* Whether `addedMass` more weight still fits under `capacity`. Pure: the caller decides what to do
|
|
44
|
+
* with a refusal, so an overload policy stays with the game.
|
|
45
|
+
*
|
|
46
|
+
* @capability carry-encumbrance gate a pickup against the carrier's remaining capacity
|
|
47
|
+
*/
|
|
48
|
+
export declare function canCarry(load: Pick<CharacterLoad, "mass" | "capacity">, addedMass: number): boolean;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { resolveEncumbrance, totalLoad, } from "./encumbrance.js";
|
|
2
|
+
/**
|
|
3
|
+
* Every filled stack in the given inventories as load entries, in slot order. Only the inventories
|
|
4
|
+
* the caller passes count, so a stash left out of the list adds no weight.
|
|
5
|
+
*
|
|
6
|
+
* @capability carry-load read carried stacks out of one or more inventories for a weight query
|
|
7
|
+
*/
|
|
8
|
+
export function carriedEntries(states) {
|
|
9
|
+
const entries = [];
|
|
10
|
+
for (const state of states) {
|
|
11
|
+
for (const slot of state.slots) {
|
|
12
|
+
if (slot !== null)
|
|
13
|
+
entries.push({ itemId: slot.itemId, quantity: slot.count });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return entries;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Weigh a character: carried stacks plus worn gear against a capacity, resolved into the shared
|
|
20
|
+
* encumbrance bands. Movement reads `moveMultiplier`, `canSprint`, `canJump` and `immobile`; pickup
|
|
21
|
+
* gates read `remaining` or {@link canCarry}. The stash is not part of `carried`, so it adds nothing.
|
|
22
|
+
*
|
|
23
|
+
* @capability carry-encumbrance weigh carried inventories plus worn gear against a capacity and hand back movement penalties
|
|
24
|
+
*/
|
|
25
|
+
export function characterLoad(input) {
|
|
26
|
+
const carriedMass = totalLoad(carriedEntries(input.carried ?? []), input.weightOf);
|
|
27
|
+
const factor = input.wornFactor !== undefined && Number.isFinite(input.wornFactor) && input.wornFactor >= 0
|
|
28
|
+
? input.wornFactor
|
|
29
|
+
: 1;
|
|
30
|
+
const wornEntries = (input.worn ?? []).map((itemId) => ({ itemId, quantity: 1 }));
|
|
31
|
+
const wornMass = totalLoad(wornEntries, input.weightOf) * factor;
|
|
32
|
+
const base = resolveEncumbrance(carriedMass + wornMass, input.capacity, input.encumbrance);
|
|
33
|
+
return { ...base, carriedMass, wornMass, remaining: input.capacity - base.mass };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Whether `addedMass` more weight still fits under `capacity`. Pure: the caller decides what to do
|
|
37
|
+
* with a refusal, so an overload policy stays with the game.
|
|
38
|
+
*
|
|
39
|
+
* @capability carry-encumbrance gate a pickup against the carrier's remaining capacity
|
|
40
|
+
*/
|
|
41
|
+
export function canCarry(load, addedMass) {
|
|
42
|
+
const added = Number.isFinite(addedMass) && addedMass > 0 ? addedMass : 0;
|
|
43
|
+
return load.mass + added <= load.capacity;
|
|
44
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** One body slot: the item kinds it accepts and the slots it occupies while filled. */
|
|
2
|
+
export interface EquipSlotDef {
|
|
3
|
+
/** Stable slot id, in the caller's nouns ("weapon", "rig", "boots"). */
|
|
4
|
+
id: string;
|
|
5
|
+
/** Item kinds this slot accepts; omitted accepts any kind. */
|
|
6
|
+
accepts?: readonly string[];
|
|
7
|
+
/** Slot ids this slot occupies while filled, e.g. a two-handed weapon blocking "offhand". */
|
|
8
|
+
blocks?: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The gear facts the equip rules read from an item. A rolled item resolves its runtime id through
|
|
12
|
+
* the instance registry to its base and answers from that base's data — this is not a second item type.
|
|
13
|
+
*/
|
|
14
|
+
export interface EquipItemDef {
|
|
15
|
+
/** Slot ids this item may occupy, most preferred first. */
|
|
16
|
+
slots: readonly string[];
|
|
17
|
+
/** Kind tag checked against {@link EquipSlotDef.accepts}. */
|
|
18
|
+
kind?: string;
|
|
19
|
+
}
|
|
20
|
+
/** Item lookup the rules read; returns nothing for an unknown id. */
|
|
21
|
+
export type EquipItemLookup = (itemId: string) => EquipItemDef | null | undefined;
|
|
22
|
+
/** Serializable equip state: the filled slots as `[slotId, itemId]` pairs. */
|
|
23
|
+
export interface EquipmentState {
|
|
24
|
+
slots: readonly (readonly [string, string])[];
|
|
25
|
+
}
|
|
26
|
+
/** Why an equip attempt was refused; `detail` names the item and slots involved. */
|
|
27
|
+
export type EquipRejection = "unknown-slot" | "not-equippable" | "wrong-slot" | "already-equipped" | "blocked";
|
|
28
|
+
/** An equip attempt: the new worn set plus the replaced item, or the refusal with its reason and detail. */
|
|
29
|
+
export type EquipResult = {
|
|
30
|
+
status: "ok";
|
|
31
|
+
state: EquipmentState;
|
|
32
|
+
slotId: string;
|
|
33
|
+
replaced: string | null;
|
|
34
|
+
} | {
|
|
35
|
+
status: "rejected";
|
|
36
|
+
reason: EquipRejection;
|
|
37
|
+
detail: string;
|
|
38
|
+
};
|
|
39
|
+
/** An unequip attempt: the new worn set plus the item taken off, or the refusal with its detail. */
|
|
40
|
+
export type UnequipResult = {
|
|
41
|
+
status: "ok";
|
|
42
|
+
state: EquipmentState;
|
|
43
|
+
itemId: string;
|
|
44
|
+
} | {
|
|
45
|
+
status: "rejected";
|
|
46
|
+
reason: "unknown-slot" | "empty-slot";
|
|
47
|
+
detail: string;
|
|
48
|
+
};
|
|
49
|
+
/** One worn item and the slot holding it. */
|
|
50
|
+
export interface WornEntry {
|
|
51
|
+
slotId: string;
|
|
52
|
+
itemId: string;
|
|
53
|
+
}
|
|
54
|
+
/** What the debug overlay reads about a worn set. */
|
|
55
|
+
export interface EquipmentProbeReading {
|
|
56
|
+
filled: number;
|
|
57
|
+
slots: Readonly<Record<string, string>>;
|
|
58
|
+
}
|
|
59
|
+
/** A worn-gear set: one item per slot, rules from slot and item data, state out and back in. */
|
|
60
|
+
export interface Equipment {
|
|
61
|
+
/** Wear `itemId` in `slotId`; a filled slot is replaced and its item returned as `replaced`. */
|
|
62
|
+
equip(slotId: string, itemId: string): EquipResult;
|
|
63
|
+
/** Wear `itemId` in the first of its preferred slots that is empty, else the first slot that accepts it. */
|
|
64
|
+
equipBest(itemId: string): EquipResult;
|
|
65
|
+
/** Clear `slotId` and hand back the item it held. */
|
|
66
|
+
unequip(slotId: string): UnequipResult;
|
|
67
|
+
/** Worn items in declared slot order. */
|
|
68
|
+
worn(): readonly WornEntry[];
|
|
69
|
+
/** The slot holding `itemId`, or null when that item is not worn. */
|
|
70
|
+
slotOf(itemId: string): string | null;
|
|
71
|
+
/** Declared slot ids, in order. */
|
|
72
|
+
slotIds(): readonly string[];
|
|
73
|
+
state(): EquipmentState;
|
|
74
|
+
/** Replace the worn set; returns false without mutation when an entry breaks the rules. */
|
|
75
|
+
restore(next: EquipmentState): boolean;
|
|
76
|
+
reset(): void;
|
|
77
|
+
/** Drop the devtools probe registered by `devtoolsName`. Safe to call twice. */
|
|
78
|
+
dispose(): void;
|
|
79
|
+
}
|
|
80
|
+
/** Options for {@link createEquipment}. */
|
|
81
|
+
export interface EquipmentOptions {
|
|
82
|
+
/** Register a devtools probe under this name reporting which slots are filled. */
|
|
83
|
+
devtoolsName?: string;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* A worn-gear set over caller slot and item data.
|
|
87
|
+
*
|
|
88
|
+
* Slot defs own what a slot accepts and which slots it blocks; item defs own which slots an item may
|
|
89
|
+
* occupy and its kind. Equipping into a filled slot swaps and returns the replaced item, so gear is
|
|
90
|
+
* never silently lost: the caller puts `replaced` back. A two-handed item and the slot it blocks
|
|
91
|
+
* cannot both be filled, and the rejection names the occupied slot.
|
|
92
|
+
*
|
|
93
|
+
* @capability equipment-slot-inventory wear gear by slot with item-kind rules, two-handed blocking and replace-on-equip
|
|
94
|
+
*/
|
|
95
|
+
export declare function createEquipment(slotDefs: readonly EquipSlotDef[], lookup: EquipItemLookup, options?: EquipmentOptions): Equipment;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { devtools } from "../devtools/devtools.js";
|
|
2
|
+
function slotAcceptsKind(slot, kind) {
|
|
3
|
+
if (slot.accepts === undefined)
|
|
4
|
+
return true;
|
|
5
|
+
return kind !== undefined && slot.accepts.includes(kind);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A worn-gear set over caller slot and item data.
|
|
9
|
+
*
|
|
10
|
+
* Slot defs own what a slot accepts and which slots it blocks; item defs own which slots an item may
|
|
11
|
+
* occupy and its kind. Equipping into a filled slot swaps and returns the replaced item, so gear is
|
|
12
|
+
* never silently lost: the caller puts `replaced` back. A two-handed item and the slot it blocks
|
|
13
|
+
* cannot both be filled, and the rejection names the occupied slot.
|
|
14
|
+
*
|
|
15
|
+
* @capability equipment-slot-inventory wear gear by slot with item-kind rules, two-handed blocking and replace-on-equip
|
|
16
|
+
*/
|
|
17
|
+
export function createEquipment(slotDefs, lookup, options = {}) {
|
|
18
|
+
const byId = new Map();
|
|
19
|
+
for (const def of slotDefs) {
|
|
20
|
+
if (def.id.trim() === "")
|
|
21
|
+
throw new RangeError('Equipment slot field "id" must not be empty');
|
|
22
|
+
if (byId.has(def.id))
|
|
23
|
+
throw new RangeError(`Equipment slot "${def.id}" is declared twice`);
|
|
24
|
+
byId.set(def.id, def);
|
|
25
|
+
}
|
|
26
|
+
const order = slotDefs.map((def) => def.id);
|
|
27
|
+
const filled = new Map();
|
|
28
|
+
function readState() {
|
|
29
|
+
const slots = [];
|
|
30
|
+
for (const slotId of order) {
|
|
31
|
+
const itemId = filled.get(slotId);
|
|
32
|
+
if (itemId !== undefined)
|
|
33
|
+
slots.push([slotId, itemId]);
|
|
34
|
+
}
|
|
35
|
+
return { slots };
|
|
36
|
+
}
|
|
37
|
+
function conflicts(slotId, occupied = filled) {
|
|
38
|
+
const blockedByOwn = byId.get(slotId)?.blocks ?? [];
|
|
39
|
+
const blocking = order.filter((other) => other !== slotId && (byId.get(other)?.blocks ?? []).includes(slotId));
|
|
40
|
+
return [...new Set([...blockedByOwn, ...blocking])].filter((other) => occupied.has(other));
|
|
41
|
+
}
|
|
42
|
+
function check(slotId, itemId, occupied) {
|
|
43
|
+
const slot = byId.get(slotId);
|
|
44
|
+
if (slot === undefined) {
|
|
45
|
+
return { status: "rejected", reason: "unknown-slot", detail: `"${slotId}" is not a declared slot (${order.join(", ")})` };
|
|
46
|
+
}
|
|
47
|
+
const item = lookup(itemId);
|
|
48
|
+
if (item === null || item === undefined || item.slots.length === 0) {
|
|
49
|
+
return { status: "rejected", reason: "not-equippable", detail: `item "${itemId}" declares no equip slot` };
|
|
50
|
+
}
|
|
51
|
+
if (!item.slots.includes(slotId)) {
|
|
52
|
+
return {
|
|
53
|
+
status: "rejected",
|
|
54
|
+
reason: "wrong-slot",
|
|
55
|
+
detail: `item "${itemId}" belongs in ${item.slots.join(", ")}; "${slotId}" is not one of them`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (!slotAcceptsKind(slot, item.kind)) {
|
|
59
|
+
return {
|
|
60
|
+
status: "rejected",
|
|
61
|
+
reason: "wrong-slot",
|
|
62
|
+
detail: `slot "${slotId}" accepts ${(slot.accepts ?? []).join(", ")}; item "${itemId}" is ${item.kind ?? "untyped"}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const wornAt = [...occupied].find(([, worn]) => worn === itemId)?.[0];
|
|
66
|
+
if (wornAt !== undefined && wornAt !== slotId) {
|
|
67
|
+
return { status: "rejected", reason: "already-equipped", detail: `item "${itemId}" is already worn in "${wornAt}"` };
|
|
68
|
+
}
|
|
69
|
+
const conflict = conflicts(slotId, occupied).filter((other) => other !== slotId)[0];
|
|
70
|
+
if (conflict !== undefined) {
|
|
71
|
+
return {
|
|
72
|
+
status: "rejected",
|
|
73
|
+
reason: "blocked",
|
|
74
|
+
detail: `slot "${conflict}" is occupied and cannot be worn with "${slotId}"`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
function equipInto(slotId, itemId) {
|
|
80
|
+
const refusal = check(slotId, itemId, filled);
|
|
81
|
+
if (refusal !== null)
|
|
82
|
+
return refusal;
|
|
83
|
+
const replaced = filled.get(slotId) ?? null;
|
|
84
|
+
filled.set(slotId, itemId);
|
|
85
|
+
return { status: "ok", state: readState(), slotId, replaced };
|
|
86
|
+
}
|
|
87
|
+
const probe = options.devtoolsName === undefined
|
|
88
|
+
? null
|
|
89
|
+
: devtools.probes.register(options.devtoolsName, () => ({
|
|
90
|
+
filled: filled.size,
|
|
91
|
+
slots: Object.fromEntries(filled),
|
|
92
|
+
}));
|
|
93
|
+
return {
|
|
94
|
+
equip: equipInto,
|
|
95
|
+
equipBest(itemId) {
|
|
96
|
+
const item = lookup(itemId);
|
|
97
|
+
if (item === null || item === undefined || item.slots.length === 0) {
|
|
98
|
+
return { status: "rejected", reason: "not-equippable", detail: `item "${itemId}" declares no equip slot` };
|
|
99
|
+
}
|
|
100
|
+
const allowed = item.slots.filter((slotId) => byId.has(slotId));
|
|
101
|
+
if (allowed.length === 0) {
|
|
102
|
+
return {
|
|
103
|
+
status: "rejected",
|
|
104
|
+
reason: "unknown-slot",
|
|
105
|
+
detail: `item "${itemId}" names no declared slot (${item.slots.join(", ")})`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const preferred = allowed.filter((slotId) => check(slotId, itemId, filled) === null);
|
|
109
|
+
if (preferred.length === 0)
|
|
110
|
+
return check(allowed[0], itemId, filled);
|
|
111
|
+
return equipInto(preferred.find((slotId) => !filled.has(slotId)) ?? preferred[0], itemId);
|
|
112
|
+
},
|
|
113
|
+
unequip(slotId) {
|
|
114
|
+
if (!byId.has(slotId)) {
|
|
115
|
+
return { status: "rejected", reason: "unknown-slot", detail: `"${slotId}" is not a declared slot (${order.join(", ")})` };
|
|
116
|
+
}
|
|
117
|
+
const itemId = filled.get(slotId);
|
|
118
|
+
if (itemId === undefined) {
|
|
119
|
+
return { status: "rejected", reason: "empty-slot", detail: `slot "${slotId}" holds nothing` };
|
|
120
|
+
}
|
|
121
|
+
filled.delete(slotId);
|
|
122
|
+
return { status: "ok", state: readState(), itemId };
|
|
123
|
+
},
|
|
124
|
+
worn() {
|
|
125
|
+
const out = [];
|
|
126
|
+
for (const slotId of order) {
|
|
127
|
+
const itemId = filled.get(slotId);
|
|
128
|
+
if (itemId !== undefined)
|
|
129
|
+
out.push({ slotId, itemId });
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
},
|
|
133
|
+
slotOf(itemId) {
|
|
134
|
+
for (const [slotId, worn] of filled)
|
|
135
|
+
if (worn === itemId)
|
|
136
|
+
return slotId;
|
|
137
|
+
return null;
|
|
138
|
+
},
|
|
139
|
+
slotIds: () => [...order],
|
|
140
|
+
state: readState,
|
|
141
|
+
restore(next) {
|
|
142
|
+
if (next === null || typeof next !== "object" || !Array.isArray(next.slots))
|
|
143
|
+
return false;
|
|
144
|
+
const staged = new Map();
|
|
145
|
+
for (const entry of next.slots) {
|
|
146
|
+
if (!Array.isArray(entry) || entry.length !== 2)
|
|
147
|
+
return false;
|
|
148
|
+
const [slotId, itemId] = entry;
|
|
149
|
+
if (typeof slotId !== "string" || typeof itemId !== "string" || itemId.trim() === "")
|
|
150
|
+
return false;
|
|
151
|
+
if (staged.has(slotId))
|
|
152
|
+
return false;
|
|
153
|
+
if (check(slotId, itemId, staged) !== null)
|
|
154
|
+
return false;
|
|
155
|
+
staged.set(slotId, itemId);
|
|
156
|
+
}
|
|
157
|
+
filled.clear();
|
|
158
|
+
for (const [slotId, itemId] of staged)
|
|
159
|
+
filled.set(slotId, itemId);
|
|
160
|
+
return true;
|
|
161
|
+
},
|
|
162
|
+
reset() {
|
|
163
|
+
filled.clear();
|
|
164
|
+
},
|
|
165
|
+
dispose() {
|
|
166
|
+
probe?.();
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -41,7 +41,7 @@ export type SplitResult = {
|
|
|
41
41
|
state: InventoryState;
|
|
42
42
|
} | {
|
|
43
43
|
status: "rejected";
|
|
44
|
-
reason: "invalid-slot" | "empty-slot" | "invalid-amount" | "no-space" | "slot-occupied";
|
|
44
|
+
reason: "invalid-slot" | "empty-slot" | "invalid-amount" | "no-space" | "slot-occupied" | "over-stack";
|
|
45
45
|
};
|
|
46
46
|
/**
|
|
47
47
|
* A bag of stackable items supporting add, remove, count, and move — the base inventory model.
|
|
@@ -63,13 +63,14 @@ export declare function hasItem(state: InventoryState, itemId: string, count: nu
|
|
|
63
63
|
export declare function moveItem(from: InventoryState, fromSlot: number, to: InventoryState, toLayout: InventoryLayout, traits: ItemTraits, toSlot?: number): MoveResult;
|
|
64
64
|
/**
|
|
65
65
|
* Splits `amount` off the stack in `slot` into `toSlot` (or the first empty slot when `toSlot` is
|
|
66
|
-
* omitted).
|
|
67
|
-
*
|
|
68
|
-
* leave both sides non-empty), no free
|
|
66
|
+
* omitted). Without `limit` it is limit-agnostic: a same-item target merges by simple addition.
|
|
67
|
+
* Pass the item's stack limit to keep the merge legal, which rejects `over-stack`. Rejects an empty
|
|
68
|
+
* source, `amount <= 0`, `amount >= stack count` (a split must leave both sides non-empty), no free
|
|
69
|
+
* slot, or a target holding a different item.
|
|
69
70
|
*
|
|
70
|
-
* @capability inventory-split split a stack into a target or first-empty slot
|
|
71
|
+
* @capability inventory-split split a stack into a target or first-empty slot, honoring a stack limit
|
|
71
72
|
*/
|
|
72
|
-
export declare function splitStack(state: InventoryState, slot: number, amount: number, toSlot?: number): SplitResult;
|
|
73
|
+
export declare function splitStack(state: InventoryState, slot: number, amount: number, toSlot?: number, limit?: number): SplitResult;
|
|
73
74
|
export interface InventorySet<TId extends string> {
|
|
74
75
|
put(id: TId, itemId: string, count: number, options?: {
|
|
75
76
|
slot?: number;
|
|
@@ -80,6 +81,14 @@ export interface InventorySet<TId extends string> {
|
|
|
80
81
|
has(id: TId, itemId: string, count: number): boolean;
|
|
81
82
|
state(id: TId): InventoryState;
|
|
82
83
|
replaceState(id: TId, state: InventoryState): void;
|
|
84
|
+
/** Detached copy of every declared inventory — a save, never a live alias. */
|
|
85
|
+
snapshot(): InventorySetState<TId>;
|
|
86
|
+
/** Replace every declared inventory from a snapshot; returns false without mutation when an entry is malformed or incomplete. */
|
|
87
|
+
restore(next: InventorySetState<TId>): boolean;
|
|
88
|
+
/** Empty every declared inventory. */
|
|
89
|
+
reset(): void;
|
|
83
90
|
}
|
|
91
|
+
/** Every inventory in a set, keyed by id, as plain slot data. */
|
|
92
|
+
export type InventorySetState<TId extends string> = Record<TId, InventoryState>;
|
|
84
93
|
/** Build a keyed set of named inventories from their `layouts`, sharing one `traits` table — the stateful façade over put/take/move/count. */
|
|
85
94
|
export declare function createInventorySet<TId extends string>(layouts: Record<TId, InventoryLayout>, traits: ItemTraits): InventorySet<TId>;
|
|
@@ -175,13 +175,14 @@ export function moveItem(from, fromSlot, to, toLayout, traits, toSlot) {
|
|
|
175
175
|
}
|
|
176
176
|
/**
|
|
177
177
|
* Splits `amount` off the stack in `slot` into `toSlot` (or the first empty slot when `toSlot` is
|
|
178
|
-
* omitted).
|
|
179
|
-
*
|
|
180
|
-
* leave both sides non-empty), no free
|
|
178
|
+
* omitted). Without `limit` it is limit-agnostic: a same-item target merges by simple addition.
|
|
179
|
+
* Pass the item's stack limit to keep the merge legal, which rejects `over-stack`. Rejects an empty
|
|
180
|
+
* source, `amount <= 0`, `amount >= stack count` (a split must leave both sides non-empty), no free
|
|
181
|
+
* slot, or a target holding a different item.
|
|
181
182
|
*
|
|
182
|
-
* @capability inventory-split split a stack into a target or first-empty slot
|
|
183
|
+
* @capability inventory-split split a stack into a target or first-empty slot, honoring a stack limit
|
|
183
184
|
*/
|
|
184
|
-
export function splitStack(state, slot, amount, toSlot) {
|
|
185
|
+
export function splitStack(state, slot, amount, toSlot, limit) {
|
|
185
186
|
if (!validSlot(state, slot))
|
|
186
187
|
return { status: "rejected", reason: "invalid-slot" };
|
|
187
188
|
const source = state.slots[slot];
|
|
@@ -206,6 +207,9 @@ export function splitStack(state, slot, amount, toSlot) {
|
|
|
206
207
|
}
|
|
207
208
|
const slots = state.slots.slice();
|
|
208
209
|
const dest = slots[target];
|
|
210
|
+
if (limit !== undefined && dest !== null && dest !== undefined && dest.count + amount > limit) {
|
|
211
|
+
return { status: "rejected", reason: "over-stack" };
|
|
212
|
+
}
|
|
209
213
|
slots[slot] = { itemId: source.itemId, count: source.count - amount };
|
|
210
214
|
slots[target] =
|
|
211
215
|
dest === null || dest === undefined
|
|
@@ -213,6 +217,26 @@ export function splitStack(state, slot, amount, toSlot) {
|
|
|
213
217
|
: { itemId: source.itemId, count: dest.count + amount };
|
|
214
218
|
return { status: "ok", state: { slots } };
|
|
215
219
|
}
|
|
220
|
+
function cloneSlots(slots) {
|
|
221
|
+
return slots.map((slot) => (slot === null ? null : { itemId: slot.itemId, count: slot.count }));
|
|
222
|
+
}
|
|
223
|
+
function validInventoryState(state, slots) {
|
|
224
|
+
if (state === null || typeof state !== "object" || !Array.isArray(state.slots))
|
|
225
|
+
return false;
|
|
226
|
+
if (state.slots.length !== slots)
|
|
227
|
+
return false;
|
|
228
|
+
for (const slot of state.slots) {
|
|
229
|
+
if (slot === null)
|
|
230
|
+
continue;
|
|
231
|
+
if (typeof slot !== "object")
|
|
232
|
+
return false;
|
|
233
|
+
if (typeof slot.itemId !== "string")
|
|
234
|
+
return false;
|
|
235
|
+
if (!Number.isFinite(slot.count) || slot.count <= 0)
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
216
240
|
/** Build a keyed set of named inventories from their `layouts`, sharing one `traits` table — the stateful façade over put/take/move/count. */
|
|
217
241
|
export function createInventorySet(layouts, traits) {
|
|
218
242
|
const states = new Map();
|
|
@@ -258,5 +282,30 @@ export function createInventorySet(layouts, traits) {
|
|
|
258
282
|
replaceState(id, state) {
|
|
259
283
|
states.set(id, state);
|
|
260
284
|
},
|
|
285
|
+
snapshot() {
|
|
286
|
+
const out = {};
|
|
287
|
+
for (const id of Object.keys(layouts)) {
|
|
288
|
+
out[id] = { slots: cloneSlots(requireState(id).slots) };
|
|
289
|
+
}
|
|
290
|
+
return out;
|
|
291
|
+
},
|
|
292
|
+
restore(next) {
|
|
293
|
+
if (next === null || typeof next !== "object")
|
|
294
|
+
return false;
|
|
295
|
+
const staged = new Map();
|
|
296
|
+
for (const id of Object.keys(layouts)) {
|
|
297
|
+
const state = next[id];
|
|
298
|
+
if (state === undefined || !validInventoryState(state, layouts[id].slots))
|
|
299
|
+
return false;
|
|
300
|
+
staged.set(id, { slots: cloneSlots(state.slots) });
|
|
301
|
+
}
|
|
302
|
+
for (const [id, state] of staged)
|
|
303
|
+
states.set(id, state);
|
|
304
|
+
return true;
|
|
305
|
+
},
|
|
306
|
+
reset() {
|
|
307
|
+
for (const id of Object.keys(layouts))
|
|
308
|
+
states.set(id, createEmptyInventory(layouts[id]));
|
|
309
|
+
},
|
|
261
310
|
};
|
|
262
311
|
}
|
|
@@ -79,6 +79,32 @@ export declare function findShapedPlacement<T>(grid: ShapedGrid<T>, footprint: F
|
|
|
79
79
|
* @capability shaped-grid a serializable inventory board with arbitrary cell footprints, rotation, overlap checks, and immutable placement
|
|
80
80
|
*/
|
|
81
81
|
export declare function placeShaped<T>(grid: ShapedGrid<T>, item: ShapedItem<T>, origin: Cell, rotation?: Rotation): ShapedResult<T>;
|
|
82
|
+
/**
|
|
83
|
+
* Why a first-fit placement failed: the footprint cannot fit an empty board at any offered rotation
|
|
84
|
+
* (`too-large`), the board is genuinely full (`no-space`), or the search hit its budget.
|
|
85
|
+
*/
|
|
86
|
+
export type ShapedFitRejection = "duplicate-id" | "invalid-footprint" | "too-large" | "no-space" | "budget-exceeded";
|
|
87
|
+
/** A placed board, or a refusal that names why the item does not fit. */
|
|
88
|
+
export type ShapedFitResult<T> = {
|
|
89
|
+
status: "ok";
|
|
90
|
+
grid: ShapedGrid<T>;
|
|
91
|
+
origin: Cell;
|
|
92
|
+
rotation: Rotation;
|
|
93
|
+
checks: number;
|
|
94
|
+
} | {
|
|
95
|
+
status: "rejected";
|
|
96
|
+
reason: ShapedFitRejection;
|
|
97
|
+
detail: string;
|
|
98
|
+
checks: number;
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Place a shaped item at the first legal spot, or say why it does not fit. Distinguishes an item that
|
|
102
|
+
* can never fit the board (`too-large`, with its footprint and the board size in `detail`) from a full
|
|
103
|
+
* board (`no-space`) and an exhausted search (`budget-exceeded`, not a full bag).
|
|
104
|
+
*
|
|
105
|
+
* @capability shaped-placement place a shaped item at its first fit and name why it does not fit
|
|
106
|
+
*/
|
|
107
|
+
export declare function fitShapedItem<T>(grid: ShapedGrid<T>, item: ShapedItem<T>, options?: ShapedPlacementSearchOptions): ShapedFitResult<T>;
|
|
82
108
|
/**
|
|
83
109
|
* Remove one placement without mutating the source board.
|
|
84
110
|
* @capability shaped-grid a serializable inventory board with arbitrary cell footprints, rotation, overlap checks, and immutable placement
|