@its-not-rocket-science/ananke 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Phase 68 — Multi-Biome Physics
3
+ *
4
+ * Defines BiomeContext: a set of optional physics overrides that alter gravity,
5
+ * thermal conduction, sound propagation, and drag relative to the standard
6
+ * Earth-surface defaults. Pass a BiomeContext via KernelContext.biome to apply
7
+ * these overrides to every entity in the simulation.
8
+ *
9
+ * Three built-in profiles cover the most common non-standard environments:
10
+ * BIOME_UNDERWATER — buoyancy-reduced gravity, severe drag, rapid heat exchange
11
+ * BIOME_LUNAR — 1/6 g, vacuum, radiation-only heat transfer
12
+ * BIOME_VACUUM — microgravity, vacuum, extreme thermal isolation
13
+ */
14
+ import { type Q, type I32 } from "../units.js";
15
+ export interface BiomeContext {
16
+ /**
17
+ * Gravitational acceleration in SCALE.mps2 units.
18
+ * Default (when absent): G_mps2 ≈ 98 067 (9.807 m/s²).
19
+ * Affects jump height and traction-limited acceleration.
20
+ */
21
+ gravity_mps2?: I32;
22
+ /**
23
+ * Base skin-layer thermal resistance (°C/W).
24
+ * Replaces the default 0.09 (still-air skin layer).
25
+ * Lower values = faster heat exchange (e.g. water ≈ 0.003).
26
+ * Higher values = slower exchange (e.g. vacuum ≈ 50 — radiation only).
27
+ */
28
+ thermalResistanceBase?: number;
29
+ /**
30
+ * Sound propagation multiplier stored as a raw Q multiple.
31
+ * q(1.0) = normal air propagation; q(0.0) = no sound (vacuum);
32
+ * q(4.0) = four-times-faster (water).
33
+ * Used to scale auditory sensory range.
34
+ */
35
+ soundPropagation?: Q;
36
+ /**
37
+ * Velocity drag factor applied to entity velocity each tick (Q, [0..SCALE.Q]).
38
+ * q(1.0) = no drag (default); q(0.3) = retain 30% velocity each tick (heavy drag).
39
+ * Applied by the movement step when non-default.
40
+ */
41
+ dragMul?: Q;
42
+ /**
43
+ * When true, entities without pressurised equipment cannot breathe.
44
+ * Unequipped entities accumulate fatigue at a fixed rate each tick.
45
+ */
46
+ isVacuum?: boolean;
47
+ }
48
+ /**
49
+ * Deep ocean / underwater environment.
50
+ * Net downward acceleration ≈ 1 m/s² (buoyancy cancels most gravity).
51
+ * Water conducts heat ~25× faster than still air.
52
+ * Sound travels ~4× faster in water than in air.
53
+ * Severe hydrodynamic drag (30% velocity retention per tick).
54
+ */
55
+ export declare const BIOME_UNDERWATER: BiomeContext;
56
+ /**
57
+ * Lunar surface — 1/6 Earth gravity, vacuum atmosphere.
58
+ * Heat transfer only via radiation → very high thermal resistance.
59
+ * No air, no sound. Entities without pressurised suits suffer vacuum fatigue.
60
+ */
61
+ export declare const BIOME_LUNAR: BiomeContext;
62
+ /**
63
+ * Microgravity / open space environment.
64
+ * Near-zero gravity, no atmosphere, extreme thermal isolation.
65
+ * Entities without pressurised suits suffer vacuum fatigue.
66
+ */
67
+ export declare const BIOME_VACUUM: BiomeContext;
68
+ /** Effective gravitational acceleration for this biome (falls back to standard G). */
69
+ export declare function biomeGravity(biome?: BiomeContext): I32;
70
+ /** Effective base thermal resistance for this biome (falls back to still-air default 0.09 °C/W). */
71
+ export declare function biomeThermalResistanceBase(biome?: BiomeContext): number;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Phase 68 — Multi-Biome Physics
3
+ *
4
+ * Defines BiomeContext: a set of optional physics overrides that alter gravity,
5
+ * thermal conduction, sound propagation, and drag relative to the standard
6
+ * Earth-surface defaults. Pass a BiomeContext via KernelContext.biome to apply
7
+ * these overrides to every entity in the simulation.
8
+ *
9
+ * Three built-in profiles cover the most common non-standard environments:
10
+ * BIOME_UNDERWATER — buoyancy-reduced gravity, severe drag, rapid heat exchange
11
+ * BIOME_LUNAR — 1/6 g, vacuum, radiation-only heat transfer
12
+ * BIOME_VACUUM — microgravity, vacuum, extreme thermal isolation
13
+ */
14
+ import { G_mps2, q, SCALE } from "../units.js";
15
+ // ── Built-in profiles ─────────────────────────────────────────────────────────
16
+ /**
17
+ * Deep ocean / underwater environment.
18
+ * Net downward acceleration ≈ 1 m/s² (buoyancy cancels most gravity).
19
+ * Water conducts heat ~25× faster than still air.
20
+ * Sound travels ~4× faster in water than in air.
21
+ * Severe hydrodynamic drag (30% velocity retention per tick).
22
+ */
23
+ export const BIOME_UNDERWATER = {
24
+ gravity_mps2: Math.round(1.0 * SCALE.mps2),
25
+ thermalResistanceBase: 0.003,
26
+ soundPropagation: q(4.0),
27
+ dragMul: q(0.30),
28
+ };
29
+ /**
30
+ * Lunar surface — 1/6 Earth gravity, vacuum atmosphere.
31
+ * Heat transfer only via radiation → very high thermal resistance.
32
+ * No air, no sound. Entities without pressurised suits suffer vacuum fatigue.
33
+ */
34
+ export const BIOME_LUNAR = {
35
+ gravity_mps2: Math.round(1.62 * SCALE.mps2),
36
+ thermalResistanceBase: 50.0,
37
+ soundPropagation: q(0),
38
+ dragMul: SCALE.Q, // no air resistance
39
+ isVacuum: true,
40
+ };
41
+ /**
42
+ * Microgravity / open space environment.
43
+ * Near-zero gravity, no atmosphere, extreme thermal isolation.
44
+ * Entities without pressurised suits suffer vacuum fatigue.
45
+ */
46
+ export const BIOME_VACUUM = {
47
+ gravity_mps2: 0,
48
+ thermalResistanceBase: 100.0,
49
+ soundPropagation: q(0),
50
+ dragMul: SCALE.Q,
51
+ isVacuum: true,
52
+ };
53
+ // ── Accessor helpers ──────────────────────────────────────────────────────────
54
+ /** Effective gravitational acceleration for this biome (falls back to standard G). */
55
+ export function biomeGravity(biome) {
56
+ return (biome?.gravity_mps2 ?? G_mps2);
57
+ }
58
+ /** Effective base thermal resistance for this biome (falls back to still-air default 0.09 °C/W). */
59
+ export function biomeThermalResistanceBase(biome) {
60
+ return biome?.thermalResistanceBase ?? 0.09;
61
+ }
@@ -7,6 +7,7 @@ import type { ObstacleGrid, ElevationGrid, SlopeGrid, HazardGrid } from "./terra
7
7
  import type { TechContext } from "./tech.js";
