@its-not-rocket-science/ananke 0.1.5 → 0.1.7
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 +37 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +4 -0
- package/dist/src/sim/ai/behavior-trees.d.ts +166 -0
- package/dist/src/sim/ai/behavior-trees.js +340 -0
- package/dist/src/sim/cover.d.ts +186 -0
- package/dist/src/sim/cover.js +290 -0
- package/dist/src/sim/formation-combat.d.ts +170 -0
- package/dist/src/sim/formation-combat.js +255 -0
- package/dist/src/snapshot.d.ts +117 -0
- package/dist/src/snapshot.js +379 -0
- package/package.json +1 -1
|
@@ -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;
|