@adatechnology/game-vehicles-driving 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +21 -2
- package/dist/index.d.ts +405 -0
- package/dist/index.global.js +529 -0
- package/dist/index.global.js.map +1 -0
- package/dist/index.js +477 -0
- package/dist/index.js.map +1 -0
- package/package.json +52 -3
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/arcade.ts","../src/run-over.ts","../src/semi/fuel.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","../src/semi/surfaces.ts","../src/semi/driver.ts","../src/semi/impact.ts","../src/semi/from-stats.ts"],"sourcesContent":["export { createArcadeDriver, defaultArcadeSpec, vehicleCorners } from './arcade'\nexport type { ArcadeOptions, ArcadeSpec, CrashRules, RunOverRules } from './arcade'\nexport { runOver } from './run-over'\nexport type { RunOverOutcome } from './run-over'\nexport type { DriveInput, Driver, DriverState, DrivingBody, DrivingEvent, DrivingWorld, StepResult } from './types'\nexport { createSemiSimDriver, defaultSemiSpec } from './semi/driver'\nexport type { SemiDriver, SemiSimOptions } from './semi/driver'\nexport { createSurfaces, defaultSurfaces, surfaceGrip } from './semi/surfaces'\nexport type { Surface, SurfaceOptions, SurfaceRegistry } from './semi/surfaces'\nexport { bodyDamage, impactEnergy, speedLoss, vehicleDamageFromImpact } from './semi/impact'\nexport { defaultFuelModel, fuelRate } from './semi/fuel'\nexport type { FuelModel } from './semi/fuel'\nexport type { SemiEvent, SemiGround, SemiInput, SemiSimSpec, SemiState, SemiWheel, SemiWorld, VehicleCondition } from './semi/types'\nexport { semiSpecFromStats } from './semi/from-stats'\nexport type { BodyDimensions, DesignStats } from './semi/from-stats'\n","import type { DriveInput, Driver, DriverState, DrivingEvent, DrivingWorld, StepResult } from './types'\n\nexport interface RunOverRules {\n /** Above this speed (m/s) a body is knocked down. */\n knockSpeed: number\n /** Above this speed a body is killed. */\n killSpeed: number\n /** Knock damage is `speed² * knockDamage`. */\n knockDamage: number\n /** Seconds a knocked body is stunned. */\n stunSeconds: number\n /** Fraction of the vehicle speed given to a knocked body. */\n throwFactor: number\n /** Sideways speed added to a knocked body. */\n sideKick: number\n /** The vehicle keeps this fraction of its speed after knocking a body. */\n knockSlow: number\n /** Damage the vehicle takes for each body killed. */\n killVehicleDamage: number\n /** Below `knockSpeed` the vehicle slows by `exp(-holdDrag * dt)` for each body in the way. */\n holdDrag: number\n /** Metres added around the vehicle's footprint when testing a body. */\n margin: number\n}\n\nexport interface CrashRules {\n /** Below this speed an impact is only a bump (no event, but the vehicle still bounces). */\n minSpeed: number\n /** Above this speed the impact damages the vehicle by `speed * damage`. */\n damageSpeed: number\n damage: number\n /** Camera shake is `speed / shakeDivisor`. */\n shakeDivisor: number\n /** Above this speed the driver is hurt by `speed * hurtFactor`. */\n hurtSpeed: number\n hurtFactor: number\n /** The speed after an impact is `v * bounce`. */\n bounce: number\n noiseRadius: number\n}\n\n/** The numbers of the game's drivable car (G 70188-70290). Every one is an option. */\nexport interface ArcadeSpec {\n length: number\n width: number\n accel: number\n /** Deceleration when accelerating against a reverse move, or braking against a forward move. */\n brakeAgainstMotion: number\n reverseAccel: number\n /** Coasting drag: `v *= exp(-drag * dt)`. */\n drag: number\n handbrakeDrag: number\n maxForward: number\n maxReverse: number\n /** Largest steering angle, radians. */\n steerMax: number\n /** The steering angle follows its target by `min(1, steerResponse * dt)`. */\n steerResponse: number\n /** Distance used in `yaw += steer * v / wheelbase * dt`. */\n wheelbase: number\n engineNoiseInterval: number\n engineNoiseBase: number\n engineNoiseSpeedFactor: number\n hornNoiseRadius: number\n crash: CrashRules\n runOver: RunOverRules\n}\n\nexport const defaultArcadeSpec: ArcadeSpec = {\n length: 4.3,\n width: 1.9,\n accel: 5.5,\n brakeAgainstMotion: 12,\n reverseAccel: 3,\n drag: 0.5,\n handbrakeDrag: 3,\n maxForward: 17,\n maxReverse: -5,\n steerMax: 0.55,\n steerResponse: 5,\n wheelbase: 2.6,\n engineNoiseInterval: 0.5,\n engineNoiseBase: 25,\n engineNoiseSpeedFactor: 2,\n hornNoiseRadius: 120,\n crash: { minSpeed: 0.5, damageSpeed: 4, damage: 9, shakeDivisor: 20, hurtSpeed: 9, hurtFactor: 1.2, bounce: -0.25, noiseRadius: 25 },\n runOver: { knockSpeed: 3, killSpeed: 8, knockDamage: 2.5, stunSeconds: 1.4, throwFactor: 0.8, sideKick: 2, knockSlow: 0.93, killVehicleDamage: 3, holdDrag: 2.5, margin: 0.3 },\n}\n\nexport type ArcadeOptions = Partial<Omit<ArcadeSpec, 'crash' | 'runOver'>> & { crash?: Partial<CrashRules>; runOver?: Partial<RunOverRules> }\n\n/** The eight points the game tests for a wall: the four corners, the middle of the front and rear, and the middle of both sides. */\nexport function vehicleCorners(spec: Pick<ArcadeSpec, 'length' | 'width'>, x: number, z: number, yaw: number): Array<[number, number]> {\n const c = Math.cos(yaw)\n const s = Math.sin(yaw)\n const hl = spec.length / 2\n const hw = spec.width / 2\n const out: Array<[number, number]> = []\n for (const [a, b] of [[hl, hw], [hl, -hw], [-hl, hw], [-hl, -hw], [hl, 0], [-hl, 0], [0, hw], [0, -hw]] as const) out.push([x + a * c + b * s, z - a * s + b * c])\n return out\n}\n\nconst clamp = (x: number, a: number, b: number): number => Math.max(a, Math.min(b, x))\nconst lerp = (a: number, b: number, t: number): number => a + (b - a) * t\n\n/**\n * The arcade driver: the game's car, extracted. A pure step function over `DriverState`: no three.js, no game. Port of `carUpdate`\n * (jogo.js 70233-70290) with the same order of operations, so a scripted run gives the same positions and events.\n */\nexport function createArcadeDriver(options: ArcadeOptions = {}): Driver & { readonly spec: ArcadeSpec } {\n const spec: ArcadeSpec = {\n ...defaultArcadeSpec,\n ...options,\n crash: { ...defaultArcadeSpec.crash, ...(options.crash || {}) },\n runOver: { ...defaultArcadeSpec.runOver, ...(options.runOver || {}) },\n }\n const state: DriverState = { x: 0, z: 0, yaw: 0, v: 0, steer: 0, noiseTimer: 0 }\n\n const step = (input: DriveInput, dt: number, world: DrivingWorld): StepResult => {\n const events: DrivingEvent[] = []\n const f = (input.throttle > 0 ? 1 : 0) - (input.brake > 0 ? 1 : 0)\n if (f > 0) state.v += (state.v < 0 ? spec.brakeAgainstMotion : spec.accel) * dt * Math.min(1, input.throttle)\n else if (f < 0) state.v -= (state.v > 0 ? spec.brakeAgainstMotion : spec.reverseAccel) * dt * Math.min(1, input.brake)\n else state.v *= Math.exp(-spec.drag * dt)\n if (input.handbrake) state.v *= Math.exp(-spec.handbrakeDrag * dt)\n state.v = clamp(state.v, spec.maxReverse, spec.maxForward)\n state.steer = lerp(state.steer, clamp(input.steer, -1, 1) * spec.steerMax, Math.min(1, dt * spec.steerResponse))\n const nyaw = state.yaw + ((state.steer * state.v) / spec.wheelbase) * dt\n const fx = Math.cos(nyaw)\n const fz = -Math.sin(nyaw)\n const nx = state.x + fx * state.v * dt\n const nz = state.z + fz * state.v * dt\n if (!world.blocked(nx, nz, nyaw)) {\n state.x = nx\n state.z = nz\n state.yaw = nyaw\n } else if (Math.abs(state.v) > spec.crash.minSpeed) {\n const sp = Math.abs(state.v)\n if (sp > spec.crash.damageSpeed) {\n events.push({ type: 'crash', speed: sp, damage: sp * spec.crash.damage, shake: sp / spec.crash.shakeDivisor, hurtDriver: sp > spec.crash.hurtSpeed ? sp * spec.crash.hurtFactor : 0 })\n events.push({ type: 'noise', radius: spec.crash.noiseRadius, x: state.x, z: state.z, source: 'crash' })\n }\n state.v *= spec.crash.bounce\n }\n // run-over: speed is read once, before the loop, as the game does\n const sp = Math.abs(state.v)\n const ro = spec.runOver\n const bodies = world.bodies ? world.bodies() : []\n for (let i = 0; i < bodies.length; i++) {\n const body = bodies[i]\n if (!body) continue\n const dx = body.x - state.x\n const dz = body.z - state.z\n const a = dx * fx + dz * fz\n const b = -dx * fz + dz * fx\n if (Math.abs(a) > spec.length / 2 + ro.margin || Math.abs(b) > spec.width / 2 + ro.margin) continue\n if (sp > ro.killSpeed) events.push({ type: 'run_over', outcome: 'kill', index: i, speed: sp, vehicleDamage: ro.killVehicleDamage })\n else if (sp > ro.knockSpeed) {\n events.push({\n type: 'run_over',\n outcome: 'knock',\n index: i,\n speed: sp,\n damage: sp * sp * ro.knockDamage,\n stunSeconds: ro.stunSeconds,\n vx: fx * sp * ro.throwFactor + (b > 0 ? -fz : fz) * ro.sideKick,\n vz: fz * sp * ro.throwFactor + (b > 0 ? fx : -fx) * ro.sideKick,\n slow: ro.knockSlow,\n })\n state.v *= ro.knockSlow\n } else {\n state.v *= Math.exp(-ro.holdDrag * dt)\n events.push({ type: 'run_over', outcome: 'push', index: i, speed: sp, dx: (b > 0 ? -fz : fz) * dt, dz: (b > 0 ? fx : -fx) * dt })\n }\n }\n state.noiseTimer -= dt\n if (state.noiseTimer <= 0) {\n state.noiseTimer = spec.engineNoiseInterval\n events.push({ type: 'noise', radius: spec.engineNoiseBase + sp * spec.engineNoiseSpeedFactor, x: state.x, z: state.z, source: 'engine' })\n }\n if (input.horn) {\n events.push({ type: 'noise', radius: spec.hornNoiseRadius, x: state.x, z: state.z, source: 'horn' })\n events.push({ type: 'horn' })\n }\n return { state, events }\n }\n\n return {\n spec,\n state,\n step,\n place(x, z, yaw) {\n state.x = x\n state.z = z\n state.yaw = yaw\n state.v = 0\n state.steer = 0\n },\n }\n}\n","import type { RunOverRules } from './arcade'\nimport { defaultArcadeSpec } from './arcade'\n\nexport type RunOverOutcome = 'push' | 'knock' | 'kill'\n\n/** What a vehicle moving at `speed` (m/s) does to a body it touches: pushes it (and is held), knocks it down, or kills it. */\nexport function runOver(speed: number, rules: Partial<RunOverRules> = {}): RunOverOutcome {\n const { knockSpeed, killSpeed } = { ...defaultArcadeSpec.runOver, ...rules }\n const s = Math.abs(speed)\n return s > killSpeed ? 'kill' : s > knockSpeed ? 'knock' : 'push'\n}\n","export interface FuelModel {\n /** Litres per second at idle. */\n idleRate: number\n /** Extra litres per second at full throttle and redline. */\n fullRate: number\n /** Extra consumption per unit of cargo mass over the vehicle's own mass (0.5 = +50% when the load equals the vehicle). */\n loadFactor: number\n /** Extra consumption of a wrecked engine (0.4 = up to +40% at zero health). */\n damagedEngineExtra: number\n}\n\nexport const defaultFuelModel: FuelModel = { idleRate: 0.0003, fullRate: 0.0035, loadFactor: 0.5, damagedEngineExtra: 0.4 }\n\n/**\n * Litres per second: it depends on the RPM, the throttle, the weight of the cargo and the state of the engine\n * (Frota document, section 04: \"a damaged engine spends up to 40% more\").\n */\nexport function fuelRate(model: FuelModel, rpmFraction: number, throttle: number, loadRatio: number, engineHealth: number): number {\n const base = model.idleRate + model.fullRate * Math.max(0, Math.min(1, rpmFraction)) * Math.max(0, Math.min(1, throttle))\n return base * (1 + model.loadFactor * Math.max(0, loadRatio)) * (1 + model.damagedEngineExtra * (1 - Math.max(0, Math.min(1, engineHealth))))\n}\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","import { applyPatches, mergeById, removeIds } from '@adatechnology/game-contracts'\n\n/** A kind of ground the tyres run on. */\nexport interface Surface {\n id: string\n /** Multiplier of the tyre grip (dry asphalt = 1). */\n grip: number\n /** Above this speed (km/h) the tyres float: grip drops linearly to `grip * aquaplaningFactor` over `aquaplaningBand` km/h. */\n aquaplaningKmh?: number\n aquaplaningFactor?: number\n aquaplaningBand?: number\n /** A vehicle without four-wheel drive sinks: its rolling resistance becomes the spec's `bogResistance`. */\n bogs?: boolean\n}\n\n/** The table of the Frota document, section 03 (Surfaces). Every number is a default a game can replace. */\nexport const defaultSurfaces: readonly Surface[] = [\n { id: 'dry_asphalt', grip: 1 },\n { id: 'wet_asphalt', grip: 0.75, aquaplaningKmh: 90, aquaplaningFactor: 0.4, aquaplaningBand: 30 },\n { id: 'dirt', grip: 0.65 },\n { id: 'grass', grip: 0.55 },\n { id: 'mud', grip: 0.4, bogs: true },\n /** Blood and bodies after a run-over; a driver applies it for a few seconds with `contaminate`. */\n { id: 'blood', grip: 0.5 },\n]\n\nexport interface SurfaceOptions {\n packs?: ReadonlyArray<{ surfaces?: readonly Surface[] }>\n patch?: Readonly<Record<string, Partial<Surface>>>\n remove?: readonly string[]\n}\n\nexport interface SurfaceRegistry {\n get(id: string): Surface\n readonly surfaces: readonly Surface[]\n}\n\n/** The registry of surfaces: the document's table, plus packs, patches and removals by id. An unknown id falls back to dry asphalt. */\nexport function createSurfaces(options: SurfaceOptions = {}): SurfaceRegistry {\n const entries = mergeById(defaultSurfaces, options.packs?.flatMap((pack) => pack.surfaces ?? []))\n applyPatches(entries, options.patch)\n removeIds(entries, options.remove)\n const fallback = entries.get('dry_asphalt') ?? ({ id: 'dry_asphalt', grip: 1 } as Surface)\n return {\n get: (id) => entries.get(id) ?? fallback,\n get surfaces() {\n return [...entries.values()]\n },\n }\n}\n\n/** Grip of a surface at a speed, with aquaplaning. */\nexport function surfaceGrip(surface: Surface, speedMetresPerSecond: number): number {\n if (surface.aquaplaningKmh == null) return surface.grip\n const kmh = Math.abs(speedMetresPerSecond) * 3.6\n const band = surface.aquaplaningBand ?? 30\n const t = Math.max(0, Math.min(1, (kmh - surface.aquaplaningKmh) / band))\n return surface.grip * (1 - (1 - (surface.aquaplaningFactor ?? 0.4)) * t)\n}\n","import type { Driver } from '../types'\nimport { fuelRate, defaultFuelModel } from './fuel'\nimport type { FuelModel } from './fuel'\nimport { createSurfaces, surfaceGrip } from './surfaces'\nimport type { SurfaceRegistry } from './surfaces'\nimport type { SemiEvent, SemiInput, SemiSimSpec, SemiState, SemiWorld, VehicleCondition } from './types'\n\nconst G = 9.81\nconst clamp = (x: number, a: number, b: number): number => Math.max(a, Math.min(b, x))\nconst MAX_SUBSTEP = 1 / 240\nconst FLAT_BELOW = 0.2\nconst STALL_HEALTH = 0.3\n\n/** A mid-size passenger car, so a game can start from numbers that drive like a car. Every field can be replaced. */\nexport const defaultSemiSpec: SemiSimSpec = {\n mass: 1300,\n centreHeight: 0.5,\n wheels: [\n { x: 1.3, z: -0.75, steer: true },\n { x: 1.3, z: 0.75, steer: true },\n { x: -1.3, z: -0.75, drive: true, handbrake: true },\n { x: -1.3, z: 0.75, drive: true, handbrake: true },\n ],\n wheelRadius: 0.32,\n fourWheelDrive: false,\n drag: 0.4,\n rolling: 0.015,\n bogResistance: 0.3,\n maxSteer: 0.55,\n brakeForce: 14000,\n engine: { idleRpm: 900, peakRpm: 4200, redlineRpm: 6500, peakTorque: 220 },\n gears: [3.6, 2.2, 1.5, 1.1, 0.85],\n reverseRatio: 3.4,\n finalDrive: 3.7,\n efficiency: 0.85,\n upshiftFraction: 0.85,\n downshiftFraction: 0.35,\n suspension: { restLength: 0.4, spring: 32000, damper: 3200 },\n tyre: { friction: 1.05, corneringStiffness: 9 },\n tank: { capacity: 45 },\n}\n\nexport interface SemiSimOptions {\n surfaces?: SurfaceRegistry\n}\n\nexport interface SemiDriver extends Omit<Driver, 'step' | 'state'> {\n readonly spec: SemiSimSpec\n readonly state: SemiState\n condition: VehicleCondition\n /** Sets the load carried, kilograms; the mass, the wheel loads and the fuel use follow. */\n setCargo(kg: number): void\n /** A surface that overrides the ground under the wheels for `seconds` (blood after a run-over). */\n contaminate(surfaceId: string, seconds: number): void\n /** Fills the tank (litres). */\n refuel(litres: number): void\n step(input: SemiInput, dt: number, world?: SemiWorld): { state: SemiState; events: SemiEvent[] }\n}\n\nconst torqueAt = (spec: SemiSimSpec, rpm: number): number => {\n const e = spec.engine\n if (rpm >= e.redlineRpm) return 0\n const t = rpm <= e.peakRpm ? 0.7 + 0.3 * clamp((rpm - e.idleRpm) / (e.peakRpm - e.idleRpm), 0, 1) : 1 - 0.4 * ((rpm - e.peakRpm) / (e.redlineRpm - e.peakRpm))\n return e.peakTorque * t\n}\n\n/**\n * The semi-simulation driver (Frota document, section 03): a single rigid body with a low centre of mass, a spring and damper on each wheel\n * measured against the ground under it, separate longitudinal and lateral tyre forces inside a friction circle, an automatic gearbox with a\n * simulated RPM, a handbrake that locks the rear wheels, surfaces with their grip, and damage that changes how it drives. No team code exists\n * for it: the numbers are the document's and the tests check them.\n */\nexport function createSemiSimDriver(specification: Partial<SemiSimSpec> = {}, options: SemiSimOptions = {}): SemiDriver {\n const spec: SemiSimSpec = { ...defaultSemiSpec, ...specification }\n const surfaces = options.surfaces ?? createSurfaces()\n const fuelModel: FuelModel = { ...defaultFuelModel, ...spec.tank }\n const wheelbase = Math.max(...spec.wheels.map((w) => w.x)) - Math.min(...spec.wheels.map((w) => w.x))\n const track = Math.max(...spec.wheels.map((w) => w.z)) - Math.min(...spec.wheels.map((w) => w.z))\n const baseMass = spec.mass\n let cargo = 0\n let contamination: { id: string; left: number } | null = null\n const n = spec.wheels.length\n const state: SemiState = {\n x: 0,\n z: 0,\n yaw: 0,\n u: 0,\n w: 0,\n yawRate: 0,\n height: spec.suspension.restLength + spec.wheelRadius - (spec.mass * G) / (specification.wheels ? specification.wheels.length : defaultSemiSpec.wheels.length) / spec.suspension.spring,\n heightRate: 0,\n pitch: 0,\n pitchRate: 0,\n roll: 0,\n rollRate: 0,\n rpm: spec.engine.idleRpm,\n gear: 1,\n engineOn: true,\n bogged: false,\n fuel: spec.tank.capacity,\n loads: new Array<number>(n).fill((baseMass * G) / n),\n slip: new Array<number>(n).fill(0),\n steerAngle: 0,\n }\n const driver: SemiDriver = {\n spec,\n state,\n condition: {},\n setCargo(kg) {\n cargo = Math.max(0, kg)\n },\n contaminate(surfaceId, seconds) {\n contamination = { id: surfaceId, left: seconds }\n },\n refuel(litres) {\n state.fuel = Math.min(spec.tank.capacity, state.fuel + litres)\n },\n place(x, z, yaw) {\n state.x = x\n state.z = z\n state.yaw = yaw\n state.u = state.w = state.yawRate = 0\n },\n step(input, dt, world = {}) {\n const events: SemiEvent[] = []\n const steps = Math.max(1, Math.ceil(dt / MAX_SUBSTEP))\n const h = dt / steps\n for (let s = 0; s < steps; s++) substep(input, h, world, events)\n return { state, events }\n },\n }\n\n const stallWatch = { throttleTime: 0 }\n\n function substep(input: SemiInput, dt: number, world: SemiWorld, events: SemiEvent[]): void {\n const cond = driver.condition\n const mass = baseMass + cargo\n const engineHealth = clamp(cond.engine ?? 1, 0, 1)\n const suspensionHealth = clamp(cond.suspension ?? 1, 0, 1)\n const inertia = (spec.yawInertia ?? (baseMass * (wheelbase * wheelbase + track * track)) / 12) * (mass / baseMass)\n const pitchInertia = (mass * (wheelbase * wheelbase + 1)) / 12\n const rollInertia = (mass * (track * track + 1)) / 12\n if (contamination) {\n contamination.left -= dt\n if (contamination.left <= 0) contamination = null\n }\n const cs = Math.cos(state.yaw)\n const sn = Math.sin(state.yaw)\n // steering: the front wheels follow the input, slower when fast (the wheel angle limit shrinks a little)\n const target = clamp(input.steer, -1, 1) * spec.maxSteer\n state.steerAngle += (target - state.steerAngle) * Math.min(1, dt * 8)\n // gearbox: automatic by default. From a stop, the brake reverses and the throttle goes forward again.\n let gear = state.gear\n if (input.gear != null) gear = input.gear\n else if (state.u < 0.5 && input.brake > 0 && input.throttle <= 0) gear = -1\n else if (gear <= 0 && !(gear === -1 && state.u < 0.5 && input.throttle <= 0)) gear = 1\n const ratioOf = (g: number): number => (g < 0 ? spec.reverseRatio : g === 0 ? 0 : (spec.gears[g - 1] as number))\n const driveWheels = spec.wheels.map((w, i) => ({ w, i })).filter(({ w }) => (spec.fourWheelDrive ? true : w.drive))\n // the wheel speed that drives the engine: mean longitudinal speed of the driven wheels / radius\n let driveSpeed = 0\n for (const { w } of driveWheels) driveSpeed += state.u + state.yawRate * w.z\n driveSpeed /= Math.max(1, driveWheels.length)\n const wheelRpm = (Math.abs(driveSpeed) / spec.wheelRadius) * (60 / (2 * Math.PI))\n if (input.gear == null && gear > 0) {\n if (wheelRpm * ratioOf(gear) * spec.finalDrive > spec.engine.redlineRpm * spec.upshiftFraction && gear < spec.gears.length) gear++\n else if (gear > 1 && wheelRpm * ratioOf(gear) * spec.finalDrive < spec.engine.redlineRpm * spec.downshiftFraction) gear--\n }\n if (gear !== state.gear) {\n state.gear = gear\n events.push({ type: 'shift', gear })\n }\n const reversing = state.gear < 0\n const accelInput = reversing ? input.brake : input.throttle\n const brakeInput = clamp(reversing ? input.throttle : input.brake, 0, 1)\n const ratio = ratioOf(state.gear) * spec.finalDrive\n state.rpm = clamp(wheelRpm * ratio, spec.engine.idleRpm, spec.engine.redlineRpm + 200)\n if (!state.engineOn || state.fuel <= 0) state.rpm = 0\n // engine torque: a wrecked engine fails to deliver at full throttle, and under 30% it can die uphill\n let throttle = clamp(accelInput, 0, 1)\n if (engineHealth < STALL_HEALTH && throttle > 0.8) {\n stallWatch.throttleTime += dt\n if (state.pitch > 0.06 && stallWatch.throttleTime > 0.5 && state.engineOn) {\n state.engineOn = false\n events.push({ type: 'stall' })\n }\n throttle *= 0.5\n } else stallWatch.throttleTime = 0\n if (!state.engineOn && accelInput > 0 && Math.abs(state.u) < 0.5 && engineHealth >= STALL_HEALTH && state.fuel > 0) state.engineOn = true\n if (state.fuel <= 0 && state.engineOn) {\n state.engineOn = false\n events.push({ type: 'out_of_fuel' })\n }\n const engineTorque = state.engineOn ? torqueAt(spec, state.rpm) * throttle * (0.55 + 0.45 * engineHealth) : 0\n const driveForceTotal = state.gear === 0 ? 0 : ((reversing ? -1 : 1) * engineTorque * ratio * spec.efficiency) / spec.wheelRadius\n // fuel\n if (state.engineOn) {\n const burned = fuelRate(fuelModel, state.rpm / spec.engine.redlineRpm, throttle, cargo / baseMass, engineHealth) * dt\n state.fuel = Math.max(0, state.fuel - burned)\n }\n\n // ground under each wheel, and the suspension\n const ride = spec.suspension.restLength\n let sumN = 0\n let sumNA = 0\n let sumNB = 0\n const wheelState: Array<{ n: number; grip: number; surface: string; bog: boolean; flat: boolean }> = []\n let bogging = false\n for (let i = 0; i < n; i++) {\n const w = spec.wheels[i] as (typeof spec.wheels)[number]\n const wx = state.x + w.x * cs + w.z * sn\n const wz = state.z - w.x * sn + w.z * cs\n const ground = world.ground ? world.ground(wx, wz) : {}\n const surface = surfaces.get(contamination && contamination.left > 0 && surfaces.get(contamination.id).grip < surfaces.get(ground.surface ?? 'dry_asphalt').grip ? contamination.id : (ground.surface ?? 'dry_asphalt'))\n const top = state.height + w.x * state.pitch - w.z * state.roll\n const length = top - (ground.height ?? 0) - spec.wheelRadius\n const compression = ride - length\n const rate = -(state.heightRate + w.x * state.pitchRate - w.z * state.rollRate)\n const k = spec.suspension.spring * (0.5 + 0.5 * suspensionHealth)\n const c = spec.suspension.damper * (0.5 + 0.5 * suspensionHealth)\n const load = Math.max(0, k * compression + c * rate)\n const tyreHealth = cond.tyres ? clamp(cond.tyres[i] ?? 1, 0, 1) : 1\n const flat = tyreHealth < FLAT_BELOW\n const bog = !!surface.bogs && !spec.fourWheelDrive\n if (bog) bogging = true\n const grip = surfaceGrip(surface, state.u) * (flat ? 0.5 : 1)\n wheelState.push({ n: load, grip, surface: surface.id, bog, flat })\n sumN += load\n sumNA += load * w.x\n sumNB += load * w.z\n }\n // forces on the ground, per wheel\n let fFwd = 0\n let fRight = 0\n let torque = 0\n let pitchTorque = 0\n let rollTorque = 0\n const handbrake = !!input.handbrake\n const loadSum = Math.max(1, wheelState.reduce((a, b) => a + b.n, 0))\n const drivenCount = Math.max(1, driveWheels.length)\n for (let i = 0; i < n; i++) {\n const w = spec.wheels[i] as (typeof spec.wheels)[number]\n const ws = wheelState[i] as (typeof wheelState)[number]\n const delta = w.steer ? state.steerAngle : 0\n const cd = Math.cos(delta)\n const sd = Math.sin(delta)\n const vxw = state.u + state.yawRate * w.z\n const vyw = state.w - state.yawRate * w.x\n const vl = vxw * cd - vyw * sd\n const vc = vxw * sd + vyw * cd\n const capacity = spec.tyre.friction * ws.grip * ws.n\n // longitudinal request: drive, brake, rolling and handbrake lock\n let fl = 0\n if (driveWheels.some((d) => d.i === i)) fl += driveForceTotal / drivenCount\n const locked = handbrake && w.handbrake\n const brakeShare = (spec.brakeForce * brakeInput * ws.n) / loadSum\n const moving = Math.abs(vl) > 0.05\n if (locked) fl += moving ? -Math.sign(vl) * capacity : 0\n else if (brakeShare > 0 && moving) fl += -Math.sign(vl) * brakeShare\n const rolling = ws.bog && accelInput > 0 ? spec.bogResistance : spec.rolling * (ws.flat ? 6 : 1)\n // lateral: slip angle into a saturating curve, separate from the longitudinal one\n const alpha = Math.atan2(vc, Math.abs(vl) + 0.5)\n let fc = -Math.tanh((spec.tyre.corneringStiffness * alpha)) * capacity\n if (locked) fc *= 0.6\n // friction circle\n const total = Math.hypot(fl, fc)\n let used = 1\n if (total > capacity && total > 0) used = capacity / total\n fl *= used\n fc *= used\n // rolling resistance is the ground's, not the tyre's: it is not limited by the friction circle\n if (moving) fl += -Math.sign(vl) * rolling * ws.n\n const slipRatio = capacity > 0 ? clamp(total / capacity, 0, 1) : 0\n state.slip[i] = slipRatio\n if (ws.surface === 'wet_asphalt' && Math.abs(state.u) * 3.6 > (surfaces.get(ws.surface).aquaplaningKmh ?? 1e9) && slipRatio >= 0.99) events.push({ type: 'aquaplaning', wheel: i })\n // to the body\n const fx = fl * cd + fc * sd\n const fy = -fl * sd + fc * cd\n fFwd += fx\n fRight += fy\n torque += w.z * fx - w.x * fy\n pitchTorque += fx * spec.centreHeight\n rollTorque += -fy * spec.centreHeight\n }\n const bogNow = bogging && Math.abs(state.u) < 1 && accelInput > 0\n if (bogNow && !state.bogged) events.push({ type: 'bogged' })\n state.bogged = bogNow\n // air drag\n fFwd += -spec.drag * state.u * Math.abs(state.u)\n fRight += -spec.drag * state.w * Math.abs(state.w)\n // planar integration in the body frame\n const du = fFwd / mass - state.yawRate * state.w\n const dw = fRight / mass + state.yawRate * state.u\n state.u += du * dt\n state.w += dw * dt\n // a stopped vehicle does not creep: the sticking friction holds it\n if (Math.abs(state.u) < 0.02 && accelInput <= 0) state.u = 0\n state.yawRate += (torque / inertia) * dt\n state.yaw += state.yawRate * dt\n const csn = Math.cos(state.yaw)\n const snn = Math.sin(state.yaw)\n state.x += (state.u * csn + state.w * snn) * dt\n state.z += (-state.u * snn + state.w * csn) * dt\n // vertical, pitch and roll from the wheel loads\n const ay = (sumN - mass * G) / mass\n state.heightRate += ay * dt\n state.height += state.heightRate * dt\n state.pitchRate += ((sumNA + pitchTorque) / pitchInertia) * dt\n state.pitch += state.pitchRate * dt\n state.rollRate += ((-sumNB + rollTorque) / rollInertia) * dt\n state.roll += state.rollRate * dt\n for (let i = 0; i < n; i++) state.loads[i] = (wheelState[i] as (typeof wheelState)[number]).n\n }\n\n return driver\n}\n","/** Kinetic energy of a vehicle against a body: `0.5 * mass * speed²` (joules, with kg and m/s). Frota document, section 08. */\nexport const impactEnergy = (vehicleMass: number, relativeSpeed: number): number => 0.5 * vehicleMass * relativeSpeed * relativeSpeed\n\n/** Damage to the body that was hit: `energy * bumperMultiplier * zoneFactor`. The zone factors (head, torso, legs) belong to the game. */\nexport const bodyDamage = (energy: number, bumperMultiplier: number, zoneFactor: number): number => energy * bumperMultiplier * zoneFactor\n\n/** Damage to the vehicle: `energy * 0.04 * (1 - frontArmor)`, with the armour from 0 to 1. */\nexport const vehicleDamageFromImpact = (energy: number, frontArmor: number, factor = 0.04): number => energy * factor * (1 - frontArmor)\n\n/** Speed the vehicle loses: `bodyMass / (vehicleMass + bodyMass) * speed`. */\nexport const speedLoss = (bodyMass: number, vehicleMass: number, speed: number): number => (bodyMass / (vehicleMass + bodyMass)) * speed\n","import { defaultSemiSpec } from './driver'\nimport type { SemiSimSpec } from './types'\n\n/** The design ratings of a vehicle (the shape of `fleetStats` in `game-vehicles-fleet`; any object with these fields works). */\nexport interface DesignStats {\n /** 0-10. */\n acceleration: number\n /** 0-10. */\n handling: number\n maxSpeedKmh: number\n}\n\nexport interface BodyDimensions {\n mass: number\n wheelbase: number\n track: number\n fourWheelDrive?: boolean\n}\n\n/**\n * A semi-simulation spec for a two-axle vehicle from its design ratings: the grip follows the handling, the torque follows the acceleration,\n * and the air drag is set so that the top gear tops out at the design top speed.\n */\nexport function semiSpecFromStats(stats: DesignStats, body: BodyDimensions, base: Partial<SemiSimSpec> = {}): SemiSimSpec {\n const spec: SemiSimSpec = { ...defaultSemiSpec, ...base }\n const half = body.wheelbase / 2\n const side = body.track / 2\n const driveAll = !!body.fourWheelDrive\n const wheels = [\n { x: half, z: -side, steer: true, drive: driveAll },\n { x: half, z: side, steer: true, drive: driveAll },\n { x: -half, z: -side, drive: true, handbrake: true },\n { x: -half, z: side, drive: true, handbrake: true },\n ]\n const peakTorque = body.mass * (0.06 + 0.015 * stats.acceleration)\n const engine = { ...spec.engine, peakTorque }\n const top = stats.maxSpeedKmh / 3.6\n // the last gear puts the redline at the top speed\n const lastRatio = (engine.redlineRpm * 0.95 * 2 * Math.PI * spec.wheelRadius) / (60 * top * spec.finalDrive)\n const gears = [...spec.gears.slice(0, -1).map((g, i, all) => Math.max(g, lastRatio * (1.25 ** (all.length - i)))), lastRatio]\n const torqueAtTop = peakTorque * 0.76\n const thrust = (torqueAtTop * lastRatio * spec.finalDrive * spec.efficiency) / spec.wheelRadius\n const rollingForce = spec.rolling * body.mass * 9.81\n const drag = Math.max(0.01, (thrust - rollingForce) / (top * top))\n return { ...spec, mass: body.mass, wheels, fourWheelDrive: driveAll, engine, gears, drag, tyre: { ...spec.tyre, friction: 0.8 + 0.03 * stats.handling } }\n}\n"],"mappings":"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","names":["sp","clamp"]}
|