8
8
  import type { WeatherState } from "./weather.js";
9
9
  import type { Q, I32 } from "../units.js";
10
+ import type { BiomeContext } from "./biome.js";
10
11
  export interface KernelContext {
11
12
  tractionCoeff: Q;
12
13
  tuning?: SimulationTuning;
@@ -65,4 +66,11 @@ export interface KernelContext {
65
66
  * When absent, weather has no effect (backward-compatible).
66
67
  */
67
68
  weather?: WeatherState;
69
+ /**
70
+ * Phase 68: biome physics overrides.
71
+ * When present, adjusts gravity (jump height, traction), thermal resistance,
72
+ * sound propagation, and velocity drag for all entities this tick.
73
+ * When absent, standard Earth-surface physics apply.
74
+ */
75
+ biome?: BiomeContext;
68
76
  }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * CE-15 — Dynamic Terrain + Cover System
3
+ *
4
+ * Structural cover segments that reduce incoming damage and deform under explosions.
5
+ * Complements Phase 60 (Environmental Hazard Zones), which handles area-effect
6
+ * environmental damage; this module handles solid cover and line-of-sight blocking.
7
+ *
8
+ * ## Cover model
9
+ * Each `CoverSegment` is an axis-aligned horizontal obstacle (wall/barricade).
10
+ * LOS and protection are computed in 2-D (x/y world plane); `height_Sm` is used
11
+ * for above-cover arc checks (grenade lobs, indirect fire).
12
+ *
13
+ * All coordinates are in SCALE.m units (1 unit = 0.1 mm; 10 000 units = 1 m).
14
+ *
15
+ * ## Deformation model
16
+ * Explosions reduce `height_Sm` and may ignite wood segments.
17
+ * `stepCoverDecay` advances burn-out and crater erosion over real-time seconds.
18
+ *
19
+ * Integration with Phase 60: callers may convert an ignited segment into a
20
+ * `HazardZone` of type "fire" centred on the segment midpoint.
21
+ */
22
+ import { type Q } from "../units.js";
23
+ /** Cover material — determines energy absorption and deformation behaviour. */
24
+ export type CoverMaterial = "dirt" | "stone" | "wood" | "sandbag";
25
+ /**
26
+ * Energy absorption fraction per material [Q].
27
+ * Applied to incoming projectile / blast energy before it reaches the target.
28
+ */
29
+ export declare const MATERIAL_ABSORPTION: Record<CoverMaterial, Q>;
30
+ /**
31
+ * Explosion energy threshold [J] above which wood ignites.
32
+ * A grenade at close range (~30 J) reliably sets wood alight.
33
+ */
34
+ export declare const WOOD_IGNITION_THRESHOLD_J = 30;
35
+ /**
36
+ * Height lost per joule of explosion energy at the segment [SCALE.m / J].
37
+ * Calibrated so a 1 000 J blast (artillery shell) craters 1 m of height
38
+ * (10 000 SCALE.m) — i.e. rate = 10 SCALE.m / J.
39
+ */
40
+ export declare const CRATER_RATE_Sm_PER_J = 10;
41
+ /**
42
+ * Natural crater erosion rate [SCALE.m / s].
43
+ * Rain and loose earth refill a small crater in hours.
44
+ * At 1 SCALE.m/s a 1 m (10 000 Sm) crater refills in ~10 000 s (~2.8 h).
45
+ */
46
+ export declare const CRATER_EROSION_RATE_Sm_PER_S = 1;
47
+ /**
48
+ * Wood burn-out rate [SCALE.m of height consumed per second while burning].
49
+ * A 2 m tall (20 000 Sm) wooden wall burns down in ~200 s (~3 min).
50
+ */
51
+ export declare const WOOD_BURN_RATE_Sm_PER_S = 100;
52
+ /**
53
+ * An axis-aligned rectangular cover segment in world-space (2-D top-down view).
54
+ *
55
+ * The segment occupies the line from `(x_Sm, y_Sm)` to `(x_Sm + length_Sm, y_Sm)`.
56
+ * For LOS purposes it is treated as an infinitely thin vertical wall of the given
57
+ * length; `height_Sm` is used only for above-cover arc checks.
58
+ */
59
+ export interface CoverSegment {
60
+ id: string;
61
+ /** Left end x-coordinate [SCALE.m]. */
62
+ x_Sm: number;
63
+ /** Left end y-coordinate (constant along the segment) [SCALE.m]. */
64
+ y_Sm: number;
65
+ /** Segment length along the x-axis [SCALE.m]. */
66
+ length_Sm: number;
67
+ /** Current cover height [SCALE.m]. Reduced by explosions. */
68
+ height_Sm: number;
69
+ /** Original (undamaged) height [SCALE.m]. Used as crater refill ceiling. */
70
+ originalHeight_Sm: number;
71
+ material: CoverMaterial;
72
+ /** True when the segment is actively on fire (wood only). */
73
+ burning: boolean;
74
+ }
75
+ /** Point in the 2-D world plane used for LOS / protection queries. */
76
+ export interface WorldPoint2D {
77
+ x_Sm: number;
78
+ y_Sm: number;
79
+ }
80
+ /** Result of `applyExplosionToTerrain`. */
81
+ export interface CoverExplosionResult {
82
+ /** Ids of segments that lost height (craters formed). */
83
+ cratered: string[];
84
+ /** Ids of segments that ignited (wood only). */
85
+ ignited: string[];
86
+ }
87
+ /**
88
+ * Create a `CoverSegment` with `originalHeight_Sm` initialised from `height_Sm`.
89
+ */
90
+ export declare function createCoverSegment(id: string, x_Sm: number, y_Sm: number, length_Sm: number, height_Sm: number, material: CoverMaterial): CoverSegment;
91
+ /** 3 m stone wall (30 000 Sm long, 1.5 m tall). */
92
+ export declare const COVER_STONE_WALL: CoverSegment;
93
+ /** 2 m sandbag barricade (20 000 Sm long, 1.0 m tall). */
94
+ export declare const COVER_SANDBAG_BARRICADE: CoverSegment;
95
+ /** 4 m wooden palisade (40 000 Sm long, 2.0 m tall). */
96
+ export declare const COVER_WOODEN_PALISADE: CoverSegment;
97
+ /** 5 m dirt berm (50 000 Sm long, 1.2 m tall). */
98
+ export declare const COVER_DIRT_BERM: CoverSegment;
99
+ /**
100
+ * Test whether at least one non-destroyed cover segment crosses the LOS
101
+ * line from `from` to `to`.
102
+ *
103
+ * Segments with `height_Sm ≤ 0` are considered destroyed and ignored.
104
+ *
105
+ * Pure integer arithmetic — no `Math.sqrt`.
106
+ *
107
+ * @param from Attacker position.
108
+ * @param to Target position.
109
+ * @param segments Cover segments to test.
110
+ * @returns `true` if LOS is blocked by at least one segment.
111
+ */
112
+ export declare function isLineOfSightBlocked(from: WorldPoint2D, to: WorldPoint2D, segments: readonly CoverSegment[]): boolean;
113
+ /**
114
+ * Compute the aggregate cover protection factor [Q] for a shot from `attacker`
115
+ * to `target`.
116
+ *
117
+ * For each cover segment that:
118
+ * 1. Intersects the LOS line.
119
+ * 2. Has `height_Sm > 0`.
120
+ *
121
+ * the material absorption fraction is composed multiplicatively:
122
+ *
123
+ * passthrough = ∏ (1 − absorptionᵢ)
124
+ * protection = 1 − passthrough
125
+ *
126
+ * Returns Q ∈ [0, SCALE.Q]:
127
+ * 0 = no cover (clear LOS)
128
+ * SCALE.Q = complete protection (unreachable for finite stacked cover)
129
+ */
130
+ export declare function computeCoverProtection(attacker: WorldPoint2D, target: WorldPoint2D, segments: readonly CoverSegment[]): Q;
131
+ /**
132
+ * Test whether arc fire (lob / indirect trajectory) at `elevation_Sm` clears all
133
+ * cover segments on the LOS from `attacker` to `target`.
134
+ *
135
+ * A segment blocks the arc only if `elevation_Sm < seg.height_Sm`. Destroyed
136
+ * segments (height ≤ 0) are ignored.
137
+ *
138
+ * @param attacker Attacker position.
139
+ * @param target Target position.
140
+ * @param elevation_Sm Arc peak height above ground [SCALE.m].
141
+ * @param segments Segments to test.
142
+ * @returns `true` if the arc passes over all cover.
143
+ */
144
+ export declare function arcClearsCover(attacker: WorldPoint2D, target: WorldPoint2D, elevation_Sm: number, segments: readonly CoverSegment[]): boolean;
145
+ /**
146
+ * Apply a point-source explosion to nearby cover segments.
147
+ *
148
+ * For each segment whose midpoint falls within `blastRadius_Sm` of `(cx, cy)`:
149
+ * - Height is reduced proportional to proximity and energy.
150
+ * - Wood segments above `WOOD_IGNITION_THRESHOLD_J` (scaled energy) ignite.
151
+ *
152
+ * Proximity scaling uses a squared falloff approximation
153
+ * `proximityQ ≈ 1 − distSq / radiusSq` (integer, no sqrt).
154
+ *
155
+ * Mutates `segments` in-place.
156
+ *
157
+ * @param cx Blast centre x [SCALE.m].
158
+ * @param cy Blast centre y [SCALE.m].
159
+ * @param energy_J Total explosion energy [J].
160
+ * @param blastRadius_Sm Blast radius [SCALE.m].
161
+ * @param segments Segments to affect (mutated).
162
+ * @returns Summary of craters and ignitions.
163
+ */
164
+ export declare function applyExplosionToTerrain(cx: number, cy: number, energy_J: number, blastRadius_Sm: number, segments: CoverSegment[]): CoverExplosionResult;
165
+ /**
166
+ * Advance cover decay over `elapsedSeconds`:
167
+ *
168
+ * - **Burning wood**: height decreases at `WOOD_BURN_RATE_Sm_PER_S` per second.
169
+ * Burning stops when height reaches 0.
170
+ * - **Craters (non-burning)**: height erodes toward `originalHeight_Sm`
171
+ * at `CRATER_EROSION_RATE_Sm_PER_S` per second.
172
+ *
173
+ * Mutates `segments` in-place.
174
+ *
175
+ * @param segments Segments to advance.
176
+ * @param elapsedSeconds Elapsed real time in seconds.
177
+ */
178
+ export declare function stepCoverDecay(segments: CoverSegment[], elapsedSeconds: number): void;
179
+ /**
180
+ * Return the 2-D midpoint of a cover segment.
181
+ */
182
+ export declare function coverSegmentCentre(seg: CoverSegment): WorldPoint2D;
183
+ /**
184
+ * Return `true` if the segment has been completely destroyed (height reduced to 0).
185
+ */
186
+ export declare function isCoverDestroyed(seg: CoverSegment): boolean;
@@ -0,0 +1,290 @@
1
+ /**
2
+ * CE-15 — Dynamic Terrain + Cover System
3
+ *
4
+ * Structural cover segments that reduce incoming damage and deform under explosions.
5
+ * Complements Phase 60 (Environmental Hazard Zones), which handles area-effect
6
+ * environmental damage; this module handles solid cover and line-of-sight blocking.
7
+ *
8
+ * ## Cover model
9
+ * Each `CoverSegment` is an axis-aligned horizontal obstacle (wall/barricade).
10
+ * LOS and protection are computed in 2-D (x/y world plane); `height_Sm` is used
11
+ * for above-cover arc checks (grenade lobs, indirect fire).
12
+ *
13
+ * All coordinates are in SCALE.m units (1 unit = 0.1 mm; 10 000 units = 1 m).
14
+ *
15
+ * ## Deformation model
16
+ * Explosions reduce `height_Sm` and may ignite wood segments.
17
+ * `stepCoverDecay` advances burn-out and crater erosion over real-time seconds.
18
+ *
19
+ * Integration with Phase 60: callers may convert an ignited segment into a
20
+ * `HazardZone` of type "fire" centred on the segment midpoint.
21
+ */
22
+ import { q, SCALE, clampQ, mulDiv } from "../units.js";
23
+ /**
24
+ * Energy absorption fraction per material [Q].
25
+ * Applied to incoming projectile / blast energy before it reaches the target.
26
+ */
27
+ export const MATERIAL_ABSORPTION = {
28
+ stone: q(0.70), // dense masonry — absorbs 70% of incoming energy
29
+ sandbag: q(0.60), // packed granular — 60%
30
+ dirt: q(0.50), // loose earth — 50%
31
+ wood: q(0.35), // timber — 35% (burns easily)
32
+ };
33
+ /**
34
+ * Explosion energy threshold [J] above which wood ignites.
35
+ * A grenade at close range (~30 J) reliably sets wood alight.
36
+ */
37
+ export const WOOD_IGNITION_THRESHOLD_J = 30;
38
+ /**
39
+ * Height lost per joule of explosion energy at the segment [SCALE.m / J].
40
+ * Calibrated so a 1 000 J blast (artillery shell) craters 1 m of height
41
+ * (10 000 SCALE.m) — i.e. rate = 10 SCALE.m / J.
42
+ */
43
+ export const CRATER_RATE_Sm_PER_J = 10;
44
+ /**
45
+ * Natural crater erosion rate [SCALE.m / s].
46
+ * Rain and loose earth refill a small crater in hours.
47
+ * At 1 SCALE.m/s a 1 m (10 000 Sm) crater refills in ~10 000 s (~2.8 h).
48
+ */
49
+ export const CRATER_EROSION_RATE_Sm_PER_S = 1;
50
+ /**
51
+ * Wood burn-out rate [SCALE.m of height consumed per second while burning].
52
+ * A 2 m tall (20 000 Sm) wooden wall burns down in ~200 s (~3 min).
53
+ */
54
+ export const WOOD_BURN_RATE_Sm_PER_S = 100;
55
+ // ── Construction helpers ──────────────────────────────────────────────────────
56
+ /**
57
+ * Create a `CoverSegment` with `originalHeight_Sm` initialised from `height_Sm`.
58
+ */
59
+ export function createCoverSegment(id, x_Sm, y_Sm, length_Sm, height_Sm, material) {
60
+ return {
61
+ id,
62
+ x_Sm,
63
+ y_Sm,
64
+ length_Sm: Math.max(1, length_Sm),
65
+ height_Sm: Math.max(0, height_Sm),
66
+ originalHeight_Sm: Math.max(0, height_Sm),
67
+ material,
68
+ burning: false,
69
+ };
70
+ }
71
+ // ── Sample segments ───────────────────────────────────────────────────────────
72
+ /** 3 m stone wall (30 000 Sm long, 1.5 m tall). */
73
+ export const COVER_STONE_WALL = createCoverSegment("stone_wall", 0, 50_000, 30_000, 15_000, "stone");
74
+ /** 2 m sandbag barricade (20 000 Sm long, 1.0 m tall). */
75
+ export const COVER_SANDBAG_BARRICADE = createCoverSegment("sandbag_barricade", 0, 30_000, 20_000, 10_000, "sandbag");
76
+ /** 4 m wooden palisade (40 000 Sm long, 2.0 m tall). */
77
+ export const COVER_WOODEN_PALISADE = createCoverSegment("wooden_palisade", 0, 20_000, 40_000, 20_000, "wood");
78
+ /** 5 m dirt berm (50 000 Sm long, 1.2 m tall). */
79
+ export const COVER_DIRT_BERM = createCoverSegment("dirt_berm", 0, 40_000, 50_000, 12_000, "dirt");
80
+ // ── Geometry helpers (integer arithmetic only) ────────────────────────────────
81
+ /** Squared Euclidean distance between two points [SCALE.m²]. */
82
+ function distSq(ax, ay, bx, by) {
83
+ const dx = bx - ax;
84
+ const dy = by - ay;
85
+ return dx * dx + dy * dy;
86
+ }
87
+ /**
88
+ * Cross product of vectors (AB × AC) — positive = C is left of AB.
89
+ */
90
+ function cross2D(ax, ay, bx, by, cx, cy) {
91
+ return (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
92
+ }
93
+ /** Check whether point (px, py) lies on segment (ax, ay)→(bx, by). */
94
+ function onSegment2D(ax, ay, bx, by, px, py) {
95
+ return (Math.min(ax, bx) <= px && px <= Math.max(ax, bx) &&
96
+ Math.min(ay, by) <= py && py <= Math.max(ay, by));
97
+ }
98
+ /**
99
+ * Test whether line segment P1→P2 intersects segment P3→P4.
100
+ * Pure integer arithmetic — no division, no float.
101
+ */
102
+ function segmentsIntersect(p1x, p1y, p2x, p2y, p3x, p3y, p4x, p4y) {
103
+ const d1 = cross2D(p3x, p3y, p4x, p4y, p1x, p1y);
104
+ const d2 = cross2D(p3x, p3y, p4x, p4y, p2x, p2y);
105
+ const d3 = cross2D(p1x, p1y, p2x, p2y, p3x, p3y);
106
+ const d4 = cross2D(p1x, p1y, p2x, p2y, p4x, p4y);
107
+ if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
108
+ ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
109
+ return true;
110
+ }
111
+ if (d1 === 0 && onSegment2D(p3x, p3y, p4x, p4y, p1x, p1y))
112
+ return true;
113
+ if (d2 === 0 && onSegment2D(p3x, p3y, p4x, p4y, p2x, p2y))
114
+ return true;
115
+ if (d3 === 0 && onSegment2D(p1x, p1y, p2x, p2y, p3x, p3y))
116
+ return true;
117
+ if (d4 === 0 && onSegment2D(p1x, p1y, p2x, p2y, p4x, p4y))
118
+ return true;
119
+ return false;
120
+ }
121
+ // ── Public API ────────────────────────────────────────────────────────────────
122
+ /**
123
+ * Test whether at least one non-destroyed cover segment crosses the LOS
124
+ * line from `from` to `to`.
125
+ *
126
+ * Segments with `height_Sm ≤ 0` are considered destroyed and ignored.
127
+ *
128
+ * Pure integer arithmetic — no `Math.sqrt`.
129
+ *
130
+ * @param from Attacker position.
131
+ * @param to Target position.
132
+ * @param segments Cover segments to test.
133
+ * @returns `true` if LOS is blocked by at least one segment.
134
+ */
135
+ export function isLineOfSightBlocked(from, to, segments) {
136
+ for (const seg of segments) {
137
+ if (seg.height_Sm <= 0)
138
+ continue;
139
+ if (segmentsIntersect(from.x_Sm, from.y_Sm, to.x_Sm, to.y_Sm, seg.x_Sm, seg.y_Sm, seg.x_Sm + seg.length_Sm, seg.y_Sm)) {
140
+ return true;
141
+ }
142
+ }
143
+ return false;
144
+ }
145
+ /**
146
+ * Compute the aggregate cover protection factor [Q] for a shot from `attacker`
147
+ * to `target`.
148
+ *
149
+ * For each cover segment that:
150
+ * 1. Intersects the LOS line.
151
+ * 2. Has `height_Sm > 0`.
152
+ *
153
+ * the material absorption fraction is composed multiplicatively:
154
+ *
155
+ * passthrough = ∏ (1 − absorptionᵢ)
156
+ * protection = 1 − passthrough
157
+ *
158
+ * Returns Q ∈ [0, SCALE.Q]:
159
+ * 0 = no cover (clear LOS)
160
+ * SCALE.Q = complete protection (unreachable for finite stacked cover)
161
+ */
162
+ export function computeCoverProtection(attacker, target, segments) {
163
+ let passthrough = SCALE.Q; // q(1.0) — all energy passes through initially
164
+ for (const seg of segments) {
165
+ if (seg.height_Sm <= 0)
166
+ continue;
167
+ if (segmentsIntersect(attacker.x_Sm, attacker.y_Sm, target.x_Sm, target.y_Sm, seg.x_Sm, seg.y_Sm, seg.x_Sm + seg.length_Sm, seg.y_Sm)) {
168
+ const absorption = MATERIAL_ABSORPTION[seg.material];
169
+ passthrough = mulDiv(passthrough, SCALE.Q - absorption, SCALE.Q);
170
+ }
171
+ }
172
+ return clampQ((SCALE.Q - passthrough), 0, SCALE.Q);
173
+ }
174
+ /**
175
+ * Test whether arc fire (lob / indirect trajectory) at `elevation_Sm` clears all
176
+ * cover segments on the LOS from `attacker` to `target`.
177
+ *
178
+ * A segment blocks the arc only if `elevation_Sm < seg.height_Sm`. Destroyed
179
+ * segments (height ≤ 0) are ignored.
180
+ *
181
+ * @param attacker Attacker position.
182
+ * @param target Target position.
183
+ * @param elevation_Sm Arc peak height above ground [SCALE.m].
184
+ * @param segments Segments to test.
185
+ * @returns `true` if the arc passes over all cover.
186
+ */
187
+ export function arcClearsCover(attacker, target, elevation_Sm, segments) {
188
+ for (const seg of segments) {
189
+ if (seg.height_Sm <= 0)
190
+ continue;
191
+ if (segmentsIntersect(attacker.x_Sm, attacker.y_Sm, target.x_Sm, target.y_Sm, seg.x_Sm, seg.y_Sm, seg.x_Sm + seg.length_Sm, seg.y_Sm)) {
192
+ if (elevation_Sm < seg.height_Sm)
193
+ return false;
194
+ }
195
+ }
196
+ return true;
197
+ }
198
+ /**
199
+ * Apply a point-source explosion to nearby cover segments.
200
+ *
201
+ * For each segment whose midpoint falls within `blastRadius_Sm` of `(cx, cy)`:
202
+ * - Height is reduced proportional to proximity and energy.
203
+ * - Wood segments above `WOOD_IGNITION_THRESHOLD_J` (scaled energy) ignite.
204
+ *
205
+ * Proximity scaling uses a squared falloff approximation
206
+ * `proximityQ ≈ 1 − distSq / radiusSq` (integer, no sqrt).
207
+ *
208
+ * Mutates `segments` in-place.
209
+ *
210
+ * @param cx Blast centre x [SCALE.m].
211
+ * @param cy Blast centre y [SCALE.m].
212
+ * @param energy_J Total explosion energy [J].
213
+ * @param blastRadius_Sm Blast radius [SCALE.m].
214
+ * @param segments Segments to affect (mutated).
215
+ * @returns Summary of craters and ignitions.
216
+ */
217
+ export function applyExplosionToTerrain(cx, cy, energy_J, blastRadius_Sm, segments) {
218
+ const cratered = [];
219
+ const ignited = [];
220
+ const radiusSq = blastRadius_Sm * blastRadius_Sm;
221
+ for (const seg of segments) {
222
+ const midX = seg.x_Sm + Math.trunc(seg.length_Sm / 2);
223
+ const midY = seg.y_Sm;
224
+ const dSq = distSq(cx, cy, midX, midY);
225
+ if (dSq > radiusSq)
226
+ continue;
227
+ // proximityQ: q(1.0) at centre, q(0) at edge — squared distance falloff
228
+ const proximityQ = Math.max(0, Math.round(SCALE.Q - Math.round((dSq * SCALE.Q) / Math.max(1, radiusSq))));
229
+ const localEnergy_J = Math.round((energy_J * proximityQ) / SCALE.Q);
230
+ if (localEnergy_J <= 0)
231
+ continue;
232
+ // Crater: reduce height, min 0
233
+ const heightLoss = Math.min(seg.height_Sm, localEnergy_J * CRATER_RATE_Sm_PER_J);
234
+ if (heightLoss > 0) {
235
+ seg.height_Sm = Math.max(0, seg.height_Sm - heightLoss);
236
+ cratered.push(seg.id);
237
+ }
238
+ // Ignition: wood above threshold
239
+ if (seg.material === "wood" && !seg.burning && localEnergy_J >= WOOD_IGNITION_THRESHOLD_J) {
240
+ seg.burning = true;
241
+ ignited.push(seg.id);
242
+ }
243
+ }
244
+ return { cratered, ignited };
245
+ }
246
+ /**
247
+ * Advance cover decay over `elapsedSeconds`:
248
+ *
249
+ * - **Burning wood**: height decreases at `WOOD_BURN_RATE_Sm_PER_S` per second.
250
+ * Burning stops when height reaches 0.
251
+ * - **Craters (non-burning)**: height erodes toward `originalHeight_Sm`
252
+ * at `CRATER_EROSION_RATE_Sm_PER_S` per second.
253
+ *
254
+ * Mutates `segments` in-place.
255
+ *
256
+ * @param segments Segments to advance.
257
+ * @param elapsedSeconds Elapsed real time in seconds.
258
+ */
259
+ export function stepCoverDecay(segments, elapsedSeconds) {
260
+ if (elapsedSeconds <= 0)
261
+ return;
262
+ for (const seg of segments) {
263
+ if (seg.burning) {
264
+ const burnLoss = Math.round(WOOD_BURN_RATE_Sm_PER_S * elapsedSeconds);
265
+ seg.height_Sm = Math.max(0, seg.height_Sm - burnLoss);
266
+ if (seg.height_Sm === 0) {
267
+ seg.burning = false; // nothing left to burn
268
+ }
269
+ }
270
+ else if (seg.height_Sm < seg.originalHeight_Sm) {
271
+ const erosion = Math.round(CRATER_EROSION_RATE_Sm_PER_S * elapsedSeconds);
272
+ seg.height_Sm = Math.min(seg.originalHeight_Sm, seg.height_Sm + erosion);
273
+ }
274
+ }
275
+ }
276
+ /**
277
+ * Return the 2-D midpoint of a cover segment.
278
+ */
279
+ export function coverSegmentCentre(seg) {
280
+ return {
281
+ x_Sm: seg.x_Sm + Math.trunc(seg.length_Sm / 2),
282
+ y_Sm: seg.y_Sm,
283
+ };
284
+ }
285
+ /**
286
+ * Return `true` if the segment has been completely destroyed (height reduced to 0).
287
+ */
288
+ export function isCoverDestroyed(seg) {
289
+ return seg.height_Sm <= 0;
290
+ }