@its-not-rocket-science/ananke 0.1.53 → 0.1.55

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
@@ -6,6 +6,35 @@ Versioning follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ---
8
8
 
9
+ ## [0.1.55] — 2026-03-30
10
+
11
+ ### Added
12
+
13
+ - **PA-6 — Unified Atmosphere Model (complete):**
14
+ - `src/atmosphere.ts` (new): single `AtmosphericState` struct derived from Phase 51 `WeatherState` and Phase 68 `BiomeContext`, with a unified per-pair query API.
15
+ - `deriveAtmosphericState(weather?, biome?)` → `AtmosphericState`: maps WeatherState wind to 3D `AtmosphericWind` (adds `dz_m`, derives `turbulence_Q` from speed); derives `precipIntensity_Q` from precipitation type; computes `baseVisibility_Sm` from fog × precipitation; computes `acousticMask_Q` from wind noise; maps biome `soundPropagation_Q` (vacuum = 0, water = 4×, standard air = 1×); derives `tractionMod_Q` and `thermalOffset_Q` from `deriveWeatherModifiers`.
16
+ - `queryAtmosphericModifiers(from, to, state)` → `AtmosphericModifiers`: single call yields all position-pair atmospheric effects — `crossWindSpeed_mps` (perpendicular wind for projectile drift), `hazardConeMul_Q` (gas/smoke cone range 0.5×–1.5× from headwind/tailwind), `acousticMaskMul_Q` (hearing range including upwind bonus and biome propagation), `visibilityRange_Sm` (headwind-boosted precipitation degradation), `tractionMod_Q`, `scentStrength_Q` (q(1.0) fully downwind of target, q(0) upwind — prerequisite for PA-7), `thermalOffset_Q`.
17
+ - `"./atmosphere"` subpath export added to `package.json`.
18
+ - Exports: `AtmosphericWind`, `AtmosphericState`, `AtmosphericModifiers`, `deriveAtmosphericState`, `queryAtmosphericModifiers`; constants `ATMO_BASE_VISIBILITY_Sm`, `ATMO_ACOUSTIC_FULL_MASK_MPS`, `ATMO_TURBULENCE_FULL_MPS`, `ATMO_HAZARD_TAILWIND_MUL_MAX`, `ATMO_HAZARD_HEADWIND_MUL_MIN`, `ATMO_HEARING_UPWIND_BONUS`.
19
+ - 53 new tests (186 test files, 5,452 tests total). Build: clean.
20
+
21
+ ---
22
+
23
+ ## [0.1.54] — 2026-03-28
24
+
25
+ ### Added
26
+
27
+ - **PA-5 — Campaign ↔ Tactical Terrain Bridge (complete):**
28
+ - `src/terrain-bridge.ts` (new): maps campaign hex tiles to tactical battlefield parameters consumable by `KernelContext`, and merges tactical battle results back into `CampaignState`.
29
+ - `extractTerrainParams(hexType)` → deterministic 10×8-cell (100 m × 80 m) battlefield with `TerrainGrid`, `ObstacleGrid`, `ElevationGrid`, `SlopeGrid`, and `CoverSegment[]` for all 8 hex types: `plains`, `forest`, `hills`, `marsh`, `urban`, `mountain`, `river_crossing`, `coastal`.
30
+ - `generateBattleSite(ctx)` → full `BattleTerrainParams` including `EntryVector[]` — attacker/defender spawn positions (y=5 m south, y=75 m north) with `facingY` direction.
31
+ - `mergeBattleOutcome(campaign, outcome)` → merges post-battle `WorldState` into `CampaignState`: removes `injury.dead` entities, copies post-battle `injury`/`condition` onto survivors, transfers looted weapons/items from captured entities to winner's inventory, advances `worldTime_s`, appends a log entry.
32
+ - Exports: `CampaignHexType`, `EntryVector`, `BattleTerrainParams`, `BattleSiteContext`, `BattleOutcome`; field constants `FIELD_WIDTH_Sm`, `FIELD_HEIGHT_Sm`, `CELL_SIZE_Sm`, `GRID_COLS`, `GRID_ROWS`.
33
+ - `"./terrain-bridge"` subpath export added to `package.json`.
34
+ - 67 new tests (185 test files, 5,397 tests total). Coverage: 97.05% stmt, 87.88% branch, 95.75% func, 97.05% lines. Build: clean.
35
+
36
+ ---
37
+
9
38
  ## [0.1.53] — 2026-03-28
10
39
 
11
40
  ### Added
