@adatechnology/game-terrain-three 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../../game-contracts/src/blast-shove.ts","../../game-contracts/src/impact.ts","../../game-contracts/src/body.ts","../../game-contracts/src/spec.ts","../../game-contracts/src/weapons.ts","../../game-contracts/src/ignition.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","../../game-contracts/src/categories.ts","../../game-contracts/src/origin.ts","../../game-contracts/src/footprint.ts","../../game-terrain/src/catalog.ts","../../game-terrain/src/debris.ts","../../game-terrain/src/marks.ts","../../game-terrain/src/random.ts","../../game-terrain/src/terrain.ts","../../game-terrain/src/impl.ts","../../game-terrain/src/types.ts","../src/surface-material.ts","../src/chunk-geometry.ts","../src/layers.ts","../src/rebar.ts","../src/renderer.ts","../src/locale.ts","../src/test-module.ts"],"sourcesContent":["export { createTerrainRenderer } from './renderer'\nexport type { TerrainRenderer, TerrainRendererOptions } from './renderer'\nexport { createSurfaceMaterial } from './surface-material'\nexport { createTerrainTestModule, TERRAIN_AREA_GROUND, TERRAIN_STRIPS } from './test-module'\n","import type { Vec3 } from './destruction'\n\n/**\n * The numbers of the shove a blast gives to a body that moves (a loose item, a piece of furniture, a person): all of them a game value, with a default that reads well in a room,\n * not a physical table. A host reads them once and lends them to the modules that move their own bodies (`ModuleHost.blastShove`).\n */\nexport interface BlastShoveOptions {\n /**\n * The share of the blast energy that reaches a body as motion: the speed at the centre is `sqrt(2 * energy * coupling / mass)`, before the falloff (default 0.02).\n */\n readonly coupling?: number\n /** Fastest a body is thrown, in m/s, at the centre of the blast (default 20). */\n readonly maxSpeed?: number\n /** How much of the push goes up: the direction is `(away, lift)` normalised from the horizontal outward direction (default 0.3). */\n readonly lift?: number\n /** Below this speed (m/s) nothing moves: a heavy thing far from a small blast stays where it is (default 0.3). */\n readonly minSpeed?: number\n /** Exponent of the falloff `(1 - distance / radius) ^ power`, the same as the damage of the destruction world (default 2). */\n readonly falloffPower?: number\n}\n\n/** The defaults of `BlastShoveOptions`. */\nexport const DEFAULT_BLAST_SHOVE: Readonly<Required<BlastShoveOptions>> = { coupling: 0.02, maxSpeed: 20, lift: 0.3, minSpeed: 0.3, falloffPower: 2 }\n\n/** A blast as the shove reads it: where, how much energy (J) and how far it reaches (m). `reach` (0..1) is the share that got through what stood in the way. */\nexport interface BlastSource {\n readonly point: Vec3\n readonly energy: number\n readonly radius: number\n readonly reach?: number\n}\n\n/** What a blast does to one body: how fast it leaves (m/s), the unit direction it leaves in, and how far from the centre it was. */\nexport interface BlastShove {\n readonly speed: number\n readonly direction: Vec3\n readonly distance: number\n /** The falloff applied, 0..1. */\n readonly falloff: number\n}\n\n/**\n * The shove a blast gives to a body of `mass` kg whose centre is at `position`: away from the centre, lifted a little, with the speed\n * `min(maxSpeed, sqrt(2 * energy * reach * coupling / mass)) * (1 - distance / radius) ^ 2`. Undefined when the body is outside the radius or the speed is under `minSpeed`.\n * The same rule moves the loose items and furniture of the test room and the people of the characters module, so a game that tunes the options tunes all of them.\n */\nexport function blastShove(blast: BlastSource, target: { readonly position: Vec3; readonly mass: number }, options: BlastShoveOptions = {}): BlastShove | undefined {\n const o = { ...DEFAULT_BLAST_SHOVE, ...options }\n const dx = target.position[0] - blast.point[0]\n const dy = target.position[1] - blast.point[1]\n const dz = target.position[2] - blast.point[2]\n const distance = Math.hypot(dx, dy, dz)\n if (!(blast.radius > 0) || distance > blast.radius) return undefined\n const falloff = (1 - distance / blast.radius) ** o.falloffPower\n const mass = Math.max(target.mass, 0.05)\n const speed = Math.min(o.maxSpeed, Math.sqrt((2 * blast.energy * (blast.reach ?? 1) * o.coupling) / mass)) * falloff\n if (!(speed >= o.minSpeed)) return undefined\n // straight above or below the centre there is no \"away\": the push goes up\n const direction: Vec3 = distance > 1e-4 ? [dx / distance, dy / distance + o.lift, dz / distance] : [0, 1, 0]\n const length = Math.hypot(direction[0], direction[1], direction[2])\n return { speed, direction: [direction[0] / length, direction[1] / length, direction[2] / length], distance, falloff }\n}\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: ['lethalRadius', '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 { BallisticExtras, Hit, HitIgnition, 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 /** Kind of the round that was loaded (`fmj`, `hp`, `ap`, `buck`, `slug`, `incendiary`, `tracer`...). */\n ammo?: string\n /** Millimetres of steel, concrete, brick and wood the round goes through at the muzzle (the ammo table of the weapons module). `shotToHit` scales it by the energy left. */\n penetration?: Readonly<Record<string, number>>\n incendiary?: boolean\n tracer?: boolean\n /** Chances that the round lights flammable vapour or fuel. */\n ignition?: HitIgnition\n}\n\n/**\n * The fields of the round that a damage model takes besides the geometry: the penetration by material scaled by the energy that is left at `distance`\n * (linear in the energy: half the energy goes through half as much), the incendiary and tracer flags and the ignition chances. Pass it as the sixth\n * argument of `DestructionWorld.shoot` or spread it in a `Hit`.\n */\nexport const shotExtras = (shot: Shot, distance = 0): BallisticExtras => {\n const out: { -readonly [K in keyof BallisticExtras]: BallisticExtras[K] } = {}\n if (shot.penetration) {\n const k = shot.energy > 0 ? Math.min(1, Math.max(0, shot.energyAt(distance) / shot.energy)) : 1\n const scaled: Record<string, number> = {}\n for (const key of Object.keys(shot.penetration)) scaled[key] = shot.penetration[key]! * k\n out.penetration = scaled\n }\n if (shot.incendiary) out.incendiary = true\n if (shot.tracer) out.tracer = true\n if (shot.ignition) out.ignition = { spark: shot.ignition.spark, flame: shot.ignition.flame }\n return out\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 ...shotExtras(shot, distance),\n source: shot.source ?? shot.weapon,\n }\n}\n\n/**\n * The `Hit` a non-firearm `Use` makes at `point` (world space), or `null` for uses that do not damage by themselves.\n *\n * - `melee` -> `blunt` / `slash` with the swing's `energy`, contact `radius` and `direction`;\n * - `flame` -> `fire` and `heat` -> `heat`, with `temperature`, `duration` and a contact radius of at most 0.3 m (one ray of the cone);\n * - `throw` -> the blast (`explosive`, `energy`, `radius`, centred on `point`, the place it detonates). A thrown item with no blast energy but\n * a `fire` patch (a molotov) gives that patch as a `fire` hit (`temperature`, `radius`, `seconds` as `duration`). No energy and no fire -> `null`;\n * - `tool`, `extinguish` and `hand` -> `null` (tools work on joints, the extinguisher cools, the hand only grabs).\n *\n * `normal` does not change the hit (same as `shotToHit`).\n */\nexport const useToHit = (use: Use, point: Vec3, normal?: Vec3): Hit | null => {\n void normal\n const p: Vec3 = [point[0], point[1], point[2]]\n switch (use.kind) {\n case 'melee':\n return { type: use.damage, point: p, direction: [use.direction[0], use.direction[1], use.direction[2]], energy: use.energy, radius: use.radius }\n case 'flame':\n case 'heat':\n return {\n type: use.kind === 'flame' ? 'fire' : 'heat',\n point: p,\n direction: [use.direction[0], use.direction[1], use.direction[2]],\n temperature: use.temperature,\n radius: Math.min(use.radius, 0.3),\n duration: use.duration,\n }\n case 'throw':\n if (use.energy > 0) return { type: 'explosive', point: p, energy: use.energy, radius: use.radius }\n if (use.fire) return { type: 'fire', point: p, temperature: use.fire.temperature, radius: use.fire.radius, duration: use.fire.seconds }\n return null\n default:\n return null\n }\n}\n","import type { Hit } from './destruction'\n\n/**\n * How a container of flammable fluid (a fuel tank, a jerrycan, a gas bottle) is set alight by what hits it. A normal bullet that goes through only\n * makes a leak; the leak lights when something supplies a flame: a spark of a hard round on metal, an incendiary or tracer round, a fire nearby\n * or a blast. These are the numbers of the target side; the numbers of the round (core, jacket, incendiary, tracer) are in the weapons module\n * (`rules.ignition` of `@adatechnology/game-weapons`) and reach the target in `Hit.ignition`. Every number is a default to be changed per game.\n */\nexport interface IgnitionRules {\n /**\n * How much of the spark and of the flame of a round reaches the vapour, by how full the container is: points `[filled, factor]`, `filled` 0..1\n * (litres over capacity), `factor` 0..1, linear between points. A full container holds a mixture too rich to burn (the vapour has no air) and\n * mostly leaks; a nearly empty one holds the vapour/air mixture of a flammable range and lights the easiest; a dry one has little vapour left.\n * Default: 0.6 empty, 1 at 10 percent, 0.6 at 35, 0.3 at 65, 0.15 at 90 and 0.12 full.\n */\n fillCurve: ReadonlyArray<readonly [number, number]>\n /** 0..1: how much of the fill factor also weighs on the flame of an incendiary or tracer (1: as much as on a spark; 0: not at all). Default 0.5. */\n flameFillInfluence: number\n /** Chance of the flame of a round that is flagged `incendiary` or `tracer` but carries no `ignition` numbers (a host that passes only the flags). */\n flameDefaults: { incendiary: number; tracer: number }\n /**\n * What a spark needs to strike: a factor 0..1 by material id of the struck part. A material that is not listed but is metal takes\n * `sparkMetalDefault`; a part that is not metal never sparks. Default: steel and iron 1, aluminium 0.25.\n */\n sparkTarget: Readonly<Record<string, number>>\n sparkMetalDefault: number\n /**\n * Factor 0..1 on the spark by fluid (default 1): diesel has a flash point of 52 degrees C and more, so a spark that lights petrol vapour does\n * not light it (a flame does). Default: diesel 0.15.\n */\n sparkFluid: Readonly<Record<string, number>>\n /** A leaking container of flammable fluid within `radius` metres of a burning part or a burning fuel stain lights at `chancePerSecond`. */\n fireNear: { radius: number; chancePerSecond: number }\n}\n\n/** Defaults of `IgnitionRules`. */\nexport const DEFAULT_IGNITION_RULES: IgnitionRules = {\n fillCurve: [\n [0, 0.6],\n [0.1, 1],\n [0.35, 0.6],\n [0.65, 0.3],\n [0.9, 0.15],\n [1, 0.12],\n ],\n flameFillInfluence: 0.5,\n flameDefaults: { incendiary: 0.7, tracer: 0.2 },\n sparkTarget: { steel: 1, iron: 1, aluminium: 0.25 },\n sparkMetalDefault: 0.5,\n sparkFluid: { diesel: 0.15 },\n fireNear: { radius: 1.5, chancePerSecond: 0.5 },\n}\n\n/** Merges a partial `IgnitionRules` over the defaults (an object is merged one level deep). */\nexport const resolveIgnitionRules = (rules?: Partial<IgnitionRules>): IgnitionRules => ({\n ...DEFAULT_IGNITION_RULES,\n ...rules,\n flameDefaults: { ...DEFAULT_IGNITION_RULES.flameDefaults, ...rules?.flameDefaults },\n sparkTarget: { ...DEFAULT_IGNITION_RULES.sparkTarget, ...rules?.sparkTarget },\n sparkFluid: { ...DEFAULT_IGNITION_RULES.sparkFluid, ...rules?.sparkFluid },\n fireNear: { ...DEFAULT_IGNITION_RULES.fireNear, ...rules?.fireNear },\n})\n\n/** Linear interpolation of the fill curve at `fill` (0..1); outside the points it keeps the first or the last. */\nexport const fillFactor = (fill: number, curve: IgnitionRules['fillCurve'] = DEFAULT_IGNITION_RULES.fillCurve): number => {\n if (curve.length === 0) return 1\n const f = Math.min(1, Math.max(0, fill))\n if (f <= curve[0]![0]) return curve[0]![1]\n for (let i = 1; i < curve.length; i++) {\n const [x1, y1] = curve[i]!\n const [x0, y0] = curve[i - 1]!\n if (f <= x1) return x1 === x0 ? y1 : y0 + ((y1 - y0) * (f - x0)) / (x1 - x0)\n }\n return curve[curve.length - 1]![1]\n}\n\n/** The factor of what a spark strikes: 0 when the struck part is not metal. */\nexport const sparkTargetFactor = (materialId: string, metal: boolean, rules: IgnitionRules = DEFAULT_IGNITION_RULES): number =>\n !metal ? 0 : rules.sparkTarget[materialId] ?? rules.sparkMetalDefault\n\n/**\n * Chance that one round that went through the wall of a container lights the fluid that leaks: the spark (only on metal) and the flame (any\n * wall), each weighed by the fill of the container; the two are independent. 0 when the round carries nothing (`Hit.ignition` absent and not\n * flagged `incendiary` or `tracer`): a plain bullet on a plain wall only leaks.\n */\nexport const ignitionChance = (hit: Pick<Hit, 'incendiary' | 'tracer' | 'ignition'>, target: { sparkTarget: number; fill: number; fluid?: string }, rules: IgnitionRules = DEFAULT_IGNITION_RULES): number => {\n const k = fillFactor(target.fill, rules.fillCurve)\n const spark = (hit.ignition?.spark ?? 0) * target.sparkTarget * (target.fluid === undefined ? 1 : rules.sparkFluid[target.fluid] ?? 1) * k\n const flameBase = hit.ignition?.flame ?? Math.max(hit.incendiary ? rules.flameDefaults.incendiary : 0, hit.tracer ? rules.flameDefaults.tracer : 0)\n const flame = flameBase * (1 - rules.flameFillInfluence * (1 - k))\n return 1 - (1 - Math.min(1, Math.max(0, spark))) * (1 - Math.min(1, Math.max(0, flame)))\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 /** The clips being played with the time each has reached and the clip itself (scaled to the duration the rule gave), for a renderer that has to follow what the clip does now (the hands working a part). */\n readonly running: ReadonlyArray<{ readonly name: string; readonly time: number; readonly clip: AnimationClipSpec }>\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 get running() {\n return instances.filter((instance) => instance.delay <= 0).map((instance) => ({ name: instance.name, time: instance.time, clip: instance.clip }))\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 * The change (or the press, for an action) takes a noticeable time, a third of a second or more: the host shows its loading indicator while it works, with this text, or the control's name for `true`.\n * Only the host's own controls panel does it; a change made through `TestRoom.bench().set / act` runs at once.\n */\n readonly slow?: boolean | string\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 { applyPatches, deepMerge, mergeById, removeIds } from './registry'\nimport type { DeepPartial } from './registry'\n\n/** One category (kind) of items. Defaults and required contracts are inherited from the `extends` chain. */\nexport interface CategoryDef<TDefaults extends object = Record<string, unknown>> {\n readonly id: string\n /** Parent category id; the child inherits its defaults and its required contracts. */\n readonly extends?: string\n /** Field values every entry of this kind starts with; objects merge down the chain, arrays replace. */\n readonly defaults?: DeepPartial<TDefaults>\n /** Contract ids (`ContractChecker.id`) every entry of this kind, or of a descendant, must satisfy. */\n readonly requires?: readonly string[]\n /** An abstract category only groups: entries cannot declare it directly, only its descendants. */\n readonly abstract?: boolean\n}\n\n/** Where a checker is being run: the entry's kind and the chain of categories root first. */\nexport interface ContractContext {\n readonly kind: string\n readonly chain: readonly string[]\n}\n\n/** A named contract: returns the problems of an entry, each a short phrase like `missing leftover.despawnSeconds`. Empty means it holds. */\nexport interface ContractChecker<TEntry> {\n readonly id: string\n check(entry: TEntry, ctx: ContractContext): string[]\n}\n\nexport interface CategoryOptions {\n /** Categories added to the shipped ones; the same id replaces. */\n readonly extra?: readonly CategoryDef[]\n /** Partial changes by id, applied after `extra`. */\n readonly patches?: Readonly<Record<string, DeepPartial<CategoryDef> | Partial<CategoryDef>>>\n /** Ids taken out of the tree (their children are then reported by `check()`). */\n readonly remove?: readonly string[]\n}\n\nexport interface CategoryRegistry {\n get(id: string): CategoryDef | undefined\n has(id: string): boolean\n ids(): string[]\n /** Root first, ending at `id`. Throws on an unknown id, an unknown parent or a cycle. */\n chain(id: string): string[]\n /** `defaults` merged along the chain, root first, so the closest category wins. */\n defaults(id: string): Record<string, unknown>\n /** Union of `requires` along the chain, order kept, without duplicates. */\n requires(id: string): string[]\n /** True when `id` is `ancestorId` or descends from it. */\n isA(id: string, ancestorId: string): boolean\n /** Problems in the tree itself: duplicate ids, unknown `extends`, cycles. */\n check(): string[]\n}\n\nexport interface CategorizedOptions {\n /** Field of the entry that names its category. Default `kind`. */\n readonly kindField?: string\n /** Known gaps by entry id: problems starting with one of these texts go to `allowed`, so the list can shrink over time. */\n readonly allow?: Readonly<Record<string, readonly string[]>>\n}\n\nexport interface CategorizedReport {\n readonly ok: boolean\n readonly problems: string[]\n readonly allowed: string[]\n}\n\nexport const defineCategories = (defs: readonly CategoryDef[], options: CategoryOptions = {}): CategoryRegistry => {\n const problems: string[] = []\n const duplicates = (list: readonly CategoryDef[]): void => {\n const ids = new Set<string>()\n for (const def of list) {\n if (ids.has(def.id)) problems.push(`duplicate category \"${def.id}\"`)\n ids.add(def.id)\n }\n }\n duplicates(defs)\n duplicates(options.extra ?? [])\n\n const entries = mergeById(defs, options.extra)\n applyPatches(entries, options.patches)\n removeIds(entries, options.remove)\n\n const chain = (id: string): string[] => {\n const path: string[] = []\n let current: string | undefined = id\n while (current !== undefined) {\n const def = entries.get(current)\n if (!def) throw new Error(path.length === 0 ? `unknown category \"${current}\"` : `category \"${path[0]}\" has unknown parent \"${current}\"`)\n if (path.includes(current)) throw new Error(`category cycle: ${[...path, current].reverse().join(' -> ')}`)\n path.push(current)\n current = def.extends\n }\n return path.reverse()\n }\n\n const registry: CategoryRegistry = {\n get: (id) => entries.get(id),\n has: (id) => entries.has(id),\n ids: () => [...entries.keys()],\n chain,\n defaults: (id) => chain(id).reduce<Record<string, unknown>>((acc, ancestor) => deepMerge(acc, entries.get(ancestor)?.defaults), {}),\n requires: (id) => {\n const out: string[] = []\n for (const ancestor of chain(id)) for (const contract of entries.get(ancestor)?.requires ?? []) if (!out.includes(contract)) out.push(contract)\n return out\n },\n isA: (id, ancestorId) => {\n try {\n return chain(id).includes(ancestorId)\n } catch {\n return false\n }\n },\n check: () => {\n const found = [...problems]\n for (const [id, def] of entries) {\n if (def.extends !== undefined && !entries.has(def.extends)) found.push(`category \"${id}\" extends unknown \"${def.extends}\"`)\n else {\n try {\n chain(id)\n } catch (error) {\n const message = (error as Error).message\n // The same cycle is found from each of its members; the dedupe keeps one line per distinct message.\n if (message.startsWith('category cycle') && !found.includes(message)) found.push(message)\n }\n }\n }\n return found\n },\n }\n return registry\n}\n\n/** The entry with the defaults of its category underneath: the entry wins, objects merge, arrays replace. */\nexport const resolveEntry = <TEntry extends object>(registry: CategoryRegistry, entry: TEntry, kindField = 'kind'): TEntry =>\n deepMerge(registry.defaults(String((entry as Record<string, unknown>)[kindField])) as TEntry, entry)\n\n/** Checks that each entry has a known, concrete kind and satisfies every contract its chain requires. */\nexport const checkCategorized = <TEntry extends { readonly id: string }>(\n entries: readonly TEntry[],\n registry: CategoryRegistry,\n checkers: readonly ContractChecker<TEntry>[],\n options: CategorizedOptions = {},\n): CategorizedReport => {\n const kindField = options.kindField ?? 'kind'\n const byId = new Map(checkers.map((checker) => [checker.id, checker]))\n const problems: string[] = [...registry.check()]\n const allowed: string[] = []\n\n for (const entry of entries) {\n const kind = String((entry as Record<string, unknown>)[kindField])\n if (!registry.has(kind)) {\n problems.push(`\"${entry.id}\" has unknown kind \"${kind}\"`)\n continue\n }\n if (registry.get(kind)?.abstract) {\n problems.push(`\"${entry.id}\" has abstract kind \"${kind}\"`)\n continue\n }\n let path: string[]\n try {\n path = registry.chain(kind)\n } catch (error) {\n problems.push(`\"${entry.id}\" (${kind}): ${(error as Error).message}`)\n continue\n }\n const resolved = resolveEntry(registry, entry, kindField)\n for (const contract of registry.requires(kind)) {\n const owner = path.find((ancestor) => registry.get(ancestor)?.requires?.includes(contract)) ?? kind\n const checker = byId.get(contract)\n if (!checker) {\n problems.push(`kind \"${kind}\" requires contract \"${contract}\" but no checker is registered`)\n continue\n }\n const prefix = `\"${entry.id}\" (${kind}): `\n for (const problem of checker.check(resolved, { kind, chain: path })) {\n const text = `${prefix}${problem}, required by ${owner}`\n if ((options.allow?.[entry.id] ?? []).some((start) => problem.startsWith(start))) allowed.push(text)\n else problems.push(text)\n }\n }\n }\n return { ok: problems.length === 0, problems: [...new Set(problems)], allowed }\n}\n","/**\n * Where a drawn object comes from. Every builder of the kit that draws a piece (a car of the fleet, a house or a wall, a piece of furniture, a person, an\n * animal, a plant, an item, a weapon) marks the root it returns, and every renderer that draws for the room (effects, debris, liquids, the room itself)\n * marks its own roots, so a host can tell a piece of the kit from something drawn on the spot. Duck-typed on three.js objects (`userData`, `parent`,\n * `children`), so the contracts stay free of three.js.\n */\nexport const KIT_ORIGIN_KEY = 'kitOrigin'\n\n/** What drew an object: the package and the kind of piece (`vehicle`, `structure`, `furniture`, `person`... or `effect`, `room` for what is not a piece). */\nexport interface KitOrigin {\n readonly owner: string\n readonly kind: string\n /** The catalogue id of the piece, when it has one. */\n readonly id?: string\n /** Drawn as a simple stand-in shape because the kit has no model of the piece yet (an item with no team model): a model still to make. */\n readonly standIn?: boolean\n}\n\ninterface Node {\n userData?: Record<string, unknown>\n parent?: Node | null\n children?: readonly Node[]\n name?: string\n visible?: boolean\n isMesh?: boolean\n isPoints?: boolean\n isLine?: boolean\n isSprite?: boolean\n}\n\n/** Marks `object` as drawn by `origin.owner` and returns it. */\nexport function markKitOrigin<T>(object: T, origin: KitOrigin): T {\n const node = object as unknown as Node\n node.userData = node.userData ?? {}\n node.userData[KIT_ORIGIN_KEY] = { ...origin }\n return object\n}\n\n/** The origin of the object or of its nearest marked ancestor; null when nothing up the tree is marked. */\nexport function kitOriginOf(object: unknown): KitOrigin | null {\n for (let node = object as Node | null | undefined; node; node = node.parent) {\n const origin = node.userData?.[KIT_ORIGIN_KEY] as KitOrigin | undefined\n if (origin && typeof origin.owner === 'string') return origin\n }\n return null\n}\n\n/** A drawn thing that no builder of the kit marked: what was drawn on the spot. */\nexport interface UnmarkedDrawing {\n /** The names of the object and its named ancestors, outermost first. */\n readonly path: string\n readonly object: unknown\n}\n\n/** Every visible mesh, line, point cloud or sprite under `root` with no marked ancestor (`root` itself not counted as one). */\nexport function unmarkedDrawings(root: unknown): UnmarkedDrawing[] {\n const out: UnmarkedDrawing[] = []\n const pathOf = (node: Node): string => {\n const names: string[] = []\n for (let n: Node | null | undefined = node; n && n !== root; n = n.parent) if (n.name) names.push(n.name)\n return names.reverse().join(' > ') || '(no name)'\n }\n const walk = (node: Node, marked: boolean): void => {\n if (node.visible === false) return\n const here = marked || (node !== root && Boolean((node.userData?.[KIT_ORIGIN_KEY] as KitOrigin | undefined)?.owner))\n if (!here && (node.isMesh || node.isPoints || node.isLine || node.isSprite)) out.push({ path: pathOf(node), object: node })\n for (const child of node.children ?? []) walk(child, here)\n }\n walk(root as Node, false)\n return out\n}\n","/** A floor rectangle in an object's own space, metres (x across, z along). */\nexport interface Footprint {\n readonly minX: number\n readonly maxX: number\n readonly minZ: number\n readonly maxZ: number\n}\n\n/** What `footprintBlocker` reads of an object: its world matrix (three.js `Object3D`, duck-typed so the contracts stay free of three.js). */\nexport interface FootprintObject {\n readonly matrixWorld: { readonly elements: ArrayLike<number> }\n readonly parent?: unknown\n updateWorldMatrix?(updateParents: boolean, updateChildren: boolean): void\n}\n\n/**\n * The `ModuleHost.addBlocker` function of a solid object standing on the floor: the walking player stays out of `footprint`, a rectangle in the object's own space,\n * so the object blocks turned with it and follows it when it moves. An object out of the scene (no parent) blocks nothing.\n */\nexport function footprintBlocker(object: FootprintObject | (() => FootprintObject | null | undefined), footprint: Footprint | (() => Footprint | null | undefined)): (x: number, z: number, radius: number) => boolean {\n return (x, z, radius) => {\n const o = typeof object === 'function' ? object() : object\n const fp = typeof footprint === 'function' ? footprint() : footprint\n if (!o || !fp || !o.parent) return false\n o.updateWorldMatrix?.(true, false)\n const e = o.matrixWorld.elements\n // the world point in the object's own floor axes: its x and z columns are at right angles, so a projection on each (over its squared length, for a scale) inverts the matrix\n const dx = x - (e[12] as number)\n const dz = z - (e[14] as number)\n const ax = (e[0] as number) ** 2 + (e[1] as number) ** 2 + (e[2] as number) ** 2\n const az = (e[8] as number) ** 2 + (e[9] as number) ** 2 + (e[10] as number) ** 2\n const lx = (dx * (e[0] as number) + dz * (e[2] as number)) / (ax || 1)\n const lz = (dx * (e[8] as number) + dz * (e[10] as number)) / (az || 1)\n return lx > fp.minX - radius && lx < fp.maxX + radius && lz > fp.minZ - radius && lz < fp.maxZ + radius\n }\n}\n","import type { TerrainMaterial, TerrainProfile } from './types'\n\n// Specific energies are what the marks of real blows imply: a 500 J pistol round leaves about 120 cm³ in soil and 3 cm³ in concrete, an 800 kJ grenade a 1.3 m crater in soil and a 25 cm spall on a slab.\nexport const TERRAIN_MATERIALS: readonly TerrainMaterial[] = [\n {\n id: 'turf',\n debris: 'clods',\n rimShare: 0.25,\n reposeDegrees: 55,\n color: [0.09, 0.2, 0.04],\n pattern: 'grass',\n hits: {\n ballistic: { specificEnergy: 3e6, aspect: 0.6 },\n blunt: { specificEnergy: 2e6, aspect: 0.2, thresholdJoules: 60 },\n slash: { specificEnergy: 3e5, aspect: 0.5 },\n explosive: { specificEnergy: 3e6, aspect: 0.45 },\n },\n fire: { maxScorch: 1, scorchPerSecond: 0.45, flammable: true },\n },\n {\n id: 'soil',\n debris: 'clods',\n rimShare: 0.35,\n reposeDegrees: 40,\n color: [0.2, 0.12, 0.06],\n pattern: 'soil',\n hits: {\n ballistic: { specificEnergy: 4e6, aspect: 0.6 },\n blunt: { specificEnergy: 2e6, aspect: 0.2, thresholdJoules: 60 },\n slash: { specificEnergy: 3e5, aspect: 0.5 },\n explosive: { specificEnergy: 4e6, aspect: 0.45 },\n },\n fire: { maxScorch: 0.6, scorchPerSecond: 0.15 },\n },\n {\n id: 'sand',\n debris: 'dust',\n rimShare: 0.5,\n reposeDegrees: 33,\n color: [0.62, 0.5, 0.3],\n pattern: 'sand',\n hits: {\n ballistic: { specificEnergy: 2.5e6, aspect: 0.5 },\n blunt: { specificEnergy: 1e6, aspect: 0.2, thresholdJoules: 30 },\n slash: { specificEnergy: 1.5e5, aspect: 0.5 },\n explosive: { specificEnergy: 3e6, aspect: 0.35 },\n },\n fire: { maxScorch: 0.2, scorchPerSecond: 0.05 },\n },\n {\n id: 'gravel',\n debris: 'chips',\n rimShare: 0.3,\n reposeDegrees: 38,\n color: [0.27, 0.25, 0.22],\n pattern: 'stone',\n hits: {\n ballistic: { specificEnergy: 8e6, aspect: 0.4 },\n blunt: { specificEnergy: 8e6, aspect: 0.2, thresholdJoules: 200 },\n slash: { specificEnergy: 3e7, aspect: 0.3, thresholdJoules: 400 },\n explosive: { specificEnergy: 1e7, aspect: 0.35 },\n },\n fire: { maxScorch: 0.3, scorchPerSecond: 0.06 },\n },\n {\n id: 'asphalt',\n debris: 'chips',\n color: [0.05, 0.05, 0.055],\n pattern: 'asphalt',\n hits: {\n ballistic: { specificEnergy: 3e7, aspect: 0.2, minRadius: 0.03 },\n blunt: { specificEnergy: 1e8, aspect: 0.1, thresholdJoules: 400 },\n slash: { specificEnergy: 3e8, aspect: 0.2, thresholdJoules: 400 },\n explosive: { specificEnergy: 2e8, aspect: 0.12 },\n },\n fire: { maxScorch: 0.85, scorchPerSecond: 0.1, burnsAway: 0.00005 },\n },\n {\n id: 'concrete',\n debris: 'chips',\n color: [0.33, 0.33, 0.31],\n pattern: 'concrete',\n hits: {\n ballistic: { specificEnergy: 1.5e8, aspect: 0.14, minRadius: 0.018 },\n blunt: { specificEnergy: 3e8, aspect: 0.08, thresholdJoules: 800 },\n slash: { specificEnergy: 1e9, aspect: 0.2, thresholdJoules: 800 },\n explosive: { specificEnergy: 2.7e9, aspect: 0.12 },\n },\n fire: { maxScorch: 0.45, scorchPerSecond: 0.08 },\n },\n {\n id: 'mortar',\n debris: 'chips',\n color: [0.42, 0.4, 0.36],\n pattern: 'plain',\n hits: {\n ballistic: { specificEnergy: 4e7, aspect: 0.2 },\n blunt: { specificEnergy: 8e7, aspect: 0.1, thresholdJoules: 300 },\n slash: { specificEnergy: 3e8, aspect: 0.2, thresholdJoules: 300 },\n explosive: { specificEnergy: 8e8, aspect: 0.14 },\n },\n fire: { maxScorch: 0.45, scorchPerSecond: 0.08 },\n },\n {\n id: 'ceramic',\n debris: 'shards',\n color: [0.72, 0.7, 0.66],\n pattern: 'tile',\n hits: {\n ballistic: { specificEnergy: 8e6, aspect: 0.1, minRadius: 0.05 },\n blunt: { specificEnergy: 1e7, aspect: 0.08, thresholdJoules: 80, minRadius: 0.06 },\n slash: { specificEnergy: 2e8, aspect: 0.1, thresholdJoules: 200, minRadius: 0.03 },\n explosive: { specificEnergy: 1e8, aspect: 0.03, minRadius: 0.12 },\n },\n fire: { maxScorch: 0.5, scorchPerSecond: 0.08 },\n },\n {\n id: 'wood_hard',\n debris: 'splinters',\n color: [0.3, 0.16, 0.07],\n pattern: 'planks',\n hits: {\n ballistic: { specificEnergy: 2.5e7, aspect: 0.35, minRadius: 0.012 },\n blunt: { specificEnergy: 8e7, aspect: 0.1, thresholdJoules: 120 },\n slash: { specificEnergy: 1.5e7, aspect: 0.4, thresholdJoules: 15 },\n explosive: { specificEnergy: 2e8, aspect: 0.1, minRadius: 0.1 },\n },\n fire: { maxScorch: 1, scorchPerSecond: 0.2, burnsAway: 0.0012, flammable: true },\n },\n {\n id: 'stone',\n debris: 'chips',\n color: [0.23, 0.22, 0.2],\n pattern: 'stone',\n hits: {\n ballistic: { specificEnergy: 2.5e8, aspect: 0.12, minRadius: 0.015 },\n blunt: { specificEnergy: 5e8, aspect: 0.08, thresholdJoules: 1500 },\n slash: { specificEnergy: 2e9, aspect: 0.2, thresholdJoules: 1500 },\n explosive: { specificEnergy: 4e9, aspect: 0.14 },\n },\n fire: { maxScorch: 0.4, scorchPerSecond: 0.06 },\n },\n]\n\nexport const TERRAIN_PROFILES: readonly TerrainProfile[] = [\n { id: 'lawn', strata: [{ material: 'turf', thickness: 0.05 }, { material: 'soil', thickness: 1.5 }, { material: 'stone', thickness: 0.5 }] },\n { id: 'dirt', strata: [{ material: 'soil', thickness: 1.8 }, { material: 'stone', thickness: 0.5 }] },\n { id: 'sand', strata: [{ material: 'sand', thickness: 2.5 }] },\n { id: 'road', strata: [{ material: 'asphalt', thickness: 0.05 }, { material: 'gravel', thickness: 0.25 }, { material: 'soil', thickness: 1.5 }] },\n { id: 'concrete_slab', strata: [{ material: 'concrete', thickness: 0.12, reinforcement: { material: 'steel', cover: 0.03, spacing: 0.15, resistance: 4 } }] },\n { id: 'suspended_slab', drop: 3, strata: [{ material: 'concrete', thickness: 0.12, reinforcement: { material: 'steel', cover: 0.03, spacing: 0.15, resistance: 4 } }] },\n { id: 'tile_floor', strata: [{ material: 'ceramic', thickness: 0.008 }, { material: 'mortar', thickness: 0.03 }, { material: 'concrete', thickness: 0.1, reinforcement: { material: 'steel', cover: 0.03, spacing: 0.15, resistance: 4 } }] },\n { id: 'wood_floor', strata: [{ material: 'wood_hard', thickness: 0.022 }, { material: 'mortar', thickness: 0.03 }, { material: 'concrete', thickness: 0.1, reinforcement: { material: 'steel', cover: 0.03, spacing: 0.15, resistance: 4 } }] },\n { id: 'stone_paving', strata: [{ material: 'stone', thickness: 0.08 }, { material: 'gravel', thickness: 0.2 }, { material: 'soil', thickness: 1.2 }] },\n]\n\n/** About twelve grenades (200 kJ) on the same spot of bare bars. */\nexport const DEFAULT_BAR_YIELD_JOULES = 2_400_000\n\n/** The profile of a floor with nothing else said: a plain slab. */\nexport const DEFAULT_PROFILE = 'concrete_slab'\n","import type { TerrainDebris, TerrainDebrisKind } from './types'\n\nconst REST_SPEED = 0.6\nconst RESTITUTION = 0.25\nconst FRICTION = 0.55\nconst MAX_BOUNCES = 2\nconst REST_GAP = 0.01\n\nexport interface DebrisWorld {\n heightAt(x: number, z: number): number\n gravity: number\n /** Soft pieces join the ground where they stop; a false keeps the piece as loose rubble. */\n settle(piece: TerrainDebris): boolean\n}\n\n/** Pieces thrown by blows: they fly, hit the deformed ground, and come to rest. Past `max` the oldest at rest goes first. */\nexport interface DebrisStore {\n readonly list: TerrainDebris[]\n throwPiece(spec: { material: string; kind: TerrainDebrisKind; radius: number; position: [number, number, number]; velocity: [number, number, number]; spin: [number, number, number] }): void\n update(dt: number, world: DebrisWorld): void\n clear(): void\n}\n\nexport function createDebrisStore(max: () => number): DebrisStore {\n const list: TerrainDebris[] = []\n let serial = 0\n\n function makeRoom(): void {\n if (list.length < max()) return\n let at = 0\n let oldest = Infinity\n let oldestResting = -1\n let restingBorn = Infinity\n list.forEach((piece, index) => {\n if (piece.born < oldest) {\n oldest = piece.born\n at = index\n }\n if (piece.atRest && piece.born < restingBorn) {\n restingBorn = piece.born\n oldestResting = index\n }\n })\n list.splice(oldestResting >= 0 ? oldestResting : at, 1)\n }\n\n return {\n list,\n throwPiece(spec) {\n makeRoom()\n list.push({ ...spec, id: ++serial, born: serial, atRest: false, bounces: 0 })\n },\n update(dt, world) {\n for (let i = list.length - 1; i >= 0; i--) {\n const piece = list[i]!\n if (piece.atRest) {\n // what it lay on was dug away or heaped over: it falls again, or rides up\n const under = world.heightAt(piece.position[0], piece.position[2]) + piece.radius * 0.6\n if (piece.position[1] > under + REST_GAP) piece.atRest = false\n else if (piece.position[1] < under - REST_GAP) piece.position[1] = under\n if (piece.atRest) continue\n }\n const { position, velocity } = piece\n velocity[1] -= world.gravity * dt\n position[0] += velocity[0] * dt\n position[1] += velocity[1] * dt\n position[2] += velocity[2] * dt\n const ground = world.heightAt(position[0], position[2]) + piece.radius * 0.6\n if (position[1] > ground) continue\n position[1] = ground\n const speed = Math.hypot(velocity[0], velocity[1], velocity[2])\n if (speed < REST_SPEED || piece.bounces >= MAX_BOUNCES) {\n piece.atRest = true\n if (world.settle(piece)) list.splice(i, 1)\n continue\n }\n piece.bounces++\n velocity[1] = Math.abs(velocity[1]) * RESTITUTION\n velocity[0] *= FRICTION\n velocity[2] *= FRICTION\n }\n },\n clear() {\n list.length = 0\n },\n }\n}\n","import type { TerrainBudget, TerrainMark } from './types'\n\n/** Two marks at this fraction of their radii or closer are one hole. */\nconst SAME_HOLE = 0.75\n\n/** Fine marks under a budget per area and a global one: past the budget the closest two of the area become one, and past the global one the oldest goes. */\nexport interface MarkStore {\n readonly list: TerrainMark[]\n add(spec: Omit<TerrainMark, 'id' | 'count' | 'born'>): TerrainMark\n near(x: number, z: number, radius: number): TerrainMark | undefined\n depthAt(x: number, z: number): number\n remove(mark: TerrainMark): void\n removeInside(x: number, z: number, radius: number): void\n clear(): void\n load(marks: readonly TerrainMark[]): void\n readonly version: number\n}\n\nexport function createMarkStore(budget: TerrainBudget): MarkStore {\n const list: TerrainMark[] = []\n const areas = new Map<number, TerrainMark[]>()\n let serial = 0\n let version = 0\n const areaKey = (x: number, z: number): number => Math.floor(x / budget.markAreaSize) * 100003 + Math.floor(z / budget.markAreaSize)\n\n function detach(mark: TerrainMark): void {\n const key = areaKey(mark.x, mark.z)\n const area = areas.get(key)\n if (area) {\n const at = area.indexOf(mark)\n if (at >= 0) area.splice(at, 1)\n if (area.length === 0) areas.delete(key)\n }\n const at = list.indexOf(mark)\n if (at >= 0) list.splice(at, 1)\n version++\n }\n\n function attach(mark: TerrainMark): void {\n const key = areaKey(mark.x, mark.z)\n const area = areas.get(key)\n if (area) area.push(mark)\n else areas.set(key, [mark])\n list.push(mark)\n version++\n }\n\n function mergeClosestPair(area: TerrainMark[]): void {\n let bestA = area[0]!\n let bestB = area[1]!\n let best = Infinity\n for (let i = 0; i < area.length; i++) {\n for (let j = i + 1; j < area.length; j++) {\n const distance = Math.hypot(area[i]!.x - area[j]!.x, area[i]!.z - area[j]!.z)\n if (distance < best) {\n best = distance\n bestA = area[i]!\n bestB = area[j]!\n }\n }\n }\n const weight = bestA.radius ** 2 + bestB.radius ** 2\n const shareB = weight > 0 ? bestB.radius ** 2 / weight : 0.5\n detach(bestB)\n bestA.x += (bestB.x - bestA.x) * shareB\n bestA.z += (bestB.z - bestA.z) * shareB\n bestA.radius = Math.sqrt(weight)\n bestA.depth = Math.max(bestA.depth, bestB.depth)\n bestA.count += bestB.count\n version++\n }\n\n return {\n list,\n add(spec) {\n const mark: TerrainMark = { ...spec, id: ++serial, count: 1, born: serial }\n attach(mark)\n const area = areas.get(areaKey(mark.x, mark.z))!\n if (area.length > budget.maxMarksPerArea) mergeClosestPair(area)\n if (list.length > budget.maxMarks) detach(list.reduce((oldest, candidate) => (candidate.born < oldest.born ? candidate : oldest)))\n return mark\n },\n near(x, z, radius) {\n let found: TerrainMark | undefined\n let best = Infinity\n const reach = Math.ceil((radius + 0.5) / budget.markAreaSize)\n const baseX = Math.floor(x / budget.markAreaSize)\n const baseZ = Math.floor(z / budget.markAreaSize)\n for (let dx = -reach; dx <= reach; dx++) {\n for (let dz = -reach; dz <= reach; dz++) {\n for (const mark of areas.get((baseX + dx) * 100003 + baseZ + dz) ?? []) {\n const distance = Math.hypot(mark.x - x, mark.z - z)\n if (distance < best && distance < Math.max(mark.radius, radius) * SAME_HOLE) {\n best = distance\n found = mark\n }\n }\n }\n }\n return found\n },\n depthAt(x, z) {\n let depth = 0\n for (const mark of areas.get(areaKey(x, z)) ?? []) {\n const distance = Math.hypot(mark.x - x, mark.z - z)\n if (distance < mark.radius) depth = Math.max(depth, mark.depth * (1 - (distance / mark.radius) ** 2))\n }\n return depth\n },\n remove: detach,\n removeInside(x, z, radius) {\n for (const mark of [...list]) if (Math.hypot(mark.x - x, mark.z - z) + mark.radius * 0.5 < radius) detach(mark)\n },\n clear() {\n list.length = 0\n areas.clear()\n version++\n },\n load(marks) {\n this.clear()\n for (const mark of marks) {\n attach({ ...mark })\n serial = Math.max(serial, mark.id)\n }\n },\n get version() {\n return version\n },\n }\n}\n","/** A small seeded stream: the same seed digs the same crater, so a blow of a few bytes draws the same on every client. */\nexport function createRandom(seed: number): () => number {\n let state = seed | 0\n return () => {\n state = (state + 0x6d2b79f5) | 0\n let mixed = Math.imul(state ^ (state >>> 15), 1 | state)\n mixed = (mixed + Math.imul(mixed ^ (mixed >>> 7), 61 | mixed)) ^ mixed\n return ((mixed ^ (mixed >>> 14)) >>> 0) / 4294967296\n }\n}\n","import type { Hit } from '@adatechnology/game-contracts'\nimport { DEFAULT_BAR_YIELD_JOULES, DEFAULT_PROFILE, TERRAIN_MATERIALS, TERRAIN_PROFILES } from './catalog'\nimport { createDebrisStore } from './debris'\nimport { createMarkStore } from './marks'\nimport { createRandom } from './random'\nimport type { GroundLayout, Terrain, TerrainBlow, TerrainBlowResult, TerrainBudget, TerrainChunk, TerrainColumn, TerrainDebris, TerrainMark, TerrainMaterial, TerrainOptions, TerrainProfile, TerrainRegion, TerrainSnapshot, TerrainStratum } from './types'\n\nexport const DEFAULT_TERRAIN_BUDGET: TerrainBudget = {\n markAreaSize: 2,\n maxMarksPerArea: 24,\n maxMarks: 800,\n maxDirtyChunksPerFrame: 6,\n maxDebris: 400,\n maxDebrisPerBlow: 36,\n maxSlideCellsPerFrame: 2500,\n maxBurningCells: 300,\n}\n\nconst GRAVITY = 9.81\n/** A bowl narrower than this many cells cannot be drawn by the height field: it stays a fine mark. */\nconst FINE_CELLS = 1.5\n/** A hole deeper than this many cells is no longer a mark on the surface. */\nconst FINE_DEPTH_CELLS = 0.4\nconst RIGID_WIDENING = 2.5\n/** Bars alone do not drop a slab that still has this share of its concrete to lose. */\nconst COLLAPSE_DEPTH_SHARE = 0.75\nconst COLLAPSE_SPREAD_BEND = 0.5\nconst RIM_REACH = 1.45\n/** Under this temperature (°C) a flame does nothing to the ground. */\nconst FLAME_MINIMUM = 200\n/** How far over the ground a flame still touches it, beyond its radius. */\nconst FLAME_REACH = 0.6\n/** How far a blast above the ground still reaches it, as a share of its radius. */\nconst BLAST_HEIGHT_REACH = 0.35\n/** Share of a blow's energy that throws the pieces. */\nconst THROW_SHARE = 0.02\n/** Side of a square of ground that burns as one, in metres. */\nconst FIRE_CELL = 0.5\nconst FIRE_SECONDS = 7\nconst FIRE_SPREAD_PER_SECOND = 0.35\nconst SLIDE_SHARE = 0.4\nconst SLIDE_EPSILON = 0.002\n/** Radius of a thrown piece per kind: a clod is a fist of soil, a chip is a thumb of concrete. */\nconst PIECE_RADIUS: Record<TerrainMaterial['debris'], number> = { clods: 0.045, chips: 0.022, shards: 0.028, splinters: 0.02, dust: 0.012 }\n/** Metres per second the fastest piece of a blow leaves at: a blast throws, a dropped weight only kicks. */\nconst MAX_PIECE_SPEED: Record<TerrainBlow, number> = { explosive: 14, ballistic: 5, blunt: 2.5, slash: 1.5 }\n\nconst clampIndex = (value: number, max: number): number => Math.max(0, Math.min(max, value))\nconst sparse = (array: Float32Array): [number, number][] => {\n const entries: [number, number][] = []\n array.forEach((value, index) => {\n if (value !== 0) entries.push([index, value])\n })\n return entries\n}\nconst fillSparse = (array: Float32Array, entries: readonly (readonly [number, number])[]): void => {\n array.fill(0)\n for (const [index, value] of entries) array[index] = value\n}\n\n/** Layer a depth falls in; past the end, the last one. */\nfunction stratumAtDepth(profile: TerrainProfile, depth: number): { index: number; stratum: TerrainStratum; top: number } {\n let top = 0\n for (let index = 0; index < profile.strata.length; index++) {\n const stratum = profile.strata[index]!\n if (depth < top + stratum.thickness - 1e-6) return { index, stratum, top }\n top += stratum.thickness\n }\n const index = profile.strata.length - 1\n const stratum = profile.strata[index]!\n return { index, stratum, top: top - stratum.thickness }\n}\nconst totalThickness = (profile: TerrainProfile): number => profile.strata.reduce((sum, stratum) => sum + stratum.thickness, 0)\n\nexport function createTerrain(options: TerrainOptions): Terrain {\n const [x0, z0, x1, z1] = options.bounds\n const cellSize = options.cellSize ?? 0.1\n const chunkCells = options.chunkCells ?? 32\n const columns = Math.max(1, Math.ceil((x1 - x0) / cellSize))\n const rows = Math.max(1, Math.ceil((z1 - z0) / cellSize))\n const stride = columns + 1\n const vertexCount = stride * (rows + 1)\n const chunksX = Math.ceil(columns / chunkCells)\n const chunksZ = Math.ceil(rows / chunkCells)\n const budget: TerrainBudget = { ...DEFAULT_TERRAIN_BUDGET, ...options.budget }\n const gravity = options.gravity ?? GRAVITY\n const materials = new Map<string, TerrainMaterial>([...TERRAIN_MATERIALS, ...(options.materials ?? [])].map((material) => [material.id, material]))\n const profileMap = new Map<string, TerrainProfile>([...TERRAIN_PROFILES, ...(options.profiles ?? [])].map((profile) => [profile.id, profile]))\n const profileList = [...profileMap.values()]\n for (const profile of profileList) {\n for (const stratum of profile.strata) if (!materials.has(stratum.material)) throw new Error(`game-terrain: profile \"${profile.id}\" uses material \"${stratum.material}\", which has no ground behaviour`)\n }\n const indexOfProfile = new Map(profileList.map((profile, index) => [profile.id, index]))\n const maxDepthOf = profileList.map(totalThickness)\n\n const base = new Float32Array(vertexCount)\n if (options.baseHeight) {\n for (let row = 0; row <= rows; row++) for (let column = 0; column <= columns; column++) base[row * stride + column] = options.baseHeight(x0 + column * cellSize, z0 + row * cellSize)\n }\n const dug = new Float32Array(vertexCount)\n const loose = new Float32Array(vertexCount)\n const scorch = new Float32Array(vertexCount)\n const barBend = new Float32Array(vertexCount)\n const profileIndex = new Uint8Array(vertexCount)\n const touched = new Uint8Array(chunksX * chunksZ)\n const mixed = new Uint8Array(chunksX * chunksZ)\n const dirty = new Set<number>()\n const marks = createMarkStore(budget)\n const debrisStore = createDebrisStore(() => budget.maxDebris)\n const sliding = new Set<number>()\n const burning = new Map<number, number>()\n const random = createRandom(options.seed ?? 1)\n let currentLayout: GroundLayout = options.layout ?? { surface: DEFAULT_PROFILE }\n\n const deformListeners = new Set<(region: TerrainRegion) => void>()\n const emitDeform = (x: number, z: number, radius: number): void => {\n for (const listener of deformListeners) listener({ x, z, radius })\n }\n\n const chunkOfVertex = (column: number, row: number): number => Math.min(chunksZ - 1, Math.floor(row / chunkCells)) * chunksX + Math.min(chunksX - 1, Math.floor(column / chunkCells))\n /** A vertex on a chunk's edge belongs to its neighbours as well. */\n function touchVertex(column: number, row: number): void {\n const chunk = chunkOfVertex(column, row)\n touched[chunk] = 1\n dirty.add(chunk)\n if (column % chunkCells === 0 && column > 0) markChunk(chunk - 1)\n if (row % chunkCells === 0 && row > 0) markChunk(chunk - chunksX)\n if (column % chunkCells === 0 && row % chunkCells === 0 && column > 0 && row > 0) markChunk(chunk - chunksX - 1)\n }\n function markChunk(chunk: number): void {\n touched[chunk] = 1\n dirty.add(chunk)\n }\n\n const profileId = (id: string): number => indexOfProfile.get(id) ?? indexOfProfile.get(DEFAULT_PROFILE) ?? 0\n\n function applyLayout(layout: GroundLayout): void {\n currentLayout = layout\n profileIndex.fill(profileId(layout.surface))\n for (const zone of layout.zones ?? []) {\n const index = profileId(zone.surface)\n const [zx0, zz0, zx1, zz1] = zone.rect\n const column0 = clampIndex(Math.round((zx0 - x0) / cellSize), columns)\n const column1 = clampIndex(Math.round((zx1 - x0) / cellSize), columns)\n const row0 = clampIndex(Math.round((zz0 - z0) / cellSize), rows)\n const row1 = clampIndex(Math.round((zz1 - z0) / cellSize), rows)\n for (let row = row0; row <= row1; row++) profileIndex.fill(index, row * stride + column0, row * stride + column1 + 1)\n }\n for (let chunkZ = 0; chunkZ < chunksZ; chunkZ++) {\n for (let chunkX = 0; chunkX < chunksX; chunkX++) {\n const chunk = chunkZ * chunksX + chunkX\n const first = profileIndex[(chunkZ * chunkCells) * stride + chunkX * chunkCells]\n let isMixed = 0\n const lastRow = Math.min(rows, (chunkZ + 1) * chunkCells)\n const lastColumn = Math.min(columns, (chunkX + 1) * chunkCells)\n for (let row = chunkZ * chunkCells; row <= lastRow && !isMixed; row++) {\n for (let column = chunkX * chunkCells; column <= lastColumn; column++) {\n if (profileIndex[row * stride + column] !== first) {\n isMixed = 1\n break\n }\n }\n }\n mixed[chunk] = isMixed\n dirty.add(chunk)\n }\n }\n }\n\n const vertexHeight = (column: number, row: number): number => {\n const index = row * stride + column\n return base[index]! - dug[index]! + loose[index]!\n }\n const contains = (x: number, z: number): boolean => x >= x0 && x <= x1 && z >= z0 && z <= z1\n\n function heightAt(x: number, z: number): number {\n // beyond the terrain the ground is untouched: no blow can dig it and a piece thrown out lands on it\n if (!contains(x, z)) return options.baseHeight?.(x, z) ?? 0\n const fx = Math.max(0, Math.min(columns - 1e-6, (x - x0) / cellSize))\n const fz = Math.max(0, Math.min(rows - 1e-6, (z - z0) / cellSize))\n const column = fx | 0\n const row = fz | 0\n const tx = fx - column\n const tz = fz - row\n const index = row * stride + column\n const h = (i: number): number => base[i]! - dug[i]! + loose[i]!\n return (h(index) * (1 - tx) + h(index + 1) * tx) * (1 - tz) + (h(index + stride) * (1 - tx) + h(index + stride + 1) * tx) * tz\n }\n\n const vertexOf = (x: number, z: number): { column: number; row: number; index: number } => {\n const column = clampIndex(Math.round((x - x0) / cellSize), columns)\n const row = clampIndex(Math.round((z - z0) / cellSize), rows)\n return { column, row, index: row * stride + column }\n }\n\n function vertexMaterial(column: number, row: number): TerrainMaterial {\n const index = row * stride + column\n return materials.get(stratumAtDepth(profileList[profileIndex[index]!]!, dug[index]!).stratum.material)!\n }\n\n function columnAt(x: number, z: number): TerrainColumn {\n const { index } = vertexOf(x, z)\n const which = profileIndex[index]!\n const profile = profileList[which]!\n const depth = Math.min(maxDepthOf[which]!, dug[index]! + marks.depthAt(x, z))\n const layer = stratumAtDepth(profile, depth)\n return { profile: profile.id, material: layer.stratum.material, stratum: layer.index, depth, remaining: maxDepthOf[which]! - depth, scorch: scorch[index]!, loose: loose[index]! }\n }\n\n /** Removes from a vertex: what is heaped first, then the column, never past its limit. Returns what came off. */\n function removeFromVertex(index: number, amount: number): number {\n const fromLoose = Math.min(loose[index]!, amount)\n loose[index] = loose[index]! - fromLoose\n const fromColumn = Math.min(maxDepthOf[profileIndex[index]!]! - dug[index]!, amount - fromLoose)\n if (fromColumn > 0) dug[index] = dug[index]! + fromColumn\n return fromLoose + Math.max(0, fromColumn)\n }\n /** Adds to a vertex: a soft floor fills what was dug, then heaps; on a rigid one the rubble just heaps. */\n function addToVertex(index: number, amount: number, fillsColumn: boolean): void {\n const filled = fillsColumn ? Math.min(dug[index]!, amount) : 0\n dug[index] = dug[index]! - filled\n loose[index] = loose[index]! + amount - filled\n }\n const isSoftExposed = (index: number): boolean => materials.get(stratumAtDepth(profileList[profileIndex[index]!]!, dug[index]!).stratum.material)!.reposeDegrees !== undefined\n\n /** Digs a lobed bowl into the height field, heaps the rim share around it. */\n function stampBowl(x: number, z: number, radius: number, depth: number, rimVolume: number): void {\n const reach = radius * RIM_REACH\n const column0 = clampIndex(Math.floor((x - reach - x0) / cellSize), columns)\n const column1 = clampIndex(Math.ceil((x + reach - x0) / cellSize), columns)\n const row0 = clampIndex(Math.floor((z - reach - z0) / cellSize), rows)\n const row1 = clampIndex(Math.ceil((z + reach - z0) / cellSize), rows)\n const phaseA = random() * 6.283\n const phaseB = random() * 6.283\n const shapeOf = (px: number, pz: number): number => {\n const angle = Math.atan2(pz - z, px - x)\n return Math.hypot(px - x, pz - z) / (radius * (1 + 0.12 * Math.sin(angle * 5 + phaseA) + 0.06 * Math.sin(angle * 11 + phaseB)))\n }\n let rimWeight = 0\n if (rimVolume > 0) {\n for (let row = row0; row <= row1; row++) {\n for (let column = column0; column <= column1; column++) {\n const q = shapeOf(x0 + column * cellSize, z0 + row * cellSize)\n if (q >= 1 && q < RIM_REACH) rimWeight += Math.sin(((q - 1) / (RIM_REACH - 1)) * Math.PI)\n }\n }\n }\n const cellArea = cellSize * cellSize\n for (let row = row0; row <= row1; row++) {\n for (let column = column0; column <= column1; column++) {\n const q = shapeOf(x0 + column * cellSize, z0 + row * cellSize)\n const index = row * stride + column\n if (q < 1) {\n removeFromVertex(index, depth * (1 - q * q) * (0.92 + random() * 0.16))\n sliding.add(index)\n touchVertex(column, row)\n } else if (q < RIM_REACH && rimWeight > 0) {\n loose[index] = loose[index]! + (rimVolume * Math.sin(((q - 1) / (RIM_REACH - 1)) * Math.PI)) / (rimWeight * cellArea)\n sliding.add(index)\n touchVertex(column, row)\n }\n }\n }\n marks.removeInside(x, z, radius)\n emitDeform(x, z, reach)\n }\n\n function throwPieces(spec: { x: number; y: number; z: number; volume: number; material: TerrainMaterial; energy: number; direction?: readonly number[]; quota: number; bowlRadius: number; blow: TerrainBlow }): void {\n const radius = PIECE_RADIUS[spec.material.debris]\n const pieceVolume = (4 / 3) * Math.PI * radius ** 3\n const count = Math.min(spec.quota, Math.max(1, Math.round(spec.volume / pieceVolume)))\n const mass = Math.max(1e-3, spec.volume * 1500)\n const launch = Math.min(MAX_PIECE_SPEED[spec.blow], Math.sqrt((2 * spec.energy * THROW_SHARE) / mass))\n for (let i = 0; i < count; i++) {\n const angle = random() * 6.283\n const out = spec.bowlRadius * Math.sqrt(random()) * 0.8\n const speed = launch * (0.5 + random() * 0.7)\n const spread = 0.35 + random() * 0.65\n const backX = spec.direction ? -(spec.direction[0] ?? 0) * 0.4 : 0\n const backZ = spec.direction ? -(spec.direction[2] ?? 0) * 0.4 : 0\n debrisStore.throwPiece({\n material: spec.material.id,\n kind: spec.material.debris,\n radius: radius * (0.6 + random() * 0.9),\n position: [spec.x + Math.cos(angle) * out, spec.y + 0.03, spec.z + Math.sin(angle) * out],\n velocity: [(Math.cos(angle) * spread + backX) * speed, (0.6 + random() * 0.7) * speed, (Math.sin(angle) * spread + backZ) * speed],\n spin: [(random() - 0.5) * 14, (random() - 0.5) * 14, (random() - 0.5) * 14],\n })\n }\n }\n\n function strike(blow: TerrainBlow, spec: { x: number; z: number; energy: number; direction?: readonly number[]; contactRadius?: number }): TerrainBlowResult {\n const { x, z } = spec\n const start = columnAt(x, z)\n const profile = profileMap.get(start.profile)!\n const removed: { material: string; volume: number }[] = []\n const point: [number, number, number] = [x, heightAt(x, z), z]\n let energy = spec.energy\n let depth = start.depth\n let radius = 0\n let carved = 0\n const firstMaterial = materials.get(start.material)!\n if (start.loose <= 0) {\n for (;;) {\n const layer = stratumAtDepth(profile, depth)\n const bottom = layer.top + layer.stratum.thickness\n const room = bottom - depth\n if (room <= 1e-6) break\n const material = materials.get(layer.stratum.material)!\n const response = material.hits[blow]\n if (!response || energy < (response.thresholdJoules ?? 0)) break\n const bars = layer.stratum.reinforcement\n const pastCover = bars !== undefined && depth - layer.top >= bars.cover - 1e-6\n const specificEnergy = response.specificEnergy * (pastCover ? bars.resistance : 1)\n const limit = bars && !pastCover ? Math.min(room, layer.top + bars.cover - depth) : room\n const volume = energy / specificEnergy\n const wanted = Math.cbrt((2 * volume) / (Math.PI * response.aspect))\n const wantedDepth = wanted * response.aspect\n const takenDepth = Math.min(wantedDepth, limit)\n const takenRadius = takenDepth < wantedDepth ? takenDepth / response.aspect : wanted\n const takenVolume = (Math.PI * takenRadius * takenRadius * takenDepth) / 2\n energy -= takenVolume * specificEnergy\n depth += takenDepth\n carved += takenDepth\n radius = Math.max(radius, takenRadius, response.minRadius ?? 0, spec.contactRadius ?? 0)\n const last = removed[removed.length - 1]\n if (last?.material === material.id) (last as { volume: number }).volume += takenVolume\n else removed.push({ material: material.id, volume: takenVolume })\n if (energy <= 1e-6 || takenDepth >= wantedDepth - 1e-9) break\n }\n } else {\n // a loose heap has no strength: it takes the blow as soft ground before the column does\n const response = firstMaterial.hits[blow]\n if (response && energy >= (response.thresholdJoules ?? 0)) {\n const volume = energy / response.specificEnergy\n radius = Math.cbrt((2 * volume) / (Math.PI * response.aspect))\n carved = radius * response.aspect\n removed.push({ material: firstMaterial.id, volume })\n }\n }\n const collapsed = loadBars(blow, spec.energy, x, z, profile)\n if (radius <= 0) return { point, radius: 0, depth: 0, totalDepth: columnAt(x, z).depth, removed: [], exposed: start.material, atLimit: start.remaining <= 1e-6, rebarExposed: rebarShowing(profile, start.depth), collapsed, fine: true }\n\n // a rigid floor cannot go down any more where it is already deep: the next blow breaks the edge of the hole, so a hole in a slab opens as it deepens\n if (firstMaterial.reposeDegrees === undefined && start.loose <= 0) radius *= 1 + RIGID_WIDENING * (start.depth / maxDepthOfProfile(profile))\n\n const volumeTotal = removed.reduce((sum, entry) => sum + entry.volume, 0)\n const fine = radius < cellSize * FINE_CELLS\n if (fine) depositMark({ x, z, radius, depth: carved, blow, profile, cap: maxDepthOfProfile(profile) })\n else {\n const rimShare = materials.get(removed[0]!.material)!.rimShare ?? 0\n stampBowl(x, z, radius, carved, volumeTotal * rimShare)\n }\n let quota = budget.maxDebrisPerBlow\n for (const entry of removed) {\n const material = materials.get(entry.material)!\n const thrown = entry.volume * (1 - (fine ? 0 : (material.rimShare ?? 0)))\n if (thrown <= 0 || quota <= 0) continue\n const share = Math.max(1, Math.round((quota * entry.volume) / volumeTotal))\n throwPieces({ x, y: point[1], z, volume: thrown, material, energy: spec.energy * (entry.volume / volumeTotal), direction: spec.direction, quota: share, bowlRadius: radius, blow })\n quota -= share\n }\n const after = columnAt(x, z)\n return { point, radius, depth: carved, totalDepth: after.depth, removed, exposed: after.material, atLimit: after.remaining <= 1e-4, rebarExposed: rebarShowing(profile, after.depth), collapsed, fine }\n }\n\n /**\n * A blow loads the bars that are bare around it: they bend, and past their limit they give way. The bowl a blow would open tells how far it reaches. Where the slab has nothing under it, the floor goes with them.\n */\n function loadBars(blow: TerrainBlow, energy: number, x: number, z: number, profile: TerrainProfile): boolean {\n const bars = profile.strata.find((stratum) => stratum.reinforcement)\n if (!bars?.reinforcement) return false\n const response = materials.get(bars.material)!.hits[blow]\n if (!response || energy < (response.thresholdJoules ?? 0)) return false\n const yieldJoules = bars.reinforcement.yieldJoules ?? DEFAULT_BAR_YIELD_JOULES\n let cover = bars.reinforcement.cover\n for (const stratum of profile.strata) {\n if (stratum === bars) break\n cover += stratum.thickness\n }\n const reach = Math.max(cellSize * FINE_CELLS, Math.cbrt((2 * energy) / response.specificEnergy / (Math.PI * response.aspect)) * (1 + RIGID_WIDENING))\n const column0 = clampIndex(Math.floor((x - reach - x0) / cellSize), columns)\n const column1 = clampIndex(Math.ceil((x + reach - x0) / cellSize), columns)\n const row0 = clampIndex(Math.floor((z - reach - z0) / cellSize), rows)\n const row1 = clampIndex(Math.ceil((z + reach - z0) / cellSize), rows)\n const limit = maxDepthOfProfile(profile)\n let collapsed = false\n for (let row = row0; row <= row1; row++) {\n for (let column = column0; column <= column1; column++) {\n const index = row * stride + column\n if (dug[index]! < cover) continue\n const q = Math.hypot(x0 + column * cellSize - x, z0 + row * cellSize - z) / reach\n if (q >= 1) continue\n barBend[index] = barBend[index]! + (energy / yieldJoules) * (1 - q * q)\n touchVertex(column, row)\n if (barBend[index]! >= 1 && profile.drop !== undefined && dug[index]! >= limit * COLLAPSE_DEPTH_SHARE && dug[index]! < limit + profile.drop) {\n dug[index] = limit + profile.drop\n collapsed = true\n }\n }\n }\n if (collapsed) emitDeform(x, z, reach)\n // what gave way takes down what was already bending around it\n if (collapsed && profile.drop !== undefined) {\n for (let row = row0; row <= row1; row++) {\n for (let column = column0; column <= column1; column++) {\n const index = row * stride + column\n if (barBend[index]! >= COLLAPSE_SPREAD_BEND && dug[index]! < limit + profile.drop) {\n dug[index] = limit + profile.drop\n touchVertex(column, row)\n }\n }\n }\n }\n return collapsed\n }\n\n const maxDepthOfProfile = (profile: TerrainProfile): number => maxDepthOf[indexOfProfile.get(profile.id)!]!\n function rebarShowing(profile: TerrainProfile, depth: number): boolean {\n const layer = stratumAtDepth(profile, depth)\n return layer.stratum.reinforcement !== undefined && depth - layer.top >= layer.stratum.reinforcement.cover - 1e-6\n }\n\n function depositMark(spec: { x: number; z: number; radius: number; depth: number; blow: TerrainBlow; profile: TerrainProfile; cap: number }): void {\n const { x, z, radius, depth, blow, profile, cap } = spec\n const dugHere = dug[vertexOf(x, z).index]!\n const existing = marks.near(x, z, radius)\n let mark: TerrainMark\n if (existing) {\n existing.radius = Math.hypot(existing.radius, radius)\n existing.depth = Math.min(cap - dugHere, existing.depth + depth)\n existing.count++\n existing.blow = blow\n mark = existing\n } else {\n mark = marks.add({ x, z, radius, depth: Math.min(cap - dugHere, depth), blow, material: '' })\n }\n mark.material = stratumAtDepth(profile, dugHere + mark.depth - 1e-6).stratum.material\n if (mark.radius >= cellSize * FINE_CELLS || mark.depth >= cellSize * FINE_DEPTH_CELLS) {\n const bowlRadius = Math.max(mark.radius, cellSize * FINE_CELLS)\n const bowlDepth = mark.depth\n marks.remove(mark)\n stampBowl(mark.x, mark.z, bowlRadius, bowlDepth, 0)\n }\n }\n\n function burn(spec: { x: number; z: number; radius: number; temperature: number; seconds: number }): void {\n if (spec.temperature < FLAME_MINIMUM) return\n const column0 = clampIndex(Math.floor((spec.x - spec.radius - x0) / cellSize), columns)\n const column1 = clampIndex(Math.ceil((spec.x + spec.radius - x0) / cellSize), columns)\n const row0 = clampIndex(Math.floor((spec.z - spec.radius - z0) / cellSize), rows)\n const row1 = clampIndex(Math.ceil((spec.z + spec.radius - z0) / cellSize), rows)\n for (let row = row0; row <= row1; row++) {\n for (let column = column0; column <= column1; column++) {\n if (Math.hypot(x0 + column * cellSize - spec.x, z0 + row * cellSize - spec.z) > spec.radius) continue\n burnVertex(column, row, spec.seconds)\n }\n }\n }\n\n function burnVertex(column: number, row: number, seconds: number): void {\n const index = row * stride + column\n const material = vertexMaterial(column, row)\n const response = material.fire\n if (!response) return\n const before = scorch[index]!\n scorch[index] = Math.max(before, Math.min(response.maxScorch, before + response.scorchPerSecond * seconds))\n if (response.burnsAway) removeFromVertex(index, response.burnsAway * seconds)\n if (scorch[index] !== before || response.burnsAway) touchVertex(column, row)\n if (response.flammable) ignite(x0 + column * cellSize, z0 + row * cellSize)\n }\n\n const fireKey = (x: number, z: number): number => Math.floor((z - z0) / FIRE_CELL) * 65536 + Math.floor((x - x0) / FIRE_CELL)\n function ignite(x: number, z: number): void {\n const key = fireKey(x, z)\n if (burning.has(key) || burning.size >= budget.maxBurningCells) return\n const index = vertexOf(x, z).index\n if (scorch[index]! >= 1) return\n burning.set(key, FIRE_SECONDS)\n }\n\n function updateFire(dt: number): void {\n for (const [key, remaining] of [...burning]) {\n const cellRow = Math.floor(key / 65536)\n const cellColumn = key - cellRow * 65536\n const x = x0 + (cellColumn + 0.5) * FIRE_CELL\n const z = z0 + (cellRow + 0.5) * FIRE_CELL\n burn({ x, z, radius: FIRE_CELL * 0.75, temperature: 600, seconds: dt })\n if (random() < FIRE_SPREAD_PER_SECOND * dt) {\n const angle = Math.floor(random() * 4) * (Math.PI / 2)\n const nextX = x + Math.cos(angle) * FIRE_CELL\n const nextZ = z + Math.sin(angle) * FIRE_CELL\n if (contains(nextX, nextZ)) {\n const { column, row, index } = vertexOf(nextX, nextZ)\n if (vertexMaterial(column, row).fire?.flammable && scorch[index]! < 1) ignite(nextX, nextZ)\n }\n }\n if (remaining - dt <= 0) burning.delete(key)\n else burning.set(key, remaining - dt)\n }\n }\n\n function updateSlides(): void {\n if (sliding.size === 0) return\n let budgetLeft = budget.maxSlideCellsPerFrame\n let minX = Infinity\n let minZ = Infinity\n let maxX = -Infinity\n let maxZ = -Infinity\n for (const index of sliding) {\n if (budgetLeft-- <= 0) break\n sliding.delete(index)\n const column = index % stride\n const row = (index - column) / stride\n if (!isSoftExposed(index)) continue\n const material = vertexMaterial(column, row)\n const slope = Math.tan(((material.reposeDegrees ?? 90) * Math.PI) / 180) * cellSize\n const height = vertexHeight(column, row)\n const neighbours: [number, number][] = [[column - 1, row], [column + 1, row], [column, row - 1], [column, row + 1]]\n for (const [nextColumn, nextRow] of neighbours) {\n if (nextColumn < 0 || nextRow < 0 || nextColumn > columns || nextRow > rows) continue\n const excess = height - vertexHeight(nextColumn, nextRow) - slope\n if (excess <= SLIDE_EPSILON) continue\n const moved = removeFromVertex(index, excess * SLIDE_SHARE)\n if (moved <= 0) continue\n const next = nextRow * stride + nextColumn\n addToVertex(next, moved, isSoftExposed(next))\n sliding.add(index)\n sliding.add(next)\n touchVertex(column, row)\n touchVertex(nextColumn, nextRow)\n const px = x0 + column * cellSize\n const pz = z0 + row * cellSize\n minX = Math.min(minX, px)\n maxX = Math.max(maxX, px)\n minZ = Math.min(minZ, pz)\n maxZ = Math.max(maxZ, pz)\n }\n }\n if (minX <= maxX) emitDeform((minX + maxX) / 2, (minZ + maxZ) / 2, Math.hypot(maxX - minX, maxZ - minZ) / 2 + cellSize * 2)\n }\n\n /** A piece of soft ground joins the height where it stops, so that what is thrown out piles up and can be walked on. */\n function settle(piece: TerrainDebris): boolean {\n const material = materials.get(piece.material)\n if (!material || material.reposeDegrees === undefined) return false\n if (!contains(piece.position[0], piece.position[2])) return true\n const { index, column, row } = vertexOf(piece.position[0], piece.position[2])\n addToVertex(index, ((4 / 3) * Math.PI * piece.radius ** 3) / (cellSize * cellSize), false)\n sliding.add(index)\n touchVertex(column, row)\n return true\n }\n\n function applyHit(hit: Hit): TerrainBlowResult | null {\n const [x, y, z] = hit.point\n switch (hit.type) {\n case 'ballistic':\n case 'blunt':\n case 'slash': {\n if (!hit.energy) return null\n const contactRadius = hit.type === 'blunt' ? hit.radius : undefined\n return strike(hit.type, { x, z, energy: hit.energy, ...(hit.direction ? { direction: hit.direction } : {}), ...(contactRadius !== undefined ? { contactRadius } : {}) })\n }\n case 'explosive': {\n if (!hit.energy) return null\n const above = Math.max(0, y - heightAt(x, z))\n const reach = (hit.radius ?? 1) * BLAST_HEIGHT_REACH\n if (above >= reach) return null\n return strike('explosive', { x, z, energy: hit.energy * (1 - above / reach) ** 2 })\n }\n case 'fire':\n case 'heat':\n if (y - heightAt(x, z) > (hit.radius ?? 0.2) + FLAME_REACH) return null\n burn({ x, z, radius: hit.radius ?? 0.2, temperature: hit.temperature ?? 0, seconds: hit.duration ?? 0.016 })\n return null\n default:\n return null\n }\n }\n\n function raycast(origin: readonly number[], direction: readonly number[], maxDistance: number): { point: [number, number, number]; distance: number; normal: [number, number, number] } | null {\n const [ox = 0, oy = 0, oz = 0] = origin\n const [dx = 0, dy = 0, dz = 0] = direction\n const step = cellSize * 0.75\n let previous = 0\n for (let t = 0; t <= maxDistance; t += step) {\n const x = ox + dx * t\n const z = oz + dz * t\n if (contains(x, z) && oy + dy * t <= heightAt(x, z)) {\n let low = previous\n let high = t\n for (let i = 0; i < 6; i++) {\n const middle = (low + high) / 2\n if (oy + dy * middle <= heightAt(ox + dx * middle, oz + dz * middle)) high = middle\n else low = middle\n }\n const px = ox + dx * high\n const pz = oz + dz * high\n const gx = heightAt(px + cellSize, pz) - heightAt(px - cellSize, pz)\n const gz = heightAt(px, pz + cellSize) - heightAt(px, pz - cellSize)\n const length = Math.hypot(gx, 2 * cellSize, gz)\n return { point: [px, oy + dy * high, pz], distance: high, normal: [-gx / length, (2 * cellSize) / length, -gz / length] }\n }\n previous = t\n }\n return null\n }\n\n function chunk(index: number): TerrainChunk {\n const chunkZ = Math.floor(index / chunksX)\n const chunkX = index - chunkZ * chunksX\n return { index, column: chunkX * chunkCells, row: chunkZ * chunkCells, cells: chunkCells, pristine: !touched[index] && !mixed[index] && !options.baseHeight }\n }\n\n function reset(): void {\n dug.fill(0)\n loose.fill(0)\n scorch.fill(0)\n barBend.fill(0)\n touched.fill(0)\n marks.clear()\n debrisStore.clear()\n sliding.clear()\n burning.clear()\n applyLayout(currentLayout)\n }\n\n applyLayout(currentLayout)\n\n return {\n bounds: options.bounds,\n cellSize,\n columns,\n rows,\n stride,\n chunkCells,\n chunksX,\n chunksZ,\n budget,\n dug,\n loose,\n scorch,\n barBend,\n profileIndex,\n profileList,\n material: (id) => {\n const material = materials.get(id)\n if (!material) throw new Error(`game-terrain: unknown material \"${id}\"`)\n return material\n },\n layout: () => currentLayout,\n setLayout(layout) {\n currentLayout = layout\n reset()\n },\n heightAt,\n vertexHeight,\n vertexMaterial,\n columnAt,\n contains,\n raycast,\n strike,\n burn,\n applyHit,\n onDeform(listener) {\n deformListeners.add(listener)\n return () => deformListeners.delete(listener)\n },\n update(dt) {\n debrisStore.update(dt, { heightAt, gravity, settle })\n updateSlides()\n updateFire(dt)\n },\n drainDirtyChunks(max = budget.maxDirtyChunksPerFrame) {\n const taken: TerrainChunk[] = []\n for (const index of dirty) {\n if (taken.length >= max) break\n dirty.delete(index)\n taken.push(chunk(index))\n }\n return taken\n },\n chunk,\n marks: () => marks.list,\n debris: () => debrisStore.list,\n burningPoints: () => [...burning.keys()].map((key) => {\n const cellRow = Math.floor(key / 65536)\n const cellColumn = key - cellRow * 65536\n return [x0 + (cellColumn + 0.5) * FIRE_CELL, z0 + (cellRow + 0.5) * FIRE_CELL] as const\n }),\n get marksVersion() {\n return marks.version\n },\n stats: () => ({ marks: marks.list.length, debris: debrisStore.list.length, debrisMoving: debrisStore.list.filter((piece) => !piece.atRest).length, dirtyChunks: dirty.size, burning: burning.size }),\n reset,\n serialize(): TerrainSnapshot {\n return { layout: currentLayout, dug: sparse(dug), loose: sparse(loose), scorch: sparse(scorch), barBend: sparse(barBend), marks: marks.list.map((mark) => ({ ...mark })) }\n },\n load(snapshot) {\n currentLayout = snapshot.layout\n reset()\n fillSparse(dug, snapshot.dug)\n fillSparse(loose, snapshot.loose)\n fillSparse(scorch, snapshot.scorch)\n fillSparse(barBend, snapshot.barBend ?? [])\n marks.load(snapshot.marks)\n for (let chunkIndex = 0; chunkIndex < touched.length; chunkIndex++) markChunk(chunkIndex)\n },\n }\n}\n","import type { DestructibleImpl, DestructibleSpec, DestructionEvent, Hit, ObjectState } from '@adatechnology/game-contracts'\nimport type { Terrain, TerrainBlowResult } from './types'\n\nconst PART_ID = 'ground'\nconst PART_DEPTH = 3\nconst RAY_BACKOFF = 1\n\nexport interface TerrainImplOptions {\n readonly id: string\n readonly name?: string\n /** Every blow that left its mark, with what it did. */\n readonly onBlow?: (result: TerrainBlowResult, hit: Hit) => void\n}\n\n/**\n * The ground as an object of the destruction world (`DestructionWorld.register`): shots, blows, blasts and flames reach it through the world's own raycast and blast like anything else.\n * The collision part is a flat box; a ray that enters it goes on to the real surface, so a round fired into a crater lands on its floor.\n */\nexport function createTerrainImpl(terrain: Terrain, options: TerrainImplOptions): DestructibleImpl {\n const { id } = options\n const [x0, z0, x1, z1] = terrain.bounds\n const spec = (): DestructibleSpec => ({\n name: options.name ?? 'Ground',\n parts: [\n {\n id: PART_ID,\n material: terrain.columnAt((x0 + x1) / 2, (z0 + z1) / 2).material,\n shape: { kind: 'box', size: [x1 - x0, PART_DEPTH, z1 - z0] },\n position: [(x0 + x1) / 2, -PART_DEPTH / 2, (z0 + z1) / 2],\n hp: 1e12,\n anchored: true,\n tags: ['terrain'],\n },\n ],\n joints: [],\n })\n const resolve = (hit: Hit): Hit => {\n if (hit.type !== 'ballistic' && hit.type !== 'blunt' && hit.type !== 'slash') return hit\n if (!hit.direction) return { ...hit, point: [hit.point[0], terrain.heightAt(hit.point[0], hit.point[2]), hit.point[2]] }\n const [dx, dy, dz] = hit.direction\n const from = [hit.point[0] - dx * RAY_BACKOFF, hit.point[1] - dy * RAY_BACKOFF, hit.point[2] - dz * RAY_BACKOFF]\n const onGround = terrain.raycast(from, hit.direction, RAY_BACKOFF + PART_DEPTH)\n return onGround ? { ...hit, point: onGround.point } : hit\n }\n return {\n id,\n ...(options.name ? { name: options.name } : {}),\n describe: spec,\n hit(hit): DestructionEvent[] {\n const landed = resolve(hit)\n const result = terrain.applyHit(landed)\n if (!result || result.radius <= 0) return []\n options.onBlow?.(result, landed)\n return [{ type: 'damage', object: id, time: 0, part: PART_ID, amount: 0, hp: 1e12, damageType: hit.type, ...(hit.source ? { source: hit.source } : {}) }]\n },\n update(dt) {\n terrain.update(dt)\n return []\n },\n state(): ObjectState {\n return {\n id,\n position: [0, 0, 0],\n rotation: [0, 0, 0, 1],\n parts: [],\n joints: [],\n groups: [[PART_ID]],\n destroyed: false,\n }\n },\n reset: () => terrain.reset(),\n serialize: () => terrain.serialize(),\n load: (data) => terrain.load(data as ReturnType<Terrain['serialize']>),\n }\n}\n","import type { GroundLayout, Hit } from '@adatechnology/game-contracts'\n\nexport type { GroundLayout, GroundZone } from '@adatechnology/game-contracts'\n\n/** The blows that remove ground. Fire and heat char it instead (`TerrainMaterial.fire`). */\nexport type TerrainBlow = 'ballistic' | 'blunt' | 'slash' | 'explosive'\n\n/** What one kind of blow does to a material. */\nexport interface TerrainBlowResponse {\n /** Joules it takes to remove one cubic metre. */\n readonly specificEnergy: number\n /** Depth over radius of the bowl it leaves: a soft ground craters deep, a hard one spalls wide and shallow. */\n readonly aspect: number\n /** A blow under this many joules leaves nothing (a punch on a slab). */\n readonly thresholdJoules?: number\n /** The bowl is never narrower: a tile comes off as a piece, not as the size of the bullet. */\n readonly minRadius?: number\n}\n\nexport type TerrainDebrisKind = 'clods' | 'chips' | 'shards' | 'splinters' | 'dust'\n\n/** How a material of the destruction catalogue behaves as ground. `id` is the catalogue's. */\nexport interface TerrainMaterial {\n readonly id: string\n readonly hits: Partial<Record<TerrainBlow, TerrainBlowResponse>>\n readonly debris: TerrainDebrisKind\n /** Share of what a blow removes that piles up on the rim; the rest is thrown. Rigid materials have none. */\n readonly rimShare?: number\n /** Steepest slope in degrees it holds; loose ground slides until it is back to it. */\n readonly reposeDegrees?: number\n readonly fire?: TerrainFireResponse\n /** Linear rgb, 0..1, of the material where it is exposed. */\n readonly color: readonly [number, number, number]\n /** Surface pattern a renderer draws while the stratum is intact (`TERRAIN_PATTERNS`). */\n readonly pattern?: TerrainPattern\n}\n\nexport interface TerrainFireResponse {\n /** How black it gets, 0..1: concrete only soots, wood and turf char through. */\n readonly maxScorch: number\n /** Scorch gained per second in a flame. */\n readonly scorchPerSecond: number\n /** Metres per second the flame eats away (wood char). */\n readonly burnsAway?: number\n /** Catches and carries the fire to the next cells on its own. */\n readonly flammable?: boolean\n}\n\nexport const TERRAIN_PATTERNS = ['plain', 'grass', 'soil', 'sand', 'asphalt', 'concrete', 'tile', 'planks', 'stone'] as const\nexport type TerrainPattern = (typeof TERRAIN_PATTERNS)[number]\n\n/** One layer of a column, from the top down. */\nexport interface TerrainStratum {\n /** A `TerrainMaterial.id`. */\n readonly material: string\n /** Metres. */\n readonly thickness: number\n /** Bars inside the layer: exposed at `cover`, they make the digging `resistance` times harder. */\n readonly reinforcement?: {\n readonly material: string\n readonly cover: number\n readonly spacing: number\n readonly resistance: number\n /** Joules on one spot of bare bars before they give way (default `DEFAULT_BAR_YIELD_JOULES`). */\n readonly yieldJoules?: number\n }\n}\n\n/** What a column of ground is made of. The strata end at the digging limit: under them nothing is removed. */\nexport interface TerrainProfile {\n readonly id: string\n readonly strata: readonly TerrainStratum[]\n /** A slab with nothing under it: where its bars give way the floor opens, and the ground is this many metres further down (the floor below). Without it the ground under the slab holds it up. */\n readonly drop?: number\n}\n\nexport interface TerrainBudget {\n /** Side in metres of the squares that hold a limited number of marks. */\n readonly markAreaSize: number\n readonly maxMarksPerArea: number\n readonly maxMarks: number\n /** Chunks a renderer redraws per frame; the rest wait. */\n readonly maxDirtyChunksPerFrame: number\n readonly maxDebris: number\n /** Thrown pieces of one blow. */\n readonly maxDebrisPerBlow: number\n /** Cells checked for sliding per frame. */\n readonly maxSlideCellsPerFrame: number\n /** Cells on fire at once. */\n readonly maxBurningCells: number\n}\n\nexport interface TerrainOptions {\n /** World rectangle `[x0, z0, x1, z1]` in metres. */\n readonly bounds: readonly [number, number, number, number]\n /** Metres per cell (default 0.1). */\n readonly cellSize?: number\n /** Cells per side of a chunk (default 32). */\n readonly chunkCells?: number\n /** Which profile is where. */\n readonly layout?: GroundLayout\n /** Extra or overriding profiles, by id. */\n readonly profiles?: readonly TerrainProfile[]\n /** Extra or overriding materials, by id. */\n readonly materials?: readonly TerrainMaterial[]\n readonly budget?: Partial<TerrainBudget>\n /** Height of the untouched ground (default flat at 0). */\n readonly baseHeight?: (x: number, z: number) => number\n /** Metres per second squared. */\n readonly gravity?: number\n readonly seed?: number\n}\n\n/** A fine mark: a bullet pock or a chip, smaller than a cell. It grows with the blows on it until it is dug into the height field. */\nexport interface TerrainMark {\n readonly id: number\n x: number\n z: number\n radius: number\n depth: number\n /** The material the mark shows at the bottom. */\n material: string\n blow: TerrainBlow\n /** Blows and marks merged into this one. */\n count: number\n /** Order of creation: the oldest is the one recycled. */\n born: number\n}\n\nexport interface TerrainDebris {\n readonly id: number\n readonly material: string\n readonly kind: TerrainDebrisKind\n readonly radius: number\n readonly position: [number, number, number]\n readonly velocity: [number, number, number]\n readonly spin: [number, number, number]\n atRest: boolean\n bounces: number\n /** Order of creation: the oldest is the one recycled. */\n readonly born: number\n}\n\n/** What a blow did. */\nexport interface TerrainBlowResult {\n readonly point: readonly [number, number, number]\n /** Bowl radius in metres; 0 when the blow left nothing. */\n readonly radius: number\n /** Metres removed at the point. */\n readonly depth: number\n /** Metres under the untouched surface the point is now. */\n readonly totalDepth: number\n /** Materials removed, top first, with the cubic metres of each. */\n readonly removed: readonly { readonly material: string; readonly volume: number }[]\n /** Material now exposed at the point. */\n readonly exposed: string\n /** The column has no more to give here: the slab's bottom, the end of the rock. */\n readonly atLimit: boolean\n /** Reinforcement is showing at the point. */\n readonly rebarExposed: boolean\n /** The bars gave way here and the slab opened (`TerrainProfile.drop`). */\n readonly collapsed: boolean\n /** Left a fine mark (`true`) or dug the height field (`false`). */\n readonly fine: boolean\n}\n\nexport interface TerrainRegion {\n readonly x: number\n readonly z: number\n readonly radius: number\n}\n\nexport interface TerrainColumn {\n readonly profile: string\n readonly material: string\n readonly stratum: number\n /** Metres under the untouched surface. */\n readonly depth: number\n /** Metres that can still be dug here. */\n readonly remaining: number\n readonly scorch: number\n readonly loose: number\n}\n\nexport interface TerrainStats {\n readonly marks: number\n readonly debris: number\n readonly debrisMoving: number\n readonly dirtyChunks: number\n readonly burning: number\n}\n\nexport interface TerrainChunk {\n readonly index: number\n /** First column and row of the chunk's vertices. */\n readonly column: number\n readonly row: number\n readonly cells: number\n /** True while nothing touched it and it has one profile: one flat quad draws it. */\n readonly pristine: boolean\n}\n\nexport interface Terrain {\n readonly bounds: readonly [number, number, number, number]\n readonly cellSize: number\n readonly columns: number\n readonly rows: number\n /** Vertices per row. */\n readonly stride: number\n readonly chunkCells: number\n readonly chunksX: number\n readonly chunksZ: number\n readonly budget: TerrainBudget\n /** Per vertex: metres removed under the untouched surface. */\n readonly dug: Float32Array\n /** Per vertex: metres of loose ground heaped on top. */\n readonly loose: Float32Array\n /** Per vertex: 0..1, how burnt. */\n readonly scorch: Float32Array\n /** Per vertex: how far the bare bars have given, 0 straight, 1 or more gone. */\n readonly barBend: Float32Array\n /** Per vertex: index in `profileList`. */\n readonly profileIndex: Uint8Array\n readonly profileList: readonly TerrainProfile[]\n material(id: string): TerrainMaterial\n layout(): GroundLayout\n setLayout(layout: GroundLayout): void\n /** Height at (x, z) as things stand on it. */\n heightAt(x: number, z: number): number\n /** Height of vertex (column, row). */\n vertexHeight(column: number, row: number): number\n /** Material exposed at a vertex. */\n vertexMaterial(column: number, row: number): TerrainMaterial\n columnAt(x: number, z: number): TerrainColumn\n contains(x: number, z: number): boolean\n /** First point of the ground along a ray, or null. */\n raycast(origin: readonly number[], direction: readonly number[], maxDistance: number): { point: [number, number, number]; distance: number; normal: [number, number, number] } | null\n /** A blow at (x, z). `energy` in joules, `direction` where it travels (it throws the pieces back along it). */\n strike(blow: TerrainBlow, spec: { x: number; z: number; energy: number; direction?: readonly number[]; contactRadius?: number }): TerrainBlowResult\n /** A flame over a disc for `seconds`. */\n burn(spec: { x: number; z: number; radius: number; temperature: number; seconds: number }): void\n /** Applies a world hit (`Hit` of the destruction contract); null when it does nothing to the ground. */\n applyHit(hit: Hit): TerrainBlowResult | null\n /** Follow the ground changing: `region` is the disc it changed in. What rests there has lost its support and must look again. Returns the unsubscribe function. */\n onDeform(listener: (region: TerrainRegion) => void): () => void\n /** Moves the pieces, the sliding ground and the fire. */\n update(dt: number): void\n /** Takes up to the budget's chunks that changed since the last call. */\n drainDirtyChunks(max?: number): TerrainChunk[]\n chunk(index: number): TerrainChunk\n marks(): readonly TerrainMark[]\n debris(): readonly TerrainDebris[]\n /** Cells on fire now, as world points. */\n burningPoints(): readonly (readonly [number, number])[]\n /** Goes up when the marks change. */\n readonly marksVersion: number\n stats(): TerrainStats\n /** Back to the untouched ground. */\n reset(): void\n serialize(): TerrainSnapshot\n load(snapshot: TerrainSnapshot): void\n}\n\nexport interface TerrainSnapshot {\n readonly layout: GroundLayout\n readonly dug: readonly (readonly [number, number])[]\n readonly loose: readonly (readonly [number, number])[]\n readonly scorch: readonly (readonly [number, number])[]\n readonly barBend?: readonly (readonly [number, number])[]\n readonly marks: readonly TerrainMark[]\n}\n","import type * as THREE_NS from 'three'\nimport { TERRAIN_PATTERNS } from '@adatechnology/game-terrain'\n\ntype ThreeLib = typeof THREE_NS\n\n/** Index of a pattern in the shader's branches. */\nexport const patternIndex = (pattern: string | undefined): number => Math.max(0, TERRAIN_PATTERNS.indexOf((pattern ?? 'plain') as (typeof TERRAIN_PATTERNS)[number]))\n\nconst SURFACE_GLSL = /* glsl */ `\nfloat tsHash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }\nfloat tsNoise(vec2 p) {\n vec2 i = floor(p); vec2 f = fract(p); f = f * f * (3.0 - 2.0 * f);\n return mix(mix(tsHash(i), tsHash(i + vec2(1.0, 0.0)), f.x), mix(tsHash(i + vec2(0.0, 1.0)), tsHash(i + vec2(1.0, 1.0)), f.x), f.y);\n}\nfloat tsFbm(vec2 p) { return tsNoise(p) * 0.55 + tsNoise(p * 2.3 + 7.1) * 0.3 + tsNoise(p * 5.1 + 3.3) * 0.15; }\nfloat tsLine(float value, float period, float width) { float d = abs(fract(value / period + 0.5) - 0.5) * period; return 1.0 - smoothstep(width * 0.6, width, d); }\nvec3 terrainSurface(vec3 base, vec2 xz, float kind) {\n float k = floor(kind + 0.5);\n if (k < 0.5) return base * (0.86 + 0.28 * tsFbm(xz * 9.0));\n if (k < 1.5) {\n float blades = tsNoise(xz * vec2(70.0, 55.0)) * 0.6 + tsNoise(xz * 22.0) * 0.4;\n float patches = tsFbm(xz * 1.3);\n return base * (0.55 + 0.8 * blades) * mix(vec3(1.25, 1.05, 0.7), vec3(0.8, 1.1, 0.85), patches);\n }\n if (k < 2.5) return base * (0.6 + 0.75 * tsFbm(xz * 6.0)) * (0.82 + 0.36 * tsNoise(xz * 48.0));\n if (k < 3.5) return base * (0.88 + 0.12 * sin(xz.x * 22.0 + tsFbm(xz * 2.0) * 9.0)) * (0.92 + 0.16 * tsNoise(xz * 90.0));\n if (k < 4.5) { float grain = tsNoise(xz * 120.0); return base * (0.55 + 1.1 * grain * grain) * (0.85 + 0.3 * tsFbm(xz * 0.8)); }\n if (k < 5.5) {\n float joint = max(tsLine(xz.x, 3.0, 0.012), tsLine(xz.y, 3.0, 0.012));\n return base * (0.82 + 0.3 * tsFbm(xz * 3.5)) * (0.94 + 0.12 * tsNoise(xz * 130.0)) * (1.0 - 0.6 * joint);\n }\n if (k < 6.5) {\n vec2 cell = floor(xz / 0.6);\n float grout = max(tsLine(xz.x, 0.6, 0.006), tsLine(xz.y, 0.6, 0.006));\n return mix(base * (0.93 + 0.14 * tsHash(cell)) * (0.97 + 0.06 * tsFbm(xz * 7.0)), vec3(0.14, 0.13, 0.12), grout);\n }\n if (k < 7.5) {\n float row = floor(xz.y / 0.14);\n float along = xz.x + tsHash(vec2(row, 3.0)) * 2.4;\n float seam = max(tsLine(xz.y, 0.14, 0.004), tsLine(along, 2.4, 0.004));\n float grain = tsNoise(vec2(along * 3.0, xz.y * 70.0)) * 0.6 + tsNoise(vec2(along * 11.0, xz.y * 180.0)) * 0.4;\n return base * (0.72 + 0.56 * tsHash(vec2(row, floor(along / 2.4)))) * (0.75 + 0.5 * grain) * (1.0 - 0.75 * seam);\n }\n vec2 cell = floor(xz / 0.22);\n float seams = max(tsLine(xz.x, 0.22, 0.012), tsLine(xz.y, 0.22, 0.012));\n return base * (0.7 + 0.6 * tsHash(cell)) * (0.85 + 0.3 * tsFbm(xz * 16.0)) * (1.0 - 0.55 * seams);\n}\n`\n\n/** The ground's material: the colour of what is exposed comes per vertex, flat so that the edge between two materials stays sharp, its pattern (blades of grass, grout, planks) is drawn per pixel from the world position so that it never tiles or stretches. */\nexport function createSurfaceMaterial(THREE: ThreeLib): THREE_NS.MeshStandardMaterial {\n const material = new THREE.MeshStandardMaterial({ roughness: 0.95, metalness: 0 })\n material.onBeforeCompile = (shader) => {\n shader.vertexShader = shader.vertexShader\n .replace('#include <common>', '#include <common>\\nattribute float aPattern;\\nattribute vec3 aBase;\\nattribute vec2 aShade;\\nflat varying float vPattern;\\nflat varying vec3 vBase;\\nvarying vec2 vShade;\\nvarying vec2 vGroundXZ;')\n .replace('#include <begin_vertex>', '#include <begin_vertex>\\nvPattern = aPattern;\\nvBase = aBase;\\nvShade = aShade;\\nvGroundXZ = position.xz;')\n shader.fragmentShader = shader.fragmentShader\n .replace('#include <common>', `#include <common>\\nflat varying float vPattern;\\nflat varying vec3 vBase;\\nvarying vec2 vShade;\\nvarying vec2 vGroundXZ;\\n${SURFACE_GLSL}`)\n .replace('#include <color_fragment>', '#include <color_fragment>\\ndiffuseColor.rgb = mix(terrainSurface(vBase, vGroundXZ, vPattern), vec3(0.012, 0.011, 0.01), vShade.y) * vShade.x;')\n }\n material.customProgramCacheKey = () => 'game-terrain-surface'\n return material\n}\n","import type * as THREE_NS from 'three'\nimport type { Terrain, TerrainChunk } from '@adatechnology/game-terrain'\nimport { patternIndex } from './surface-material'\n\ntype ThreeLib = typeof THREE_NS\n\n/** Metres over which the raw colour of a dug layer darkens: the bottom of a hole gets little light. */\nconst DEPTH_SHADE = 1.6\nconst MIN_DEPTH_LIGHT = 0.55\nconst LOOSE_VISIBLE = 0.004\n/** A skin thinner than this (turf, asphalt) is not what comes out of a hole: what is under it is. */\nconst THIN_SKIN = 0.1\nconst SKIRT_QUADS = 4\nconst BROKEN_DEPTH = 0.004\nconst BROKEN_TONE = 0.62\n\n/** What a vertex shows: the colour and pattern of what is exposed or heaped on it, sharp at the edge between materials, and its light and burn, which blend. */\nfunction vertexLook(terrain: Terrain, column: number, row: number, base: [number, number, number], shade: [number, number]): number {\n const index = row * terrain.stride + column\n let material = terrain.vertexMaterial(column, row)\n if (terrain.loose[index]! > LOOSE_VISIBLE) {\n const profile = terrain.profileList[terrain.profileIndex[index]!]!\n const first = profile.strata[0]!\n material = terrain.material(first.thickness < THIN_SKIN && profile.strata[1] ? profile.strata[1].material : first.material)\n }\n // dug concrete shows its broken aggregate, not the troweled surface with its joints\n const broken = material.pattern === 'concrete' && terrain.dug[index]! > BROKEN_DEPTH\n const tone = broken ? BROKEN_TONE : 1\n base[0] = material.color[0] * tone\n base[1] = material.color[1] * tone\n base[2] = material.color[2] * tone\n shade[0] = Math.max(MIN_DEPTH_LIGHT, 1 - terrain.dug[index]! / DEPTH_SHADE)\n shade[1] = terrain.scorch[index]!\n return patternIndex(broken ? 'soil' : material.pattern)\n}\n\n/** The chunk as a grid with one vertex every `step` cells; the normals come from the height field, so that neighbouring chunks meet without a seam. */\nexport function buildChunkGeometry(THREE: ThreeLib, terrain: Terrain, chunk: TerrainChunk, step: number): THREE_NS.BufferGeometry {\n const [x0, z0] = terrain.bounds\n const { cellSize, columns, rows } = terrain\n const lastColumn = Math.min(columns, chunk.column + chunk.cells)\n const lastRow = Math.min(rows, chunk.row + chunk.cells)\n const across = Math.ceil((lastColumn - chunk.column) / step) + 1\n const down = Math.ceil((lastRow - chunk.row) / step) + 1\n const positions = new Float32Array(across * down * 3)\n const normals = new Float32Array(across * down * 3)\n const bases = new Float32Array(across * down * 3)\n const shades = new Float32Array(across * down * 2)\n const patterns = new Float32Array(across * down)\n const base: [number, number, number] = [0, 0, 0]\n const shade: [number, number] = [1, 0]\n let at = 0\n for (let j = 0; j < down; j++) {\n const row = Math.min(lastRow, chunk.row + j * step)\n for (let i = 0; i < across; i++) {\n const column = Math.min(lastColumn, chunk.column + i * step)\n const height = terrain.vertexHeight(column, row)\n positions[at * 3] = x0 + column * cellSize\n positions[at * 3 + 1] = height\n positions[at * 3 + 2] = z0 + row * cellSize\n const left = terrain.vertexHeight(Math.max(0, column - 1), row)\n const right = terrain.vertexHeight(Math.min(columns, column + 1), row)\n const back = terrain.vertexHeight(column, Math.max(0, row - 1))\n const front = terrain.vertexHeight(column, Math.min(rows, row + 1))\n const nx = left - right\n const ny = 2 * cellSize\n const nz = back - front\n const length = Math.hypot(nx, ny, nz)\n normals[at * 3] = nx / length\n normals[at * 3 + 1] = ny / length\n normals[at * 3 + 2] = nz / length\n patterns[at] = vertexLook(terrain, column, row, base, shade)\n bases.set(base, at * 3)\n shades.set(shade, at * 2)\n at++\n }\n }\n const indices = new Uint32Array((across - 1) * (down - 1) * 6)\n let cursor = 0\n for (let j = 0; j < down - 1; j++) {\n for (let i = 0; i < across - 1; i++) {\n const a = j * across + i\n const b = a + 1\n const c = a + across\n const d = c + 1\n indices[cursor++] = a\n indices[cursor++] = c\n indices[cursor++] = b\n // both triangles end on `b`: a flat attribute takes the last vertex, so the whole quad has one material and the edge between two is a straight line\n indices[cursor++] = c\n indices[cursor++] = d\n indices[cursor++] = b\n }\n }\n const geometry = new THREE.BufferGeometry()\n geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))\n geometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3))\n geometry.setAttribute('aBase', new THREE.BufferAttribute(bases, 3))\n geometry.setAttribute('aShade', new THREE.BufferAttribute(shades, 2))\n geometry.setAttribute('aPattern', new THREE.BufferAttribute(patterns, 1))\n geometry.setIndex(new THREE.BufferAttribute(indices, 1))\n return geometry\n}\n\n/**\n * Every chunk nothing touched, as one flat quad each in one geometry: the whole floor of an arena is a single draw call until someone digs it. Around them, out to `skirt` metres, the\n * ground goes on as the same surface: what is thrown past the edge lands on something, not on the sky.\n */\nexport function buildPristineGeometry(THREE: ThreeLib, terrain: Terrain, chunks: readonly TerrainChunk[], skirt: number): THREE_NS.BufferGeometry {\n const [x0, z0] = terrain.bounds\n const { cellSize, columns, rows } = terrain\n const [, , x1, z1] = terrain.bounds\n const quads = chunks.length + SKIRT_QUADS\n const positions = new Float32Array(quads * 12)\n const normals = new Float32Array(quads * 12)\n const bases = new Float32Array(quads * 12)\n const shades = new Float32Array(quads * 8)\n const patterns = new Float32Array(quads * 4)\n const indices = new Uint32Array(quads * 6)\n const base: [number, number, number] = [0, 0, 0]\n const shade: [number, number] = [1, 0]\n chunks.forEach((chunk, n) => {\n const lastColumn = Math.min(columns, chunk.column + chunk.cells)\n const lastRow = Math.min(rows, chunk.row + chunk.cells)\n const corners = [[chunk.column, chunk.row], [lastColumn, chunk.row], [chunk.column, lastRow], [lastColumn, lastRow]] as const\n const pattern = vertexLook(terrain, chunk.column, chunk.row, base, shade)\n corners.forEach(([column, row], corner) => {\n const at = n * 4 + corner\n positions[at * 3] = x0 + column * cellSize\n positions[at * 3 + 1] = terrain.vertexHeight(column, row)\n positions[at * 3 + 2] = z0 + row * cellSize\n normals[at * 3 + 1] = 1\n bases.set(base, at * 3)\n shades.set(shade, at * 2)\n patterns[at] = pattern\n })\n const first = n * 4\n indices.set([first, first + 2, first + 1, first + 2, first + 3, first + 1], n * 6)\n })\n const surface = terrain.material(terrain.profileList.find((profile) => profile.id === terrain.layout().surface)?.strata[0]?.material ?? terrain.profileList[terrain.profileIndex[0]!]!.strata[0]!.material)\n const rects = [\n [x0 - skirt, z0 - skirt, x1 + skirt, z0],\n [x0 - skirt, z1, x1 + skirt, z1 + skirt],\n [x0 - skirt, z0, x0, z1],\n [x1, z0, x1 + skirt, z1],\n ] as const\n rects.forEach(([ax, az, bx, bz], r) => {\n const n = chunks.length + r\n const corners = [[ax, az], [bx, az], [ax, bz], [bx, bz]] as const\n corners.forEach(([x, z], corner) => {\n const at = n * 4 + corner\n positions[at * 3] = x\n positions[at * 3 + 2] = z\n normals[at * 3 + 1] = 1\n bases.set(surface.color, at * 3)\n shades[at * 2] = 1\n patterns[at] = patternIndex(surface.pattern)\n })\n const first = n * 4\n indices.set([first, first + 2, first + 1, first + 2, first + 3, first + 1], n * 6)\n })\n const geometry = new THREE.BufferGeometry()\n geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))\n geometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3))\n geometry.setAttribute('aBase', new THREE.BufferAttribute(bases, 3))\n geometry.setAttribute('aShade', new THREE.BufferAttribute(shades, 2))\n geometry.setAttribute('aPattern', new THREE.BufferAttribute(patterns, 1))\n geometry.setIndex(new THREE.BufferAttribute(indices, 1))\n return geometry\n}\n","import type * as THREE_NS from 'three'\nimport type { Terrain, TerrainDebrisKind } from '@adatechnology/game-terrain'\n\ntype ThreeLib = typeof THREE_NS\n\nconst MARK_LIFT = 0.006\nconst MARK_REACH = 1.5\nconst MARK_TEXTURE_SIZE = 64\nconst MARK_DARKEN = 0.85\n/** How a piece of each kind is stretched: a shard is a flake, a splinter a long sliver. */\nconst PIECE_SHAPE: Record<TerrainDebrisKind, readonly [number, number, number]> = { clods: [1, 0.85, 1], chips: [1, 0.6, 0.8], shards: [1.3, 0.2, 1], splinters: [0.3, 0.25, 2.4], dust: [1, 0.8, 1] }\n\n/** A pock as an alpha disc: dark in the middle, a broken lighter ring where the surface chipped. */\nfunction createMarkTexture(THREE: ThreeLib): THREE_NS.DataTexture {\n const size = MARK_TEXTURE_SIZE\n const data = new Uint8Array(size * size * 4)\n for (let y = 0; y < size; y++) {\n for (let x = 0; x < size; x++) {\n const u = (x + 0.5) / size - 0.5\n const v = (y + 0.5) / size - 0.5\n const angle = Math.atan2(v, u)\n const lobes = 0.86 + 0.09 * Math.sin(angle * 5) + 0.05 * Math.sin(angle * 13 + 1)\n const q = Math.hypot(u, v) / (0.5 * lobes)\n const shade = q < 0.55 ? 0.42 + q * 0.5 : 0.7 + (q - 0.55) * 0.8\n const at = (y * size + x) * 4\n data[at] = data[at + 1] = data[at + 2] = Math.round(Math.min(1, shade) * 255)\n data[at + 3] = q < 0.8 ? 255 : q < 1 ? Math.round((1 - (q - 0.8) / 0.2) * 255) : 0\n }\n }\n const texture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat)\n texture.magFilter = THREE.LinearFilter\n texture.minFilter = THREE.LinearFilter\n texture.needsUpdate = true\n return texture\n}\n\nexport function createMarkLayer(THREE: ThreeLib, terrain: Terrain, drawDistance: number): { mesh: THREE_NS.InstancedMesh; sync(camera: { x: number; z: number }): void; dispose(): void } {\n const geometry = new THREE.PlaneGeometry(1, 1)\n geometry.rotateX(-Math.PI / 2)\n const texture = createMarkTexture(THREE)\n const material = new THREE.MeshStandardMaterial({ map: texture, transparent: true, depthWrite: false, roughness: 1, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2 })\n const mesh = new THREE.InstancedMesh(geometry, material, terrain.budget.maxMarks)\n mesh.name = 'terrain-marks'\n mesh.count = 0\n mesh.frustumCulled = false\n mesh.receiveShadow = true\n mesh.renderOrder = 1\n const matrix = new THREE.Matrix4()\n const rotation = new THREE.Matrix4()\n const scale = new THREE.Matrix4()\n const colour = new THREE.Color()\n let seen = ''\n let cameraX = Infinity\n let cameraZ = Infinity\n return {\n mesh,\n sync(camera) {\n const marks = terrain.marks()\n let blows = 0\n for (const mark of marks) blows += mark.count\n const stamp = `${terrain.marksVersion}:${blows}`\n if (stamp === seen && Math.hypot(camera.x - cameraX, camera.z - cameraZ) < 5) return\n seen = stamp\n cameraX = camera.x\n cameraZ = camera.z\n let count = 0\n for (const mark of marks) {\n if (Math.hypot(mark.x - camera.x, mark.z - camera.z) > drawDistance) continue\n const size = mark.radius * 2 * MARK_REACH\n rotation.makeRotationY(mark.id * 2.399)\n scale.makeScale(size, 1, size)\n matrix.multiplyMatrices(rotation, scale)\n matrix.setPosition(mark.x, terrain.heightAt(mark.x, mark.z) + MARK_LIFT, mark.z)\n mesh.setMatrixAt(count, matrix)\n const [r, g, b] = terrain.material(mark.material).color\n colour.setRGB(r * MARK_DARKEN + 0.04, g * MARK_DARKEN + 0.04, b * MARK_DARKEN + 0.04)\n mesh.setColorAt(count, colour)\n count++\n }\n mesh.count = count\n mesh.instanceMatrix.needsUpdate = true\n if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true\n },\n dispose() {\n geometry.dispose()\n material.dispose()\n texture.dispose()\n mesh.removeFromParent()\n },\n }\n}\n\nexport function createDebrisLayer(THREE: ThreeLib, terrain: Terrain): { mesh: THREE_NS.InstancedMesh; sync(): void; dispose(): void } {\n const geometry = new THREE.IcosahedronGeometry(1, 0)\n const material = new THREE.MeshStandardMaterial({ roughness: 0.95, flatShading: true })\n const mesh = new THREE.InstancedMesh(geometry, material, terrain.budget.maxDebris)\n mesh.name = 'terrain-debris'\n mesh.count = 0\n mesh.castShadow = true\n mesh.frustumCulled = false\n const matrix = new THREE.Matrix4()\n const position = new THREE.Vector3()\n const scale = new THREE.Vector3()\n const quaternion = new THREE.Quaternion()\n const euler = new THREE.Euler()\n const colour = new THREE.Color()\n return {\n mesh,\n sync() {\n const pieces = terrain.debris()\n let count = 0\n for (const piece of pieces) {\n const [x, y, z] = piece.position\n // the turn follows the distance travelled, so that a piece at rest stays as it landed\n const travelled = x * 1.7 + z * 1.3 + y\n euler.set(piece.spin[0] * travelled * 0.2 + piece.id, piece.spin[1] * travelled * 0.2, piece.spin[2] * travelled * 0.2)\n const shape = PIECE_SHAPE[piece.kind]\n scale.set(shape[0] * piece.radius, shape[1] * piece.radius, shape[2] * piece.radius)\n matrix.compose(position.set(x, y, z), quaternion.setFromEuler(euler), scale)\n mesh.setMatrixAt(count, matrix)\n const [r, g, b] = terrain.material(piece.material).color\n const tone = 0.85 + ((piece.id * 37) % 30) / 100\n mesh.setColorAt(count, colour.setRGB(r * tone, g * tone, b * tone))\n count++\n }\n mesh.count = count\n mesh.instanceMatrix.needsUpdate = true\n if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true\n },\n dispose() {\n geometry.dispose()\n material.dispose()\n mesh.removeFromParent()\n },\n }\n}\n","import type * as THREE_NS from 'three'\nimport type { Terrain, TerrainChunk } from '@adatechnology/game-terrain'\n\ntype ThreeLib = typeof THREE_NS\n\nconst BAR_RADIUS = 0.006\nconst EXPOSED_MARGIN = 0.002\n/** Metres a bar hangs just before it gives way. */\nconst MAX_SAG = 0.07\n\n/** The bars of a reinforced layer where the digging passed their cover: a mesh of the chunk, or null when none is showing. */\nexport function buildRebarGeometry(THREE: ThreeLib, terrain: Terrain, chunk: TerrainChunk): THREE_NS.BufferGeometry | null {\n const [x0, z0] = terrain.bounds\n const { cellSize, columns, rows, stride } = terrain\n const lastColumn = Math.min(columns, chunk.column + chunk.cells)\n const lastRow = Math.min(rows, chunk.row + chunk.cells)\n const boxes: number[] = []\n const coverOf = (index: number): { depth: number; spacing: number } | null => {\n let top = 0\n for (const stratum of terrain.profileList[terrain.profileIndex[index]!]!.strata) {\n if (stratum.reinforcement) return { depth: top + stratum.reinforcement.cover, spacing: stratum.reinforcement.spacing }\n top += stratum.thickness\n }\n return null\n }\n // past the bottom of its strata the slab has opened: the bars are gone there and stay bent around the hole\n const showing = (index: number, depth: number): boolean => {\n const profile = terrain.profileList[terrain.profileIndex[index]!]!\n const limit = profile.strata.reduce((sum, stratum) => sum + stratum.thickness, 0)\n return terrain.dug[index]! > depth + EXPOSED_MARGIN && terrain.dug[index]! <= limit + EXPOSED_MARGIN\n }\n const onBar = (coordinate: number, spacing: number): boolean => Math.floor(coordinate / spacing) !== Math.floor((coordinate + cellSize) / spacing)\n const addBox = (ax: number, az: number, bx: number, bz: number, y: number): void => {\n boxes.push(Math.min(ax, bx) - BAR_RADIUS, y - BAR_RADIUS, Math.min(az, bz) - BAR_RADIUS, Math.max(ax, bx) + BAR_RADIUS, y + BAR_RADIUS, Math.max(az, bz) + BAR_RADIUS)\n }\n for (let row = chunk.row; row <= lastRow; row++) {\n for (let column = chunk.column; column <= lastColumn; column++) {\n const index = row * stride + column\n const cover = coverOf(index)\n if (!cover || !showing(index, cover.depth)) continue\n const x = x0 + column * cellSize\n const z = z0 + row * cellSize\n const y = -cover.depth - BAR_RADIUS - Math.min(1, terrain.barBend[index]!) * MAX_SAG\n if (onBar(z, cover.spacing) && column < lastColumn && showing(index + 1, cover.depth)) addBox(x, z, x + cellSize, z, y)\n if (onBar(x, cover.spacing) && row < lastRow && showing(index + stride, cover.depth)) addBox(x, z, x, z + cellSize, y)\n }\n }\n if (boxes.length === 0) return null\n const count = boxes.length / 6\n const positions = new Float32Array(count * 24 * 3)\n const normals = new Float32Array(count * 24 * 3)\n const indices = new Uint32Array(count * 36)\n const FACES: readonly (readonly [readonly number[], readonly [number, number, number]])[] = [\n [[0, 4, 2, 0, 4, 5, 3, 4, 5, 3, 4, 2], [0, 1, 0]],\n [[0, 1, 5, 0, 1, 2, 3, 1, 2, 3, 1, 5], [0, -1, 0]],\n [[0, 1, 5, 3, 1, 5, 3, 4, 5, 0, 4, 5], [0, 0, 1]],\n [[3, 1, 2, 0, 1, 2, 0, 4, 2, 3, 4, 2], [0, 0, -1]],\n [[3, 1, 5, 3, 1, 2, 3, 4, 2, 3, 4, 5], [1, 0, 0]],\n [[0, 1, 2, 0, 1, 5, 0, 4, 5, 0, 4, 2], [-1, 0, 0]],\n ]\n let vertex = 0\n let cursor = 0\n for (let n = 0; n < count; n++) {\n const box = boxes.slice(n * 6, n * 6 + 6)\n for (const [corners, normal] of FACES) {\n const first = vertex\n for (let c = 0; c < 4; c++) {\n positions[vertex * 3] = box[corners[c * 3]!]!\n positions[vertex * 3 + 1] = box[corners[c * 3 + 1]!]!\n positions[vertex * 3 + 2] = box[corners[c * 3 + 2]!]!\n normals.set(normal, vertex * 3)\n vertex++\n }\n indices.set([first, first + 1, first + 2, first, first + 2, first + 3], cursor)\n cursor += 6\n }\n }\n const geometry = new THREE.BufferGeometry()\n geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))\n geometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3))\n geometry.setIndex(new THREE.BufferAttribute(indices, 1))\n return geometry\n}\n","import type * as THREE_NS from 'three'\nimport { markKitOrigin } from '@adatechnology/game-contracts'\nimport type { Terrain, TerrainChunk } from '@adatechnology/game-terrain'\nimport { buildChunkGeometry, buildPristineGeometry } from './chunk-geometry'\nimport { createDebrisLayer, createMarkLayer } from './layers'\nimport { buildRebarGeometry } from './rebar'\nimport { createSurfaceMaterial } from './surface-material'\n\ntype ThreeLib = typeof THREE_NS\n\nexport interface TerrainRendererOptions {\n readonly terrain: Terrain\n /** Beyond this distance in metres a dug chunk draws half its vertices (default 30), twice this a quarter. */\n readonly lodDistance?: number\n /** Marks farther than this in metres are not drawn (default 45). */\n readonly markDistance?: number\n /** Metres the untouched ground goes on beyond the terrain, so that it has no edge in sight (default 300). */\n readonly skirt?: number\n}\n\nexport interface TerrainRenderer {\n readonly group: THREE_NS.Group\n /** Call every frame with the camera's position. Redraws at most the terrain's budget of chunks. */\n sync(camera: { readonly x: number; readonly y: number; readonly z: number }): void\n /** What is on the screen, for a bench. */\n stats(): { readonly chunkMeshes: number; readonly pristineChunks: number; readonly marksDrawn: number; readonly debrisDrawn: number }\n dispose(): void\n}\n\nconst DEFAULT_LOD_DISTANCE = 30\nconst DEFAULT_MARK_DISTANCE = 45\nconst DEFAULT_SKIRT = 300\nconst LOD_CHECK_METRES = 4\nconst REBAR_COLOUR = 0x4a3528\n\ninterface DrawnChunk {\n mesh: THREE_NS.Mesh\n rebar: THREE_NS.Mesh | null\n step: number\n}\n\n/** The ground of a `Terrain`: chunks nobody touched share one flat geometry, a dug one gets a mesh of its own that gets simpler far away. */\nexport function createTerrainRenderer(THREE: ThreeLib, options: TerrainRendererOptions): TerrainRenderer {\n const { terrain } = options\n const lodDistance = options.lodDistance ?? DEFAULT_LOD_DISTANCE\n const skirt = options.skirt ?? DEFAULT_SKIRT\n const group = markKitOrigin(new THREE.Group(), { owner: 'game-terrain-three', kind: 'ground' })\n group.name = 'floor'\n const surface = createSurfaceMaterial(THREE)\n const rebarMaterial = new THREE.MeshStandardMaterial({ color: REBAR_COLOUR, roughness: 0.75, metalness: 0.35 })\n const pristineMesh = new THREE.Mesh(new THREE.BufferGeometry(), surface)\n pristineMesh.receiveShadow = true\n pristineMesh.name = 'terrain-pristine'\n pristineMesh.frustumCulled = false\n group.add(pristineMesh)\n const drawn = new Map<number, DrawnChunk>()\n const marks = createMarkLayer(THREE, terrain, options.markDistance ?? DEFAULT_MARK_DISTANCE)\n const debris = createDebrisLayer(THREE, terrain)\n group.add(marks.mesh, debris.mesh)\n let pristineStale = true\n let pristineCount = 0\n let lodX = Infinity\n let lodZ = Infinity\n let firstSync = true\n\n const distanceTo = (chunk: TerrainChunk, camera: { x: number; z: number }): number => {\n const half = (chunk.cells * terrain.cellSize) / 2\n const centreX = terrain.bounds[0] + chunk.column * terrain.cellSize + half\n const centreZ = terrain.bounds[1] + chunk.row * terrain.cellSize + half\n return Math.hypot(centreX - camera.x, centreZ - camera.z)\n }\n const stepFor = (distance: number): number => (distance > lodDistance * 2 ? 4 : distance > lodDistance ? 2 : 1)\n\n function drop(index: number): void {\n const entry = drawn.get(index)\n if (!entry) return\n group.remove(entry.mesh)\n entry.mesh.geometry.dispose()\n if (entry.rebar) {\n group.remove(entry.rebar)\n entry.rebar.geometry.dispose()\n }\n drawn.delete(index)\n }\n\n function draw(chunk: TerrainChunk, camera: { x: number; z: number }): void {\n drop(chunk.index)\n const step = stepFor(distanceTo(chunk, camera))\n const mesh = new THREE.Mesh(buildChunkGeometry(THREE, terrain, chunk, step), surface)\n mesh.receiveShadow = true\n group.add(mesh)\n const bars = buildRebarGeometry(THREE, terrain, chunk)\n let rebar: THREE_NS.Mesh | null = null\n if (bars) {\n rebar = new THREE.Mesh(bars, rebarMaterial)\n rebar.castShadow = true\n group.add(rebar)\n }\n drawn.set(chunk.index, { mesh, rebar, step })\n }\n\n function rebuildPristine(): void {\n const chunks: TerrainChunk[] = []\n for (let index = 0; index < terrain.chunksX * terrain.chunksZ; index++) {\n const chunk = terrain.chunk(index)\n if (chunk.pristine) chunks.push(chunk)\n }\n pristineMesh.geometry.dispose()\n pristineMesh.geometry = buildPristineGeometry(THREE, terrain, chunks, skirt)\n pristineCount = chunks.length\n pristineStale = false\n }\n\n return {\n group,\n sync(camera) {\n // the first picture and a floor just laid have every chunk dirty: pristine ones need no work of their own, the rest are drawn at once so that the floor never has holes\n const limit = firstSync ? Infinity : undefined\n firstSync = false\n for (const chunk of terrain.drainDirtyChunks(limit)) {\n if (chunk.pristine) {\n if (drawn.has(chunk.index)) drop(chunk.index)\n pristineStale = true\n } else {\n if (!drawn.has(chunk.index)) pristineStale = true\n draw(chunk, camera)\n }\n }\n if (pristineStale) rebuildPristine()\n if (Math.hypot(camera.x - lodX, camera.z - lodZ) > LOD_CHECK_METRES) {\n lodX = camera.x\n lodZ = camera.z\n for (const [index, entry] of drawn) {\n const chunk = terrain.chunk(index)\n if (stepFor(distanceTo(chunk, camera)) !== entry.step) draw(chunk, camera)\n }\n }\n marks.sync(camera)\n debris.sync()\n },\n stats: () => ({ chunkMeshes: drawn.size, pristineChunks: pristineCount, marksDrawn: marks.mesh.count, debrisDrawn: debris.mesh.count }),\n dispose() {\n for (const index of [...drawn.keys()]) drop(index)\n pristineMesh.geometry.dispose()\n surface.dispose()\n rebarMaterial.dispose()\n marks.dispose()\n debris.dispose()\n group.removeFromParent()\n },\n }\n}\n","import type { TERRAIN_STRIPS } from './test-module'\n\ninterface TerrainText {\n readonly name: string\n readonly group: string\n readonly weight: string\n readonly surfaces: Record<(typeof TERRAIN_STRIPS)[number], string>\n}\n\nconst LOCALES: Record<string, TerrainText> = {\n en: {\n name: 'Terrain',\n group: 'Terrain',\n weight: 'Concrete block (60 kg)',\n surfaces: { dirt: 'Dirt', lawn: 'Lawn', sand: 'Sand', road: 'Asphalt', concrete_slab: 'Concrete slab', suspended_slab: 'Slab over a void', wood_floor: 'Wood floor', tile_floor: 'Tile', stone_paving: 'Stone paving' },\n },\n 'pt-BR': {\n name: 'Terreno',\n group: 'Terreno',\n weight: 'Bloco de concreto (60 kg)',\n surfaces: { dirt: 'Terra', lawn: 'Grama', sand: 'Areia', road: 'Asfalto', concrete_slab: 'Laje de concreto', suspended_slab: 'Laje sobre vão', wood_floor: 'Assoalho', tile_floor: 'Cerâmica', stone_paving: 'Pedra' },\n },\n}\n\nexport const locale = (id: string): TerrainText => LOCALES[id] ?? LOCALES.en!\n","import type * as THREE_NS from 'three'\nimport { markKitOrigin } from '@adatechnology/game-contracts'\nimport type { DestructibleSpec, GroundLayout, ModuleArea, ModuleHost, TestableItem, TestableModule } from '@adatechnology/game-contracts'\nimport { locale as localeTable } from './locale'\n\ntype ThreeLib = typeof THREE_NS\n\n/** The strips of the area, side by side, one of each ground. */\nexport const TERRAIN_STRIPS = ['dirt', 'lawn', 'sand', 'road', 'concrete_slab', 'suspended_slab', 'wood_floor', 'tile_floor', 'stone_paving'] as const\nconst STRIP_WIDTH = 3\nconst STRIP_Z0 = 4\nconst STRIP_Z1 = 13\nconst stripRect = (index: number): readonly [number, number, number, number] => {\n const x0 = (index - TERRAIN_STRIPS.length / 2) * STRIP_WIDTH\n return [x0, STRIP_Z0, x0 + STRIP_WIDTH, STRIP_Z1]\n}\nexport const TERRAIN_AREA_GROUND: GroundLayout = {\n surface: 'concrete_slab',\n zones: TERRAIN_STRIPS.map((surface, index) => ({ rect: stripRect(index), surface })),\n}\n\nconst WEIGHT_SIDE = 0.32\nconst WEIGHT_HEIGHT = 4\nconst LABEL_Y = 0.02\nconst LABEL_WIDTH = 2.6\nconst LABEL_HEIGHT = 0.5\n\nfunction weightSpec(): DestructibleSpec {\n return { name: 'Weight', parts: [{ id: 'block', material: 'concrete', shape: { kind: 'box', size: [WEIGHT_SIDE, WEIGHT_SIDE, WEIGHT_SIDE] }, position: [0, WEIGHT_SIDE / 2, 0] }], joints: [] }\n}\n\nfunction buildWeight(THREE: ThreeLib): THREE_NS.Object3D {\n const mesh = new THREE.Mesh(new THREE.BoxGeometry(WEIGHT_SIDE, WEIGHT_SIDE, WEIGHT_SIDE), new THREE.MeshStandardMaterial({ color: 0xb4b4ae, roughness: 0.9 }))\n mesh.name = 'block'\n mesh.position.y = WEIGHT_SIDE / 2\n mesh.castShadow = true\n const root = markKitOrigin(new THREE.Group(), { owner: 'game-terrain-three', kind: 'item' })\n root.add(mesh)\n return root\n}\n\nfunction buildLabel(THREE: ThreeLib, text: string, x: number): THREE_NS.Object3D | null {\n if (typeof document === 'undefined') return null\n const canvas = document.createElement('canvas')\n canvas.width = 512\n canvas.height = 96\n const context = canvas.getContext('2d')\n if (!context) return null\n context.fillStyle = 'rgba(0,0,0,0.55)'\n context.fillRect(0, 0, 512, 96)\n context.fillStyle = '#fff'\n context.font = 'bold 54px sans-serif'\n context.textAlign = 'center'\n context.textBaseline = 'middle'\n context.fillText(text, 256, 50)\n const mesh = new THREE.Mesh(new THREE.PlaneGeometry(LABEL_WIDTH, LABEL_HEIGHT), new THREE.MeshBasicMaterial({ map: new THREE.CanvasTexture(canvas), transparent: true }))\n // read from the start of the area, looking along +z\n markKitOrigin(mesh, { owner: 'game-terrain-three', kind: 'room' })\n mesh.rotation.set(-Math.PI / 2, 0, Math.PI)\n mesh.position.set(x, LABEL_Y, STRIP_Z0 - LABEL_HEIGHT)\n return mesh\n}\n\n/**\n * The ground bench: a strip of each surface side by side to shoot, blow up, burn and drop weights on. Opening the area lays the strips (`ground`) and drops a concrete block\n * on each from four metres.\n */\nexport function createTerrainTestModule(options: { readonly locale?: string } = {}): TestableModule {\n let labels: THREE_NS.Object3D[] = []\n const text = localeTable(options.locale ?? 'en')\n return {\n id: 'terrain',\n name: text.name,\n group: text.group,\n ground: TERRAIN_AREA_GROUND,\n items(): TestableItem[] {\n return [{ id: 'terrain_weight', name: text.weight, build: (THREE) => buildWeight(THREE as ThreeLib), spec: weightSpec, movable: true }]\n },\n area(): ModuleArea {\n for (const label of labels) label.visible = true\n return {\n placements: TERRAIN_STRIPS.map((_, index) => {\n const [x0] = stripRect(index)\n return { item: 'terrain_weight', position: [x0 + STRIP_WIDTH / 2, WEIGHT_HEIGHT, STRIP_Z1 - 1.2] }\n }),\n view: { eye: [0, 1.7, 1.5], target: [0, 0, 8] },\n }\n },\n attach(host: ModuleHost): () => void {\n const THREE = host.THREE as ThreeLib\n const scene = host.scene as THREE_NS.Scene\n labels = TERRAIN_STRIPS.flatMap((surface, index) => {\n const [x0] = stripRect(index)\n const label = buildLabel(THREE, text.surfaces[surface], x0 + STRIP_WIDTH / 2)\n if (!label) return []\n label.visible = false\n scene.add(label)\n return [label]\n })\n const stop = host.onClear?.(() => {\n for (const label of labels) label.visible = false\n })\n return () => {\n stop?.()\n for (const label of labels) label.removeFromParent()\n labels = []\n }\n },\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;AaMO,MAAM,iBAAiB;AAyBvB,WAAS,cAAiB,QAAW,QAAsB;AAChE,UAAM,OAAO;AACb,SAAK,WAAW,KAAK,YAAY,CAAC;AAClC,SAAK,SAAS,cAAc,IAAI,EAAE,GAAG,OAAO;AAC5C,WAAO;EACT;;;AQYO,MAAM,mBAAmB,CAAC,SAAS,SAAS,QAAQ,QAAQ,WAAW,YAAY,QAAQ,UAAU,OAAO;;;AC1C5G,MAAM,eAAe,CAAC,YAAwC,KAAK,IAAI,GAAG,iBAAiB,QAAS,WAAW,OAA6C,CAAC;AAEpK,MAAM;AAAA;AAAA,IAA0B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA0CzB,WAAS,sBAAsB,OAAgD;AACpF,UAAM,WAAW,IAAI,MAAM,qBAAqB,EAAE,WAAW,MAAM,WAAW,EAAE,CAAC;AACjF,aAAS,kBAAkB,CAAC,WAAW;AACrC,aAAO,eAAe,OAAO,aAC1B,QAAQ,qBAAqB,oMAAoM,EACjO,QAAQ,2BAA2B,2GAA2G;AACjJ,aAAO,iBAAiB,OAAO,eAC5B,QAAQ,qBAAqB;AAAA;AAAA;AAAA;AAAA;AAAA,EAA6H,YAAY,EAAE,EACxK,QAAQ,6BAA6B,+IAA+I;AAAA,IACzL;AACA,aAAS,wBAAwB,MAAM;AACvC,WAAO;AAAA,EACT;;;ACvDA,MAAM,cAAc;AACpB,MAAM,kBAAkB;AACxB,MAAM,gBAAgB;AAEtB,MAAM,YAAY;AAClB,MAAM,cAAc;AACpB,MAAM,eAAe;AACrB,MAAM,cAAc;AAGpB,WAAS,WAAW,SAAkB,QAAgB,KAAa,MAAgC,OAAiC;AAClI,UAAM,QAAQ,MAAM,QAAQ,SAAS;AACrC,QAAI,WAAW,QAAQ,eAAe,QAAQ,GAAG;AACjD,QAAI,QAAQ,MAAM,KAAK,IAAK,eAAe;AACzC,YAAM,UAAU,QAAQ,YAAY,QAAQ,aAAa,KAAK,CAAE;AAChE,YAAM,QAAQ,QAAQ,OAAO,CAAC;AAC9B,iBAAW,QAAQ,SAAS,MAAM,YAAY,aAAa,QAAQ,OAAO,CAAC,IAAI,QAAQ,OAAO,CAAC,EAAE,WAAW,MAAM,QAAQ;AAAA,IAC5H;AAEA,UAAM,SAAS,SAAS,YAAY,cAAc,QAAQ,IAAI,KAAK,IAAK;AACxE,UAAM,OAAO,SAAS,cAAc;AACpC,SAAK,CAAC,IAAI,SAAS,MAAM,CAAC,IAAI;AAC9B,SAAK,CAAC,IAAI,SAAS,MAAM,CAAC,IAAI;AAC9B,SAAK,CAAC,IAAI,SAAS,MAAM,CAAC,IAAI;AAC9B,UAAM,CAAC,IAAI,KAAK,IAAI,iBAAiB,IAAI,QAAQ,IAAI,KAAK,IAAK,WAAW;AAC1E,UAAM,CAAC,IAAI,QAAQ,OAAO,KAAK;AAC/B,WAAO,aAAa,SAAS,SAAS,SAAS,OAAO;AAAA,EACxD;AAGO,WAAS,mBAAmB,OAAiB,SAAkB,OAAqB,MAAuC;AAChI,UAAM,CAAC,IAAI,EAAE,IAAI,QAAQ;AACzB,UAAM,EAAE,UAAU,SAAS,KAAK,IAAI;AACpC,UAAM,aAAa,KAAK,IAAI,SAAS,MAAM,SAAS,MAAM,KAAK;AAC/D,UAAM,UAAU,KAAK,IAAI,MAAM,MAAM,MAAM,MAAM,KAAK;AACtD,UAAM,SAAS,KAAK,MAAM,aAAa,MAAM,UAAU,IAAI,IAAI;AAC/D,UAAM,OAAO,KAAK,MAAM,UAAU,MAAM,OAAO,IAAI,IAAI;AACvD,UAAM,YAAY,IAAI,aAAa,SAAS,OAAO,CAAC;AACpD,UAAM,UAAU,IAAI,aAAa,SAAS,OAAO,CAAC;AAClD,UAAM,QAAQ,IAAI,aAAa,SAAS,OAAO,CAAC;AAChD,UAAM,SAAS,IAAI,aAAa,SAAS,OAAO,CAAC;AACjD,UAAM,WAAW,IAAI,aAAa,SAAS,IAAI;AAC/C,UAAM,OAAiC,CAAC,GAAG,GAAG,CAAC;AAC/C,UAAM,QAA0B,CAAC,GAAG,CAAC;AACrC,QAAI,KAAK;AACT,aAAS,IAAI,GAAG,IAAI,MAAM,KAAK;AAC7B,YAAM,MAAM,KAAK,IAAI,SAAS,MAAM,MAAM,IAAI,IAAI;AAClD,eAAS,IAAI,GAAG,IAAI,QAAQ,KAAK;AAC/B,cAAM,SAAS,KAAK,IAAI,YAAY,MAAM,SAAS,IAAI,IAAI;AAC3D,cAAM,SAAS,QAAQ,aAAa,QAAQ,GAAG;AAC/C,kBAAU,KAAK,CAAC,IAAI,KAAK,SAAS;AAClC,kBAAU,KAAK,IAAI,CAAC,IAAI;AACxB,kBAAU,KAAK,IAAI,CAAC,IAAI,KAAK,MAAM;AACnC,cAAM,OAAO,QAAQ,aAAa,KAAK,IAAI,GAAG,SAAS,CAAC,GAAG,GAAG;AAC9D,cAAM,QAAQ,QAAQ,aAAa,KAAK,IAAI,SAAS,SAAS,CAAC,GAAG,GAAG;AACrE,cAAM,OAAO,QAAQ,aAAa,QAAQ,KAAK,IAAI,GAAG,MAAM,CAAC,CAAC;AAC9D,cAAM,QAAQ,QAAQ,aAAa,QAAQ,KAAK,IAAI,MAAM,MAAM,CAAC,CAAC;AAClE,cAAM,KAAK,OAAO;AAClB,cAAM,KAAK,IAAI;AACf,cAAM,KAAK,OAAO;AAClB,cAAM,SAAS,KAAK,MAAM,IAAI,IAAI,EAAE;AACpC,gBAAQ,KAAK,CAAC,IAAI,KAAK;AACvB,gBAAQ,KAAK,IAAI,CAAC,IAAI,KAAK;AAC3B,gBAAQ,KAAK,IAAI,CAAC,IAAI,KAAK;AAC3B,iBAAS,EAAE,IAAI,WAAW,SAAS,QAAQ,KAAK,MAAM,KAAK;AAC3D,cAAM,IAAI,MAAM,KAAK,CAAC;AACtB,eAAO,IAAI,OAAO,KAAK,CAAC;AACxB;AAAA,MACF;AAAA,IACF;AACA,UAAM,UAAU,IAAI,aAAa,SAAS,MAAM,OAAO,KAAK,CAAC;AAC7D,QAAI,SAAS;AACb,aAAS,IAAI,GAAG,IAAI,OAAO,GAAG,KAAK;AACjC,eAAS,IAAI,GAAG,IAAI,SAAS,GAAG,KAAK;AACnC,cAAM,IAAI,IAAI,SAAS;AACvB,cAAM,IAAI,IAAI;AACd,cAAM,IAAI,IAAI;AACd,cAAM,IAAI,IAAI;AACd,gBAAQ,QAAQ,IAAI;AACpB,gBAAQ,QAAQ,IAAI;AACpB,gBAAQ,QAAQ,IAAI;AAEpB,gBAAQ,QAAQ,IAAI;AACpB,gBAAQ,QAAQ,IAAI;AACpB,gBAAQ,QAAQ,IAAI;AAAA,MACtB;AAAA,IACF;AACA,UAAM,WAAW,IAAI,MAAM,eAAe;AAC1C,aAAS,aAAa,YAAY,IAAI,MAAM,gBAAgB,WAAW,CAAC,CAAC;AACzE,aAAS,aAAa,UAAU,IAAI,MAAM,gBAAgB,SAAS,CAAC,CAAC;AACrE,aAAS,aAAa,SAAS,IAAI,MAAM,gBAAgB,OAAO,CAAC,CAAC;AAClE,aAAS,aAAa,UAAU,IAAI,MAAM,gBAAgB,QAAQ,CAAC,CAAC;AACpE,aAAS,aAAa,YAAY,IAAI,MAAM,gBAAgB,UAAU,CAAC,CAAC;AACxE,aAAS,SAAS,IAAI,MAAM,gBAAgB,SAAS,CAAC,CAAC;AACvD,WAAO;AAAA,EACT;AAMO,WAAS,sBAAsB,OAAiB,SAAkB,QAAiC,OAAwC;AAChJ,UAAM,CAAC,IAAI,EAAE,IAAI,QAAQ;AACzB,UAAM,EAAE,UAAU,SAAS,KAAK,IAAI;AACpC,UAAM,CAAC,EAAE,EAAE,IAAI,EAAE,IAAI,QAAQ;AAC7B,UAAM,QAAQ,OAAO,SAAS;AAC9B,UAAM,YAAY,IAAI,aAAa,QAAQ,EAAE;AAC7C,UAAM,UAAU,IAAI,aAAa,QAAQ,EAAE;AAC3C,UAAM,QAAQ,IAAI,aAAa,QAAQ,EAAE;AACzC,UAAM,SAAS,IAAI,aAAa,QAAQ,CAAC;AACzC,UAAM,WAAW,IAAI,aAAa,QAAQ,CAAC;AAC3C,UAAM,UAAU,IAAI,YAAY,QAAQ,CAAC;AACzC,UAAM,OAAiC,CAAC,GAAG,GAAG,CAAC;AAC/C,UAAM,QAA0B,CAAC,GAAG,CAAC;AACrC,WAAO,QAAQ,CAAC,OAAO,MAAM;AAC3B,YAAM,aAAa,KAAK,IAAI,SAAS,MAAM,SAAS,MAAM,KAAK;AAC/D,YAAM,UAAU,KAAK,IAAI,MAAM,MAAM,MAAM,MAAM,KAAK;AACtD,YAAM,UAAU,CAAC,CAAC,MAAM,QAAQ,MAAM,GAAG,GAAG,CAAC,YAAY,MAAM,GAAG,GAAG,CAAC,MAAM,QAAQ,OAAO,GAAG,CAAC,YAAY,OAAO,CAAC;AACnH,YAAM,UAAU,WAAW,SAAS,MAAM,QAAQ,MAAM,KAAK,MAAM,KAAK;AACxE,cAAQ,QAAQ,CAAC,CAAC,QAAQ,GAAG,GAAG,WAAW;AACzC,cAAM,KAAK,IAAI,IAAI;AACnB,kBAAU,KAAK,CAAC,IAAI,KAAK,SAAS;AAClC,kBAAU,KAAK,IAAI,CAAC,IAAI,QAAQ,aAAa,QAAQ,GAAG;AACxD,kBAAU,KAAK,IAAI,CAAC,IAAI,KAAK,MAAM;AACnC,gBAAQ,KAAK,IAAI,CAAC,IAAI;AACtB,cAAM,IAAI,MAAM,KAAK,CAAC;AACtB,eAAO,IAAI,OAAO,KAAK,CAAC;AACxB,iBAAS,EAAE,IAAI;AAAA,MACjB,CAAC;AACD,YAAM,QAAQ,IAAI;AAClB,cAAQ,IAAI,CAAC,OAAO,QAAQ,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC,GAAG,IAAI,CAAC;AAAA,IACnF,CAAC;AACD,UAAM,UAAU,QAAQ,SAAS,QAAQ,YAAY,KAAK,CAAC,YAAY,QAAQ,OAAO,QAAQ,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,GAAG,YAAY,QAAQ,YAAY,QAAQ,aAAa,CAAC,CAAE,EAAG,OAAO,CAAC,EAAG,QAAQ;AAC1M,UAAM,QAAQ;AAAA,MACZ,CAAC,KAAK,OAAO,KAAK,OAAO,KAAK,OAAO,EAAE;AAAA,MACvC,CAAC,KAAK,OAAO,IAAI,KAAK,OAAO,KAAK,KAAK;AAAA,MACvC,CAAC,KAAK,OAAO,IAAI,IAAI,EAAE;AAAA,MACvB,CAAC,IAAI,IAAI,KAAK,OAAO,EAAE;AAAA,IACzB;AACA,UAAM,QAAQ,CAAC,CAAC,IAAI,IAAI,IAAI,EAAE,GAAG,MAAM;AACrC,YAAM,IAAI,OAAO,SAAS;AAC1B,YAAM,UAAU,CAAC,CAAC,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,CAAC;AACvD,cAAQ,QAAQ,CAAC,CAAC,GAAG,CAAC,GAAG,WAAW;AAClC,cAAM,KAAK,IAAI,IAAI;AACnB,kBAAU,KAAK,CAAC,IAAI;AACpB,kBAAU,KAAK,IAAI,CAAC,IAAI;AACxB,gBAAQ,KAAK,IAAI,CAAC,IAAI;AACtB,cAAM,IAAI,QAAQ,OAAO,KAAK,CAAC;AAC/B,eAAO,KAAK,CAAC,IAAI;AACjB,iBAAS,EAAE,IAAI,aAAa,QAAQ,OAAO;AAAA,MAC7C,CAAC;AACD,YAAM,QAAQ,IAAI;AAClB,cAAQ,IAAI,CAAC,OAAO,QAAQ,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC,GAAG,IAAI,CAAC;AAAA,IACnF,CAAC;AACD,UAAM,WAAW,IAAI,MAAM,eAAe;AAC1C,aAAS,aAAa,YAAY,IAAI,MAAM,gBAAgB,WAAW,CAAC,CAAC;AACzE,aAAS,aAAa,UAAU,IAAI,MAAM,gBAAgB,SAAS,CAAC,CAAC;AACrE,aAAS,aAAa,SAAS,IAAI,MAAM,gBAAgB,OAAO,CAAC,CAAC;AAClE,aAAS,aAAa,UAAU,IAAI,MAAM,gBAAgB,QAAQ,CAAC,CAAC;AACpE,aAAS,aAAa,YAAY,IAAI,MAAM,gBAAgB,UAAU,CAAC,CAAC;AACxE,aAAS,SAAS,IAAI,MAAM,gBAAgB,SAAS,CAAC,CAAC;AACvD,WAAO;AAAA,EACT;;;ACpKA,MAAM,YAAY;AAClB,MAAM,aAAa;AACnB,MAAM,oBAAoB;AAC1B,MAAM,cAAc;AAEpB,MAAM,cAA4E,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,GAAG,OAAO,CAAC,GAAG,KAAK,GAAG,GAAG,QAAQ,CAAC,KAAK,KAAK,CAAC,GAAG,WAAW,CAAC,KAAK,MAAM,GAAG,GAAG,MAAM,CAAC,GAAG,KAAK,CAAC,EAAE;AAGrM,WAAS,kBAAkB,OAAuC;AAChE,UAAM,OAAO;AACb,UAAM,OAAO,IAAI,WAAW,OAAO,OAAO,CAAC;AAC3C,aAAS,IAAI,GAAG,IAAI,MAAM,KAAK;AAC7B,eAAS,IAAI,GAAG,IAAI,MAAM,KAAK;AAC7B,cAAM,KAAK,IAAI,OAAO,OAAO;AAC7B,cAAM,KAAK,IAAI,OAAO,OAAO;AAC7B,cAAM,QAAQ,KAAK,MAAM,GAAG,CAAC;AAC7B,cAAM,QAAQ,OAAO,OAAO,KAAK,IAAI,QAAQ,CAAC,IAAI,OAAO,KAAK,IAAI,QAAQ,KAAK,CAAC;AAChF,cAAM,IAAI,KAAK,MAAM,GAAG,CAAC,KAAK,MAAM;AACpC,cAAM,QAAQ,IAAI,OAAO,OAAO,IAAI,MAAM,OAAO,IAAI,QAAQ;AAC7D,cAAM,MAAM,IAAI,OAAO,KAAK;AAC5B,aAAK,EAAE,IAAI,KAAK,KAAK,CAAC,IAAI,KAAK,KAAK,CAAC,IAAI,KAAK,MAAM,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG;AAC5E,aAAK,KAAK,CAAC,IAAI,IAAI,MAAM,MAAM,IAAI,IAAI,KAAK,OAAO,KAAK,IAAI,OAAO,OAAO,GAAG,IAAI;AAAA,MACnF;AAAA,IACF;AACA,UAAM,UAAU,IAAI,MAAM,YAAY,MAAM,MAAM,MAAM,MAAM,UAAU;AACxE,YAAQ,YAAY,MAAM;AAC1B,YAAQ,YAAY,MAAM;AAC1B,YAAQ,cAAc;AACtB,WAAO;AAAA,EACT;AAEO,WAAS,gBAAgB,OAAiB,SAAkB,cAAuH;AACxL,UAAM,WAAW,IAAI,MAAM,cAAc,GAAG,CAAC;AAC7C,aAAS,QAAQ,CAAC,KAAK,KAAK,CAAC;AAC7B,UAAM,UAAU,kBAAkB,KAAK;AACvC,UAAM,WAAW,IAAI,MAAM,qBAAqB,EAAE,KAAK,SAAS,aAAa,MAAM,YAAY,OAAO,WAAW,GAAG,eAAe,MAAM,qBAAqB,IAAI,oBAAoB,GAAG,CAAC;AAC1L,UAAM,OAAO,IAAI,MAAM,cAAc,UAAU,UAAU,QAAQ,OAAO,QAAQ;AAChF,SAAK,OAAO;AACZ,SAAK,QAAQ;AACb,SAAK,gBAAgB;AACrB,SAAK,gBAAgB;AACrB,SAAK,cAAc;AACnB,UAAM,SAAS,IAAI,MAAM,QAAQ;AACjC,UAAM,WAAW,IAAI,MAAM,QAAQ;AACnC,UAAM,QAAQ,IAAI,MAAM,QAAQ;AAChC,UAAM,SAAS,IAAI,MAAM,MAAM;AAC/B,QAAI,OAAO;AACX,QAAI,UAAU;AACd,QAAI,UAAU;AACd,WAAO;AAAA,MACL;AAAA,MACA,KAAK,QAAQ;AACX,cAAM,QAAQ,QAAQ,MAAM;AAC5B,YAAI,QAAQ;AACZ,mBAAW,QAAQ,MAAO,UAAS,KAAK;AACxC,cAAM,QAAQ,GAAG,QAAQ,YAAY,IAAI,KAAK;AAC9C,YAAI,UAAU,QAAQ,KAAK,MAAM,OAAO,IAAI,SAAS,OAAO,IAAI,OAAO,IAAI,EAAG;AAC9E,eAAO;AACP,kBAAU,OAAO;AACjB,kBAAU,OAAO;AACjB,YAAI,QAAQ;AACZ,mBAAW,QAAQ,OAAO;AACxB,cAAI,KAAK,MAAM,KAAK,IAAI,OAAO,GAAG,KAAK,IAAI,OAAO,CAAC,IAAI,aAAc;AACrE,gBAAM,OAAO,KAAK,SAAS,IAAI;AAC/B,mBAAS,cAAc,KAAK,KAAK,KAAK;AACtC,gBAAM,UAAU,MAAM,GAAG,IAAI;AAC7B,iBAAO,iBAAiB,UAAU,KAAK;AACvC,iBAAO,YAAY,KAAK,GAAG,QAAQ,SAAS,KAAK,GAAG,KAAK,CAAC,IAAI,WAAW,KAAK,CAAC;AAC/E,eAAK,YAAY,OAAO,MAAM;AAC9B,gBAAM,CAAC,GAAG,GAAG,CAAC,IAAI,QAAQ,SAAS,KAAK,QAAQ,EAAE;AAClD,iBAAO,OAAO,IAAI,cAAc,MAAM,IAAI,cAAc,MAAM,IAAI,cAAc,IAAI;AACpF,eAAK,WAAW,OAAO,MAAM;AAC7B;AAAA,QACF;AACA,aAAK,QAAQ;AACb,aAAK,eAAe,cAAc;AAClC,YAAI,KAAK,cAAe,MAAK,cAAc,cAAc;AAAA,MAC3D;AAAA,MACA,UAAU;AACR,iBAAS,QAAQ;AACjB,iBAAS,QAAQ;AACjB,gBAAQ,QAAQ;AAChB,aAAK,iBAAiB;AAAA,MACxB;AAAA,IACF;AAAA,EACF;AAEO,WAAS,kBAAkB,OAAiB,SAAmF;AACpI,UAAM,WAAW,IAAI,MAAM,oBAAoB,GAAG,CAAC;AACnD,UAAM,WAAW,IAAI,MAAM,qBAAqB,EAAE,WAAW,MAAM,aAAa,KAAK,CAAC;AACtF,UAAM,OAAO,IAAI,MAAM,cAAc,UAAU,UAAU,QAAQ,OAAO,SAAS;AACjF,SAAK,OAAO;AACZ,SAAK,QAAQ;AACb,SAAK,aAAa;AAClB,SAAK,gBAAgB;AACrB,UAAM,SAAS,IAAI,MAAM,QAAQ;AACjC,UAAM,WAAW,IAAI,MAAM,QAAQ;AACnC,UAAM,QAAQ,IAAI,MAAM,QAAQ;AAChC,UAAM,aAAa,IAAI,MAAM,WAAW;AACxC,UAAM,QAAQ,IAAI,MAAM,MAAM;AAC9B,UAAM,SAAS,IAAI,MAAM,MAAM;AAC/B,WAAO;AAAA,MACL;AAAA,MACA,OAAO;AACL,cAAM,SAAS,QAAQ,OAAO;AAC9B,YAAI,QAAQ;AACZ,mBAAW,SAAS,QAAQ;AAC1B,gBAAM,CAAC,GAAG,GAAG,CAAC,IAAI,MAAM;AAExB,gBAAM,YAAY,IAAI,MAAM,IAAI,MAAM;AACtC,gBAAM,IAAI,MAAM,KAAK,CAAC,IAAI,YAAY,MAAM,MAAM,IAAI,MAAM,KAAK,CAAC,IAAI,YAAY,KAAK,MAAM,KAAK,CAAC,IAAI,YAAY,GAAG;AACtH,gBAAM,QAAQ,YAAY,MAAM,IAAI;AACpC,gBAAM,IAAI,MAAM,CAAC,IAAI,MAAM,QAAQ,MAAM,CAAC,IAAI,MAAM,QAAQ,MAAM,CAAC,IAAI,MAAM,MAAM;AACnF,iBAAO,QAAQ,SAAS,IAAI,GAAG,GAAG,CAAC,GAAG,WAAW,aAAa,KAAK,GAAG,KAAK;AAC3E,eAAK,YAAY,OAAO,MAAM;AAC9B,gBAAM,CAAC,GAAG,GAAG,CAAC,IAAI,QAAQ,SAAS,MAAM,QAAQ,EAAE;AACnD,gBAAM,OAAO,OAAS,MAAM,KAAK,KAAM,KAAM;AAC7C,eAAK,WAAW,OAAO,OAAO,OAAO,IAAI,MAAM,IAAI,MAAM,IAAI,IAAI,CAAC;AAClE;AAAA,QACF;AACA,aAAK,QAAQ;AACb,aAAK,eAAe,cAAc;AAClC,YAAI,KAAK,cAAe,MAAK,cAAc,cAAc;AAAA,MAC3D;AAAA,MACA,UAAU;AACR,iBAAS,QAAQ;AACjB,iBAAS,QAAQ;AACjB,aAAK,iBAAiB;AAAA,MACxB;AAAA,IACF;AAAA,EACF;;;AClIA,MAAM,aAAa;AACnB,MAAM,iBAAiB;AAEvB,MAAM,UAAU;AAGT,WAAS,mBAAmB,OAAiB,SAAkB,OAAqD;AACzH,UAAM,CAAC,IAAI,EAAE,IAAI,QAAQ;AACzB,UAAM,EAAE,UAAU,SAAS,MAAM,OAAO,IAAI;AAC5C,UAAM,aAAa,KAAK,IAAI,SAAS,MAAM,SAAS,MAAM,KAAK;AAC/D,UAAM,UAAU,KAAK,IAAI,MAAM,MAAM,MAAM,MAAM,KAAK;AACtD,UAAM,QAAkB,CAAC;AACzB,UAAM,UAAU,CAAC,UAA6D;AAC5E,UAAI,MAAM;AACV,iBAAW,WAAW,QAAQ,YAAY,QAAQ,aAAa,KAAK,CAAE,EAAG,QAAQ;AAC/E,YAAI,QAAQ,cAAe,QAAO,EAAE,OAAO,MAAM,QAAQ,cAAc,OAAO,SAAS,QAAQ,cAAc,QAAQ;AACrH,eAAO,QAAQ;AAAA,MACjB;AACA,aAAO;AAAA,IACT;AAEA,UAAM,UAAU,CAAC,OAAe,UAA2B;AACzD,YAAM,UAAU,QAAQ,YAAY,QAAQ,aAAa,KAAK,CAAE;AAChE,YAAM,QAAQ,QAAQ,OAAO,OAAO,CAAC,KAAK,YAAY,MAAM,QAAQ,WAAW,CAAC;AAChF,aAAO,QAAQ,IAAI,KAAK,IAAK,QAAQ,kBAAkB,QAAQ,IAAI,KAAK,KAAM,QAAQ;AAAA,IACxF;AACA,UAAM,QAAQ,CAAC,YAAoB,YAA6B,KAAK,MAAM,aAAa,OAAO,MAAM,KAAK,OAAO,aAAa,YAAY,OAAO;AACjJ,UAAM,SAAS,CAAC,IAAY,IAAY,IAAY,IAAY,MAAoB;AAClF,YAAM,KAAK,KAAK,IAAI,IAAI,EAAE,IAAI,YAAY,IAAI,YAAY,KAAK,IAAI,IAAI,EAAE,IAAI,YAAY,KAAK,IAAI,IAAI,EAAE,IAAI,YAAY,IAAI,YAAY,KAAK,IAAI,IAAI,EAAE,IAAI,UAAU;AAAA,IACvK;AACA,aAAS,MAAM,MAAM,KAAK,OAAO,SAAS,OAAO;AAC/C,eAAS,SAAS,MAAM,QAAQ,UAAU,YAAY,UAAU;AAC9D,cAAM,QAAQ,MAAM,SAAS;AAC7B,cAAM,QAAQ,QAAQ,KAAK;AAC3B,YAAI,CAAC,SAAS,CAAC,QAAQ,OAAO,MAAM,KAAK,EAAG;AAC5C,cAAM,IAAI,KAAK,SAAS;AACxB,cAAM,IAAI,KAAK,MAAM;AACrB,cAAM,IAAI,CAAC,MAAM,QAAQ,aAAa,KAAK,IAAI,GAAG,QAAQ,QAAQ,KAAK,CAAE,IAAI;AAC7E,YAAI,MAAM,GAAG,MAAM,OAAO,KAAK,SAAS,cAAc,QAAQ,QAAQ,GAAG,MAAM,KAAK,EAAG,QAAO,GAAG,GAAG,IAAI,UAAU,GAAG,CAAC;AACtH,YAAI,MAAM,GAAG,MAAM,OAAO,KAAK,MAAM,WAAW,QAAQ,QAAQ,QAAQ,MAAM,KAAK,EAAG,QAAO,GAAG,GAAG,GAAG,IAAI,UAAU,CAAC;AAAA,MACvH;AAAA,IACF;AACA,QAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,UAAM,QAAQ,MAAM,SAAS;AAC7B,UAAM,YAAY,IAAI,aAAa,QAAQ,KAAK,CAAC;AACjD,UAAM,UAAU,IAAI,aAAa,QAAQ,KAAK,CAAC;AAC/C,UAAM,UAAU,IAAI,YAAY,QAAQ,EAAE;AAC1C,UAAM,QAAsF;AAAA,MAC1F,CAAC,CAAC,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC;AAAA,MAChD,CAAC,CAAC,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,CAAC;AAAA,MACjD,CAAC,CAAC,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC;AAAA,MAChD,CAAC,CAAC,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC,GAAG,CAAC,GAAG,GAAG,EAAE,CAAC;AAAA,MACjD,CAAC,CAAC,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC;AAAA,MAChD,CAAC,CAAC,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,CAAC;AAAA,IACnD;AACA,QAAI,SAAS;AACb,QAAI,SAAS;AACb,aAAS,IAAI,GAAG,IAAI,OAAO,KAAK;AAC9B,YAAM,MAAM,MAAM,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC;AACxC,iBAAW,CAAC,SAAS,MAAM,KAAK,OAAO;AACrC,cAAM,QAAQ;AACd,iBAAS,IAAI,GAAG,IAAI,GAAG,KAAK;AAC1B,oBAAU,SAAS,CAAC,IAAI,IAAI,QAAQ,IAAI,CAAC,CAAE;AAC3C,oBAAU,SAAS,IAAI,CAAC,IAAI,IAAI,QAAQ,IAAI,IAAI,CAAC,CAAE;AACnD,oBAAU,SAAS,IAAI,CAAC,IAAI,IAAI,QAAQ,IAAI,IAAI,CAAC,CAAE;AACnD,kBAAQ,IAAI,QAAQ,SAAS,CAAC;AAC9B;AAAA,QACF;AACA,gBAAQ,IAAI,CAAC,OAAO,QAAQ,GAAG,QAAQ,GAAG,OAAO,QAAQ,GAAG,QAAQ,CAAC,GAAG,MAAM;AAC9E,kBAAU;AAAA,MACZ;AAAA,IACF;AACA,UAAM,WAAW,IAAI,MAAM,eAAe;AAC1C,aAAS,aAAa,YAAY,IAAI,MAAM,gBAAgB,WAAW,CAAC,CAAC;AACzE,aAAS,aAAa,UAAU,IAAI,MAAM,gBAAgB,SAAS,CAAC,CAAC;AACrE,aAAS,SAAS,IAAI,MAAM,gBAAgB,SAAS,CAAC,CAAC;AACvD,WAAO;AAAA,EACT;;;ACrDA,MAAM,uBAAuB;AAC7B,MAAM,wBAAwB;AAC9B,MAAM,gBAAgB;AACtB,MAAM,mBAAmB;AACzB,MAAM,eAAe;AASd,WAAS,sBAAsB,OAAiB,SAAkD;AACvG,UAAM,EAAE,QAAQ,IAAI;AACpB,UAAM,cAAc,QAAQ,eAAe;AAC3C,UAAM,QAAQ,QAAQ,SAAS;AAC/B,UAAM,QAAQ,cAAc,IAAI,MAAM,MAAM,GAAG,EAAE,OAAO,sBAAsB,MAAM,SAAS,CAAC;AAC9F,UAAM,OAAO;AACb,UAAM,UAAU,sBAAsB,KAAK;AAC3C,UAAM,gBAAgB,IAAI,MAAM,qBAAqB,EAAE,OAAO,cAAc,WAAW,MAAM,WAAW,KAAK,CAAC;AAC9G,UAAM,eAAe,IAAI,MAAM,KAAK,IAAI,MAAM,eAAe,GAAG,OAAO;AACvE,iBAAa,gBAAgB;AAC7B,iBAAa,OAAO;AACpB,iBAAa,gBAAgB;AAC7B,UAAM,IAAI,YAAY;AACtB,UAAM,QAAQ,oBAAI,IAAwB;AAC1C,UAAM,QAAQ,gBAAgB,OAAO,SAAS,QAAQ,gBAAgB,qBAAqB;AAC3F,UAAM,SAAS,kBAAkB,OAAO,OAAO;AAC/C,UAAM,IAAI,MAAM,MAAM,OAAO,IAAI;AACjC,QAAI,gBAAgB;AACpB,QAAI,gBAAgB;AACpB,QAAI,OAAO;AACX,QAAI,OAAO;AACX,QAAI,YAAY;AAEhB,UAAM,aAAa,CAAC,OAAqB,WAA6C;AACpF,YAAM,OAAQ,MAAM,QAAQ,QAAQ,WAAY;AAChD,YAAM,UAAU,QAAQ,OAAO,CAAC,IAAI,MAAM,SAAS,QAAQ,WAAW;AACtE,YAAM,UAAU,QAAQ,OAAO,CAAC,IAAI,MAAM,MAAM,QAAQ,WAAW;AACnE,aAAO,KAAK,MAAM,UAAU,OAAO,GAAG,UAAU,OAAO,CAAC;AAAA,IAC1D;AACA,UAAM,UAAU,CAAC,aAA8B,WAAW,cAAc,IAAI,IAAI,WAAW,cAAc,IAAI;AAE7G,aAAS,KAAK,OAAqB;AACjC,YAAM,QAAQ,MAAM,IAAI,KAAK;AAC7B,UAAI,CAAC,MAAO;AACZ,YAAM,OAAO,MAAM,IAAI;AACvB,YAAM,KAAK,SAAS,QAAQ;AAC5B,UAAI,MAAM,OAAO;AACf,cAAM,OAAO,MAAM,KAAK;AACxB,cAAM,MAAM,SAAS,QAAQ;AAAA,MAC/B;AACA,YAAM,OAAO,KAAK;AAAA,IACpB;AAEA,aAAS,KAAK,OAAqB,QAAwC;AACzE,WAAK,MAAM,KAAK;AAChB,YAAM,OAAO,QAAQ,WAAW,OAAO,MAAM,CAAC;AAC9C,YAAM,OAAO,IAAI,MAAM,KAAK,mBAAmB,OAAO,SAAS,OAAO,IAAI,GAAG,OAAO;AACpF,WAAK,gBAAgB;AACrB,YAAM,IAAI,IAAI;AACd,YAAM,OAAO,mBAAmB,OAAO,SAAS,KAAK;AACrD,UAAI,QAA8B;AAClC,UAAI,MAAM;AACR,gBAAQ,IAAI,MAAM,KAAK,MAAM,aAAa;AAC1C,cAAM,aAAa;AACnB,cAAM,IAAI,KAAK;AAAA,MACjB;AACA,YAAM,IAAI,MAAM,OAAO,EAAE,MAAM,OAAO,KAAK,CAAC;AAAA,IAC9C;AAEA,aAAS,kBAAwB;AAC/B,YAAM,SAAyB,CAAC;AAChC,eAAS,QAAQ,GAAG,QAAQ,QAAQ,UAAU,QAAQ,SAAS,SAAS;AACtE,cAAM,QAAQ,QAAQ,MAAM,KAAK;AACjC,YAAI,MAAM,SAAU,QAAO,KAAK,KAAK;AAAA,MACvC;AACA,mBAAa,SAAS,QAAQ;AAC9B,mBAAa,WAAW,sBAAsB,OAAO,SAAS,QAAQ,KAAK;AAC3E,sBAAgB,OAAO;AACvB,sBAAgB;AAAA,IAClB;AAEA,WAAO;AAAA,MACL;AAAA,MACA,KAAK,QAAQ;AAEX,cAAM,QAAQ,YAAY,WAAW;AACrC,oBAAY;AACZ,mBAAW,SAAS,QAAQ,iBAAiB,KAAK,GAAG;AACnD,cAAI,MAAM,UAAU;AAClB,gBAAI,MAAM,IAAI,MAAM,KAAK,EAAG,MAAK,MAAM,KAAK;AAC5C,4BAAgB;AAAA,UAClB,OAAO;AACL,gBAAI,CAAC,MAAM,IAAI,MAAM,KAAK,EAAG,iBAAgB;AAC7C,iBAAK,OAAO,MAAM;AAAA,UACpB;AAAA,QACF;AACA,YAAI,cAAe,iBAAgB;AACnC,YAAI,KAAK,MAAM,OAAO,IAAI,MAAM,OAAO,IAAI,IAAI,IAAI,kBAAkB;AACnE,iBAAO,OAAO;AACd,iBAAO,OAAO;AACd,qBAAW,CAAC,OAAO,KAAK,KAAK,OAAO;AAClC,kBAAM,QAAQ,QAAQ,MAAM,KAAK;AACjC,gBAAI,QAAQ,WAAW,OAAO,MAAM,CAAC,MAAM,MAAM,KAAM,MAAK,OAAO,MAAM;AAAA,UAC3E;AAAA,QACF;AACA,cAAM,KAAK,MAAM;AACjB,eAAO,KAAK;AAAA,MACd;AAAA,MACA,OAAO,OAAO,EAAE,aAAa,MAAM,MAAM,gBAAgB,eAAe,YAAY,MAAM,KAAK,OAAO,aAAa,OAAO,KAAK,MAAM;AAAA,MACrI,UAAU;AACR,mBAAW,SAAS,CAAC,GAAG,MAAM,KAAK,CAAC,EAAG,MAAK,KAAK;AACjD,qBAAa,SAAS,QAAQ;AAC9B,gBAAQ,QAAQ;AAChB,sBAAc,QAAQ;AACtB,cAAM,QAAQ;AACd,eAAO,QAAQ;AACf,cAAM,iBAAiB;AAAA,MACzB;AAAA,IACF;AAAA,EACF;;;AC9IA,MAAM,UAAuC;AAAA,IAC3C,IAAI;AAAA,MACF,MAAM;AAAA,MACN,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,UAAU,EAAE,MAAM,QAAQ,MAAM,QAAQ,MAAM,QAAQ,MAAM,WAAW,eAAe,iBAAiB,gBAAgB,oBAAoB,YAAY,cAAc,YAAY,QAAQ,cAAc,eAAe;AAAA,IACxN;AAAA,IACA,SAAS;AAAA,MACP,MAAM;AAAA,MACN,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,UAAU,EAAE,MAAM,SAAS,MAAM,SAAS,MAAM,SAAS,MAAM,WAAW,eAAe,oBAAoB,gBAAgB,qBAAkB,YAAY,YAAY,YAAY,eAAY,cAAc,QAAQ;AAAA,IACvN;AAAA,EACF;AAEO,MAAM,SAAS,CAAC,OAA4B,QAAQ,EAAE,KAAK,QAAQ;;;AChBnE,MAAM,iBAAiB,CAAC,QAAQ,QAAQ,QAAQ,QAAQ,iBAAiB,kBAAkB,cAAc,cAAc,cAAc;AAC5I,MAAM,cAAc;AACpB,MAAM,WAAW;AACjB,MAAM,WAAW;AACjB,MAAM,YAAY,CAAC,UAA6D;AAC9E,UAAM,MAAM,QAAQ,eAAe,SAAS,KAAK;AACjD,WAAO,CAAC,IAAI,UAAU,KAAK,aAAa,QAAQ;AAAA,EAClD;AACO,MAAM,sBAAoC;AAAA,IAC/C,SAAS;AAAA,IACT,OAAO,eAAe,IAAI,CAAC,SAAS,WAAW,EAAE,MAAM,UAAU,KAAK,GAAG,QAAQ,EAAE;AAAA,EACrF;AAEA,MAAM,cAAc;AACpB,MAAM,gBAAgB;AACtB,MAAM,UAAU;AAChB,MAAM,cAAc;AACpB,MAAM,eAAe;AAErB,WAAS,aAA+B;AACtC,WAAO,EAAE,MAAM,UAAU,OAAO,CAAC,EAAE,IAAI,SAAS,UAAU,YAAY,OAAO,EAAE,MAAM,OAAO,MAAM,CAAC,aAAa,aAAa,WAAW,EAAE,GAAG,UAAU,CAAC,GAAG,cAAc,GAAG,CAAC,EAAE,CAAC,GAAG,QAAQ,CAAC,EAAE;AAAA,EAChM;AAEA,WAAS,YAAY,OAAoC;AACvD,UAAM,OAAO,IAAI,MAAM,KAAK,IAAI,MAAM,YAAY,aAAa,aAAa,WAAW,GAAG,IAAI,MAAM,qBAAqB,EAAE,OAAO,UAAU,WAAW,IAAI,CAAC,CAAC;AAC7J,SAAK,OAAO;AACZ,SAAK,SAAS,IAAI,cAAc;AAChC,SAAK,aAAa;AAClB,UAAM,OAAO,cAAc,IAAI,MAAM,MAAM,GAAG,EAAE,OAAO,sBAAsB,MAAM,OAAO,CAAC;AAC3F,SAAK,IAAI,IAAI;AACb,WAAO;AAAA,EACT;AAEA,WAAS,WAAW,OAAiB,MAAc,GAAqC;AACtF,QAAI,OAAO,aAAa,YAAa,QAAO;AAC5C,UAAM,SAAS,SAAS,cAAc,QAAQ;AAC9C,WAAO,QAAQ;AACf,WAAO,SAAS;AAChB,UAAM,UAAU,OAAO,WAAW,IAAI;AACtC,QAAI,CAAC,QAAS,QAAO;AACrB,YAAQ,YAAY;AACpB,YAAQ,SAAS,GAAG,GAAG,KAAK,EAAE;AAC9B,YAAQ,YAAY;AACpB,YAAQ,OAAO;AACf,YAAQ,YAAY;AACpB,YAAQ,eAAe;AACvB,YAAQ,SAAS,MAAM,KAAK,EAAE;AAC9B,UAAM,OAAO,IAAI,MAAM,KAAK,IAAI,MAAM,cAAc,aAAa,YAAY,GAAG,IAAI,MAAM,kBAAkB,EAAE,KAAK,IAAI,MAAM,cAAc,MAAM,GAAG,aAAa,KAAK,CAAC,CAAC;AAExK,kBAAc,MAAM,EAAE,OAAO,sBAAsB,MAAM,OAAO,CAAC;AACjE,SAAK,SAAS,IAAI,CAAC,KAAK,KAAK,GAAG,GAAG,KAAK,EAAE;AAC1C,SAAK,SAAS,IAAI,GAAG,SAAS,WAAW,YAAY;AACrD,WAAO;AAAA,EACT;AAMO,WAAS,wBAAwB,UAAwC,CAAC,GAAmB;AAClG,QAAI,SAA8B,CAAC;AACnC,UAAM,OAAO,OAAY,QAAQ,UAAU,IAAI;AAC/C,WAAO;AAAA,MACL,IAAI;AAAA,MACJ,MAAM,KAAK;AAAA,MACX,OAAO,KAAK;AAAA,MACZ,QAAQ;AAAA,MACR,QAAwB;AACtB,eAAO,CAAC,EAAE,IAAI,kBAAkB,MAAM,KAAK,QAAQ,OAAO,CAAC,UAAU,YAAY,KAAiB,GAAG,MAAM,YAAY,SAAS,KAAK,CAAC;AAAA,MACxI;AAAA,MACA,OAAmB;AACjB,mBAAW,SAAS,OAAQ,OAAM,UAAU;AAC5C,eAAO;AAAA,UACL,YAAY,eAAe,IAAI,CAAC,GAAG,UAAU;AAC3C,kBAAM,CAAC,EAAE,IAAI,UAAU,KAAK;AAC5B,mBAAO,EAAE,MAAM,kBAAkB,UAAU,CAAC,KAAK,cAAc,GAAG,eAAe,WAAW,GAAG,EAAE;AAAA,UACnG,CAAC;AAAA,UACD,MAAM,EAAE,KAAK,CAAC,GAAG,KAAK,GAAG,GAAG,QAAQ,CAAC,GAAG,GAAG,CAAC,EAAE;AAAA,QAChD;AAAA,MACF;AAAA,MACA,OAAO,MAA8B;AACnC,cAAM,QAAQ,KAAK;AACnB,cAAM,QAAQ,KAAK;AACnB,iBAAS,eAAe,QAAQ,CAAC,SAAS,UAAU;AAClD,gBAAM,CAAC,EAAE,IAAI,UAAU,KAAK;AAC5B,gBAAM,QAAQ,WAAW,OAAO,KAAK,SAAS,OAAO,GAAG,KAAK,cAAc,CAAC;AAC5E,cAAI,CAAC,MAAO,QAAO,CAAC;AACpB,gBAAM,UAAU;AAChB,gBAAM,IAAI,KAAK;AACf,iBAAO,CAAC,KAAK;AAAA,QACf,CAAC;AACD,cAAM,OAAO,KAAK,UAAU,MAAM;AAChC,qBAAW,SAAS,OAAQ,OAAM,UAAU;AAAA,QAC9C,CAAC;AACD,eAAO,MAAM;AACX,iBAAO;AACP,qBAAW,SAAS,OAAQ,OAAM,iBAAiB;AACnD,mBAAS,CAAC;AAAA,QACZ;AAAA,MACF;AAAA,IACF;AAAA,EACF;","names":[]}