@its-not-rocket-science/ananke 0.1.5 → 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.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,23 @@ Versioning follows [Semantic Versioning](https://semver.org/).
10
10
 
11
11
  ---
12
12
 
13
+ ## [0.1.6] — 2026-03-23
14
+
15
+ ### Added
16
+
17
+ - **CE-15 · Dynamic Terrain Cover System** (`src/sim/cover.ts`)
18
+ - CoverSegment type: axis-aligned obstacle with material, height, burn state
19
+ - isLineOfSightBlocked(): pure integer segment-intersection test (no sqrt)
20
+ - computeCoverProtection(): multiplicative absorption across stacked cover
21
+ - arcClearsCover(): indirect/lob fire height check
22
+ - applyExplosionToTerrain(): proximity-scaled crater + wood ignition
23
+ - stepCoverDecay(): wood burn-out and crater erosion over real time
24
+ - 4 sample presets: stone wall, sandbag barricade, wooden palisade, dirt berm
25
+ - 60 tests
26
+ - Export via src/index.ts
27
+
28
+ ---
29
+
13
30
  ## [0.1.5] — 2026-03-21
14
31
 
15
32
  ### Added
@@ -18,3 +18,5 @@ export * from "./bridge/index.js";
18
18
  export * from "./world-factory.js";
19
19
  export * from "./scenario.js";
20
20
  export * from "./catalog.js";
21
+ export * from "./sim/formation-combat.js";
22
+ export * from "./sim/cover.js";
package/dist/src/index.js CHANGED
@@ -27,3 +27,5 @@ export * from "./bridge/index.js"; // BridgeEngine, InterpolatedState, BridgeCon
27
27
  export * from "./world-factory.js"; // createWorld(), EntitySpec, ARCHETYPE_MAP, ITEM_MAP
28
28
  export * from "./scenario.js"; // loadScenario(), validateScenario(), AnankeScenario
29
29
  export * from "./catalog.js"; // CE-12: registerArchetype(), registerWeapon(), registerArmour(), getCatalogEntry()
30
+ export * from "./sim/formation-combat.js"; // Phase 69: FormationUnit, TacticalEngagement, resolveTacticalEngagement()
31
+ export * from "./sim/cover.js"; // CE-15: CoverSegment, computeCoverProtection(), isLineOfSightBlocked(), applyExplosionToTerrain()
@@ -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
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Phase 69 — Macro-Scale Formation Combat
3
+ *
4
+ * A tactical abstraction layer between individual entity simulation (20 Hz) and
5
+ * polity-level conflict (1 tick/day). Squads and companies resolve combat as
6
+ * cohesive units via Lanchester's square law, adjusted for terrain and morale.
7
+ *
8
+ * When a named entity (id < NAMED_ENTITY_THRESHOLD, or in a caller-supplied set)
9
+ * participates in the engagement, the resolver marks them in `namedEntityIds` so the
10
+ * host can run a full per-entity micro-simulation frame at the decisive tick.
11
+ *
12
+ * Lanchester's Square Law:
13
+ * Attrition per tick ∝ opponent_strength² / own_strength
14
+ * δA = k × B² δB = k × A²
15
+ *
16
+ * where k is derived from aggregated combat effectiveness (force_N × endurance × morale).
17
+ */
18
+ import { type Q, type I32 } from "../units.js";
19
+ import type { Archetype } from "../archetypes.js";
20
+ /**
21
+ * A squad- or company-level unit in a formation engagement.
22
+ * All numeric fields that represent ratios use Q; headcounts are plain integers.
23
+ */
24
+ export interface FormationUnit {
25
+ /** Unique identifier within the engagement. */
26
+ id: string;
27
+ /** Polity / faction identifier. Units with the same factionId fight together. */
28
+ factionId: string;
29
+ /** Current headcount (soldiers present and capable of fighting). */
30
+ strength: number;
31
+ /** Sum of `peakForce_N` across all members (SCALE.N units). */
32
+ aggregatedForce_N: I32;
33
+ /**
34
+ * Average continuous endurance as a Q fraction [0..SCALE.Q].
35
+ * Derived from avg(continuousPower_W) / HUMAN_CONT_W_REFERENCE.
36
+ */
37
+ aggregatedEndurance: Q;
38
+ /** Formation morale Q [0..SCALE.Q]. Collapse below `breakThreshold` triggers rout. */
39
+ moraleQ: Q;
40
+ /**
41
+ * Representative archetype for the unit. Used by the host to spawn micro-simulation
42
+ * entities at the decisive tick when named characters are present.
43
+ */
44
+ archetype: Archetype;
45
+ /**
46
+ * Optional list of named entity ids (from the micro-simulation) embedded in this unit.
47
+ * Any id below NAMED_ENTITY_THRESHOLD is treated as named automatically.
48
+ */
49
+ namedEntityIds?: readonly number[];
50
+ }
51
+ /** Terrain type affects defender effectiveness multiplier. */
52
+ export type TacticalTerrain = "open" | "difficult" | "fortified";
53
+ /**
54
+ * An engagement between two (or more) sides.
55
+ * `attackers` and `defenders` are lists of `FormationUnit`.
56
+ * All units sharing a `factionId` within a side fight cohesively.
57
+ */
58
+ export interface TacticalEngagement {
59
+ attackers: FormationUnit[];
60
+ defenders: FormationUnit[];
61
+ /** Terrain favours the defender. */
62
+ terrain: TacticalTerrain;
63
+ /**
64
+ * How many tactical ticks to resolve (1 tactical tick ≈ 1 real second at this scale).
65
+ * Typical engagement: 30–600 ticks.
66
+ */
67
+ durationTicks: number;
68
+ /**
69
+ * World seed — used for deterministic morale collapse rolls.
70
+ * If omitted, morale collapse is deterministic via threshold comparison only (no randomness).
71
+ */
72
+ seed?: number;
73
+ }
74
+ /** Per-side outcome summary from `resolveTacticalEngagement`. */
75
+ export interface TacticalSideResult {
76
+ casualties: number;
77
+ survivingStrength: number;
78
+ finalMoraleQ: Q;
79
+ routed: boolean;
80
+ }
81
+ export interface TacticalResult {
82
+ attackerResult: TacticalSideResult;
83
+ defenderResult: TacticalSideResult;
84
+ /** Faction ids that routed (morale collapsed below `breakThreshold`). */
85
+ routedFactions: string[];
86
+ /**
87
+ * Named entity ids present in the engagement that require micro-simulation resolution.
88
+ * The host should run `stepWorld` for these entities at the decisive tick.
89
+ */
90
+ namedEntityIds: number[];
91
+ /**
92
+ * The tick number (0-based) at which the decisive moment occurred.
93
+ * If no side routed or was wiped out, equals `durationTicks`.
94
+ */
95
+ decisiveTick: number;
96
+ }
97
+ /**
98
+ * Entity ids strictly below this value are treated as "named" and trigger
99
+ * micro-simulation delegation. Hosts may override via `FormationUnit.namedEntityIds`.
100
+ */
101
+ export declare const NAMED_ENTITY_THRESHOLD = 1000;
102
+ /**
103
+ * Morale Q threshold below which a unit routs.
104
+ * Matches Phase 32D formation morale `BASE_DECAY` model.
105
+ */
106
+ export declare const ROUT_THRESHOLD: Q;
107
+ /**
108
+ * Base morale decay per tactical tick due to casualties (Q per tick per 1% casualty rate).
109
+ * A unit sustaining 10% casualties/tick loses ~q(0.10) morale/tick.
110
+ */
111
+ export declare const MORALE_CASUALTY_DECAY_PER_PCT: Q;
112
+ /**
113
+ * Lanchester attrition rate (fraction of effective opponent strength killed per tick).
114
+ *
115
+ * Lanchester's Square Law differential form:
116
+ * dA/dt = -rate × B_eff (attacker casualties ∝ effective defender count)
117
+ * dB/dt = -rate × A_eff (defender casualties ∝ effective attacker count)
118
+ *
119
+ * The "square law" refers to the conservation integral (A²-B²=const), not squared
120
+ * differentials. rate=0.01 gives ~100-tick engagements for equal 100-person units.
121
+ */
122
+ export declare const LANCHESTER_RATE = 0.1;
123
+ /**
124
+ * Reference combat power of a single standard human soldier at q(1.0) morale.
125
+ * Used to convert aggregated sidePower() to "effective fighter count" for attrition.
126
+ * Units: same as aggregatedForce_N × conversionEfficiency / SCALE.Q (SCALE.N units).
127
+ */
128
+ export declare const REFERENCE_POWER_PER_SOLDIER: number;
129
+ /**
130
+ * Defender effectiveness multiplier per terrain type.
131
+ * Applied to the defender's combat power (force × endurance × morale).
132
+ * Attackers always use multiplier q(1.0).
133
+ */
134
+ export declare const TERRAIN_DEFENDER_MUL: Record<TacticalTerrain, Q>;
135
+ /**
136
+ * Build a `FormationUnit` from headcount and archetype.
137
+ *
138
+ * @param id Unique unit identifier.
139
+ * @param factionId Faction / polity identifier.
140
+ * @param strength Number of combatants.
141
+ * @param archetype Representative archetype (used for force and endurance derivation).
142
+ * @param moraleQ Initial morale (defaults to q(0.70)).
143
+ */
144
+ export declare function createFormationUnit(id: string, factionId: string, strength: number, archetype: Archetype, moraleQ?: Q): FormationUnit;
145
+ /**
146
+ * Resolve a tactical engagement over `durationTicks` using Lanchester's square law.
147
+ *
148
+ * The engagement proceeds tick-by-tick:
149
+ * 1. Compute combat power for each side (aggregated force × endurance × morale × terrain).
150
+ * 2. Apply Lanchester attrition: δA = k × B²/A, δB = k × A²/B.
151
+ * 3. Apply morale pressure proportional to casualty rate.
152
+ * 4. Check rout conditions — any unit below ROUT_THRESHOLD is considered routed.
153
+ * 5. Stop early if all units on one side are routed or wiped out.
154
+ *
155
+ * **Note:** This mutates `strength` and `moraleQ` on the supplied `FormationUnit` objects.
156
+ * Clone them before calling if you need to preserve original state.
157
+ *
158
+ * @param engagement - The engagement parameters.
159
+ * @returns `TacticalResult` with per-side outcomes, routed factions, and named entity ids.
160
+ */
161
+ export declare function resolveTacticalEngagement(engagement: TacticalEngagement): TacticalResult;
162
+ /**
163
+ * Apply the tactical result back to polity military strength (Q).
164
+ *
165
+ * @param currentStrength_Q - Current `polity.militaryStrength_Q`.
166
+ * @param initialStrength - Headcount at engagement start.
167
+ * @param result - Side result from `resolveTacticalEngagement`.
168
+ * @returns Updated `militaryStrength_Q`.
169
+ */
170
+ export declare function applyTacticalResultToPolity(currentStrength_Q: Q, initialStrength: number, result: TacticalSideResult): Q;
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Phase 69 — Macro-Scale Formation Combat
3
+ *
4
+ * A tactical abstraction layer between individual entity simulation (20 Hz) and
5
+ * polity-level conflict (1 tick/day). Squads and companies resolve combat as
6
+ * cohesive units via Lanchester's square law, adjusted for terrain and morale.
7
+ *
8
+ * When a named entity (id < NAMED_ENTITY_THRESHOLD, or in a caller-supplied set)
9
+ * participates in the engagement, the resolver marks them in `namedEntityIds` so the
10
+ * host can run a full per-entity micro-simulation frame at the decisive tick.
11
+ *
12
+ * Lanchester's Square Law:
13
+ * Attrition per tick ∝ opponent_strength² / own_strength
14
+ * δA = k × B² δB = k × A²
15
+ *
16
+ * where k is derived from aggregated combat effectiveness (force_N × endurance × morale).
17
+ */
18
+ import { q, SCALE, clampQ, qMul, mulDiv } from "../units.js";
19
+ import { HUMAN_BASE } from "../archetypes.js";
20
+ // ── Constants ─────────────────────────────────────────────────────────────────
21
+ /**
22
+ * Entity ids strictly below this value are treated as "named" and trigger
23
+ * micro-simulation delegation. Hosts may override via `FormationUnit.namedEntityIds`.
24
+ */
25
+ export const NAMED_ENTITY_THRESHOLD = 1000;
26
+ /**
27
+ * Morale Q threshold below which a unit routs.
28
+ * Matches Phase 32D formation morale `BASE_DECAY` model.
29
+ */
30
+ export const ROUT_THRESHOLD = q(0.20);
31
+ /**
32
+ * Base morale decay per tactical tick due to casualties (Q per tick per 1% casualty rate).
33
+ * A unit sustaining 10% casualties/tick loses ~q(0.10) morale/tick.
34
+ */
35
+ export const MORALE_CASUALTY_DECAY_PER_PCT = q(0.010);
36
+ /**
37
+ * Lanchester attrition rate (fraction of effective opponent strength killed per tick).
38
+ *
39
+ * Lanchester's Square Law differential form:
40
+ * dA/dt = -rate × B_eff (attacker casualties ∝ effective defender count)
41
+ * dB/dt = -rate × A_eff (defender casualties ∝ effective attacker count)
42
+ *
43
+ * The "square law" refers to the conservation integral (A²-B²=const), not squared
44
+ * differentials. rate=0.01 gives ~100-tick engagements for equal 100-person units.
45
+ */
46
+ export const LANCHESTER_RATE = 0.10;
47
+ /**
48
+ * Reference combat power of a single standard human soldier at q(1.0) morale.
49
+ * Used to convert aggregated sidePower() to "effective fighter count" for attrition.
50
+ * Units: same as aggregatedForce_N × conversionEfficiency / SCALE.Q (SCALE.N units).
51
+ */
52
+ export const REFERENCE_POWER_PER_SOLDIER = Math.round((HUMAN_BASE.peakForce_N * HUMAN_BASE.conversionEfficiency) / SCALE.Q);
53
+ // ── Terrain multipliers ───────────────────────────────────────────────────────
54
+ /**
55
+ * Defender effectiveness multiplier per terrain type.
56
+ * Applied to the defender's combat power (force × endurance × morale).
57
+ * Attackers always use multiplier q(1.0).
58
+ */
59
+ export const TERRAIN_DEFENDER_MUL = {
60
+ open: q(1.00), // no terrain advantage
61
+ difficult: q(1.30), // broken ground, forest, river — 30% defender bonus
62
+ fortified: q(2.00), // walls, prepared positions — 2× defender effectiveness
63
+ };
64
+ // ── Internal helpers ──────────────────────────────────────────────────────────
65
+ /**
66
+ * Aggregate combat power for a side (Q-scaled, relative units).
67
+ * power = sum(force_N × endurance × morale / SCALE.Q²)
68
+ * Returns an integer in SCALE.N × Q units (before the /SCALE.Q² normalisation).
69
+ */
70
+ function sidePower(units, terrainMul) {
71
+ let total = 0;
72
+ for (const u of units) {
73
+ if (u.strength <= 0 || u.moraleQ <= 0)
74
+ continue;
75
+ // effective = force_N × (endurance / SCALE.Q) × (morale / SCALE.Q) × (terrain / SCALE.Q)
76
+ const effForce = mulDiv(u.aggregatedForce_N, u.aggregatedEndurance, SCALE.Q);
77
+ const withMorale = mulDiv(effForce, u.moraleQ, SCALE.Q);
78
+ total += mulDiv(withMorale, terrainMul, SCALE.Q);
79
+ }
80
+ return Math.max(0, total);
81
+ }
82
+ /** Total headcount across all units on a side. */
83
+ function sideStrength(units) {
84
+ return units.reduce((s, u) => s + Math.max(0, u.strength), 0);
85
+ }
86
+ /**
87
+ * Distribute `casualties` proportionally across units by current strength.
88
+ * Mutates unit.strength in-place. Returns actual casualties applied.
89
+ */
90
+ function distributeCasualties(units, casualties) {
91
+ const total = sideStrength(units);
92
+ if (total <= 0 || casualties <= 0)
93
+ return 0;
94
+ let applied = 0;
95
+ for (const u of units) {
96
+ if (u.strength <= 0)
97
+ continue;
98
+ const share = Math.round((casualties * u.strength) / total);
99
+ const actual = Math.min(share, u.strength);
100
+ u.strength -= actual;
101
+ applied += actual;
102
+ }
103
+ return applied;
104
+ }
105
+ /**
106
+ * Apply morale pressure to all units on a side.
107
+ * Pressure = casualty rate × MORALE_CASUALTY_DECAY_PER_PCT.
108
+ */
109
+ function applyMoralePressure(units, casualtiesThisTick) {
110
+ const total = sideStrength(units) + casualtiesThisTick;
111
+ if (total <= 0)
112
+ return;
113
+ const pct = Math.round((casualtiesThisTick * SCALE.Q) / total);
114
+ const decay = qMul(MORALE_CASUALTY_DECAY_PER_PCT, pct);
115
+ for (const u of units) {
116
+ u.moraleQ = clampQ((u.moraleQ - decay), 0, SCALE.Q);
117
+ }
118
+ }
119
+ /** Collect named entity ids from all units across both sides. */
120
+ function collectNamedIds(attackers, defenders) {
121
+ const ids = new Set();
122
+ for (const u of [...attackers, ...defenders]) {
123
+ // Explicit named ids
124
+ if (u.namedEntityIds) {
125
+ for (const id of u.namedEntityIds)
126
+ ids.add(id);
127
+ }
128
+ }
129
+ return [...ids];
130
+ }
131
+ // ── Public API ────────────────────────────────────────────────────────────────
132
+ /**
133
+ * Build a `FormationUnit` from headcount and archetype.
134
+ *
135
+ * @param id Unique unit identifier.
136
+ * @param factionId Faction / polity identifier.
137
+ * @param strength Number of combatants.
138
+ * @param archetype Representative archetype (used for force and endurance derivation).
139
+ * @param moraleQ Initial morale (defaults to q(0.70)).
140
+ */
141
+ export function createFormationUnit(id, factionId, strength, archetype, moraleQ = q(0.70)) {
142
+ return {
143
+ id,
144
+ factionId,
145
+ strength: Math.max(0, strength),
146
+ aggregatedForce_N: Math.round(archetype.peakForce_N * strength),
147
+ aggregatedEndurance: archetype.conversionEfficiency,
148
+ moraleQ,
149
+ archetype,
150
+ };
151
+ }
152
+ /**
153
+ * Resolve a tactical engagement over `durationTicks` using Lanchester's square law.
154
+ *
155
+ * The engagement proceeds tick-by-tick:
156
+ * 1. Compute combat power for each side (aggregated force × endurance × morale × terrain).
157
+ * 2. Apply Lanchester attrition: δA = k × B²/A, δB = k × A²/B.
158
+ * 3. Apply morale pressure proportional to casualty rate.
159
+ * 4. Check rout conditions — any unit below ROUT_THRESHOLD is considered routed.
160
+ * 5. Stop early if all units on one side are routed or wiped out.
161
+ *
162
+ * **Note:** This mutates `strength` and `moraleQ` on the supplied `FormationUnit` objects.
163
+ * Clone them before calling if you need to preserve original state.
164
+ *
165
+ * @param engagement - The engagement parameters.
166
+ * @returns `TacticalResult` with per-side outcomes, routed factions, and named entity ids.
167
+ */
168
+ export function resolveTacticalEngagement(engagement) {
169
+ const { attackers, defenders, terrain, durationTicks } = engagement;
170
+ const terrainMul = TERRAIN_DEFENDER_MUL[terrain];
171
+ const namedEntityIds = collectNamedIds(attackers, defenders);
172
+ let totalAttackerCasualties = 0;
173
+ let totalDefenderCasualties = 0;
174
+ const routedFactions = new Set();
175
+ let decisiveTick = durationTicks;
176
+ for (let tick = 0; tick < durationTicks; tick++) {
177
+ const aStrength = sideStrength(attackers);
178
+ const dStrength = sideStrength(defenders);
179
+ // Stop if one side is wiped out
180
+ if (aStrength <= 0 || dStrength <= 0) {
181
+ decisiveTick = tick;
182
+ break;
183
+ }
184
+ // Combat power for each side
185
+ const aPower = sidePower(attackers, SCALE.Q); // attackers: no terrain bonus
186
+ const dPower = sidePower(defenders, terrainMul); // defenders: terrain multiplier
187
+ // Lanchester's Square Law — linear differential form:
188
+ // δA = rate × dEff (attacker casualties ∝ effective defender count)
189
+ // δB = rate × aEff (defender casualties ∝ effective attacker count)
190
+ //
191
+ // Convert aggregated power to "effective fighter count" by dividing by the
192
+ // reference combat power of one standard soldier.
193
+ const ref = Math.max(1, REFERENCE_POWER_PER_SOLDIER);
194
+ const aEff = Math.max(1, Math.round(aPower / ref));
195
+ const dEff = Math.max(1, Math.round(dPower / ref));
196
+ const aCas = Math.max(0, Math.round(LANCHESTER_RATE * dEff));
197
+ const dCas = Math.max(0, Math.round(LANCHESTER_RATE * aEff));
198
+ // Distribute casualties
199
+ totalAttackerCasualties += distributeCasualties(attackers, aCas);
200
+ totalDefenderCasualties += distributeCasualties(defenders, dCas);
201
+ // Morale pressure
202
+ applyMoralePressure(attackers, aCas);
203
+ applyMoralePressure(defenders, dCas);
204
+ // Rout check
205
+ for (const u of attackers) {
206
+ if (u.moraleQ < ROUT_THRESHOLD || u.strength <= 0)
207
+ routedFactions.add(u.factionId);
208
+ }
209
+ for (const u of defenders) {
210
+ if (u.moraleQ < ROUT_THRESHOLD || u.strength <= 0)
211
+ routedFactions.add(u.factionId);
212
+ }
213
+ // Early stop: all attacker or all defender factions routed
214
+ const aAllRouted = attackers.every(u => routedFactions.has(u.factionId) || u.strength <= 0);
215
+ const dAllRouted = defenders.every(u => routedFactions.has(u.factionId) || u.strength <= 0);
216
+ if (aAllRouted || dAllRouted) {
217
+ decisiveTick = tick + 1;
218
+ break;
219
+ }
220
+ }
221
+ const attackerResult = {
222
+ casualties: totalAttackerCasualties,
223
+ survivingStrength: sideStrength(attackers),
224
+ finalMoraleQ: Math.round(attackers.reduce((s, u) => s + u.moraleQ, 0) / Math.max(1, attackers.length)),
225
+ routed: attackers.every(u => routedFactions.has(u.factionId) || u.strength <= 0),
226
+ };
227
+ const defenderResult = {
228
+ casualties: totalDefenderCasualties,
229
+ survivingStrength: sideStrength(defenders),
230
+ finalMoraleQ: Math.round(defenders.reduce((s, u) => s + u.moraleQ, 0) / Math.max(1, defenders.length)),
231
+ routed: defenders.every(u => routedFactions.has(u.factionId) || u.strength <= 0),
232
+ };
233
+ return {
234
+ attackerResult,
235
+ defenderResult,
236
+ routedFactions: [...routedFactions],
237
+ namedEntityIds,
238
+ decisiveTick,
239
+ };
240
+ }
241
+ /**
242
+ * Apply the tactical result back to polity military strength (Q).
243
+ *
244
+ * @param currentStrength_Q - Current `polity.militaryStrength_Q`.
245
+ * @param initialStrength - Headcount at engagement start.
246
+ * @param result - Side result from `resolveTacticalEngagement`.
247
+ * @returns Updated `militaryStrength_Q`.
248
+ */
249
+ export function applyTacticalResultToPolity(currentStrength_Q, initialStrength, result) {
250
+ if (initialStrength <= 0)
251
+ return currentStrength_Q;
252
+ const survivorFrac = Math.round((result.survivingStrength * SCALE.Q) / initialStrength);
253
+ const moraleAdj = qMul(survivorFrac, result.finalMoraleQ);
254
+ return clampQ(Math.round((currentStrength_Q * moraleAdj) / SCALE.Q), 0, SCALE.Q);
255
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@its-not-rocket-science/ananke",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
6
6
  "license": "MIT",