@adatechnology/game-items 0.1.1 → 0.1.3
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/dist/index.global.js.map +1 -1
- package/package.json +4 -4
package/dist/index.global.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../../game-contracts/src/impact.ts","../../game-contracts/src/body.ts","../../game-contracts/src/spec.ts","../../game-contracts/src/weapons.ts","../../game-contracts/src/animation.ts","../../game-contracts/src/tool-animation.ts","../../game-contracts/src/registry.ts","../../game-contracts/src/bench.ts","../src/categories.ts","../src/types.ts","../src/check-items.ts","../src/rules.ts","../src/create-items.ts","../src/decay.ts","../src/protection.ts","../src/carry.ts","../src/use-effect.ts","../src/recipes.ts","../src/finds/depletion.ts","../src/finds/rarity-pool.ts","../src/finds/category-roll.ts","../src/finds/explicit-pool.ts","../src/finds/spawn-state.ts","../src/finds/provider.ts","../../game-interactables/src/colliders.ts","../../game-interactables/src/rng.ts","../../game-interactables/src/loot.ts","../../game-interactables/src/locale.ts","../../game-interactables/src/math.ts","../../game-interactables/src/world.ts","../src/finds/place-finds.ts","../src/finds/loot-tables.ts","../src/arsenal-items.ts"],"sourcesContent":["export { createItems } from './create-items'\nexport { checkItems } from './check-items'\nexport { ITEM_CATEGORIES } from './categories'\nexport { DEFAULT_RULES } from './rules'\nexport { BODY_SLOTS, DURABILITY_KINDS, RARITIES } from './types'\nexport type * from './types'\nexport type * from './catalog-types'\nexport type * from './rules-types'\nexport { advanceAge, chargeLevel, failsToStart, freshness, moveToStorage, shelfRatio, storageMultiplier } from './decay'\nexport type { AgeOptions, AgeState, Freshness } from './decay'\nexport { blockChance, pieceBlock, repairPiece, wearBlocked } from './protection'\nexport type { AttackKind, WornPiece } from './protection'\nexport { bagOption, canTake, carriedKg, loadState, maxLoadKg } from './carry'\nexport type { BagOption, CarryEntry, LoadLevel, LoadState, TakeResult } from './carry'\nexport { resolveUse } from './use-effect'\nexport type { NutritionResult, UseContext, UseResult } from './use-effect'\nexport type * from './use-types'\nexport { canCraft, craft } from './recipes'\nexport type { CraftCheck, CraftContext, CraftOutcome, CraftResult, Consumption } from './recipes'\nexport type * from './recipe-types'\nexport { rollByCategory } from './finds/category-roll'\nexport { depletionFactor, scaleCount } from './finds/depletion'\nexport { rollExplicitPool } from './finds/explicit-pool'\nexport type { PoolPick } from './finds/explicit-pool'\nexport { createFindsProvider } from './finds/provider'\nexport type { FindsProviderOptions } from './finds/provider'\nexport { placeFinds } from './finds/place-finds'\nexport type { PlacedFinds } from './finds/place-finds'\nexport { toLootTables } from './finds/loot-tables'\nexport type { LootTablesQuery } from './finds/loot-tables'\nexport { spawnAgeDays, spawnData } from './finds/spawn-state'\nexport type { FindData, FindsProvider, FindsQuery, FindStack, Rng } from './finds/types'\nexport { ARSENAL_CATEGORIES, itemsFromArsenal } from './arsenal-items'\nexport type { ArsenalCaliberLike, ArsenalItemRecord, ArsenalItemsOptions, ArsenalLike, ArsenalWeaponLike } from './arsenal-items'\n","import type { DamageType, DestructibleSpec, DestructionEvent, Hit, JointSpec, ObjectState, ToolKind, Vec3 } from './destruction'\n\n/**\n * What a game module implements for an object whose behaviour it owns (a vehicle with its own panel deformation, a creature, a machine). The\n * world (`DestructionWorld.register`) hosts it next to its own objects: raycast, shoot, explode, hit, disassemble, extinguish, update, state,\n * reset and `on` all route to it.\n *\n * Space: the impl receives hits in WORLD space and returns events in WORLD space (it owns its transform; the transform given to `register` or\n * `setTransform` is only used by the world for raycasts and blast range). Every event must carry `object === id` and name parts listed by\n * `describe()`.\n */\nexport interface DestructibleImpl {\n readonly id: string\n readonly name?: string\n /** Parts, joints and structure in the impl's local space, for raycast, inspector and log. Called often: keep it cheap. */\n describe(): DestructibleSpec\n /** Apply one hit (see `IMPACT_CONTRACT` for the fields per type) and return the events it caused. */\n hit(hit: Hit): DestructionEvent[]\n /** Advance fire, melting, collapse and other timed behaviour. */\n update?(dt: number): DestructionEvent[]\n state(): ObjectState\n reset?(): void\n /** Same contract as `DestructionWorld.disassemble`: progress 0..1, or -1 when the tool cannot undo the joint. */\n disassemble?(jointId: string, tool: ToolKind, dt: number): number\n extinguish?(partId?: string): void\n /** Optional: an animation moved a part; `position` / `rotation` (Euler XYZ, radians) are object-local. Keep raycast and hit shapes in sync. */\n setPartPose?(partId: string, position: Vec3, rotation?: Vec3): void\n /** Plain JSON of the impl's own state, stored in `DestructionSnapshot.impls`. */\n serialize?(): unknown\n load?(data: unknown): void\n}\n\n/** The fields a weapon or hazard must put in a `Hit`, per damage type. THE impact contract. */\nexport interface ImpactContract {\n required: string[]\n optional: string[]\n note: string\n}\n\nexport const IMPACT_CONTRACT: Record<DamageType, ImpactContract> = {\n ballistic: { required: ['type', 'point', 'direction', 'energy', 'caliber'], optional: ['space', 'source'], note: 'bullets and pellets: joules and diameter in millimetres' },\n blunt: { required: ['type', 'point', 'energy'], optional: ['direction', 'radius', 'space', 'source'], note: 'contact radius in metres (default 3 cm)' },\n slash: { required: ['type', 'point', 'energy'], optional: ['direction', 'radius', 'space', 'source'], note: 'axes, knives, machetes' },\n explosive: { required: ['type', 'point', 'energy', 'radius'], optional: ['space', 'source'], note: 'point is the centre of the blast, radius the range in metres' },\n fire: { required: ['type', 'point', 'temperature', 'radius'], optional: ['duration', 'space', 'source'], note: 'flame temperature in °C; duration in seconds (call every frame with `dt`)' },\n heat: { required: ['type', 'point', 'temperature', 'radius'], optional: ['duration', 'space', 'source'], note: 'heat without flame (cutting torch, hot plate)' },\n}\n\n/** Problems with a hit according to the impact contract (empty when it is valid). */\nexport const checkHit = (hit: Hit): string[] => {\n const c = IMPACT_CONTRACT[hit.type]\n if (!c) return [`unknown damage type \"${String(hit.type)}\"`]\n const out: string[] = []\n for (const f of c.required) {\n const v = (hit as unknown as Record<string, unknown>)[f]\n if (v === undefined || (Array.isArray(v) ? v.some((n) => !Number.isFinite(n)) : typeof v === 'number' && !Number.isFinite(v))) out.push(`${hit.type} hit needs \"${f}\"`)\n }\n return out\n}\n\nexport interface CheckOptions {\n /**\n * Ids of every material the impl may use (the contracts package does not know the materials of your world: pass the ids of\n * `world.materials`, or the built-in plus your own). When omitted, the material check is skipped.\n */\n materials?: Iterable<string>\n}\n\nconst finiteJson = (v: unknown, path: string, out: string[]) => {\n if (typeof v === 'number') {\n if (!Number.isFinite(v)) out.push(`${path} is not a finite number`)\n } else if (Array.isArray(v)) v.forEach((x, i) => finiteJson(x, `${path}[${i}]`, out))\n else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) finiteJson(x, `${path}.${k}`, out)\n else if (typeof v === 'function' || typeof v === 'symbol' || typeof v === 'bigint') out.push(`${path} is not JSON`)\n}\n\n/**\n * Contract checker for the test room (it needs no world): `describe()` is valid, a 500 J ballistic hit at a part centre returns well-formed events of this impl, `state()`\n * matches `describe()`, hit points never go negative, `update(0.016)` returns an array and every event is JSON-serializable. It hits the impl\n * (and calls `reset()` afterwards when there is one).\n */\nexport const checkDestructible = (impl: DestructibleImpl, options: CheckOptions = {}): { ok: boolean; problems: string[] } => {\n const problems: string[] = []\n const known = options.materials ? new Set(options.materials) : null\n let spec: DestructibleSpec | undefined\n try {\n spec = impl.describe()\n } catch (e) {\n return { ok: false, problems: [`describe() threw: ${String(e)}`] }\n }\n if (!spec || !Array.isArray(spec.parts) || spec.parts.length === 0) return { ok: false, problems: ['describe() must return at least one part'] }\n const ids = new Set<string>()\n for (const p of spec.parts) {\n if (ids.has(p.id)) problems.push(`duplicate part id \"${p.id}\"`)\n ids.add(p.id)\n if (known && !known.has(p.material)) problems.push(`part \"${p.id}\" has unknown material \"${p.material}\"`)\n if (!p.shape || !['box', 'cylinder', 'sphere'].includes(p.shape.kind)) problems.push(`part \"${p.id}\" has no valid shape`)\n if (!Array.isArray(p.position) || p.position.length !== 3) problems.push(`part \"${p.id}\" has no position`)\n }\n const jointIds = new Set<string>()\n for (const j of (spec.joints ?? []) as JointSpec[]) {\n if (!ids.has(j.a) || !ids.has(j.b)) problems.push(`joint \"${j.id ?? `${j.a}~${j.b}`}\" references an unknown part`)\n const jid = j.id ?? `${j.a}~${j.b}`\n if (jointIds.has(jid)) problems.push(`duplicate joint id \"${jid}\"`)\n jointIds.add(jid)\n }\n const checkState = (label: string) => {\n let st: ObjectState\n try {\n st = impl.state()\n } catch (e) {\n problems.push(`state() threw ${label}: ${String(e)}`)\n return\n }\n const sids = st.parts.map((p) => p.id).sort().join(',')\n if (sids !== [...ids].sort().join(',')) problems.push(`state() part ids do not match describe() ${label}`)\n for (const p of st.parts) if (!(p.hp >= 0)) problems.push(`part \"${p.id}\" has negative or invalid hp ${label}`)\n }\n const checkEvents = (events: unknown, label: string) => {\n if (!Array.isArray(events)) {\n problems.push(`${label} must return an array`)\n return\n }\n const evs = events as DestructionEvent[]\n evs.forEach((e, i) => {\n if (e.object !== impl.id) problems.push(`${label} event ${i} (${e.type}) has object \"${String(e.object)}\", expected \"${impl.id}\"`)\n const part = (e as { part?: string }).part\n if (part !== undefined && !ids.has(part)) problems.push(`${label} event ${i} (${e.type}) names unknown part \"${part}\"`)\n })\n const bad: string[] = []\n finiteJson(evs, 'events', bad)\n problems.push(...bad.map((b) => `${label}: ${b}`))\n try {\n const s = JSON.stringify(evs)\n if (JSON.stringify(JSON.parse(s)) !== s) problems.push(`${label} events do not survive a JSON round trip`)\n } catch (e) {\n problems.push(`${label} events are not JSON-serializable: ${String(e)}`)\n }\n }\n checkState('before the hit')\n const target = spec.parts[0]\n try {\n const hit: Hit = { type: 'ballistic', point: [target.position[0], target.position[1], target.position[2]], direction: [0, 0, -1], energy: 500, caliber: 9 }\n checkEvents(impl.hit(hit), 'hit()')\n } catch (e) {\n problems.push(`hit() threw: ${String(e)}`)\n }\n checkState('after the hit')\n if (impl.update) {\n try {\n checkEvents(impl.update(0.016), 'update()')\n } catch (e) {\n problems.push(`update() threw: ${String(e)}`)\n }\n checkState('after update')\n }\n try {\n impl.reset?.()\n } catch (e) {\n problems.push(`reset() threw: ${String(e)}`)\n }\n return { ok: problems.length === 0, problems }\n}\n","/** The slots of a body an item can cover (the item slot vocabulary: game-items imports it from here). */\nexport type BodySlot = 'head' | 'eyes' | 'torso' | 'arms' | 'hands' | 'legs' | 'feet' | 'over'\n\n/** The 11 segments of the detailed body model (the Arsenal ids). */\nexport type SegmentKind = 'head' | 'jaw' | 'neck' | 'torso' | 'pelvis' | 'upperArm' | 'forearm' | 'hand' | 'thigh' | 'shin' | 'foot'\n\n/** The 6 regions of the crowd model (the game regions 0..5). */\nexport type BodyRegion = 'head' | 'torso' | 'armL' | 'armR' | 'legL' | 'legR'\n\n/** Bits of the `missing` mask of a body. The instanced body shader reads them, so the values are part of the contract. */\nexport const MISSING_BITS = {\n head: 1,\n armL: 2,\n armR: 4,\n legL: 8,\n legR: 16,\n armLBroken: 32,\n armRBroken: 64,\n legLBroken: 128,\n legRBroken: 256,\n jaw: 512,\n} as const\n","import type { DestructibleSpec, PartSpec } from './destruction'\n\nexport interface CheckSpecOptions {\n /** Known material ids. When given, every part's material must be one of them. */\n materials?: Iterable<string>\n}\n\nexport interface SpecReport {\n /** False when there is at least one problem (warnings do not count). */\n ok: boolean\n problems: string[]\n /** Things that are legal but probably wrong, e.g. structural parts that cannot reach an anchored part. */\n warnings: string[]\n}\n\nconst STRUCTURE_ROLES = ['foundation', 'column', 'beam', 'slab', 'wall', 'partition', 'roof', 'stair', 'none']\nconst BEARS = [true, false, 'a_on_b', 'b_on_a']\n\nconst finite = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n)\nconst positive = (n: unknown): boolean => finite(n) && n > 0\nconst vec3 = (v: unknown): boolean => Array.isArray(v) && v.length === 3 && v.every(finite)\nconst nonNegative = (n: unknown): boolean => n === undefined || (finite(n) && n >= 0)\n\nconst FACES = ['both', '+', '-']\nconst BONDS = ['running', 'stack']\n\n/** Layers, reinforcement, units and breach: known materials, finite positive sizes. */\nconst checkStagedFields = (p: PartSpec | undefined, label: string, materials: Set<string> | null, problems: string[]): void => {\n const known = (m: unknown, what: string): void => {\n if (materials && !materials.has(m as string)) problems.push(`${label} ${what} has unknown material \"${String(m)}\"`)\n }\n ;(p?.layers ?? []).forEach((l, i) => {\n known(l?.material, `layer ${i}`)\n if (!positive(l?.thickness)) problems.push(`${label} layer ${i} thickness must be finite and positive`)\n if (!FACES.includes(l?.faces)) problems.push(`${label} layer ${i} has an invalid faces \"${String(l?.faces)}\"`)\n })\n const r = p?.reinforcement\n if (r) {\n known(r.material, 'reinforcement')\n if (!positive(r.spacing) || !positive(r.diameter)) problems.push(`${label} reinforcement spacing and diameter must be finite and positive`)\n if (r.residualCapacity !== undefined && !(finite(r.residualCapacity) && r.residualCapacity >= 0 && r.residualCapacity <= 1)) problems.push(`${label} reinforcement residualCapacity must be between 0 and 1`)\n }\n const u = p?.units\n if (u) {\n known(u.mortar, 'units mortar')\n if (!vec3(u.size) || !u.size.every(positive)) problems.push(`${label} units size must be three finite positive numbers`)\n if (!BONDS.includes(u.bond)) problems.push(`${label} units has an invalid bond \"${String(u.bond)}\"`)\n }\n if (p?.breach && p.breach.area !== undefined && !positive(p.breach.area)) problems.push(`${label} breach area must be finite and positive`)\n}\n\n/**\n * Static checks on a `DestructibleSpec` (no world needed), for items that give a `spec` instead of an `impl`: unique part ids, known materials\n * (when `options.materials` is given), joints that reference existing, different parts, finite positive sizes, non-negative mass / hp / capacity,\n * valid structure roles and `bears` directions. A structural part with no path of joints to an anchored part is a warning, not a problem: it\n * is allowed, it just falls.\n */\nexport const checkSpec = (spec: DestructibleSpec, options: CheckSpecOptions = {}): SpecReport => {\n const problems: string[] = []\n const warnings: string[] = []\n const materials = options.materials ? new Set(options.materials) : null\n const parts = Array.isArray(spec?.parts) ? spec.parts : []\n if (!Array.isArray(spec?.parts) || parts.length === 0) problems.push('the spec needs at least one part')\n const ids = new Set<string>()\n for (const p of parts) {\n const id = String(p?.id ?? '')\n const label = `part \"${id}\"`\n if (id === '') problems.push('a part has no id')\n else if (ids.has(id)) problems.push(`duplicate part id \"${id}\"`)\n ids.add(id)\n if (materials && !materials.has(p?.material)) problems.push(`${label} has unknown material \"${String(p?.material)}\"`)\n else if (typeof p?.material !== 'string' || p.material === '') problems.push(`${label} needs a material`)\n const s = p?.shape\n if (s?.kind === 'box') {\n if (!Array.isArray(s.size) || s.size.length !== 3 || !s.size.every(positive)) problems.push(`${label} box size must be three finite positive numbers`)\n } else if (s?.kind === 'cylinder') {\n if (!positive(s.radius) || !positive(s.height)) problems.push(`${label} cylinder radius and height must be finite and positive`)\n } else if (s?.kind === 'sphere') {\n if (!positive(s.radius)) problems.push(`${label} sphere radius must be finite and positive`)\n } else problems.push(`${label} has an unknown shape`)\n if (!vec3(p?.position)) problems.push(`${label} needs a finite position [x, y, z]`)\n if (p?.rotation !== undefined && !vec3(p.rotation)) problems.push(`${label} has an invalid rotation`)\n for (const f of ['mass', 'hp', 'thickness'] as const) if (!nonNegative(p?.[f])) problems.push(`${label} ${f} must be a finite number >= 0`)\n if (p?.fill !== undefined && !(finite(p.fill) && p.fill >= 0 && p.fill <= 1)) problems.push(`${label} fill must be between 0 and 1`)\n if (p?.structure) {\n if (!STRUCTURE_ROLES.includes(p.structure.role)) problems.push(`${label} has an invalid structure role \"${String(p.structure.role)}\"`)\n if (!nonNegative(p.structure.capacity)) problems.push(`${label} structure capacity must be a finite number >= 0`)\n }\n if (p?.contains && !nonNegative(p.contains.liters)) problems.push(`${label} contains.liters must be a finite number >= 0`)\n checkStagedFields(p, label, materials, problems)\n }\n\n const jointIds = new Set<string>()\n const edges: Array<[string, string]> = []\n for (const j of spec?.joints ?? []) {\n const id = j.id ?? `${j.a}~${j.b}`\n const label = `joint \"${id}\"`\n if (jointIds.has(id)) problems.push(`duplicate joint id \"${id}\"`)\n jointIds.add(id)\n if (!ids.has(j.a)) problems.push(`${label} references unknown part \"${String(j.a)}\"`)\n if (!ids.has(j.b)) problems.push(`${label} references unknown part \"${String(j.b)}\"`)\n if (j.a === j.b) problems.push(`${label} joins part \"${String(j.a)}\" to itself`)\n if (j.bears !== undefined && !BEARS.includes(j.bears)) problems.push(`${label} has an invalid bears \"${String(j.bears)}\"`)\n if (!nonNegative(j.strength)) problems.push(`${label} strength must be a finite number >= 0`)\n if (!nonNegative(j.seconds)) problems.push(`${label} seconds must be a finite number >= 0`)\n if (j.bears !== false && ids.has(j.a) && ids.has(j.b) && j.a !== j.b) edges.push([j.a, j.b])\n }\n\n // structural parts must reach an anchored part over the joints (only when the spec lists its joints: otherwise they are inferred later)\n const structural = parts.filter((p) => p?.structure && p.structure.role !== 'none')\n if (structural.length && spec.joints) {\n const anchored = parts.filter((p) => p.anchored || p.structure?.role === 'foundation').map((p) => p.id)\n const adj = new Map<string, string[]>()\n for (const [a, b] of edges) {\n adj.set(a, [...(adj.get(a) ?? []), b])\n adj.set(b, [...(adj.get(b) ?? []), a])\n }\n const seen = new Set(anchored)\n const queue = [...anchored]\n while (queue.length) {\n for (const n of adj.get(queue.pop()!) ?? []) {\n if (seen.has(n)) continue\n seen.add(n)\n queue.push(n)\n }\n }\n const lost = structural.filter((p) => !seen.has(p.id)).map((p) => p.id)\n if (lost.length) warnings.push(`structural part(s) ${lost.map((i) => `\"${i}\"`).join(', ')} cannot reach an anchored part, so they will fall`)\n }\n return { ok: problems.length === 0, problems, warnings }\n}\n","import type { Hit, Vec3 } from './destruction'\n\n/**\n * A ballistic shot (one per pellet) as a weapon module produces it. Plain data: any damage model can consume it (`shotToHit` makes the `Hit`).\n */\nexport interface Shot {\n origin: Vec3\n /** Unit vector. */\n direction: Vec3\n /** Muzzle energy in joules. */\n energy: number\n /** Bullet diameter in millimetres. */\n caliber: number\n /** snake_case calibre id, e.g. `9x19`. */\n caliberId: string\n /** Muzzle speed in m/s. */\n speed: number\n /** Projectile mass in grams (one pellet for a shotgun load). */\n mass?: number\n /** Energy at a distance in metres, with the calibre's drag. */\n energyAt(distance: number): number\n pellet: number\n /** snake_case weapon id. */\n weapon: string\n source?: string\n}\n\n/** One use of a non-firearm (melee swing, flame tick, heat tick, throw, tool tick, extinguisher tick). */\nexport type Use =\n | { kind: 'melee'; damage: 'blunt' | 'slash'; energy: number; radius: number; reach: number; origin: Vec3; direction: Vec3 }\n | { kind: 'flame'; temperature: number; radius: number; range: number; cone: boolean; duration: number; origin: Vec3; direction: Vec3 }\n | { kind: 'heat'; temperature: number; radius: number; range: number; duration: number; origin: Vec3; direction: Vec3 }\n | {\n kind: 'throw'\n energy: number\n radius: number\n fuse: number\n velocity: Vec3\n origin: Vec3\n fire?: { temperature: number; radius: number; seconds: number }\n /** Explosive id (`m67`, `molotov`, `pg7v`...): the host looks the numbers up in the arsenal. Absent on a plain thrown object. */\n explosive?: string\n /** What sets it off: `timer` after `fuse` seconds, `impact` on the first hit once armed. */\n trigger?: 'timer' | 'impact'\n /** Metres the projectile must fly before an impact sets it off; closer, it hits without exploding (rocket launchers). */\n armDistance?: number\n /** Radius in metres of the wounding area (`radius` is the lethal one). */\n injuryRadius?: number\n /** Seconds the blast stuns whoever is inside the injury radius (flashbang). */\n stunSeconds?: number\n /** Seconds the thrower held it after pulling the pin: `fuse` is what was left of the full fuse when it left the hand. */\n cooked?: number\n /** Went off in the thrower's hand (held past the fuse): no velocity, `fuse` is 0 and the blast is at `origin`. */\n inHand?: boolean\n }\n /** An explosive set down where the trigger is pulled (mine, C4, charge): it waits for its trigger instead of flying. */\n | {\n kind: 'place'\n explosive: string\n /** `wire`: something trips it; `remote`: the player fires it; `timer`: after `fuse` seconds. */\n triggers: readonly ('timer' | 'wire' | 'remote')[]\n energy: number\n /** Lethal radius in metres. */\n radius: number\n injuryRadius: number\n /** Cone the blast goes through, in front of `direction` (mine); absent for a blast in every direction. */\n cone?: { halfAngleDeg: number; length: number }\n /** The wire a `wire` charge stretches from where it is set down: metres out, degrees from its facing (counter-clockwise from above) and metres above it. */\n tripwire?: { length: number; angleDeg: number; height: number }\n fuse?: number\n origin: Vec3\n direction: Vec3\n }\n /** The blast behind a rocket launcher: dangerous inside `radius` metres, in the cone around `direction` (pointing away from the target). */\n | { kind: 'backblast'; radius: number; halfAngleDeg: number; origin: Vec3; direction: Vec3 }\n | { kind: 'tool'; tool: string; reach: number; duration: number; origin: Vec3; direction: Vec3 }\n | { kind: 'extinguish'; range: number; coolC: number; duration: number; origin: Vec3; direction: Vec3 }\n | { kind: 'hand'; reach: number; origin: Vec3; direction: Vec3 }\n\n/**\n * The `Hit` a ballistic shot makes at `point` (world space): the energy is what is left of the shot at that distance from its origin\n * (`shot.energyAt`), the direction and calibre come from the shot. `normal` (the surface normal at the point, when the caller knows it) does not\n * change the hit; it is accepted so that callers can pass what their raycast returned and so that a stricter implementation could use it.\n */\nexport const shotToHit = (shot: Shot, point: Vec3, normal?: Vec3): Hit => {\n void normal\n const dx = point[0] - shot.origin[0]\n const dy = point[1] - shot.origin[1]\n const dz = point[2] - shot.origin[2]\n const distance = Math.hypot(dx, dy, dz)\n return {\n type: 'ballistic',\n point: [point[0], point[1], point[2]],\n direction: [shot.direction[0], shot.direction[1], shot.direction[2]],\n energy: shot.energyAt(distance),\n caliber: shot.caliber,\n ...(shot.mass === undefined ? {} : { mass: shot.mass }),\n source: shot.source ?? shot.weapon,\n }\n}\n","import type { Vec3 } from './destruction'\n\n/** Pose of one part (or of the whole item) at a key: position offset in metres and Euler XYZ rotation in radians, both relative to the rest pose. */\nexport interface AnimationPose {\n position?: Vec3\n rotation?: Vec3\n}\n\nexport interface AnimationKey {\n /** Seconds from the start of the clip. */\n t: number\n /** Pose of the item's root. */\n position?: Vec3\n rotation?: Vec3\n /** Poses of named parts: the key is a destruction part id (a pistol `reload` moves `mag`; a car door `open` rotates `door_l`). */\n parts?: Record<string, AnimationPose>\n}\n\nexport interface AnimationEventSpec {\n /** Seconds from the start of the clip. */\n t: number\n /** snake_case type the host maps (`sound`, `eject_casing`, `door_latch`...). */\n type: string\n data?: Record<string, unknown>\n}\n\n/** A use animation a module brings with its item. Poses between keys are interpolated by the player (the test room does it linearly). */\nexport interface AnimationClipSpec {\n /** snake_case: `reload`, `open`, `drink`... Unique within the item. */\n id: string\n /** Seconds. */\n duration: number\n keys: AnimationKey[]\n events?: AnimationEventSpec[]\n}\n\n/** What an item exposes when it brings its own animations. */\nexport interface ItemAnimationContract {\n animations: AnimationClipSpec[]\n}\n\nconst finite3 = (v: unknown): boolean => Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === 'number' && Number.isFinite(n))\n\n/**\n * Problems with a clip (empty when it is valid): a snake_case id, a positive finite duration, at least one key, key and event times inside\n * `[0, duration]` and not going backwards, finite vectors, every named part one of `partIds`, event types that are non-empty strings and\n * JSON-serializable event data.\n */\nexport const checkAnimation = (clip: AnimationClipSpec, partIds: Iterable<string>): { ok: boolean; problems: string[] } => {\n const problems: string[] = []\n const parts = new Set(partIds)\n const id = String(clip?.id ?? '')\n if (!/^[a-z][a-z0-9_]*$/.test(id)) problems.push(`clip id \"${id}\" must be snake_case`)\n const label = `clip \"${id}\"`\n if (!(typeof clip?.duration === 'number' && Number.isFinite(clip.duration) && clip.duration > 0)) problems.push(`${label} needs a positive finite duration`)\n const dur = typeof clip?.duration === 'number' && clip.duration > 0 ? clip.duration : Infinity\n if (!Array.isArray(clip?.keys) || clip.keys.length === 0) {\n problems.push(`${label} needs at least one key`)\n return { ok: false, problems }\n }\n let prev = -Infinity\n clip.keys.forEach((k, i) => {\n if (!(typeof k.t === 'number' && Number.isFinite(k.t)) || k.t < 0 || k.t > dur + 1e-9) problems.push(`${label} key ${i} has t=${String(k.t)} outside 0..${dur}`)\n else if (k.t < prev) problems.push(`${label} key ${i} goes back in time (${k.t} < ${prev})`)\n else prev = k.t\n for (const f of ['position', 'rotation'] as const) if (k[f] !== undefined && !finite3(k[f])) problems.push(`${label} key ${i} has an invalid ${f}`)\n for (const [pid, pose] of Object.entries(k.parts ?? {})) {\n if (!parts.has(pid)) problems.push(`${label} key ${i} moves unknown part \"${pid}\"`)\n for (const f of ['position', 'rotation'] as const) if (pose?.[f] !== undefined && !finite3(pose[f])) problems.push(`${label} key ${i} part \"${pid}\" has an invalid ${f}`)\n }\n })\n let prevEv = -Infinity\n ;(clip.events ?? []).forEach((e, i) => {\n if (!(typeof e.t === 'number' && Number.isFinite(e.t)) || e.t < 0 || e.t > dur + 1e-9) problems.push(`${label} event ${i} has t=${String(e.t)} outside 0..${dur}`)\n else if (e.t < prevEv) problems.push(`${label} event ${i} goes back in time`)\n else prevEv = e.t\n if (typeof e.type !== 'string' || e.type === '') problems.push(`${label} event ${i} needs a type`)\n try {\n JSON.stringify(e.data ?? {})\n } catch {\n problems.push(`${label} event ${i} data is not JSON`)\n }\n })\n return { ok: problems.length === 0, problems }\n}\n","import type { AnimationClipSpec, AnimationEventSpec, AnimationPose } from './animation'\nimport { checkAnimation } from './animation'\nimport type { HostEvent } from './module'\n\n/** Clips a tool can bring, one per move of its cycle. A tool brings the ones it has; a missing clip is simply not played. */\nexport const TOOL_CLIP = {\n DRAW: 'draw',\n SHOT: 'shot',\n MISFIRE: 'misfire',\n EMPTY: 'empty',\n CYCLE: 'cycle',\n RELOAD: 'reload',\n FEED: 'feed',\n FAULT: 'fault',\n CLEAR: 'clear',\n SAFETY_ON: 'safety_on',\n SAFETY_OFF: 'safety_off',\n MODE: 'mode',\n /** A throwable with the pin out, held until it is thrown. */\n COOK: 'cook',\n /** The stock folds, unfolds, slides in or out (variants `fold`, `unfold`, `collapse`, `extend`). */\n STOCK: 'stock',\n /** A light or a laser is switched (variants `on`, `off`). */\n TOGGLE: 'toggle',\n} as const\nexport type ToolClipId = (typeof TOOL_CLIP)[keyof typeof TOOL_CLIP]\n\n/** What a renderer does when a rule event arrives. `variant` picks `<clip>_<variant>` when the tool has it, else `<clip>`. */\nexport type ToolAnimationStep =\n | { readonly type: 'play'; readonly clip: ToolClipId; readonly variant?: string; readonly duration?: number; readonly repeat?: number }\n /** Move the clip that is playing to the marker (a clip event `stage` with `data.stage`) the rule just reached. */\n | { readonly type: 'sync'; readonly stage: string }\n /** The rule says the phase is over: the clip jumps to its end if it is still running. */\n | { readonly type: 'finish'; readonly clip: ToolClipId }\n\nconst stockVariant = (event: Extract<HostEvent, { type: 'stock' }>): string => {\n const isFolding = event.kind === undefined ? false : event.kind.includes('fold')\n if (event.state === 'collapsed') return isFolding ? 'fold' : 'collapse'\n return isFolding ? 'unfold' : 'extend'\n}\n\n/** The move a host event asks of the tool in hand, or null when it has no motion. */\nexport const toolAnimationStep = (event: HostEvent): ToolAnimationStep | null => {\n switch (event.type) {\n case 'tool':\n return { type: 'play', clip: TOOL_CLIP.DRAW, ...(event.duration === undefined ? {} : { duration: event.duration }) }\n case 'shot':\n return { type: 'play', clip: TOOL_CLIP.SHOT, repeat: Math.max(1, event.rounds ?? 1) }\n case 'misfire':\n return { type: 'play', clip: TOOL_CLIP.MISFIRE }\n case 'empty':\n return { type: 'play', clip: TOOL_CLIP.EMPTY }\n case 'mode':\n return { type: 'play', clip: TOOL_CLIP.MODE, variant: event.mode }\n case 'fault':\n return { type: 'play', clip: TOOL_CLIP.FAULT, variant: event.fault }\n case 'clear':\n if (event.phase === 'end') return { type: 'finish', clip: TOOL_CLIP.CLEAR }\n return { type: 'play', clip: TOOL_CLIP.CLEAR, variant: event.fault, ...(event.duration === undefined ? {} : { duration: event.duration }) }\n case 'safety':\n return { type: 'play', clip: event.on ? TOOL_CLIP.SAFETY_ON : TOOL_CLIP.SAFETY_OFF }\n case 'cycle':\n if (event.phase === 'end') return { type: 'finish', clip: TOOL_CLIP.CYCLE }\n return { type: 'play', clip: TOOL_CLIP.CYCLE, ...(event.duration === undefined ? {} : { duration: event.duration }) }\n case 'reload':\n if (event.phase === 'end') return { type: 'finish', clip: TOOL_CLIP.RELOAD }\n return { type: 'play', clip: TOOL_CLIP.RELOAD, ...(event.kind === undefined ? {} : { variant: event.kind }), ...(event.duration === undefined ? {} : { duration: event.duration }) }\n case 'stage':\n return { type: 'sync', stage: event.stage }\n case 'feed':\n return { type: 'play', clip: TOOL_CLIP.FEED }\n case 'cook':\n if (event.phase === 'start') return { type: 'play', clip: TOOL_CLIP.COOK, duration: event.fuse }\n return { type: 'finish', clip: TOOL_CLIP.COOK }\n case 'stock':\n return { type: 'play', clip: TOOL_CLIP.STOCK, variant: stockVariant(event), ...(event.duration === undefined ? {} : { duration: event.duration }) }\n case 'toggle':\n return { type: 'play', clip: TOOL_CLIP.TOGGLE, variant: event.on ? 'on' : 'off' }\n default:\n return null\n }\n}\n\n/** Where a renderer puts the poses: every part of the tool by id (the ids the clips move), and the whole tool. Poses are offsets from rest. */\nexport interface ToolRig {\n setPartPose(partId: string, pose: AnimationPose): void\n setRootPose?(pose: AnimationPose): void\n}\n\n/** Clips by id, with the variants as `<clip>_<variant>`. */\nexport type ToolClipSet = Readonly<Record<string, AnimationClipSpec>>\n\n/** A renderer's tool animator: feed it the host events and the frame time. */\nexport interface ToolAnimator {\n handle(event: HostEvent): void\n update(dt: number): void\n /** Ids of the clips playing now. */\n readonly playing: readonly string[]\n dispose(): void\n}\n\nexport const STAGE_EVENT = 'stage'\n\n/** The clip with its times (keys and events) scaled so that it lasts `duration` seconds. */\nexport const scaleClip = (clip: AnimationClipSpec, duration: number): AnimationClipSpec => {\n const factor = clip.duration > 0 ? duration / clip.duration : 1\n return {\n ...clip,\n duration,\n keys: clip.keys.map((key) => ({ ...key, t: key.t * factor })),\n ...(clip.events ? { events: clip.events.map((event) => ({ ...event, t: event.t * factor })) } : {}),\n }\n}\n\nconst ZERO: [number, number, number] = [0, 0, 0]\nconst mix = (from: readonly number[], to: readonly number[], fraction: number): [number, number, number] => [\n (from[0] ?? 0) + ((to[0] ?? 0) - (from[0] ?? 0)) * fraction,\n (from[1] ?? 0) + ((to[1] ?? 0) - (from[1] ?? 0)) * fraction,\n (from[2] ?? 0) + ((to[2] ?? 0) - (from[2] ?? 0)) * fraction,\n]\n\n/** Pose of the whole tool (`partId` undefined) or of one part at `time`, linear between keys; a key that does not name it means rest. */\nexport const sampleClip = (clip: AnimationClipSpec, partId: string | undefined, time: number): { position: [number, number, number]; rotation: [number, number, number] } => {\n const points = clip.keys.map((key) => {\n const pose = partId === undefined ? key : key.parts?.[partId]\n return { t: key.t, position: pose?.position ?? ZERO, rotation: pose?.rotation ?? ZERO }\n })\n const first = points[0]\n if (!first || first.t > 0) points.unshift({ t: 0, position: ZERO, rotation: ZERO })\n const last = points[points.length - 1]\n if (!last) return { position: ZERO, rotation: ZERO }\n if (time >= last.t) return { position: mix(last.position, last.position, 0), rotation: mix(last.rotation, last.rotation, 0) }\n const index = Math.max(0, points.findIndex((point, i) => time >= point.t && time < (points[i + 1]?.t ?? Infinity)))\n const from = points[index]\n const to = points[index + 1]\n if (!from || !to) return { position: ZERO, rotation: ZERO }\n const fraction = to.t > from.t ? (time - from.t) / (to.t - from.t) : 0\n return { position: mix(from.position, to.position, fraction), rotation: mix(from.rotation, to.rotation, fraction) }\n}\n\n/** Problems with a clip set (empty when valid): each key is the clip id (or `<clip>_<variant>`), every clip passes `checkAnimation`, and a `stage` event names its stage. */\nexport const checkToolClips = (clips: ToolClipSet, partIds: Iterable<string>): { ok: boolean; problems: string[] } => {\n const parts = [...partIds]\n const problems: string[] = []\n for (const [key, clip] of Object.entries(clips)) {\n if (clip.id !== key) problems.push(`clip \"${key}\" has the id \"${clip.id}\"`)\n if (!(clip.duration > 0)) problems.push(`clip \"${key}\" has no duration`)\n problems.push(...checkAnimation(clip, parts).problems)\n for (const event of clip.events ?? []) if (event.type === STAGE_EVENT && typeof event.data?.stage !== 'string') problems.push(`clip \"${key}\" has a stage event without data.stage`)\n }\n return { ok: problems.length === 0, problems }\n}\n\ninterface Instance {\n readonly name: string\n readonly clip: AnimationClipSpec\n time: number\n delay: number\n fired: number\n}\n\nexport interface ToolClipPlayerOptions {\n clips: ToolClipSet\n rig: ToolRig\n /** Called for each clip event as its time passes (`eject_casing`, `drop_magazine`, `stage`...). */\n onEvent?(event: AnimationEventSpec, clip: string): void\n /** Seconds between the rounds of a `repeat` (a burst): default 0.1. */\n repeatInterval?(): number\n}\n\n/** The default player of a tool's clips: runs the steps of `toolAnimationStep` against a rig, scaled to the duration the rule gives. */\nexport interface ToolClipPlayer {\n apply(step: ToolAnimationStep): void\n update(dt: number): void\n readonly playing: readonly string[]\n}\n\nexport const createToolClipPlayer = (options: ToolClipPlayerOptions): ToolClipPlayer => {\n const { clips, rig, onEvent } = options\n let instances: Instance[] = []\n\n const find = (clip: string, variant?: string): { name: string; clip: AnimationClipSpec } | undefined => {\n const name = variant !== undefined && clips[`${clip}_${variant}`] ? `${clip}_${variant}` : clip\n const found = clips[name]\n return found ? { name, clip: found } : undefined\n }\n\n const render = (instance: Instance): void => {\n const time = Math.min(instance.time, instance.clip.duration)\n const root = sampleClip(instance.clip, undefined, time)\n if (instance.clip.keys.some((key) => key.position || key.rotation)) rig.setRootPose?.(root)\n const touched = new Set(instance.clip.keys.flatMap((key) => Object.keys(key.parts ?? {})))\n for (const id of touched) rig.setPartPose(id, sampleClip(instance.clip, id, time))\n }\n\n const fireEvents = (instance: Instance, upTo: number): void => {\n const events = instance.clip.events ?? []\n while (instance.fired < events.length && (events[instance.fired]?.t ?? Infinity) <= upTo + 1e-9) {\n const event = events[instance.fired]\n instance.fired += 1\n if (event) onEvent?.(event, instance.name)\n }\n }\n\n return {\n apply(step) {\n if (step.type === 'sync') {\n for (const instance of instances) {\n const marker = (instance.clip.events ?? []).find((event) => event.type === STAGE_EVENT && event.data?.stage === step.stage)\n if (marker && instance.time < marker.t) instance.time = marker.t\n }\n return\n }\n if (step.type === 'finish') {\n for (const instance of instances) {\n if (instance.name !== step.clip && !instance.name.startsWith(`${step.clip}_`)) continue\n fireEvents(instance, instance.clip.duration)\n instance.time = instance.clip.duration\n }\n return\n }\n const found = find(step.clip, step.variant)\n if (!found) return\n const clip = step.duration && step.duration > 0 ? scaleClip(found.clip, step.duration) : found.clip\n const interval = options.repeatInterval?.() ?? 0.1\n instances = instances.filter((existing) => existing.name !== found.name || existing.delay > 0)\n for (let round = 0; round < (step.repeat ?? 1); round++) {\n instances.push({ name: found.name, clip, time: 0, delay: round * interval, fired: 0 })\n }\n },\n update(dt) {\n for (const instance of instances) {\n const carried = instance.delay > 0 ? dt - instance.delay : dt\n if (instance.delay > 0) {\n instance.delay = Math.max(0, instance.delay - dt)\n if (instance.delay > 0) continue\n }\n instance.time += carried\n fireEvents(instance, instance.time)\n render(instance)\n }\n instances = instances.filter((instance) => instance.delay > 0 || instance.time < instance.clip.duration)\n },\n get playing() {\n return instances.map((instance) => instance.name)\n },\n }\n}\n","type PlainObject = Record<string, unknown>\ntype Entry = { readonly id: string }\n\n/** Keys that would reach the prototype of the result when a patch comes from parsed JSON. */\nconst UNSAFE_KEYS: ReadonlySet<string> = new Set(['__proto__', 'constructor', 'prototype'])\n\nconst isPlainObject = (value: unknown): value is PlainObject =>\n typeof value === 'object' && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype\n\n/** Recursively optional; arrays and functions are replaced as a whole, objects are merged. */\nexport type DeepPartial<TValue> = TValue extends (...args: never[]) => unknown\n ? TValue\n : TValue extends readonly unknown[]\n ? TValue\n : TValue extends object\n ? { [TKey in keyof TValue]?: DeepPartial<TValue[TKey]> }\n : TValue\n\n/** Merges `patch` over `base`: plain objects merge field by field, arrays, functions and primitives replace. `undefined` in the patch is ignored. */\nexport const deepMerge = <TBase extends object>(base: TBase, patch: object | undefined): TBase => {\n if (!patch) return base\n const result: PlainObject = { ...(base as PlainObject) }\n for (const [key, value] of Object.entries(patch)) {\n if (value === undefined || UNSAFE_KEYS.has(key)) continue\n const current = result[key]\n result[key] = isPlainObject(current) && isPlainObject(value) ? deepMerge(current, value) : value\n }\n return result as TBase\n}\n\n/** The shipped entries with the ones from `extra` added or, when the id exists, replaced. */\nexport const mergeById = <TEntry extends Entry>(shipped: readonly TEntry[], extra: readonly TEntry[] | undefined): Map<string, TEntry> => {\n const entries = new Map<string, TEntry>()\n for (const entry of [...shipped, ...(extra ?? [])]) entries.set(entry.id, entry)\n return entries\n}\n\n/** Applies `patch` by id; a patch for an id that does not exist is ignored. */\nexport const applyPatches = <TEntry extends Entry>(entries: Map<string, TEntry>, patches: Readonly<Record<string, DeepPartial<TEntry> | Partial<TEntry>>> | undefined): void => {\n for (const [id, patch] of Object.entries(patches ?? {})) {\n const entry = entries.get(id)\n if (entry) entries.set(id, deepMerge(entry, patch))\n }\n}\n\nexport const removeIds = (entries: Map<string, unknown>, ids: readonly string[] | undefined): void => {\n for (const id of ids ?? []) entries.delete(id)\n}\n","import type { Vec3 } from './destruction'\n\n/**\n * The bench of a module: controls, layers and counters it declares as data. The host keeps the values, draws them in a generic way and hands\n * every change back through `ModuleHost.bench`. Nothing here is specific to a kind of module.\n */\nexport type ControlValue = number | boolean | string\n\nexport interface ChoiceOption {\n readonly id: string\n readonly name: string\n /** Heading the host lists the option under (`optgroup`, a grouped row of buttons). Options of a group must sit next to each other. */\n readonly group?: string\n}\n\ninterface ControlBase {\n /** snake_case, unique across the module's controls, layers and metrics. */\n readonly id: string\n /** Display name, already translated for the locale the host asked for. */\n readonly name: string\n readonly hint?: string\n readonly hidden?: boolean\n}\n\nexport interface NumberControl extends ControlBase {\n readonly kind: 'number'\n readonly min: number\n readonly max: number\n readonly step?: number\n readonly unit?: string\n readonly default: number\n}\n\nexport interface BooleanControl extends ControlBase {\n readonly kind: 'boolean'\n readonly default: boolean\n}\n\nexport interface ChoiceControl extends ControlBase {\n readonly kind: 'choice'\n readonly options: readonly ChoiceOption[]\n readonly default: string\n}\n\n/** A button: no value, only the press. */\nexport interface ActionControl extends ControlBase {\n readonly kind: 'action'\n}\n\nexport type ModuleControl = NumberControl | BooleanControl | ChoiceControl | ActionControl\n\n/** Something of the module the viewer turns on or off (smoke, flash, casings). */\nexport interface ModuleLayer {\n readonly id: string\n readonly name: string\n /** On unless `false`. */\n readonly default?: boolean\n}\n\n/** Pulled: the room calls read only when it samples (~4 Hz, or on TestRoom.metrics()). */\nexport interface ModuleMetric {\n readonly id: string\n readonly name: string\n readonly unit?: string\n /** Budget: the host shows a bar and warns past it. */\n readonly max?: number\n readonly digits?: number\n read(): number | string\n}\n\nexport type BenchChange =\n | { readonly kind: 'control'; readonly id: string; readonly value: ControlValue }\n | { readonly kind: 'layer'; readonly id: string; readonly on: boolean }\n | { readonly kind: 'action'; readonly id: string }\n\nexport interface ControlPatch {\n readonly hidden?: boolean\n readonly disabled?: boolean\n readonly options?: readonly ChoiceOption[]\n readonly value?: ControlValue\n}\n\n/** What the host lends a module that declares a bench: the current values and the changes. */\nexport interface ModuleBench {\n value(id: string): ControlValue | undefined\n layer(id: string): boolean\n /** Right after attach returns, the host sends one change per control (not actions) and per layer, in declaration order. */\n onChange(listener: (change: BenchChange) => void): () => void\n /** `value` sets without emitting (no echo loops); `options` falls back to the first option if the value is no longer offered. */\n patch(id: string, patch: ControlPatch): void\n /** Move the host camera (a near / middle / far choice). */\n look(eye: Vec3, target: Vec3): void\n /** Shows a sheet on the host's panel, or takes it down with `null`. A host without a sheet area ignores it. */\n sheet?(sheet: BenchSheet | null): void\n}\n\n/**\n * A card of text the module puts on the host's panel (the sheet of an effect, of a weapon): what it is, its numbers, its steps, its colours and,\n * optionally, a strip of segments over time. Names and texts are already translated for the host's locale.\n */\nexport interface BenchSheet {\n readonly kicker?: string\n readonly title: string\n readonly body?: string\n readonly facts?: readonly { readonly label: string; readonly value: string }[]\n readonly steps?: readonly { readonly at: string; readonly text: string }[]\n readonly swatches?: readonly { readonly color: string; readonly name: string }[]\n readonly note?: string\n /** A strip of named segments (seconds), with tick marks and a playhead that follows the module metric `metric`. */\n readonly timeline?: {\n readonly segments: readonly { readonly name: string; readonly start: number; readonly end: number; readonly color: string }[]\n readonly ticks: readonly number[]\n readonly metric: string\n }\n}\n\nexport interface BenchReport {\n readonly ok: boolean\n readonly problems: readonly string[]\n}\n\nexport interface BenchDeclaration {\n readonly controls?: readonly ModuleControl[]\n readonly layers?: readonly ModuleLayer[]\n readonly metrics?: readonly ModuleMetric[]\n}\n\nconst SNAKE_CASE = /^[a-z][a-z0-9_]*$/\nconst finite = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n)\n\nfunction checkControl(control: ModuleControl, problems: string[]): void {\n const label = `control \"${control.id}\"`\n if (control.kind === 'number') {\n if (!finite(control.min) || !finite(control.max) || control.min >= control.max) problems.push(`${label} needs min < max`)\n if (control.step !== undefined && !(finite(control.step) && control.step > 0)) problems.push(`${label} needs a step > 0`)\n if (!finite(control.default) || control.default < control.min || control.default > control.max) problems.push(`${label} default ${String(control.default)} is outside ${control.min}..${control.max}`)\n return\n }\n if (control.kind !== 'choice') return\n if (control.options.length === 0) {\n problems.push(`${label} needs at least one option`)\n return\n }\n const seen = new Set<string>()\n const groups = new Set<string>()\n let previousGroup: string | undefined\n for (const option of control.options) {\n if (seen.has(option.id)) problems.push(`${label} repeats option \"${option.id}\"`)\n seen.add(option.id)\n if (option.group !== undefined && (typeof option.group !== 'string' || option.group === '')) problems.push(`${label} option \"${option.id}\" has an empty group`)\n else if (option.group !== undefined && option.group !== previousGroup && groups.has(option.group)) problems.push(`${label} group \"${option.group}\" is split: its options must be next to each other`)\n if (typeof option.group === 'string' && option.group !== '') groups.add(option.group)\n previousGroup = option.group\n }\n if (!seen.has(control.default)) problems.push(`${label} default \"${control.default}\" is not among its options`)\n}\n\n/** snake_case ids unique across controls, layers and metrics; min < max; step > 0; default in range; options non-empty, unique, default among them, each group in one run; metric max > 0. */\nexport function checkBench(declared: BenchDeclaration): BenchReport {\n const problems: string[] = []\n const ids = new Set<string>()\n const claim = (kind: string, id: string): void => {\n if (!SNAKE_CASE.test(id)) problems.push(`${kind} id \"${id}\" must be snake_case`)\n if (ids.has(id)) problems.push(`duplicate id \"${id}\" across controls, layers and metrics`)\n ids.add(id)\n }\n for (const control of declared.controls ?? []) {\n claim('control', control.id)\n checkControl(control, problems)\n }\n for (const layer of declared.layers ?? []) claim('layer', layer.id)\n for (const metric of declared.metrics ?? []) {\n claim('metric', metric.id)\n if (metric.max !== undefined && !(finite(metric.max) && metric.max > 0)) problems.push(`metric \"${metric.id}\" needs a max > 0`)\n }\n return { ok: problems.length === 0, problems }\n}\n","import type { CategoryDef } from './types'\n\n/** The 14 categories of the team's catalog; always registered. */\nexport const ITEM_CATEGORIES: readonly CategoryDef[] = [\n { id: 'food', name: 'Food' },\n { id: 'drink', name: 'Drink' },\n { id: 'medical', name: 'Medical' },\n { id: 'tools', name: 'Tools' },\n { id: 'melee', name: 'Melee' },\n { id: 'materials', name: 'Materials' },\n { id: 'clothing', name: 'Clothing' },\n { id: 'bags', name: 'Bags' },\n { id: 'electronics', name: 'Electronics' },\n { id: 'fuel', name: 'Fuel' },\n { id: 'outdoors', name: 'Outdoors' },\n { id: 'books', name: 'Books' },\n { id: 'collectibles', name: 'Collectibles' },\n { id: 'keys', name: 'Keys' },\n]\n","/**\n * Public types of @adatechnology/game-items.\n *\n * An `ItemDef` is a real-world thing (a can of beans, a hammer, a jacket) with its true size and weight; the rest of the fields are optional groups that\n * only the categories that need them fill (nutrition for food, protection for clothing, carry for bags). Units: metres, kilograms, litres, days.\n */\nimport type { BodySlot } from '@adatechnology/game-contracts'\nimport type { ItemDef as BaseItemDef } from '@adatechnology/game-interactables'\n\nimport type { RecipeDef } from './recipe-types'\nimport type { UseEffectDef } from './use-types'\n\nexport type { BaseItemDef }\n\nexport const RARITIES = ['common', 'uncommon', 'rare', 'very_rare', 'legendary', 'unique'] as const\nexport type Rarity = (typeof RARITIES)[number]\n\n/** The body slots an item covers: the vocabulary is `BodySlot` of game-contracts (`over` is a layer worn over the others, such as a coat or a vest). */\nexport type { BodySlot } from '@adatechnology/game-contracts'\nexport const BODY_SLOTS: readonly BodySlot[] = ['head', 'eyes', 'torso', 'arms', 'hands', 'legs', 'feet', 'over']\n\nexport const DURABILITY_KINDS = ['uses', 'portions', 'doses', 'hits', 'cuts', 'shots', 'condition', 'charges', 'seconds', 'days', 'infinite', 'container'] as const\nexport type DurabilityKind = (typeof DURABILITY_KINDS)[number]\n\n/** How long an item lasts, read from the source text (\"10 uses\", \"4 portions\", \"300 hits\", \"infinite\"). */\nexport interface Durability {\n kind: DurabilityKind\n amount?: number\n /** Source qualifier when the amount alone is not enough (for example the voltage of a battery). */\n per?: string\n}\n\n/** Id of a place where items turn up (house, hospital, farm...). Places are a registry: a pack or the game declares the ones it uses. */\nexport type PlaceTag = string\n\n/** Id of an item category. The 14 of `ITEM_CATEGORIES` are always present; a game adds its own through `createItems({ categories })`. */\nexport type ItemCategory = string\n\nexport interface CategoryDef {\n id: ItemCategory\n /** Display name in English; games translate by id. */\n name?: string\n}\n\nexport interface PlaceDef {\n id: PlaceTag\n name?: string\n}\n\n/**\n * Where items are found inside a place (a fridge, a drawer, a locker): which categories it draws from and how many stacks. The finds roll reads `count`,\n * `categories` (or `categoriesByPlace`, with `*` for any other place) and `places`; other fields of a pack's container are kept and ignored.\n */\nexport interface ContainerDef {\n id: string\n name?: string\n /** Places the container turns up in (`toLootTables` only); empty or missing means any. */\n places?: readonly PlaceTag[]\n /** Weight of each category when a stack is drawn for this container. */\n categories?: Readonly<Record<ItemCategory, number>>\n /** When `categories` is missing: the weights by place (a shop shelf stocks what the shop sells). */\n categoriesByPlace?: Readonly<Record<PlaceTag, Readonly<Record<ItemCategory, number>>>>\n /** Range of stacks a container holds when it is filled. */\n count?: readonly [number, number]\n}\n\n/** One thing an explicit pool can hold (a fridge: water bottle, milk, eggs...): `item` is the catalog id, `weight` the odds, `qty` the range. */\nexport interface PoolEntry {\n item: string\n weight: number\n qty: readonly [number, number]\n /** Supplies (food, water, medicine, tools) are what a fortified house stocks up on. */\n isSupply?: boolean\n}\n\nexport interface ItemDef extends BaseItemDef {\n category: ItemCategory\n /** The old household category (`kitchen`, `hygiene`, `office`...), `perishable` or `craft_only`. */\n tags?: readonly string[]\n rarity: Rarity\n /** Litres. Derived from `size` when the pack does not give it. */\n volume?: number\n durability?: Durability\n /** Days to spoil at room temperature. */\n shelfLife?: number\n portions?: number\n cookable?: boolean\n nutrition?: { satiety?: number; hydration?: number }\n /** Fractions 0..1 per attack, per body slot covered. */\n protection?: {\n slots: readonly BodySlot[]\n bite?: number\n scratch?: number\n warmth?: number\n /** Against projectiles (read by game-characters): `resistance` in penetration units; a stopped projectile does `blunt` of its damage, one that goes through `pass`. */\n ballistic?: { resistance: number; blunt?: number; pass?: number; consumable?: boolean }\n }\n carry?: { capacityKg: number; weightReduction: number; quickSlots: number; wheels?: boolean }\n /** Id of the weapon in the arsenal when the item is also one. */\n weapon?: string\n /** Id of the liquid (`game-fluids`) when the item holds one. */\n fluid?: { liquid: string; liters: number }\n uses?: readonly string[]\n finds?: { places: readonly PlaceTag[] }\n /** Battery-like items: share of the charge lost per month. */\n charge?: { lossPerMonth: number }\n /** Fuel-like items: after `afterDays` each use fails with probability `chance`. */\n agingFault?: { afterDays: number; chance: number }\n /** Key of the 3D model in `game-items-three`; without it a simple shape from `shape` is drawn. */\n model?: string\n}\n\n/** A bundle of content: items plus the categories, places and containers they rely on. */\nexport interface ItemPack {\n id: string\n items: readonly ItemDef[]\n categories?: readonly CategoryDef[]\n places?: readonly PlaceDef[]\n containers?: readonly ContainerDef[]\n /** What using an item does, in priority order (`resolveUse`). */\n useEffects?: readonly UseEffectDef[]\n recipes?: readonly RecipeDef[]\n /** Explicit pools of what a kind of furniture holds, by key (`fridge`, `sink`...); they win over the category roll of a container. */\n pools?: Readonly<Record<string, readonly PoolEntry[]>>\n /** Keys that share another key's pool (`garage_wardrobe` -> `wardrobe`). */\n poolAliases?: Readonly<Record<string, string>>\n}\n","import type { ItemRules } from './rules-types'\nimport type { CategoryDef, ContainerDef, ItemDef, PlaceDef } from './types'\nimport { BODY_SLOTS, DURABILITY_KINDS, RARITIES } from './types'\n\nexport interface ItemsParts {\n readonly items: ReadonlyMap<string, ItemDef>\n readonly categories: ReadonlyMap<string, CategoryDef>\n readonly places: ReadonlyMap<string, PlaceDef>\n readonly containers: ReadonlyMap<string, ContainerDef>\n readonly rules: ItemRules\n}\n\n/** Ids that appear more than once in a list a game passed in. */\nexport const findDuplicateIds = (kind: string, entries: readonly { readonly id: string }[] | undefined): string[] => {\n const seen = new Set<string>()\n const problems: string[] = []\n for (const { id } of entries ?? []) {\n if (seen.has(id)) problems.push(`${kind} \"${id}\" is listed more than once`)\n seen.add(id)\n }\n return problems\n}\n\nconst isFraction = (value: number | undefined): boolean => value === undefined || (value >= 0 && value <= 1)\nconst isPositive = (value: number): boolean => Number.isFinite(value) && value > 0\n\nconst checkItem = (item: ItemDef, { categories, places }: ItemsParts): string[] => {\n const problems: string[] = []\n const issue = (text: string): number => problems.push(`item \"${item.id}\" ${text}`)\n if (!categories.has(item.category)) issue(`has unknown category \"${item.category}\"`)\n if (!RARITIES.includes(item.rarity)) issue(`has unknown rarity \"${item.rarity}\"`)\n if (item.size.length !== 3 || !item.size.every(isPositive)) issue(`has an invalid size [${item.size.join(', ')}]`)\n if (!(item.weight >= 0) || !Number.isFinite(item.weight)) issue(`has a weight of ${item.weight} kg`)\n if (item.volume !== undefined && !isPositive(item.volume)) issue(`has a volume of ${item.volume} L`)\n if (item.shelfLife !== undefined && !isPositive(item.shelfLife)) issue(`has a shelf life of ${item.shelfLife} days`)\n if (item.portions !== undefined && !(Number.isInteger(item.portions) && item.portions > 0)) issue(`has ${item.portions} portions`)\n if (item.durability && !DURABILITY_KINDS.includes(item.durability.kind)) issue(`has unknown durability kind \"${item.durability.kind}\"`)\n const { protection } = item\n if (protection) {\n for (const slot of protection.slots) if (!BODY_SLOTS.includes(slot)) issue(`protects unknown body slot \"${slot}\"`)\n if (protection.slots.length === 0) issue('has protection but covers no body slot')\n if (![protection.bite, protection.scratch, protection.warmth].every(isFraction)) issue('has a protection value outside 0..1')\n }\n if (item.carry && !(item.carry.capacityKg > 0 && isFraction(item.carry.weightReduction))) issue('has an invalid carry (capacity above 0, reduction within 0..1)')\n if (places.size > 0) for (const place of item.finds?.places ?? []) if (!places.has(place)) issue(`is found in unknown place \"${place}\"`)\n return problems\n}\n\nconst checkContainer = (container: ContainerDef, { categories }: ItemsParts): string[] => {\n const problems: string[] = []\n for (const category of Object.keys(container.categories ?? {})) {\n if (!categories.has(category)) problems.push(`container \"${container.id}\" draws from unknown category \"${category}\"`)\n }\n const [low, high] = container.count ?? [0, 0]\n if (low < 0 || high < low) problems.push(`container \"${container.id}\" has an invalid count [${low}, ${high}]`)\n return problems\n}\n\nconst checkRules = ({ rarity, freshness, storage, carry, protection }: ItemRules): string[] => {\n const problems: string[] = []\n const hasNegativeWeight = RARITIES.some((id) => !(rarity.weights[id] >= 0))\n if (hasNegativeWeight || !RARITIES.some((id) => rarity.weights[id] > 0 && !rarity.fixedOnly.includes(id))) {\n problems.push('rules.rarity.weights must be non-negative with at least one rollable rarity above 0')\n }\n if (!(freshness.stale > 0 && freshness.stale < freshness.spoiled && freshness.spoiled < freshness.rotten)) problems.push('rules.freshness must rise: 0 < stale < spoiled < rotten')\n for (const [id, rule] of Object.entries(storage)) if (!isPositive(rule.multiplier) || (rule.limitDays !== undefined && !isPositive(rule.afterLimit ?? 0))) problems.push(`rules.storage \"${id}\" is invalid`)\n if (!(isPositive(carry.baseKg) && carry.perStrengthKg >= 0 && isPositive(carry.factor) && carry.encumbered > 0 && carry.overloaded > carry.encumbered)) problems.push('rules.carry is invalid')\n if (!(protection.cap > 0 && protection.cap <= 1 && isFraction(protection.tornShare))) problems.push('rules.protection is invalid')\n return problems\n}\n\n/** Every inconsistency of a catalog, one line each; empty when it is sound. */\nexport const checkItems = (parts: ItemsParts): string[] => [\n ...[...parts.items.values()].flatMap((item) => checkItem(item, parts)),\n ...[...parts.containers.values()].flatMap((container) => checkContainer(container, parts)),\n ...checkRules(parts.rules),\n]\n","import type { ItemRules } from './rules-types'\n\n/**\n * The numbers of the rules, from the team's Vale Cinzento delivery. `storage` was read by hand from its table (Validade e apodrecimento) so the\n * skeleton runs; the source test of the catalog tool proves it against the HTML (legendary and unique are never rolled: L276). Every field is an option of `createItems({ rules })`.\n */\nexport const DEFAULT_RULES: ItemRules = {\n rarity: {\n weights: { common: 60, uncommon: 28, rare: 9, very_rare: 2.5, legendary: 0.5, unique: 0 },\n fixedOnly: ['legendary', 'unique'],\n },\n freshness: { stale: 0.5, spoiled: 1, rotten: 1.5 },\n storage: {\n fridge: { multiplier: 0.33, needsPower: true },\n freezer: { multiplier: 0.1, needsPower: true },\n cooler: { multiplier: 0.5, limitDays: 2, afterLimit: 1 },\n delivery_bag: { multiplier: 0.8 },\n cellar: { multiplier: 0.66 },\n ambient: { multiplier: 1 },\n hot: { multiplier: 2 },\n },\n carry: { baseKg: 8, perStrengthKg: 2.5, factor: 1.2, encumbered: 1, overloaded: 1.5 },\n protection: { cap: 0.9, wear: { bite: 8, scratch: 3 }, tornShare: 0.25, repairMargin: 10 },\n aging: { thawShelfDays: { freezer: 2 } },\n inventory: { defaultSlots: 10, encumberedRunFactor: 0.75 },\n use: {\n freshness: {\n stale: { satietyFactor: 0.7, poisonChance: 0.1, morale: 0, isEdible: true },\n spoiled: { satietyFactor: 0.7, poisonChance: 0.5, morale: -20, isEdible: true },\n rotten: { satietyFactor: 0, poisonChance: 0, morale: 0, isEdible: false },\n },\n cookedPoisonFactor: 0.25,\n },\n spawn: {\n retailNew: true,\n retailPlaces: ['supermarket', 'pharmacy', 'mall', 'gas_station', 'gun_shop'],\n retailContainers: ['store_shelf', 'stockroom'],\n condition: { common: [30, 90], uncommon: [40, 90], rare: [50, 100], very_rare: [50, 100], legendary: [50, 100], unique: [50, 100] },\n ageShare: [0, 0.6],\n charge: [0.2, 1],\n fillable: ['fuel_jerrycan'],\n fill: [0, 0.6],\n },\n finds: {\n fallbackContainer: 'zombie_corpse',\n rollExcluded: ['ammo_7_62x39'],\n depletion: [\n { places: ['pharmacy', 'gun_shop', 'police_station'], perDay: 0.05, floor: 0.3 },\n { places: ['supermarket', 'gas_station'], perDay: 0.04, floor: 0.35 },\n { places: ['mall', 'hospital'], perDay: 0.025, floor: 0.5 },\n { places: ['house', 'apartment'], perDay: 0.01, floor: 0.7 },\n { places: ['farm', 'warehouse', 'mine', 'dam', 'lodge', 'church'], perDay: 0.005, floor: 0.85 },\n { places: ['military_base'], perDay: 0, floor: 1 },\n ],\n regionMultiplier: {},\n states: { normal: 'normal', fortified: 'fortified', abandoned: 'abandoned' },\n pool: {\n defaultFootprint: 0.5,\n offset: 1.5,\n perSquareMetre: 3,\n jitter: 1.5,\n min: 2,\n max: 5,\n fortifiedExtra: 2,\n supplyWeight: 2.5,\n supplyExtraQty: 1,\n abandonedEmptyChance: 0.4,\n abandonedKept: 1,\n junkChance: 0.5,\n junkItem: 'food_packaging',\n spoiledFoodItem: 'leftover_meal',\n coldContainers: ['geladeira', 'refrig_exp', 'freezer'],\n },\n },\n}\n","import { applyPatches, deepMerge, mergeById, removeIds } from '@adatechnology/game-contracts'\n\nimport { ITEM_CATEGORIES } from './categories'\nimport type { ItemsCatalog, ItemsOptions } from './catalog-types'\nimport { checkItems, findDuplicateIds } from './check-items'\nimport { DEFAULT_RULES } from './rules'\nimport type { RecipeDef } from './recipe-types'\nimport type { ContainerDef, ItemDef, PlaceDef, PoolEntry } from './types'\nimport type { UseEffectDef } from './use-types'\n\nconst LITRES_PER_CUBIC_METRE = 1000\n\n/** Fills what a pack leaves out: the volume comes from the size. */\nconst withDerivedFields = (item: ItemDef): ItemDef => (item.volume === undefined ? { ...item, volume: item.size[0] * item.size[1] * item.size[2] * LITRES_PER_CUBIC_METRE } : item)\n\nconst buildPools = (options: ItemsOptions, packs: NonNullable<ItemsOptions['packs']>): Map<string, readonly PoolEntry[]> => {\n const pools = new Map<string, readonly PoolEntry[]>()\n for (const source of [...packs, options]) for (const [key, entries] of Object.entries(source.pools ?? {})) pools.set(key, entries)\n const problems: string[] = []\n for (const source of [...packs, options]) {\n for (const [alias, key] of Object.entries(source.poolAliases ?? {})) {\n const entries = pools.get(key)\n if (entries) pools.set(alias, entries)\n else problems.push(`pool alias \"${alias}\" points to unknown pool \"${key}\"`)\n }\n }\n if (problems.length > 0) throw new Error(`game-items: the catalog is not consistent:\\n${problems.map((problem) => ` - ${problem}`).join('\\n')}`)\n return pools\n}\n\nconst groupBy = (items: Iterable<ItemDef>, keysOf: (item: ItemDef) => readonly string[]): Map<string, ItemDef[]> => {\n const groups = new Map<string, ItemDef[]>()\n for (const item of items) for (const key of keysOf(item)) groups.set(key, [...(groups.get(key) ?? []), item])\n return groups\n}\n\n/**\n * Create the catalog. Order: the packs in order (added or replaced by id), then `patch`, then `remove`, then `include`; categories, places and\n * containers are those of the packs plus the options, by id. Every inconsistency found is reported in one error.\n */\nexport const createItems = (options: ItemsOptions = {}): ItemsCatalog => {\n const packs = options.packs ?? []\n const items = mergeById([] as ItemDef[], packs.flatMap((pack) => pack.items))\n applyPatches<ItemDef>(items, options.patch)\n removeIds(items, options.remove)\n if (options.include) for (const [id, item] of items) if (!options.include(item)) items.delete(id)\n for (const [id, item] of items) items.set(id, withDerivedFields(item))\n\n const categories = mergeById(ITEM_CATEGORIES, [...packs.flatMap((pack) => pack.categories ?? []), ...(options.categories ?? [])])\n const places = mergeById<PlaceDef>([], [...packs.flatMap((pack) => pack.places ?? []), ...(options.places ?? [])])\n const containers = mergeById<ContainerDef>([], [...packs.flatMap((pack) => pack.containers ?? []), ...(options.containers ?? [])])\n const useEffects = [...mergeById<UseEffectDef>([], [...packs.flatMap((pack) => pack.useEffects ?? []), ...(options.useEffects ?? [])]).values()]\n const recipes = mergeById<RecipeDef>([], [...packs.flatMap((pack) => pack.recipes ?? []), ...(options.recipes ?? [])])\n const pools = buildPools(options, packs)\n const rules = deepMerge(DEFAULT_RULES, options.rules)\n\n const problems = [\n ...packs.flatMap((pack) => findDuplicateIds(`item in pack \"${pack.id}\"`, pack.items)),\n ...findDuplicateIds('category', options.categories),\n ...findDuplicateIds('place', options.places),\n ...findDuplicateIds('container', options.containers),\n ...findDuplicateIds('use effect', options.useEffects),\n ...findDuplicateIds('recipe', options.recipes),\n ...checkItems({ items, categories, places, containers, rules }),\n ]\n if (problems.length > 0) throw new Error(`game-items: the catalog is not consistent:\\n${problems.map((problem) => ` - ${problem}`).join('\\n')}`)\n\n const byCategory = groupBy(items.values(), (item) => [item.category])\n const byPlace = groupBy(items.values(), (item) => item.finds?.places ?? [])\n const byTag = groupBy(items.values(), (item) => item.tags ?? [])\n\n return {\n items,\n categories,\n places,\n containers,\n pools,\n useEffects,\n recipes,\n rules,\n has: (id) => items.has(id),\n get(id) {\n const item = items.get(id)\n if (!item) throw new Error(`game-items: unknown item \"${id}\"`)\n return item\n },\n list: () => [...items.values()],\n byCategory: (category) => [...(byCategory.get(category) ?? [])],\n byPlace: (place) => [...(byPlace.get(place) ?? [])],\n byTag: (tag) => [...(byTag.get(tag) ?? [])],\n }\n}\n","import { DEFAULT_RULES } from './rules'\nimport type { ItemRules, StorageId } from './rules-types'\nimport type { ItemDef } from './types'\n\nexport type Freshness = 'fresh' | 'stale' | 'spoiled' | 'rotten'\n\n/**\n * What an item remembers about its age. Nothing ticks: the account is closed lazily, when the item moves or someone looks at it.\n * Times are in days on the host's clock.\n */\nexport interface AgeState {\n /** Effective age: days at room temperature. */\n ageDays: number\n /** Time at which `ageDays` was last closed. */\n since: number\n storage: StorageId\n /** Time at which the item entered `storage` (a cooler only helps for its first days). */\n storedAt: number\n /** Replaces the item's shelf life (a thawed freezer leaves 2 days). */\n shelfLife?: number\n}\n\nexport interface AgeOptions {\n /** Whether the storage has power right now (fridge, freezer). Default true. */\n powered?: boolean\n rules?: ItemRules\n}\n\nconst storageRuleOf = (rules: ItemRules, storage: StorageId) => {\n const rule = rules.storage[storage]\n if (!rule) throw new Error(`game-items: unknown storage \"${storage}\"`)\n return rule\n}\n\n/** The multiplier of the age at one instant: `heldDays` is how long the item has been in `storage`. */\nexport const storageMultiplier = (storage: StorageId, options: AgeOptions & { heldDays?: number } = {}): number => {\n const { powered = true, rules = DEFAULT_RULES, heldDays = 0 } = options\n const rule = storageRuleOf(rules, storage)\n if (rule.needsPower && !powered) return 1\n if (rule.limitDays !== undefined && heldDays >= rule.limitDays) return rule.afterLimit ?? 1\n return rule.multiplier\n}\n\n/** Days of effective age accrued between `from` and `to` in a storage the item entered at `storedAt`. */\nconst accruedDays = (storage: StorageId, window: { storedAt: number; from: number; to: number }, options: AgeOptions): number => {\n const { powered = true, rules = DEFAULT_RULES } = options\n const rule = storageRuleOf(rules, storage)\n const elapsed = window.to - window.from\n if (elapsed <= 0) return 0\n if (rule.needsPower && !powered) return elapsed\n if (rule.limitDays === undefined) return elapsed * rule.multiplier\n const limitEnd = window.storedAt + rule.limitDays\n const early = Math.max(0, Math.min(window.to, limitEnd) - Math.max(window.from, window.storedAt))\n return early * rule.multiplier + (elapsed - early) * (rule.afterLimit ?? 1)\n}\n\n/** Closes the account up to `now`: `ageDays += (now - since) x multiplier`. Pure: returns the new state. */\nexport const advanceAge = (state: AgeState, now: number, options: AgeOptions = {}): AgeState => {\n const { powered = true, rules = DEFAULT_RULES } = options\n const rule = storageRuleOf(rules, state.storage)\n const thawDays = rules.aging.thawShelfDays[state.storage]\n const isThawing = rule.needsPower === true && !powered && thawDays !== undefined && state.shelfLife === undefined\n const base = isThawing ? { ...state, ageDays: 0, shelfLife: thawDays } : state\n const ageDays = base.ageDays + accruedDays(state.storage, { storedAt: state.storedAt, from: state.since, to: now }, options)\n return { ...base, ageDays, since: Math.max(now, state.since) }\n}\n\n/** Moves the item to another storage: closes the account in the old one, then the new multiplier applies from `now`. */\nexport const moveToStorage = (state: AgeState, now: number, storage: StorageId, options: AgeOptions = {}): AgeState => ({\n ...advanceAge(state, now, options),\n storage,\n storedAt: now,\n})\n\n/** Age as a share of the shelf life; 0 for an item that does not spoil. */\nexport const shelfRatio = (item: ItemDef, state: AgeState): number => {\n const shelfLife = state.shelfLife ?? item.shelfLife\n return shelfLife === undefined ? 0 : state.ageDays / shelfLife\n}\n\n/** `fresh`, `stale`, `spoiled` or `rotten` at 0.5, 1 and 1.5 of the shelf life (the rule's `freshness`). Closes the account up to `now` first. */\nexport const freshness = (item: ItemDef, state: AgeState, now: number, options: AgeOptions = {}): Freshness => {\n const { rules = DEFAULT_RULES } = options\n const ratio = shelfRatio(item, advanceAge(state, now, options))\n if (ratio >= rules.freshness.rotten) return 'rotten'\n if (ratio >= rules.freshness.spoiled) return 'spoiled'\n if (ratio >= rules.freshness.stale) return 'stale'\n return 'fresh'\n}\n\n/** Charge left (0..1) of a battery-like item: it loses `charge.lossPerMonth` of the full charge per 30 days of effective age. */\nexport const chargeLevel = (item: ItemDef, initialCharge: number, ageDays: number): number => {\n if (!item.charge) return initialCharge\n return Math.max(0, initialCharge - item.charge.lossPerMonth * (ageDays / 30))\n}\n\n/** True when one use of an old fuel-like item fails: past `agingFault.afterDays` each use fails with probability `chance`. Draws once, only when it can fail. */\nexport const failsToStart = (item: ItemDef, ageDays: number, random: () => number): boolean => {\n if (!item.agingFault || ageDays <= item.agingFault.afterDays) return false\n return random() < item.agingFault.chance\n}\n","import { DEFAULT_RULES } from './rules'\nimport type { ItemRules } from './rules-types'\nimport type { BodySlot, ItemDef } from './types'\n\nexport type AttackKind = 'bite' | 'scratch'\n\n/** A piece of clothing on a body: the item and its condition, 0 (torn) to 100 (new). */\nexport interface WornPiece {\n item: ItemDef\n condition: number\n}\n\nconst FULL_CONDITION = 100\n\nconst covers = (piece: WornPiece, slot: BodySlot): boolean => piece.item.protection?.slots.includes(slot) ?? false\n\n/** What one piece blocks of an attack: its value times its condition; a torn piece (condition 0) keeps `tornShare` of the value. */\nexport const pieceBlock = (piece: WornPiece, attack: AttackKind, rules: ItemRules = DEFAULT_RULES): number => {\n const value = piece.item.protection?.[attack] ?? 0\n const share = piece.condition <= 0 ? rules.protection.tornShare : piece.condition / FULL_CONDITION\n return value * share\n}\n\n/** Share of an attack on `slot` that clothing blocks: `1 - PROD(1 - value x condition)`, never above the cap (there is always some risk). */\nexport const blockChance = (pieces: readonly WornPiece[], slot: BodySlot, attack: AttackKind, rules: ItemRules = DEFAULT_RULES): number => {\n const unblocked = pieces.filter((piece) => covers(piece, slot)).reduce((product, piece) => product * (1 - pieceBlock(piece, attack, rules)), 1)\n return Math.min(rules.protection.cap, 1 - unblocked)\n}\n\n/** A blocked attack wears every piece that covers the slot and blocks something: bite -8, scratch -3 (never below 0). */\nexport const wearBlocked = (pieces: readonly WornPiece[], slot: BodySlot, attack: AttackKind, rules: ItemRules = DEFAULT_RULES): WornPiece[] =>\n pieces.map((piece) => {\n if (!covers(piece, slot) || pieceBlock(piece, attack, rules) <= 0) return piece\n return { ...piece, condition: Math.max(0, piece.condition - rules.protection.wear[attack]) }\n })\n\n/** A repair kit restores up to the maximum condition minus the margin (10); a piece already better is left alone. */\nexport const repairPiece = (piece: WornPiece, rules: ItemRules = DEFAULT_RULES, maxCondition: number = FULL_CONDITION): WornPiece => ({\n ...piece,\n condition: Math.max(piece.condition, maxCondition - rules.protection.repairMargin),\n})\n","import { DEFAULT_RULES } from './rules'\nimport type { ItemRules } from './rules-types'\nimport type { ItemDef } from './types'\n\nexport interface CarryEntry {\n item: ItemDef\n count: number\n}\n\nexport type LoadLevel = 'normal' | 'encumbered' | 'overloaded'\n\nexport interface LoadState {\n /** Load as a share of the maximum. */\n ratio: number\n level: LoadLevel\n /** Multiplier of running speed and breath. */\n runFactor: number\n canRun: boolean\n}\n\n/** What a character can carry, kg: `(8 + 2.5 x strength) x 1.2` with the default rules. */\nexport const maxLoadKg = (strength: number, rules: ItemRules = DEFAULT_RULES): number => (rules.carry.baseKg + rules.carry.perStrengthKg * strength) * rules.carry.factor\n\nconst sumKg = (entries: readonly CarryEntry[]): number => entries.reduce((total, entry) => total + entry.item.weight * entry.count, 0)\n\n/** Weight carried: what is outside the bag counts in full, what is inside counts times `(1 - weightReduction)`. */\nexport const carriedKg = (parts: { loose: readonly CarryEntry[]; packed?: readonly CarryEntry[]; weightReduction?: number }): number =>\n sumKg(parts.loose) + sumKg(parts.packed ?? []) * (1 - (parts.weightReduction ?? 0))\n\n/** Above 100% of the maximum, running and breath drop 25%; above 150% the character cannot run. */\nexport const loadState = (loadKg: number, maxKg: number, rules: ItemRules = DEFAULT_RULES): LoadState => {\n const ratio = maxKg > 0 ? loadKg / maxKg : Number.POSITIVE_INFINITY\n if (ratio > rules.carry.overloaded) return { ratio, level: 'overloaded', runFactor: 0, canRun: false }\n if (ratio > rules.carry.encumbered) return { ratio, level: 'encumbered', runFactor: rules.inventory.encumberedRunFactor, canRun: true }\n return { ratio, level: 'normal', runFactor: 1, canRun: true }\n}\n\n/** The bag of a character: how many item slots it has and how much it holds. */\nexport interface BagOption {\n slots: number\n capacityKg: number\n}\n\n/** The bag a bag item gives: its `carry.capacityKg` is the slot count the team's game reads from `cap`; the weight limit is the character's. */\nexport const bagOption = (bag: ItemDef | undefined, characterMaxKg: number, rules: ItemRules = DEFAULT_RULES): BagOption => ({\n slots: bag?.carry ? Math.round(bag.carry.capacityKg) : rules.inventory.defaultSlots,\n capacityKg: characterMaxKg,\n})\n\nexport type TakeResult = 'ok' | 'bag_full' | 'too_heavy'\n\n/** Whether `item` can go into the bag now: first the slots, then the weight. */\nexport const canTake = (item: ItemDef, bag: BagOption, used: { slots: number; loadKg: number }): TakeResult => {\n if (used.slots >= bag.slots) return 'bag_full'\n if (used.loadKg + item.weight > bag.capacityKg) return 'too_heavy'\n return 'ok'\n}\n","import type { Freshness } from './decay'\nimport { DEFAULT_RULES } from './rules'\nimport type { ItemRules } from './rules-types'\nimport type { ItemDef } from './types'\nimport type { UseEffectDef } from './use-types'\n\nexport interface UseContext {\n /** State of the food, from `freshness()`. Default `fresh`. */\n freshness?: Freshness\n /** The food was cooked: it keeps only `cookedPoisonFactor` of its poison chance. */\n isCooked?: boolean\n}\n\nexport interface NutritionResult {\n satiety: number\n hydration: number\n morale: number\n poisonChance: number\n isEdible: boolean\n}\n\nexport interface UseResult {\n useId: string\n effect: string\n params: Readonly<Record<string, unknown>>\n /** The item is spent (false for food too rotten to eat). */\n consumes: boolean\n /** Only for `consume_nutrition`. */\n nutrition?: NutritionResult\n}\n\nconst FRESH_NUTRITION = { satietyFactor: 1, poisonChance: 0, morale: 0, isEdible: true }\n\nconst nutritionOf = (item: ItemDef, context: UseContext, rules: ItemRules): NutritionResult => {\n const state = context.freshness ?? 'fresh'\n const effect = state === 'fresh' ? FRESH_NUTRITION : rules.use.freshness[state]\n const poisonShare = context.isCooked ? rules.use.cookedPoisonFactor : 1\n return {\n satiety: (item.nutrition?.satiety ?? 0) * effect.satietyFactor,\n hydration: item.nutrition?.hydration ?? 0,\n morale: effect.morale,\n poisonChance: effect.poisonChance * poisonShare,\n isEdible: effect.isEdible,\n }\n}\n\nconst findEffect = (item: ItemDef, effects: readonly UseEffectDef[]): UseEffectDef | undefined =>\n effects.find((entry) => entry.items?.includes(item.id)) ?? effects.find((entry) => entry.category?.includes(item.category))\n\n/**\n * What using `item` does, as data: the host applies it (the player, the needs, the world). Pure. The first effect that lists the item wins, then the first\n * that lists its category; `undefined` when nothing happens. Food past its best follows `rules.use.freshness`.\n */\nexport const resolveUse = (item: ItemDef, effects: readonly UseEffectDef[], context: UseContext = {}, rules: ItemRules = DEFAULT_RULES): UseResult | undefined => {\n const def = findEffect(item, effects)\n if (!def) return undefined\n const base = { useId: def.id, effect: def.effect, params: def.params ?? {}, consumes: def.consumes }\n if (def.effect !== 'consume_nutrition') return base\n const nutrition = nutritionOf(item, context, rules)\n return { ...base, consumes: def.consumes && nutrition.isEdible, nutrition }\n}\n","import type { ItemsCatalog } from './catalog-types'\nimport type { RecipeDef, RecipeInput, RecipeOutput } from './recipe-types'\n\nexport interface CraftContext {\n catalog: ItemsCatalog\n /** Count of each item the character has, by id. */\n inventory: ReadonlyMap<string, number>\n /** Litres of each fluid at hand, by fluid id. */\n fluids?: Readonly<Record<string, number>>\n /** Stations and things of the world that are near (`fire`, `table`, `soil`). */\n world?: ReadonlySet<string>\n /** Skill levels by id. Without it the skill requirement is ignored. */\n skills?: Readonly<Record<string, number>>\n /** Ids unlocked so far. Without it `unlockedBy` is ignored. */\n unlocked?: ReadonlySet<string>\n}\n\nexport interface CraftCheck {\n isPossible: boolean\n /** One entry per unmet requirement: `item:nails`, `any:salt|sugar`, `match:cookable`, `fluid:water`, `world:fire`, `tool:hammer|axe`, `skill:carpentry:2`, `unlock:magazine`. */\n missing: string[]\n}\n\nexport type Consumption = { item: string; count: number; meters?: number } | { fluid: string; liters: number }\n\nexport type CraftOutcome = { item: string; count: number } | { effect: string; params: Readonly<Record<string, unknown>>; text?: string }\n\nexport type CraftResult = { isCrafted: true; seconds: number; consumed: Consumption[]; output: CraftOutcome } | { isCrafted: false; missing: string[] }\n\nconst countOf = (context: CraftContext, id: string): number => context.inventory.get(id) ?? 0\n\n/** Ids that satisfy an item input, in the order they are used. */\nconst candidatesOf = (input: RecipeInput, context: CraftContext): string[] => {\n if (input.item !== undefined) return [input.item]\n if (input.any) return [...input.any]\n if (!input.match) return []\n const { category, cookable } = input.match\n return context.catalog.list().filter((item) => (category === undefined || item.category === category) && (cookable === undefined || item.cookable === cookable)).map((item) => item.id)\n}\n\nconst describeInput = (input: RecipeInput): string => {\n if (input.item !== undefined) return `item:${input.item}`\n if (input.any) return `any:${input.any.join('|')}`\n if (input.match) return `match:${input.match.category ?? 'cookable'}`\n if (input.fluid !== undefined) return `fluid:${input.fluid}`\n return `world:${(input.world ?? []).join('|')}`\n}\n\n/** Takes `needed` units from the candidates in order; returns what was taken, or undefined when there is not enough. */\nconst takeUnits = (candidates: readonly string[], needed: number, context: CraftContext): Consumption[] | undefined => {\n const taken: Consumption[] = []\n let remaining = needed\n for (const id of candidates) {\n if (remaining <= 0) break\n const count = Math.min(remaining, countOf(context, id))\n if (count > 0) taken.push({ item: id, count })\n remaining -= count\n }\n return remaining > 0 ? undefined : taken\n}\n\nconst consumptionFor = (input: RecipeInput, context: CraftContext): Consumption[] | undefined => {\n if (input.world) return input.world.some((thing) => context.world?.has(thing)) ? [] : undefined\n if (input.fluid !== undefined) {\n const liters = input.liters ?? 1\n return (context.fluids?.[input.fluid] ?? 0) >= liters ? [{ fluid: input.fluid, liters }] : undefined\n }\n const taken = takeUnits(candidatesOf(input, context), input.qty ?? 1, context)\n if (!taken || input.meters === undefined) return taken\n return taken.map((entry) => ('item' in entry ? { ...entry, meters: input.meters as number } : entry))\n}\n\nconst toolMissing = (recipe: RecipeDef, context: CraftContext): string[] =>\n recipe.tools.flatMap((tool) => {\n const missing: string[] = []\n if (tool.anyOf.length > 0 && !tool.anyOf.some((id) => countOf(context, id) > 0)) missing.push(`tool:${tool.anyOf.join('|')}`)\n if (tool.world && !tool.world.some((station) => context.world?.has(station))) missing.push(`world:${tool.world.join('|')}`)\n return missing\n })\n\nconst gateMissing = (recipe: RecipeDef, context: CraftContext): string[] => {\n const missing: string[] = []\n if (context.skills && recipe.skill && (context.skills[recipe.skill.id] ?? 0) < recipe.skill.level) missing.push(`skill:${recipe.skill.id}:${recipe.skill.level}`)\n if (context.unlocked && recipe.unlockedBy !== undefined && !context.unlocked.has(recipe.unlockedBy)) missing.push(`unlock:${recipe.unlockedBy}`)\n return missing\n}\n\n/** Whether the character can make the recipe now, and what is missing when not. Skill and `unlockedBy` are ignored when the host does not pass `skills` or `unlocked`. */\nexport const canCraft = (recipe: RecipeDef, context: CraftContext): CraftCheck => {\n const missing = [\n ...gateMissing(recipe, context),\n ...toolMissing(recipe, context),\n ...recipe.inputs.filter((input) => consumptionFor(input, context) === undefined).map(describeInput),\n ]\n return { isPossible: missing.length === 0, missing }\n}\n\nconst outcomeOf = (output: RecipeOutput, random: () => number): CraftOutcome => {\n if ('effect' in output) return { effect: output.effect, params: output.params, ...(output.text === undefined ? {} : { text: output.text }) }\n const [low, high] = output.qty\n return { item: output.item, count: low + Math.floor(random() * (high - low + 1)) }\n}\n\n/**\n * Makes the recipe: what is consumed and what comes out. Pure: the host takes the consumed things from its inventory and applies the output (an item is\n * added, an effect such as \"window with 200 life\" is the host's to handle). Draws one number only when the output quantity is a range.\n */\nexport const craft = (recipe: RecipeDef, context: CraftContext, random: () => number): CraftResult => {\n const check = canCraft(recipe, context)\n if (!check.isPossible) return { isCrafted: false, missing: check.missing }\n const consumed = recipe.inputs.flatMap((input) => consumptionFor(input, context) ?? [])\n return { isCrafted: true, seconds: recipe.seconds, consumed, output: outcomeOf(recipe.output, random) }\n}\n","import type { DepletionRule } from '../rules-types'\nimport type { ItemRules } from '../rules-types'\nimport type { PlaceTag } from '../types'\n\n/** Share of the normal stock a place still has `day` days into the outbreak: `max(floor, 1 - perDay x day)`; 1 on day 0 and where no rule names the place. */\nexport const depletionFactor = (place: PlaceTag, day: number, rules: ItemRules): number => {\n if (day <= 0) return 1\n const rule: DepletionRule | undefined = rules.finds.depletion.find((entry) => entry.places.includes(place))\n return rule ? Math.max(rule.floor, 1 - rule.perDay * day) : 1\n}\n\n/** The count of stacks after the days of the outbreak and the region: the roll is drawn first, so day 0 and an unknown region leave it as it was. */\nexport const scaleCount = (count: number, scale: { place: PlaceTag; day: number; region?: string }, rules: ItemRules): number => {\n const region = scale.region === undefined ? 1 : (rules.finds.regionMultiplier[scale.region] ?? 1)\n return Math.round(count * depletionFactor(scale.place, scale.day, rules) * region)\n}\n","import type { ItemsCatalog } from '../catalog-types'\nimport type { ItemCategory, ItemDef, PlaceTag } from '../types'\n\n/** The items of `category` a roll can draw in `place`: no fixed-spot rarity, nothing excluded; when none lists the place, every one of the category (the game's recuo). */\nexport const rollableItems = (catalog: ItemsCatalog, category: ItemCategory, place: PlaceTag): ItemDef[] => {\n const { rules } = catalog\n const rollable = catalog.byCategory(category).filter((item) => !rules.rarity.fixedOnly.includes(item.rarity) && !rules.finds.rollExcluded.includes(item.id))\n const here = rollable.filter((item) => item.finds?.places.includes(place))\n return here.length > 0 ? here : rollable\n}\n\nexport const rarityWeight = (catalog: ItemsCatalog, item: ItemDef): number => catalog.rules.rarity.weights[item.rarity]\n","import type { ItemsCatalog } from '../catalog-types'\nimport type { ItemCategory } from '../types'\n\nimport { scaleCount } from './depletion'\nimport { rarityWeight, rollableItems } from './rarity-pool'\nimport type { FindsQuery } from './types'\n\nconst pickCategory = (weights: Readonly<Record<ItemCategory, number>>, random: () => number): ItemCategory | undefined => {\n let remaining = random()\n const categories = Object.keys(weights)\n for (const category of categories) {\n remaining -= weights[category] as number\n if (remaining <= 0) return category\n }\n return categories[0]\n}\n\n/**\n * Port of the team game's `rollItems` (jogo.js L68796-68812) on the container table (`CONT_TAB`) and the rarity weights (`RAR_W`).\n *\n * Order of the random draws, the contract the golden test holds: one for the count; then for each stack one to pick the category, and (unless the category\n * has nothing to draw) one to pick the item among those of the place, by rarity weight. Nothing else draws. The count is scaled by the day and the region\n * after it is drawn, so day 0 gives exactly the game's rolls. Returns the item ids, one per stack.\n */\nexport const rollByCategory = (catalog: ItemsCatalog, query: FindsQuery): string[] => {\n const { rules } = catalog\n const table = catalog.containers.get(query.container) ?? catalog.containers.get(rules.finds.fallbackContainer)\n if (!table) return []\n const [low, high] = table.count ?? [0, 0]\n const drawn = low + Math.floor(query.rng() * (high - low + 1))\n const count = scaleCount(drawn, { place: query.place, day: query.day ?? 0, ...(query.region === undefined ? {} : { region: query.region }) }, rules)\n const weights = table.categories ?? table.categoriesByPlace?.[query.place] ?? table.categoriesByPlace?.['*']\n if (!weights) return []\n\n const picked: string[] = []\n for (let stack = 0; stack < count; stack++) {\n const category = pickCategory(weights, query.rng)\n const pool = category === undefined ? [] : rollableItems(catalog, category, query.place)\n if (pool.length === 0) continue\n const total = pool.reduce((sum, item) => sum + rarityWeight(catalog, item), 0)\n let remaining = query.rng() * total\n for (const item of pool) {\n remaining -= rarityWeight(catalog, item)\n if (remaining <= 0) {\n picked.push(item.id)\n break\n }\n }\n }\n return picked\n}\n","import type { ItemRules } from '../rules-types'\nimport type { FindState } from '../rules-types'\nimport type { PoolEntry } from '../types'\n\nimport type { FindsQuery } from './types'\n\nexport interface PoolPick {\n itemId: string\n count: number\n /** The pool entry it came from; none for the junk an abandoned container leaves. */\n entry?: PoolEntry\n}\n\ninterface Candidate {\n entry: PoolEntry\n weight: number\n}\n\n/** Draws without replacement by weight (the viewer's `ordemPonderada`): one draw per candidate, all of them, before anything is cut. */\nconst weightedOrder = (candidates: readonly Candidate[], random: () => number): Candidate[] => {\n const remaining = [...candidates]\n const ordered: Candidate[] = []\n while (remaining.length > 0) {\n const total = remaining.reduce((sum, candidate) => sum + candidate.weight, 0)\n let x = random() * total\n let index = 0\n for (; index < remaining.length - 1; index++) {\n x -= (remaining[index] as Candidate).weight\n if (x < 0) break\n }\n ordered.push(...remaining.splice(index, 1))\n }\n return ordered\n}\n\nconst countFor = (pool: readonly PoolEntry[], state: FindState, footprint: number, query: FindsQuery, rules: ItemRules): number => {\n const settings = rules.finds.pool\n const base = Math.max(settings.min, Math.min(settings.max, Math.round(settings.offset + footprint * settings.perSquareMetre + query.rng() * settings.jitter)))\n if (state === 'fortified') return Math.min(pool.length, base + settings.fortifiedExtra)\n if (state === 'abandoned') return query.rng() < settings.abandonedEmptyChance ? 0 : settings.abandonedKept\n return base\n}\n\n/**\n * Port of the 3D house viewer's `sorteiaConteudo` (visor-casas-3d.js L10837-10863) over a pool of what a kind of furniture holds.\n *\n * Order of the random draws, the contract the golden test holds: one for the count (`base`); for an abandoned container one more (empty or not); then a full\n * weighted order of the pool (one draw per entry, before the cut to the count); then one per kept entry for its quantity; and only for an abandoned and empty\n * container one last draw for the junk. Fortified: more entries, supplies weigh x2.5 and give +1. Abandoned: 40% empty, otherwise one entry, plus junk and,\n * in a fridge or freezer, spoiled food.\n */\nexport const rollExplicitPool = (pool: readonly PoolEntry[], state: FindState, query: FindsQuery, rules: ItemRules): PoolPick[] => {\n const settings = rules.finds.pool\n const n = countFor(pool, state, query.footprint ?? settings.defaultFootprint, query, rules)\n const isFortified = state === 'fortified'\n const candidates = pool.map((entry) => ({ entry, weight: isFortified && entry.isSupply ? entry.weight * settings.supplyWeight : entry.weight }))\n\n const picks: PoolPick[] = []\n for (const { entry } of weightedOrder(candidates, query.rng).slice(0, n)) {\n const [low, high] = entry.qty\n const bonus = isFortified && entry.isSupply ? settings.supplyExtraQty : 0\n picks.push({ itemId: entry.item, count: low + Math.floor(query.rng() * (high - low + 1)) + bonus, entry })\n }\n if (state === 'abandoned') {\n if (picks.length === 0 && query.rng() < settings.junkChance) picks.push({ itemId: settings.junkItem, count: 1 })\n if (settings.coldContainers.includes(query.container) || (query.key !== undefined && settings.coldContainers.includes(query.key))) picks.push({ itemId: settings.spoiledFoodItem, count: 1 })\n }\n return picks\n}\n","import type { ItemsCatalog } from '../catalog-types'\nimport type { ItemDef } from '../types'\n\nimport type { FindData, FindsQuery } from './types'\n\nconst WEARING_DURABILITY = ['condition', 'hits', 'cuts']\n\nconst hasCondition = (item: ItemDef): boolean => item.protection !== undefined || item.weapon !== undefined || WEARING_DURABILITY.includes(item.durability?.kind ?? '')\n\nconst between = (range: readonly [number, number], random: () => number): number => range[0] + random() * (range[1] - range[0])\n\n/** Age of food when it turns up: the days since the outbreak plus 0 to 60% of the shelf life. On day 0 that is the normal turnover of the shelf. One draw. */\nexport const spawnAgeDays = (item: ItemDef, outbreakDays: number, random: () => number, catalog: ItemsCatalog): number | undefined =>\n item.shelfLife === undefined ? undefined : outbreakDays + between(catalog.rules.spawn.ageShare, random) * item.shelfLife\n\nconst isRetailStock = (query: FindsQuery, catalog: ItemsCatalog): boolean => {\n const { spawn } = catalog.rules\n const isRetail = spawn.retailNew && (query.day ?? 0) <= 0 && spawn.retailPlaces.includes(query.place)\n return isRetail && (spawn.retailContainers.length === 0 || spawn.retailContainers.includes(query.container))\n}\n\n/**\n * The state an item has when it turns up, drawn after all the stacks of a container were chosen (so the choice itself matches the sources): food age, condition\n * by rarity (100 for shop stock on day 0), battery charge and how full a jerrycan is. Draws in that order, only what applies.\n */\nexport const spawnData = (item: ItemDef, query: FindsQuery, catalog: ItemsCatalog): FindData | undefined => {\n const { spawn } = catalog.rules\n const data: { ageDays?: number; condition?: number; charge?: number; fill?: number } = {}\n const ageDays = spawnAgeDays(item, query.day ?? 0, query.rng, catalog)\n if (ageDays !== undefined) data.ageDays = ageDays\n if (hasCondition(item)) data.condition = isRetailStock(query, catalog) ? 100 : Math.round(between(spawn.condition[item.rarity], query.rng))\n if (item.charge) data.charge = between(spawn.charge, query.rng)\n if (spawn.fillable.includes(item.id)) data.fill = between(spawn.fill, query.rng)\n return Object.keys(data).length > 0 ? data : undefined\n}\n","import { deepMerge } from '@adatechnology/game-contracts'\nimport type { DeepPartial } from '@adatechnology/game-contracts'\n\nimport type { ItemsCatalog } from '../catalog-types'\nimport type { ItemRules } from '../rules-types'\n\nimport { rollByCategory } from './category-roll'\nimport { rollExplicitPool } from './explicit-pool'\nimport { spawnData } from './spawn-state'\nimport type { FindStack, FindsProvider, FindsQuery } from './types'\n\nexport interface FindsProviderOptions {\n /** `team` (default): a container with an explicit pool in the catalog uses it; `none`: always the category roll. */\n pools?: 'team' | 'none'\n /** Changes to the `finds` and `spawn` rules for this provider. */\n rules?: DeepPartial<Pick<ItemRules, 'finds' | 'spawn'>>\n /** Names a host uses for a place, mapped to the place tags of the catalog (a floorplan `cabin` is a `lodge`). */\n placeAliases?: Readonly<Record<string, string>>\n /** Places where the explicit pools apply (the viewer's pools describe dwellings); the others use the category roll. Default: every place. */\n poolPlaces?: readonly string[]\n /** A container with neither a pool nor a table: `fallback` (default) uses the table of `rules.finds.fallbackContainer`, like the game; `pass` answers `null` so the host's own default applies. */\n unknownContainers?: 'fallback' | 'pass'\n}\n\n/**\n * What a container holds, as the floorplan's `FindsProvider` asks it. A container with an explicit pool (`query.key` or the container id) draws from it\n * (port of the viewer); otherwise the category roll of the game draws on the container table. Every stack is chosen first, in the order of the sources, and\n * only then each gets its state (age, condition, charge, fill) from the same generator. Items the catalog no longer has (removed by a game) are dropped\n * after the draws, so the others do not change.\n */\nexport function createFindsProvider(catalog: ItemsCatalog, options: FindsProviderOptions & { unknownContainers: 'pass' }): FindsProvider\nexport function createFindsProvider(catalog: ItemsCatalog, options?: FindsProviderOptions): { findsFor(query: FindsQuery): readonly FindStack[] }\nexport function createFindsProvider(catalog: ItemsCatalog, options: FindsProviderOptions = {}): FindsProvider {\n const rules = options.rules ? deepMerge(catalog.rules, options.rules) : catalog.rules\n const scoped: ItemsCatalog = rules === catalog.rules ? catalog : { ...catalog, rules }\n const usesPools = (options.pools ?? 'team') === 'team'\n\n const aliases = options.placeAliases ?? {}\n const poolPlaces = options.poolPlaces\n\n const passes = options.unknownContainers === 'pass'\n\n const choose = (query: FindsQuery): FindStack[] | null => {\n const pool = usesPools && (poolPlaces === undefined || poolPlaces.includes(query.place)) ? (scoped.pools.get(query.key ?? query.container) ?? scoped.pools.get(query.container)) : undefined\n if (!pool && passes && !scoped.containers.has(query.container)) return null\n if (!pool) return rollByCategory(scoped, query).map((itemId) => ({ itemId, count: 1 }))\n return rollExplicitPool(pool, rules.finds.states[query.state] ?? 'normal', query, rules).map(({ itemId, count }) => ({ itemId, count }))\n }\n\n return {\n findsFor(asked) {\n const query = aliases[asked.place] === undefined ? asked : { ...asked, place: aliases[asked.place] as string }\n const chosen = choose(query)\n if (chosen === null) return null\n return chosen\n .filter((stack) => scoped.has(stack.itemId))\n .map((stack) => {\n const data = spawnData(scoped.get(stack.itemId), query, scoped)\n return data ? { ...stack, data } : stack\n })\n },\n }\n}\n","import type { ColliderOptions, WorldCollider } from './types'\n\ninterface Box {\n ids: string[]\n object: string\n u0: number\n u1: number\n v0: number\n v1: number\n y0: number\n y1: number\n}\n\nconst volume = (b: Box): number => (b.u1 - b.u0) * (b.v1 - b.v0) * (b.y1 - b.y0)\nconst overlap = (a0: number, a1: number, b0: number, b1: number): number => Math.max(0, Math.min(a1, b1) - Math.max(a0, b0))\n\nfunction tryMerge(a: Box, b: Box, gap: number, slack: number): Box | null {\n if (a.u0 - gap > b.u1 || b.u0 - gap > a.u1) return null\n if (a.v0 - gap > b.v1 || b.v0 - gap > a.v1) return null\n if (a.y0 - gap > b.y1 || b.y0 - gap > a.y1) return null\n const u0 = Math.min(a.u0, b.u0)\n const u1 = Math.max(a.u1, b.u1)\n const v0 = Math.min(a.v0, b.v0)\n const v1 = Math.max(a.v1, b.v1)\n const y0 = Math.min(a.y0, b.y0)\n const y1 = Math.max(a.y1, b.y1)\n const hull = (u1 - u0) * (v1 - v0) * (y1 - y0)\n const common = overlap(a.u0, a.u1, b.u0, b.u1) * overlap(a.v0, a.v1, b.v0, b.v1) * overlap(a.y0, a.y1, b.y0, b.y1)\n const real = volume(a) + volume(b) - common\n if (hull > real * (1 + slack) + 1e-9) return null\n return { ids: [...a.ids, ...b.ids], object: a.object, u0, u1, v0, v1, y0, y1 }\n}\n\n/**\n * Union overlapping or adjacent colliders of the same object (same rotation) into fewer OBBs.\n * A merge happens only when the merged box adds at most `slack` extra volume over the real union, so coverage is never lost\n * and never grows noticeably. Colliders of different objects or rotations are left alone.\n */\nexport function mergeColliders(list: WorldCollider[], options: Pick<ColliderOptions, 'gap' | 'slack'> = {}): WorldCollider[] {\n const gap = options.gap ?? 0.02\n const slack = options.slack ?? 0.1\n const groups = new Map<string, WorldCollider[]>()\n for (const c of list) {\n const key = `${c.object}|${Math.round(c.ry * 1000)}`\n const g = groups.get(key)\n if (g) g.push(c)\n else groups.set(key, [c])\n }\n const out: WorldCollider[] = []\n for (const g of groups.values()) {\n const ry = g[0]?.ry ?? 0\n const cs = Math.cos(ry)\n const sn = Math.sin(ry)\n let boxes: Box[] = g.map((c) => {\n const u = c.cx * cs - c.cz * sn\n const v = c.cx * sn + c.cz * cs\n return { ids: [c.id], object: c.object, u0: u - c.hw, u1: u + c.hw, v0: v - c.hd, v1: v + c.hd, y0: c.y0, y1: c.y1 }\n })\n let changed = boxes.length > 1\n while (changed) {\n changed = false\n outer: for (let i = 0; i < boxes.length; i++) {\n for (let j = i + 1; j < boxes.length; j++) {\n const m = tryMerge(boxes[i] as Box, boxes[j] as Box, gap, slack)\n if (!m) continue\n boxes = boxes.filter((_, k) => k !== i && k !== j)\n boxes.push(m)\n changed = true\n break outer\n }\n }\n }\n for (const b of boxes) {\n const u = (b.u0 + b.u1) / 2\n const v = (b.v0 + b.v1) / 2\n out.push({\n id: b.ids.length === 1 ? (b.ids[0] as string) : `${b.ids[0]}+${b.ids.length - 1}`,\n object: b.object,\n cx: u * cs + v * sn,\n cz: -u * sn + v * cs,\n hw: (b.u1 - b.u0) / 2,\n hd: (b.v1 - b.v0) / 2,\n ry,\n y0: b.y0,\n y1: b.y1,\n })\n }\n }\n return out\n}\n","/** FNV-1a hash of a string to a 32-bit unsigned integer. */\nexport function hashString(text: string): number {\n let h = 2166136261\n for (let i = 0; i < text.length; i++) {\n h ^= text.charCodeAt(i)\n h = Math.imul(h, 16777619)\n }\n return h >>> 0\n}\n\n/** Deterministic random generator (mulberry32). Returns a function producing floats in [0, 1). Numbers and strings are accepted as seeds. */\nexport function rng(seed: number | string = 1): () => number {\n let a = (typeof seed === 'number' ? Math.floor(seed) >>> 0 : hashString(seed)) || 0x9e3779b9\n return () => {\n a = (a + 0x6d2b79f5) >>> 0\n let t = a\n t = Math.imul(t ^ (t >>> 15), t | 1)\n t ^= t + Math.imul(t ^ (t >>> 7), t | 61)\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n}\n","import { rng } from './rng'\nimport type { ItemCatalog, ItemStack, LootTables, SlotSpec, Vec3 } from './types'\n\n/** True when an item of `size` [w, h, d] fits in a slot of `max`; width and depth may be swapped (the item is turned). */\nexport function fitsSize(size: Vec3, max: Vec3): boolean {\n const eps = 1e-9\n if (size[1] > max[1] + eps) return false\n return (size[0] <= max[0] + eps && size[2] <= max[2] + eps) || (size[2] <= max[0] + eps && size[0] <= max[2] + eps)\n}\n\n/** Does the item type fit the slot. Types missing from the catalog are accepted (the host owns unknown items). */\nexport function fitsSlot(type: string, slot: SlotSpec, items?: ItemCatalog): boolean {\n const def = items?.[type]\n return def ? fitsSize(def.size, slot.max) : true\n}\n\n/**\n * Deterministic contents for a container: one entry per slot. A slot is filled with probability `fill`; the entry is a weighted pick\n * among the table entries whose item fits the slot. Same seed, same table and same slots always give the same result.\n */\nexport function generateLoot(\n tables: LootTables | undefined,\n tableId: string,\n slots: SlotSpec[],\n seed: number | string,\n items?: ItemCatalog,\n): Array<ItemStack | null> {\n const table = tables?.[tableId]\n const out: Array<ItemStack | null> = slots.map(() => null)\n if (!table) return out\n const rand = rng(seed)\n slots.forEach((slot, i) => {\n // Always draw the same number of random values per slot so a change in one slot does not shift the others.\n const roll = rand()\n const pick = rand()\n const q = rand()\n if (roll >= table.fill) return\n const candidates = table.entries.filter((e) => e.weight > 0 && fitsSlot(e.type, slot, items))\n const total = candidates.reduce((s, e) => s + e.weight, 0)\n if (total <= 0) return\n let r = pick * total\n let chosen = candidates[candidates.length - 1]\n for (const e of candidates) {\n r -= e.weight\n if (r < 0) {\n chosen = e\n break\n }\n }\n const [lo, hi] = chosen.qty ?? [1, 1]\n const def = items?.[chosen.type]\n const stackMax = def ? (def.stack ?? 1) : Number.POSITIVE_INFINITY\n const qty = Math.max(1, Math.min(stackMax, lo + Math.floor(q * (hi - lo + 1))))\n out[i] = { type: chosen.type, qty }\n })\n return out\n}\n","import type { InteractablesLocale, Strings } from './types'\n\n/** Engine-level prompts and messages. Content packs add their own keys through `WorldOptions.strings`. */\nexport const locales: Record<InteractablesLocale, Strings> = {\n en: {\n 'prompt.open': 'Open {name}',\n 'prompt.close': 'Close {name}',\n 'prompt.turn_on': 'Turn on {name}',\n 'prompt.turn_off': 'Turn off {name}',\n 'prompt.take': 'Take',\n 'prompt.sit': 'Sit',\n 'prompt.lie_down': 'Lie down',\n 'prompt.get_up': 'Get up',\n 'prompt.light': 'Light',\n 'prompt.change_channel': 'Change channel',\n 'prompt.read': 'Read',\n 'prompt.breaker_on': 'Switch on {circuit}',\n 'prompt.breaker_off': 'Switch off {circuit}',\n 'prompt.open_valve': 'Open valve',\n 'prompt.close_valve': 'Close valve',\n no_power: 'No power',\n no_gas: 'No gas',\n no_water: 'No water',\n locked: 'Locked',\n requires_open: 'Open it first',\n already_lit: 'Already lit',\n needs_ignition: 'Light it first',\n turn_on_first: 'Turn it on first',\n empty: 'Empty',\n full: 'Full',\n },\n 'pt-BR': {\n 'prompt.open': 'Abrir {name}',\n 'prompt.close': 'Fechar {name}',\n 'prompt.turn_on': 'Ligar {name}',\n 'prompt.turn_off': 'Desligar {name}',\n 'prompt.take': 'Pegar',\n 'prompt.sit': 'Sentar',\n 'prompt.lie_down': 'Deitar',\n 'prompt.get_up': 'Levantar',\n 'prompt.light': 'Acender',\n 'prompt.change_channel': 'Trocar de canal',\n 'prompt.read': 'Ler',\n 'prompt.breaker_on': 'Ligar {circuit}',\n 'prompt.breaker_off': 'Desligar {circuit}',\n 'prompt.open_valve': 'Abrir registro',\n 'prompt.close_valve': 'Fechar registro',\n no_power: 'Sem energia',\n no_gas: 'Sem gás',\n no_water: 'Sem água',\n locked: 'Trancado',\n requires_open: 'Abra primeiro',\n already_lit: 'Já está aceso',\n needs_ignition: 'Acenda primeiro',\n turn_on_first: 'Ligue primeiro',\n empty: 'Vazio',\n full: 'Cheio',\n },\n}\n\n/** Pick a built-in locale for an arbitrary tag (`pt`, `pt-br`, `en-GB`...). Unknown tags give `en`. */\nexport function pickLocale(locale: string | undefined): InteractablesLocale {\n if (!locale) return 'en'\n const l = locale.toLowerCase()\n if (l === 'pt-br' || l === 'pt') return 'pt-BR'\n return 'en'\n}\n\n/**\n * Merged table for a locale: built-in `en`, then the built-in table of `locale`, then the extra strings for `en` and for `locale`.\n * Lookup therefore falls back to English, and the caller never gets undefined for engine keys.\n */\nexport function resolveStrings(locale?: string, extra?: Partial<Record<InteractablesLocale, Strings>>): Strings {\n const loc = pickLocale(locale)\n return { ...locales.en, ...locales[loc], ...(extra?.en ?? {}), ...(loc === 'en' ? {} : (extra?.[loc] ?? {})) }\n}\n\n/** Replace `{name}` placeholders. Unknown placeholders are left untouched. */\nexport function format(text: string, vars?: Record<string, string | number>): string {\n if (!vars) return text\n return text.replace(/\\{(\\w+)\\}/g, (m, k: string) => (k in vars ? String(vars[k]) : m))\n}\n","import type { Motion, Transform, Vec3 } from './types'\n\nexport interface Pose {\n /** Translation relative to the pivot. */\n position: Vec3\n /** Rotation around `axis` (in the part frame) by `angle` radians. */\n rotation: { axis: Vec3; angle: number }\n scale: Vec3\n}\n\nconst DEFAULT_AXIS: Vec3 = [0, 1, 0]\n\n/**\n * Pose of a part at animation value `t` (0..1), relative to its pivot, so any renderer can apply it:\n * `position`, then `rotation` around the pivot, then `scale` along the axis.\n */\nexport function poseOf(motion: Motion, t: number): Pose {\n const tt = Math.max(0, Math.min(1, t))\n const axis = motion.axis ?? DEFAULT_AXIS\n const pose: Pose = { position: [0, 0, 0], rotation: { axis: [axis[0], axis[1], axis[2]], angle: 0 }, scale: [1, 1, 1] }\n switch (motion.kind) {\n case 'hinge':\n case 'rocker':\n case 'knob':\n pose.rotation.angle = (motion.angle ?? Math.PI / 2) * tt\n break\n case 'slide': {\n const d = (motion.distance ?? 0.4) * tt\n pose.position = [axis[0] * d, axis[1] * d, axis[2] * d]\n break\n }\n case 'scale': {\n const k = 1 + ((motion.scale ?? 0.2) - 1) * tt\n pose.scale = [axis[0] !== 0 ? k : 1, axis[1] !== 0 ? k : 1, axis[2] !== 0 ? k : 1]\n break\n }\n }\n return pose\n}\n\n/** Rotate a vector around +y by `ry` radians (same convention as three.js `rotation.y`). */\nexport function rotY(v: Vec3, ry: number): Vec3 {\n const c = Math.cos(ry)\n const s = Math.sin(ry)\n return [c * v[0] + s * v[2], v[1], -s * v[0] + c * v[2]]\n}\n\n/** Object-local point to world space. */\nexport function localToWorld(transform: Transform, v: Vec3): Vec3 {\n const r = rotY(v, transform.ry)\n return [transform.x + r[0], transform.y + r[1], transform.z + r[2]]\n}\n\n/** World point to object-local space. */\nexport function worldToLocal(transform: Transform, v: Vec3): Vec3 {\n return rotY([v[0] - transform.x, v[1] - transform.y, v[2] - transform.z], -transform.ry)\n}\n\n/** Rodrigues rotation of `v` around a unit-length-normalised `axis` by `angle`. */\nexport function rotateAxis(v: Vec3, axis: Vec3, angle: number): Vec3 {\n const len = Math.hypot(axis[0], axis[1], axis[2]) || 1\n const k: Vec3 = [axis[0] / len, axis[1] / len, axis[2] / len]\n const c = Math.cos(angle)\n const s = Math.sin(angle)\n const dot = k[0] * v[0] + k[1] * v[1] + k[2] * v[2]\n const cross: Vec3 = [k[1] * v[2] - k[2] * v[1], k[2] * v[0] - k[0] * v[2], k[0] * v[1] - k[1] * v[0]]\n return [v[0] * c + cross[0] * s + k[0] * dot * (1 - c), v[1] * c + cross[1] * s + k[1] * dot * (1 - c), v[2] * c + cross[2] * s + k[2] * dot * (1 - c)]\n}\n\nexport function clamp(v: number, lo: number, hi: number): number {\n return v < lo ? lo : v > hi ? hi : v\n}\n\nexport function dist3(a: Vec3, b: Vec3): number {\n return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2])\n}\n","import { mergeColliders } from './colliders'\nimport { fitsSlot, generateLoot } from './loot'\nimport { format, resolveStrings } from './locale'\nimport { clamp, dist3, localToWorld, rotateAxis, rotY, worldToLocal } from './math'\nimport type {\n ActiveEmitter,\n BurnerSpec,\n ColliderOptions,\n DeviceSpec,\n FixtureSpec,\n FuelSourceSpec,\n Interactable,\n InteractionSpec,\n ItemStack,\n ObjectSpec,\n PartSpec,\n SeatResult,\n SoftSpec,\n TankSpec,\n TargetOptions,\n Transform,\n Vec3,\n World,\n WorldCollider,\n WorldEvent,\n WorldObject,\n WorldOptions,\n WorldState,\n} from './types'\n\n// ---------------------------------------------------------------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------------------------------------------------------------\n\nconst DEFAULT_ACTOR = 'player'\nconst DEFAULT_RADIUS = 1.2\nconst AUTO_RANGE = 2\nconst SMOKE_TAIL = 20\nconst FLUSH_SECONDS = 2\nconst ACTOR_HEIGHT = 1.7\nconst PUSH_GAIN = 1.2\nconst SWEEP_STEPS = 24\nconst FLOW_DEFAULTS: Record<string, number> = { tap: 6, shower: 8, utility_sink: 6, flush: 0, hose: 10, filter: 1 }\n\n// ---------------------------------------------------------------------------------------------------------------------------------\n// Internal state\n// ---------------------------------------------------------------------------------------------------------------------------------\n\ninterface PartState {\n spec: PartSpec\n t: number\n target: number\n maxT: number\n}\n\ninterface DeviceState {\n spec: DeviceSpec\n on: boolean\n channel: number\n}\n\ninterface FuelState {\n spec: FuelSourceSpec\n level: number\n}\n\ninterface BurnerState {\n spec: BurnerSpec\n on: boolean\n lit: boolean\n /** Seconds of smoke left after a solid-fuel fire went out. */\n smoke: number\n}\n\ninterface TankState {\n spec: TankSpec\n level: number\n}\n\ninterface FixtureState {\n spec: FixtureSpec\n on: boolean\n flowing: boolean\n /** Delivered / wanted in the last step, 0..1. */\n frac: number\n /** Seconds left of a flush. */\n timer: number\n}\n\ninterface SoftState {\n spec: SoftSpec\n ax: number\n az: number\n vax: number\n vaz: number\n phase: number\n}\n\ninterface DamageState {\n max: number\n hp: number\n}\n\ninterface ObjState {\n id: string\n public: WorldObject\n spec: ObjectSpec\n parts: Map<string, PartState>\n contents: Map<string, Array<ItemStack | null>>\n devices: Map<string, DeviceState>\n fuels: Map<string, FuelState>\n burners: Map<string, BurnerState>\n tanks: Map<string, TankState>\n fixtures: Map<string, FixtureState>\n soft: Map<string, SoftState>\n hp: Map<string, DamageState>\n interactions: InteractionSpec[]\n}\n\nfunction cloneStack(s: ItemStack | null): ItemStack | null {\n return s ? { type: s.type, qty: s.qty, ...(s.data ? { data: JSON.parse(JSON.stringify(s.data)) as Record<string, unknown> } : {}) } : null\n}\n\nfunction humanize(id: string): string {\n const s = id.replace(/[_-]+/g, ' ').trim()\n return s.charAt(0).toUpperCase() + s.slice(1)\n}\n\n// ---------------------------------------------------------------------------------------------------------------------------------\n// World\n// ---------------------------------------------------------------------------------------------------------------------------------\n\nexport function createWorld(options: WorldOptions = {}): World {\n const seed = options.seed ?? 1\n const objs = new Map<string, ObjState>()\n const breakers = new Map<string, boolean>()\n const seats = new Map<string, { object: string; seat: string }>()\n const plugs = new Map<string, string | null>()\n let locale = options.locale ?? 'en'\n let table = resolveStrings(locale, options.strings)\n let grid = options.grid ?? true\n let rain = options.rain ?? 0\n let wind: [number, number] = options.wind ? [options.wind[0], options.wind[1]] : [0, 0]\n let defaultTank: string | null = null\n let defaultTankExplicit = false\n let time = 0\n\n // ------------------------------------------------------------------ text & events\n function t(key: string, vars?: Record<string, string | number>): string {\n return format(table[key] ?? key, vars)\n }\n\n function emit(e: WorldEvent): void {\n options.onEvent?.(e)\n }\n\n function message(key: string): void {\n emit({ type: 'message', key, text: t(key) })\n }\n\n // ------------------------------------------------------------------ lookup helpers\n function gid(o: ObjState, local: string): string {\n return `${o.id}:${local}`\n }\n\n /** Find the first entity matching a world-wide id `objectId:localId`. */\n function findGlobal<T>(id: string, pick: (o: ObjState, local: string) => T | undefined): { o: ObjState; value: T; local: string } | null {\n for (const o of objs.values()) {\n if (!id.startsWith(o.id + ':')) continue\n const local = id.slice(o.id.length + 1)\n const value = pick(o, local)\n if (value !== undefined) return { o, value, local }\n }\n return null\n }\n\n function objectOrThrow(id: string): ObjState {\n const o = objs.get(id)\n if (!o) throw new Error(`Unknown object \"${id}\"`)\n return o\n }\n\n function originOf(o: ObjState): Vec3 {\n const tr = o.public.transform\n return [tr.x, tr.y, tr.z]\n }\n\n function toWorld(o: ObjState, v: Vec3): Vec3 {\n return localToWorld(o.public.transform, v)\n }\n\n // ------------------------------------------------------------------ parts\n function openThreshold(p: PartState): number {\n return Math.min(0.5, p.maxT * 0.9)\n }\n\n function isOpen(o: ObjState, partId: string | undefined): boolean {\n if (!partId) return true\n const p = o.parts.get(partId)\n if (!p) return true\n return p.maxT >= 0.1 && p.t >= openThreshold(p)\n }\n\n function targetIsOpen(p: PartState): boolean {\n return p.maxT >= 0.1 && p.target >= openThreshold(p)\n }\n\n function writeT(o: ObjState, p: PartState, v: number): void {\n p.t = v\n o.public.parts[p.spec.id] = v\n }\n\n function gated(o: ObjState, p: PartState): boolean {\n return !!p.spec.requires && o.parts.has(p.spec.requires) && !isOpen(o, p.spec.requires)\n }\n\n function setOnePart(o: ObjState, p: PartState, target: number, instant: boolean): void {\n const next = clamp(target, 0, p.maxT)\n const changed = next !== p.target\n p.target = next\n if (instant) writeT(o, p, next)\n if (changed || instant) emit({ type: 'part', object: o.id, part: p.spec.id, t: p.t, target: p.target })\n }\n\n function setPartTarget(o: ObjState, partId: string, target: number, instant = false): void {\n const p = o.parts.get(partId)\n if (!p) return\n const group = p.spec.group\n const members = group ? [...o.parts.values()].filter((q) => q.spec.group === group) : [p]\n for (const q of members) {\n setOne(o, q, target, instant)\n }\n }\n\n function setOne(o: ObjState, p: PartState, target: number, instant: boolean): void {\n setOnePart(o, p, target, instant)\n // a part that other parts require closes them with it (the drawer behind a door closes with the door)\n if (!targetIsOpen(p)) {\n for (const q of o.parts.values()) {\n if (q.spec.requires === p.spec.id && q.target > 0) setPartTarget(o, q.spec.id, 0, instant)\n }\n }\n }\n\n /** Frame of a part right now: rotation (axis in object-local, angle) and translation, both about its pivot. */\n function partFrame(o: ObjState, partId: string | undefined): { pivot: Vec3; axis: Vec3; angle: number; offset: Vec3 } | null {\n if (!partId) return null\n const p = o.parts.get(partId)\n if (!p) return null\n const m = p.spec.motion\n const axis = rotY(m.axis ?? [0, 1, 0], p.spec.ry ?? 0)\n const frame = { pivot: p.spec.pivot, axis, angle: 0, offset: [0, 0, 0] as Vec3 }\n if (m.kind === 'slide') {\n const d = (m.distance ?? 0.4) * p.t\n frame.offset = [axis[0] * d, axis[1] * d, axis[2] * d]\n } else if (m.kind !== 'scale') {\n frame.angle = (m.angle ?? Math.PI / 2) * p.t\n }\n return frame\n }\n\n function applyPart(o: ObjState, partId: string | undefined, local: Vec3): Vec3 {\n const f = partFrame(o, partId)\n if (!f) return local\n const rel: Vec3 = [local[0] - f.pivot[0], local[1] - f.pivot[1], local[2] - f.pivot[2]]\n const r = f.angle !== 0 ? rotateAxis(rel, f.axis, f.angle) : rel\n return [f.pivot[0] + r[0] + f.offset[0], f.pivot[1] + r[1] + f.offset[1], f.pivot[2] + r[2] + f.offset[2]]\n }\n\n /** Moves the full closed-pose slab of a part and returns the largest t whose pose keeps every sampled point clear. */\n function fitBounds(o: ObjState, p: PartState, b: NonNullable<PartSpec['bounds']>, blockedAt: (x: number, z: number) => boolean): number {\n const STEP = 0.05\n const ANGLES = 48\n const m = p.spec.motion\n const axis = rotY(m.axis ?? [0, 1, 0], p.spec.ry ?? 0)\n const lo: Vec3 = [Math.min(b[0], b[3]), Math.min(b[1], b[4]), Math.min(b[2], b[5])]\n const hi: Vec3 = [Math.max(b[0], b[3]), Math.max(b[1], b[4]), Math.max(b[2], b[5])]\n const axisPoints = (i: number): number[] => {\n const n = Math.max(1, Math.ceil((hi[i] - lo[i]) / STEP))\n return Array.from({ length: n + 1 }, (_, k) => lo[i] + ((hi[i] - lo[i]) * k) / n)\n }\n const xs = axisPoints(0)\n const ys = axisPoints(1)\n const zs = axisPoints(2)\n // `blocked` only sees x/z, so only the floor footprint matters. About a vertical axis (and for slides) y never changes\n // the footprint, so the x/z outline is enough; otherwise the footprint boundary is the projection of the box edges.\n const flat = m.kind === 'slide' || Math.abs(axis[1]) > 0.999\n const seen = new Set<string>()\n const pts: Vec3[] = []\n const add = (x: number, y: number, z: number): void => {\n const key = `${x.toFixed(5)},${y.toFixed(5)},${z.toFixed(5)}`\n if (seen.has(key)) return\n seen.add(key)\n pts.push([x, y, z])\n }\n if (flat) {\n for (const x of xs) for (const z of [lo[2], hi[2]]) add(x, lo[1], z)\n for (const z of zs) for (const x of [lo[0], hi[0]]) add(x, lo[1], z)\n } else {\n for (const x of xs) for (const y of [lo[1], hi[1]]) for (const z of [lo[2], hi[2]]) add(x, y, z)\n for (const y of ys) for (const x of [lo[0], hi[0]]) for (const z of [lo[2], hi[2]]) add(x, y, z)\n for (const z of zs) for (const x of [lo[0], hi[0]]) for (const y of [lo[1], hi[1]]) add(x, y, z)\n }\n const tr = o.public.transform\n const pivot = p.spec.pivot\n for (let i = 1; i <= ANGLES; i++) {\n const tt = i / ANGLES\n const angle = m.kind === 'slide' ? 0 : (m.angle ?? Math.PI / 2) * tt\n const d = m.kind === 'slide' ? (m.distance ?? 0.4) * tt : 0\n for (const [x, y, z] of pts) {\n let q: Vec3 = [x, y, z]\n if (angle !== 0) {\n const r = rotateAxis([x - pivot[0], y - pivot[1], z - pivot[2]], axis, angle)\n q = [pivot[0] + r[0], pivot[1] + r[1], pivot[2] + r[2]]\n }\n const w = localToWorld(tr, [q[0] + axis[0] * d, 0, q[2] + axis[2] * d])\n if (blockedAt(w[0], w[2])) return (i - 1) / ANGLES\n }\n }\n return 1\n }\n\n function fitSweep(objectId: string, partId: string, blocked: (x: number, z: number) => boolean): number {\n const o = objectOrThrow(objectId)\n const p = o.parts.get(partId)\n if (!p) throw new Error(`Unknown part \"${partId}\" on \"${objectId}\"`)\n const sweep = p.spec.sweep\n let result = 1\n const bounds = p.spec.bounds\n const mk = p.spec.motion.kind\n if (bounds && (mk === 'hinge' || mk === 'rocker' || mk === 'knob' || mk === 'slide')) {\n result = fitBounds(o, p, bounds, blocked)\n } else if (sweep) {\n const x0 = Math.min(sweep[0], sweep[2])\n const x1 = Math.max(sweep[0], sweep[2])\n const z0 = Math.min(sweep[1], sweep[3])\n const z1 = Math.max(sweep[1], sweep[3])\n const tr = o.public.transform\n const hit = (lx: number, lz: number): boolean => {\n const w = localToWorld(tr, [lx, 0, lz])\n return blocked(w[0], w[2])\n }\n const m = p.spec.motion\n const axis = rotY(m.axis ?? [0, 1, 0], p.spec.ry ?? 0)\n if (m.kind === 'hinge' || m.kind === 'rocker' || m.kind === 'knob') {\n if (Math.abs(axis[1]) >= 0.5) {\n const angle = m.angle ?? Math.PI / 2\n const total = -angle * Math.sign(axis[1])\n const [px, , pz] = p.spec.pivot\n const cx = (x0 + x1) / 2\n const cz = (z0 + z1) / 2\n const w = x1 - x0\n const d = z1 - z0\n const radius = Math.abs(angle) > 1.7 ? Math.min(w, d) : Math.max(w, d)\n const tol = 0.05\n const inRect = (x: number, z: number) => x >= x0 - tol && x <= x1 + tol && z >= z0 - tol && z <= z1 + tol\n const centered = Math.hypot(cx - px, cz - pz) < 1e-6\n const mid = Math.atan2(cz - pz, cx - px)\n for (let i = 1; i <= SWEEP_STEPS && result === 1; i++) {\n const tt = i / SWEEP_STEPS\n let isHit = false\n if (centered) {\n for (let x = x0; x <= x1 + 1e-9 && !isHit; x += 0.1) for (let z = z0; z <= z1 + 1e-9 && !isHit; z += 0.1) isHit = hit(x, z)\n } else {\n const phi = mid - total / 2 + total * tt\n const steps = Math.max(2, Math.ceil(radius / 0.1))\n for (let k = 1; k <= steps && !isHit; k++) {\n const r = (radius * k) / steps\n const x = px + Math.cos(phi) * r\n const z = pz + Math.sin(phi) * r\n if (inRect(x, z)) isHit = hit(x, z)\n }\n }\n if (isHit) result = (i - 1) / SWEEP_STEPS\n }\n }\n } else if (m.kind === 'slide') {\n const len = Math.hypot(axis[0], axis[2])\n if (len > 1e-6) {\n const dx = axis[0] / len\n const dz = axis[2] / len\n const proj = [x0 * dx + z0 * dz, x1 * dx + z0 * dz, x0 * dx + z1 * dz, x1 * dx + z1 * dz]\n const pmin = Math.min(...proj)\n const pmax = Math.max(...proj)\n const nx = Math.max(1, Math.ceil((x1 - x0) / 0.1))\n const nz = Math.max(1, Math.ceil((z1 - z0) / 0.1))\n for (let i = 1; i <= SWEEP_STEPS && result === 1; i++) {\n const limit = pmin + (pmax - pmin) * (i / SWEEP_STEPS)\n let isHit = false\n for (let a = 0; a <= nx && !isHit; a++) {\n for (let b = 0; b <= nz && !isHit; b++) {\n const x = x0 + ((x1 - x0) * a) / nx\n const z = z0 + ((z1 - z0) * b) / nz\n if (x * dx + z * dz <= limit + 1e-9 && hit(x, z)) isHit = true\n }\n }\n if (isHit) result = (i - 1) / SWEEP_STEPS\n }\n }\n }\n }\n p.maxT = result\n if (p.target > result) p.target = result\n if (p.t > result) writeT(o, p, result)\n return result\n }\n\n // ------------------------------------------------------------------ containers\n function containerOf(o: ObjState, id: string) {\n return o.spec.containers?.find((c) => c.id === id)\n }\n\n function accessible(objectId: string, containerId: string): boolean {\n const o = objs.get(objectId)\n if (!o) return false\n const c = containerOf(o, containerId)\n if (!c) return false\n return isOpen(o, c.access)\n }\n\n function contents(objectId: string, containerId: string): Array<ItemStack | null> {\n return (objs.get(objectId)?.contents.get(containerId) ?? []).map(cloneStack)\n }\n\n function take(objectId: string, containerId: string, slot: number, actor: string = DEFAULT_ACTOR): ItemStack | null {\n const o = objs.get(objectId)\n if (!o) return null\n const list = o.contents.get(containerId)\n if (!list || !accessible(objectId, containerId)) return null\n const stack = list[slot]\n if (!stack) return null\n const def = options.items?.[stack.type]\n if (def && def.pickable === false) return null\n list[slot] = null\n emit({ type: 'pickup', object: objectId, container: containerId, slot, stack: cloneStack(stack) as ItemStack, actor })\n return stack\n }\n\n function put(objectId: string, containerId: string, stack: ItemStack): number | null {\n const o = objs.get(objectId)\n if (!o) return null\n const c = containerOf(o, containerId)\n const list = o.contents.get(containerId)\n if (!c || !list || !accessible(objectId, containerId)) return null\n const maxStack = options.items?.[stack.type]?.stack ?? 1\n for (let i = 0; i < list.length; i++) {\n const cur = list[i]\n if (cur && cur.type === stack.type && !cur.data && !stack.data && cur.qty + stack.qty <= maxStack) {\n cur.qty += stack.qty\n return i\n }\n }\n for (let i = 0; i < list.length; i++) {\n if (!list[i] && fitsSlot(stack.type, c.slots[i], options.items)) {\n list[i] = cloneStack(stack)\n return i\n }\n }\n message('full')\n return null\n }\n\n function slotPosition(objectId: string, containerId: string, slot: number): Vec3 {\n const o = objectOrThrow(objectId)\n const c = containerOf(o, containerId)\n const s = c?.slots[slot]\n if (!c || !s) throw new Error(`Unknown slot ${slot} of \"${containerId}\" on \"${objectId}\"`)\n return toWorld(o, applyPart(o, s.part, s.pos))\n }\n\n // ------------------------------------------------------------------ power\n function rebuildPlugs(): void {\n plugs.clear()\n const sockets: Array<{ id: string; pos: Vec3; circuit?: string; local: string }> = []\n for (const o of objs.values()) {\n for (const s of o.spec.sockets ?? []) sockets.push({ id: gid(o, s.id), local: s.id, pos: toWorld(o, s.pos), circuit: s.circuit })\n }\n const used = new Set<string>()\n const autos: Array<{ o: ObjState; d: DeviceState }> = []\n for (const o of objs.values()) {\n for (const d of o.devices.values()) {\n const key = gid(o, d.spec.id)\n const want = d.spec.socket\n if (want === undefined || d.spec.circuit) {\n plugs.set(key, null)\n } else if (want === 'auto') {\n plugs.set(key, null)\n autos.push({ o, d })\n } else {\n const s = sockets.find((x) => x.id === want) ?? sockets.find((x) => x.id === gid(o, want)) ?? sockets.find((x) => x.local === want)\n plugs.set(key, s ? s.id : null)\n if (s) used.add(s.id)\n }\n }\n }\n for (const { o, d } of autos) {\n const from = d.spec.plug ? toWorld(o, d.spec.plug) : originOf(o)\n let best: (typeof sockets)[number] | null = null\n let bestD = AUTO_RANGE\n for (const s of sockets) {\n if (used.has(s.id)) continue\n const dd = dist3(from, s.pos)\n if (dd <= bestD) {\n best = s\n bestD = dd\n }\n }\n if (best) {\n used.add(best.id)\n plugs.set(gid(o, d.spec.id), best.id)\n }\n }\n }\n\n function breaker(circuit: string): boolean {\n return breakers.get(circuit) ?? true\n }\n\n function devicePowered(o: ObjState, d: DeviceState): boolean {\n if (d.spec.electric === false) return true\n if (!grid) return false\n if (d.spec.circuit) return breaker(d.spec.circuit)\n if (d.spec.socket === undefined) return false\n const sid = plugs.get(gid(o, d.spec.id))\n if (!sid) return false\n const found = findGlobal(sid, (oo, local) => oo.spec.sockets?.find((s) => s.id === local))\n if (!found) return false\n return found.value.circuit ? breaker(found.value.circuit) : true\n }\n\n function getDevice(objectId: string, deviceId: string): { o: ObjState; d: DeviceState } | null {\n const o = objs.get(objectId)\n const d = o?.devices.get(deviceId)\n return o && d ? { o, d } : null\n }\n\n function powerSnapshot(): Map<string, boolean> {\n const m = new Map<string, boolean>()\n for (const o of objs.values()) for (const d of o.devices.values()) m.set(gid(o, d.spec.id), d.on && devicePowered(o, d))\n return m\n }\n\n function emitPowerChanges(before: Map<string, boolean>): void {\n for (const o of objs.values()) {\n for (const d of o.devices.values()) {\n const key = gid(o, d.spec.id)\n const now = d.on && devicePowered(o, d)\n if (before.get(key) !== now && d.on) emit({ type: 'device', object: o.id, device: d.spec.id, on: d.on, powered: devicePowered(o, d), channel: d.spec.kind === 'screen' ? d.channel : undefined })\n }\n }\n }\n\n function setDeviceOn(o: ObjState, d: DeviceState, on: boolean): void {\n d.on = on\n if (d.spec.part) setPartTarget(o, d.spec.part, on ? 1 : 0)\n const powered = devicePowered(o, d)\n emit({ type: 'device', object: o.id, device: d.spec.id, on, powered, channel: d.spec.kind === 'screen' ? d.channel : undefined })\n if (on && !powered) message('no_power')\n }\n\n function setDevice(objectId: string, deviceId: string, on: boolean): boolean {\n const f = getDevice(objectId, deviceId)\n if (!f) return false\n if (f.d.on !== on) setDeviceOn(f.o, f.d, on)\n return true\n }\n\n function setFixture(objectId: string, fixtureId: string, on: boolean): boolean {\n const fx = objs.get(objectId)?.fixtures.get(fixtureId)\n if (!fx) return false\n if (fx.on !== on) toggleFixture(objs.get(objectId) as ObjState, fixtureId)\n return fx.on === on\n }\n\n function setBurner(objectId: string, burnerId: string, on: boolean): boolean {\n const o = objs.get(objectId)\n const b = o?.burners.get(burnerId)\n if (!o || !b) return false\n const isOn = b.on || b.lit\n if (on === isOn) return true\n if (!on) return toggleBurner(o, burnerId)\n return b.spec.needsIgnition ? ignite(o, burnerId) : toggleBurner(o, burnerId)\n }\n\n function resolveDevice(o: ObjState, id: string): { o: ObjState; d: DeviceState } | null {\n if (id.includes(':')) {\n const f = findGlobal(id, (oo, local) => oo.devices.get(local))\n return f ? { o: f.o, d: f.value } : null\n }\n const local = o.devices.get(id)\n if (local) return { o, d: local }\n for (const oo of objs.values()) {\n const d = oo.devices.get(id)\n if (d) return { o: oo, d }\n }\n return null\n }\n\n function switchIsOn(o: ObjState, spec: { part?: string; controls: string[] }): boolean {\n if (spec.part && o.parts.has(spec.part)) return targetIsOpen(o.parts.get(spec.part) as PartState)\n return spec.controls.some((id) => resolveDevice(o, id)?.d.on)\n }\n\n function toggleSwitch(o: ObjState, id: string): boolean {\n const sw = o.spec.switches?.find((s) => s.id === id)\n if (!sw) return false\n const on = !switchIsOn(o, sw)\n if (sw.part) setPartTarget(o, sw.part, on ? 1 : 0)\n let unpowered = false\n for (const ref of sw.controls) {\n const r = resolveDevice(o, ref)\n if (!r) continue\n r.d.on = on\n if (r.d.spec.part) setPartTarget(r.o, r.d.spec.part, on ? 1 : 0)\n const powered = devicePowered(r.o, r.d)\n if (on && !powered) unpowered = true\n emit({ type: 'device', object: r.o.id, device: r.d.spec.id, on, powered })\n }\n emit({ type: 'switch', object: o.id, switch: sw.id, on })\n if (unpowered) message('no_power')\n return true\n }\n\n // ------------------------------------------------------------------ fuel\n function resolveFuel(o: ObjState, b: BurnerState): { o: ObjState; f: FuelState } | null {\n const want = b.spec.fuel\n if (want === 'auto') {\n const from = toWorld(o, b.spec.pos)\n let best: { o: ObjState; f: FuelState } | null = null\n let bestD = AUTO_RANGE\n for (const oo of objs.values()) {\n for (const f of oo.fuels.values()) {\n if (f.spec.kind !== 'gas') continue\n const d = dist3(from, originOf(oo))\n if (d <= bestD) {\n best = { o: oo, f }\n bestD = d\n }\n }\n }\n return best\n }\n if (want.includes(':')) {\n const f = findGlobal(want, (oo, local) => oo.fuels.get(local))\n return f ? { o: f.o, f: f.value } : null\n }\n const local = o.fuels.get(want)\n return local ? { o, f: local } : null\n }\n\n function valveOpen(o: ObjState, f: FuelState): boolean {\n const v = f.spec.valve\n if (!v || !o.parts.has(v)) return true\n return targetIsOpen(o.parts.get(v) as PartState)\n }\n\n function canBurn(b: BurnerState, ref: { o: ObjState; f: FuelState } | null): boolean {\n return !!ref && ref.f.level > 0 && valveOpen(ref.o, ref.f)\n }\n\n function isSolid(ref: { f: FuelState } | null): boolean {\n return !!ref && ref.f.spec.kind !== 'gas'\n }\n\n function emitBurner(o: ObjState, b: BurnerState): void {\n emit({ type: 'burner', object: o.id, burner: b.spec.id, on: b.on, lit: b.lit })\n }\n\n function extinguish(o: ObjState, b: BurnerState, turnOff: boolean): void {\n const ref = resolveFuel(o, b)\n const wasLit = b.lit\n b.lit = false\n if (turnOff) b.on = false\n if (b.spec.part) setPartTarget(o, b.spec.part, b.on ? 1 : 0)\n if (wasLit && isSolid(ref)) b.smoke = SMOKE_TAIL\n emitBurner(o, b)\n }\n\n function toggleBurner(o: ObjState, id: string): boolean {\n const b = o.burners.get(id)\n if (!b) return false\n if (b.on || b.lit) {\n extinguish(o, b, true)\n return true\n }\n if (b.spec.needsIgnition) return false\n const ref = resolveFuel(o, b)\n b.on = true\n b.lit = canBurn(b, ref)\n if (b.lit) b.smoke = 0\n if (b.spec.part) setPartTarget(o, b.spec.part, 1)\n emitBurner(o, b)\n if (!b.lit) message(ref && ref.f.level <= 0 ? 'empty' : 'no_gas')\n return true\n }\n\n function ignite(o: ObjState, id: string): boolean {\n const b = o.burners.get(id)\n if (!b || b.lit) return false\n const ref = resolveFuel(o, b)\n if (!canBurn(b, ref)) {\n message(ref && ref.f.level <= 0 ? 'empty' : 'no_gas')\n return false\n }\n b.on = true\n b.lit = true\n b.smoke = 0\n if (b.spec.part) setPartTarget(o, b.spec.part, 1)\n emitBurner(o, b)\n return true\n }\n\n function toggleFuel(o: ObjState, id: string): boolean {\n const f = o.fuels.get(id)\n if (!f || !f.spec.valve || !o.parts.has(f.spec.valve)) return false\n const p = o.parts.get(f.spec.valve) as PartState\n setPartTarget(o, f.spec.valve, targetIsOpen(p) ? 0 : 1)\n return true\n }\n\n // ------------------------------------------------------------------ water\n function resolveTank(o: ObjState, id: string | undefined): { o: ObjState; t: TankState } | null {\n const want = id === undefined || id === 'auto' ? defaultTank : id\n if (!want) return null\n const f = findGlobal(want, (oo, local) => oo.tanks.get(local))\n if (f) return { o: f.o, t: f.value }\n const local = o.tanks.get(want)\n return local ? { o, t: local } : null\n }\n\n function drawWater(o: ObjState, fx: FixtureState, liters: number): number {\n if (liters <= 0) return 0\n if (options.waterSupply) return Math.max(0, Math.min(liters, options.waterSupply.draw(gid(o, fx.spec.id), liters)))\n const tank = resolveTank(o, fx.spec.tank)\n if (!tank) return 0\n const got = Math.min(liters, tank.t.level)\n tank.t.level -= got\n return got\n }\n\n function emitFixture(o: ObjState, fx: FixtureState): void {\n emit({ type: 'fixture', object: o.id, fixture: fx.spec.id, on: fx.on, flowing: fx.flowing })\n }\n\n function flowOf(fx: FixtureState): number {\n return fx.spec.flow ?? FLOW_DEFAULTS[fx.spec.kind] ?? 6\n }\n\n function stepFixture(o: ObjState, fx: FixtureState, dt: number): void {\n if (!fx.on) return\n if (fx.spec.kind === 'flush') {\n fx.timer -= dt\n if (fx.timer <= 0) {\n fx.on = false\n fx.flowing = false\n fx.frac = 0\n if (fx.spec.part) setPartTarget(o, fx.spec.part, 0)\n emitFixture(o, fx)\n }\n return\n }\n if (dt <= 0) return\n const wanted = (flowOf(fx) / 60) * dt\n const got = drawWater(o, fx, wanted)\n const flowing = got > 1e-9\n fx.frac = wanted > 0 ? Math.min(1, got / wanted) : 0\n if (flowing !== fx.flowing) {\n fx.flowing = flowing\n emitFixture(o, fx)\n }\n }\n\n function toggleFixture(o: ObjState, id: string): boolean {\n const fx = o.fixtures.get(id)\n if (!fx) return false\n if (fx.on) {\n fx.on = false\n fx.flowing = false\n fx.frac = 0\n if (fx.spec.part) setPartTarget(o, fx.spec.part, 0)\n emitFixture(o, fx)\n return true\n }\n fx.on = true\n if (fx.spec.part) setPartTarget(o, fx.spec.part, 1)\n if (fx.spec.kind === 'flush') {\n const volume = fx.spec.volume ?? 6\n const got = drawWater(o, fx, volume)\n fx.flowing = got > 1e-9\n fx.frac = fx.flowing ? 1 : 0\n fx.timer = FLUSH_SECONDS\n if (!fx.flowing) {\n fx.on = false\n if (fx.spec.part) setPartTarget(o, fx.spec.part, 0)\n }\n } else {\n fx.flowing = false\n fx.frac = 0\n stepFixture(o, fx, 1 / 60)\n }\n emitFixture(o, fx)\n if (!fx.flowing) message('no_water')\n return true\n }\n\n // ------------------------------------------------------------------ seats\n function seatTaken(objectId: string, seatId: string): string | null {\n for (const [actor, s] of seats) if (s.object === objectId && s.seat === seatId) return actor\n return null\n }\n\n function stand(actor: string = DEFAULT_ACTOR): Vec3 | null {\n const s = seats.get(actor)\n if (!s) return null\n seats.delete(actor)\n const o = objs.get(s.object)\n const spec = o?.spec.seats?.find((x) => x.id === s.seat)\n emit({ type: 'seat', object: s.object, seat: s.seat, actor, seated: false })\n if (!o || !spec) return null\n return toWorld(o, spec.exit)\n }\n\n function sit(objectId: string, seatId: string, actor: string = DEFAULT_ACTOR): SeatResult | null {\n const o = objs.get(objectId)\n const seat = o?.spec.seats?.find((s) => s.id === seatId)\n if (!o || !seat) return null\n const holder = seatTaken(objectId, seatId)\n if (holder && holder !== actor) return null\n if (seats.has(actor) && !holder) stand(actor)\n seats.set(actor, { object: objectId, seat: seatId })\n const eye = seat.eye ?? (seat.pose === 'lie' ? 0.18 : 0.62)\n emit({ type: 'seat', object: objectId, seat: seatId, actor, seated: true })\n return { eye: toWorld(o, [seat.pos[0], seat.pos[1] + eye, seat.pos[2]]), yaw: o.public.transform.ry + seat.ry + Math.PI, pose: seat.pose }\n }\n\n // ------------------------------------------------------------------ soft volumes\n function push(pos: Vec3, radius: number, vel: Vec3): void {\n for (const o of objs.values()) {\n if (!o.soft.size) continue\n const tr = o.public.transform\n const lp = worldToLocal(tr, pos)\n const lv = rotY(vel, -tr.ry)\n for (const s of o.soft.values()) {\n const [w, h, d] = s.spec.size\n const hx = w / 2\n const hz = Math.max(d, 0.1) / 2\n const dx = Math.max(Math.abs(lp[0] - s.spec.anchor[0]) - hx, 0)\n const dz = Math.max(Math.abs(lp[2] - s.spec.anchor[2]) - hz, 0)\n if (Math.hypot(dx, dz) > radius) continue\n const top = tr.y + s.spec.anchor[1]\n if (pos[1] + ACTOR_HEIGHT < top - h || pos[1] > top) continue\n const cap = 8\n s.vax = clamp(s.vax - lv[2] * PUSH_GAIN, -cap, cap)\n s.vaz = clamp(s.vaz + lv[0] * PUSH_GAIN, -cap, cap)\n if (s.spec.kind === 'panel' && s.spec.part && lv[2] < -0.05 && lp[2] > s.spec.anchor[2]) setPartTarget(o, s.spec.part, 1)\n }\n }\n }\n\n function stepSoft(dt: number): void {\n const sub = Math.max(1, Math.ceil(dt / (1 / 120)))\n const h = dt / sub\n for (const o of objs.values()) {\n if (!o.soft.size) continue\n const wl = rotY([wind[0], 0, wind[1]], -o.public.transform.ry)\n for (const s of o.soft.values()) {\n const k = s.spec.stiffness ?? 30\n const c = s.spec.damping ?? 4\n const max = s.spec.maxAngle ?? 0.7\n const gain = s.spec.wind ?? (s.spec.kind === 'panel' ? 0 : 1)\n const gust = gain * (0.6 + 0.4 * Math.sin(time * 1.7 + s.phase)) * 0.1\n const fx = -wl[2] * gust\n const fz = wl[0] * gust\n for (let i = 0; i < sub; i++) {\n s.vax += (-k * s.ax - c * s.vax + fx) * h\n s.vaz += (-k * s.az - c * s.vaz + fz) * h\n s.ax += s.vax * h\n s.az += s.vaz * h\n }\n if (Math.abs(s.ax) > max) {\n s.ax = Math.sign(s.ax) * max\n s.vax = 0\n }\n if (Math.abs(s.az) > max) {\n s.az = Math.sign(s.az) * max\n s.vaz = 0\n }\n }\n }\n }\n\n // ------------------------------------------------------------------ damage\n function damage(objectId: string, target: string, amount: number, _kind?: string): { hp: number; destroyed: boolean } | null {\n const o = objs.get(objectId)\n if (o && options.destruction) {\n const pos = toWorld(o, [0, 0.5, 0])\n const type = _kind === 'gas' ? 'explosive' : _kind === 'fabric' ? 'slash' : 'blunt'\n const result = options.destruction.hitPart(objectId, target, { type, point: pos, energy: Math.max(0, amount), source: 'interactables' }) as { events?: Array<{ type?: string; part?: string }> } | null | undefined\n const destroyed = !!result?.events?.some((e) => e.type === 'fracture' && e.part === target)\n emit({ type: 'damage', object: objectId, target, hp: -1, destroyed })\n return { hp: -1, destroyed }\n }\n const st = o?.hp.get(target)\n if (!o || !st) return null\n if (st.hp <= 0) return { hp: 0, destroyed: true }\n const before = st.hp\n st.hp = Math.max(0, st.hp - Math.max(0, amount))\n const destroyed = st.hp <= 0\n emit({ type: 'damage', object: objectId, target, hp: st.hp, destroyed })\n const pos = toWorld(o, [0, 0.5, 0])\n const fuel = o.fuels.get(target)\n if (fuel && destroyed && fuel.spec.kind === 'gas' && fuel.level > 0) {\n emit({ type: 'explosion', object: objectId, pos, radius: 2 + fuel.level / 5 })\n fuel.level = 0\n }\n const tank = o.tanks.get(target)\n if (tank && before === st.max && st.hp < before) emit({ type: 'leak', object: objectId, target, pos })\n return { hp: st.hp, destroyed }\n }\n\n // ------------------------------------------------------------------ interactions\n function deriveInteractions(spec: ObjectSpec): InteractionSpec[] {\n const out: InteractionSpec[] = []\n const used = new Set<string>()\n const partPos = (id: string | undefined, fallback: Vec3): Vec3 => spec.parts?.find((p) => p.id === id)?.pivot ?? fallback\n const partLabel = (id: string | undefined): string => spec.parts?.find((p) => p.id === id)?.label ?? ''\n const claim = (id: string | undefined) => {\n if (id) used.add(id)\n }\n for (const d of spec.devices ?? []) {\n claim(d.part)\n const pos = d.part ? partPos(d.part, [0, 1, 0]) : (d.plug ?? [0, 1, 0])\n // a hard-wired lamp (circuit, no plug, no part to press) is driven by switches and `setDevice`: it never steals the interact key\n if (d.kind === 'lamp' && !d.part && !d.plug && d.electric !== false) continue\n out.push({ id: `device.${d.id}`, pos, action: { do: 'toggle_device', device: d.id }, label: partLabel(d.part) })\n if (d.kind === 'screen' && (d.channels ?? 1) > 1) out.push({ id: `channel.${d.id}`, pos, action: { do: 'cycle_device', device: d.id }, label: '' })\n }\n for (const s of spec.switches ?? []) {\n claim(s.part)\n out.push({ id: `switch.${s.id}`, pos: partPos(s.part, [0, 1.2, 0]), action: { do: 'toggle_switch', switch: s.id }, label: partLabel(s.part) })\n }\n const driven = new Set((spec.devices ?? []).map((d) => d.fixture))\n for (const f of spec.fixtures ?? []) {\n claim(f.part)\n if (driven.has(f.id)) continue\n out.push({ id: `fixture.${f.id}`, pos: f.part ? partPos(f.part, f.outlet) : f.outlet, action: { do: 'toggle_fixture', fixture: f.id }, label: partLabel(f.part) })\n }\n for (const b of spec.burners ?? []) {\n claim(b.part)\n const pos = b.part ? partPos(b.part, b.pos) : b.pos\n if (b.needsIgnition) out.push({ id: `ignite.${b.id}`, pos, action: { do: 'ignite', burner: b.id }, label: '' })\n out.push({ id: `burner.${b.id}`, pos, action: { do: 'toggle_burner', burner: b.id }, label: partLabel(b.part) })\n }\n for (const f of spec.fuels ?? []) {\n if (!f.valve) continue\n claim(f.valve)\n out.push({ id: `fuel.${f.id}`, pos: partPos(f.valve, [0, 0.5, 0]), action: { do: 'toggle_fuel', fuel: f.id }, label: partLabel(f.valve) })\n }\n for (const c of spec.containers ?? []) {\n if (!c.access) out.push({ id: `open.${c.id}`, pos: c.volume.center, action: { do: 'open_container', container: c.id }, label: '' })\n }\n for (const p of spec.parts ?? []) {\n if (used.has(p.id)) continue\n if (p.label) out.push({ id: `part.${p.id}`, pos: p.pivot, action: { do: 'toggle_part', part: p.id }, label: p.label })\n }\n for (const c of spec.containers ?? []) {\n if (c.access && !spec.parts?.find((p) => p.id === c.access)?.label) {\n out.push({ id: `open.${c.id}`, pos: partPos(c.access, c.volume.center), action: { do: 'open_container', container: c.id }, label: '' })\n }\n }\n for (const s of spec.seats ?? []) out.push({ id: `seat.${s.id}`, pos: s.pos, action: { do: 'sit', seat: s.id }, label: '' })\n return out\n }\n\n function typeName(o: ObjState): string {\n return table[`type.${o.spec.type}`] ?? humanize(o.spec.type)\n }\n\n function promptOf(o: ObjState, i: InteractionSpec): { label: string; verb: string; object: string } {\n const a = i.action\n let state: string | null = null\n let def = ''\n switch (a.do) {\n case 'toggle_part': {\n const p = o.parts.get(a.part)\n const open = !!p && targetIsOpen(p)\n state = open ? 'open' : 'closed'\n def = open ? 'prompt.close' : 'prompt.open'\n break\n }\n case 'open_container': {\n const c = containerOf(o, a.container)\n const p = c?.access ? o.parts.get(c.access) : undefined\n const open = p ? targetIsOpen(p) : false\n state = p ? (open ? 'open' : 'closed') : null\n def = open ? 'prompt.close' : 'prompt.open'\n break\n }\n case 'toggle_device': {\n const on = !!o.devices.get(a.device)?.on\n state = on ? 'on' : 'off'\n def = on ? 'prompt.turn_off' : 'prompt.turn_on'\n break\n }\n case 'toggle_switch': {\n const sw = o.spec.switches?.find((s) => s.id === a.switch)\n const on = !!sw && switchIsOn(o, sw)\n state = on ? 'on' : 'off'\n def = on ? 'prompt.turn_off' : 'prompt.turn_on'\n break\n }\n case 'toggle_breaker': {\n const on = breaker(a.circuit)\n state = on ? 'on' : 'off'\n def = on ? 'prompt.breaker_off' : 'prompt.breaker_on'\n break\n }\n case 'toggle_fixture': {\n const on = !!o.fixtures.get(a.fixture)?.on\n state = on ? 'on' : 'off'\n def = on ? 'prompt.turn_off' : 'prompt.turn_on'\n break\n }\n case 'toggle_burner': {\n const b = o.burners.get(a.burner)\n const on = !!b && (b.on || b.lit)\n state = on ? 'on' : 'off'\n def = on ? 'prompt.turn_off' : 'prompt.turn_on'\n break\n }\n case 'toggle_fuel': {\n const f = o.fuels.get(a.fuel)\n const open = !!f && valveOpen(o, f)\n state = open ? 'open' : 'closed'\n def = open ? 'prompt.close_valve' : 'prompt.open_valve'\n break\n }\n case 'take':\n def = 'prompt.take'\n break\n case 'read':\n def = 'prompt.read'\n break\n case 'ignite':\n def = 'prompt.light'\n break\n case 'cycle_device':\n def = 'prompt.change_channel'\n break\n case 'sit': {\n const seat = o.spec.seats?.find((s) => s.id === a.seat)\n const mine = seats.get(DEFAULT_ACTOR)\n def = mine && mine.object === o.id && mine.seat === a.seat ? 'prompt.get_up' : seat?.pose === 'lie' ? 'prompt.lie_down' : 'prompt.sit'\n break\n }\n }\n let key = def\n if (i.label) {\n if (state && table[`${i.label}.${state}`] !== undefined) key = `${i.label}.${state}`\n else if (table[i.label] !== undefined) key = i.label\n }\n const vars = { name: typeName(o), circuit: a.do === 'toggle_breaker' ? (a.name ?? a.circuit) : '' }\n const label = t(key, vars)\n // structured prompt: explicit `<key>.verb` / `<key>.object` entries win; otherwise the first word is the verb and the rest the object\n let verb: string\n let object: string\n if (table[`${key}.verb`] !== undefined) {\n verb = t(`${key}.verb`, vars)\n object = table[`${key}.object`] ? t(`${key}.object`, vars) : vars.name\n } else {\n const sp = label.indexOf(' ')\n verb = sp < 0 ? label : label.slice(0, sp)\n object = sp < 0 ? vars.name : label.slice(sp + 1)\n }\n return { label, verb, object }\n }\n\n /** Locale key of the reason an interaction cannot be used right now, or null when it can. */\n function unavailableReason(o: ObjState, i: InteractionSpec): string | null {\n const a = i.action\n if (!requirementMet(i)) return 'requires_open'\n switch (a.do) {\n case 'toggle_part': {\n const p = o.parts.get(a.part)\n return p && gated(o, p) ? 'requires_open' : null\n }\n case 'open_container': {\n const c = containerOf(o, a.container)\n const p = c?.access ? o.parts.get(c.access) : undefined\n return p && gated(o, p) ? 'requires_open' : null\n }\n case 'toggle_breaker': {\n const rocker = a.part ? o.parts.get(a.part) : undefined\n return rocker && gated(o, rocker) ? 'requires_open' : null\n }\n case 'take':\n case 'read':\n return accessible(o.id, a.container) ? null : 'requires_open'\n case 'ignite':\n return o.burners.get(a.burner)?.lit ? 'already_lit' : null\n case 'toggle_burner': {\n const b = o.burners.get(a.burner)\n return !b?.spec.needsIgnition || b.lit ? null : 'needs_ignition'\n }\n case 'cycle_device': {\n const d = o.devices.get(a.device)\n if (!d || !d.on) return 'turn_on_first'\n return devicePowered(o, d) ? null : 'no_power'\n }\n default:\n return null\n }\n }\n\n function requirementMet(i: InteractionSpec): boolean {\n if (!i.requires) return true\n const p = objs.get(i.requires.object)?.parts.get(i.requires.part)\n return !!p && targetIsOpen(p)\n }\n\n function interactablesOf(o: ObjState): Array<{ item: Interactable; spec: InteractionSpec; o: ObjState }> {\n return o.interactions.map((i) => {\n const id = gid(o, i.id)\n const p = promptOf(o, i)\n const item: Interactable = {\n id,\n object: o.id,\n kind: i.action.do,\n pos: toWorld(o, i.pos),\n radius: i.radius ?? DEFAULT_RADIUS,\n label: p.label,\n verb: p.verb,\n noun: p.object,\n available: true,\n act: (actor) => act(id, actor),\n }\n const reason = unavailableReason(o, i) ?? (options.canInteract?.(item, DEFAULT_ACTOR) === false ? 'locked' : null)\n if (reason) {\n item.available = false\n item.reason = reason\n }\n return { item, spec: i, o }\n })\n }\n\n function interactables(): Interactable[] {\n const out: Interactable[] = []\n for (const o of objs.values()) for (const e of interactablesOf(o)) out.push(e.item)\n return out\n }\n\n function target(eye: { x: number; y: number; z: number; yaw: number; pitch: number }, opts: TargetOptions = {}): Interactable | null {\n const reach = opts.reach ?? 1.8\n const cone = opts.cone ?? 0.45\n const dir: Vec3 = [-Math.sin(eye.yaw) * Math.cos(eye.pitch), Math.sin(eye.pitch), -Math.cos(eye.yaw) * Math.cos(eye.pitch)]\n let best: Interactable | null = null\n let bestScore = Infinity\n for (const o of objs.values()) {\n for (const e of interactablesOf(o)) {\n if (!e.item.available && !opts.includeUnavailable) continue\n if (opts.kinds && !opts.kinds.includes(e.item.kind)) continue\n if (opts.filter && !opts.filter(e.item)) continue\n const v: Vec3 = [e.item.pos[0] - eye.x, e.item.pos[1] - eye.y, e.item.pos[2] - eye.z]\n const d = Math.hypot(v[0], v[1], v[2])\n if (d > reach) continue\n const cos = d < 1e-6 ? 1 : clamp((v[0] * dir[0] + v[1] * dir[1] + v[2] * dir[2]) / d, -1, 1)\n const angle = Math.acos(cos)\n if (angle > cone) continue\n const score = (angle / cone) * 0.6 + (d / reach) * 0.4\n if (score < bestScore) {\n bestScore = score\n best = e.item\n }\n }\n }\n return best\n }\n\n function act(interactableId: string, actor: string = DEFAULT_ACTOR): boolean {\n let found: { item: Interactable; spec: InteractionSpec; o: ObjState } | null = null\n for (const o of objs.values()) {\n if (!interactableId.startsWith(o.id + ':')) continue\n const local = interactableId.slice(o.id.length + 1)\n if (!o.interactions.some((i) => i.id === local)) continue\n found = interactablesOf(o).find((e) => e.spec.id === local) ?? null\n if (found) break\n }\n if (!found) return false\n if (!requirementMet(found.spec)) return false\n if (options.canInteract?.(found.item, actor) === false) return false\n return execute(found.o, found.spec.action, actor)\n }\n\n function execute(o: ObjState, a: InteractionSpec['action'], actor: string): boolean {\n switch (a.do) {\n case 'toggle_part': {\n const p = o.parts.get(a.part)\n if (!p || gated(o, p)) return false\n setPartTarget(o, a.part, targetIsOpen(p) ? 0 : 1)\n return true\n }\n case 'open_container': {\n const c = containerOf(o, a.container)\n if (!c) return false\n if (c.access) return execute(o, { do: 'toggle_part', part: c.access }, actor)\n emit({ type: 'container', object: o.id, container: c.id, open: true })\n return true\n }\n case 'take': {\n if (!accessible(o.id, a.container)) return false\n const list = o.contents.get(a.container)\n if (!list) return false\n const slot = a.slot ?? list.findIndex((s) => s !== null)\n if (slot < 0 || !list[slot]) {\n message('empty')\n return false\n }\n return take(o.id, a.container, slot, actor) !== null\n }\n case 'read': {\n if (!accessible(o.id, a.container)) return false\n const stack = o.contents.get(a.container)?.[a.slot]\n if (!stack) {\n message('empty')\n return false\n }\n const key = typeof stack.data?.text === 'string' ? stack.data.text : null\n if (!key) return false\n message(key)\n return true\n }\n case 'toggle_device': {\n const d = o.devices.get(a.device)\n if (!d) return false\n setDeviceOn(o, d, !d.on)\n return true\n }\n case 'cycle_device': {\n const d = o.devices.get(a.device)\n if (!d || !d.on) return false\n if (!devicePowered(o, d)) {\n message('no_power')\n return false\n }\n d.channel = (d.channel + 1) % Math.max(1, d.spec.channels ?? 1)\n emit({ type: 'device', object: o.id, device: d.spec.id, on: true, powered: true, channel: d.channel })\n return true\n }\n case 'toggle_switch':\n return toggleSwitch(o, a.switch)\n case 'toggle_breaker': {\n const rocker = a.part ? o.parts.get(a.part) : undefined\n if (rocker && gated(o, rocker)) return false\n const on = !breaker(a.circuit)\n if (a.part) setPartTarget(o, a.part, on ? 1 : 0)\n world.power.setBreaker(a.circuit, on)\n return true\n }\n case 'toggle_fixture':\n return toggleFixture(o, a.fixture)\n case 'toggle_burner':\n return toggleBurner(o, a.burner)\n case 'ignite':\n return ignite(o, a.burner)\n case 'toggle_fuel':\n return toggleFuel(o, a.fuel)\n case 'sit': {\n const mine = seats.get(actor)\n if (mine && mine.object === o.id && mine.seat === a.seat) {\n stand(actor)\n return true\n }\n return sit(o.id, a.seat, actor) !== null\n }\n }\n }\n\n // ------------------------------------------------------------------ emitters, mirrors, colliders\n function emitters(): ActiveEmitter[] {\n const out: ActiveEmitter[] = []\n for (const o of objs.values()) {\n for (const e of o.spec.emitters ?? []) {\n let intensity = 0\n const b = o.burners.get(e.source)\n const fx = o.fixtures.get(e.source)\n const dev = o.devices.get(e.source)\n const fuel = o.fuels.get(e.source)\n if (b) {\n const ref = resolveFuel(o, b)\n const frac = ref ? clamp(ref.f.level / ref.f.spec.capacity, 0, 1) : 0\n if (b.lit) intensity = 0.35 + 0.65 * frac\n else if (e.kind === 'smoke' && b.smoke > 0) intensity = 0.6 * (b.smoke / SMOKE_TAIL)\n } else if (fx) {\n if (fx.on && fx.flowing) intensity = fx.frac\n } else if (dev) {\n if (dev.on && devicePowered(o, dev)) intensity = 1\n } else if (fuel) {\n const lit = [...o.burners.values()].some((bb) => bb.lit && resolveFuel(o, bb)?.f === fuel)\n if (lit) intensity = 0.35 + 0.65 * clamp(fuel.level / fuel.spec.capacity, 0, 1)\n }\n if (intensity <= 0) continue\n const dir = rotY(e.dir ?? [0, 1, 0], o.public.transform.ry)\n out.push({ id: gid(o, e.id), object: o.id, kind: e.kind, pos: toWorld(o, e.pos), dir, size: e.size ?? 0.3, ...(e.reach !== undefined ? { reach: e.reach } : {}), intensity: clamp(intensity, 0, 1) })\n }\n }\n return out\n }\n\n function mirrors(eye: Vec3, maxDistance = 10, count = 4) {\n const out: Array<{ id: string; center: Vec3; normal: Vec3; w: number; h: number; distance: number }> = []\n for (const o of objs.values()) {\n for (const m of o.spec.mirrors ?? []) {\n const center = toWorld(o, applyPart(o, m.part, m.center))\n const f = partFrame(o, m.part)\n const ln = f && f.angle !== 0 ? rotateAxis(m.normal, f.axis, f.angle) : m.normal\n const normal = rotY(ln, o.public.transform.ry)\n const len = Math.hypot(normal[0], normal[1], normal[2]) || 1\n const n: Vec3 = [normal[0] / len, normal[1] / len, normal[2] / len]\n const to: Vec3 = [eye[0] - center[0], eye[1] - center[1], eye[2] - center[2]]\n if (n[0] * to[0] + n[1] * to[1] + n[2] * to[2] <= 0) continue\n const distance = Math.hypot(to[0], to[1], to[2])\n if (distance > maxDistance) continue\n out.push({ id: gid(o, m.id), center, normal: n, w: m.w, h: m.h, distance })\n }\n }\n return out.sort((a, b) => a.distance - b.distance).slice(0, count)\n }\n\n function colliders(opts: ColliderOptions = {}): WorldCollider[] {\n const out: WorldCollider[] = []\n const fixed: WorldCollider[] = []\n for (const o of objs.values()) {\n const tr = o.public.transform\n for (const c of o.spec.colliders ?? []) {\n if (c.part && c.offWhenOpen && (o.parts.get(c.part)?.t ?? 0) > 0.5) continue\n let extra = 0\n if (c.part) {\n const f = partFrame(o, c.part)\n if (f && f.angle !== 0 && Math.abs(f.axis[1]) >= 0.5) extra = f.angle * Math.sign(f.axis[1])\n }\n const center = toWorld(o, applyPart(o, c.part, c.center))\n ;(c.part ? out : fixed).push({\n id: gid(o, c.id),\n object: o.id,\n cx: center[0],\n cz: center[2],\n hw: c.half[0],\n hd: c.half[2],\n ry: tr.ry + (c.ry ?? 0) + extra,\n y0: center[1] - c.half[1],\n y1: center[1] + c.half[1],\n })\n }\n }\n if (opts.merge) return [...mergeColliders(fixed, { gap: opts.gap, slack: opts.slack }), ...out]\n return [...fixed, ...out]\n }\n\n // ------------------------------------------------------------------ update\n function update(dt: number): void {\n if (!(dt > 0)) return\n time += dt\n for (const o of objs.values()) {\n for (const p of o.parts.values()) {\n if (p.t === p.target) continue\n if (gated(o, p) && p.target > p.t) continue // a gated part may still close\n const step = (p.spec.motion.speed ?? 2.5) * dt\n const next = p.t < p.target ? Math.min(p.target, p.t + step) : Math.max(p.target, p.t - step)\n writeT(o, p, next)\n if (next === p.target) emit({ type: 'part', object: o.id, part: p.spec.id, t: next, target: p.target })\n }\n }\n stepSoft(dt)\n for (const o of objs.values()) {\n for (const b of o.burners.values()) {\n if (b.lit) {\n const ref = resolveFuel(o, b)\n if (!ref || !valveOpen(ref.o, ref.f)) {\n extinguish(o, b, !!b.spec.needsIgnition)\n } else {\n ref.f.level = Math.max(0, ref.f.level - b.spec.rate * dt)\n if (ref.f.level <= 0) extinguish(o, b, !!b.spec.needsIgnition)\n }\n } else if (b.on && !b.spec.needsIgnition && canBurn(b, resolveFuel(o, b))) {\n // a gas knob left open without gas lights as soon as the bottle valve opens\n b.lit = true\n b.smoke = 0\n emitBurner(o, b)\n } else if (b.smoke > 0) {\n b.smoke = Math.max(0, b.smoke - dt)\n }\n }\n if (rain > 0) {\n for (const tk of o.tanks.values()) {\n if (tk.spec.rainArea) tk.level = Math.min(tk.spec.capacity, tk.level + ((rain * tk.spec.rainArea) / 3600) * dt)\n }\n }\n for (const tk of o.tanks.values()) {\n const st = o.hp.get(tk.spec.id)\n if (st && st.hp < st.max && tk.level > 0) {\n const rate = 0.5 + 2.5 * (1 - st.hp / st.max)\n tk.level = Math.max(0, tk.level - rate * dt)\n }\n }\n }\n for (const o of objs.values()) {\n for (const d of o.devices.values()) {\n const fx = d.spec.fixture ? o.fixtures.get(d.spec.fixture) : undefined\n if (!fx) continue\n const run = d.on && devicePowered(o, d)\n if (fx.on === run) continue\n fx.on = run\n if (!run) {\n fx.flowing = false\n fx.frac = 0\n emitFixture(o, fx)\n }\n }\n for (const fx of o.fixtures.values()) stepFixture(o, fx, dt)\n }\n }\n\n // ------------------------------------------------------------------ add / remove\n function add(id: string, spec: ObjectSpec, transform: Transform): WorldObject {\n if (objs.has(id)) throw new Error(`Object \"${id}\" already exists`)\n const pub: WorldObject = { id, spec, transform: { ...transform }, parts: {} }\n const o: ObjState = {\n id,\n public: pub,\n spec,\n parts: new Map(),\n contents: new Map(),\n devices: new Map(),\n fuels: new Map(),\n burners: new Map(),\n tanks: new Map(),\n fixtures: new Map(),\n soft: new Map(),\n hp: new Map(),\n interactions: spec.interactions ?? deriveInteractions(spec),\n }\n for (const p of spec.parts ?? []) {\n const t0 = clamp(p.t0 ?? 0, 0, 1)\n o.parts.set(p.id, { spec: p, t: t0, target: t0, maxT: 1 })\n pub.parts[p.id] = t0\n }\n for (const c of spec.containers ?? []) {\n let list: Array<ItemStack | null>\n if (c.contents) list = c.slots.map((_, i) => cloneStack(c.contents?.[i] ?? null))\n else if (c.loot) list = generateLoot(options.loot, c.loot, c.slots, `${seed}|${id}|${c.id}`, options.items)\n else list = c.slots.map(() => null)\n o.contents.set(c.id, list)\n }\n for (const d of spec.devices ?? []) {\n o.devices.set(d.id, { spec: d, on: !!d.on, channel: 0 })\n if (d.on && d.part) {\n const p = o.parts.get(d.part)\n if (p) {\n p.target = p.maxT\n writeT(o, p, p.maxT)\n }\n }\n }\n for (const f of spec.fuels ?? []) o.fuels.set(f.id, { spec: f, level: clamp(f.level ?? f.capacity, 0, f.capacity) })\n for (const b of spec.burners ?? []) o.burners.set(b.id, { spec: b, on: false, lit: false, smoke: 0 })\n for (const tk of spec.tanks ?? []) o.tanks.set(tk.id, { spec: tk, level: clamp(tk.level ?? tk.capacity, 0, tk.capacity) })\n for (const f of spec.fixtures ?? []) o.fixtures.set(f.id, { spec: f, on: false, flowing: false, frac: 0, timer: 0 })\n let n = 0\n for (const s of spec.soft ?? []) {\n o.soft.set(s.id, { spec: s, ax: 0, az: 0, vax: 0, vaz: 0, phase: (n++ * 2.399963) % (Math.PI * 2) })\n }\n for (const d of spec.damage ?? []) o.hp.set(d.target, { max: d.hp, hp: d.hp })\n objs.set(id, o)\n if (!defaultTankExplicit && defaultTank === null && o.tanks.size) defaultTank = gid(o, [...o.tanks.keys()][0])\n rebuildPlugs()\n return pub\n }\n\n function remove(id: string): void {\n const o = objs.get(id)\n if (!o) return\n for (const [actor, s] of [...seats]) if (s.object === id) seats.delete(actor)\n objs.delete(id)\n if (defaultTank && defaultTank.startsWith(id + ':')) defaultTank = null\n rebuildPlugs()\n }\n\n // ------------------------------------------------------------------ save / load\n function serialize(): WorldState {\n const objects: WorldState['objects'] = {}\n for (const o of objs.values()) {\n objects[o.id] = {\n parts: Object.fromEntries([...o.parts].map(([k, p]) => [k, p.t])),\n contents: Object.fromEntries([...o.contents].map(([k, v]) => [k, v.map(cloneStack)])),\n devices: Object.fromEntries([...o.devices].map(([k, d]) => [k, { on: d.on, channel: d.channel }])),\n fuels: Object.fromEntries([...o.fuels].map(([k, f]) => [k, { level: f.level, open: valveOpen(o, f) }])),\n burners: Object.fromEntries([...o.burners].map(([k, b]) => [k, { on: b.on, lit: b.lit }])),\n tanks: Object.fromEntries([...o.tanks].map(([k, tk]) => [k, tk.level])),\n fixtures: Object.fromEntries([...o.fixtures].map(([k, f]) => [k, f.on])),\n hp: Object.fromEntries([...o.hp].map(([k, h]) => [k, h.hp])),\n }\n }\n return {\n version: 1,\n objects,\n grid,\n breakers: Object.fromEntries(breakers),\n seats: Object.fromEntries([...seats].map(([a, s]) => [a, { object: s.object, seat: s.seat }])),\n defaultTank,\n }\n }\n\n function load(state: WorldState): void {\n for (const [id, st] of Object.entries(state.objects)) {\n const o = objs.get(id)\n if (!o) continue\n for (const [k, v] of Object.entries(st.parts)) {\n const p = o.parts.get(k)\n if (p) {\n // keep the limit found by fitSweep: a loaded save must not let a door open through a wall again\n p.target = clamp(v, 0, p.maxT)\n writeT(o, p, p.target)\n }\n }\n for (const [k, v] of Object.entries(st.contents)) {\n const c = containerOf(o, k)\n if (c) o.contents.set(k, c.slots.map((_, i) => cloneStack(v[i] ?? null)))\n }\n for (const [k, v] of Object.entries(st.devices)) {\n const d = o.devices.get(k)\n if (d) {\n d.on = v.on\n d.channel = v.channel\n }\n }\n for (const [k, v] of Object.entries(st.fuels)) {\n const f = o.fuels.get(k)\n if (f) f.level = v.level\n }\n for (const [k, v] of Object.entries(st.burners)) {\n const b = o.burners.get(k)\n if (b) {\n b.on = v.on\n b.lit = v.lit\n b.smoke = 0\n }\n }\n for (const [k, v] of Object.entries(st.tanks)) {\n const tk = o.tanks.get(k)\n if (tk) tk.level = v\n }\n for (const [k, v] of Object.entries(st.fixtures)) {\n const f = o.fixtures.get(k)\n if (f) {\n f.on = v\n f.flowing = false\n f.frac = 0\n f.timer = v && f.spec.kind === 'flush' ? FLUSH_SECONDS : 0\n }\n }\n for (const [k, v] of Object.entries(st.hp)) {\n const h = o.hp.get(k)\n if (h) h.hp = v\n }\n }\n grid = state.grid\n breakers.clear()\n for (const [k, v] of Object.entries(state.breakers)) breakers.set(k, v)\n seats.clear()\n for (const [a, s] of Object.entries(state.seats)) if (objs.has(s.object)) seats.set(a, { object: s.object, seat: s.seat })\n defaultTank = state.defaultTank\n defaultTankExplicit = true\n }\n\n // ------------------------------------------------------------------ public surface\n const world: World = {\n add,\n remove,\n get: (id) => objs.get(id)?.public,\n objects: () => [...objs.values()].map((o) => o.public),\n update,\n interactables,\n target,\n act,\n partT: (objectId, partId) => objs.get(objectId)?.parts.get(partId)?.t ?? 0,\n setDevice,\n setFixture,\n setBurner,\n setPart: (objectId, partId, targetT, instant) => {\n const o = objs.get(objectId)\n if (o) setPartTarget(o, partId, targetT, instant)\n },\n fitSweep,\n contents,\n accessible,\n take,\n put,\n slotPosition,\n power: {\n get grid() {\n return grid\n },\n set grid(on: boolean) {\n world.power.setGrid(on)\n },\n setGrid(on) {\n if (on === grid) return\n const before = powerSnapshot()\n grid = on\n emit({ type: 'power', grid })\n emitPowerChanges(before)\n },\n setBreaker(circuit, on) {\n if (breaker(circuit) === on) {\n breakers.set(circuit, on)\n return\n }\n const before = powerSnapshot()\n breakers.set(circuit, on)\n emitPowerChanges(before)\n },\n breaker,\n isPowered: (objectId, deviceId) => {\n const r = getDevice(objectId, deviceId)\n return r ? devicePowered(r.o, r.d) : false\n },\n isOn: (objectId, deviceId) => getDevice(objectId, deviceId)?.d.on ?? false,\n pluggedInto: (objectId, deviceId) => plugs.get(`${objectId}:${deviceId}`) ?? null,\n channel: (objectId, deviceId) => getDevice(objectId, deviceId)?.d.channel ?? 0,\n },\n fuelLevel: (objectId, fuelId) => objs.get(objectId)?.fuels.get(fuelId)?.level ?? 0,\n isLit: (objectId, burnerId) => objs.get(objectId)?.burners.get(burnerId)?.lit ?? false,\n water: {\n setDefaultTank(tankId) {\n defaultTank = tankId\n defaultTankExplicit = true\n },\n level(tankId) {\n return findGlobal(tankId, (oo, local) => oo.tanks.get(local))?.value.level ?? 0\n },\n refill(tankId, liters) {\n const f = findGlobal(tankId, (oo, local) => oo.tanks.get(local))\n if (!f) return 0\n const added = Math.max(0, Math.min(liters, f.value.spec.capacity - f.value.level))\n f.value.level += added\n return added\n },\n setRain(mm) {\n rain = Math.max(0, mm)\n },\n isFlowing: (objectId, fixtureId) => {\n const fx = objs.get(objectId)?.fixtures.get(fixtureId)\n return !!fx && fx.on && fx.flowing\n },\n },\n sit,\n stand,\n seatOf: (actor = DEFAULT_ACTOR) => {\n const s = seats.get(actor)\n return s ? { object: s.object, seat: s.seat } : null\n },\n push,\n sway: (objectId, softId) => {\n const s = objs.get(objectId)?.soft.get(softId)\n return s ? { ax: s.ax, az: s.az } : { ax: 0, az: 0 }\n },\n setWind: (x, z) => {\n wind = [x, z]\n },\n emitters,\n mirrors,\n colliders,\n damage,\n hp: (objectId, target) => (options.destruction ? null : (objs.get(objectId)?.hp.get(target)?.hp ?? null)),\n t,\n setLocale: (l) => {\n locale = l\n table = resolveStrings(locale, options.strings)\n },\n serialize,\n load,\n }\n return world\n}\n","import { fitsSize } from '@adatechnology/game-interactables'\nimport type { ItemStack, SlotSpec } from '@adatechnology/game-interactables'\n\nimport type { ItemsCatalog } from '../catalog-types'\n\nimport type { FindStack } from './types'\n\nexport interface PlacedFinds {\n /** One entry per slot, in the order of `slots`; null is empty. */\n contents: Array<ItemStack | null>\n /** Stacks that fit no free slot. */\n leftover: FindStack[]\n}\n\n/**\n * Puts the stacks in the slots of a container: each in the first free slot its item fits (`fitsSize`, width and depth may swap), a stack larger than the\n * item's `stack` splits over several slots. Pure and in order, so the same finds always land in the same slots. The result is the floorplan's `contents`.\n */\nexport const placeFinds = (stacks: readonly FindStack[], slots: readonly SlotSpec[], catalog: ItemsCatalog): PlacedFinds => {\n const contents: Array<ItemStack | null> = slots.map(() => null)\n const leftover: FindStack[] = []\n for (const stack of stacks) {\n const item = catalog.get(stack.itemId)\n const stackMax = item.stack ?? 1\n let remaining = stack.count\n while (remaining > 0) {\n const index = slots.findIndex((slot, position) => contents[position] === null && fitsSize(item.size, slot.max))\n if (index < 0) break\n const qty = Math.min(remaining, stackMax)\n contents[index] = { type: item.id, qty, ...(stack.data ? { data: { ...stack.data } } : {}) }\n remaining -= qty\n }\n if (remaining > 0) leftover.push({ ...stack, count: remaining })\n }\n return { contents, leftover }\n}\n","import type { LootTable, LootTables } from '@adatechnology/game-interactables'\n\nimport type { ItemsCatalog } from '../catalog-types'\nimport type { ContainerDef } from '../types'\n\nimport { depletionFactor } from './depletion'\nimport { rarityWeight, rollableItems } from './rarity-pool'\n\nexport interface LootTablesQuery {\n place: string\n /** Outbreak days; 0 is the normal world. */\n day?: number\n region?: string\n}\n\nconst weightsOf = (container: ContainerDef, place: string): Readonly<Record<string, number>> | undefined => container.categories ?? container.categoriesByPlace?.[place] ?? container.categoriesByPlace?.['*']\n\nconst tableFor = (catalog: ItemsCatalog, container: ContainerDef, query: LootTablesQuery): LootTable | undefined => {\n const weights = weightsOf(container, query.place)\n if (!weights) return undefined\n const entries = Object.entries(weights).flatMap(([category, categoryWeight]) => {\n const pool = rollableItems(catalog, category, query.place)\n const total = pool.reduce((sum, item) => sum + rarityWeight(catalog, item), 0)\n return total > 0 ? pool.map((item) => ({ type: item.id, weight: (categoryWeight * rarityWeight(catalog, item)) / total })) : []\n })\n const [low, high] = container.count ?? [0, 0]\n const region = query.region === undefined ? 1 : (catalog.rules.finds.regionMultiplier[query.region] ?? 1)\n const expected = ((low + high) / 2) * depletionFactor(query.place, query.day ?? 0, catalog.rules) * region\n return entries.length > 0 && high > 0 ? { fill: Math.min(1, expected / high), entries } : undefined\n}\n\n/**\n * The finds as the loot tables of `game-interactables`, for worlds that have no floorplan: one table per container of the place, named by the container id.\n * A table has one entry per rollable item, weighted by the container's category weight and the rarity (60/28/9/2.5). A slot is filled with probability\n * `fill` = expected count / most stacks, taking the container to have as many slots as its largest count; the day and the region scale the expected count.\n */\nexport const toLootTables = (catalog: ItemsCatalog, query: LootTablesQuery): LootTables => {\n const tables: LootTables = {}\n for (const container of catalog.containers.values()) {\n if (container.places && container.places.length > 0 && !container.places.includes(query.place)) continue\n const table = tableFor(catalog, container, query)\n if (table) tables[container.id] = table\n }\n return tables\n}\n","import type { CategoryDef, ItemDef, ItemPack, Rarity } from './types'\n\n/** The part of a weapon definition the bridge reads; `WeaponDef` of `@adatechnology/game-weapons` fits it without importing the package. */\nexport interface ArsenalWeaponLike {\n readonly id: string\n readonly mass: number\n readonly action: { readonly kind: string; readonly explosive?: string }\n readonly dimensions?: { readonly length: number }\n}\n\n/** The part of a calibre definition the bridge reads; `CaliberDef` fits it. */\nexport interface ArsenalCaliberLike {\n readonly id: string\n readonly bulletGrams: number\n readonly pellets?: number\n readonly caseType?: string\n readonly cartridge?: { readonly caseDiameter: number; readonly overallLength: number }\n}\n\n/** The part of an `Arsenal` the bridge reads. */\nexport interface ArsenalLike {\n readonly weapons: ReadonlyMap<string, ArsenalWeaponLike>\n readonly calibers: ReadonlyMap<string, ArsenalCaliberLike>\n}\n\n/** What the team's game says of a box or an arsenal item (`ammo-boxes.json`, from its `EXTRA` table); it wins over the estimates below. */\nexport interface ArsenalItemRecord {\n /** Id of the item; without it the bridge uses `ammo_<caliber>`, `firearm_<weapon>` or `throwable_<weapon>`. */\n id?: string\n kind: 'ammo_box' | 'firearm' | 'throwable'\n /** Calibre id of a box. */\n caliber?: string\n /** Weapon id of a firearm or throwable; for a throwable also the explosive it uses. */\n weapon?: string\n rounds?: number\n weight: number\n rarity: Rarity\n}\n\nexport interface ArsenalItemsOptions {\n records?: readonly ArsenalItemRecord[]\n}\n\nexport const ARSENAL_CATEGORIES: readonly CategoryDef[] = [\n { id: 'ammo', name: 'Ammo' },\n { id: 'firearms', name: 'Firearms' },\n { id: 'throwables', name: 'Throwables' },\n]\n\nconst MM = 0.001\nconst BOX_TARE_KG = 0.03\nconst BOX_WALL = 6 * MM\nconst ROUNDS_BY_CASE: Readonly<Record<string, number>> = { tiny: 50, pistol: 50, rifle: 20, shell: 25, big: 10 }\nconst DEFAULT_ROUNDS = 12\nconst CARTRIDGE_MASS_FACTOR = 2.3\nconst SHELL_MASS_FACTOR = 1.5\nconst BOX_DENSITY_KG_PER_M3 = 600\nconst THROWABLE_DENSITY_KG_PER_M3 = 1500\n\nconst cubeSide = (mass: number, density: number): number => Math.cbrt(mass / density)\n\n/** Estimates (REF) for a calibre the game has no box for: rounds by case type, mass of the rounds plus a tare, a box that packs them in a near-square tray. */\nconst estimateBox = (caliber: ArsenalCaliberLike): { rounds: number; weight: number; size: [number, number, number] } => {\n const rounds = ROUNDS_BY_CASE[caliber.caseType ?? ''] ?? DEFAULT_ROUNDS\n const factor = caliber.caseType === 'shell' ? SHELL_MASS_FACTOR : CARTRIDGE_MASS_FACTOR\n const weight = Number((rounds * caliber.bulletGrams * (caliber.pellets ?? 1) * factor * 0.001 + BOX_TARE_KG).toFixed(3))\n return { rounds, weight, size: boxSize(caliber, rounds, weight) }\n}\n\nconst boxSize = (caliber: ArsenalCaliberLike, rounds: number, weight: number): [number, number, number] => {\n if (!caliber.cartridge) {\n const side = cubeSide(weight, BOX_DENSITY_KG_PER_M3)\n return [side, side, side]\n }\n const rows = Math.ceil(Math.sqrt(rounds))\n const columns = Math.ceil(rounds / rows)\n const { caseDiameter, overallLength } = caliber.cartridge\n return [rows * caseDiameter * MM + BOX_WALL, overallLength * MM + BOX_WALL, columns * caseDiameter * MM + BOX_WALL]\n}\n\nconst ammoItem = (caliber: ArsenalCaliberLike, record: ArsenalItemRecord | undefined): ItemDef => {\n const estimate = estimateBox(caliber)\n const weight = record?.weight ?? estimate.weight\n return {\n id: record?.id ?? `ammo_${caliber.id}`,\n category: 'ammo',\n size: record?.weight === undefined ? estimate.size : boxSize(caliber, record.rounds ?? estimate.rounds, weight),\n weight,\n stack: 4,\n shape: 'box',\n rarity: record?.rarity ?? 'uncommon',\n }\n}\n\n/** A firearm's size from its length: height and thickness grow with it (a pistol 0.2 m is 0.10 x 0.03, a rifle 1 m is 0.20 x 0.05). Without a length, one from the mass. */\nconst firearmSize = (weapon: ArsenalWeaponLike): [number, number, number] => {\n const length = weapon.dimensions?.length ?? 0.15 + 0.2 * weapon.mass\n return [length, 0.08 + 0.12 * length, 0.02 + 0.03 * length]\n}\n\nconst weaponItem = (weapon: ArsenalWeaponLike, record: ArsenalItemRecord | undefined): ItemDef => {\n const isFirearm = weapon.action.kind === 'firearm'\n const side = cubeSide(weapon.mass, THROWABLE_DENSITY_KG_PER_M3)\n return {\n id: record?.id ?? `${isFirearm ? 'firearm' : 'throwable'}_${weapon.id}`,\n category: isFirearm ? 'firearms' : 'throwables',\n size: isFirearm ? firearmSize(weapon) : [side, side, side],\n weight: record?.weight ?? weapon.mass,\n stack: 1,\n shape: isFirearm ? 'long_tool' : 'jar',\n rarity: record?.rarity ?? 'unique',\n weapon: weapon.id,\n }\n}\n\nconst recordFor = (records: readonly ArsenalItemRecord[], match: (record: ArsenalItemRecord) => boolean): ArsenalItemRecord | undefined => records.find(match)\n\n/**\n * The arsenal as items, so a bag can carry it: a box `ammo_<caliber>` for every calibre, `firearm_<id>` for every firearm and `throwable_<id>` for every\n * throwable, with the mass and the real dimensions of the `WeaponDef` (`mass`, `dimensions.length`). A record from the team's game (`records`) gives the rounds\n * per box, the weight and the rarity of what it listed (9x19: 20 rounds, 0.24 kg); the rest are estimated from the calibre (REF): rounds by case type,\n * weight from the bullet. Returns a pack (with the `ammo`, `firearms` and `throwables` categories) for `createItems({ packs })`.\n */\nexport const itemsFromArsenal = (arsenal: ArsenalLike, options: ArsenalItemsOptions = {}): ItemPack => {\n const records = options.records ?? []\n const items: ItemDef[] = []\n for (const caliber of arsenal.calibers.values()) items.push(ammoItem(caliber, recordFor(records, (record) => record.kind === 'ammo_box' && record.caliber === caliber.id)))\n for (const weapon of arsenal.weapons.values()) {\n const kind = weapon.action.kind\n if (kind !== 'firearm' && kind !== 'throwable') continue\n items.push(weaponItem(weapon, recordFor(records, (record) => record.kind === kind && (record.weapon === weapon.id || record.weapon === weapon.action.explosive))))\n }\n return { id: 'arsenal', items, categories: ARSENAL_CATEGORIES }\n}\n"],"mappings":"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","names":["freshness"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../../game-contracts/src/impact.ts","../../game-contracts/src/body.ts","../../game-contracts/src/spec.ts","../../game-contracts/src/weapons.ts","../../game-contracts/src/animation.ts","../../game-contracts/src/tool-animation.ts","../../game-contracts/src/registry.ts","../../game-contracts/src/bench.ts","../../game-contracts/src/environment.ts","../src/categories.ts","../src/types.ts","../src/check-items.ts","../src/rules.ts","../src/create-items.ts","../src/decay.ts","../src/protection.ts","../src/carry.ts","../src/use-effect.ts","../src/recipes.ts","../src/finds/depletion.ts","../src/finds/rarity-pool.ts","../src/finds/category-roll.ts","../src/finds/explicit-pool.ts","../src/finds/spawn-state.ts","../src/finds/provider.ts","../../game-interactables/src/colliders.ts","../../game-interactables/src/rng.ts","../../game-interactables/src/loot.ts","../../game-interactables/src/locale.ts","../../game-interactables/src/math.ts","../../game-interactables/src/world.ts","../src/finds/place-finds.ts","../src/finds/loot-tables.ts","../src/arsenal-items.ts"],"sourcesContent":["export { createItems } from './create-items'\nexport { checkItems } from './check-items'\nexport { ITEM_CATEGORIES } from './categories'\nexport { DEFAULT_RULES } from './rules'\nexport { BODY_SLOTS, DURABILITY_KINDS, RARITIES } from './types'\nexport type * from './types'\nexport type * from './catalog-types'\nexport type * from './rules-types'\nexport { advanceAge, chargeLevel, failsToStart, freshness, moveToStorage, shelfRatio, storageMultiplier } from './decay'\nexport type { AgeOptions, AgeState, Freshness } from './decay'\nexport { blockChance, pieceBlock, repairPiece, wearBlocked } from './protection'\nexport type { AttackKind, WornPiece } from './protection'\nexport { bagOption, canTake, carriedKg, loadState, maxLoadKg } from './carry'\nexport type { BagOption, CarryEntry, LoadLevel, LoadState, TakeResult } from './carry'\nexport { resolveUse } from './use-effect'\nexport type { NutritionResult, UseContext, UseResult } from './use-effect'\nexport type * from './use-types'\nexport { canCraft, craft } from './recipes'\nexport type { CraftCheck, CraftContext, CraftOutcome, CraftResult, Consumption } from './recipes'\nexport type * from './recipe-types'\nexport { rollByCategory } from './finds/category-roll'\nexport { depletionFactor, scaleCount } from './finds/depletion'\nexport { rollExplicitPool } from './finds/explicit-pool'\nexport type { PoolPick } from './finds/explicit-pool'\nexport { createFindsProvider } from './finds/provider'\nexport type { FindsProviderOptions } from './finds/provider'\nexport { placeFinds } from './finds/place-finds'\nexport type { PlacedFinds } from './finds/place-finds'\nexport { toLootTables } from './finds/loot-tables'\nexport type { LootTablesQuery } from './finds/loot-tables'\nexport { spawnAgeDays, spawnData } from './finds/spawn-state'\nexport type { FindData, FindsProvider, FindsQuery, FindStack, Rng } from './finds/types'\nexport { ARSENAL_CATEGORIES, itemsFromArsenal } from './arsenal-items'\nexport type { ArsenalCaliberLike, ArsenalItemRecord, ArsenalItemsOptions, ArsenalLike, ArsenalWeaponLike } from './arsenal-items'\n","import type { DamageType, DestructibleSpec, DestructionEvent, Hit, JointSpec, ObjectState, ToolKind, Vec3 } from './destruction'\n\n/**\n * What a game module implements for an object whose behaviour it owns (a vehicle with its own panel deformation, a creature, a machine). The\n * world (`DestructionWorld.register`) hosts it next to its own objects: raycast, shoot, explode, hit, disassemble, extinguish, update, state,\n * reset and `on` all route to it.\n *\n * Space: the impl receives hits in WORLD space and returns events in WORLD space (it owns its transform; the transform given to `register` or\n * `setTransform` is only used by the world for raycasts and blast range). Every event must carry `object === id` and name parts listed by\n * `describe()`.\n */\nexport interface DestructibleImpl {\n readonly id: string\n readonly name?: string\n /** Parts, joints and structure in the impl's local space, for raycast, inspector and log. Called often: keep it cheap. */\n describe(): DestructibleSpec\n /** Apply one hit (see `IMPACT_CONTRACT` for the fields per type) and return the events it caused. */\n hit(hit: Hit): DestructionEvent[]\n /** Advance fire, melting, collapse and other timed behaviour. */\n update?(dt: number): DestructionEvent[]\n state(): ObjectState\n reset?(): void\n /** Same contract as `DestructionWorld.disassemble`: progress 0..1, or -1 when the tool cannot undo the joint. */\n disassemble?(jointId: string, tool: ToolKind, dt: number): number\n extinguish?(partId?: string): void\n /** Optional: an animation moved a part; `position` / `rotation` (Euler XYZ, radians) are object-local. Keep raycast and hit shapes in sync. */\n setPartPose?(partId: string, position: Vec3, rotation?: Vec3): void\n /** Plain JSON of the impl's own state, stored in `DestructionSnapshot.impls`. */\n serialize?(): unknown\n load?(data: unknown): void\n}\n\n/** The fields a weapon or hazard must put in a `Hit`, per damage type. THE impact contract. */\nexport interface ImpactContract {\n required: string[]\n optional: string[]\n note: string\n}\n\nexport const IMPACT_CONTRACT: Record<DamageType, ImpactContract> = {\n ballistic: { required: ['type', 'point', 'direction', 'energy', 'caliber'], optional: ['space', 'source'], note: 'bullets and pellets: joules and diameter in millimetres' },\n blunt: { required: ['type', 'point', 'energy'], optional: ['direction', 'radius', 'space', 'source'], note: 'contact radius in metres (default 3 cm)' },\n slash: { required: ['type', 'point', 'energy'], optional: ['direction', 'radius', 'space', 'source'], note: 'axes, knives, machetes' },\n explosive: { required: ['type', 'point', 'energy', 'radius'], optional: ['space', 'source'], note: 'point is the centre of the blast, radius the range in metres' },\n fire: { required: ['type', 'point', 'temperature', 'radius'], optional: ['duration', 'space', 'source'], note: 'flame temperature in °C; duration in seconds (call every frame with `dt`)' },\n heat: { required: ['type', 'point', 'temperature', 'radius'], optional: ['duration', 'space', 'source'], note: 'heat without flame (cutting torch, hot plate)' },\n}\n\n/** Problems with a hit according to the impact contract (empty when it is valid). */\nexport const checkHit = (hit: Hit): string[] => {\n const c = IMPACT_CONTRACT[hit.type]\n if (!c) return [`unknown damage type \"${String(hit.type)}\"`]\n const out: string[] = []\n for (const f of c.required) {\n const v = (hit as unknown as Record<string, unknown>)[f]\n if (v === undefined || (Array.isArray(v) ? v.some((n) => !Number.isFinite(n)) : typeof v === 'number' && !Number.isFinite(v))) out.push(`${hit.type} hit needs \"${f}\"`)\n }\n return out\n}\n\nexport interface CheckOptions {\n /**\n * Ids of every material the impl may use (the contracts package does not know the materials of your world: pass the ids of\n * `world.materials`, or the built-in plus your own). When omitted, the material check is skipped.\n */\n materials?: Iterable<string>\n}\n\nconst finiteJson = (v: unknown, path: string, out: string[]) => {\n if (typeof v === 'number') {\n if (!Number.isFinite(v)) out.push(`${path} is not a finite number`)\n } else if (Array.isArray(v)) v.forEach((x, i) => finiteJson(x, `${path}[${i}]`, out))\n else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) finiteJson(x, `${path}.${k}`, out)\n else if (typeof v === 'function' || typeof v === 'symbol' || typeof v === 'bigint') out.push(`${path} is not JSON`)\n}\n\n/**\n * Contract checker for the test room (it needs no world): `describe()` is valid, a 500 J ballistic hit at a part centre returns well-formed events of this impl, `state()`\n * matches `describe()`, hit points never go negative, `update(0.016)` returns an array and every event is JSON-serializable. It hits the impl\n * (and calls `reset()` afterwards when there is one).\n */\nexport const checkDestructible = (impl: DestructibleImpl, options: CheckOptions = {}): { ok: boolean; problems: string[] } => {\n const problems: string[] = []\n const known = options.materials ? new Set(options.materials) : null\n let spec: DestructibleSpec | undefined\n try {\n spec = impl.describe()\n } catch (e) {\n return { ok: false, problems: [`describe() threw: ${String(e)}`] }\n }\n if (!spec || !Array.isArray(spec.parts) || spec.parts.length === 0) return { ok: false, problems: ['describe() must return at least one part'] }\n const ids = new Set<string>()\n for (const p of spec.parts) {\n if (ids.has(p.id)) problems.push(`duplicate part id \"${p.id}\"`)\n ids.add(p.id)\n if (known && !known.has(p.material)) problems.push(`part \"${p.id}\" has unknown material \"${p.material}\"`)\n if (!p.shape || !['box', 'cylinder', 'sphere'].includes(p.shape.kind)) problems.push(`part \"${p.id}\" has no valid shape`)\n if (!Array.isArray(p.position) || p.position.length !== 3) problems.push(`part \"${p.id}\" has no position`)\n }\n const jointIds = new Set<string>()\n for (const j of (spec.joints ?? []) as JointSpec[]) {\n if (!ids.has(j.a) || !ids.has(j.b)) problems.push(`joint \"${j.id ?? `${j.a}~${j.b}`}\" references an unknown part`)\n const jid = j.id ?? `${j.a}~${j.b}`\n if (jointIds.has(jid)) problems.push(`duplicate joint id \"${jid}\"`)\n jointIds.add(jid)\n }\n const checkState = (label: string) => {\n let st: ObjectState\n try {\n st = impl.state()\n } catch (e) {\n problems.push(`state() threw ${label}: ${String(e)}`)\n return\n }\n const sids = st.parts.map((p) => p.id).sort().join(',')\n if (sids !== [...ids].sort().join(',')) problems.push(`state() part ids do not match describe() ${label}`)\n for (const p of st.parts) if (!(p.hp >= 0)) problems.push(`part \"${p.id}\" has negative or invalid hp ${label}`)\n }\n const checkEvents = (events: unknown, label: string) => {\n if (!Array.isArray(events)) {\n problems.push(`${label} must return an array`)\n return\n }\n const evs = events as DestructionEvent[]\n evs.forEach((e, i) => {\n if (e.object !== impl.id) problems.push(`${label} event ${i} (${e.type}) has object \"${String(e.object)}\", expected \"${impl.id}\"`)\n const part = (e as { part?: string }).part\n if (part !== undefined && !ids.has(part)) problems.push(`${label} event ${i} (${e.type}) names unknown part \"${part}\"`)\n })\n const bad: string[] = []\n finiteJson(evs, 'events', bad)\n problems.push(...bad.map((b) => `${label}: ${b}`))\n try {\n const s = JSON.stringify(evs)\n if (JSON.stringify(JSON.parse(s)) !== s) problems.push(`${label} events do not survive a JSON round trip`)\n } catch (e) {\n problems.push(`${label} events are not JSON-serializable: ${String(e)}`)\n }\n }\n checkState('before the hit')\n const target = spec.parts[0]\n try {\n const hit: Hit = { type: 'ballistic', point: [target.position[0], target.position[1], target.position[2]], direction: [0, 0, -1], energy: 500, caliber: 9 }\n checkEvents(impl.hit(hit), 'hit()')\n } catch (e) {\n problems.push(`hit() threw: ${String(e)}`)\n }\n checkState('after the hit')\n if (impl.update) {\n try {\n checkEvents(impl.update(0.016), 'update()')\n } catch (e) {\n problems.push(`update() threw: ${String(e)}`)\n }\n checkState('after update')\n }\n try {\n impl.reset?.()\n } catch (e) {\n problems.push(`reset() threw: ${String(e)}`)\n }\n return { ok: problems.length === 0, problems }\n}\n","/** The slots of a body an item can cover (the item slot vocabulary: game-items imports it from here). */\nexport type BodySlot = 'head' | 'eyes' | 'torso' | 'arms' | 'hands' | 'legs' | 'feet' | 'over'\n\n/** The 11 segments of the detailed body model (the Arsenal ids). */\nexport type SegmentKind = 'head' | 'jaw' | 'neck' | 'torso' | 'pelvis' | 'upperArm' | 'forearm' | 'hand' | 'thigh' | 'shin' | 'foot'\n\n/** The 6 regions of the crowd model (the game regions 0..5). */\nexport type BodyRegion = 'head' | 'torso' | 'armL' | 'armR' | 'legL' | 'legR'\n\n/** Bits of the `missing` mask of a body. The instanced body shader reads them, so the values are part of the contract. */\nexport const MISSING_BITS = {\n head: 1,\n armL: 2,\n armR: 4,\n legL: 8,\n legR: 16,\n armLBroken: 32,\n armRBroken: 64,\n legLBroken: 128,\n legRBroken: 256,\n jaw: 512,\n} as const\n","import type { DestructibleSpec, PartSpec } from './destruction'\n\nexport interface CheckSpecOptions {\n /** Known material ids. When given, every part's material must be one of them. */\n materials?: Iterable<string>\n}\n\nexport interface SpecReport {\n /** False when there is at least one problem (warnings do not count). */\n ok: boolean\n problems: string[]\n /** Things that are legal but probably wrong, e.g. structural parts that cannot reach an anchored part. */\n warnings: string[]\n}\n\nconst STRUCTURE_ROLES = ['foundation', 'column', 'beam', 'slab', 'wall', 'partition', 'roof', 'stair', 'none']\nconst BEARS = [true, false, 'a_on_b', 'b_on_a']\n\nconst finite = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n)\nconst positive = (n: unknown): boolean => finite(n) && n > 0\nconst vec3 = (v: unknown): boolean => Array.isArray(v) && v.length === 3 && v.every(finite)\nconst nonNegative = (n: unknown): boolean => n === undefined || (finite(n) && n >= 0)\n\nconst FACES = ['both', '+', '-']\nconst BONDS = ['running', 'stack']\n\n/** Layers, reinforcement, units and breach: known materials, finite positive sizes. */\nconst checkStagedFields = (p: PartSpec | undefined, label: string, materials: Set<string> | null, problems: string[]): void => {\n const known = (m: unknown, what: string): void => {\n if (materials && !materials.has(m as string)) problems.push(`${label} ${what} has unknown material \"${String(m)}\"`)\n }\n ;(p?.layers ?? []).forEach((l, i) => {\n known(l?.material, `layer ${i}`)\n if (!positive(l?.thickness)) problems.push(`${label} layer ${i} thickness must be finite and positive`)\n if (!FACES.includes(l?.faces)) problems.push(`${label} layer ${i} has an invalid faces \"${String(l?.faces)}\"`)\n })\n const r = p?.reinforcement\n if (r) {\n known(r.material, 'reinforcement')\n if (!positive(r.spacing) || !positive(r.diameter)) problems.push(`${label} reinforcement spacing and diameter must be finite and positive`)\n if (r.residualCapacity !== undefined && !(finite(r.residualCapacity) && r.residualCapacity >= 0 && r.residualCapacity <= 1)) problems.push(`${label} reinforcement residualCapacity must be between 0 and 1`)\n }\n const u = p?.units\n if (u) {\n known(u.mortar, 'units mortar')\n if (!vec3(u.size) || !u.size.every(positive)) problems.push(`${label} units size must be three finite positive numbers`)\n if (!BONDS.includes(u.bond)) problems.push(`${label} units has an invalid bond \"${String(u.bond)}\"`)\n }\n if (p?.breach && p.breach.area !== undefined && !positive(p.breach.area)) problems.push(`${label} breach area must be finite and positive`)\n}\n\n/**\n * Static checks on a `DestructibleSpec` (no world needed), for items that give a `spec` instead of an `impl`: unique part ids, known materials\n * (when `options.materials` is given), joints that reference existing, different parts, finite positive sizes, non-negative mass / hp / capacity,\n * valid structure roles and `bears` directions. A structural part with no path of joints to an anchored part is a warning, not a problem: it\n * is allowed, it just falls.\n */\nexport const checkSpec = (spec: DestructibleSpec, options: CheckSpecOptions = {}): SpecReport => {\n const problems: string[] = []\n const warnings: string[] = []\n const materials = options.materials ? new Set(options.materials) : null\n const parts = Array.isArray(spec?.parts) ? spec.parts : []\n if (!Array.isArray(spec?.parts) || parts.length === 0) problems.push('the spec needs at least one part')\n const ids = new Set<string>()\n for (const p of parts) {\n const id = String(p?.id ?? '')\n const label = `part \"${id}\"`\n if (id === '') problems.push('a part has no id')\n else if (ids.has(id)) problems.push(`duplicate part id \"${id}\"`)\n ids.add(id)\n if (materials && !materials.has(p?.material)) problems.push(`${label} has unknown material \"${String(p?.material)}\"`)\n else if (typeof p?.material !== 'string' || p.material === '') problems.push(`${label} needs a material`)\n const s = p?.shape\n if (s?.kind === 'box') {\n if (!Array.isArray(s.size) || s.size.length !== 3 || !s.size.every(positive)) problems.push(`${label} box size must be three finite positive numbers`)\n } else if (s?.kind === 'cylinder') {\n if (!positive(s.radius) || !positive(s.height)) problems.push(`${label} cylinder radius and height must be finite and positive`)\n } else if (s?.kind === 'sphere') {\n if (!positive(s.radius)) problems.push(`${label} sphere radius must be finite and positive`)\n } else problems.push(`${label} has an unknown shape`)\n if (!vec3(p?.position)) problems.push(`${label} needs a finite position [x, y, z]`)\n if (p?.rotation !== undefined && !vec3(p.rotation)) problems.push(`${label} has an invalid rotation`)\n for (const f of ['mass', 'hp', 'thickness'] as const) if (!nonNegative(p?.[f])) problems.push(`${label} ${f} must be a finite number >= 0`)\n if (p?.fill !== undefined && !(finite(p.fill) && p.fill >= 0 && p.fill <= 1)) problems.push(`${label} fill must be between 0 and 1`)\n if (p?.structure) {\n if (!STRUCTURE_ROLES.includes(p.structure.role)) problems.push(`${label} has an invalid structure role \"${String(p.structure.role)}\"`)\n if (!nonNegative(p.structure.capacity)) problems.push(`${label} structure capacity must be a finite number >= 0`)\n }\n if (p?.contains && !nonNegative(p.contains.liters)) problems.push(`${label} contains.liters must be a finite number >= 0`)\n checkStagedFields(p, label, materials, problems)\n }\n\n const jointIds = new Set<string>()\n const edges: Array<[string, string]> = []\n for (const j of spec?.joints ?? []) {\n const id = j.id ?? `${j.a}~${j.b}`\n const label = `joint \"${id}\"`\n if (jointIds.has(id)) problems.push(`duplicate joint id \"${id}\"`)\n jointIds.add(id)\n if (!ids.has(j.a)) problems.push(`${label} references unknown part \"${String(j.a)}\"`)\n if (!ids.has(j.b)) problems.push(`${label} references unknown part \"${String(j.b)}\"`)\n if (j.a === j.b) problems.push(`${label} joins part \"${String(j.a)}\" to itself`)\n if (j.bears !== undefined && !BEARS.includes(j.bears)) problems.push(`${label} has an invalid bears \"${String(j.bears)}\"`)\n if (!nonNegative(j.strength)) problems.push(`${label} strength must be a finite number >= 0`)\n if (!nonNegative(j.seconds)) problems.push(`${label} seconds must be a finite number >= 0`)\n if (j.bears !== false && ids.has(j.a) && ids.has(j.b) && j.a !== j.b) edges.push([j.a, j.b])\n }\n\n // structural parts must reach an anchored part over the joints (only when the spec lists its joints: otherwise they are inferred later)\n const structural = parts.filter((p) => p?.structure && p.structure.role !== 'none')\n if (structural.length && spec.joints) {\n const anchored = parts.filter((p) => p.anchored || p.structure?.role === 'foundation').map((p) => p.id)\n const adj = new Map<string, string[]>()\n for (const [a, b] of edges) {\n adj.set(a, [...(adj.get(a) ?? []), b])\n adj.set(b, [...(adj.get(b) ?? []), a])\n }\n const seen = new Set(anchored)\n const queue = [...anchored]\n while (queue.length) {\n for (const n of adj.get(queue.pop()!) ?? []) {\n if (seen.has(n)) continue\n seen.add(n)\n queue.push(n)\n }\n }\n const lost = structural.filter((p) => !seen.has(p.id)).map((p) => p.id)\n if (lost.length) warnings.push(`structural part(s) ${lost.map((i) => `\"${i}\"`).join(', ')} cannot reach an anchored part, so they will fall`)\n }\n return { ok: problems.length === 0, problems, warnings }\n}\n","import type { Hit, Vec3 } from './destruction'\n\n/**\n * A ballistic shot (one per pellet) as a weapon module produces it. Plain data: any damage model can consume it (`shotToHit` makes the `Hit`).\n */\nexport interface Shot {\n origin: Vec3\n /** Unit vector. */\n direction: Vec3\n /** Muzzle energy in joules. */\n energy: number\n /** Bullet diameter in millimetres. */\n caliber: number\n /** snake_case calibre id, e.g. `9x19`. */\n caliberId: string\n /** Muzzle speed in m/s. */\n speed: number\n /** Projectile mass in grams (one pellet for a shotgun load). */\n mass?: number\n /** Energy at a distance in metres, with the calibre's drag. */\n energyAt(distance: number): number\n pellet: number\n /** snake_case weapon id. */\n weapon: string\n source?: string\n}\n\n/** One use of a non-firearm (melee swing, flame tick, heat tick, throw, tool tick, extinguisher tick). */\nexport type Use =\n | { kind: 'melee'; damage: 'blunt' | 'slash'; energy: number; radius: number; reach: number; origin: Vec3; direction: Vec3 }\n | { kind: 'flame'; temperature: number; radius: number; range: number; cone: boolean; duration: number; origin: Vec3; direction: Vec3 }\n | { kind: 'heat'; temperature: number; radius: number; range: number; duration: number; origin: Vec3; direction: Vec3 }\n | {\n kind: 'throw'\n energy: number\n radius: number\n fuse: number\n velocity: Vec3\n origin: Vec3\n fire?: { temperature: number; radius: number; seconds: number }\n /** Explosive id (`m67`, `molotov`, `pg7v`...): the host looks the numbers up in the arsenal. Absent on a plain thrown object. */\n explosive?: string\n /** What sets it off: `timer` after `fuse` seconds, `impact` on the first hit once armed. */\n trigger?: 'timer' | 'impact'\n /** Metres the projectile must fly before an impact sets it off; closer, it hits without exploding (rocket launchers). */\n armDistance?: number\n /** Radius in metres of the wounding area (`radius` is the lethal one). */\n injuryRadius?: number\n /** Seconds the blast stuns whoever is inside the injury radius (flashbang). */\n stunSeconds?: number\n /** Seconds the thrower held it after pulling the pin: `fuse` is what was left of the full fuse when it left the hand. */\n cooked?: number\n /** Went off in the thrower's hand (held past the fuse): no velocity, `fuse` is 0 and the blast is at `origin`. */\n inHand?: boolean\n }\n /** An explosive set down where the trigger is pulled (mine, C4, charge): it waits for its trigger instead of flying. */\n | {\n kind: 'place'\n explosive: string\n /** `wire`: something trips it; `remote`: the player fires it; `timer`: after `fuse` seconds. */\n triggers: readonly ('timer' | 'wire' | 'remote')[]\n energy: number\n /** Lethal radius in metres. */\n radius: number\n injuryRadius: number\n /** Cone the blast goes through, in front of `direction` (mine); absent for a blast in every direction. */\n cone?: { halfAngleDeg: number; length: number }\n /** The wire a `wire` charge stretches from where it is set down: metres out, degrees from its facing (counter-clockwise from above) and metres above it. */\n tripwire?: { length: number; angleDeg: number; height: number }\n fuse?: number\n origin: Vec3\n direction: Vec3\n }\n /** The blast behind a rocket launcher: dangerous inside `radius` metres, in the cone around `direction` (pointing away from the target). */\n | { kind: 'backblast'; radius: number; halfAngleDeg: number; origin: Vec3; direction: Vec3 }\n | { kind: 'tool'; tool: string; reach: number; duration: number; origin: Vec3; direction: Vec3 }\n | { kind: 'extinguish'; range: number; coolC: number; duration: number; origin: Vec3; direction: Vec3 }\n | { kind: 'hand'; reach: number; origin: Vec3; direction: Vec3 }\n\n/**\n * The `Hit` a ballistic shot makes at `point` (world space): the energy is what is left of the shot at that distance from its origin\n * (`shot.energyAt`), the direction and calibre come from the shot. `normal` (the surface normal at the point, when the caller knows it) does not\n * change the hit; it is accepted so that callers can pass what their raycast returned and so that a stricter implementation could use it.\n */\nexport const shotToHit = (shot: Shot, point: Vec3, normal?: Vec3): Hit => {\n void normal\n const dx = point[0] - shot.origin[0]\n const dy = point[1] - shot.origin[1]\n const dz = point[2] - shot.origin[2]\n const distance = Math.hypot(dx, dy, dz)\n return {\n type: 'ballistic',\n point: [point[0], point[1], point[2]],\n direction: [shot.direction[0], shot.direction[1], shot.direction[2]],\n energy: shot.energyAt(distance),\n caliber: shot.caliber,\n ...(shot.mass === undefined ? {} : { mass: shot.mass }),\n source: shot.source ?? shot.weapon,\n }\n}\n","import type { Vec3 } from './destruction'\n\n/** Pose of one part (or of the whole item) at a key: position offset in metres and Euler XYZ rotation in radians, both relative to the rest pose. */\nexport interface AnimationPose {\n position?: Vec3\n rotation?: Vec3\n}\n\nexport interface AnimationKey {\n /** Seconds from the start of the clip. */\n t: number\n /** Pose of the item's root. */\n position?: Vec3\n rotation?: Vec3\n /** Poses of named parts: the key is a destruction part id (a pistol `reload` moves `mag`; a car door `open` rotates `door_l`). */\n parts?: Record<string, AnimationPose>\n}\n\nexport interface AnimationEventSpec {\n /** Seconds from the start of the clip. */\n t: number\n /** snake_case type the host maps (`sound`, `eject_casing`, `door_latch`...). */\n type: string\n data?: Record<string, unknown>\n}\n\n/** A use animation a module brings with its item. Poses between keys are interpolated by the player (the test room does it linearly). */\nexport interface AnimationClipSpec {\n /** snake_case: `reload`, `open`, `drink`... Unique within the item. */\n id: string\n /** Seconds. */\n duration: number\n keys: AnimationKey[]\n events?: AnimationEventSpec[]\n}\n\n/** What an item exposes when it brings its own animations. */\nexport interface ItemAnimationContract {\n animations: AnimationClipSpec[]\n}\n\nconst finite3 = (v: unknown): boolean => Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === 'number' && Number.isFinite(n))\n\n/**\n * Problems with a clip (empty when it is valid): a snake_case id, a positive finite duration, at least one key, key and event times inside\n * `[0, duration]` and not going backwards, finite vectors, every named part one of `partIds`, event types that are non-empty strings and\n * JSON-serializable event data.\n */\nexport const checkAnimation = (clip: AnimationClipSpec, partIds: Iterable<string>): { ok: boolean; problems: string[] } => {\n const problems: string[] = []\n const parts = new Set(partIds)\n const id = String(clip?.id ?? '')\n if (!/^[a-z][a-z0-9_]*$/.test(id)) problems.push(`clip id \"${id}\" must be snake_case`)\n const label = `clip \"${id}\"`\n if (!(typeof clip?.duration === 'number' && Number.isFinite(clip.duration) && clip.duration > 0)) problems.push(`${label} needs a positive finite duration`)\n const dur = typeof clip?.duration === 'number' && clip.duration > 0 ? clip.duration : Infinity\n if (!Array.isArray(clip?.keys) || clip.keys.length === 0) {\n problems.push(`${label} needs at least one key`)\n return { ok: false, problems }\n }\n let prev = -Infinity\n clip.keys.forEach((k, i) => {\n if (!(typeof k.t === 'number' && Number.isFinite(k.t)) || k.t < 0 || k.t > dur + 1e-9) problems.push(`${label} key ${i} has t=${String(k.t)} outside 0..${dur}`)\n else if (k.t < prev) problems.push(`${label} key ${i} goes back in time (${k.t} < ${prev})`)\n else prev = k.t\n for (const f of ['position', 'rotation'] as const) if (k[f] !== undefined && !finite3(k[f])) problems.push(`${label} key ${i} has an invalid ${f}`)\n for (const [pid, pose] of Object.entries(k.parts ?? {})) {\n if (!parts.has(pid)) problems.push(`${label} key ${i} moves unknown part \"${pid}\"`)\n for (const f of ['position', 'rotation'] as const) if (pose?.[f] !== undefined && !finite3(pose[f])) problems.push(`${label} key ${i} part \"${pid}\" has an invalid ${f}`)\n }\n })\n let prevEv = -Infinity\n ;(clip.events ?? []).forEach((e, i) => {\n if (!(typeof e.t === 'number' && Number.isFinite(e.t)) || e.t < 0 || e.t > dur + 1e-9) problems.push(`${label} event ${i} has t=${String(e.t)} outside 0..${dur}`)\n else if (e.t < prevEv) problems.push(`${label} event ${i} goes back in time`)\n else prevEv = e.t\n if (typeof e.type !== 'string' || e.type === '') problems.push(`${label} event ${i} needs a type`)\n try {\n JSON.stringify(e.data ?? {})\n } catch {\n problems.push(`${label} event ${i} data is not JSON`)\n }\n })\n return { ok: problems.length === 0, problems }\n}\n","import type { AnimationClipSpec, AnimationEventSpec, AnimationPose } from './animation'\nimport { checkAnimation } from './animation'\nimport type { HostEvent } from './module'\n\n/** Clips a tool can bring, one per move of its cycle. A tool brings the ones it has; a missing clip is simply not played. */\nexport const TOOL_CLIP = {\n DRAW: 'draw',\n SHOT: 'shot',\n MISFIRE: 'misfire',\n EMPTY: 'empty',\n CYCLE: 'cycle',\n RELOAD: 'reload',\n FEED: 'feed',\n FAULT: 'fault',\n CLEAR: 'clear',\n SAFETY_ON: 'safety_on',\n SAFETY_OFF: 'safety_off',\n MODE: 'mode',\n /** A throwable with the pin out, held until it is thrown. */\n COOK: 'cook',\n /** The stock folds, unfolds, slides in or out (variants `fold`, `unfold`, `collapse`, `extend`). */\n STOCK: 'stock',\n /** A light or a laser is switched (variants `on`, `off`). */\n TOGGLE: 'toggle',\n} as const\nexport type ToolClipId = (typeof TOOL_CLIP)[keyof typeof TOOL_CLIP]\n\n/** What a renderer does when a rule event arrives. `variant` picks `<clip>_<variant>` when the tool has it, else `<clip>`. */\nexport type ToolAnimationStep =\n | { readonly type: 'play'; readonly clip: ToolClipId; readonly variant?: string; readonly duration?: number; readonly repeat?: number }\n /** Move the clip that is playing to the marker (a clip event `stage` with `data.stage`) the rule just reached. */\n | { readonly type: 'sync'; readonly stage: string }\n /** The rule says the phase is over: the clip jumps to its end if it is still running. */\n | { readonly type: 'finish'; readonly clip: ToolClipId }\n\nconst stockVariant = (event: Extract<HostEvent, { type: 'stock' }>): string => {\n const isFolding = event.kind === undefined ? false : event.kind.includes('fold')\n if (event.state === 'collapsed') return isFolding ? 'fold' : 'collapse'\n return isFolding ? 'unfold' : 'extend'\n}\n\n/** The move a host event asks of the tool in hand, or null when it has no motion. */\nexport const toolAnimationStep = (event: HostEvent): ToolAnimationStep | null => {\n switch (event.type) {\n case 'tool':\n return { type: 'play', clip: TOOL_CLIP.DRAW, ...(event.duration === undefined ? {} : { duration: event.duration }) }\n case 'shot':\n return { type: 'play', clip: TOOL_CLIP.SHOT, repeat: Math.max(1, event.rounds ?? 1) }\n case 'misfire':\n return { type: 'play', clip: TOOL_CLIP.MISFIRE }\n case 'empty':\n return { type: 'play', clip: TOOL_CLIP.EMPTY }\n case 'mode':\n return { type: 'play', clip: TOOL_CLIP.MODE, variant: event.mode }\n case 'fault':\n return { type: 'play', clip: TOOL_CLIP.FAULT, variant: event.fault }\n case 'clear':\n if (event.phase === 'end') return { type: 'finish', clip: TOOL_CLIP.CLEAR }\n return { type: 'play', clip: TOOL_CLIP.CLEAR, variant: event.fault, ...(event.duration === undefined ? {} : { duration: event.duration }) }\n case 'safety':\n return { type: 'play', clip: event.on ? TOOL_CLIP.SAFETY_ON : TOOL_CLIP.SAFETY_OFF }\n case 'cycle':\n if (event.phase === 'end') return { type: 'finish', clip: TOOL_CLIP.CYCLE }\n return { type: 'play', clip: TOOL_CLIP.CYCLE, ...(event.duration === undefined ? {} : { duration: event.duration }) }\n case 'reload':\n if (event.phase === 'end') return { type: 'finish', clip: TOOL_CLIP.RELOAD }\n return { type: 'play', clip: TOOL_CLIP.RELOAD, ...(event.kind === undefined ? {} : { variant: event.kind }), ...(event.duration === undefined ? {} : { duration: event.duration }) }\n case 'stage':\n return { type: 'sync', stage: event.stage }\n case 'feed':\n return { type: 'play', clip: TOOL_CLIP.FEED }\n case 'cook':\n if (event.phase === 'start') return { type: 'play', clip: TOOL_CLIP.COOK, duration: event.fuse }\n return { type: 'finish', clip: TOOL_CLIP.COOK }\n case 'stock':\n return { type: 'play', clip: TOOL_CLIP.STOCK, variant: stockVariant(event), ...(event.duration === undefined ? {} : { duration: event.duration }) }\n case 'toggle':\n return { type: 'play', clip: TOOL_CLIP.TOGGLE, variant: event.on ? 'on' : 'off' }\n default:\n return null\n }\n}\n\n/** Where a renderer puts the poses: every part of the tool by id (the ids the clips move), and the whole tool. Poses are offsets from rest. */\nexport interface ToolRig {\n setPartPose(partId: string, pose: AnimationPose): void\n setRootPose?(pose: AnimationPose): void\n}\n\n/** Clips by id, with the variants as `<clip>_<variant>`. */\nexport type ToolClipSet = Readonly<Record<string, AnimationClipSpec>>\n\n/** A renderer's tool animator: feed it the host events and the frame time. */\nexport interface ToolAnimator {\n handle(event: HostEvent): void\n update(dt: number): void\n /** Ids of the clips playing now. */\n readonly playing: readonly string[]\n dispose(): void\n}\n\nexport const STAGE_EVENT = 'stage'\n\n/** The clip with its times (keys and events) scaled so that it lasts `duration` seconds. */\nexport const scaleClip = (clip: AnimationClipSpec, duration: number): AnimationClipSpec => {\n const factor = clip.duration > 0 ? duration / clip.duration : 1\n return {\n ...clip,\n duration,\n keys: clip.keys.map((key) => ({ ...key, t: key.t * factor })),\n ...(clip.events ? { events: clip.events.map((event) => ({ ...event, t: event.t * factor })) } : {}),\n }\n}\n\nconst ZERO: [number, number, number] = [0, 0, 0]\nconst mix = (from: readonly number[], to: readonly number[], fraction: number): [number, number, number] => [\n (from[0] ?? 0) + ((to[0] ?? 0) - (from[0] ?? 0)) * fraction,\n (from[1] ?? 0) + ((to[1] ?? 0) - (from[1] ?? 0)) * fraction,\n (from[2] ?? 0) + ((to[2] ?? 0) - (from[2] ?? 0)) * fraction,\n]\n\n/** Pose of the whole tool (`partId` undefined) or of one part at `time`, linear between keys; a key that does not name it means rest. */\nexport const sampleClip = (clip: AnimationClipSpec, partId: string | undefined, time: number): { position: [number, number, number]; rotation: [number, number, number] } => {\n const points = clip.keys.map((key) => {\n const pose = partId === undefined ? key : key.parts?.[partId]\n return { t: key.t, position: pose?.position ?? ZERO, rotation: pose?.rotation ?? ZERO }\n })\n const first = points[0]\n if (!first || first.t > 0) points.unshift({ t: 0, position: ZERO, rotation: ZERO })\n const last = points[points.length - 1]\n if (!last) return { position: ZERO, rotation: ZERO }\n if (time >= last.t) return { position: mix(last.position, last.position, 0), rotation: mix(last.rotation, last.rotation, 0) }\n const index = Math.max(0, points.findIndex((point, i) => time >= point.t && time < (points[i + 1]?.t ?? Infinity)))\n const from = points[index]\n const to = points[index + 1]\n if (!from || !to) return { position: ZERO, rotation: ZERO }\n const fraction = to.t > from.t ? (time - from.t) / (to.t - from.t) : 0\n return { position: mix(from.position, to.position, fraction), rotation: mix(from.rotation, to.rotation, fraction) }\n}\n\n/** Problems with a clip set (empty when valid): each key is the clip id (or `<clip>_<variant>`), every clip passes `checkAnimation`, and a `stage` event names its stage. */\nexport const checkToolClips = (clips: ToolClipSet, partIds: Iterable<string>): { ok: boolean; problems: string[] } => {\n const parts = [...partIds]\n const problems: string[] = []\n for (const [key, clip] of Object.entries(clips)) {\n if (clip.id !== key) problems.push(`clip \"${key}\" has the id \"${clip.id}\"`)\n if (!(clip.duration > 0)) problems.push(`clip \"${key}\" has no duration`)\n problems.push(...checkAnimation(clip, parts).problems)\n for (const event of clip.events ?? []) if (event.type === STAGE_EVENT && typeof event.data?.stage !== 'string') problems.push(`clip \"${key}\" has a stage event without data.stage`)\n }\n return { ok: problems.length === 0, problems }\n}\n\ninterface Instance {\n readonly name: string\n readonly clip: AnimationClipSpec\n time: number\n delay: number\n fired: number\n}\n\nexport interface ToolClipPlayerOptions {\n clips: ToolClipSet\n rig: ToolRig\n /** Called for each clip event as its time passes (`eject_casing`, `drop_magazine`, `stage`...). */\n onEvent?(event: AnimationEventSpec, clip: string): void\n /** Seconds between the rounds of a `repeat` (a burst): default 0.1. */\n repeatInterval?(): number\n}\n\n/** The default player of a tool's clips: runs the steps of `toolAnimationStep` against a rig, scaled to the duration the rule gives. */\nexport interface ToolClipPlayer {\n apply(step: ToolAnimationStep): void\n update(dt: number): void\n readonly playing: readonly string[]\n}\n\nexport const createToolClipPlayer = (options: ToolClipPlayerOptions): ToolClipPlayer => {\n const { clips, rig, onEvent } = options\n let instances: Instance[] = []\n\n const find = (clip: string, variant?: string): { name: string; clip: AnimationClipSpec } | undefined => {\n const name = variant !== undefined && clips[`${clip}_${variant}`] ? `${clip}_${variant}` : clip\n const found = clips[name]\n return found ? { name, clip: found } : undefined\n }\n\n const render = (instance: Instance): void => {\n const time = Math.min(instance.time, instance.clip.duration)\n const root = sampleClip(instance.clip, undefined, time)\n if (instance.clip.keys.some((key) => key.position || key.rotation)) rig.setRootPose?.(root)\n const touched = new Set(instance.clip.keys.flatMap((key) => Object.keys(key.parts ?? {})))\n for (const id of touched) rig.setPartPose(id, sampleClip(instance.clip, id, time))\n }\n\n const fireEvents = (instance: Instance, upTo: number): void => {\n const events = instance.clip.events ?? []\n while (instance.fired < events.length && (events[instance.fired]?.t ?? Infinity) <= upTo + 1e-9) {\n const event = events[instance.fired]\n instance.fired += 1\n if (event) onEvent?.(event, instance.name)\n }\n }\n\n return {\n apply(step) {\n if (step.type === 'sync') {\n for (const instance of instances) {\n const marker = (instance.clip.events ?? []).find((event) => event.type === STAGE_EVENT && event.data?.stage === step.stage)\n if (marker && instance.time < marker.t) instance.time = marker.t\n }\n return\n }\n if (step.type === 'finish') {\n for (const instance of instances) {\n if (instance.name !== step.clip && !instance.name.startsWith(`${step.clip}_`)) continue\n fireEvents(instance, instance.clip.duration)\n instance.time = instance.clip.duration\n }\n return\n }\n const found = find(step.clip, step.variant)\n if (!found) return\n const clip = step.duration && step.duration > 0 ? scaleClip(found.clip, step.duration) : found.clip\n const interval = options.repeatInterval?.() ?? 0.1\n instances = instances.filter((existing) => existing.name !== found.name || existing.delay > 0)\n for (let round = 0; round < (step.repeat ?? 1); round++) {\n instances.push({ name: found.name, clip, time: 0, delay: round * interval, fired: 0 })\n }\n },\n update(dt) {\n for (const instance of instances) {\n const carried = instance.delay > 0 ? dt - instance.delay : dt\n if (instance.delay > 0) {\n instance.delay = Math.max(0, instance.delay - dt)\n if (instance.delay > 0) continue\n }\n instance.time += carried\n fireEvents(instance, instance.time)\n render(instance)\n }\n instances = instances.filter((instance) => instance.delay > 0 || instance.time < instance.clip.duration)\n },\n get playing() {\n return instances.map((instance) => instance.name)\n },\n }\n}\n","type PlainObject = Record<string, unknown>\ntype Entry = { readonly id: string }\n\n/** Keys that would reach the prototype of the result when a patch comes from parsed JSON. */\nconst UNSAFE_KEYS: ReadonlySet<string> = new Set(['__proto__', 'constructor', 'prototype'])\n\nconst isPlainObject = (value: unknown): value is PlainObject =>\n typeof value === 'object' && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype\n\n/** Recursively optional; arrays and functions are replaced as a whole, objects are merged. */\nexport type DeepPartial<TValue> = TValue extends (...args: never[]) => unknown\n ? TValue\n : TValue extends readonly unknown[]\n ? TValue\n : TValue extends object\n ? { [TKey in keyof TValue]?: DeepPartial<TValue[TKey]> }\n : TValue\n\n/** Merges `patch` over `base`: plain objects merge field by field, arrays, functions and primitives replace. `undefined` in the patch is ignored. */\nexport const deepMerge = <TBase extends object>(base: TBase, patch: object | undefined): TBase => {\n if (!patch) return base\n const result: PlainObject = { ...(base as PlainObject) }\n for (const [key, value] of Object.entries(patch)) {\n if (value === undefined || UNSAFE_KEYS.has(key)) continue\n const current = result[key]\n result[key] = isPlainObject(current) && isPlainObject(value) ? deepMerge(current, value) : value\n }\n return result as TBase\n}\n\n/** The shipped entries with the ones from `extra` added or, when the id exists, replaced. */\nexport const mergeById = <TEntry extends Entry>(shipped: readonly TEntry[], extra: readonly TEntry[] | undefined): Map<string, TEntry> => {\n const entries = new Map<string, TEntry>()\n for (const entry of [...shipped, ...(extra ?? [])]) entries.set(entry.id, entry)\n return entries\n}\n\n/** Applies `patch` by id; a patch for an id that does not exist is ignored. */\nexport const applyPatches = <TEntry extends Entry>(entries: Map<string, TEntry>, patches: Readonly<Record<string, DeepPartial<TEntry> | Partial<TEntry>>> | undefined): void => {\n for (const [id, patch] of Object.entries(patches ?? {})) {\n const entry = entries.get(id)\n if (entry) entries.set(id, deepMerge(entry, patch))\n }\n}\n\nexport const removeIds = (entries: Map<string, unknown>, ids: readonly string[] | undefined): void => {\n for (const id of ids ?? []) entries.delete(id)\n}\n","import type { Vec3 } from './destruction'\n\n/**\n * The bench of a module: controls, layers and counters it declares as data. The host keeps the values, draws them in a generic way and hands\n * every change back through `ModuleHost.bench`. Nothing here is specific to a kind of module.\n */\nexport type ControlValue = number | boolean | string\n\nexport interface ChoiceOption {\n readonly id: string\n readonly name: string\n /** Heading the host lists the option under (`optgroup`, a grouped row of buttons). Options of a group must sit next to each other. */\n readonly group?: string\n}\n\ninterface ControlBase {\n /** snake_case, unique across the module's controls, layers and metrics. */\n readonly id: string\n /** Display name, already translated for the locale the host asked for. */\n readonly name: string\n readonly hint?: string\n readonly hidden?: boolean\n}\n\nexport interface NumberControl extends ControlBase {\n readonly kind: 'number'\n readonly min: number\n readonly max: number\n readonly step?: number\n readonly unit?: string\n readonly default: number\n}\n\nexport interface BooleanControl extends ControlBase {\n readonly kind: 'boolean'\n readonly default: boolean\n}\n\nexport interface ChoiceControl extends ControlBase {\n readonly kind: 'choice'\n readonly options: readonly ChoiceOption[]\n readonly default: string\n}\n\n/** A button: no value, only the press. */\nexport interface ActionControl extends ControlBase {\n readonly kind: 'action'\n}\n\nexport type ModuleControl = NumberControl | BooleanControl | ChoiceControl | ActionControl\n\n/** Something of the module the viewer turns on or off (smoke, flash, casings). */\nexport interface ModuleLayer {\n readonly id: string\n readonly name: string\n /** On unless `false`. */\n readonly default?: boolean\n}\n\n/** Pulled: the room calls read only when it samples (~4 Hz, or on TestRoom.metrics()). */\nexport interface ModuleMetric {\n readonly id: string\n readonly name: string\n readonly unit?: string\n /** Budget: the host shows a bar and warns past it. */\n readonly max?: number\n readonly digits?: number\n read(): number | string\n}\n\nexport type BenchChange =\n | { readonly kind: 'control'; readonly id: string; readonly value: ControlValue }\n | { readonly kind: 'layer'; readonly id: string; readonly on: boolean }\n | { readonly kind: 'action'; readonly id: string }\n\nexport interface ControlPatch {\n readonly hidden?: boolean\n readonly disabled?: boolean\n readonly options?: readonly ChoiceOption[]\n readonly value?: ControlValue\n}\n\n/** What the host lends a module that declares a bench: the current values and the changes. */\nexport interface ModuleBench {\n value(id: string): ControlValue | undefined\n layer(id: string): boolean\n /** Right after attach returns, the host sends one change per control (not actions) and per layer, in declaration order. */\n onChange(listener: (change: BenchChange) => void): () => void\n /** `value` sets without emitting (no echo loops); `options` falls back to the first option if the value is no longer offered. */\n patch(id: string, patch: ControlPatch): void\n /** Move the host camera (a near / middle / far choice). */\n look(eye: Vec3, target: Vec3): void\n /** Shows a sheet on the host's panel, or takes it down with `null`. A host without a sheet area ignores it. */\n sheet?(sheet: BenchSheet | null): void\n}\n\n/**\n * A card of text the module puts on the host's panel (the sheet of an effect, of a weapon): what it is, its numbers, its steps, its colours and,\n * optionally, a strip of segments over time. Names and texts are already translated for the host's locale.\n */\nexport interface BenchSheet {\n readonly kicker?: string\n readonly title: string\n readonly body?: string\n readonly facts?: readonly { readonly label: string; readonly value: string }[]\n readonly steps?: readonly { readonly at: string; readonly text: string }[]\n readonly swatches?: readonly { readonly color: string; readonly name: string }[]\n readonly note?: string\n /** A strip of named segments (seconds), with tick marks and a playhead that follows the module metric `metric`. */\n readonly timeline?: {\n readonly segments: readonly { readonly name: string; readonly start: number; readonly end: number; readonly color: string }[]\n readonly ticks: readonly number[]\n readonly metric: string\n }\n}\n\nexport interface BenchReport {\n readonly ok: boolean\n readonly problems: readonly string[]\n}\n\nexport interface BenchDeclaration {\n readonly controls?: readonly ModuleControl[]\n readonly layers?: readonly ModuleLayer[]\n readonly metrics?: readonly ModuleMetric[]\n}\n\nconst SNAKE_CASE = /^[a-z][a-z0-9_]*$/\nconst finite = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n)\n\nfunction checkControl(control: ModuleControl, problems: string[]): void {\n const label = `control \"${control.id}\"`\n if (control.kind === 'number') {\n if (!finite(control.min) || !finite(control.max) || control.min >= control.max) problems.push(`${label} needs min < max`)\n if (control.step !== undefined && !(finite(control.step) && control.step > 0)) problems.push(`${label} needs a step > 0`)\n if (!finite(control.default) || control.default < control.min || control.default > control.max) problems.push(`${label} default ${String(control.default)} is outside ${control.min}..${control.max}`)\n return\n }\n if (control.kind !== 'choice') return\n if (control.options.length === 0) {\n problems.push(`${label} needs at least one option`)\n return\n }\n const seen = new Set<string>()\n const groups = new Set<string>()\n let previousGroup: string | undefined\n for (const option of control.options) {\n if (seen.has(option.id)) problems.push(`${label} repeats option \"${option.id}\"`)\n seen.add(option.id)\n if (option.group !== undefined && (typeof option.group !== 'string' || option.group === '')) problems.push(`${label} option \"${option.id}\" has an empty group`)\n else if (option.group !== undefined && option.group !== previousGroup && groups.has(option.group)) problems.push(`${label} group \"${option.group}\" is split: its options must be next to each other`)\n if (typeof option.group === 'string' && option.group !== '') groups.add(option.group)\n previousGroup = option.group\n }\n if (!seen.has(control.default)) problems.push(`${label} default \"${control.default}\" is not among its options`)\n}\n\n/** snake_case ids unique across controls, layers and metrics; min < max; step > 0; default in range; options non-empty, unique, default among them, each group in one run; metric max > 0. */\nexport function checkBench(declared: BenchDeclaration): BenchReport {\n const problems: string[] = []\n const ids = new Set<string>()\n const claim = (kind: string, id: string): void => {\n if (!SNAKE_CASE.test(id)) problems.push(`${kind} id \"${id}\" must be snake_case`)\n if (ids.has(id)) problems.push(`duplicate id \"${id}\" across controls, layers and metrics`)\n ids.add(id)\n }\n for (const control of declared.controls ?? []) {\n claim('control', control.id)\n checkControl(control, problems)\n }\n for (const layer of declared.layers ?? []) claim('layer', layer.id)\n for (const metric of declared.metrics ?? []) {\n claim('metric', metric.id)\n if (metric.max !== undefined && !(finite(metric.max) && metric.max > 0)) problems.push(`metric \"${metric.id}\" needs a max > 0`)\n }\n return { ok: problems.length === 0, problems }\n}\n","/** The weather, light and ground as the world is right now. Written by whoever drives the weather, read by whoever reacts to it. */\nexport interface EnvironmentState {\n /** Rain in mm/h; 0 is dry. */\n readonly rain: number\n /** Wind in m/s along x and z, and its strength (the length of that vector). */\n readonly windX: number\n readonly windZ: number\n readonly windSpeed: number\n /** Strongest gust in m/s. */\n readonly gust: number\n /** Render fog far distance in metres. */\n readonly fogFar: number\n /** How far one can see for gameplay, in metres (not the render fog). */\n readonly visibility: number\n /** 0 is full daylight, 1 is the darkest night. */\n readonly nightFactor: number\n readonly temperatureC: number\n /** Wet ground, 0..1: rises with rain and dries after it stops. */\n readonly wetness: number\n}\n\nexport type EnvironmentKey = keyof EnvironmentState\n\n/** A calm dry day (the normal world, before anything went wrong). */\nexport const DEFAULT_ENVIRONMENT: EnvironmentState = {\n rain: 0,\n windX: 0,\n windZ: 0,\n windSpeed: 0,\n gust: 0,\n fogFar: 220,\n visibility: 220,\n nightFactor: 0,\n temperatureC: 20,\n wetness: 0,\n}\n\n/** The host's environment (`ModuleHost.environment`): a store the weather module writes to and the others read. */\nexport interface EnvironmentBus {\n get(): EnvironmentState\n /** Merges `patch` into the state; listeners hear it only when a value really changed. */\n set(patch: Partial<EnvironmentState>): void\n /** Hears every change with the keys that changed. Returns the unsubscribe function. */\n on(listener: (state: EnvironmentState, changed: readonly EnvironmentKey[]) => void): () => void\n}\n\n/** A plain store that implements the bus; the test room and tests use it, a game may bring its own. */\nexport function createEnvironmentBus(initial: Partial<EnvironmentState> = {}): EnvironmentBus {\n let state: EnvironmentState = { ...DEFAULT_ENVIRONMENT, ...initial }\n const listeners = new Set<(state: EnvironmentState, changed: readonly EnvironmentKey[]) => void>()\n return {\n get: () => state,\n set(patch) {\n const changed = (Object.keys(patch) as EnvironmentKey[]).filter((key) => patch[key] !== undefined && patch[key] !== state[key])\n if (changed.length === 0) return\n const next = { ...state } as { -readonly [Key in EnvironmentKey]: number }\n for (const key of changed) next[key] = patch[key] as number\n state = next\n for (const listener of [...listeners]) listener(state, changed)\n },\n on(listener) {\n listeners.add(listener)\n return () => void listeners.delete(listener)\n },\n }\n}\n","import type { CategoryDef } from './types'\n\n/** The 14 categories of the team's catalog; always registered. */\nexport const ITEM_CATEGORIES: readonly CategoryDef[] = [\n { id: 'food', name: 'Food' },\n { id: 'drink', name: 'Drink' },\n { id: 'medical', name: 'Medical' },\n { id: 'tools', name: 'Tools' },\n { id: 'melee', name: 'Melee' },\n { id: 'materials', name: 'Materials' },\n { id: 'clothing', name: 'Clothing' },\n { id: 'bags', name: 'Bags' },\n { id: 'electronics', name: 'Electronics' },\n { id: 'fuel', name: 'Fuel' },\n { id: 'outdoors', name: 'Outdoors' },\n { id: 'books', name: 'Books' },\n { id: 'collectibles', name: 'Collectibles' },\n { id: 'keys', name: 'Keys' },\n]\n","/**\n * Public types of @adatechnology/game-items.\n *\n * An `ItemDef` is a real-world thing (a can of beans, a hammer, a jacket) with its true size and weight; the rest of the fields are optional groups that\n * only the categories that need them fill (nutrition for food, protection for clothing, carry for bags). Units: metres, kilograms, litres, days.\n */\nimport type { BodySlot } from '@adatechnology/game-contracts'\nimport type { ItemDef as BaseItemDef } from '@adatechnology/game-interactables'\n\nimport type { RecipeDef } from './recipe-types'\nimport type { UseEffectDef } from './use-types'\n\nexport type { BaseItemDef }\n\nexport const RARITIES = ['common', 'uncommon', 'rare', 'very_rare', 'legendary', 'unique'] as const\nexport type Rarity = (typeof RARITIES)[number]\n\n/** The body slots an item covers: the vocabulary is `BodySlot` of game-contracts (`over` is a layer worn over the others, such as a coat or a vest). */\nexport type { BodySlot } from '@adatechnology/game-contracts'\nexport const BODY_SLOTS: readonly BodySlot[] = ['head', 'eyes', 'torso', 'arms', 'hands', 'legs', 'feet', 'over']\n\nexport const DURABILITY_KINDS = ['uses', 'portions', 'doses', 'hits', 'cuts', 'shots', 'condition', 'charges', 'seconds', 'days', 'infinite', 'container'] as const\nexport type DurabilityKind = (typeof DURABILITY_KINDS)[number]\n\n/** How long an item lasts, read from the source text (\"10 uses\", \"4 portions\", \"300 hits\", \"infinite\"). */\nexport interface Durability {\n kind: DurabilityKind\n amount?: number\n /** Source qualifier when the amount alone is not enough (for example the voltage of a battery). */\n per?: string\n}\n\n/** Id of a place where items turn up (house, hospital, farm...). Places are a registry: a pack or the game declares the ones it uses. */\nexport type PlaceTag = string\n\n/** Id of an item category. The 14 of `ITEM_CATEGORIES` are always present; a game adds its own through `createItems({ categories })`. */\nexport type ItemCategory = string\n\nexport interface CategoryDef {\n id: ItemCategory\n /** Display name in English; games translate by id. */\n name?: string\n}\n\nexport interface PlaceDef {\n id: PlaceTag\n name?: string\n}\n\n/**\n * Where items are found inside a place (a fridge, a drawer, a locker): which categories it draws from and how many stacks. The finds roll reads `count`,\n * `categories` (or `categoriesByPlace`, with `*` for any other place) and `places`; other fields of a pack's container are kept and ignored.\n */\nexport interface ContainerDef {\n id: string\n name?: string\n /** Places the container turns up in (`toLootTables` only); empty or missing means any. */\n places?: readonly PlaceTag[]\n /** Weight of each category when a stack is drawn for this container. */\n categories?: Readonly<Record<ItemCategory, number>>\n /** When `categories` is missing: the weights by place (a shop shelf stocks what the shop sells). */\n categoriesByPlace?: Readonly<Record<PlaceTag, Readonly<Record<ItemCategory, number>>>>\n /** Range of stacks a container holds when it is filled. */\n count?: readonly [number, number]\n}\n\n/** One thing an explicit pool can hold (a fridge: water bottle, milk, eggs...): `item` is the catalog id, `weight` the odds, `qty` the range. */\nexport interface PoolEntry {\n item: string\n weight: number\n qty: readonly [number, number]\n /** Supplies (food, water, medicine, tools) are what a fortified house stocks up on. */\n isSupply?: boolean\n}\n\nexport interface ItemDef extends BaseItemDef {\n category: ItemCategory\n /** The old household category (`kitchen`, `hygiene`, `office`...), `perishable` or `craft_only`. */\n tags?: readonly string[]\n rarity: Rarity\n /** Litres. Derived from `size` when the pack does not give it. */\n volume?: number\n durability?: Durability\n /** Days to spoil at room temperature. */\n shelfLife?: number\n portions?: number\n cookable?: boolean\n nutrition?: { satiety?: number; hydration?: number }\n /** Fractions 0..1 per attack, per body slot covered. */\n protection?: {\n slots: readonly BodySlot[]\n bite?: number\n scratch?: number\n warmth?: number\n /** Against projectiles (read by game-characters): `resistance` in penetration units; a stopped projectile does `blunt` of its damage, one that goes through `pass`. */\n ballistic?: { resistance: number; blunt?: number; pass?: number; consumable?: boolean }\n }\n carry?: { capacityKg: number; weightReduction: number; quickSlots: number; wheels?: boolean }\n /** Id of the weapon in the arsenal when the item is also one. */\n weapon?: string\n /** Id of the liquid (`game-fluids`) when the item holds one. */\n fluid?: { liquid: string; liters: number }\n uses?: readonly string[]\n finds?: { places: readonly PlaceTag[] }\n /** Battery-like items: share of the charge lost per month. */\n charge?: { lossPerMonth: number }\n /** Fuel-like items: after `afterDays` each use fails with probability `chance`. */\n agingFault?: { afterDays: number; chance: number }\n /** Key of the 3D model in `game-items-three`; without it a simple shape from `shape` is drawn. */\n model?: string\n}\n\n/** A bundle of content: items plus the categories, places and containers they rely on. */\nexport interface ItemPack {\n id: string\n items: readonly ItemDef[]\n categories?: readonly CategoryDef[]\n places?: readonly PlaceDef[]\n containers?: readonly ContainerDef[]\n /** What using an item does, in priority order (`resolveUse`). */\n useEffects?: readonly UseEffectDef[]\n recipes?: readonly RecipeDef[]\n /** Explicit pools of what a kind of furniture holds, by key (`fridge`, `sink`...); they win over the category roll of a container. */\n pools?: Readonly<Record<string, readonly PoolEntry[]>>\n /** Keys that share another key's pool (`garage_wardrobe` -> `wardrobe`). */\n poolAliases?: Readonly<Record<string, string>>\n}\n","import type { ItemRules } from './rules-types'\nimport type { CategoryDef, ContainerDef, ItemDef, PlaceDef } from './types'\nimport { BODY_SLOTS, DURABILITY_KINDS, RARITIES } from './types'\n\nexport interface ItemsParts {\n readonly items: ReadonlyMap<string, ItemDef>\n readonly categories: ReadonlyMap<string, CategoryDef>\n readonly places: ReadonlyMap<string, PlaceDef>\n readonly containers: ReadonlyMap<string, ContainerDef>\n readonly rules: ItemRules\n}\n\n/** Ids that appear more than once in a list a game passed in. */\nexport const findDuplicateIds = (kind: string, entries: readonly { readonly id: string }[] | undefined): string[] => {\n const seen = new Set<string>()\n const problems: string[] = []\n for (const { id } of entries ?? []) {\n if (seen.has(id)) problems.push(`${kind} \"${id}\" is listed more than once`)\n seen.add(id)\n }\n return problems\n}\n\nconst isFraction = (value: number | undefined): boolean => value === undefined || (value >= 0 && value <= 1)\nconst isPositive = (value: number): boolean => Number.isFinite(value) && value > 0\n\nconst checkItem = (item: ItemDef, { categories, places }: ItemsParts): string[] => {\n const problems: string[] = []\n const issue = (text: string): number => problems.push(`item \"${item.id}\" ${text}`)\n if (!categories.has(item.category)) issue(`has unknown category \"${item.category}\"`)\n if (!RARITIES.includes(item.rarity)) issue(`has unknown rarity \"${item.rarity}\"`)\n if (item.size.length !== 3 || !item.size.every(isPositive)) issue(`has an invalid size [${item.size.join(', ')}]`)\n if (!(item.weight >= 0) || !Number.isFinite(item.weight)) issue(`has a weight of ${item.weight} kg`)\n if (item.volume !== undefined && !isPositive(item.volume)) issue(`has a volume of ${item.volume} L`)\n if (item.shelfLife !== undefined && !isPositive(item.shelfLife)) issue(`has a shelf life of ${item.shelfLife} days`)\n if (item.portions !== undefined && !(Number.isInteger(item.portions) && item.portions > 0)) issue(`has ${item.portions} portions`)\n if (item.durability && !DURABILITY_KINDS.includes(item.durability.kind)) issue(`has unknown durability kind \"${item.durability.kind}\"`)\n const { protection } = item\n if (protection) {\n for (const slot of protection.slots) if (!BODY_SLOTS.includes(slot)) issue(`protects unknown body slot \"${slot}\"`)\n if (protection.slots.length === 0) issue('has protection but covers no body slot')\n if (![protection.bite, protection.scratch, protection.warmth].every(isFraction)) issue('has a protection value outside 0..1')\n }\n if (item.carry && !(item.carry.capacityKg > 0 && isFraction(item.carry.weightReduction))) issue('has an invalid carry (capacity above 0, reduction within 0..1)')\n if (places.size > 0) for (const place of item.finds?.places ?? []) if (!places.has(place)) issue(`is found in unknown place \"${place}\"`)\n return problems\n}\n\nconst checkContainer = (container: ContainerDef, { categories }: ItemsParts): string[] => {\n const problems: string[] = []\n for (const category of Object.keys(container.categories ?? {})) {\n if (!categories.has(category)) problems.push(`container \"${container.id}\" draws from unknown category \"${category}\"`)\n }\n const [low, high] = container.count ?? [0, 0]\n if (low < 0 || high < low) problems.push(`container \"${container.id}\" has an invalid count [${low}, ${high}]`)\n return problems\n}\n\nconst checkRules = ({ rarity, freshness, storage, carry, protection }: ItemRules): string[] => {\n const problems: string[] = []\n const hasNegativeWeight = RARITIES.some((id) => !(rarity.weights[id] >= 0))\n if (hasNegativeWeight || !RARITIES.some((id) => rarity.weights[id] > 0 && !rarity.fixedOnly.includes(id))) {\n problems.push('rules.rarity.weights must be non-negative with at least one rollable rarity above 0')\n }\n if (!(freshness.stale > 0 && freshness.stale < freshness.spoiled && freshness.spoiled < freshness.rotten)) problems.push('rules.freshness must rise: 0 < stale < spoiled < rotten')\n for (const [id, rule] of Object.entries(storage)) if (!isPositive(rule.multiplier) || (rule.limitDays !== undefined && !isPositive(rule.afterLimit ?? 0))) problems.push(`rules.storage \"${id}\" is invalid`)\n if (!(isPositive(carry.baseKg) && carry.perStrengthKg >= 0 && isPositive(carry.factor) && carry.encumbered > 0 && carry.overloaded > carry.encumbered)) problems.push('rules.carry is invalid')\n if (!(protection.cap > 0 && protection.cap <= 1 && isFraction(protection.tornShare))) problems.push('rules.protection is invalid')\n return problems\n}\n\n/** Every inconsistency of a catalog, one line each; empty when it is sound. */\nexport const checkItems = (parts: ItemsParts): string[] => [\n ...[...parts.items.values()].flatMap((item) => checkItem(item, parts)),\n ...[...parts.containers.values()].flatMap((container) => checkContainer(container, parts)),\n ...checkRules(parts.rules),\n]\n","import type { ItemRules } from './rules-types'\n\n/**\n * The numbers of the rules, from the team's Vale Cinzento delivery. `storage` was read by hand from its table (Validade e apodrecimento) so the\n * skeleton runs; the source test of the catalog tool proves it against the HTML (legendary and unique are never rolled: L276). Every field is an option of `createItems({ rules })`.\n */\nexport const DEFAULT_RULES: ItemRules = {\n rarity: {\n weights: { common: 60, uncommon: 28, rare: 9, very_rare: 2.5, legendary: 0.5, unique: 0 },\n fixedOnly: ['legendary', 'unique'],\n },\n freshness: { stale: 0.5, spoiled: 1, rotten: 1.5 },\n storage: {\n fridge: { multiplier: 0.33, needsPower: true },\n freezer: { multiplier: 0.1, needsPower: true },\n cooler: { multiplier: 0.5, limitDays: 2, afterLimit: 1 },\n delivery_bag: { multiplier: 0.8 },\n cellar: { multiplier: 0.66 },\n ambient: { multiplier: 1 },\n hot: { multiplier: 2 },\n },\n carry: { baseKg: 8, perStrengthKg: 2.5, factor: 1.2, encumbered: 1, overloaded: 1.5 },\n protection: { cap: 0.9, wear: { bite: 8, scratch: 3 }, tornShare: 0.25, repairMargin: 10 },\n aging: { thawShelfDays: { freezer: 2 } },\n inventory: { defaultSlots: 10, encumberedRunFactor: 0.75 },\n use: {\n freshness: {\n stale: { satietyFactor: 0.7, poisonChance: 0.1, morale: 0, isEdible: true },\n spoiled: { satietyFactor: 0.7, poisonChance: 0.5, morale: -20, isEdible: true },\n rotten: { satietyFactor: 0, poisonChance: 0, morale: 0, isEdible: false },\n },\n cookedPoisonFactor: 0.25,\n },\n spawn: {\n retailNew: true,\n retailPlaces: ['supermarket', 'pharmacy', 'mall', 'gas_station', 'gun_shop'],\n retailContainers: ['store_shelf', 'stockroom'],\n condition: { common: [30, 90], uncommon: [40, 90], rare: [50, 100], very_rare: [50, 100], legendary: [50, 100], unique: [50, 100] },\n ageShare: [0, 0.6],\n charge: [0.2, 1],\n fillable: ['fuel_jerrycan'],\n fill: [0, 0.6],\n },\n finds: {\n fallbackContainer: 'zombie_corpse',\n rollExcluded: ['ammo_7_62x39'],\n depletion: [\n { places: ['pharmacy', 'gun_shop', 'police_station'], perDay: 0.05, floor: 0.3 },\n { places: ['supermarket', 'gas_station'], perDay: 0.04, floor: 0.35 },\n { places: ['mall', 'hospital'], perDay: 0.025, floor: 0.5 },\n { places: ['house', 'apartment'], perDay: 0.01, floor: 0.7 },\n { places: ['farm', 'warehouse', 'mine', 'dam', 'lodge', 'church'], perDay: 0.005, floor: 0.85 },\n { places: ['military_base'], perDay: 0, floor: 1 },\n ],\n regionMultiplier: {},\n states: { normal: 'normal', fortified: 'fortified', abandoned: 'abandoned' },\n pool: {\n defaultFootprint: 0.5,\n offset: 1.5,\n perSquareMetre: 3,\n jitter: 1.5,\n min: 2,\n max: 5,\n fortifiedExtra: 2,\n supplyWeight: 2.5,\n supplyExtraQty: 1,\n abandonedEmptyChance: 0.4,\n abandonedKept: 1,\n junkChance: 0.5,\n junkItem: 'food_packaging',\n spoiledFoodItem: 'leftover_meal',\n coldContainers: ['geladeira', 'refrig_exp', 'freezer'],\n },\n },\n}\n","import { applyPatches, deepMerge, mergeById, removeIds } from '@adatechnology/game-contracts'\n\nimport { ITEM_CATEGORIES } from './categories'\nimport type { ItemsCatalog, ItemsOptions } from './catalog-types'\nimport { checkItems, findDuplicateIds } from './check-items'\nimport { DEFAULT_RULES } from './rules'\nimport type { RecipeDef } from './recipe-types'\nimport type { ContainerDef, ItemDef, PlaceDef, PoolEntry } from './types'\nimport type { UseEffectDef } from './use-types'\n\nconst LITRES_PER_CUBIC_METRE = 1000\n\n/** Fills what a pack leaves out: the volume comes from the size. */\nconst withDerivedFields = (item: ItemDef): ItemDef => (item.volume === undefined ? { ...item, volume: item.size[0] * item.size[1] * item.size[2] * LITRES_PER_CUBIC_METRE } : item)\n\nconst buildPools = (options: ItemsOptions, packs: NonNullable<ItemsOptions['packs']>): Map<string, readonly PoolEntry[]> => {\n const pools = new Map<string, readonly PoolEntry[]>()\n for (const source of [...packs, options]) for (const [key, entries] of Object.entries(source.pools ?? {})) pools.set(key, entries)\n const problems: string[] = []\n for (const source of [...packs, options]) {\n for (const [alias, key] of Object.entries(source.poolAliases ?? {})) {\n const entries = pools.get(key)\n if (entries) pools.set(alias, entries)\n else problems.push(`pool alias \"${alias}\" points to unknown pool \"${key}\"`)\n }\n }\n if (problems.length > 0) throw new Error(`game-items: the catalog is not consistent:\\n${problems.map((problem) => ` - ${problem}`).join('\\n')}`)\n return pools\n}\n\nconst groupBy = (items: Iterable<ItemDef>, keysOf: (item: ItemDef) => readonly string[]): Map<string, ItemDef[]> => {\n const groups = new Map<string, ItemDef[]>()\n for (const item of items) for (const key of keysOf(item)) groups.set(key, [...(groups.get(key) ?? []), item])\n return groups\n}\n\n/**\n * Create the catalog. Order: the packs in order (added or replaced by id), then `patch`, then `remove`, then `include`; categories, places and\n * containers are those of the packs plus the options, by id. Every inconsistency found is reported in one error.\n */\nexport const createItems = (options: ItemsOptions = {}): ItemsCatalog => {\n const packs = options.packs ?? []\n const items = mergeById([] as ItemDef[], packs.flatMap((pack) => pack.items))\n applyPatches<ItemDef>(items, options.patch)\n removeIds(items, options.remove)\n if (options.include) for (const [id, item] of items) if (!options.include(item)) items.delete(id)\n for (const [id, item] of items) items.set(id, withDerivedFields(item))\n\n const categories = mergeById(ITEM_CATEGORIES, [...packs.flatMap((pack) => pack.categories ?? []), ...(options.categories ?? [])])\n const places = mergeById<PlaceDef>([], [...packs.flatMap((pack) => pack.places ?? []), ...(options.places ?? [])])\n const containers = mergeById<ContainerDef>([], [...packs.flatMap((pack) => pack.containers ?? []), ...(options.containers ?? [])])\n const useEffects = [...mergeById<UseEffectDef>([], [...packs.flatMap((pack) => pack.useEffects ?? []), ...(options.useEffects ?? [])]).values()]\n const recipes = mergeById<RecipeDef>([], [...packs.flatMap((pack) => pack.recipes ?? []), ...(options.recipes ?? [])])\n const pools = buildPools(options, packs)\n const rules = deepMerge(DEFAULT_RULES, options.rules)\n\n const problems = [\n ...packs.flatMap((pack) => findDuplicateIds(`item in pack \"${pack.id}\"`, pack.items)),\n ...findDuplicateIds('category', options.categories),\n ...findDuplicateIds('place', options.places),\n ...findDuplicateIds('container', options.containers),\n ...findDuplicateIds('use effect', options.useEffects),\n ...findDuplicateIds('recipe', options.recipes),\n ...checkItems({ items, categories, places, containers, rules }),\n ]\n if (problems.length > 0) throw new Error(`game-items: the catalog is not consistent:\\n${problems.map((problem) => ` - ${problem}`).join('\\n')}`)\n\n const byCategory = groupBy(items.values(), (item) => [item.category])\n const byPlace = groupBy(items.values(), (item) => item.finds?.places ?? [])\n const byTag = groupBy(items.values(), (item) => item.tags ?? [])\n\n return {\n items,\n categories,\n places,\n containers,\n pools,\n useEffects,\n recipes,\n rules,\n has: (id) => items.has(id),\n get(id) {\n const item = items.get(id)\n if (!item) throw new Error(`game-items: unknown item \"${id}\"`)\n return item\n },\n list: () => [...items.values()],\n byCategory: (category) => [...(byCategory.get(category) ?? [])],\n byPlace: (place) => [...(byPlace.get(place) ?? [])],\n byTag: (tag) => [...(byTag.get(tag) ?? [])],\n }\n}\n","import { DEFAULT_RULES } from './rules'\nimport type { ItemRules, StorageId } from './rules-types'\nimport type { ItemDef } from './types'\n\nexport type Freshness = 'fresh' | 'stale' | 'spoiled' | 'rotten'\n\n/**\n * What an item remembers about its age. Nothing ticks: the account is closed lazily, when the item moves or someone looks at it.\n * Times are in days on the host's clock.\n */\nexport interface AgeState {\n /** Effective age: days at room temperature. */\n ageDays: number\n /** Time at which `ageDays` was last closed. */\n since: number\n storage: StorageId\n /** Time at which the item entered `storage` (a cooler only helps for its first days). */\n storedAt: number\n /** Replaces the item's shelf life (a thawed freezer leaves 2 days). */\n shelfLife?: number\n}\n\nexport interface AgeOptions {\n /** Whether the storage has power right now (fridge, freezer). Default true. */\n powered?: boolean\n rules?: ItemRules\n}\n\nconst storageRuleOf = (rules: ItemRules, storage: StorageId) => {\n const rule = rules.storage[storage]\n if (!rule) throw new Error(`game-items: unknown storage \"${storage}\"`)\n return rule\n}\n\n/** The multiplier of the age at one instant: `heldDays` is how long the item has been in `storage`. */\nexport const storageMultiplier = (storage: StorageId, options: AgeOptions & { heldDays?: number } = {}): number => {\n const { powered = true, rules = DEFAULT_RULES, heldDays = 0 } = options\n const rule = storageRuleOf(rules, storage)\n if (rule.needsPower && !powered) return 1\n if (rule.limitDays !== undefined && heldDays >= rule.limitDays) return rule.afterLimit ?? 1\n return rule.multiplier\n}\n\n/** Days of effective age accrued between `from` and `to` in a storage the item entered at `storedAt`. */\nconst accruedDays = (storage: StorageId, window: { storedAt: number; from: number; to: number }, options: AgeOptions): number => {\n const { powered = true, rules = DEFAULT_RULES } = options\n const rule = storageRuleOf(rules, storage)\n const elapsed = window.to - window.from\n if (elapsed <= 0) return 0\n if (rule.needsPower && !powered) return elapsed\n if (rule.limitDays === undefined) return elapsed * rule.multiplier\n const limitEnd = window.storedAt + rule.limitDays\n const early = Math.max(0, Math.min(window.to, limitEnd) - Math.max(window.from, window.storedAt))\n return early * rule.multiplier + (elapsed - early) * (rule.afterLimit ?? 1)\n}\n\n/** Closes the account up to `now`: `ageDays += (now - since) x multiplier`. Pure: returns the new state. */\nexport const advanceAge = (state: AgeState, now: number, options: AgeOptions = {}): AgeState => {\n const { powered = true, rules = DEFAULT_RULES } = options\n const rule = storageRuleOf(rules, state.storage)\n const thawDays = rules.aging.thawShelfDays[state.storage]\n const isThawing = rule.needsPower === true && !powered && thawDays !== undefined && state.shelfLife === undefined\n const base = isThawing ? { ...state, ageDays: 0, shelfLife: thawDays } : state\n const ageDays = base.ageDays + accruedDays(state.storage, { storedAt: state.storedAt, from: state.since, to: now }, options)\n return { ...base, ageDays, since: Math.max(now, state.since) }\n}\n\n/** Moves the item to another storage: closes the account in the old one, then the new multiplier applies from `now`. */\nexport const moveToStorage = (state: AgeState, now: number, storage: StorageId, options: AgeOptions = {}): AgeState => ({\n ...advanceAge(state, now, options),\n storage,\n storedAt: now,\n})\n\n/** Age as a share of the shelf life; 0 for an item that does not spoil. */\nexport const shelfRatio = (item: ItemDef, state: AgeState): number => {\n const shelfLife = state.shelfLife ?? item.shelfLife\n return shelfLife === undefined ? 0 : state.ageDays / shelfLife\n}\n\n/** `fresh`, `stale`, `spoiled` or `rotten` at 0.5, 1 and 1.5 of the shelf life (the rule's `freshness`). Closes the account up to `now` first. */\nexport const freshness = (item: ItemDef, state: AgeState, now: number, options: AgeOptions = {}): Freshness => {\n const { rules = DEFAULT_RULES } = options\n const ratio = shelfRatio(item, advanceAge(state, now, options))\n if (ratio >= rules.freshness.rotten) return 'rotten'\n if (ratio >= rules.freshness.spoiled) return 'spoiled'\n if (ratio >= rules.freshness.stale) return 'stale'\n return 'fresh'\n}\n\n/** Charge left (0..1) of a battery-like item: it loses `charge.lossPerMonth` of the full charge per 30 days of effective age. */\nexport const chargeLevel = (item: ItemDef, initialCharge: number, ageDays: number): number => {\n if (!item.charge) return initialCharge\n return Math.max(0, initialCharge - item.charge.lossPerMonth * (ageDays / 30))\n}\n\n/** True when one use of an old fuel-like item fails: past `agingFault.afterDays` each use fails with probability `chance`. Draws once, only when it can fail. */\nexport const failsToStart = (item: ItemDef, ageDays: number, random: () => number): boolean => {\n if (!item.agingFault || ageDays <= item.agingFault.afterDays) return false\n return random() < item.agingFault.chance\n}\n","import { DEFAULT_RULES } from './rules'\nimport type { ItemRules } from './rules-types'\nimport type { BodySlot, ItemDef } from './types'\n\nexport type AttackKind = 'bite' | 'scratch'\n\n/** A piece of clothing on a body: the item and its condition, 0 (torn) to 100 (new). */\nexport interface WornPiece {\n item: ItemDef\n condition: number\n}\n\nconst FULL_CONDITION = 100\n\nconst covers = (piece: WornPiece, slot: BodySlot): boolean => piece.item.protection?.slots.includes(slot) ?? false\n\n/** What one piece blocks of an attack: its value times its condition; a torn piece (condition 0) keeps `tornShare` of the value. */\nexport const pieceBlock = (piece: WornPiece, attack: AttackKind, rules: ItemRules = DEFAULT_RULES): number => {\n const value = piece.item.protection?.[attack] ?? 0\n const share = piece.condition <= 0 ? rules.protection.tornShare : piece.condition / FULL_CONDITION\n return value * share\n}\n\n/** Share of an attack on `slot` that clothing blocks: `1 - PROD(1 - value x condition)`, never above the cap (there is always some risk). */\nexport const blockChance = (pieces: readonly WornPiece[], slot: BodySlot, attack: AttackKind, rules: ItemRules = DEFAULT_RULES): number => {\n const unblocked = pieces.filter((piece) => covers(piece, slot)).reduce((product, piece) => product * (1 - pieceBlock(piece, attack, rules)), 1)\n return Math.min(rules.protection.cap, 1 - unblocked)\n}\n\n/** A blocked attack wears every piece that covers the slot and blocks something: bite -8, scratch -3 (never below 0). */\nexport const wearBlocked = (pieces: readonly WornPiece[], slot: BodySlot, attack: AttackKind, rules: ItemRules = DEFAULT_RULES): WornPiece[] =>\n pieces.map((piece) => {\n if (!covers(piece, slot) || pieceBlock(piece, attack, rules) <= 0) return piece\n return { ...piece, condition: Math.max(0, piece.condition - rules.protection.wear[attack]) }\n })\n\n/** A repair kit restores up to the maximum condition minus the margin (10); a piece already better is left alone. */\nexport const repairPiece = (piece: WornPiece, rules: ItemRules = DEFAULT_RULES, maxCondition: number = FULL_CONDITION): WornPiece => ({\n ...piece,\n condition: Math.max(piece.condition, maxCondition - rules.protection.repairMargin),\n})\n","import { DEFAULT_RULES } from './rules'\nimport type { ItemRules } from './rules-types'\nimport type { ItemDef } from './types'\n\nexport interface CarryEntry {\n item: ItemDef\n count: number\n}\n\nexport type LoadLevel = 'normal' | 'encumbered' | 'overloaded'\n\nexport interface LoadState {\n /** Load as a share of the maximum. */\n ratio: number\n level: LoadLevel\n /** Multiplier of running speed and breath. */\n runFactor: number\n canRun: boolean\n}\n\n/** What a character can carry, kg: `(8 + 2.5 x strength) x 1.2` with the default rules. */\nexport const maxLoadKg = (strength: number, rules: ItemRules = DEFAULT_RULES): number => (rules.carry.baseKg + rules.carry.perStrengthKg * strength) * rules.carry.factor\n\nconst sumKg = (entries: readonly CarryEntry[]): number => entries.reduce((total, entry) => total + entry.item.weight * entry.count, 0)\n\n/** Weight carried: what is outside the bag counts in full, what is inside counts times `(1 - weightReduction)`. */\nexport const carriedKg = (parts: { loose: readonly CarryEntry[]; packed?: readonly CarryEntry[]; weightReduction?: number }): number =>\n sumKg(parts.loose) + sumKg(parts.packed ?? []) * (1 - (parts.weightReduction ?? 0))\n\n/** Above 100% of the maximum, running and breath drop 25%; above 150% the character cannot run. */\nexport const loadState = (loadKg: number, maxKg: number, rules: ItemRules = DEFAULT_RULES): LoadState => {\n const ratio = maxKg > 0 ? loadKg / maxKg : Number.POSITIVE_INFINITY\n if (ratio > rules.carry.overloaded) return { ratio, level: 'overloaded', runFactor: 0, canRun: false }\n if (ratio > rules.carry.encumbered) return { ratio, level: 'encumbered', runFactor: rules.inventory.encumberedRunFactor, canRun: true }\n return { ratio, level: 'normal', runFactor: 1, canRun: true }\n}\n\n/** The bag of a character: how many item slots it has and how much it holds. */\nexport interface BagOption {\n slots: number\n capacityKg: number\n}\n\n/** The bag a bag item gives: its `carry.capacityKg` is the slot count the team's game reads from `cap`; the weight limit is the character's. */\nexport const bagOption = (bag: ItemDef | undefined, characterMaxKg: number, rules: ItemRules = DEFAULT_RULES): BagOption => ({\n slots: bag?.carry ? Math.round(bag.carry.capacityKg) : rules.inventory.defaultSlots,\n capacityKg: characterMaxKg,\n})\n\nexport type TakeResult = 'ok' | 'bag_full' | 'too_heavy'\n\n/** Whether `item` can go into the bag now: first the slots, then the weight. */\nexport const canTake = (item: ItemDef, bag: BagOption, used: { slots: number; loadKg: number }): TakeResult => {\n if (used.slots >= bag.slots) return 'bag_full'\n if (used.loadKg + item.weight > bag.capacityKg) return 'too_heavy'\n return 'ok'\n}\n","import type { Freshness } from './decay'\nimport { DEFAULT_RULES } from './rules'\nimport type { ItemRules } from './rules-types'\nimport type { ItemDef } from './types'\nimport type { UseEffectDef } from './use-types'\n\nexport interface UseContext {\n /** State of the food, from `freshness()`. Default `fresh`. */\n freshness?: Freshness\n /** The food was cooked: it keeps only `cookedPoisonFactor` of its poison chance. */\n isCooked?: boolean\n}\n\nexport interface NutritionResult {\n satiety: number\n hydration: number\n morale: number\n poisonChance: number\n isEdible: boolean\n}\n\nexport interface UseResult {\n useId: string\n effect: string\n params: Readonly<Record<string, unknown>>\n /** The item is spent (false for food too rotten to eat). */\n consumes: boolean\n /** Only for `consume_nutrition`. */\n nutrition?: NutritionResult\n}\n\nconst FRESH_NUTRITION = { satietyFactor: 1, poisonChance: 0, morale: 0, isEdible: true }\n\nconst nutritionOf = (item: ItemDef, context: UseContext, rules: ItemRules): NutritionResult => {\n const state = context.freshness ?? 'fresh'\n const effect = state === 'fresh' ? FRESH_NUTRITION : rules.use.freshness[state]\n const poisonShare = context.isCooked ? rules.use.cookedPoisonFactor : 1\n return {\n satiety: (item.nutrition?.satiety ?? 0) * effect.satietyFactor,\n hydration: item.nutrition?.hydration ?? 0,\n morale: effect.morale,\n poisonChance: effect.poisonChance * poisonShare,\n isEdible: effect.isEdible,\n }\n}\n\nconst findEffect = (item: ItemDef, effects: readonly UseEffectDef[]): UseEffectDef | undefined =>\n effects.find((entry) => entry.items?.includes(item.id)) ?? effects.find((entry) => entry.category?.includes(item.category))\n\n/**\n * What using `item` does, as data: the host applies it (the player, the needs, the world). Pure. The first effect that lists the item wins, then the first\n * that lists its category; `undefined` when nothing happens. Food past its best follows `rules.use.freshness`.\n */\nexport const resolveUse = (item: ItemDef, effects: readonly UseEffectDef[], context: UseContext = {}, rules: ItemRules = DEFAULT_RULES): UseResult | undefined => {\n const def = findEffect(item, effects)\n if (!def) return undefined\n const base = { useId: def.id, effect: def.effect, params: def.params ?? {}, consumes: def.consumes }\n if (def.effect !== 'consume_nutrition') return base\n const nutrition = nutritionOf(item, context, rules)\n return { ...base, consumes: def.consumes && nutrition.isEdible, nutrition }\n}\n","import type { ItemsCatalog } from './catalog-types'\nimport type { RecipeDef, RecipeInput, RecipeOutput } from './recipe-types'\n\nexport interface CraftContext {\n catalog: ItemsCatalog\n /** Count of each item the character has, by id. */\n inventory: ReadonlyMap<string, number>\n /** Litres of each fluid at hand, by fluid id. */\n fluids?: Readonly<Record<string, number>>\n /** Stations and things of the world that are near (`fire`, `table`, `soil`). */\n world?: ReadonlySet<string>\n /** Skill levels by id. Without it the skill requirement is ignored. */\n skills?: Readonly<Record<string, number>>\n /** Ids unlocked so far. Without it `unlockedBy` is ignored. */\n unlocked?: ReadonlySet<string>\n}\n\nexport interface CraftCheck {\n isPossible: boolean\n /** One entry per unmet requirement: `item:nails`, `any:salt|sugar`, `match:cookable`, `fluid:water`, `world:fire`, `tool:hammer|axe`, `skill:carpentry:2`, `unlock:magazine`. */\n missing: string[]\n}\n\nexport type Consumption = { item: string; count: number; meters?: number } | { fluid: string; liters: number }\n\nexport type CraftOutcome = { item: string; count: number } | { effect: string; params: Readonly<Record<string, unknown>>; text?: string }\n\nexport type CraftResult = { isCrafted: true; seconds: number; consumed: Consumption[]; output: CraftOutcome } | { isCrafted: false; missing: string[] }\n\nconst countOf = (context: CraftContext, id: string): number => context.inventory.get(id) ?? 0\n\n/** Ids that satisfy an item input, in the order they are used. */\nconst candidatesOf = (input: RecipeInput, context: CraftContext): string[] => {\n if (input.item !== undefined) return [input.item]\n if (input.any) return [...input.any]\n if (!input.match) return []\n const { category, cookable } = input.match\n return context.catalog.list().filter((item) => (category === undefined || item.category === category) && (cookable === undefined || item.cookable === cookable)).map((item) => item.id)\n}\n\nconst describeInput = (input: RecipeInput): string => {\n if (input.item !== undefined) return `item:${input.item}`\n if (input.any) return `any:${input.any.join('|')}`\n if (input.match) return `match:${input.match.category ?? 'cookable'}`\n if (input.fluid !== undefined) return `fluid:${input.fluid}`\n return `world:${(input.world ?? []).join('|')}`\n}\n\n/** Takes `needed` units from the candidates in order; returns what was taken, or undefined when there is not enough. */\nconst takeUnits = (candidates: readonly string[], needed: number, context: CraftContext): Consumption[] | undefined => {\n const taken: Consumption[] = []\n let remaining = needed\n for (const id of candidates) {\n if (remaining <= 0) break\n const count = Math.min(remaining, countOf(context, id))\n if (count > 0) taken.push({ item: id, count })\n remaining -= count\n }\n return remaining > 0 ? undefined : taken\n}\n\nconst consumptionFor = (input: RecipeInput, context: CraftContext): Consumption[] | undefined => {\n if (input.world) return input.world.some((thing) => context.world?.has(thing)) ? [] : undefined\n if (input.fluid !== undefined) {\n const liters = input.liters ?? 1\n return (context.fluids?.[input.fluid] ?? 0) >= liters ? [{ fluid: input.fluid, liters }] : undefined\n }\n const taken = takeUnits(candidatesOf(input, context), input.qty ?? 1, context)\n if (!taken || input.meters === undefined) return taken\n return taken.map((entry) => ('item' in entry ? { ...entry, meters: input.meters as number } : entry))\n}\n\nconst toolMissing = (recipe: RecipeDef, context: CraftContext): string[] =>\n recipe.tools.flatMap((tool) => {\n const missing: string[] = []\n if (tool.anyOf.length > 0 && !tool.anyOf.some((id) => countOf(context, id) > 0)) missing.push(`tool:${tool.anyOf.join('|')}`)\n if (tool.world && !tool.world.some((station) => context.world?.has(station))) missing.push(`world:${tool.world.join('|')}`)\n return missing\n })\n\nconst gateMissing = (recipe: RecipeDef, context: CraftContext): string[] => {\n const missing: string[] = []\n if (context.skills && recipe.skill && (context.skills[recipe.skill.id] ?? 0) < recipe.skill.level) missing.push(`skill:${recipe.skill.id}:${recipe.skill.level}`)\n if (context.unlocked && recipe.unlockedBy !== undefined && !context.unlocked.has(recipe.unlockedBy)) missing.push(`unlock:${recipe.unlockedBy}`)\n return missing\n}\n\n/** Whether the character can make the recipe now, and what is missing when not. Skill and `unlockedBy` are ignored when the host does not pass `skills` or `unlocked`. */\nexport const canCraft = (recipe: RecipeDef, context: CraftContext): CraftCheck => {\n const missing = [\n ...gateMissing(recipe, context),\n ...toolMissing(recipe, context),\n ...recipe.inputs.filter((input) => consumptionFor(input, context) === undefined).map(describeInput),\n ]\n return { isPossible: missing.length === 0, missing }\n}\n\nconst outcomeOf = (output: RecipeOutput, random: () => number): CraftOutcome => {\n if ('effect' in output) return { effect: output.effect, params: output.params, ...(output.text === undefined ? {} : { text: output.text }) }\n const [low, high] = output.qty\n return { item: output.item, count: low + Math.floor(random() * (high - low + 1)) }\n}\n\n/**\n * Makes the recipe: what is consumed and what comes out. Pure: the host takes the consumed things from its inventory and applies the output (an item is\n * added, an effect such as \"window with 200 life\" is the host's to handle). Draws one number only when the output quantity is a range.\n */\nexport const craft = (recipe: RecipeDef, context: CraftContext, random: () => number): CraftResult => {\n const check = canCraft(recipe, context)\n if (!check.isPossible) return { isCrafted: false, missing: check.missing }\n const consumed = recipe.inputs.flatMap((input) => consumptionFor(input, context) ?? [])\n return { isCrafted: true, seconds: recipe.seconds, consumed, output: outcomeOf(recipe.output, random) }\n}\n","import type { DepletionRule } from '../rules-types'\nimport type { ItemRules } from '../rules-types'\nimport type { PlaceTag } from '../types'\n\n/** Share of the normal stock a place still has `day` days into the outbreak: `max(floor, 1 - perDay x day)`; 1 on day 0 and where no rule names the place. */\nexport const depletionFactor = (place: PlaceTag, day: number, rules: ItemRules): number => {\n if (day <= 0) return 1\n const rule: DepletionRule | undefined = rules.finds.depletion.find((entry) => entry.places.includes(place))\n return rule ? Math.max(rule.floor, 1 - rule.perDay * day) : 1\n}\n\n/** The count of stacks after the days of the outbreak and the region: the roll is drawn first, so day 0 and an unknown region leave it as it was. */\nexport const scaleCount = (count: number, scale: { place: PlaceTag; day: number; region?: string }, rules: ItemRules): number => {\n const region = scale.region === undefined ? 1 : (rules.finds.regionMultiplier[scale.region] ?? 1)\n return Math.round(count * depletionFactor(scale.place, scale.day, rules) * region)\n}\n","import type { ItemsCatalog } from '../catalog-types'\nimport type { ItemCategory, ItemDef, PlaceTag } from '../types'\n\n/** The items of `category` a roll can draw in `place`: no fixed-spot rarity, nothing excluded; when none lists the place, every one of the category (the game's recuo). */\nexport const rollableItems = (catalog: ItemsCatalog, category: ItemCategory, place: PlaceTag): ItemDef[] => {\n const { rules } = catalog\n const rollable = catalog.byCategory(category).filter((item) => !rules.rarity.fixedOnly.includes(item.rarity) && !rules.finds.rollExcluded.includes(item.id))\n const here = rollable.filter((item) => item.finds?.places.includes(place))\n return here.length > 0 ? here : rollable\n}\n\nexport const rarityWeight = (catalog: ItemsCatalog, item: ItemDef): number => catalog.rules.rarity.weights[item.rarity]\n","import type { ItemsCatalog } from '../catalog-types'\nimport type { ItemCategory } from '../types'\n\nimport { scaleCount } from './depletion'\nimport { rarityWeight, rollableItems } from './rarity-pool'\nimport type { FindsQuery } from './types'\n\nconst pickCategory = (weights: Readonly<Record<ItemCategory, number>>, random: () => number): ItemCategory | undefined => {\n let remaining = random()\n const categories = Object.keys(weights)\n for (const category of categories) {\n remaining -= weights[category] as number\n if (remaining <= 0) return category\n }\n return categories[0]\n}\n\n/**\n * Port of the team game's `rollItems` (jogo.js L68796-68812) on the container table (`CONT_TAB`) and the rarity weights (`RAR_W`).\n *\n * Order of the random draws, the contract the golden test holds: one for the count; then for each stack one to pick the category, and (unless the category\n * has nothing to draw) one to pick the item among those of the place, by rarity weight. Nothing else draws. The count is scaled by the day and the region\n * after it is drawn, so day 0 gives exactly the game's rolls. Returns the item ids, one per stack.\n */\nexport const rollByCategory = (catalog: ItemsCatalog, query: FindsQuery): string[] => {\n const { rules } = catalog\n const table = catalog.containers.get(query.container) ?? catalog.containers.get(rules.finds.fallbackContainer)\n if (!table) return []\n const [low, high] = table.count ?? [0, 0]\n const drawn = low + Math.floor(query.rng() * (high - low + 1))\n const count = scaleCount(drawn, { place: query.place, day: query.day ?? 0, ...(query.region === undefined ? {} : { region: query.region }) }, rules)\n const weights = table.categories ?? table.categoriesByPlace?.[query.place] ?? table.categoriesByPlace?.['*']\n if (!weights) return []\n\n const picked: string[] = []\n for (let stack = 0; stack < count; stack++) {\n const category = pickCategory(weights, query.rng)\n const pool = category === undefined ? [] : rollableItems(catalog, category, query.place)\n if (pool.length === 0) continue\n const total = pool.reduce((sum, item) => sum + rarityWeight(catalog, item), 0)\n let remaining = query.rng() * total\n for (const item of pool) {\n remaining -= rarityWeight(catalog, item)\n if (remaining <= 0) {\n picked.push(item.id)\n break\n }\n }\n }\n return picked\n}\n","import type { ItemRules } from '../rules-types'\nimport type { FindState } from '../rules-types'\nimport type { PoolEntry } from '../types'\n\nimport type { FindsQuery } from './types'\n\nexport interface PoolPick {\n itemId: string\n count: number\n /** The pool entry it came from; none for the junk an abandoned container leaves. */\n entry?: PoolEntry\n}\n\ninterface Candidate {\n entry: PoolEntry\n weight: number\n}\n\n/** Draws without replacement by weight (the viewer's `ordemPonderada`): one draw per candidate, all of them, before anything is cut. */\nconst weightedOrder = (candidates: readonly Candidate[], random: () => number): Candidate[] => {\n const remaining = [...candidates]\n const ordered: Candidate[] = []\n while (remaining.length > 0) {\n const total = remaining.reduce((sum, candidate) => sum + candidate.weight, 0)\n let x = random() * total\n let index = 0\n for (; index < remaining.length - 1; index++) {\n x -= (remaining[index] as Candidate).weight\n if (x < 0) break\n }\n ordered.push(...remaining.splice(index, 1))\n }\n return ordered\n}\n\nconst countFor = (pool: readonly PoolEntry[], state: FindState, footprint: number, query: FindsQuery, rules: ItemRules): number => {\n const settings = rules.finds.pool\n const base = Math.max(settings.min, Math.min(settings.max, Math.round(settings.offset + footprint * settings.perSquareMetre + query.rng() * settings.jitter)))\n if (state === 'fortified') return Math.min(pool.length, base + settings.fortifiedExtra)\n if (state === 'abandoned') return query.rng() < settings.abandonedEmptyChance ? 0 : settings.abandonedKept\n return base\n}\n\n/**\n * Port of the 3D house viewer's `sorteiaConteudo` (visor-casas-3d.js L10837-10863) over a pool of what a kind of furniture holds.\n *\n * Order of the random draws, the contract the golden test holds: one for the count (`base`); for an abandoned container one more (empty or not); then a full\n * weighted order of the pool (one draw per entry, before the cut to the count); then one per kept entry for its quantity; and only for an abandoned and empty\n * container one last draw for the junk. Fortified: more entries, supplies weigh x2.5 and give +1. Abandoned: 40% empty, otherwise one entry, plus junk and,\n * in a fridge or freezer, spoiled food.\n */\nexport const rollExplicitPool = (pool: readonly PoolEntry[], state: FindState, query: FindsQuery, rules: ItemRules): PoolPick[] => {\n const settings = rules.finds.pool\n const n = countFor(pool, state, query.footprint ?? settings.defaultFootprint, query, rules)\n const isFortified = state === 'fortified'\n const candidates = pool.map((entry) => ({ entry, weight: isFortified && entry.isSupply ? entry.weight * settings.supplyWeight : entry.weight }))\n\n const picks: PoolPick[] = []\n for (const { entry } of weightedOrder(candidates, query.rng).slice(0, n)) {\n const [low, high] = entry.qty\n const bonus = isFortified && entry.isSupply ? settings.supplyExtraQty : 0\n picks.push({ itemId: entry.item, count: low + Math.floor(query.rng() * (high - low + 1)) + bonus, entry })\n }\n if (state === 'abandoned') {\n if (picks.length === 0 && query.rng() < settings.junkChance) picks.push({ itemId: settings.junkItem, count: 1 })\n if (settings.coldContainers.includes(query.container) || (query.key !== undefined && settings.coldContainers.includes(query.key))) picks.push({ itemId: settings.spoiledFoodItem, count: 1 })\n }\n return picks\n}\n","import type { ItemsCatalog } from '../catalog-types'\nimport type { ItemDef } from '../types'\n\nimport type { FindData, FindsQuery } from './types'\n\nconst WEARING_DURABILITY = ['condition', 'hits', 'cuts']\n\nconst hasCondition = (item: ItemDef): boolean => item.protection !== undefined || item.weapon !== undefined || WEARING_DURABILITY.includes(item.durability?.kind ?? '')\n\nconst between = (range: readonly [number, number], random: () => number): number => range[0] + random() * (range[1] - range[0])\n\n/** Age of food when it turns up: the days since the outbreak plus 0 to 60% of the shelf life. On day 0 that is the normal turnover of the shelf. One draw. */\nexport const spawnAgeDays = (item: ItemDef, outbreakDays: number, random: () => number, catalog: ItemsCatalog): number | undefined =>\n item.shelfLife === undefined ? undefined : outbreakDays + between(catalog.rules.spawn.ageShare, random) * item.shelfLife\n\nconst isRetailStock = (query: FindsQuery, catalog: ItemsCatalog): boolean => {\n const { spawn } = catalog.rules\n const isRetail = spawn.retailNew && (query.day ?? 0) <= 0 && spawn.retailPlaces.includes(query.place)\n return isRetail && (spawn.retailContainers.length === 0 || spawn.retailContainers.includes(query.container))\n}\n\n/**\n * The state an item has when it turns up, drawn after all the stacks of a container were chosen (so the choice itself matches the sources): food age, condition\n * by rarity (100 for shop stock on day 0), battery charge and how full a jerrycan is. Draws in that order, only what applies.\n */\nexport const spawnData = (item: ItemDef, query: FindsQuery, catalog: ItemsCatalog): FindData | undefined => {\n const { spawn } = catalog.rules\n const data: { ageDays?: number; condition?: number; charge?: number; fill?: number } = {}\n const ageDays = spawnAgeDays(item, query.day ?? 0, query.rng, catalog)\n if (ageDays !== undefined) data.ageDays = ageDays\n if (hasCondition(item)) data.condition = isRetailStock(query, catalog) ? 100 : Math.round(between(spawn.condition[item.rarity], query.rng))\n if (item.charge) data.charge = between(spawn.charge, query.rng)\n if (spawn.fillable.includes(item.id)) data.fill = between(spawn.fill, query.rng)\n return Object.keys(data).length > 0 ? data : undefined\n}\n","import { deepMerge } from '@adatechnology/game-contracts'\nimport type { DeepPartial } from '@adatechnology/game-contracts'\n\nimport type { ItemsCatalog } from '../catalog-types'\nimport type { ItemRules } from '../rules-types'\n\nimport { rollByCategory } from './category-roll'\nimport { rollExplicitPool } from './explicit-pool'\nimport { spawnData } from './spawn-state'\nimport type { FindStack, FindsProvider, FindsQuery } from './types'\n\nexport interface FindsProviderOptions {\n /** `team` (default): a container with an explicit pool in the catalog uses it; `none`: always the category roll. */\n pools?: 'team' | 'none'\n /** Changes to the `finds` and `spawn` rules for this provider. */\n rules?: DeepPartial<Pick<ItemRules, 'finds' | 'spawn'>>\n /** Names a host uses for a place, mapped to the place tags of the catalog (a floorplan `cabin` is a `lodge`). */\n placeAliases?: Readonly<Record<string, string>>\n /** Places where the explicit pools apply (the viewer's pools describe dwellings); the others use the category roll. Default: every place. */\n poolPlaces?: readonly string[]\n /** A container with neither a pool nor a table: `fallback` (default) uses the table of `rules.finds.fallbackContainer`, like the game; `pass` answers `null` so the host's own default applies. */\n unknownContainers?: 'fallback' | 'pass'\n}\n\n/**\n * What a container holds, as the floorplan's `FindsProvider` asks it. A container with an explicit pool (`query.key` or the container id) draws from it\n * (port of the viewer); otherwise the category roll of the game draws on the container table. Every stack is chosen first, in the order of the sources, and\n * only then each gets its state (age, condition, charge, fill) from the same generator. Items the catalog no longer has (removed by a game) are dropped\n * after the draws, so the others do not change.\n */\nexport function createFindsProvider(catalog: ItemsCatalog, options: FindsProviderOptions & { unknownContainers: 'pass' }): FindsProvider\nexport function createFindsProvider(catalog: ItemsCatalog, options?: FindsProviderOptions): { findsFor(query: FindsQuery): readonly FindStack[] }\nexport function createFindsProvider(catalog: ItemsCatalog, options: FindsProviderOptions = {}): FindsProvider {\n const rules = options.rules ? deepMerge(catalog.rules, options.rules) : catalog.rules\n const scoped: ItemsCatalog = rules === catalog.rules ? catalog : { ...catalog, rules }\n const usesPools = (options.pools ?? 'team') === 'team'\n\n const aliases = options.placeAliases ?? {}\n const poolPlaces = options.poolPlaces\n\n const passes = options.unknownContainers === 'pass'\n\n const choose = (query: FindsQuery): FindStack[] | null => {\n const pool = usesPools && (poolPlaces === undefined || poolPlaces.includes(query.place)) ? (scoped.pools.get(query.key ?? query.container) ?? scoped.pools.get(query.container)) : undefined\n if (!pool && passes && !scoped.containers.has(query.container)) return null\n if (!pool) return rollByCategory(scoped, query).map((itemId) => ({ itemId, count: 1 }))\n return rollExplicitPool(pool, rules.finds.states[query.state] ?? 'normal', query, rules).map(({ itemId, count }) => ({ itemId, count }))\n }\n\n return {\n findsFor(asked) {\n const query = aliases[asked.place] === undefined ? asked : { ...asked, place: aliases[asked.place] as string }\n const chosen = choose(query)\n if (chosen === null) return null\n return chosen\n .filter((stack) => scoped.has(stack.itemId))\n .map((stack) => {\n const data = spawnData(scoped.get(stack.itemId), query, scoped)\n return data ? { ...stack, data } : stack\n })\n },\n }\n}\n","import type { ColliderOptions, WorldCollider } from './types'\n\ninterface Box {\n ids: string[]\n object: string\n u0: number\n u1: number\n v0: number\n v1: number\n y0: number\n y1: number\n}\n\nconst volume = (b: Box): number => (b.u1 - b.u0) * (b.v1 - b.v0) * (b.y1 - b.y0)\nconst overlap = (a0: number, a1: number, b0: number, b1: number): number => Math.max(0, Math.min(a1, b1) - Math.max(a0, b0))\n\nfunction tryMerge(a: Box, b: Box, gap: number, slack: number): Box | null {\n if (a.u0 - gap > b.u1 || b.u0 - gap > a.u1) return null\n if (a.v0 - gap > b.v1 || b.v0 - gap > a.v1) return null\n if (a.y0 - gap > b.y1 || b.y0 - gap > a.y1) return null\n const u0 = Math.min(a.u0, b.u0)\n const u1 = Math.max(a.u1, b.u1)\n const v0 = Math.min(a.v0, b.v0)\n const v1 = Math.max(a.v1, b.v1)\n const y0 = Math.min(a.y0, b.y0)\n const y1 = Math.max(a.y1, b.y1)\n const hull = (u1 - u0) * (v1 - v0) * (y1 - y0)\n const common = overlap(a.u0, a.u1, b.u0, b.u1) * overlap(a.v0, a.v1, b.v0, b.v1) * overlap(a.y0, a.y1, b.y0, b.y1)\n const real = volume(a) + volume(b) - common\n if (hull > real * (1 + slack) + 1e-9) return null\n return { ids: [...a.ids, ...b.ids], object: a.object, u0, u1, v0, v1, y0, y1 }\n}\n\n/**\n * Union overlapping or adjacent colliders of the same object (same rotation) into fewer OBBs.\n * A merge happens only when the merged box adds at most `slack` extra volume over the real union, so coverage is never lost\n * and never grows noticeably. Colliders of different objects or rotations are left alone.\n */\nexport function mergeColliders(list: WorldCollider[], options: Pick<ColliderOptions, 'gap' | 'slack'> = {}): WorldCollider[] {\n const gap = options.gap ?? 0.02\n const slack = options.slack ?? 0.1\n const groups = new Map<string, WorldCollider[]>()\n for (const c of list) {\n const key = `${c.object}|${Math.round(c.ry * 1000)}`\n const g = groups.get(key)\n if (g) g.push(c)\n else groups.set(key, [c])\n }\n const out: WorldCollider[] = []\n for (const g of groups.values()) {\n const ry = g[0]?.ry ?? 0\n const cs = Math.cos(ry)\n const sn = Math.sin(ry)\n let boxes: Box[] = g.map((c) => {\n const u = c.cx * cs - c.cz * sn\n const v = c.cx * sn + c.cz * cs\n return { ids: [c.id], object: c.object, u0: u - c.hw, u1: u + c.hw, v0: v - c.hd, v1: v + c.hd, y0: c.y0, y1: c.y1 }\n })\n let changed = boxes.length > 1\n while (changed) {\n changed = false\n outer: for (let i = 0; i < boxes.length; i++) {\n for (let j = i + 1; j < boxes.length; j++) {\n const m = tryMerge(boxes[i] as Box, boxes[j] as Box, gap, slack)\n if (!m) continue\n boxes = boxes.filter((_, k) => k !== i && k !== j)\n boxes.push(m)\n changed = true\n break outer\n }\n }\n }\n for (const b of boxes) {\n const u = (b.u0 + b.u1) / 2\n const v = (b.v0 + b.v1) / 2\n out.push({\n id: b.ids.length === 1 ? (b.ids[0] as string) : `${b.ids[0]}+${b.ids.length - 1}`,\n object: b.object,\n cx: u * cs + v * sn,\n cz: -u * sn + v * cs,\n hw: (b.u1 - b.u0) / 2,\n hd: (b.v1 - b.v0) / 2,\n ry,\n y0: b.y0,\n y1: b.y1,\n })\n }\n }\n return out\n}\n","/** FNV-1a hash of a string to a 32-bit unsigned integer. */\nexport function hashString(text: string): number {\n let h = 2166136261\n for (let i = 0; i < text.length; i++) {\n h ^= text.charCodeAt(i)\n h = Math.imul(h, 16777619)\n }\n return h >>> 0\n}\n\n/** Deterministic random generator (mulberry32). Returns a function producing floats in [0, 1). Numbers and strings are accepted as seeds. */\nexport function rng(seed: number | string = 1): () => number {\n let a = (typeof seed === 'number' ? Math.floor(seed) >>> 0 : hashString(seed)) || 0x9e3779b9\n return () => {\n a = (a + 0x6d2b79f5) >>> 0\n let t = a\n t = Math.imul(t ^ (t >>> 15), t | 1)\n t ^= t + Math.imul(t ^ (t >>> 7), t | 61)\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n}\n","import { rng } from './rng'\nimport type { ItemCatalog, ItemStack, LootTables, SlotSpec, Vec3 } from './types'\n\n/** True when an item of `size` [w, h, d] fits in a slot of `max`; width and depth may be swapped (the item is turned). */\nexport function fitsSize(size: Vec3, max: Vec3): boolean {\n const eps = 1e-9\n if (size[1] > max[1] + eps) return false\n return (size[0] <= max[0] + eps && size[2] <= max[2] + eps) || (size[2] <= max[0] + eps && size[0] <= max[2] + eps)\n}\n\n/** Does the item type fit the slot. Types missing from the catalog are accepted (the host owns unknown items). */\nexport function fitsSlot(type: string, slot: SlotSpec, items?: ItemCatalog): boolean {\n const def = items?.[type]\n return def ? fitsSize(def.size, slot.max) : true\n}\n\n/**\n * Deterministic contents for a container: one entry per slot. A slot is filled with probability `fill`; the entry is a weighted pick\n * among the table entries whose item fits the slot. Same seed, same table and same slots always give the same result.\n */\nexport function generateLoot(\n tables: LootTables | undefined,\n tableId: string,\n slots: SlotSpec[],\n seed: number | string,\n items?: ItemCatalog,\n): Array<ItemStack | null> {\n const table = tables?.[tableId]\n const out: Array<ItemStack | null> = slots.map(() => null)\n if (!table) return out\n const rand = rng(seed)\n slots.forEach((slot, i) => {\n // Always draw the same number of random values per slot so a change in one slot does not shift the others.\n const roll = rand()\n const pick = rand()\n const q = rand()\n if (roll >= table.fill) return\n const candidates = table.entries.filter((e) => e.weight > 0 && fitsSlot(e.type, slot, items))\n const total = candidates.reduce((s, e) => s + e.weight, 0)\n if (total <= 0) return\n let r = pick * total\n let chosen = candidates[candidates.length - 1]\n for (const e of candidates) {\n r -= e.weight\n if (r < 0) {\n chosen = e\n break\n }\n }\n const [lo, hi] = chosen.qty ?? [1, 1]\n const def = items?.[chosen.type]\n const stackMax = def ? (def.stack ?? 1) : Number.POSITIVE_INFINITY\n const qty = Math.max(1, Math.min(stackMax, lo + Math.floor(q * (hi - lo + 1))))\n out[i] = { type: chosen.type, qty }\n })\n return out\n}\n","import type { InteractablesLocale, Strings } from './types'\n\n/** Engine-level prompts and messages. Content packs add their own keys through `WorldOptions.strings`. */\nexport const locales: Record<InteractablesLocale, Strings> = {\n en: {\n 'prompt.open': 'Open {name}',\n 'prompt.close': 'Close {name}',\n 'prompt.turn_on': 'Turn on {name}',\n 'prompt.turn_off': 'Turn off {name}',\n 'prompt.take': 'Take',\n 'prompt.sit': 'Sit',\n 'prompt.lie_down': 'Lie down',\n 'prompt.get_up': 'Get up',\n 'prompt.light': 'Light',\n 'prompt.change_channel': 'Change channel',\n 'prompt.read': 'Read',\n 'prompt.breaker_on': 'Switch on {circuit}',\n 'prompt.breaker_off': 'Switch off {circuit}',\n 'prompt.open_valve': 'Open valve',\n 'prompt.close_valve': 'Close valve',\n no_power: 'No power',\n no_gas: 'No gas',\n no_water: 'No water',\n locked: 'Locked',\n requires_open: 'Open it first',\n already_lit: 'Already lit',\n needs_ignition: 'Light it first',\n turn_on_first: 'Turn it on first',\n empty: 'Empty',\n full: 'Full',\n },\n 'pt-BR': {\n 'prompt.open': 'Abrir {name}',\n 'prompt.close': 'Fechar {name}',\n 'prompt.turn_on': 'Ligar {name}',\n 'prompt.turn_off': 'Desligar {name}',\n 'prompt.take': 'Pegar',\n 'prompt.sit': 'Sentar',\n 'prompt.lie_down': 'Deitar',\n 'prompt.get_up': 'Levantar',\n 'prompt.light': 'Acender',\n 'prompt.change_channel': 'Trocar de canal',\n 'prompt.read': 'Ler',\n 'prompt.breaker_on': 'Ligar {circuit}',\n 'prompt.breaker_off': 'Desligar {circuit}',\n 'prompt.open_valve': 'Abrir registro',\n 'prompt.close_valve': 'Fechar registro',\n no_power: 'Sem energia',\n no_gas: 'Sem gás',\n no_water: 'Sem água',\n locked: 'Trancado',\n requires_open: 'Abra primeiro',\n already_lit: 'Já está aceso',\n needs_ignition: 'Acenda primeiro',\n turn_on_first: 'Ligue primeiro',\n empty: 'Vazio',\n full: 'Cheio',\n },\n}\n\n/** Pick a built-in locale for an arbitrary tag (`pt`, `pt-br`, `en-GB`...). Unknown tags give `en`. */\nexport function pickLocale(locale: string | undefined): InteractablesLocale {\n if (!locale) return 'en'\n const l = locale.toLowerCase()\n if (l === 'pt-br' || l === 'pt') return 'pt-BR'\n return 'en'\n}\n\n/**\n * Merged table for a locale: built-in `en`, then the built-in table of `locale`, then the extra strings for `en` and for `locale`.\n * Lookup therefore falls back to English, and the caller never gets undefined for engine keys.\n */\nexport function resolveStrings(locale?: string, extra?: Partial<Record<InteractablesLocale, Strings>>): Strings {\n const loc = pickLocale(locale)\n return { ...locales.en, ...locales[loc], ...(extra?.en ?? {}), ...(loc === 'en' ? {} : (extra?.[loc] ?? {})) }\n}\n\n/** Replace `{name}` placeholders. Unknown placeholders are left untouched. */\nexport function format(text: string, vars?: Record<string, string | number>): string {\n if (!vars) return text\n return text.replace(/\\{(\\w+)\\}/g, (m, k: string) => (k in vars ? String(vars[k]) : m))\n}\n","import type { Motion, Transform, Vec3 } from './types'\n\nexport interface Pose {\n /** Translation relative to the pivot. */\n position: Vec3\n /** Rotation around `axis` (in the part frame) by `angle` radians. */\n rotation: { axis: Vec3; angle: number }\n scale: Vec3\n}\n\nconst DEFAULT_AXIS: Vec3 = [0, 1, 0]\n\n/**\n * Pose of a part at animation value `t` (0..1), relative to its pivot, so any renderer can apply it:\n * `position`, then `rotation` around the pivot, then `scale` along the axis.\n */\nexport function poseOf(motion: Motion, t: number): Pose {\n const tt = Math.max(0, Math.min(1, t))\n const axis = motion.axis ?? DEFAULT_AXIS\n const pose: Pose = { position: [0, 0, 0], rotation: { axis: [axis[0], axis[1], axis[2]], angle: 0 }, scale: [1, 1, 1] }\n switch (motion.kind) {\n case 'hinge':\n case 'rocker':\n case 'knob':\n pose.rotation.angle = (motion.angle ?? Math.PI / 2) * tt\n break\n case 'slide': {\n const d = (motion.distance ?? 0.4) * tt\n pose.position = [axis[0] * d, axis[1] * d, axis[2] * d]\n break\n }\n case 'scale': {\n const k = 1 + ((motion.scale ?? 0.2) - 1) * tt\n pose.scale = [axis[0] !== 0 ? k : 1, axis[1] !== 0 ? k : 1, axis[2] !== 0 ? k : 1]\n break\n }\n }\n return pose\n}\n\n/** Rotate a vector around +y by `ry` radians (same convention as three.js `rotation.y`). */\nexport function rotY(v: Vec3, ry: number): Vec3 {\n const c = Math.cos(ry)\n const s = Math.sin(ry)\n return [c * v[0] + s * v[2], v[1], -s * v[0] + c * v[2]]\n}\n\n/** Object-local point to world space. */\nexport function localToWorld(transform: Transform, v: Vec3): Vec3 {\n const r = rotY(v, transform.ry)\n return [transform.x + r[0], transform.y + r[1], transform.z + r[2]]\n}\n\n/** World point to object-local space. */\nexport function worldToLocal(transform: Transform, v: Vec3): Vec3 {\n return rotY([v[0] - transform.x, v[1] - transform.y, v[2] - transform.z], -transform.ry)\n}\n\n/** Rodrigues rotation of `v` around a unit-length-normalised `axis` by `angle`. */\nexport function rotateAxis(v: Vec3, axis: Vec3, angle: number): Vec3 {\n const len = Math.hypot(axis[0], axis[1], axis[2]) || 1\n const k: Vec3 = [axis[0] / len, axis[1] / len, axis[2] / len]\n const c = Math.cos(angle)\n const s = Math.sin(angle)\n const dot = k[0] * v[0] + k[1] * v[1] + k[2] * v[2]\n const cross: Vec3 = [k[1] * v[2] - k[2] * v[1], k[2] * v[0] - k[0] * v[2], k[0] * v[1] - k[1] * v[0]]\n return [v[0] * c + cross[0] * s + k[0] * dot * (1 - c), v[1] * c + cross[1] * s + k[1] * dot * (1 - c), v[2] * c + cross[2] * s + k[2] * dot * (1 - c)]\n}\n\nexport function clamp(v: number, lo: number, hi: number): number {\n return v < lo ? lo : v > hi ? hi : v\n}\n\nexport function dist3(a: Vec3, b: Vec3): number {\n return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2])\n}\n","import { mergeColliders } from './colliders'\nimport { fitsSlot, generateLoot } from './loot'\nimport { format, resolveStrings } from './locale'\nimport { clamp, dist3, localToWorld, rotateAxis, rotY, worldToLocal } from './math'\nimport type {\n ActiveEmitter,\n BurnerSpec,\n ColliderOptions,\n DeviceSpec,\n FixtureSpec,\n FuelSourceSpec,\n Interactable,\n InteractionSpec,\n ItemStack,\n ObjectSpec,\n PartSpec,\n SeatResult,\n SoftSpec,\n TankSpec,\n TargetOptions,\n Transform,\n Vec3,\n World,\n WorldCollider,\n WorldEvent,\n WorldObject,\n WorldOptions,\n WorldState,\n} from './types'\n\n// ---------------------------------------------------------------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------------------------------------------------------------\n\nconst DEFAULT_ACTOR = 'player'\nconst DEFAULT_RADIUS = 1.2\nconst AUTO_RANGE = 2\nconst SMOKE_TAIL = 20\nconst FLUSH_SECONDS = 2\nconst ACTOR_HEIGHT = 1.7\nconst PUSH_GAIN = 1.2\nconst SWEEP_STEPS = 24\nconst FLOW_DEFAULTS: Record<string, number> = { tap: 6, shower: 8, utility_sink: 6, flush: 0, hose: 10, filter: 1 }\n\n// ---------------------------------------------------------------------------------------------------------------------------------\n// Internal state\n// ---------------------------------------------------------------------------------------------------------------------------------\n\ninterface PartState {\n spec: PartSpec\n t: number\n target: number\n maxT: number\n}\n\ninterface DeviceState {\n spec: DeviceSpec\n on: boolean\n channel: number\n}\n\ninterface FuelState {\n spec: FuelSourceSpec\n level: number\n}\n\ninterface BurnerState {\n spec: BurnerSpec\n on: boolean\n lit: boolean\n /** Seconds of smoke left after a solid-fuel fire went out. */\n smoke: number\n}\n\ninterface TankState {\n spec: TankSpec\n level: number\n}\n\ninterface FixtureState {\n spec: FixtureSpec\n on: boolean\n flowing: boolean\n /** Delivered / wanted in the last step, 0..1. */\n frac: number\n /** Seconds left of a flush. */\n timer: number\n}\n\ninterface SoftState {\n spec: SoftSpec\n ax: number\n az: number\n vax: number\n vaz: number\n phase: number\n}\n\ninterface DamageState {\n max: number\n hp: number\n}\n\ninterface ObjState {\n id: string\n public: WorldObject\n spec: ObjectSpec\n parts: Map<string, PartState>\n contents: Map<string, Array<ItemStack | null>>\n devices: Map<string, DeviceState>\n fuels: Map<string, FuelState>\n burners: Map<string, BurnerState>\n tanks: Map<string, TankState>\n fixtures: Map<string, FixtureState>\n soft: Map<string, SoftState>\n hp: Map<string, DamageState>\n interactions: InteractionSpec[]\n}\n\nfunction cloneStack(s: ItemStack | null): ItemStack | null {\n return s ? { type: s.type, qty: s.qty, ...(s.data ? { data: JSON.parse(JSON.stringify(s.data)) as Record<string, unknown> } : {}) } : null\n}\n\nfunction humanize(id: string): string {\n const s = id.replace(/[_-]+/g, ' ').trim()\n return s.charAt(0).toUpperCase() + s.slice(1)\n}\n\n// ---------------------------------------------------------------------------------------------------------------------------------\n// World\n// ---------------------------------------------------------------------------------------------------------------------------------\n\nexport function createWorld(options: WorldOptions = {}): World {\n const seed = options.seed ?? 1\n const objs = new Map<string, ObjState>()\n const breakers = new Map<string, boolean>()\n const seats = new Map<string, { object: string; seat: string }>()\n const plugs = new Map<string, string | null>()\n let locale = options.locale ?? 'en'\n let table = resolveStrings(locale, options.strings)\n let grid = options.grid ?? true\n let rain = options.rain ?? 0\n let wind: [number, number] = options.wind ? [options.wind[0], options.wind[1]] : [0, 0]\n let defaultTank: string | null = null\n let defaultTankExplicit = false\n let time = 0\n\n // ------------------------------------------------------------------ text & events\n function t(key: string, vars?: Record<string, string | number>): string {\n return format(table[key] ?? key, vars)\n }\n\n function emit(e: WorldEvent): void {\n options.onEvent?.(e)\n }\n\n function message(key: string): void {\n emit({ type: 'message', key, text: t(key) })\n }\n\n // ------------------------------------------------------------------ lookup helpers\n function gid(o: ObjState, local: string): string {\n return `${o.id}:${local}`\n }\n\n /** Find the first entity matching a world-wide id `objectId:localId`. */\n function findGlobal<T>(id: string, pick: (o: ObjState, local: string) => T | undefined): { o: ObjState; value: T; local: string } | null {\n for (const o of objs.values()) {\n if (!id.startsWith(o.id + ':')) continue\n const local = id.slice(o.id.length + 1)\n const value = pick(o, local)\n if (value !== undefined) return { o, value, local }\n }\n return null\n }\n\n function objectOrThrow(id: string): ObjState {\n const o = objs.get(id)\n if (!o) throw new Error(`Unknown object \"${id}\"`)\n return o\n }\n\n function originOf(o: ObjState): Vec3 {\n const tr = o.public.transform\n return [tr.x, tr.y, tr.z]\n }\n\n function toWorld(o: ObjState, v: Vec3): Vec3 {\n return localToWorld(o.public.transform, v)\n }\n\n // ------------------------------------------------------------------ parts\n function openThreshold(p: PartState): number {\n return Math.min(0.5, p.maxT * 0.9)\n }\n\n function isOpen(o: ObjState, partId: string | undefined): boolean {\n if (!partId) return true\n const p = o.parts.get(partId)\n if (!p) return true\n return p.maxT >= 0.1 && p.t >= openThreshold(p)\n }\n\n function targetIsOpen(p: PartState): boolean {\n return p.maxT >= 0.1 && p.target >= openThreshold(p)\n }\n\n function writeT(o: ObjState, p: PartState, v: number): void {\n p.t = v\n o.public.parts[p.spec.id] = v\n }\n\n function gated(o: ObjState, p: PartState): boolean {\n return !!p.spec.requires && o.parts.has(p.spec.requires) && !isOpen(o, p.spec.requires)\n }\n\n function setOnePart(o: ObjState, p: PartState, target: number, instant: boolean): void {\n const next = clamp(target, 0, p.maxT)\n const changed = next !== p.target\n p.target = next\n if (instant) writeT(o, p, next)\n if (changed || instant) emit({ type: 'part', object: o.id, part: p.spec.id, t: p.t, target: p.target })\n }\n\n function setPartTarget(o: ObjState, partId: string, target: number, instant = false): void {\n const p = o.parts.get(partId)\n if (!p) return\n const group = p.spec.group\n const members = group ? [...o.parts.values()].filter((q) => q.spec.group === group) : [p]\n for (const q of members) {\n setOne(o, q, target, instant)\n }\n }\n\n function setOne(o: ObjState, p: PartState, target: number, instant: boolean): void {\n setOnePart(o, p, target, instant)\n // a part that other parts require closes them with it (the drawer behind a door closes with the door)\n if (!targetIsOpen(p)) {\n for (const q of o.parts.values()) {\n if (q.spec.requires === p.spec.id && q.target > 0) setPartTarget(o, q.spec.id, 0, instant)\n }\n }\n }\n\n /** Frame of a part right now: rotation (axis in object-local, angle) and translation, both about its pivot. */\n function partFrame(o: ObjState, partId: string | undefined): { pivot: Vec3; axis: Vec3; angle: number; offset: Vec3 } | null {\n if (!partId) return null\n const p = o.parts.get(partId)\n if (!p) return null\n const m = p.spec.motion\n const axis = rotY(m.axis ?? [0, 1, 0], p.spec.ry ?? 0)\n const frame = { pivot: p.spec.pivot, axis, angle: 0, offset: [0, 0, 0] as Vec3 }\n if (m.kind === 'slide') {\n const d = (m.distance ?? 0.4) * p.t\n frame.offset = [axis[0] * d, axis[1] * d, axis[2] * d]\n } else if (m.kind !== 'scale') {\n frame.angle = (m.angle ?? Math.PI / 2) * p.t\n }\n return frame\n }\n\n function applyPart(o: ObjState, partId: string | undefined, local: Vec3): Vec3 {\n const f = partFrame(o, partId)\n if (!f) return local\n const rel: Vec3 = [local[0] - f.pivot[0], local[1] - f.pivot[1], local[2] - f.pivot[2]]\n const r = f.angle !== 0 ? rotateAxis(rel, f.axis, f.angle) : rel\n return [f.pivot[0] + r[0] + f.offset[0], f.pivot[1] + r[1] + f.offset[1], f.pivot[2] + r[2] + f.offset[2]]\n }\n\n /** Moves the full closed-pose slab of a part and returns the largest t whose pose keeps every sampled point clear. */\n function fitBounds(o: ObjState, p: PartState, b: NonNullable<PartSpec['bounds']>, blockedAt: (x: number, z: number) => boolean): number {\n const STEP = 0.05\n const ANGLES = 48\n const m = p.spec.motion\n const axis = rotY(m.axis ?? [0, 1, 0], p.spec.ry ?? 0)\n const lo: Vec3 = [Math.min(b[0], b[3]), Math.min(b[1], b[4]), Math.min(b[2], b[5])]\n const hi: Vec3 = [Math.max(b[0], b[3]), Math.max(b[1], b[4]), Math.max(b[2], b[5])]\n const axisPoints = (i: number): number[] => {\n const n = Math.max(1, Math.ceil((hi[i] - lo[i]) / STEP))\n return Array.from({ length: n + 1 }, (_, k) => lo[i] + ((hi[i] - lo[i]) * k) / n)\n }\n const xs = axisPoints(0)\n const ys = axisPoints(1)\n const zs = axisPoints(2)\n // `blocked` only sees x/z, so only the floor footprint matters. About a vertical axis (and for slides) y never changes\n // the footprint, so the x/z outline is enough; otherwise the footprint boundary is the projection of the box edges.\n const flat = m.kind === 'slide' || Math.abs(axis[1]) > 0.999\n const seen = new Set<string>()\n const pts: Vec3[] = []\n const add = (x: number, y: number, z: number): void => {\n const key = `${x.toFixed(5)},${y.toFixed(5)},${z.toFixed(5)}`\n if (seen.has(key)) return\n seen.add(key)\n pts.push([x, y, z])\n }\n if (flat) {\n for (const x of xs) for (const z of [lo[2], hi[2]]) add(x, lo[1], z)\n for (const z of zs) for (const x of [lo[0], hi[0]]) add(x, lo[1], z)\n } else {\n for (const x of xs) for (const y of [lo[1], hi[1]]) for (const z of [lo[2], hi[2]]) add(x, y, z)\n for (const y of ys) for (const x of [lo[0], hi[0]]) for (const z of [lo[2], hi[2]]) add(x, y, z)\n for (const z of zs) for (const x of [lo[0], hi[0]]) for (const y of [lo[1], hi[1]]) add(x, y, z)\n }\n const tr = o.public.transform\n const pivot = p.spec.pivot\n for (let i = 1; i <= ANGLES; i++) {\n const tt = i / ANGLES\n const angle = m.kind === 'slide' ? 0 : (m.angle ?? Math.PI / 2) * tt\n const d = m.kind === 'slide' ? (m.distance ?? 0.4) * tt : 0\n for (const [x, y, z] of pts) {\n let q: Vec3 = [x, y, z]\n if (angle !== 0) {\n const r = rotateAxis([x - pivot[0], y - pivot[1], z - pivot[2]], axis, angle)\n q = [pivot[0] + r[0], pivot[1] + r[1], pivot[2] + r[2]]\n }\n const w = localToWorld(tr, [q[0] + axis[0] * d, 0, q[2] + axis[2] * d])\n if (blockedAt(w[0], w[2])) return (i - 1) / ANGLES\n }\n }\n return 1\n }\n\n function fitSweep(objectId: string, partId: string, blocked: (x: number, z: number) => boolean): number {\n const o = objectOrThrow(objectId)\n const p = o.parts.get(partId)\n if (!p) throw new Error(`Unknown part \"${partId}\" on \"${objectId}\"`)\n const sweep = p.spec.sweep\n let result = 1\n const bounds = p.spec.bounds\n const mk = p.spec.motion.kind\n if (bounds && (mk === 'hinge' || mk === 'rocker' || mk === 'knob' || mk === 'slide')) {\n result = fitBounds(o, p, bounds, blocked)\n } else if (sweep) {\n const x0 = Math.min(sweep[0], sweep[2])\n const x1 = Math.max(sweep[0], sweep[2])\n const z0 = Math.min(sweep[1], sweep[3])\n const z1 = Math.max(sweep[1], sweep[3])\n const tr = o.public.transform\n const hit = (lx: number, lz: number): boolean => {\n const w = localToWorld(tr, [lx, 0, lz])\n return blocked(w[0], w[2])\n }\n const m = p.spec.motion\n const axis = rotY(m.axis ?? [0, 1, 0], p.spec.ry ?? 0)\n if (m.kind === 'hinge' || m.kind === 'rocker' || m.kind === 'knob') {\n if (Math.abs(axis[1]) >= 0.5) {\n const angle = m.angle ?? Math.PI / 2\n const total = -angle * Math.sign(axis[1])\n const [px, , pz] = p.spec.pivot\n const cx = (x0 + x1) / 2\n const cz = (z0 + z1) / 2\n const w = x1 - x0\n const d = z1 - z0\n const radius = Math.abs(angle) > 1.7 ? Math.min(w, d) : Math.max(w, d)\n const tol = 0.05\n const inRect = (x: number, z: number) => x >= x0 - tol && x <= x1 + tol && z >= z0 - tol && z <= z1 + tol\n const centered = Math.hypot(cx - px, cz - pz) < 1e-6\n const mid = Math.atan2(cz - pz, cx - px)\n for (let i = 1; i <= SWEEP_STEPS && result === 1; i++) {\n const tt = i / SWEEP_STEPS\n let isHit = false\n if (centered) {\n for (let x = x0; x <= x1 + 1e-9 && !isHit; x += 0.1) for (let z = z0; z <= z1 + 1e-9 && !isHit; z += 0.1) isHit = hit(x, z)\n } else {\n const phi = mid - total / 2 + total * tt\n const steps = Math.max(2, Math.ceil(radius / 0.1))\n for (let k = 1; k <= steps && !isHit; k++) {\n const r = (radius * k) / steps\n const x = px + Math.cos(phi) * r\n const z = pz + Math.sin(phi) * r\n if (inRect(x, z)) isHit = hit(x, z)\n }\n }\n if (isHit) result = (i - 1) / SWEEP_STEPS\n }\n }\n } else if (m.kind === 'slide') {\n const len = Math.hypot(axis[0], axis[2])\n if (len > 1e-6) {\n const dx = axis[0] / len\n const dz = axis[2] / len\n const proj = [x0 * dx + z0 * dz, x1 * dx + z0 * dz, x0 * dx + z1 * dz, x1 * dx + z1 * dz]\n const pmin = Math.min(...proj)\n const pmax = Math.max(...proj)\n const nx = Math.max(1, Math.ceil((x1 - x0) / 0.1))\n const nz = Math.max(1, Math.ceil((z1 - z0) / 0.1))\n for (let i = 1; i <= SWEEP_STEPS && result === 1; i++) {\n const limit = pmin + (pmax - pmin) * (i / SWEEP_STEPS)\n let isHit = false\n for (let a = 0; a <= nx && !isHit; a++) {\n for (let b = 0; b <= nz && !isHit; b++) {\n const x = x0 + ((x1 - x0) * a) / nx\n const z = z0 + ((z1 - z0) * b) / nz\n if (x * dx + z * dz <= limit + 1e-9 && hit(x, z)) isHit = true\n }\n }\n if (isHit) result = (i - 1) / SWEEP_STEPS\n }\n }\n }\n }\n p.maxT = result\n if (p.target > result) p.target = result\n if (p.t > result) writeT(o, p, result)\n return result\n }\n\n // ------------------------------------------------------------------ containers\n function containerOf(o: ObjState, id: string) {\n return o.spec.containers?.find((c) => c.id === id)\n }\n\n function accessible(objectId: string, containerId: string): boolean {\n const o = objs.get(objectId)\n if (!o) return false\n const c = containerOf(o, containerId)\n if (!c) return false\n return isOpen(o, c.access)\n }\n\n function contents(objectId: string, containerId: string): Array<ItemStack | null> {\n return (objs.get(objectId)?.contents.get(containerId) ?? []).map(cloneStack)\n }\n\n function take(objectId: string, containerId: string, slot: number, actor: string = DEFAULT_ACTOR): ItemStack | null {\n const o = objs.get(objectId)\n if (!o) return null\n const list = o.contents.get(containerId)\n if (!list || !accessible(objectId, containerId)) return null\n const stack = list[slot]\n if (!stack) return null\n const def = options.items?.[stack.type]\n if (def && def.pickable === false) return null\n list[slot] = null\n emit({ type: 'pickup', object: objectId, container: containerId, slot, stack: cloneStack(stack) as ItemStack, actor })\n return stack\n }\n\n function put(objectId: string, containerId: string, stack: ItemStack): number | null {\n const o = objs.get(objectId)\n if (!o) return null\n const c = containerOf(o, containerId)\n const list = o.contents.get(containerId)\n if (!c || !list || !accessible(objectId, containerId)) return null\n const maxStack = options.items?.[stack.type]?.stack ?? 1\n for (let i = 0; i < list.length; i++) {\n const cur = list[i]\n if (cur && cur.type === stack.type && !cur.data && !stack.data && cur.qty + stack.qty <= maxStack) {\n cur.qty += stack.qty\n return i\n }\n }\n for (let i = 0; i < list.length; i++) {\n if (!list[i] && fitsSlot(stack.type, c.slots[i], options.items)) {\n list[i] = cloneStack(stack)\n return i\n }\n }\n message('full')\n return null\n }\n\n function slotPosition(objectId: string, containerId: string, slot: number): Vec3 {\n const o = objectOrThrow(objectId)\n const c = containerOf(o, containerId)\n const s = c?.slots[slot]\n if (!c || !s) throw new Error(`Unknown slot ${slot} of \"${containerId}\" on \"${objectId}\"`)\n return toWorld(o, applyPart(o, s.part, s.pos))\n }\n\n // ------------------------------------------------------------------ power\n function rebuildPlugs(): void {\n plugs.clear()\n const sockets: Array<{ id: string; pos: Vec3; circuit?: string; local: string }> = []\n for (const o of objs.values()) {\n for (const s of o.spec.sockets ?? []) sockets.push({ id: gid(o, s.id), local: s.id, pos: toWorld(o, s.pos), circuit: s.circuit })\n }\n const used = new Set<string>()\n const autos: Array<{ o: ObjState; d: DeviceState }> = []\n for (const o of objs.values()) {\n for (const d of o.devices.values()) {\n const key = gid(o, d.spec.id)\n const want = d.spec.socket\n if (want === undefined || d.spec.circuit) {\n plugs.set(key, null)\n } else if (want === 'auto') {\n plugs.set(key, null)\n autos.push({ o, d })\n } else {\n const s = sockets.find((x) => x.id === want) ?? sockets.find((x) => x.id === gid(o, want)) ?? sockets.find((x) => x.local === want)\n plugs.set(key, s ? s.id : null)\n if (s) used.add(s.id)\n }\n }\n }\n for (const { o, d } of autos) {\n const from = d.spec.plug ? toWorld(o, d.spec.plug) : originOf(o)\n let best: (typeof sockets)[number] | null = null\n let bestD = AUTO_RANGE\n for (const s of sockets) {\n if (used.has(s.id)) continue\n const dd = dist3(from, s.pos)\n if (dd <= bestD) {\n best = s\n bestD = dd\n }\n }\n if (best) {\n used.add(best.id)\n plugs.set(gid(o, d.spec.id), best.id)\n }\n }\n }\n\n function breaker(circuit: string): boolean {\n return breakers.get(circuit) ?? true\n }\n\n function devicePowered(o: ObjState, d: DeviceState): boolean {\n if (d.spec.electric === false) return true\n if (!grid) return false\n if (d.spec.circuit) return breaker(d.spec.circuit)\n if (d.spec.socket === undefined) return false\n const sid = plugs.get(gid(o, d.spec.id))\n if (!sid) return false\n const found = findGlobal(sid, (oo, local) => oo.spec.sockets?.find((s) => s.id === local))\n if (!found) return false\n return found.value.circuit ? breaker(found.value.circuit) : true\n }\n\n function getDevice(objectId: string, deviceId: string): { o: ObjState; d: DeviceState } | null {\n const o = objs.get(objectId)\n const d = o?.devices.get(deviceId)\n return o && d ? { o, d } : null\n }\n\n function powerSnapshot(): Map<string, boolean> {\n const m = new Map<string, boolean>()\n for (const o of objs.values()) for (const d of o.devices.values()) m.set(gid(o, d.spec.id), d.on && devicePowered(o, d))\n return m\n }\n\n function emitPowerChanges(before: Map<string, boolean>): void {\n for (const o of objs.values()) {\n for (const d of o.devices.values()) {\n const key = gid(o, d.spec.id)\n const now = d.on && devicePowered(o, d)\n if (before.get(key) !== now && d.on) emit({ type: 'device', object: o.id, device: d.spec.id, on: d.on, powered: devicePowered(o, d), channel: d.spec.kind === 'screen' ? d.channel : undefined })\n }\n }\n }\n\n function setDeviceOn(o: ObjState, d: DeviceState, on: boolean): void {\n d.on = on\n if (d.spec.part) setPartTarget(o, d.spec.part, on ? 1 : 0)\n const powered = devicePowered(o, d)\n emit({ type: 'device', object: o.id, device: d.spec.id, on, powered, channel: d.spec.kind === 'screen' ? d.channel : undefined })\n if (on && !powered) message('no_power')\n }\n\n function setDevice(objectId: string, deviceId: string, on: boolean): boolean {\n const f = getDevice(objectId, deviceId)\n if (!f) return false\n if (f.d.on !== on) setDeviceOn(f.o, f.d, on)\n return true\n }\n\n function setFixture(objectId: string, fixtureId: string, on: boolean): boolean {\n const fx = objs.get(objectId)?.fixtures.get(fixtureId)\n if (!fx) return false\n if (fx.on !== on) toggleFixture(objs.get(objectId) as ObjState, fixtureId)\n return fx.on === on\n }\n\n function setBurner(objectId: string, burnerId: string, on: boolean): boolean {\n const o = objs.get(objectId)\n const b = o?.burners.get(burnerId)\n if (!o || !b) return false\n const isOn = b.on || b.lit\n if (on === isOn) return true\n if (!on) return toggleBurner(o, burnerId)\n return b.spec.needsIgnition ? ignite(o, burnerId) : toggleBurner(o, burnerId)\n }\n\n function resolveDevice(o: ObjState, id: string): { o: ObjState; d: DeviceState } | null {\n if (id.includes(':')) {\n const f = findGlobal(id, (oo, local) => oo.devices.get(local))\n return f ? { o: f.o, d: f.value } : null\n }\n const local = o.devices.get(id)\n if (local) return { o, d: local }\n for (const oo of objs.values()) {\n const d = oo.devices.get(id)\n if (d) return { o: oo, d }\n }\n return null\n }\n\n function switchIsOn(o: ObjState, spec: { part?: string; controls: string[] }): boolean {\n if (spec.part && o.parts.has(spec.part)) return targetIsOpen(o.parts.get(spec.part) as PartState)\n return spec.controls.some((id) => resolveDevice(o, id)?.d.on)\n }\n\n function toggleSwitch(o: ObjState, id: string): boolean {\n const sw = o.spec.switches?.find((s) => s.id === id)\n if (!sw) return false\n const on = !switchIsOn(o, sw)\n if (sw.part) setPartTarget(o, sw.part, on ? 1 : 0)\n let unpowered = false\n for (const ref of sw.controls) {\n const r = resolveDevice(o, ref)\n if (!r) continue\n r.d.on = on\n if (r.d.spec.part) setPartTarget(r.o, r.d.spec.part, on ? 1 : 0)\n const powered = devicePowered(r.o, r.d)\n if (on && !powered) unpowered = true\n emit({ type: 'device', object: r.o.id, device: r.d.spec.id, on, powered })\n }\n emit({ type: 'switch', object: o.id, switch: sw.id, on })\n if (unpowered) message('no_power')\n return true\n }\n\n // ------------------------------------------------------------------ fuel\n function resolveFuel(o: ObjState, b: BurnerState): { o: ObjState; f: FuelState } | null {\n const want = b.spec.fuel\n if (want === 'auto') {\n const from = toWorld(o, b.spec.pos)\n let best: { o: ObjState; f: FuelState } | null = null\n let bestD = AUTO_RANGE\n for (const oo of objs.values()) {\n for (const f of oo.fuels.values()) {\n if (f.spec.kind !== 'gas') continue\n const d = dist3(from, originOf(oo))\n if (d <= bestD) {\n best = { o: oo, f }\n bestD = d\n }\n }\n }\n return best\n }\n if (want.includes(':')) {\n const f = findGlobal(want, (oo, local) => oo.fuels.get(local))\n return f ? { o: f.o, f: f.value } : null\n }\n const local = o.fuels.get(want)\n return local ? { o, f: local } : null\n }\n\n function valveOpen(o: ObjState, f: FuelState): boolean {\n const v = f.spec.valve\n if (!v || !o.parts.has(v)) return true\n return targetIsOpen(o.parts.get(v) as PartState)\n }\n\n function canBurn(b: BurnerState, ref: { o: ObjState; f: FuelState } | null): boolean {\n return !!ref && ref.f.level > 0 && valveOpen(ref.o, ref.f)\n }\n\n function isSolid(ref: { f: FuelState } | null): boolean {\n return !!ref && ref.f.spec.kind !== 'gas'\n }\n\n function emitBurner(o: ObjState, b: BurnerState): void {\n emit({ type: 'burner', object: o.id, burner: b.spec.id, on: b.on, lit: b.lit })\n }\n\n function extinguish(o: ObjState, b: BurnerState, turnOff: boolean): void {\n const ref = resolveFuel(o, b)\n const wasLit = b.lit\n b.lit = false\n if (turnOff) b.on = false\n if (b.spec.part) setPartTarget(o, b.spec.part, b.on ? 1 : 0)\n if (wasLit && isSolid(ref)) b.smoke = SMOKE_TAIL\n emitBurner(o, b)\n }\n\n function toggleBurner(o: ObjState, id: string): boolean {\n const b = o.burners.get(id)\n if (!b) return false\n if (b.on || b.lit) {\n extinguish(o, b, true)\n return true\n }\n if (b.spec.needsIgnition) return false\n const ref = resolveFuel(o, b)\n b.on = true\n b.lit = canBurn(b, ref)\n if (b.lit) b.smoke = 0\n if (b.spec.part) setPartTarget(o, b.spec.part, 1)\n emitBurner(o, b)\n if (!b.lit) message(ref && ref.f.level <= 0 ? 'empty' : 'no_gas')\n return true\n }\n\n function ignite(o: ObjState, id: string): boolean {\n const b = o.burners.get(id)\n if (!b || b.lit) return false\n const ref = resolveFuel(o, b)\n if (!canBurn(b, ref)) {\n message(ref && ref.f.level <= 0 ? 'empty' : 'no_gas')\n return false\n }\n b.on = true\n b.lit = true\n b.smoke = 0\n if (b.spec.part) setPartTarget(o, b.spec.part, 1)\n emitBurner(o, b)\n return true\n }\n\n function toggleFuel(o: ObjState, id: string): boolean {\n const f = o.fuels.get(id)\n if (!f || !f.spec.valve || !o.parts.has(f.spec.valve)) return false\n const p = o.parts.get(f.spec.valve) as PartState\n setPartTarget(o, f.spec.valve, targetIsOpen(p) ? 0 : 1)\n return true\n }\n\n // ------------------------------------------------------------------ water\n function resolveTank(o: ObjState, id: string | undefined): { o: ObjState; t: TankState } | null {\n const want = id === undefined || id === 'auto' ? defaultTank : id\n if (!want) return null\n const f = findGlobal(want, (oo, local) => oo.tanks.get(local))\n if (f) return { o: f.o, t: f.value }\n const local = o.tanks.get(want)\n return local ? { o, t: local } : null\n }\n\n function drawWater(o: ObjState, fx: FixtureState, liters: number): number {\n if (liters <= 0) return 0\n if (options.waterSupply) return Math.max(0, Math.min(liters, options.waterSupply.draw(gid(o, fx.spec.id), liters)))\n const tank = resolveTank(o, fx.spec.tank)\n if (!tank) return 0\n const got = Math.min(liters, tank.t.level)\n tank.t.level -= got\n return got\n }\n\n function emitFixture(o: ObjState, fx: FixtureState): void {\n emit({ type: 'fixture', object: o.id, fixture: fx.spec.id, on: fx.on, flowing: fx.flowing })\n }\n\n function flowOf(fx: FixtureState): number {\n return fx.spec.flow ?? FLOW_DEFAULTS[fx.spec.kind] ?? 6\n }\n\n function stepFixture(o: ObjState, fx: FixtureState, dt: number): void {\n if (!fx.on) return\n if (fx.spec.kind === 'flush') {\n fx.timer -= dt\n if (fx.timer <= 0) {\n fx.on = false\n fx.flowing = false\n fx.frac = 0\n if (fx.spec.part) setPartTarget(o, fx.spec.part, 0)\n emitFixture(o, fx)\n }\n return\n }\n if (dt <= 0) return\n const wanted = (flowOf(fx) / 60) * dt\n const got = drawWater(o, fx, wanted)\n const flowing = got > 1e-9\n fx.frac = wanted > 0 ? Math.min(1, got / wanted) : 0\n if (flowing !== fx.flowing) {\n fx.flowing = flowing\n emitFixture(o, fx)\n }\n }\n\n function toggleFixture(o: ObjState, id: string): boolean {\n const fx = o.fixtures.get(id)\n if (!fx) return false\n if (fx.on) {\n fx.on = false\n fx.flowing = false\n fx.frac = 0\n if (fx.spec.part) setPartTarget(o, fx.spec.part, 0)\n emitFixture(o, fx)\n return true\n }\n fx.on = true\n if (fx.spec.part) setPartTarget(o, fx.spec.part, 1)\n if (fx.spec.kind === 'flush') {\n const volume = fx.spec.volume ?? 6\n const got = drawWater(o, fx, volume)\n fx.flowing = got > 1e-9\n fx.frac = fx.flowing ? 1 : 0\n fx.timer = FLUSH_SECONDS\n if (!fx.flowing) {\n fx.on = false\n if (fx.spec.part) setPartTarget(o, fx.spec.part, 0)\n }\n } else {\n fx.flowing = false\n fx.frac = 0\n stepFixture(o, fx, 1 / 60)\n }\n emitFixture(o, fx)\n if (!fx.flowing) message('no_water')\n return true\n }\n\n // ------------------------------------------------------------------ seats\n function seatTaken(objectId: string, seatId: string): string | null {\n for (const [actor, s] of seats) if (s.object === objectId && s.seat === seatId) return actor\n return null\n }\n\n function stand(actor: string = DEFAULT_ACTOR): Vec3 | null {\n const s = seats.get(actor)\n if (!s) return null\n seats.delete(actor)\n const o = objs.get(s.object)\n const spec = o?.spec.seats?.find((x) => x.id === s.seat)\n emit({ type: 'seat', object: s.object, seat: s.seat, actor, seated: false })\n if (!o || !spec) return null\n return toWorld(o, spec.exit)\n }\n\n function sit(objectId: string, seatId: string, actor: string = DEFAULT_ACTOR): SeatResult | null {\n const o = objs.get(objectId)\n const seat = o?.spec.seats?.find((s) => s.id === seatId)\n if (!o || !seat) return null\n const holder = seatTaken(objectId, seatId)\n if (holder && holder !== actor) return null\n if (seats.has(actor) && !holder) stand(actor)\n seats.set(actor, { object: objectId, seat: seatId })\n const eye = seat.eye ?? (seat.pose === 'lie' ? 0.18 : 0.62)\n emit({ type: 'seat', object: objectId, seat: seatId, actor, seated: true })\n return { eye: toWorld(o, [seat.pos[0], seat.pos[1] + eye, seat.pos[2]]), yaw: o.public.transform.ry + seat.ry + Math.PI, pose: seat.pose }\n }\n\n // ------------------------------------------------------------------ soft volumes\n function push(pos: Vec3, radius: number, vel: Vec3): void {\n for (const o of objs.values()) {\n if (!o.soft.size) continue\n const tr = o.public.transform\n const lp = worldToLocal(tr, pos)\n const lv = rotY(vel, -tr.ry)\n for (const s of o.soft.values()) {\n const [w, h, d] = s.spec.size\n const hx = w / 2\n const hz = Math.max(d, 0.1) / 2\n const dx = Math.max(Math.abs(lp[0] - s.spec.anchor[0]) - hx, 0)\n const dz = Math.max(Math.abs(lp[2] - s.spec.anchor[2]) - hz, 0)\n if (Math.hypot(dx, dz) > radius) continue\n const top = tr.y + s.spec.anchor[1]\n if (pos[1] + ACTOR_HEIGHT < top - h || pos[1] > top) continue\n const cap = 8\n s.vax = clamp(s.vax - lv[2] * PUSH_GAIN, -cap, cap)\n s.vaz = clamp(s.vaz + lv[0] * PUSH_GAIN, -cap, cap)\n if (s.spec.kind === 'panel' && s.spec.part && lv[2] < -0.05 && lp[2] > s.spec.anchor[2]) setPartTarget(o, s.spec.part, 1)\n }\n }\n }\n\n function stepSoft(dt: number): void {\n const sub = Math.max(1, Math.ceil(dt / (1 / 120)))\n const h = dt / sub\n for (const o of objs.values()) {\n if (!o.soft.size) continue\n const wl = rotY([wind[0], 0, wind[1]], -o.public.transform.ry)\n for (const s of o.soft.values()) {\n const k = s.spec.stiffness ?? 30\n const c = s.spec.damping ?? 4\n const max = s.spec.maxAngle ?? 0.7\n const gain = s.spec.wind ?? (s.spec.kind === 'panel' ? 0 : 1)\n const gust = gain * (0.6 + 0.4 * Math.sin(time * 1.7 + s.phase)) * 0.1\n const fx = -wl[2] * gust\n const fz = wl[0] * gust\n for (let i = 0; i < sub; i++) {\n s.vax += (-k * s.ax - c * s.vax + fx) * h\n s.vaz += (-k * s.az - c * s.vaz + fz) * h\n s.ax += s.vax * h\n s.az += s.vaz * h\n }\n if (Math.abs(s.ax) > max) {\n s.ax = Math.sign(s.ax) * max\n s.vax = 0\n }\n if (Math.abs(s.az) > max) {\n s.az = Math.sign(s.az) * max\n s.vaz = 0\n }\n }\n }\n }\n\n // ------------------------------------------------------------------ damage\n function damage(objectId: string, target: string, amount: number, _kind?: string): { hp: number; destroyed: boolean } | null {\n const o = objs.get(objectId)\n if (o && options.destruction) {\n const pos = toWorld(o, [0, 0.5, 0])\n const type = _kind === 'gas' ? 'explosive' : _kind === 'fabric' ? 'slash' : 'blunt'\n const result = options.destruction.hitPart(objectId, target, { type, point: pos, energy: Math.max(0, amount), source: 'interactables' }) as { events?: Array<{ type?: string; part?: string }> } | null | undefined\n const destroyed = !!result?.events?.some((e) => e.type === 'fracture' && e.part === target)\n emit({ type: 'damage', object: objectId, target, hp: -1, destroyed })\n return { hp: -1, destroyed }\n }\n const st = o?.hp.get(target)\n if (!o || !st) return null\n if (st.hp <= 0) return { hp: 0, destroyed: true }\n const before = st.hp\n st.hp = Math.max(0, st.hp - Math.max(0, amount))\n const destroyed = st.hp <= 0\n emit({ type: 'damage', object: objectId, target, hp: st.hp, destroyed })\n const pos = toWorld(o, [0, 0.5, 0])\n const fuel = o.fuels.get(target)\n if (fuel && destroyed && fuel.spec.kind === 'gas' && fuel.level > 0) {\n emit({ type: 'explosion', object: objectId, pos, radius: 2 + fuel.level / 5 })\n fuel.level = 0\n }\n const tank = o.tanks.get(target)\n if (tank && before === st.max && st.hp < before) emit({ type: 'leak', object: objectId, target, pos })\n return { hp: st.hp, destroyed }\n }\n\n // ------------------------------------------------------------------ interactions\n function deriveInteractions(spec: ObjectSpec): InteractionSpec[] {\n const out: InteractionSpec[] = []\n const used = new Set<string>()\n const partPos = (id: string | undefined, fallback: Vec3): Vec3 => spec.parts?.find((p) => p.id === id)?.pivot ?? fallback\n const partLabel = (id: string | undefined): string => spec.parts?.find((p) => p.id === id)?.label ?? ''\n const claim = (id: string | undefined) => {\n if (id) used.add(id)\n }\n for (const d of spec.devices ?? []) {\n claim(d.part)\n const pos = d.part ? partPos(d.part, [0, 1, 0]) : (d.plug ?? [0, 1, 0])\n // a hard-wired lamp (circuit, no plug, no part to press) is driven by switches and `setDevice`: it never steals the interact key\n if (d.kind === 'lamp' && !d.part && !d.plug && d.electric !== false) continue\n out.push({ id: `device.${d.id}`, pos, action: { do: 'toggle_device', device: d.id }, label: partLabel(d.part) })\n if (d.kind === 'screen' && (d.channels ?? 1) > 1) out.push({ id: `channel.${d.id}`, pos, action: { do: 'cycle_device', device: d.id }, label: '' })\n }\n for (const s of spec.switches ?? []) {\n claim(s.part)\n out.push({ id: `switch.${s.id}`, pos: partPos(s.part, [0, 1.2, 0]), action: { do: 'toggle_switch', switch: s.id }, label: partLabel(s.part) })\n }\n const driven = new Set((spec.devices ?? []).map((d) => d.fixture))\n for (const f of spec.fixtures ?? []) {\n claim(f.part)\n if (driven.has(f.id)) continue\n out.push({ id: `fixture.${f.id}`, pos: f.part ? partPos(f.part, f.outlet) : f.outlet, action: { do: 'toggle_fixture', fixture: f.id }, label: partLabel(f.part) })\n }\n for (const b of spec.burners ?? []) {\n claim(b.part)\n const pos = b.part ? partPos(b.part, b.pos) : b.pos\n if (b.needsIgnition) out.push({ id: `ignite.${b.id}`, pos, action: { do: 'ignite', burner: b.id }, label: '' })\n out.push({ id: `burner.${b.id}`, pos, action: { do: 'toggle_burner', burner: b.id }, label: partLabel(b.part) })\n }\n for (const f of spec.fuels ?? []) {\n if (!f.valve) continue\n claim(f.valve)\n out.push({ id: `fuel.${f.id}`, pos: partPos(f.valve, [0, 0.5, 0]), action: { do: 'toggle_fuel', fuel: f.id }, label: partLabel(f.valve) })\n }\n for (const c of spec.containers ?? []) {\n if (!c.access) out.push({ id: `open.${c.id}`, pos: c.volume.center, action: { do: 'open_container', container: c.id }, label: '' })\n }\n for (const p of spec.parts ?? []) {\n if (used.has(p.id)) continue\n if (p.label) out.push({ id: `part.${p.id}`, pos: p.pivot, action: { do: 'toggle_part', part: p.id }, label: p.label })\n }\n for (const c of spec.containers ?? []) {\n if (c.access && !spec.parts?.find((p) => p.id === c.access)?.label) {\n out.push({ id: `open.${c.id}`, pos: partPos(c.access, c.volume.center), action: { do: 'open_container', container: c.id }, label: '' })\n }\n }\n for (const s of spec.seats ?? []) out.push({ id: `seat.${s.id}`, pos: s.pos, action: { do: 'sit', seat: s.id }, label: '' })\n return out\n }\n\n function typeName(o: ObjState): string {\n return table[`type.${o.spec.type}`] ?? humanize(o.spec.type)\n }\n\n function promptOf(o: ObjState, i: InteractionSpec): { label: string; verb: string; object: string } {\n const a = i.action\n let state: string | null = null\n let def = ''\n switch (a.do) {\n case 'toggle_part': {\n const p = o.parts.get(a.part)\n const open = !!p && targetIsOpen(p)\n state = open ? 'open' : 'closed'\n def = open ? 'prompt.close' : 'prompt.open'\n break\n }\n case 'open_container': {\n const c = containerOf(o, a.container)\n const p = c?.access ? o.parts.get(c.access) : undefined\n const open = p ? targetIsOpen(p) : false\n state = p ? (open ? 'open' : 'closed') : null\n def = open ? 'prompt.close' : 'prompt.open'\n break\n }\n case 'toggle_device': {\n const on = !!o.devices.get(a.device)?.on\n state = on ? 'on' : 'off'\n def = on ? 'prompt.turn_off' : 'prompt.turn_on'\n break\n }\n case 'toggle_switch': {\n const sw = o.spec.switches?.find((s) => s.id === a.switch)\n const on = !!sw && switchIsOn(o, sw)\n state = on ? 'on' : 'off'\n def = on ? 'prompt.turn_off' : 'prompt.turn_on'\n break\n }\n case 'toggle_breaker': {\n const on = breaker(a.circuit)\n state = on ? 'on' : 'off'\n def = on ? 'prompt.breaker_off' : 'prompt.breaker_on'\n break\n }\n case 'toggle_fixture': {\n const on = !!o.fixtures.get(a.fixture)?.on\n state = on ? 'on' : 'off'\n def = on ? 'prompt.turn_off' : 'prompt.turn_on'\n break\n }\n case 'toggle_burner': {\n const b = o.burners.get(a.burner)\n const on = !!b && (b.on || b.lit)\n state = on ? 'on' : 'off'\n def = on ? 'prompt.turn_off' : 'prompt.turn_on'\n break\n }\n case 'toggle_fuel': {\n const f = o.fuels.get(a.fuel)\n const open = !!f && valveOpen(o, f)\n state = open ? 'open' : 'closed'\n def = open ? 'prompt.close_valve' : 'prompt.open_valve'\n break\n }\n case 'take':\n def = 'prompt.take'\n break\n case 'read':\n def = 'prompt.read'\n break\n case 'ignite':\n def = 'prompt.light'\n break\n case 'cycle_device':\n def = 'prompt.change_channel'\n break\n case 'sit': {\n const seat = o.spec.seats?.find((s) => s.id === a.seat)\n const mine = seats.get(DEFAULT_ACTOR)\n def = mine && mine.object === o.id && mine.seat === a.seat ? 'prompt.get_up' : seat?.pose === 'lie' ? 'prompt.lie_down' : 'prompt.sit'\n break\n }\n }\n let key = def\n if (i.label) {\n if (state && table[`${i.label}.${state}`] !== undefined) key = `${i.label}.${state}`\n else if (table[i.label] !== undefined) key = i.label\n }\n const vars = { name: typeName(o), circuit: a.do === 'toggle_breaker' ? (a.name ?? a.circuit) : '' }\n const label = t(key, vars)\n // structured prompt: explicit `<key>.verb` / `<key>.object` entries win; otherwise the first word is the verb and the rest the object\n let verb: string\n let object: string\n if (table[`${key}.verb`] !== undefined) {\n verb = t(`${key}.verb`, vars)\n object = table[`${key}.object`] ? t(`${key}.object`, vars) : vars.name\n } else {\n const sp = label.indexOf(' ')\n verb = sp < 0 ? label : label.slice(0, sp)\n object = sp < 0 ? vars.name : label.slice(sp + 1)\n }\n return { label, verb, object }\n }\n\n /** Locale key of the reason an interaction cannot be used right now, or null when it can. */\n function unavailableReason(o: ObjState, i: InteractionSpec): string | null {\n const a = i.action\n if (!requirementMet(i)) return 'requires_open'\n switch (a.do) {\n case 'toggle_part': {\n const p = o.parts.get(a.part)\n return p && gated(o, p) ? 'requires_open' : null\n }\n case 'open_container': {\n const c = containerOf(o, a.container)\n const p = c?.access ? o.parts.get(c.access) : undefined\n return p && gated(o, p) ? 'requires_open' : null\n }\n case 'toggle_breaker': {\n const rocker = a.part ? o.parts.get(a.part) : undefined\n return rocker && gated(o, rocker) ? 'requires_open' : null\n }\n case 'take':\n case 'read':\n return accessible(o.id, a.container) ? null : 'requires_open'\n case 'ignite':\n return o.burners.get(a.burner)?.lit ? 'already_lit' : null\n case 'toggle_burner': {\n const b = o.burners.get(a.burner)\n return !b?.spec.needsIgnition || b.lit ? null : 'needs_ignition'\n }\n case 'cycle_device': {\n const d = o.devices.get(a.device)\n if (!d || !d.on) return 'turn_on_first'\n return devicePowered(o, d) ? null : 'no_power'\n }\n default:\n return null\n }\n }\n\n function requirementMet(i: InteractionSpec): boolean {\n if (!i.requires) return true\n const p = objs.get(i.requires.object)?.parts.get(i.requires.part)\n return !!p && targetIsOpen(p)\n }\n\n function interactablesOf(o: ObjState): Array<{ item: Interactable; spec: InteractionSpec; o: ObjState }> {\n return o.interactions.map((i) => {\n const id = gid(o, i.id)\n const p = promptOf(o, i)\n const item: Interactable = {\n id,\n object: o.id,\n kind: i.action.do,\n pos: toWorld(o, i.pos),\n radius: i.radius ?? DEFAULT_RADIUS,\n label: p.label,\n verb: p.verb,\n noun: p.object,\n available: true,\n act: (actor) => act(id, actor),\n }\n const reason = unavailableReason(o, i) ?? (options.canInteract?.(item, DEFAULT_ACTOR) === false ? 'locked' : null)\n if (reason) {\n item.available = false\n item.reason = reason\n }\n return { item, spec: i, o }\n })\n }\n\n function interactables(): Interactable[] {\n const out: Interactable[] = []\n for (const o of objs.values()) for (const e of interactablesOf(o)) out.push(e.item)\n return out\n }\n\n function target(eye: { x: number; y: number; z: number; yaw: number; pitch: number }, opts: TargetOptions = {}): Interactable | null {\n const reach = opts.reach ?? 1.8\n const cone = opts.cone ?? 0.45\n const dir: Vec3 = [-Math.sin(eye.yaw) * Math.cos(eye.pitch), Math.sin(eye.pitch), -Math.cos(eye.yaw) * Math.cos(eye.pitch)]\n let best: Interactable | null = null\n let bestScore = Infinity\n for (const o of objs.values()) {\n for (const e of interactablesOf(o)) {\n if (!e.item.available && !opts.includeUnavailable) continue\n if (opts.kinds && !opts.kinds.includes(e.item.kind)) continue\n if (opts.filter && !opts.filter(e.item)) continue\n const v: Vec3 = [e.item.pos[0] - eye.x, e.item.pos[1] - eye.y, e.item.pos[2] - eye.z]\n const d = Math.hypot(v[0], v[1], v[2])\n if (d > reach) continue\n const cos = d < 1e-6 ? 1 : clamp((v[0] * dir[0] + v[1] * dir[1] + v[2] * dir[2]) / d, -1, 1)\n const angle = Math.acos(cos)\n if (angle > cone) continue\n const score = (angle / cone) * 0.6 + (d / reach) * 0.4\n if (score < bestScore) {\n bestScore = score\n best = e.item\n }\n }\n }\n return best\n }\n\n function act(interactableId: string, actor: string = DEFAULT_ACTOR): boolean {\n let found: { item: Interactable; spec: InteractionSpec; o: ObjState } | null = null\n for (const o of objs.values()) {\n if (!interactableId.startsWith(o.id + ':')) continue\n const local = interactableId.slice(o.id.length + 1)\n if (!o.interactions.some((i) => i.id === local)) continue\n found = interactablesOf(o).find((e) => e.spec.id === local) ?? null\n if (found) break\n }\n if (!found) return false\n if (!requirementMet(found.spec)) return false\n if (options.canInteract?.(found.item, actor) === false) return false\n return execute(found.o, found.spec.action, actor)\n }\n\n function execute(o: ObjState, a: InteractionSpec['action'], actor: string): boolean {\n switch (a.do) {\n case 'toggle_part': {\n const p = o.parts.get(a.part)\n if (!p || gated(o, p)) return false\n setPartTarget(o, a.part, targetIsOpen(p) ? 0 : 1)\n return true\n }\n case 'open_container': {\n const c = containerOf(o, a.container)\n if (!c) return false\n if (c.access) return execute(o, { do: 'toggle_part', part: c.access }, actor)\n emit({ type: 'container', object: o.id, container: c.id, open: true })\n return true\n }\n case 'take': {\n if (!accessible(o.id, a.container)) return false\n const list = o.contents.get(a.container)\n if (!list) return false\n const slot = a.slot ?? list.findIndex((s) => s !== null)\n if (slot < 0 || !list[slot]) {\n message('empty')\n return false\n }\n return take(o.id, a.container, slot, actor) !== null\n }\n case 'read': {\n if (!accessible(o.id, a.container)) return false\n const stack = o.contents.get(a.container)?.[a.slot]\n if (!stack) {\n message('empty')\n return false\n }\n const key = typeof stack.data?.text === 'string' ? stack.data.text : null\n if (!key) return false\n message(key)\n return true\n }\n case 'toggle_device': {\n const d = o.devices.get(a.device)\n if (!d) return false\n setDeviceOn(o, d, !d.on)\n return true\n }\n case 'cycle_device': {\n const d = o.devices.get(a.device)\n if (!d || !d.on) return false\n if (!devicePowered(o, d)) {\n message('no_power')\n return false\n }\n d.channel = (d.channel + 1) % Math.max(1, d.spec.channels ?? 1)\n emit({ type: 'device', object: o.id, device: d.spec.id, on: true, powered: true, channel: d.channel })\n return true\n }\n case 'toggle_switch':\n return toggleSwitch(o, a.switch)\n case 'toggle_breaker': {\n const rocker = a.part ? o.parts.get(a.part) : undefined\n if (rocker && gated(o, rocker)) return false\n const on = !breaker(a.circuit)\n if (a.part) setPartTarget(o, a.part, on ? 1 : 0)\n world.power.setBreaker(a.circuit, on)\n return true\n }\n case 'toggle_fixture':\n return toggleFixture(o, a.fixture)\n case 'toggle_burner':\n return toggleBurner(o, a.burner)\n case 'ignite':\n return ignite(o, a.burner)\n case 'toggle_fuel':\n return toggleFuel(o, a.fuel)\n case 'sit': {\n const mine = seats.get(actor)\n if (mine && mine.object === o.id && mine.seat === a.seat) {\n stand(actor)\n return true\n }\n return sit(o.id, a.seat, actor) !== null\n }\n }\n }\n\n // ------------------------------------------------------------------ emitters, mirrors, colliders\n function emitters(): ActiveEmitter[] {\n const out: ActiveEmitter[] = []\n for (const o of objs.values()) {\n for (const e of o.spec.emitters ?? []) {\n let intensity = 0\n const b = o.burners.get(e.source)\n const fx = o.fixtures.get(e.source)\n const dev = o.devices.get(e.source)\n const fuel = o.fuels.get(e.source)\n if (b) {\n const ref = resolveFuel(o, b)\n const frac = ref ? clamp(ref.f.level / ref.f.spec.capacity, 0, 1) : 0\n if (b.lit) intensity = 0.35 + 0.65 * frac\n else if (e.kind === 'smoke' && b.smoke > 0) intensity = 0.6 * (b.smoke / SMOKE_TAIL)\n } else if (fx) {\n if (fx.on && fx.flowing) intensity = fx.frac\n } else if (dev) {\n if (dev.on && devicePowered(o, dev)) intensity = 1\n } else if (fuel) {\n const lit = [...o.burners.values()].some((bb) => bb.lit && resolveFuel(o, bb)?.f === fuel)\n if (lit) intensity = 0.35 + 0.65 * clamp(fuel.level / fuel.spec.capacity, 0, 1)\n }\n if (intensity <= 0) continue\n const dir = rotY(e.dir ?? [0, 1, 0], o.public.transform.ry)\n out.push({ id: gid(o, e.id), object: o.id, kind: e.kind, pos: toWorld(o, e.pos), dir, size: e.size ?? 0.3, ...(e.reach !== undefined ? { reach: e.reach } : {}), intensity: clamp(intensity, 0, 1) })\n }\n }\n return out\n }\n\n function mirrors(eye: Vec3, maxDistance = 10, count = 4) {\n const out: Array<{ id: string; center: Vec3; normal: Vec3; w: number; h: number; distance: number }> = []\n for (const o of objs.values()) {\n for (const m of o.spec.mirrors ?? []) {\n const center = toWorld(o, applyPart(o, m.part, m.center))\n const f = partFrame(o, m.part)\n const ln = f && f.angle !== 0 ? rotateAxis(m.normal, f.axis, f.angle) : m.normal\n const normal = rotY(ln, o.public.transform.ry)\n const len = Math.hypot(normal[0], normal[1], normal[2]) || 1\n const n: Vec3 = [normal[0] / len, normal[1] / len, normal[2] / len]\n const to: Vec3 = [eye[0] - center[0], eye[1] - center[1], eye[2] - center[2]]\n if (n[0] * to[0] + n[1] * to[1] + n[2] * to[2] <= 0) continue\n const distance = Math.hypot(to[0], to[1], to[2])\n if (distance > maxDistance) continue\n out.push({ id: gid(o, m.id), center, normal: n, w: m.w, h: m.h, distance })\n }\n }\n return out.sort((a, b) => a.distance - b.distance).slice(0, count)\n }\n\n function colliders(opts: ColliderOptions = {}): WorldCollider[] {\n const out: WorldCollider[] = []\n const fixed: WorldCollider[] = []\n for (const o of objs.values()) {\n const tr = o.public.transform\n for (const c of o.spec.colliders ?? []) {\n if (c.part && c.offWhenOpen && (o.parts.get(c.part)?.t ?? 0) > 0.5) continue\n let extra = 0\n if (c.part) {\n const f = partFrame(o, c.part)\n if (f && f.angle !== 0 && Math.abs(f.axis[1]) >= 0.5) extra = f.angle * Math.sign(f.axis[1])\n }\n const center = toWorld(o, applyPart(o, c.part, c.center))\n ;(c.part ? out : fixed).push({\n id: gid(o, c.id),\n object: o.id,\n cx: center[0],\n cz: center[2],\n hw: c.half[0],\n hd: c.half[2],\n ry: tr.ry + (c.ry ?? 0) + extra,\n y0: center[1] - c.half[1],\n y1: center[1] + c.half[1],\n })\n }\n }\n if (opts.merge) return [...mergeColliders(fixed, { gap: opts.gap, slack: opts.slack }), ...out]\n return [...fixed, ...out]\n }\n\n // ------------------------------------------------------------------ update\n function update(dt: number): void {\n if (!(dt > 0)) return\n time += dt\n for (const o of objs.values()) {\n for (const p of o.parts.values()) {\n if (p.t === p.target) continue\n if (gated(o, p) && p.target > p.t) continue // a gated part may still close\n const step = (p.spec.motion.speed ?? 2.5) * dt\n const next = p.t < p.target ? Math.min(p.target, p.t + step) : Math.max(p.target, p.t - step)\n writeT(o, p, next)\n if (next === p.target) emit({ type: 'part', object: o.id, part: p.spec.id, t: next, target: p.target })\n }\n }\n stepSoft(dt)\n for (const o of objs.values()) {\n for (const b of o.burners.values()) {\n if (b.lit) {\n const ref = resolveFuel(o, b)\n if (!ref || !valveOpen(ref.o, ref.f)) {\n extinguish(o, b, !!b.spec.needsIgnition)\n } else {\n ref.f.level = Math.max(0, ref.f.level - b.spec.rate * dt)\n if (ref.f.level <= 0) extinguish(o, b, !!b.spec.needsIgnition)\n }\n } else if (b.on && !b.spec.needsIgnition && canBurn(b, resolveFuel(o, b))) {\n // a gas knob left open without gas lights as soon as the bottle valve opens\n b.lit = true\n b.smoke = 0\n emitBurner(o, b)\n } else if (b.smoke > 0) {\n b.smoke = Math.max(0, b.smoke - dt)\n }\n }\n if (rain > 0) {\n for (const tk of o.tanks.values()) {\n if (tk.spec.rainArea) tk.level = Math.min(tk.spec.capacity, tk.level + ((rain * tk.spec.rainArea) / 3600) * dt)\n }\n }\n for (const tk of o.tanks.values()) {\n const st = o.hp.get(tk.spec.id)\n if (st && st.hp < st.max && tk.level > 0) {\n const rate = 0.5 + 2.5 * (1 - st.hp / st.max)\n tk.level = Math.max(0, tk.level - rate * dt)\n }\n }\n }\n for (const o of objs.values()) {\n for (const d of o.devices.values()) {\n const fx = d.spec.fixture ? o.fixtures.get(d.spec.fixture) : undefined\n if (!fx) continue\n const run = d.on && devicePowered(o, d)\n if (fx.on === run) continue\n fx.on = run\n if (!run) {\n fx.flowing = false\n fx.frac = 0\n emitFixture(o, fx)\n }\n }\n for (const fx of o.fixtures.values()) stepFixture(o, fx, dt)\n }\n }\n\n // ------------------------------------------------------------------ add / remove\n function add(id: string, spec: ObjectSpec, transform: Transform): WorldObject {\n if (objs.has(id)) throw new Error(`Object \"${id}\" already exists`)\n const pub: WorldObject = { id, spec, transform: { ...transform }, parts: {} }\n const o: ObjState = {\n id,\n public: pub,\n spec,\n parts: new Map(),\n contents: new Map(),\n devices: new Map(),\n fuels: new Map(),\n burners: new Map(),\n tanks: new Map(),\n fixtures: new Map(),\n soft: new Map(),\n hp: new Map(),\n interactions: spec.interactions ?? deriveInteractions(spec),\n }\n for (const p of spec.parts ?? []) {\n const t0 = clamp(p.t0 ?? 0, 0, 1)\n o.parts.set(p.id, { spec: p, t: t0, target: t0, maxT: 1 })\n pub.parts[p.id] = t0\n }\n for (const c of spec.containers ?? []) {\n let list: Array<ItemStack | null>\n if (c.contents) list = c.slots.map((_, i) => cloneStack(c.contents?.[i] ?? null))\n else if (c.loot) list = generateLoot(options.loot, c.loot, c.slots, `${seed}|${id}|${c.id}`, options.items)\n else list = c.slots.map(() => null)\n o.contents.set(c.id, list)\n }\n for (const d of spec.devices ?? []) {\n o.devices.set(d.id, { spec: d, on: !!d.on, channel: 0 })\n if (d.on && d.part) {\n const p = o.parts.get(d.part)\n if (p) {\n p.target = p.maxT\n writeT(o, p, p.maxT)\n }\n }\n }\n for (const f of spec.fuels ?? []) o.fuels.set(f.id, { spec: f, level: clamp(f.level ?? f.capacity, 0, f.capacity) })\n for (const b of spec.burners ?? []) o.burners.set(b.id, { spec: b, on: false, lit: false, smoke: 0 })\n for (const tk of spec.tanks ?? []) o.tanks.set(tk.id, { spec: tk, level: clamp(tk.level ?? tk.capacity, 0, tk.capacity) })\n for (const f of spec.fixtures ?? []) o.fixtures.set(f.id, { spec: f, on: false, flowing: false, frac: 0, timer: 0 })\n let n = 0\n for (const s of spec.soft ?? []) {\n o.soft.set(s.id, { spec: s, ax: 0, az: 0, vax: 0, vaz: 0, phase: (n++ * 2.399963) % (Math.PI * 2) })\n }\n for (const d of spec.damage ?? []) o.hp.set(d.target, { max: d.hp, hp: d.hp })\n objs.set(id, o)\n if (!defaultTankExplicit && defaultTank === null && o.tanks.size) defaultTank = gid(o, [...o.tanks.keys()][0])\n rebuildPlugs()\n return pub\n }\n\n function remove(id: string): void {\n const o = objs.get(id)\n if (!o) return\n for (const [actor, s] of [...seats]) if (s.object === id) seats.delete(actor)\n objs.delete(id)\n if (defaultTank && defaultTank.startsWith(id + ':')) defaultTank = null\n rebuildPlugs()\n }\n\n // ------------------------------------------------------------------ save / load\n function serialize(): WorldState {\n const objects: WorldState['objects'] = {}\n for (const o of objs.values()) {\n objects[o.id] = {\n parts: Object.fromEntries([...o.parts].map(([k, p]) => [k, p.t])),\n contents: Object.fromEntries([...o.contents].map(([k, v]) => [k, v.map(cloneStack)])),\n devices: Object.fromEntries([...o.devices].map(([k, d]) => [k, { on: d.on, channel: d.channel }])),\n fuels: Object.fromEntries([...o.fuels].map(([k, f]) => [k, { level: f.level, open: valveOpen(o, f) }])),\n burners: Object.fromEntries([...o.burners].map(([k, b]) => [k, { on: b.on, lit: b.lit }])),\n tanks: Object.fromEntries([...o.tanks].map(([k, tk]) => [k, tk.level])),\n fixtures: Object.fromEntries([...o.fixtures].map(([k, f]) => [k, f.on])),\n hp: Object.fromEntries([...o.hp].map(([k, h]) => [k, h.hp])),\n }\n }\n return {\n version: 1,\n objects,\n grid,\n breakers: Object.fromEntries(breakers),\n seats: Object.fromEntries([...seats].map(([a, s]) => [a, { object: s.object, seat: s.seat }])),\n defaultTank,\n }\n }\n\n function load(state: WorldState): void {\n for (const [id, st] of Object.entries(state.objects)) {\n const o = objs.get(id)\n if (!o) continue\n for (const [k, v] of Object.entries(st.parts)) {\n const p = o.parts.get(k)\n if (p) {\n // keep the limit found by fitSweep: a loaded save must not let a door open through a wall again\n p.target = clamp(v, 0, p.maxT)\n writeT(o, p, p.target)\n }\n }\n for (const [k, v] of Object.entries(st.contents)) {\n const c = containerOf(o, k)\n if (c) o.contents.set(k, c.slots.map((_, i) => cloneStack(v[i] ?? null)))\n }\n for (const [k, v] of Object.entries(st.devices)) {\n const d = o.devices.get(k)\n if (d) {\n d.on = v.on\n d.channel = v.channel\n }\n }\n for (const [k, v] of Object.entries(st.fuels)) {\n const f = o.fuels.get(k)\n if (f) f.level = v.level\n }\n for (const [k, v] of Object.entries(st.burners)) {\n const b = o.burners.get(k)\n if (b) {\n b.on = v.on\n b.lit = v.lit\n b.smoke = 0\n }\n }\n for (const [k, v] of Object.entries(st.tanks)) {\n const tk = o.tanks.get(k)\n if (tk) tk.level = v\n }\n for (const [k, v] of Object.entries(st.fixtures)) {\n const f = o.fixtures.get(k)\n if (f) {\n f.on = v\n f.flowing = false\n f.frac = 0\n f.timer = v && f.spec.kind === 'flush' ? FLUSH_SECONDS : 0\n }\n }\n for (const [k, v] of Object.entries(st.hp)) {\n const h = o.hp.get(k)\n if (h) h.hp = v\n }\n }\n grid = state.grid\n breakers.clear()\n for (const [k, v] of Object.entries(state.breakers)) breakers.set(k, v)\n seats.clear()\n for (const [a, s] of Object.entries(state.seats)) if (objs.has(s.object)) seats.set(a, { object: s.object, seat: s.seat })\n defaultTank = state.defaultTank\n defaultTankExplicit = true\n }\n\n // ------------------------------------------------------------------ public surface\n const world: World = {\n add,\n remove,\n get: (id) => objs.get(id)?.public,\n objects: () => [...objs.values()].map((o) => o.public),\n update,\n interactables,\n target,\n act,\n partT: (objectId, partId) => objs.get(objectId)?.parts.get(partId)?.t ?? 0,\n setDevice,\n setFixture,\n setBurner,\n setPart: (objectId, partId, targetT, instant) => {\n const o = objs.get(objectId)\n if (o) setPartTarget(o, partId, targetT, instant)\n },\n fitSweep,\n contents,\n accessible,\n take,\n put,\n slotPosition,\n power: {\n get grid() {\n return grid\n },\n set grid(on: boolean) {\n world.power.setGrid(on)\n },\n setGrid(on) {\n if (on === grid) return\n const before = powerSnapshot()\n grid = on\n emit({ type: 'power', grid })\n emitPowerChanges(before)\n },\n setBreaker(circuit, on) {\n if (breaker(circuit) === on) {\n breakers.set(circuit, on)\n return\n }\n const before = powerSnapshot()\n breakers.set(circuit, on)\n emitPowerChanges(before)\n },\n breaker,\n isPowered: (objectId, deviceId) => {\n const r = getDevice(objectId, deviceId)\n return r ? devicePowered(r.o, r.d) : false\n },\n isOn: (objectId, deviceId) => getDevice(objectId, deviceId)?.d.on ?? false,\n pluggedInto: (objectId, deviceId) => plugs.get(`${objectId}:${deviceId}`) ?? null,\n channel: (objectId, deviceId) => getDevice(objectId, deviceId)?.d.channel ?? 0,\n },\n fuelLevel: (objectId, fuelId) => objs.get(objectId)?.fuels.get(fuelId)?.level ?? 0,\n isLit: (objectId, burnerId) => objs.get(objectId)?.burners.get(burnerId)?.lit ?? false,\n water: {\n setDefaultTank(tankId) {\n defaultTank = tankId\n defaultTankExplicit = true\n },\n level(tankId) {\n return findGlobal(tankId, (oo, local) => oo.tanks.get(local))?.value.level ?? 0\n },\n refill(tankId, liters) {\n const f = findGlobal(tankId, (oo, local) => oo.tanks.get(local))\n if (!f) return 0\n const added = Math.max(0, Math.min(liters, f.value.spec.capacity - f.value.level))\n f.value.level += added\n return added\n },\n setRain(mm) {\n rain = Math.max(0, mm)\n },\n isFlowing: (objectId, fixtureId) => {\n const fx = objs.get(objectId)?.fixtures.get(fixtureId)\n return !!fx && fx.on && fx.flowing\n },\n },\n sit,\n stand,\n seatOf: (actor = DEFAULT_ACTOR) => {\n const s = seats.get(actor)\n return s ? { object: s.object, seat: s.seat } : null\n },\n push,\n sway: (objectId, softId) => {\n const s = objs.get(objectId)?.soft.get(softId)\n return s ? { ax: s.ax, az: s.az } : { ax: 0, az: 0 }\n },\n setWind: (x, z) => {\n wind = [x, z]\n },\n emitters,\n mirrors,\n colliders,\n damage,\n hp: (objectId, target) => (options.destruction ? null : (objs.get(objectId)?.hp.get(target)?.hp ?? null)),\n t,\n setLocale: (l) => {\n locale = l\n table = resolveStrings(locale, options.strings)\n },\n serialize,\n load,\n }\n return world\n}\n","import { fitsSize } from '@adatechnology/game-interactables'\nimport type { ItemStack, SlotSpec } from '@adatechnology/game-interactables'\n\nimport type { ItemsCatalog } from '../catalog-types'\n\nimport type { FindStack } from './types'\n\nexport interface PlacedFinds {\n /** One entry per slot, in the order of `slots`; null is empty. */\n contents: Array<ItemStack | null>\n /** Stacks that fit no free slot. */\n leftover: FindStack[]\n}\n\n/**\n * Puts the stacks in the slots of a container: each in the first free slot its item fits (`fitsSize`, width and depth may swap), a stack larger than the\n * item's `stack` splits over several slots. Pure and in order, so the same finds always land in the same slots. The result is the floorplan's `contents`.\n */\nexport const placeFinds = (stacks: readonly FindStack[], slots: readonly SlotSpec[], catalog: ItemsCatalog): PlacedFinds => {\n const contents: Array<ItemStack | null> = slots.map(() => null)\n const leftover: FindStack[] = []\n for (const stack of stacks) {\n const item = catalog.get(stack.itemId)\n const stackMax = item.stack ?? 1\n let remaining = stack.count\n while (remaining > 0) {\n const index = slots.findIndex((slot, position) => contents[position] === null && fitsSize(item.size, slot.max))\n if (index < 0) break\n const qty = Math.min(remaining, stackMax)\n contents[index] = { type: item.id, qty, ...(stack.data ? { data: { ...stack.data } } : {}) }\n remaining -= qty\n }\n if (remaining > 0) leftover.push({ ...stack, count: remaining })\n }\n return { contents, leftover }\n}\n","import type { LootTable, LootTables } from '@adatechnology/game-interactables'\n\nimport type { ItemsCatalog } from '../catalog-types'\nimport type { ContainerDef } from '../types'\n\nimport { depletionFactor } from './depletion'\nimport { rarityWeight, rollableItems } from './rarity-pool'\n\nexport interface LootTablesQuery {\n place: string\n /** Outbreak days; 0 is the normal world. */\n day?: number\n region?: string\n}\n\nconst weightsOf = (container: ContainerDef, place: string): Readonly<Record<string, number>> | undefined => container.categories ?? container.categoriesByPlace?.[place] ?? container.categoriesByPlace?.['*']\n\nconst tableFor = (catalog: ItemsCatalog, container: ContainerDef, query: LootTablesQuery): LootTable | undefined => {\n const weights = weightsOf(container, query.place)\n if (!weights) return undefined\n const entries = Object.entries(weights).flatMap(([category, categoryWeight]) => {\n const pool = rollableItems(catalog, category, query.place)\n const total = pool.reduce((sum, item) => sum + rarityWeight(catalog, item), 0)\n return total > 0 ? pool.map((item) => ({ type: item.id, weight: (categoryWeight * rarityWeight(catalog, item)) / total })) : []\n })\n const [low, high] = container.count ?? [0, 0]\n const region = query.region === undefined ? 1 : (catalog.rules.finds.regionMultiplier[query.region] ?? 1)\n const expected = ((low + high) / 2) * depletionFactor(query.place, query.day ?? 0, catalog.rules) * region\n return entries.length > 0 && high > 0 ? { fill: Math.min(1, expected / high), entries } : undefined\n}\n\n/**\n * The finds as the loot tables of `game-interactables`, for worlds that have no floorplan: one table per container of the place, named by the container id.\n * A table has one entry per rollable item, weighted by the container's category weight and the rarity (60/28/9/2.5). A slot is filled with probability\n * `fill` = expected count / most stacks, taking the container to have as many slots as its largest count; the day and the region scale the expected count.\n */\nexport const toLootTables = (catalog: ItemsCatalog, query: LootTablesQuery): LootTables => {\n const tables: LootTables = {}\n for (const container of catalog.containers.values()) {\n if (container.places && container.places.length > 0 && !container.places.includes(query.place)) continue\n const table = tableFor(catalog, container, query)\n if (table) tables[container.id] = table\n }\n return tables\n}\n","import type { CategoryDef, ItemDef, ItemPack, Rarity } from './types'\n\n/** The part of a weapon definition the bridge reads; `WeaponDef` of `@adatechnology/game-weapons` fits it without importing the package. */\nexport interface ArsenalWeaponLike {\n readonly id: string\n readonly mass: number\n readonly action: { readonly kind: string; readonly explosive?: string }\n readonly dimensions?: { readonly length: number }\n}\n\n/** The part of a calibre definition the bridge reads; `CaliberDef` fits it. */\nexport interface ArsenalCaliberLike {\n readonly id: string\n readonly bulletGrams: number\n readonly pellets?: number\n readonly caseType?: string\n readonly cartridge?: { readonly caseDiameter: number; readonly overallLength: number }\n}\n\n/** The part of an `Arsenal` the bridge reads. */\nexport interface ArsenalLike {\n readonly weapons: ReadonlyMap<string, ArsenalWeaponLike>\n readonly calibers: ReadonlyMap<string, ArsenalCaliberLike>\n}\n\n/** What the team's game says of a box or an arsenal item (`ammo-boxes.json`, from its `EXTRA` table); it wins over the estimates below. */\nexport interface ArsenalItemRecord {\n /** Id of the item; without it the bridge uses `ammo_<caliber>`, `firearm_<weapon>` or `throwable_<weapon>`. */\n id?: string\n kind: 'ammo_box' | 'firearm' | 'throwable'\n /** Calibre id of a box. */\n caliber?: string\n /** Weapon id of a firearm or throwable; for a throwable also the explosive it uses. */\n weapon?: string\n rounds?: number\n weight: number\n rarity: Rarity\n}\n\nexport interface ArsenalItemsOptions {\n records?: readonly ArsenalItemRecord[]\n}\n\nexport const ARSENAL_CATEGORIES: readonly CategoryDef[] = [\n { id: 'ammo', name: 'Ammo' },\n { id: 'firearms', name: 'Firearms' },\n { id: 'throwables', name: 'Throwables' },\n]\n\nconst MM = 0.001\nconst BOX_TARE_KG = 0.03\nconst BOX_WALL = 6 * MM\nconst ROUNDS_BY_CASE: Readonly<Record<string, number>> = { tiny: 50, pistol: 50, rifle: 20, shell: 25, big: 10 }\nconst DEFAULT_ROUNDS = 12\nconst CARTRIDGE_MASS_FACTOR = 2.3\nconst SHELL_MASS_FACTOR = 1.5\nconst BOX_DENSITY_KG_PER_M3 = 600\nconst THROWABLE_DENSITY_KG_PER_M3 = 1500\n\nconst cubeSide = (mass: number, density: number): number => Math.cbrt(mass / density)\n\n/** Estimates (REF) for a calibre the game has no box for: rounds by case type, mass of the rounds plus a tare, a box that packs them in a near-square tray. */\nconst estimateBox = (caliber: ArsenalCaliberLike): { rounds: number; weight: number; size: [number, number, number] } => {\n const rounds = ROUNDS_BY_CASE[caliber.caseType ?? ''] ?? DEFAULT_ROUNDS\n const factor = caliber.caseType === 'shell' ? SHELL_MASS_FACTOR : CARTRIDGE_MASS_FACTOR\n const weight = Number((rounds * caliber.bulletGrams * (caliber.pellets ?? 1) * factor * 0.001 + BOX_TARE_KG).toFixed(3))\n return { rounds, weight, size: boxSize(caliber, rounds, weight) }\n}\n\nconst boxSize = (caliber: ArsenalCaliberLike, rounds: number, weight: number): [number, number, number] => {\n if (!caliber.cartridge) {\n const side = cubeSide(weight, BOX_DENSITY_KG_PER_M3)\n return [side, side, side]\n }\n const rows = Math.ceil(Math.sqrt(rounds))\n const columns = Math.ceil(rounds / rows)\n const { caseDiameter, overallLength } = caliber.cartridge\n return [rows * caseDiameter * MM + BOX_WALL, overallLength * MM + BOX_WALL, columns * caseDiameter * MM + BOX_WALL]\n}\n\nconst ammoItem = (caliber: ArsenalCaliberLike, record: ArsenalItemRecord | undefined): ItemDef => {\n const estimate = estimateBox(caliber)\n const weight = record?.weight ?? estimate.weight\n return {\n id: record?.id ?? `ammo_${caliber.id}`,\n category: 'ammo',\n size: record?.weight === undefined ? estimate.size : boxSize(caliber, record.rounds ?? estimate.rounds, weight),\n weight,\n stack: 4,\n shape: 'box',\n rarity: record?.rarity ?? 'uncommon',\n }\n}\n\n/** A firearm's size from its length: height and thickness grow with it (a pistol 0.2 m is 0.10 x 0.03, a rifle 1 m is 0.20 x 0.05). Without a length, one from the mass. */\nconst firearmSize = (weapon: ArsenalWeaponLike): [number, number, number] => {\n const length = weapon.dimensions?.length ?? 0.15 + 0.2 * weapon.mass\n return [length, 0.08 + 0.12 * length, 0.02 + 0.03 * length]\n}\n\nconst weaponItem = (weapon: ArsenalWeaponLike, record: ArsenalItemRecord | undefined): ItemDef => {\n const isFirearm = weapon.action.kind === 'firearm'\n const side = cubeSide(weapon.mass, THROWABLE_DENSITY_KG_PER_M3)\n return {\n id: record?.id ?? `${isFirearm ? 'firearm' : 'throwable'}_${weapon.id}`,\n category: isFirearm ? 'firearms' : 'throwables',\n size: isFirearm ? firearmSize(weapon) : [side, side, side],\n weight: record?.weight ?? weapon.mass,\n stack: 1,\n shape: isFirearm ? 'long_tool' : 'jar',\n rarity: record?.rarity ?? 'unique',\n weapon: weapon.id,\n }\n}\n\nconst recordFor = (records: readonly ArsenalItemRecord[], match: (record: ArsenalItemRecord) => boolean): ArsenalItemRecord | undefined => records.find(match)\n\n/**\n * The arsenal as items, so a bag can carry it: a box `ammo_<caliber>` for every calibre, `firearm_<id>` for every firearm and `throwable_<id>` for every\n * throwable, with the mass and the real dimensions of the `WeaponDef` (`mass`, `dimensions.length`). A record from the team's game (`records`) gives the rounds\n * per box, the weight and the rarity of what it listed (9x19: 20 rounds, 0.24 kg); the rest are estimated from the calibre (REF): rounds by case type,\n * weight from the bullet. Returns a pack (with the `ammo`, `firearms` and `throwables` categories) for `createItems({ packs })`.\n */\nexport const itemsFromArsenal = (arsenal: ArsenalLike, options: ArsenalItemsOptions = {}): ItemPack => {\n const records = options.records ?? []\n const items: ItemDef[] = []\n for (const caliber of arsenal.calibers.values()) items.push(ammoItem(caliber, recordFor(records, (record) => record.kind === 'ammo_box' && record.caliber === caliber.id)))\n for (const weapon of arsenal.weapons.values()) {\n const kind = weapon.action.kind\n if (kind !== 'firearm' && kind !== 'throwable') continue\n items.push(weaponItem(weapon, recordFor(records, (record) => record.kind === kind && (record.weapon === weapon.id || record.weapon === weapon.action.explosive))))\n }\n return { id: 'arsenal', items, categories: ARSENAL_CATEGORIES }\n}\n"],"mappings":"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","names":["freshness"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adatechnology/game-items",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Engine-agnostic item catalog engine: item definitions (category, size, weight, rarity, durability, shelf life, protection, carry), an extensible registry with patch/remove/include, and the default rules for finds, freshness, storage, carry weight and protection. No content and no renderer.",
|
|
6
6
|
"keywords": [
|
|
@@ -37,15 +37,15 @@
|
|
|
37
37
|
"dist"
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@adatechnology/game-
|
|
41
|
-
"@adatechnology/game-
|
|
40
|
+
"@adatechnology/game-interactables": "0.2.3",
|
|
41
|
+
"@adatechnology/game-contracts": "0.11.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/node": "^22.10.7",
|
|
45
45
|
"tsup": "^8.5.1",
|
|
46
46
|
"typescript": "^5.7.3",
|
|
47
47
|
"vitest": "^3.2.4",
|
|
48
|
-
"@adatechnology/game-weapons": "0.4.
|
|
48
|
+
"@adatechnology/game-weapons": "0.4.3",
|
|
49
49
|
"@adatechnology/golden-tool": "0.0.0"
|
|
50
50
|
},
|
|
51
51
|
"scripts": {
|