@@ -0,0 +1,222 @@
1
+ import { type Q } from "./units.js";
2
+ import { type WeatherState } from "./sim/weather.js";
3
+ import type { BiomeContext } from "./sim/biome.js";
4
+ /**
5
+ * Wind speed [WindField mps units, 100 per m/s] that produces maximum acoustic
6
+ * masking (q(1.0)). Calibrated at 40 m/s (gale / violent storm).
7
+ */
8
+ export declare const ATMO_ACOUSTIC_FULL_MASK_MPS = 4000;
9
+ /**
10
+ * Wind speed [WindField mps units] that produces maximum turbulence (q(1.0)).
11
+ * Calibrated at 50 m/s (hurricane-force).
12
+ */
13
+ export declare const ATMO_TURBULENCE_FULL_MPS = 5000;
14
+ /**
15
+ * Clear-sky baseline visibility range [SCALE.m]. 1 000 m = 10 000 000 SCALE.m.
16
+ */
17
+ export declare const ATMO_BASE_VISIBILITY_Sm = 10000000;
18
+ /**
19
+ * Maximum hazard-cone range multiplier from a strong tailwind (1.5× base range).
20
+ * Stored as a raw multiplier where SCALE.Q = 1.0.
21
+ */
22
+ export declare const ATMO_HAZARD_TAILWIND_MUL_MAX = 15000;
23
+ /**
24
+ * Minimum hazard-cone range multiplier from a strong headwind (0.5× base range).
25
+ */
26
+ export declare const ATMO_HAZARD_HEADWIND_MUL_MIN = 5000;
27
+ /**
28
+ * Acoustic hearing range bonus when the sound source is directly upwind
29
+ * (sound carries toward the listener): +20% of SCALE.Q.
30
+ */
31
+ export declare const ATMO_HEARING_UPWIND_BONUS = 2000;
32
+ /**
33
+ * 3-D wind field with vertical component and turbulence.
34
+ *
35
+ * Extends `WeatherState.WindField` (2-D) — `deriveAtmosphericState` maps the
36
+ * existing 2-D wind field and adds a zero vertical component when the source
37
+ * has no vertical wind data.
38
+ *
39
+ * Speed units: 100 units = 1 m/s (same convention as WeatherState.WindField).
40
+ */
41
+ export interface AtmosphericWind {
42
+ /** X component of wind direction — unit vector in SCALE.m space. */
43
+ dx_m: number;
44
+ /** Y component of wind direction — unit vector in SCALE.m space. */
45
+ dy_m: number;
46
+ /**
47
+ * Vertical (Z) component — positive = rising (updraft).
48
+ * Unit vector in SCALE.m space. `0` for standard horizontal wind.
49
+ */
50
+ dz_m: number;
51
+ /** Wind speed [100 units = 1 m/s]. */
52
+ speed_mps: number;
53
+ /**
54
+ * Turbulence intensity [Q 0..SCALE.Q].
55
+ * q(0) = laminar flow; q(1.0) = severe gusts (hurricane-force).
56
+ * Added to aim-error grouping radius for ranged attacks; derived from wind
57
+ * speed by `deriveAtmosphericState`.
58
+ */
59
+ turbulence_Q: Q;
60
+ }
61
+ /**
62
+ * Unified atmospheric state — combines wind, precipitation, visibility,
63
+ * acoustic environment, and thermal offset into a single queryable struct.
64
+ *
65
+ * Build once per tick from `WeatherState` and `BiomeContext` via
66
+ * `deriveAtmosphericState`, then query per entity-pair via
67
+ * `queryAtmosphericModifiers`.
68
+ */
69
+ export interface AtmosphericState {
70
+ /** 3-D wind field. */
71
+ wind: AtmosphericWind;
72
+ /**
73
+ * Precipitation intensity [Q 0..SCALE.Q].
74
+ * q(0) = none; q(1.0) = blizzard/torrential rain.
75
+ * Affects traction, visibility, and acoustic masking.
76
+ */
77
+ precipIntensity_Q: Q;
78
+ /**
79
+ * Baseline visibility range [SCALE.m] — clear-sky minus fog and precipitation.
80
+ * `queryAtmosphericModifiers` adjusts this for headwind-driven precipitation.
81
+ */
82
+ baseVisibility_Sm: number;
83
+ /**
84
+ * Surface traction multiplier [Q].
85
+ * Multiply `KernelContext.tractionCoeff` by this value (already derived from
86
+ * `deriveWeatherModifiers().tractionMul_Q`).
87
+ */
88
+ tractionMod_Q: Q;
89
+ /**
90
+ * Ambient acoustic masking from wind noise [Q 0..SCALE.Q].
91
+ * q(0) = still air (no masking); q(1.0) = severe wind noise (hearing near zero).
92
+ * Applied to base hearing range before directional adjustments in
93
+ * `queryAtmosphericModifiers`.
94
+ */
95
+ acousticMask_Q: Q;
96
+ /**
97
+ * Sound propagation multiplier from biome [Q].
98
+ * q(1.0) = normal air propagation (default).
99
+ * q(0.0) = vacuum (no sound).
100
+ * q(4.0) = water (~4× faster propagation).
101
+ * Multiplies the post-masking hearing range in `queryAtmosphericModifiers`.
102
+ */
103
+ soundPropagation_Q: Q;
104
+ /**
105
+ * Thermal offset in Phase-29 Q encoding.
106
+ * Add to `KernelContext.thermalAmbient_Q`.
107
+ */
108
+ thermalOffset_Q: number;
109
+ }
110
+ /**
111
+ * Per-pair atmospheric modifiers returned by `queryAtmosphericModifiers`.
112
+ *
113
+ * All values are ready to apply:
114
+ * - Multiply Q fields using `Math.round(value × mul / SCALE.Q)`.
115
+ * - Add offset fields directly.
116
+ *
117
+ * @see queryAtmosphericModifiers
118
+ */
119
+ export interface AtmosphericModifiers {
120
+ /**
121
+ * Perpendicular wind component relative to the shot/query direction
122
+ * [100 units = 1 m/s].
123
+ *
124
+ * Convert to projectile drift [SCALE.m]:
125
+ * ```
126
+ * drift_Sm = crossWindSpeed_mps × range_Sm / proj_speed_mps
127
+ * ```
128
+ * where `range_Sm` is in SCALE.m and `proj_speed_mps` is in the same
129
+ * 100-per-m/s wind units. Zero when the query pair is coincident.
130
+ */
131
+ crossWindSpeed_mps: number;
132
+ /**
133
+ * Hazard-cone range multiplier for gas/smoke cones aimed from `from` to `to`
134
+ * [raw factor, SCALE.Q = 1.0].
135
+ *
136
+ * Values > SCALE.Q are valid and intentional:
137
+ * - Tailwind extends range up to `ATMO_HAZARD_TAILWIND_MUL_MAX` (1.5×).
138
+ * - Headwind reduces range down to `ATMO_HAZARD_HEADWIND_MUL_MIN` (0.5×).
139
+ * - Crosswind or calm → SCALE.Q (1.0×, no change).
140
+ *
141
+ * Apply: `adjustedRange_Sm = Math.round(baseRange_Sm × hazardConeMul_Q / SCALE.Q)`.
142
+ */
143
+ hazardConeMul_Q: number;
144
+ /**
145
+ * Effective hearing range multiplier [Q 0..SCALE.Q + ATMO_HEARING_UPWIND_BONUS].
146
+ *
147
+ * Combines wind noise masking and directional propagation:
148
+ * - Values < SCALE.Q: hearing degraded (wind noise or vacuum).
149
+ * - Values up to SCALE.Q + ATMO_HEARING_UPWIND_BONUS: enhanced (sound downwind of listener).
150
+ *
151
+ * Apply: `effectiveRange_Sm = Math.round(baseRange_Sm × acousticMaskMul_Q / SCALE.Q)`.
152
+ */
153
+ acousticMaskMul_Q: number;
154
+ /**
155
+ * Effective visibility range [SCALE.m] for the query direction.
156
+ * Already adjusted for headwind-driven precipitation and fog.
157
+ * Use as maximum ranged-detection distance for this query pair.
158
+ */
159
+ visibilityRange_Sm: number;
160
+ /**
161
+ * Surface traction modifier [Q] — same as `AtmosphericState.tractionMod_Q`.
162
+ * Included here for convenience so callers can read all mods from one struct.
163
+ */
164
+ tractionMod_Q: Q;
165
+ /**
166
+ * Scent propagation strength from `to` toward `from` [Q 0..SCALE.Q].
167
+ *
168
+ * Physics: the observer at `from` smells the entity at `to` when the wind
169
+ * blows from `to` toward `from` (i.e., `from` is downwind of `to`).
170
+ *
171
+ * - q(1.0) = `from` is directly downwind of `to` (maximum scent).
172
+ * - q(0) = `from` is upwind of `to` (no scent reaches observer).
173
+ * - Intermediate values for crosswind/partial alignment.
174
+ *
175
+ * Used by PA-7 olfactory detection.
176
+ */
177
+ scentStrength_Q: Q;
178
+ /**
179
+ * Thermal offset in Phase-29 Q encoding.
180
+ * Same as `AtmosphericState.thermalOffset_Q`.
181
+ */
182
+ thermalOffset_Q: number;
183
+ }
184
+ /**
185
+ * Build an `AtmosphericState` from Phase 51 `WeatherState` and Phase 68
186
+ * `BiomeContext`.
187
+ *
188
+ * Both parameters are optional — absent values produce calm, clear-sky,
189
+ * standard-air atmosphere with no modifiers.
190
+ *
191
+ * @example
192
+ * ```ts
193
+ * const atmo = deriveAtmosphericState(ctx.weather, ctx.biome);
194
+ * // Use once per tick, query per entity-pair:
195
+ * const mods = queryAtmosphericModifiers(attacker, target, atmo);
196
+ * ```
197
+ */
198
+ export declare function deriveAtmosphericState(weather?: WeatherState, biome?: BiomeContext): AtmosphericState;
199
+ /**
200
+ * Query all atmospheric modifiers for a position pair.
201
+ *
202
+ * Computes wind-relative effects along the `from → to` vector:
203
+ * - Crosswind component for projectile drift.
204
+ * - Tailwind/headwind ratio for hazard-cone range.
205
+ * - Directional acoustic effects.
206
+ * - Headwind-boosted precipitation degrading visibility.
207
+ * - Scent propagation strength (downwind = strong, upwind = none).
208
+ *
209
+ * Pure function — no mutation, safe to call multiple times per tick.
210
+ *
211
+ * @param from Observer / attacker position [SCALE.m].
212
+ * @param to Target / source position [SCALE.m].
213
+ * @param state Atmospheric state built by `deriveAtmosphericState`.
214
+ * @returns All modifiers for this position pair.
215
+ */
216
+ export declare function queryAtmosphericModifiers(from: {
217
+ x_Sm: number;
218
+ y_Sm: number;
219
+ }, to: {
220
+ x_Sm: number;
221
+ y_Sm: number;
222
+ }, state: AtmosphericState): AtmosphericModifiers;
@@ -0,0 +1,200 @@
1
+ // src/atmosphere.ts — PA-6: Unified Atmosphere Model
2
+ //
3
+ // Provides a single `AtmosphericState` struct that derives from WeatherState
4
+ // (Phase 51) and BiomeContext (Phase 68) and exposes a unified query API.
5
+ //
6
+ // `queryAtmosphericModifiers(from, to, state)` returns all atmospheric effects
7
+ // relevant to a position pair in one call — projectile drift, hazard cone
8
+ // distortion, acoustic masking, visibility, traction, and scent propagation —
9
+ // so hosts no longer need per-system wind configuration.
10
+ //
11
+ // Integration:
12
+ // 1. Build once per tick: state = deriveAtmosphericState(weather, biome)
13
+ // 2. Query per-pair: mods = queryAtmosphericModifiers(from, to, state)
14
+ // 3. Apply mods to:
15
+ // - resolveShoot: gRadius_m += crossWindSpeed_mps × range / proj_speed
16
+ // - adjustConeRange: multiply result by hazardConeMul_Q / SCALE.Q
17
+ // - sensoryEnv.hearingRange_m: multiply by acousticMaskMul_Q / SCALE.Q
18
+ // - KernelContext.tractionCoeff: multiply by mods.tractionMod_Q / SCALE.Q
19
+ // - PA-7 senses: use scentStrength_Q for olfactory detection
20
+ import { SCALE, q, clampQ } from "./units.js";
21
+ import { deriveWeatherModifiers, } from "./sim/weather.js";
22
+ // ── Constants ─────────────────────────────────────────────────────────────────
23
+ /**
24
+ * Wind speed [WindField mps units, 100 per m/s] that produces maximum acoustic
25
+ * masking (q(1.0)). Calibrated at 40 m/s (gale / violent storm).
26
+ */
27
+ export const ATMO_ACOUSTIC_FULL_MASK_MPS = 4_000;
28
+ /**
29
+ * Wind speed [WindField mps units] that produces maximum turbulence (q(1.0)).
30
+ * Calibrated at 50 m/s (hurricane-force).
31
+ */
32
+ export const ATMO_TURBULENCE_FULL_MPS = 5_000;
33
+ /**
34
+ * Clear-sky baseline visibility range [SCALE.m]. 1 000 m = 10 000 000 SCALE.m.
35
+ */
36
+ export const ATMO_BASE_VISIBILITY_Sm = 10_000_000;
37
+ /**
38
+ * Maximum hazard-cone range multiplier from a strong tailwind (1.5× base range).
39
+ * Stored as a raw multiplier where SCALE.Q = 1.0.
40
+ */
41
+ export const ATMO_HAZARD_TAILWIND_MUL_MAX = 15_000; // 1.5 × SCALE.Q
42
+ /**
43
+ * Minimum hazard-cone range multiplier from a strong headwind (0.5× base range).
44
+ */
45
+ export const ATMO_HAZARD_HEADWIND_MUL_MIN = 5_000; // 0.5 × SCALE.Q
46
+ /**
47
+ * Acoustic hearing range bonus when the sound source is directly upwind
48
+ * (sound carries toward the listener): +20% of SCALE.Q.
49
+ */
50
+ export const ATMO_HEARING_UPWIND_BONUS = 2_000; // 0.2 × SCALE.Q
51
+ // ── Zero-wind sentinel ────────────────────────────────────────────────────────
52
+ const ZERO_WIND = {
53
+ dx_m: SCALE.m, // direction irrelevant at zero speed; default East
54
+ dy_m: 0,
55
+ dz_m: 0,
56
+ speed_mps: 0,
57
+ turbulence_Q: q(0),
58
+ };
59
+ // ── deriveAtmosphericState ────────────────────────────────────────────────────
60
+ /**
61
+ * Build an `AtmosphericState` from Phase 51 `WeatherState` and Phase 68
62
+ * `BiomeContext`.
63
+ *
64
+ * Both parameters are optional — absent values produce calm, clear-sky,
65
+ * standard-air atmosphere with no modifiers.
66
+ *
67
+ * @example
68
+ * ```ts
69
+ * const atmo = deriveAtmosphericState(ctx.weather, ctx.biome);
70
+ * // Use once per tick, query per entity-pair:
71
+ * const mods = queryAtmosphericModifiers(attacker, target, atmo);
72
+ * ```
73
+ */
74
+ export function deriveAtmosphericState(weather, biome) {
75
+ // ── Wind ──────────────────────────────────────────────────────────────────
76
+ const srcWind = weather?.wind;
77
+ const turbulence_Q = srcWind
78
+ ? clampQ(Math.round(srcWind.speed_mps * SCALE.Q / ATMO_TURBULENCE_FULL_MPS), q(0), SCALE.Q)
79
+ : q(0);
80
+ const wind = srcWind
81
+ ? { dx_m: srcWind.dx_m, dy_m: srcWind.dy_m, dz_m: 0, speed_mps: srcWind.speed_mps, turbulence_Q }
82
+ : ZERO_WIND;
83
+ // ── Weather-derived modifiers ─────────────────────────────────────────────
84
+ const wmod = weather ? deriveWeatherModifiers(weather) : null;
85
+ const tractionMod_Q = (wmod?.tractionMul_Q ?? SCALE.Q);
86
+ const thermalOffset_Q = wmod?.thermalOffset_Q ?? 0;
87
+ // ── Precipitation intensity ───────────────────────────────────────────────
88
+ // Map WeatherModifiers.precipVisionMul_Q inversely: q(1.0) = none, q(0.30) = blizzard
89
+ const precipVisionMul = wmod?.precipVisionMul_Q ?? SCALE.Q;
90
+ // precipIntensity = 1.0 - precipVisionMul (inverted, clamped to [0, 1])
91
+ const precipIntensity_Q = clampQ((SCALE.Q - precipVisionMul), q(0), SCALE.Q);
92
+ // ── Visibility ────────────────────────────────────────────────────────────
93
+ // Fog: q(0) → full visibility; q(1.0) → 1% of baseline
94
+ const fogDensity = weather?.fogDensity_Q ?? 0;
95
+ const fogMul = Math.max(100, SCALE.Q - Math.round(fogDensity * 9_900 / SCALE.Q)); // 100..10000
96
+ const precipMul = precipVisionMul; // already in [q(0.30), q(1.0)]
97
+ // Combined: fog + precip (multiplicative)
98
+ const combinedVisionMul = Math.round(fogMul * precipMul / SCALE.Q);
99
+ const baseVisibility_Sm = Math.max(10 * SCALE.m, // minimum: 10 m visibility
100
+ Math.round(ATMO_BASE_VISIBILITY_Sm * combinedVisionMul / SCALE.Q));
101
+ // ── Acoustic masking (ambient wind noise) ─────────────────────────────────
102
+ const acousticMask_Q = clampQ(Math.round(wind.speed_mps * SCALE.Q / ATMO_ACOUSTIC_FULL_MASK_MPS), q(0), SCALE.Q);
103
+ // ── Sound propagation from biome ──────────────────────────────────────────
104
+ const soundPropagation_Q = biome?.soundPropagation !== undefined
105
+ ? biome.soundPropagation
106
+ : SCALE.Q;
107
+ return {
108
+ wind,
109
+ precipIntensity_Q,
110
+ baseVisibility_Sm,
111
+ tractionMod_Q,
112
+ acousticMask_Q,
113
+ soundPropagation_Q,
114
+ thermalOffset_Q,
115
+ };
116
+ }
117
+ // ── queryAtmosphericModifiers ─────────────────────────────────────────────────
118
+ /**
119
+ * Query all atmospheric modifiers for a position pair.
120
+ *
121
+ * Computes wind-relative effects along the `from → to` vector:
122
+ * - Crosswind component for projectile drift.
123
+ * - Tailwind/headwind ratio for hazard-cone range.
124
+ * - Directional acoustic effects.
125
+ * - Headwind-boosted precipitation degrading visibility.
126
+ * - Scent propagation strength (downwind = strong, upwind = none).
127
+ *
128
+ * Pure function — no mutation, safe to call multiple times per tick.
129
+ *
130
+ * @param from Observer / attacker position [SCALE.m].
131
+ * @param to Target / source position [SCALE.m].
132
+ * @param state Atmospheric state built by `deriveAtmosphericState`.
133
+ * @returns All modifiers for this position pair.
134
+ */
135
+ export function queryAtmosphericModifiers(from, to, state) {
136
+ const shotDx = to.x_Sm - from.x_Sm;
137
+ const shotDy = to.y_Sm - from.y_Sm;
138
+ const distSq = shotDx * shotDx + shotDy * shotDy;
139
+ const dist_Sm = distSq > 0 ? Math.trunc(Math.sqrt(distSq)) : 0;
140
+ const { wind } = state;
141
+ // ── Wind-direction geometry ────────────────────────────────────────────────
142
+ // Dot and cross products of wind direction (unit vector × SCALE.m) with shot
143
+ // direction (SCALE.m). Both raw values have units SCALE.m², scaled by dist.
144
+ // We normalise by dividing by dist_Sm to recover SCALE.m-range cosine/sine.
145
+ // dotNorm ∈ [-SCALE.m, SCALE.m]: positive = tailwind, negative = headwind.
146
+ // crossNorm ∈ [0, SCALE.m]: magnitude of perpendicular component.
147
+ let dotNorm = 0;
148
+ let crossNorm = 0;
149
+ if (dist_Sm > 0 && wind.speed_mps > 0) {
150
+ const dotRaw = wind.dx_m * shotDx + wind.dy_m * shotDy;
151
+ const crossRaw = wind.dx_m * shotDy - wind.dy_m * shotDx;
152
+ dotNorm = Math.trunc(dotRaw / dist_Sm); // SCALE.m units (cosine component)
153
+ crossNorm = Math.trunc(Math.abs(crossRaw) / dist_Sm); // SCALE.m units (sine component)
154
+ // Clamp to valid range (floating-point-free but integer rounding can exceed SCALE.m)
155
+ dotNorm = Math.max(-SCALE.m, Math.min(SCALE.m, dotNorm));
156
+ crossNorm = Math.min(SCALE.m, crossNorm);
157
+ }
158
+ // ── Crosswind speed (projectile drift) ───────────────────────────────────
159
+ // crossWindSpeed_mps = |sin θ| × wind.speed_mps
160
+ const crossWindSpeed_mps = Math.round(crossNorm * wind.speed_mps / SCALE.m);
161
+ // ── Hazard-cone multiplier ────────────────────────────────────────────────
162
+ // At full tailwind (dotNorm = +SCALE.m): 1.5× range.
163
+ // At full headwind (dotNorm = -SCALE.m): 0.5× range.
164
+ // Range: [ATMO_HAZARD_HEADWIND_MUL_MIN, ATMO_HAZARD_TAILWIND_MUL_MAX].
165
+ const HAZARD_HALF_RANGE = Math.round((ATMO_HAZARD_TAILWIND_MUL_MAX - ATMO_HAZARD_HEADWIND_MUL_MIN) / 2);
166
+ const hazardConeMul_Q = Math.max(ATMO_HAZARD_HEADWIND_MUL_MIN, Math.min(ATMO_HAZARD_TAILWIND_MUL_MAX, SCALE.Q + Math.round(dotNorm * HAZARD_HALF_RANGE / SCALE.m)));
167
+ // ── Acoustic masking multiplier ───────────────────────────────────────────
168
+ // Base: 1.0 − ambient wind noise masking.
169
+ // Directional bonus: source upwind of listener (negative dotNorm) → sound
170
+ // carries toward the listener → up to +ATMO_HEARING_UPWIND_BONUS.
171
+ const baseHearing = SCALE.Q - state.acousticMask_Q;
172
+ const upwindBonus = Math.max(0, Math.round(-dotNorm * ATMO_HEARING_UPWIND_BONUS / SCALE.m));
173
+ // Apply biome sound propagation (e.g. ×4 in water, ×0 in vacuum)
174
+ const rawAcousticMul = Math.round((baseHearing + upwindBonus) * state.soundPropagation_Q / SCALE.Q);
175
+ const acousticMaskMul_Q = Math.max(0, rawAcousticMul);
176
+ // ── Visibility (directional) ──────────────────────────────────────────────
177
+ // Headwind drives precipitation particles directly at the sensor, increasing
178
+ // effective precipitation density and reducing visibility further.
179
+ const headwindFrac = Math.max(0, -dotNorm); // 0..SCALE.m for headwind
180
+ // precipBoostPenalty: extra vision reduction from headwind-driven precipitation
181
+ const precipBoostPenalty = Math.round(headwindFrac * state.precipIntensity_Q / SCALE.m * q(0.30) / SCALE.Q);
182
+ const visionMul = Math.max(100, // ≥ 1% of base
183
+ SCALE.Q - Math.round(state.precipIntensity_Q * 3_000 / SCALE.Q) - precipBoostPenalty);
184
+ const visibilityRange_Sm = Math.max(10 * SCALE.m, Math.round(state.baseVisibility_Sm * visionMul / SCALE.Q));
185
+ // ── Scent strength ────────────────────────────────────────────────────────
186
+ // "from" smells "to" when wind blows from to→from (reversed shot direction
187
+ // aligns with wind direction).
188
+ // scentDotNorm = -dotNorm (positive = wind blows from `to` toward `from`).
189
+ const scentDotNorm = -dotNorm;
190
+ const scentStrength_Q = clampQ(Math.round(scentDotNorm * SCALE.Q / SCALE.m), q(0), SCALE.Q);
191
+ return {
192
+ crossWindSpeed_mps,
193
+ hazardConeMul_Q,
194
+ acousticMaskMul_Q,
195
+ visibilityRange_Sm,
196
+ tractionMod_Q: state.tractionMod_Q,
197
+ scentStrength_Q,
198
+ thermalOffset_Q: state.thermalOffset_Q,
199
+ };
200
+ }
@@ -0,0 +1,161 @@
1
+ import { type I32 } from "./units.js";
2
+ import { type TerrainGrid, type ObstacleGrid, type ElevationGrid, type SlopeGrid, type SurfaceType } from "./sim/terrain.js";
3
+ import { type CoverSegment } from "./sim/cover.js";
4
+ import type { CampaignState } from "./campaign.js";
5
+ import type { WorldState } from "./sim/world.js";
6
+ /** Battlefield width [SCALE.m]. 100 m. */
7
+ export declare const FIELD_WIDTH_Sm: I32;
8
+ /** Battlefield depth [SCALE.m]. 80 m. */
9
+ export declare const FIELD_HEIGHT_Sm: I32;
10
+ /** Terrain cell size [SCALE.m]. 10 m per cell → 10 × 8 grid. */
11
+ export declare const CELL_SIZE_Sm: I32;
12
+ /** Number of grid columns (field width / cell size). */
13
+ export declare const GRID_COLS = 10;
14
+ /** Number of grid rows (field height / cell size). */
15
+ export declare const GRID_ROWS = 8;
16
+ /** Attacker spawn y [SCALE.m] — 5 m from the south (y=0) edge. */
17
+ export declare const ATTACKER_SPAWN_Y_Sm: I32;
18
+ /** Defender spawn y [SCALE.m] — 5 m from the north edge. */
19
+ export declare const DEFENDER_SPAWN_Y_Sm: I32;
20
+ /**
21
+ * Campaign map tile type.
22
+ *
23
+ * Each hex type produces a distinct battlefield layout — surface type, cover
24
+ * density, elevation profile, and obstacle placement.
25
+ */
26
+ export type CampaignHexType = "plains" | "forest" | "hills" | "marsh" | "urban" | "mountain" | "river_crossing" | "coastal";
27
+ /**
28
+ * Initial spawn position and facing for one team entering the battlefield.
29
+ *
30
+ * Attackers (south-entry) have `facingY: 1` (moving toward increasing y).
31
+ * Defenders (north-entry) have `facingY: -1`.
32
+ */
33
+ export interface EntryVector {
34
+ /** Team identifier matching `entity.teamId`. */
35
+ teamId: number;
36
+ /** Spawn x-coordinate [SCALE.m]. */
37
+ x_Sm: number;
38
+ /** Spawn y-coordinate [SCALE.m]. */
39
+ y_Sm: number;
40
+ /**
41
+ * Initial movement direction along the y-axis.
42
+ * `1` = attacker advancing north.
43
+ * `-1` = defender holding or advancing south.
44
+ */
45
+ facingY: 1 | -1;
46
+ }
47
+ /**
48
+ * Complete tactical battlefield specification derived from a campaign hex encounter.
49
+ *
50
+ * ## Integration
51
+ * ```ts
52
+ * const site = generateBattleSite({ hexType: "forest", ... });
53
+ * const ctx: KernelContext = {
54
+ * tractionCoeff: SURFACE_TRACTION[site.dominantSurface],
55
+ * cellSize_m: site.cellSize_Sm,
56
+ * terrainGrid: site.terrainGrid,
57
+ * obstacleGrid: site.obstacleGrid,
58
+ * elevationGrid: site.elevationGrid,
59
+ * slopeGrid: site.slopeGrid,
60
+ * };
61
+ * // Position entities at site.entryVectors[n].x_Sm / y_Sm before first stepWorld.
62
+ * ```
63
+ */
64
+ export interface BattleTerrainParams {
65
+ /** Total battlefield width [SCALE.m]. */
66
+ width_Sm: number;
67
+ /** Total battlefield depth [SCALE.m]. */
68
+ height_Sm: number;
69
+ /** Cell size used for terrain grid lookups [SCALE.m]. */
70
+ cellSize_Sm: number;
71
+ /** Per-cell surface type — pass to `KernelContext.terrainGrid`. */
72
+ terrainGrid: TerrainGrid;
73
+ /** Per-cell cover fraction — pass to `KernelContext.obstacleGrid`. */
74
+ obstacleGrid: ObstacleGrid;
75
+ /** Per-cell elevation above ground [SCALE.m] — pass to `KernelContext.elevationGrid`. */
76
+ elevationGrid: ElevationGrid;
77
+ /** Per-cell slope direction and grade — pass to `KernelContext.slopeGrid`. */
78
+ slopeGrid: SlopeGrid;
79
+ /** Structural cover segments to place in the world before battle begins. */
80
+ coverSegments: CoverSegment[];
81
+ /** Entry spawn positions for each team. */
82
+ entryVectors: EntryVector[];
83
+ /**
84
+ * Dominant surface type — use `SURFACE_TRACTION[dominantSurface]` as
85
+ * the default `KernelContext.tractionCoeff`.
86
+ */
87
+ dominantSurface: SurfaceType;
88
+ }
89
+ /**
90
+ * Context provided to `generateBattleSite`.
91
+ */
92
+ export interface BattleSiteContext {
93
+ /** Campaign hex tile where the battle occurs. */
94
+ hexType: CampaignHexType;
95
+ /** Attacking team ids — they enter from the south (y ≈ 0). */
96
+ attackerTeamIds: number[];
97
+ /** Defending team ids — they enter from the north (y ≈ FIELD_HEIGHT). */
98
+ defenderTeamIds: number[];
99
+ /**
100
+ * World seed from the campaign — reserved for future micro-variance.
101
+ * Currently unused but forwarded for determinism documentation purposes.
102
+ */
103
+ seed?: number;
104
+ }
105
+ /**
106
+ * Outcome produced by the tactical simulation, passed to `mergeBattleOutcome`.
107
+ */
108
+ export interface BattleOutcome {
109
+ /** Final `WorldState` after the tactical battle ends. */
110
+ worldState: WorldState;
111
+ /**
112
+ * Battle duration in simulated seconds, added to `CampaignState.worldTime_s`.
113
+ */
114
+ elapsedSeconds: number;
115
+ /**
116
+ * Entity ids of combatants incapacitated or captured on the losing side.
117
+ * Their weapons and armour will be transferred to the winning team's inventory.
118
+ */
119
+ capturedEntityIds?: number[];
120
+ /**
121
+ * Team id of the victorious side. When `undefined` the battle is a draw and
122
+ * no equipment transfer occurs.
123
+ */
124
+ winnerTeamId?: number;
125
+ }
126
+ /**
127
+ * Build terrain, obstacle, elevation, and slope grids for a campaign hex type.
128
+ *
129
+ * Fully deterministic — produces the same output for the same `hexType` every
130
+ * call. Does not include `entryVectors`; use `generateBattleSite` for a full
131
+ * site including team spawn positions.
132
+ *
133
+ * Grid layout: 10 columns × 8 rows, each cell 10 m (100 000 SCALE.m).
134
+ * Total field: 100 m wide × 80 m deep.
135
+ */
136
+ export declare function extractTerrainParams(hexType: CampaignHexType): Omit<BattleTerrainParams, "entryVectors">;
137
+ /**
138
+ * Generate a complete battle site for a campaign encounter.
139
+ *
140
+ * Calls `extractTerrainParams` and appends `EntryVector` entries for each
141
+ * attacking and defending team.
142
+ *
143
+ * Teams with more than three members reuse spawn positions (cyclic).
144
+ */
145
+ export declare function generateBattleSite(ctx: BattleSiteContext): BattleTerrainParams;
146
+ /**
147
+ * Merge a completed tactical battle back into campaign state.
148
+ *
149
+ * **What this does:**
150
+ * - Advances `campaign.worldTime_s` by `outcome.elapsedSeconds`.
151
+ * - Removes entities that died in battle (`injury.dead === true`) from the
152
+ * campaign entity registry, location map, and inventory map.
153
+ * - Copies post-battle `injury` and `condition` state onto surviving campaign
154
+ * entities so wounds persist between encounters.
155
+ * - Transfers weapons and armour from `capturedEntityIds` to the winning
156
+ * team's first surviving entity inventory (item id → count).
157
+ * - Appends a human-readable battle summary to `campaign.log`.
158
+ *
159
+ * Mutates `campaign` in-place.
160
+ */
161
+ export declare function mergeBattleOutcome(campaign: CampaignState, outcome: BattleOutcome): void;
@@ -0,0 +1,322 @@
1
+ // src/terrain-bridge.ts — PA-5: Campaign ↔ Tactical Terrain Bridge
2
+ //
3
+ // Maps campaign hex tiles to tactical battlefield parameters consumable by
4
+ // KernelContext, and merges tactical battle results back into CampaignState.
5
+ //
6
+ // Typical workflow:
7
+ // 1. Army enters a forest hex on the campaign map.
8
+ // 2. Call generateBattleSite({ hexType: "forest", ... }) → BattleTerrainParams.
9
+ // 3. Build KernelContext from terrain params; run stepWorld until battle ends.
10
+ // 4. Call mergeBattleOutcome(campaign, { worldState, elapsedSeconds }) to
11
+ // apply casualties, injuries, and looted equipment back to campaign.
12
+ import { q } from "./units.js";
13
+ import { buildTerrainGrid, buildObstacleGrid, buildElevationGrid, buildSlopeGrid, terrainKey, } from "./sim/terrain.js";
14
+ import { createCoverSegment } from "./sim/cover.js";
15
+ // ── Field constants ────────────────────────────────────────────────────────────
16
+ /** Battlefield width [SCALE.m]. 100 m. */
17
+ export const FIELD_WIDTH_Sm = 1_000_000;
18
+ /** Battlefield depth [SCALE.m]. 80 m. */
19
+ export const FIELD_HEIGHT_Sm = 800_000;
20
+ /** Terrain cell size [SCALE.m]. 10 m per cell → 10 × 8 grid. */
21
+ export const CELL_SIZE_Sm = 100_000;
22
+ /** Number of grid columns (field width / cell size). */
23
+ export const GRID_COLS = 10;
24
+ /** Number of grid rows (field height / cell size). */
25
+ export const GRID_ROWS = 8;
26
+ /** Attacker spawn y [SCALE.m] — 5 m from the south (y=0) edge. */
27
+ export const ATTACKER_SPAWN_Y_Sm = 50_000;
28
+ /** Defender spawn y [SCALE.m] — 5 m from the north edge. */
29
+ export const DEFENDER_SPAWN_Y_Sm = (FIELD_HEIGHT_Sm - 50_000);
30
+ // ── extractTerrainParams ──────────────────────────────────────────────────────
31
+ /**
32
+ * Build terrain, obstacle, elevation, and slope grids for a campaign hex type.
33
+ *
34
+ * Fully deterministic — produces the same output for the same `hexType` every
35
+ * call. Does not include `entryVectors`; use `generateBattleSite` for a full
36
+ * site including team spawn positions.
37
+ *
38
+ * Grid layout: 10 columns × 8 rows, each cell 10 m (100 000 SCALE.m).
39
+ * Total field: 100 m wide × 80 m deep.
40
+ */
41
+ export function extractTerrainParams(hexType) {
42
+ switch (hexType) {
43
+ case "plains": return _plains();
44
+ case "forest": return _forest();
45
+ case "hills": return _hills();
46
+ case "marsh": return _marsh();
47
+ case "urban": return _urban();
48
+ case "mountain": return _mountain();
49
+ case "river_crossing": return _riverCrossing();
50
+ case "coastal": return _coastal();
51
+ }
52
+ }
53
+ // ── generateBattleSite ────────────────────────────────────────────────────────
54
+ /** Three evenly-spaced x spawn positions (15 m, 50 m, 85 m). */
55
+ const SPAWN_X_Sm = [150_000, 500_000, 850_000];
56
+ /**
57
+ * Generate a complete battle site for a campaign encounter.
58
+ *
59
+ * Calls `extractTerrainParams` and appends `EntryVector` entries for each
60
+ * attacking and defending team.
61
+ *
62
+ * Teams with more than three members reuse spawn positions (cyclic).
63
+ */
64
+ export function generateBattleSite(ctx) {
65
+ const base = extractTerrainParams(ctx.hexType);
66
+ const entryVectors = [];
67
+ for (let i = 0; i < ctx.attackerTeamIds.length; i++) {
68
+ entryVectors.push({
69
+ teamId: ctx.attackerTeamIds[i],
70
+ x_Sm: SPAWN_X_Sm[i % SPAWN_X_Sm.length],
71
+ y_Sm: ATTACKER_SPAWN_Y_Sm,
72
+ facingY: 1,
73
+ });
74
+ }
75
+ for (let i = 0; i < ctx.defenderTeamIds.length; i++) {
76
+ entryVectors.push({
77
+ teamId: ctx.defenderTeamIds[i],
78
+ x_Sm: SPAWN_X_Sm[i % SPAWN_X_Sm.length],
79
+ y_Sm: DEFENDER_SPAWN_Y_Sm,
80
+ facingY: -1,
81
+ });
82
+ }
83
+ return { ...base, entryVectors };
84
+ }
85
+ // ── mergeBattleOutcome ────────────────────────────────────────────────────────
86
+ /**
87
+ * Merge a completed tactical battle back into campaign state.
88
+ *
89
+ * **What this does:**
90
+ * - Advances `campaign.worldTime_s` by `outcome.elapsedSeconds`.
91
+ * - Removes entities that died in battle (`injury.dead === true`) from the
92
+ * campaign entity registry, location map, and inventory map.
93
+ * - Copies post-battle `injury` and `condition` state onto surviving campaign
94
+ * entities so wounds persist between encounters.
95
+ * - Transfers weapons and armour from `capturedEntityIds` to the winning
96
+ * team's first surviving entity inventory (item id → count).
97
+ * - Appends a human-readable battle summary to `campaign.log`.
98
+ *
99
+ * Mutates `campaign` in-place.
100
+ */
101
+ export function mergeBattleOutcome(campaign, outcome) {
102
+ campaign.worldTime_s += outcome.elapsedSeconds;
103
+ const { worldState, capturedEntityIds = [], winnerTeamId } = outcome;
104
+ let killed = 0;
105
+ let survived = 0;
106
+ for (const entity of worldState.entities) {
107
+ const campaignEntity = campaign.entities.get(entity.id);
108
+ if (campaignEntity === undefined)
109
+ continue; // not in this campaign
110
+ if (_isDead(entity)) {
111
+ campaign.entities.delete(entity.id);
112
+ campaign.entityLocations.delete(entity.id);
113
+ campaign.entityInventories.delete(entity.id);
114
+ killed++;
115
+ }
116
+ else {
117
+ // Carry forward post-battle wounds and psychological state
118
+ if (entity.injury !== undefined)
119
+ campaignEntity.injury = entity.injury;
120
+ if (entity.condition !== undefined)
121
+ campaignEntity.condition = entity.condition;
122
+ survived++;
123
+ }
124
+ }
125
+ // Equipment transfer: looted gear from captured/incapacitated enemies
126
+ if (winnerTeamId !== undefined && capturedEntityIds.length > 0) {
127
+ const winnerId = _firstAliveInTeam(worldState, winnerTeamId);
128
+ if (winnerId !== undefined) {
129
+ const winInv = campaign.entityInventories.get(winnerId) ?? new Map();
130
+ for (const capturedId of capturedEntityIds) {
131
+ // Carry items from campaign inventory
132
+ const inv = campaign.entityInventories.get(capturedId);
133
+ if (inv !== undefined) {
134
+ for (const [itemId, count] of inv) {
135
+ winInv.set(itemId, (winInv.get(itemId) ?? 0) + count);
136
+ }
137
+ }
138
+ // Transfer equipped items (weapons, armour) by id
139
+ const capEntity = campaign.entities.get(capturedId);
140
+ if (capEntity !== undefined) {
141
+ for (const item of capEntity.loadout.items) {
142
+ winInv.set(item.id, (winInv.get(item.id) ?? 0) + 1);
143
+ }
144
+ }
145
+ }
146
+ campaign.entityInventories.set(winnerId, winInv);
147
+ }
148
+ }
149
+ const outcome_note = winnerTeamId !== undefined
150
+ ? ` Team ${winnerTeamId} victorious.`
151
+ : " Draw.";
152
+ campaign.log.push({
153
+ worldTime_s: campaign.worldTime_s,
154
+ text: `Battle concluded (${survived} survived, ${killed} killed).${outcome_note}`,
155
+ });
156
+ }
157
+ // ── Internal helpers ──────────────────────────────────────────────────────────
158
+ function _isDead(entity) {
159
+ return entity.injury?.dead === true;
160
+ }
161
+ function _firstAliveInTeam(world, teamId) {
162
+ for (const e of world.entities) {
163
+ if (e.teamId === teamId && !_isDead(e))
164
+ return e.id;
165
+ }
166
+ return undefined;
167
+ }
168
+ function _base() {
169
+ return {
170
+ width_Sm: FIELD_WIDTH_Sm,
171
+ height_Sm: FIELD_HEIGHT_Sm,
172
+ cellSize_Sm: CELL_SIZE_Sm,
173
+ terrainGrid: new Map(),
174
+ obstacleGrid: new Map(),
175
+ elevationGrid: new Map(),
176
+ slopeGrid: new Map(),
177
+ coverSegments: [],
178
+ dominantSurface: "normal",
179
+ };
180
+ }
181
+ function ck(col, row) {
182
+ return terrainKey(col, row);
183
+ }
184
+ /** Plains: open ground, two dirt berm defensive lines. */
185
+ function _plains() {
186
+ const r = _base();
187
+ r.coverSegments.push(createCoverSegment("plains_berm_s", 200_000, 250_000, 600_000, 8_000, "dirt"), createCoverSegment("plains_berm_n", 200_000, 550_000, 600_000, 8_000, "dirt"));
188
+ return r;
189
+ }
190
+ /** Forest: muddy undergrowth, partial cover, two wood tree-lines flanking a central path. */
191
+ function _forest() {
192
+ const r = _base();
193
+ r.dominantSurface = "mud";
194
+ const terrain = {};
195
+ const obstacles = {};
196
+ for (let col = 0; col < GRID_COLS; col++) {
197
+ for (let row = 0; row < GRID_ROWS; row++) {
198
+ const key = ck(col, row);
199
+ if (row !== 3 && row !== 4) { // rows 3-4 = natural path (no mud)
200
+ terrain[key] = "mud";
201
+ obstacles[key] = q(0.40); // dense undergrowth — 40% cover
202
+ }
203
+ }
204
+ }
205
+ r.terrainGrid = buildTerrainGrid(terrain);
206
+ r.obstacleGrid = buildObstacleGrid(obstacles);
207
+ // Tree-lines flanking the path (y = 25 m and y = 50 m)
208
+ r.coverSegments.push(createCoverSegment("forest_tree_s", 0, 250_000, 1_000_000, 12_000, "wood"), createCoverSegment("forest_tree_n", 0, 500_000, 1_000_000, 12_000, "wood"));
209
+ return r;
210
+ }
211
+ /** Hills: gradient elevation south-to-north, stone cover on the crest. */
212
+ function _hills() {
213
+ const r = _base();
214
+ r.dominantSurface = "slope_up";
215
+ const terrain = {};
216
+ const elevation = {};
217
+ const slopes = {};
218
+ for (let col = 0; col < GRID_COLS; col++) {
219
+ for (let row = 0; row < GRID_ROWS; row++) {
220
+ const key = ck(col, row);
221
+ if (row < 4) {
222
+ // South half — uphill approach; elevation rises 5 m per row (0–15 m)
223
+ terrain[key] = "slope_up";
224
+ elevation[key] = (row * 50_000);
225
+ slopes[key] = { type: "uphill", grade: q(0.50) };
226
+ }
227
+ else {
228
+ // North half — downhill on far side of crest
229
+ terrain[key] = "slope_down";
230
+ elevation[key] = ((GRID_ROWS - 1 - row) * 50_000);
231
+ slopes[key] = { type: "downhill", grade: q(0.50) };
232
+ }
233
+ }
234
+ }
235
+ r.terrainGrid = buildTerrainGrid(terrain);
236
+ r.elevationGrid = buildElevationGrid(elevation);
237
+ r.slopeGrid = buildSlopeGrid(slopes);
238
+ // Stone wall along the ridgeline (y ≈ 40 m)
239
+ r.coverSegments.push(createCoverSegment("hills_ridgeline", 100_000, 400_000, 800_000, 10_000, "stone"));
240
+ return r;
241
+ }
242
+ /** Marsh: all-mud terrain, no cover — speed heavily penalised. */
243
+ function _marsh() {
244
+ const r = _base();
245
+ r.dominantSurface = "mud";
246
+ const terrain = {};
247
+ for (let col = 0; col < GRID_COLS; col++) {
248
+ for (let row = 0; row < GRID_ROWS; row++) {
249
+ terrain[ck(col, row)] = "mud";
250
+ }
251
+ }
252
+ r.terrainGrid = buildTerrainGrid(terrain);
253
+ return r;
254
+ }
255
+ /** Urban: dense stone and wood cover forming a street grid; partial obstacle cells. */
256
+ function _urban() {
257
+ const r = _base();
258
+ // Stone building walls — south block, mid block, north block
259
+ r.coverSegments.push(createCoverSegment("urban_s1", 0, 200_000, 350_000, 20_000, "stone"), createCoverSegment("urban_s2", 450_000, 200_000, 350_000, 20_000, "stone"), createCoverSegment("urban_s3", 200_000, 300_000, 200_000, 20_000, "stone"), createCoverSegment("urban_m1", 0, 400_000, 250_000, 20_000, "stone"), createCoverSegment("urban_m2", 350_000, 400_000, 300_000, 20_000, "stone"), createCoverSegment("urban_m3", 750_000, 400_000, 250_000, 20_000, "stone"), createCoverSegment("urban_n1", 100_000, 600_000, 350_000, 20_000, "stone"), createCoverSegment("urban_n2", 550_000, 600_000, 350_000, 20_000, "stone"), createCoverSegment("urban_barr1", 350_000, 350_000, 100_000, 10_000, "wood"), createCoverSegment("urban_barr2", 350_000, 500_000, 100_000, 10_000, "wood"));
260
+ // Partial obstacles in building interiors (~50% cover)
261
+ r.obstacleGrid = buildObstacleGrid({
262
+ [ck(1, 2)]: q(0.50),
263
+ [ck(5, 2)]: q(0.50),
264
+ [ck(2, 4)]: q(0.50),
265
+ [ck(7, 4)]: q(0.50),
266
+ });
267
+ return r;
268
+ }
269
+ /** Mountain: steep icy ascent, high elevation, rocky outcrops. */
270
+ function _mountain() {
271
+ const r = _base();
272
+ r.dominantSurface = "slope_up";
273
+ const terrain = {};
274
+ const elevation = {};
275
+ const slopes = {};
276
+ for (let col = 0; col < GRID_COLS; col++) {
277
+ for (let row = 0; row < GRID_ROWS; row++) {
278
+ const key = ck(col, row);
279
+ elevation[key] = (row * 100_000); // 0–70 m rise
280
+ terrain[key] = row >= 4 ? "ice" : "slope_up";
281
+ slopes[key] = {
282
+ type: "uphill",
283
+ grade: (row >= 4 ? q(0.80) : q(0.60)),
284
+ };
285
+ }
286
+ }
287
+ r.terrainGrid = buildTerrainGrid(terrain);
288
+ r.elevationGrid = buildElevationGrid(elevation);
289
+ r.slopeGrid = buildSlopeGrid(slopes);
290
+ // Rocky outcrops for cover
291
+ r.coverSegments.push(createCoverSegment("mtn_rock1", 100_000, 200_000, 150_000, 15_000, "stone"), createCoverSegment("mtn_rock2", 600_000, 350_000, 200_000, 15_000, "stone"), createCoverSegment("mtn_rock3", 300_000, 500_000, 150_000, 15_000, "stone"));
292
+ return r;
293
+ }
294
+ /** River crossing: muddy ford in the center, sandbag cover on the defending bank. */
295
+ function _riverCrossing() {
296
+ const r = _base();
297
+ r.dominantSurface = "mud";
298
+ // Mud band at rows 3-4 (30–50 m) — the ford
299
+ const terrain = {};
300
+ for (let col = 0; col < GRID_COLS; col++) {
301
+ terrain[ck(col, 3)] = "mud";
302
+ terrain[ck(col, 4)] = "mud";
303
+ }
304
+ r.terrainGrid = buildTerrainGrid(terrain);
305
+ // Sandbag defensive line on the far (north) bank
306
+ r.coverSegments.push(createCoverSegment("river_cover_w", 50_000, 500_000, 350_000, 12_000, "sandbag"), createCoverSegment("river_cover_e", 600_000, 500_000, 350_000, 12_000, "sandbag"));
307
+ return r;
308
+ }
309
+ /** Coastal: muddy beach approach (south 2 rows), dunes and rocky outcrops for cover. */
310
+ function _coastal() {
311
+ const r = _base();
312
+ // Soft sand / surf zone — rows 0-1 (0–20 m from south)
313
+ const terrain = {};
314
+ for (let col = 0; col < GRID_COLS; col++) {
315
+ terrain[ck(col, 0)] = "mud";
316
+ terrain[ck(col, 1)] = "mud";
317
+ }
318
+ r.terrainGrid = buildTerrainGrid(terrain);
319
+ // Dunes and rocky coastal outcrops
320
+ r.coverSegments.push(createCoverSegment("coastal_dune1", 100_000, 150_000, 300_000, 8_000, "dirt"), createCoverSegment("coastal_dune2", 600_000, 150_000, 300_000, 8_000, "dirt"), createCoverSegment("coastal_rock1", 0, 350_000, 200_000, 12_000, "stone"), createCoverSegment("coastal_rock2", 800_000, 450_000, 200_000, 12_000, "stone"));
321
+ return r;
322
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@its-not-rocket-science/ananke",
3
- "version": "0.1.53",
3
+ "version": "0.1.55",
4
4
  "type": "module",
5
5
  "description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
6
6
  "license": "MIT",
@@ -181,6 +181,14 @@
181
181
  "./content-pack": {
182
182
  "import": "./dist/src/content-pack.js",
183
183
  "types": "./dist/src/content-pack.d.ts"
184
+ },
185
+ "./terrain-bridge": {
186
+ "import": "./dist/src/terrain-bridge.js",
187
+ "types": "./dist/src/terrain-bridge.d.ts"
188
+ },
189
+ "./atmosphere": {
190
+ "import": "./dist/src/atmosphere.js",
191
+ "types": "./dist/src/atmosphere.d.ts"
184
192
  }
185
193
  },
186
194
  "workspaces": [