@its-not-rocket-science/ananke 0.1.54 → 0.1.56
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 +32 -0
- package/dist/src/atmosphere.d.ts +222 -0
- package/dist/src/atmosphere.js +200 -0
- package/dist/src/extended-senses.d.ts +146 -0
- package/dist/src/extended-senses.js +310 -0
- package/dist/src/sim/sensory-extended.d.ts +11 -0
- package/package.json +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,38 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## [0.1.56] — 2026-03-30
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **PA-7 — Advanced Non-Visual Sensory Systems (complete):**
|
|
14
|
+
- `src/sim/sensory-extended.ts`: added `thermalVisionRange_m?: number` to `ExtendedSenses` interface — fourth modality alongside echolocation, electroreception, and olfaction. Effective range scales with target thermal signature; degraded by precipitation; dead entities have no thermal signature.
|
|
15
|
+
- `src/extended-senses.ts` (new): unified extended-senses module with `AtmosphericState` integration (PA-6).
|
|
16
|
+
- Body-plan predicates: `hasEcholocation`, `hasElectroreception`, `hasThermalVision`, `hasOlfaction`, `dominantSense` (priority: electroreception > echolocation > thermal > olfaction > vision).
|
|
17
|
+
- `thermalSignature(entity)` → Q: dead=q(0); living=base q(0.30) + q(0.10) per bleeding region + q(0.15) if shock≥q(0.40).
|
|
18
|
+
- `canDetectByThermalVision(observer, subject, dist_m, precipIntensity?)`: effective range = baseRange × signature / SCALE.Q × (1 − precipIntensity × 0.60). Detection quality `DETECT_THERMAL = q(0.35)`.
|
|
19
|
+
- `canDetectExtendedAtmospheric(observer, subject, env, atmospheric, sensorBoost?)`: drop-in replacement for Phase 52 `canDetectExtended` that uses `AtmosphericState.scentStrength_Q` from `queryAtmosphericModifiers` for olfaction, and `precipIntensity_Q` for thermal attenuation.
|
|
20
|
+
- `stepExtendedSenses(observer, world, atmospheric, env)` → `ExtendedSensesResult { detections }`: per-tick batch detection accumulator; iterates all world entities, checks all four extended modalities, returns `SensoryDetection[]` with `entityId`, `modality`, `quality_Q`, `dist_Sm`. Multiple detections per target are possible.
|
|
21
|
+
- Exports: `SenseModality`, `SensoryDetection`, `ExtendedSensesResult`, `THERMAL_BASE_SIGNATURE_Q`, `THERMAL_BLEED_BONUS_Q`, `THERMAL_SHOCK_BONUS_Q`, `THERMAL_SHOCK_THRESHOLD`, `THERMAL_PRECIP_PENALTY`, `DETECT_THERMAL`, `DETECT_OLFACTION_ATMO_MIN`, `DETECT_OLFACTION_ATMO_MAX`.
|
|
22
|
+
- `"./extended-senses"` subpath export added to `package.json`.
|
|
23
|
+
- 60 new tests (187 test files, 5,512 tests total). Build: clean.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## [0.1.55] — 2026-03-30
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- **PA-6 — Unified Atmosphere Model (complete):**
|
|
32
|
+
- `src/atmosphere.ts` (new): single `AtmosphericState` struct derived from Phase 51 `WeatherState` and Phase 68 `BiomeContext`, with a unified per-pair query API.
|
|
33
|
+
- `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`.
|
|
34
|
+
- `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`.
|
|
35
|
+
- `"./atmosphere"` subpath export added to `package.json`.
|
|
36
|
+
- 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`.
|
|
37
|
+
- 53 new tests (186 test files, 5,452 tests total). Build: clean.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
9
41
|
## [0.1.54] — 2026-03-28
|
|
10
42
|
|
|
11
43
|
### 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,146 @@
|
|
|
1
|
+
import { type Q } from "./units.js";
|
|
2
|
+
import type { Entity } from "./sim/entity.js";
|
|
3
|
+
import type { WorldState } from "./sim/world.js";
|
|
4
|
+
import type { SensoryEnvironment } from "./sim/sensory.js";
|
|
5
|
+
import { type AtmosphericState } from "./atmosphere.js";
|
|
6
|
+
/** Sensory modality used for a detection. */
|
|
7
|
+
export type SenseModality = "vision" | "echolocation" | "electroreception" | "olfaction" | "thermal";
|
|
8
|
+
/**
|
|
9
|
+
* A single detected entity and the sense used to detect it.
|
|
10
|
+
*
|
|
11
|
+
* Multiple detections for the same `entityId` are possible (e.g. a close
|
|
12
|
+
* target may be detected by both olfaction and echolocation). Callers should
|
|
13
|
+
* take the maximum `quality_Q` per entity for targeting decisions.
|
|
14
|
+
*/
|
|
15
|
+
export interface SensoryDetection {
|
|
16
|
+
/** Id of the detected entity. */
|
|
17
|
+
entityId: number;
|
|
18
|
+
/** Modality that produced this detection. */
|
|
19
|
+
modality: SenseModality;
|
|
20
|
+
/**
|
|
21
|
+
* Detection quality [Q 0..SCALE.Q].
|
|
22
|
+
* Higher = more precise positional information.
|
|
23
|
+
* q(0.80) = electroreception; q(0.70) = echolocation; q(0.40) = olfaction/thermal.
|
|
24
|
+
*/
|
|
25
|
+
quality_Q: Q;
|
|
26
|
+
/** Distance from observer to detected entity [SCALE.m]. */
|
|
27
|
+
dist_Sm: number;
|
|
28
|
+
}
|
|
29
|
+
/** Result of a `stepExtendedSenses` call. */
|
|
30
|
+
export interface ExtendedSensesResult {
|
|
31
|
+
/** All detections produced this tick. May be empty. */
|
|
32
|
+
detections: SensoryDetection[];
|
|
33
|
+
}
|
|
34
|
+
/** Thermal signature of a living entity at rest [Q]. */
|
|
35
|
+
export declare const THERMAL_BASE_SIGNATURE_Q: Q;
|
|
36
|
+
/**
|
|
37
|
+
* Additional thermal signature per bleeding body region [Q].
|
|
38
|
+
* Warm blood on the surface raises infrared contrast.
|
|
39
|
+
*/
|
|
40
|
+
export declare const THERMAL_BLEED_BONUS_Q: Q;
|
|
41
|
+
/**
|
|
42
|
+
* Additional thermal signature when entity shock exceeds `THERMAL_SHOCK_THRESHOLD`.
|
|
43
|
+
* Fever and inflammation elevate core temperature.
|
|
44
|
+
*/
|
|
45
|
+
export declare const THERMAL_SHOCK_BONUS_Q: Q;
|
|
46
|
+
/** Shock level [Q] above which a fever/inflammatory bonus applies. */
|
|
47
|
+
export declare const THERMAL_SHOCK_THRESHOLD: Q;
|
|
48
|
+
/**
|
|
49
|
+
* Precipitation reduces effective thermal range.
|
|
50
|
+
* At precipIntensity_Q = SCALE.Q: range × (1 - THERMAL_PRECIP_PENALTY).
|
|
51
|
+
*/
|
|
52
|
+
export declare const THERMAL_PRECIP_PENALTY: Q;
|
|
53
|
+
/** Detection quality returned for thermal detections. */
|
|
54
|
+
export declare const DETECT_THERMAL: Q;
|
|
55
|
+
/** Minimum olfaction detection quality when atmospheric scentStrength_Q is q(1.0). */
|
|
56
|
+
export declare const DETECT_OLFACTION_ATMO_MIN: Q;
|
|
57
|
+
/** Maximum olfaction detection quality. */
|
|
58
|
+
export declare const DETECT_OLFACTION_ATMO_MAX: Q;
|
|
59
|
+
/** Returns `true` if the entity has echolocation capability. */
|
|
60
|
+
export declare function hasEcholocation(entity: Entity): boolean;
|
|
61
|
+
/** Returns `true` if the entity has electroreception capability. */
|
|
62
|
+
export declare function hasElectroreception(entity: Entity): boolean;
|
|
63
|
+
/** Returns `true` if the entity has thermal (infrared) vision. */
|
|
64
|
+
export declare function hasThermalVision(entity: Entity): boolean;
|
|
65
|
+
/** Returns `true` if the entity has olfaction (scent) capability. */
|
|
66
|
+
export declare function hasOlfaction(entity: Entity): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Returns the entity's dominant non-visual sense.
|
|
69
|
+
*
|
|
70
|
+
* Priority: electroreception > echolocation > thermal > olfaction > vision.
|
|
71
|
+
*
|
|
72
|
+
* Use this to steer AI targeting logic:
|
|
73
|
+
* - `"echolocation"` → entity can hunt in total darkness.
|
|
74
|
+
* - `"electroreception"` → entity detects living creatures at close range
|
|
75
|
+
* regardless of light, noise, or scent.
|
|
76
|
+
* - `"thermal"` → entity detects warm-bodied prey by heat signature.
|
|
77
|
+
* - `"olfaction"` → entity tracks prey by scent trail (wind-dependent).
|
|
78
|
+
* - `"vision"` → standard visual detection (default).
|
|
79
|
+
*/
|
|
80
|
+
export declare function dominantSense(entity: Entity): SenseModality;
|
|
81
|
+
/**
|
|
82
|
+
* Compute the thermal signature of an entity [Q 0..SCALE.Q].
|
|
83
|
+
*
|
|
84
|
+
* Dead entities return q(0) — no remaining metabolic heat.
|
|
85
|
+
* Living entities radiate at least `THERMAL_BASE_SIGNATURE_Q`.
|
|
86
|
+
* Active bleeding and fever raise the signature further.
|
|
87
|
+
*/
|
|
88
|
+
export declare function thermalSignature(entity: Entity): Q;
|
|
89
|
+
/**
|
|
90
|
+
* Whether observer can detect subject via thermal (infrared) vision.
|
|
91
|
+
*
|
|
92
|
+
* - Requires `observer.extendedSenses.thermalVisionRange_m > 0`.
|
|
93
|
+
* - Dead entities have no thermal signature and are not detected.
|
|
94
|
+
* - Effective range: `thermalVisionRange_m × signature_Q / SCALE.Q`,
|
|
95
|
+
* further reduced by precipitation (`THERMAL_PRECIP_PENALTY`).
|
|
96
|
+
* - Unaffected by ambient light or noise.
|
|
97
|
+
*
|
|
98
|
+
* @param dist_m Distance from observer to subject [SCALE.m].
|
|
99
|
+
* @param precipIntensity Precipitation intensity [Q 0..SCALE.Q] from AtmosphericState.
|
|
100
|
+
*/
|
|
101
|
+
export declare function canDetectByThermalVision(observer: Entity, subject: Entity, dist_m: number, precipIntensity?: Q): boolean;
|
|
102
|
+
/**
|
|
103
|
+
* Full detection check using all four extended modalities, with `AtmosphericState`
|
|
104
|
+
* integration for olfaction and thermal.
|
|
105
|
+
*
|
|
106
|
+
* Returns best detection quality [Q] across all active senses:
|
|
107
|
+
* - q(1.0): vision (Phase 4)
|
|
108
|
+
* - q(0.80): electroreception
|
|
109
|
+
* - q(0.70): echolocation
|
|
110
|
+
* - q(0.20–0.40): olfaction (atmospheric, wind/precip dependent)
|
|
111
|
+
* - q(0.35): thermal (heat-signature dependent)
|
|
112
|
+
* - q(0): undetected
|
|
113
|
+
*
|
|
114
|
+
* Use this as a drop-in replacement for `canDetectExtended` when an
|
|
115
|
+
* `AtmosphericState` is available.
|
|
116
|
+
*/
|
|
117
|
+
export declare function canDetectExtendedAtmospheric(observer: Entity, subject: Entity, env: SensoryEnvironment, atmospheric: AtmosphericState, sensorBoost?: {
|
|
118
|
+
visionRangeMul: Q;
|
|
119
|
+
hearingRangeMul: Q;
|
|
120
|
+
}): Q;
|
|
121
|
+
/**
|
|
122
|
+
* Accumulate all extended-sense detections for one observer entity.
|
|
123
|
+
*
|
|
124
|
+
* Iterates all entities in `world`, skips the observer itself, and for each
|
|
125
|
+
* other entity checks all four extended modalities. Multiple detections per
|
|
126
|
+
* target are possible and are all returned (callers take the max quality).
|
|
127
|
+
*
|
|
128
|
+
* Visual and hearing detection is **not** included — use `canDetect` (Phase 4)
|
|
129
|
+
* or `canDetectExtendedAtmospheric` for full detection checks.
|
|
130
|
+
*
|
|
131
|
+
* @param observer The sensing entity.
|
|
132
|
+
* @param world Current world state (iterated for targets).
|
|
133
|
+
* @param atmospheric Atmospheric state from `deriveAtmosphericState` (PA-6).
|
|
134
|
+
* @param env Sensory environment for echolocation noise level.
|
|
135
|
+
* @returns All detections this tick (may be empty).
|
|
136
|
+
*
|
|
137
|
+
* @example
|
|
138
|
+
* ```ts
|
|
139
|
+
* const atmo = deriveAtmosphericState(ctx.weather, ctx.biome);
|
|
140
|
+
* const result = stepExtendedSenses(bat, world, atmo, ctx.sensoryEnv ?? DEFAULT_SENSORY_ENV);
|
|
141
|
+
* for (const det of result.detections) {
|
|
142
|
+
* // det.entityId, det.modality, det.quality_Q, det.dist_Sm
|
|
143
|
+
* }
|
|
144
|
+
* ```
|
|
145
|
+
*/
|
|
146
|
+
export declare function stepExtendedSenses(observer: Entity, world: WorldState, atmospheric: AtmosphericState, env: SensoryEnvironment): ExtendedSensesResult;
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// src/extended-senses.ts — PA-7: Advanced Non-Visual Sensory Systems
|
|
2
|
+
//
|
|
3
|
+
// Extends Phase 52 (sensory-extended.ts) with:
|
|
4
|
+
// - Thermal (infrared) detection — 4th modality alongside echolocation,
|
|
5
|
+
// electroreception, and olfaction.
|
|
6
|
+
// - stepExtendedSenses(entity, world, atmospheric, env) — batch per-tick
|
|
7
|
+
// detection accumulator returning structured results per modality.
|
|
8
|
+
// - AtmosphericState integration — olfaction uses scentStrength_Q from
|
|
9
|
+
// queryAtmosphericModifiers (PA-6) rather than re-computing wind alignment.
|
|
10
|
+
// - Body-plan predicates: hasEcholocation, hasElectroreception,
|
|
11
|
+
// hasThermalVision, hasOlfaction, dominantSense.
|
|
12
|
+
//
|
|
13
|
+
// Compatibility: fully additive — does not modify the Phase 52 API. Hosts
|
|
14
|
+
// can replace calls to canDetectExtended with canDetectExtendedAtmospheric
|
|
15
|
+
// for unified atmospheric integration, or continue using Phase 52 directly.
|
|
16
|
+
import { SCALE, q, clampQ, mulDiv } from "./units.js";
|
|
17
|
+
import { canDetect } from "./sim/sensory.js";
|
|
18
|
+
import { canDetectByEcholocation, canDetectByElectroreception, DETECT_ECHOLOCATION, DETECT_ELECTRORECEPTION, } from "./sim/sensory-extended.js";
|
|
19
|
+
import { queryAtmosphericModifiers, } from "./atmosphere.js";
|
|
20
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
21
|
+
/** Thermal signature of a living entity at rest [Q]. */
|
|
22
|
+
export const THERMAL_BASE_SIGNATURE_Q = q(0.30);
|
|
23
|
+
/**
|
|
24
|
+
* Additional thermal signature per bleeding body region [Q].
|
|
25
|
+
* Warm blood on the surface raises infrared contrast.
|
|
26
|
+
*/
|
|
27
|
+
export const THERMAL_BLEED_BONUS_Q = q(0.10);
|
|
28
|
+
/**
|
|
29
|
+
* Additional thermal signature when entity shock exceeds `THERMAL_SHOCK_THRESHOLD`.
|
|
30
|
+
* Fever and inflammation elevate core temperature.
|
|
31
|
+
*/
|
|
32
|
+
export const THERMAL_SHOCK_BONUS_Q = q(0.15);
|
|
33
|
+
/** Shock level [Q] above which a fever/inflammatory bonus applies. */
|
|
34
|
+
export const THERMAL_SHOCK_THRESHOLD = q(0.40);
|
|
35
|
+
/**
|
|
36
|
+
* Precipitation reduces effective thermal range.
|
|
37
|
+
* At precipIntensity_Q = SCALE.Q: range × (1 - THERMAL_PRECIP_PENALTY).
|
|
38
|
+
*/
|
|
39
|
+
export const THERMAL_PRECIP_PENALTY = q(0.60);
|
|
40
|
+
/** Detection quality returned for thermal detections. */
|
|
41
|
+
export const DETECT_THERMAL = q(0.35);
|
|
42
|
+
/** Minimum olfaction detection quality when atmospheric scentStrength_Q is q(1.0). */
|
|
43
|
+
export const DETECT_OLFACTION_ATMO_MIN = q(0.20);
|
|
44
|
+
/** Maximum olfaction detection quality. */
|
|
45
|
+
export const DETECT_OLFACTION_ATMO_MAX = q(0.40);
|
|
46
|
+
// ── Body-plan predicates ──────────────────────────────────────────────────────
|
|
47
|
+
/** Returns `true` if the entity has echolocation capability. */
|
|
48
|
+
export function hasEcholocation(entity) {
|
|
49
|
+
return (entity.extendedSenses?.echolocationRange_m ?? 0) > 0;
|
|
50
|
+
}
|
|
51
|
+
/** Returns `true` if the entity has electroreception capability. */
|
|
52
|
+
export function hasElectroreception(entity) {
|
|
53
|
+
return (entity.extendedSenses?.electroreceptionRange_m ?? 0) > 0;
|
|
54
|
+
}
|
|
55
|
+
/** Returns `true` if the entity has thermal (infrared) vision. */
|
|
56
|
+
export function hasThermalVision(entity) {
|
|
57
|
+
return (entity.extendedSenses?.thermalVisionRange_m ?? 0) > 0;
|
|
58
|
+
}
|
|
59
|
+
/** Returns `true` if the entity has olfaction (scent) capability. */
|
|
60
|
+
export function hasOlfaction(entity) {
|
|
61
|
+
return (entity.extendedSenses?.olfactionSensitivity_Q ?? 0) > 0;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Returns the entity's dominant non-visual sense.
|
|
65
|
+
*
|
|
66
|
+
* Priority: electroreception > echolocation > thermal > olfaction > vision.
|
|
67
|
+
*
|
|
68
|
+
* Use this to steer AI targeting logic:
|
|
69
|
+
* - `"echolocation"` → entity can hunt in total darkness.
|
|
70
|
+
* - `"electroreception"` → entity detects living creatures at close range
|
|
71
|
+
* regardless of light, noise, or scent.
|
|
72
|
+
* - `"thermal"` → entity detects warm-bodied prey by heat signature.
|
|
73
|
+
* - `"olfaction"` → entity tracks prey by scent trail (wind-dependent).
|
|
74
|
+
* - `"vision"` → standard visual detection (default).
|
|
75
|
+
*/
|
|
76
|
+
export function dominantSense(entity) {
|
|
77
|
+
if (hasElectroreception(entity))
|
|
78
|
+
return "electroreception";
|
|
79
|
+
if (hasEcholocation(entity))
|
|
80
|
+
return "echolocation";
|
|
81
|
+
if (hasThermalVision(entity))
|
|
82
|
+
return "thermal";
|
|
83
|
+
if (hasOlfaction(entity))
|
|
84
|
+
return "olfaction";
|
|
85
|
+
return "vision";
|
|
86
|
+
}
|
|
87
|
+
// ── Thermal detection ─────────────────────────────────────────────────────────
|
|
88
|
+
/**
|
|
89
|
+
* Compute the thermal signature of an entity [Q 0..SCALE.Q].
|
|
90
|
+
*
|
|
91
|
+
* Dead entities return q(0) — no remaining metabolic heat.
|
|
92
|
+
* Living entities radiate at least `THERMAL_BASE_SIGNATURE_Q`.
|
|
93
|
+
* Active bleeding and fever raise the signature further.
|
|
94
|
+
*/
|
|
95
|
+
export function thermalSignature(entity) {
|
|
96
|
+
if (entity.injury?.dead)
|
|
97
|
+
return q(0);
|
|
98
|
+
let sig = THERMAL_BASE_SIGNATURE_Q;
|
|
99
|
+
// Bleeding: each bleeding region raises infrared contrast (warm blood visible)
|
|
100
|
+
if (entity.injury !== undefined) {
|
|
101
|
+
for (const region of Object.values(entity.injury.byRegion)) {
|
|
102
|
+
if (region.bleedingRate > 0) {
|
|
103
|
+
sig += THERMAL_BLEED_BONUS_Q;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Fever / high shock: inflammation raises skin temperature
|
|
108
|
+
if ((entity.injury?.shock ?? 0) >= THERMAL_SHOCK_THRESHOLD) {
|
|
109
|
+
sig += THERMAL_SHOCK_BONUS_Q;
|
|
110
|
+
}
|
|
111
|
+
return clampQ(Math.round(sig), q(0), SCALE.Q);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Whether observer can detect subject via thermal (infrared) vision.
|
|
115
|
+
*
|
|
116
|
+
* - Requires `observer.extendedSenses.thermalVisionRange_m > 0`.
|
|
117
|
+
* - Dead entities have no thermal signature and are not detected.
|
|
118
|
+
* - Effective range: `thermalVisionRange_m × signature_Q / SCALE.Q`,
|
|
119
|
+
* further reduced by precipitation (`THERMAL_PRECIP_PENALTY`).
|
|
120
|
+
* - Unaffected by ambient light or noise.
|
|
121
|
+
*
|
|
122
|
+
* @param dist_m Distance from observer to subject [SCALE.m].
|
|
123
|
+
* @param precipIntensity Precipitation intensity [Q 0..SCALE.Q] from AtmosphericState.
|
|
124
|
+
*/
|
|
125
|
+
export function canDetectByThermalVision(observer, subject, dist_m, precipIntensity) {
|
|
126
|
+
const baseRange = observer.extendedSenses?.thermalVisionRange_m;
|
|
127
|
+
if (!baseRange || baseRange <= 0)
|
|
128
|
+
return false;
|
|
129
|
+
if (subject.injury?.dead)
|
|
130
|
+
return false;
|
|
131
|
+
const sig = thermalSignature(subject);
|
|
132
|
+
if (sig <= 0)
|
|
133
|
+
return false;
|
|
134
|
+
// Signature scales effective range (high-signature targets detectable at greater range)
|
|
135
|
+
const sigRange = mulDiv(baseRange, sig, SCALE.Q);
|
|
136
|
+
// Precipitation attenuates thermal radiation
|
|
137
|
+
const precipPenalty = precipIntensity !== undefined
|
|
138
|
+
? mulDiv(THERMAL_PRECIP_PENALTY, precipIntensity, SCALE.Q)
|
|
139
|
+
: 0;
|
|
140
|
+
const precipMul = Math.max(0, SCALE.Q - precipPenalty);
|
|
141
|
+
const effectiveRange = mulDiv(sigRange, precipMul, SCALE.Q);
|
|
142
|
+
return dist_m <= effectiveRange;
|
|
143
|
+
}
|
|
144
|
+
// ── Atmospheric olfaction ─────────────────────────────────────────────────────
|
|
145
|
+
/**
|
|
146
|
+
* Compute olfaction detection quality using pre-computed atmospheric context.
|
|
147
|
+
*
|
|
148
|
+
* Replaces calling `deriveScentDetection` when an `AtmosphericState` is
|
|
149
|
+
* available — uses `scentStrength_Q` from `queryAtmosphericModifiers` instead
|
|
150
|
+
* of independently re-computing wind alignment.
|
|
151
|
+
*
|
|
152
|
+
* @returns Q quality ∈ [0, DETECT_OLFACTION_ATMO_MAX].
|
|
153
|
+
*/
|
|
154
|
+
function _olfactionQualityAtmospheric(observer, subject, dist_m, scentStrength_Q, precipIntensity_Q) {
|
|
155
|
+
const sens = observer.extendedSenses?.olfactionSensitivity_Q;
|
|
156
|
+
if (!sens || sens <= 0)
|
|
157
|
+
return q(0);
|
|
158
|
+
if (dist_m <= 0)
|
|
159
|
+
return DETECT_OLFACTION_ATMO_MAX;
|
|
160
|
+
// Reference range: q(1.0) sensitivity detects at 50 m downwind (500 000 Sm)
|
|
161
|
+
const REF_RANGE_m = 50 * SCALE.m;
|
|
162
|
+
const distStrength = clampQ(Math.trunc(sens * REF_RANGE_m / dist_m), q(0), SCALE.Q);
|
|
163
|
+
// Wind alignment from atmospheric (scentStrength_Q from PA-6)
|
|
164
|
+
// Precipitation dispersal (q(0) intensity = no dispersal; q(1.0) = 60% reduction)
|
|
165
|
+
const precipDisperse = Math.max(q(0.20), SCALE.Q - Math.round(precipIntensity_Q * 8_000 / SCALE.Q));
|
|
166
|
+
const combined = Math.trunc(Math.trunc(distStrength * scentStrength_Q / SCALE.Q) * precipDisperse / SCALE.Q);
|
|
167
|
+
if (combined < DETECT_OLFACTION_ATMO_MIN)
|
|
168
|
+
return q(0);
|
|
169
|
+
return clampQ(combined, DETECT_OLFACTION_ATMO_MIN, DETECT_OLFACTION_ATMO_MAX);
|
|
170
|
+
}
|
|
171
|
+
// ── canDetectExtendedAtmospheric ──────────────────────────────────────────────
|
|
172
|
+
/**
|
|
173
|
+
* Full detection check using all four extended modalities, with `AtmosphericState`
|
|
174
|
+
* integration for olfaction and thermal.
|
|
175
|
+
*
|
|
176
|
+
* Returns best detection quality [Q] across all active senses:
|
|
177
|
+
* - q(1.0): vision (Phase 4)
|
|
178
|
+
* - q(0.80): electroreception
|
|
179
|
+
* - q(0.70): echolocation
|
|
180
|
+
* - q(0.20–0.40): olfaction (atmospheric, wind/precip dependent)
|
|
181
|
+
* - q(0.35): thermal (heat-signature dependent)
|
|
182
|
+
* - q(0): undetected
|
|
183
|
+
*
|
|
184
|
+
* Use this as a drop-in replacement for `canDetectExtended` when an
|
|
185
|
+
* `AtmosphericState` is available.
|
|
186
|
+
*/
|
|
187
|
+
export function canDetectExtendedAtmospheric(observer, subject, env, atmospheric, sensorBoost) {
|
|
188
|
+
// Phase 4 vision + hearing (existing canDetect)
|
|
189
|
+
const primary = canDetect(observer, subject, env, sensorBoost);
|
|
190
|
+
if (primary > q(0))
|
|
191
|
+
return primary;
|
|
192
|
+
// Distance (integer sqrt over 3D position)
|
|
193
|
+
const dx = subject.position_m.x - observer.position_m.x;
|
|
194
|
+
const dy = subject.position_m.y - observer.position_m.y;
|
|
195
|
+
const dz = subject.position_m.z - observer.position_m.z;
|
|
196
|
+
const dist_m = Math.trunc(Math.sqrt(dx * dx + dy * dy + dz * dz));
|
|
197
|
+
// Electroreception
|
|
198
|
+
if (canDetectByElectroreception(observer, subject, dist_m)) {
|
|
199
|
+
return DETECT_ELECTRORECEPTION;
|
|
200
|
+
}
|
|
201
|
+
// Echolocation — use atmospheric noiseMul if available
|
|
202
|
+
// acousticMaskMul_Q → inverse of noiseMul: q(1.0) = normal noise, lower = quieter
|
|
203
|
+
// Existing canDetectByEcholocation takes noiseMul directly from env
|
|
204
|
+
if (canDetectByEcholocation(observer, subject, dist_m, env.noiseMul)) {
|
|
205
|
+
return DETECT_ECHOLOCATION;
|
|
206
|
+
}
|
|
207
|
+
// Thermal detection
|
|
208
|
+
if (canDetectByThermalVision(observer, subject, dist_m, atmospheric.precipIntensity_Q)) {
|
|
209
|
+
return DETECT_THERMAL;
|
|
210
|
+
}
|
|
211
|
+
// Olfaction (atmospheric integration)
|
|
212
|
+
const from2d = { x_Sm: observer.position_m.x, y_Sm: observer.position_m.y };
|
|
213
|
+
const to2d = { x_Sm: subject.position_m.x, y_Sm: subject.position_m.y };
|
|
214
|
+
const mods = queryAtmosphericModifiers(from2d, to2d, atmospheric);
|
|
215
|
+
const olfactionQ = _olfactionQualityAtmospheric(observer, subject, dist_m, mods.scentStrength_Q, atmospheric.precipIntensity_Q);
|
|
216
|
+
if (olfactionQ > q(0))
|
|
217
|
+
return olfactionQ;
|
|
218
|
+
return q(0);
|
|
219
|
+
}
|
|
220
|
+
// ── stepExtendedSenses ────────────────────────────────────────────────────────
|
|
221
|
+
/**
|
|
222
|
+
* Accumulate all extended-sense detections for one observer entity.
|
|
223
|
+
*
|
|
224
|
+
* Iterates all entities in `world`, skips the observer itself, and for each
|
|
225
|
+
* other entity checks all four extended modalities. Multiple detections per
|
|
226
|
+
* target are possible and are all returned (callers take the max quality).
|
|
227
|
+
*
|
|
228
|
+
* Visual and hearing detection is **not** included — use `canDetect` (Phase 4)
|
|
229
|
+
* or `canDetectExtendedAtmospheric` for full detection checks.
|
|
230
|
+
*
|
|
231
|
+
* @param observer The sensing entity.
|
|
232
|
+
* @param world Current world state (iterated for targets).
|
|
233
|
+
* @param atmospheric Atmospheric state from `deriveAtmosphericState` (PA-6).
|
|
234
|
+
* @param env Sensory environment for echolocation noise level.
|
|
235
|
+
* @returns All detections this tick (may be empty).
|
|
236
|
+
*
|
|
237
|
+
* @example
|
|
238
|
+
* ```ts
|
|
239
|
+
* const atmo = deriveAtmosphericState(ctx.weather, ctx.biome);
|
|
240
|
+
* const result = stepExtendedSenses(bat, world, atmo, ctx.sensoryEnv ?? DEFAULT_SENSORY_ENV);
|
|
241
|
+
* for (const det of result.detections) {
|
|
242
|
+
* // det.entityId, det.modality, det.quality_Q, det.dist_Sm
|
|
243
|
+
* }
|
|
244
|
+
* ```
|
|
245
|
+
*/
|
|
246
|
+
export function stepExtendedSenses(observer, world, atmospheric, env) {
|
|
247
|
+
const detections = [];
|
|
248
|
+
const hasEcho = hasEcholocation(observer);
|
|
249
|
+
const hasElec = hasElectroreception(observer);
|
|
250
|
+
const hasThermal = hasThermalVision(observer);
|
|
251
|
+
const hasOlf = hasOlfaction(observer);
|
|
252
|
+
// Early-out if observer has no extended senses
|
|
253
|
+
if (!hasEcho && !hasElec && !hasThermal && !hasOlf) {
|
|
254
|
+
return { detections };
|
|
255
|
+
}
|
|
256
|
+
const obsX = observer.position_m.x;
|
|
257
|
+
const obsY = observer.position_m.y;
|
|
258
|
+
const obsZ = observer.position_m.z;
|
|
259
|
+
for (const subject of world.entities) {
|
|
260
|
+
if (subject.id === observer.id)
|
|
261
|
+
continue;
|
|
262
|
+
const dx = subject.position_m.x - obsX;
|
|
263
|
+
const dy = subject.position_m.y - obsY;
|
|
264
|
+
const dz = subject.position_m.z - obsZ;
|
|
265
|
+
const dist_m = Math.trunc(Math.sqrt(dx * dx + dy * dy + dz * dz));
|
|
266
|
+
// Electroreception
|
|
267
|
+
if (hasElec && canDetectByElectroreception(observer, subject, dist_m)) {
|
|
268
|
+
detections.push({
|
|
269
|
+
entityId: subject.id,
|
|
270
|
+
modality: "electroreception",
|
|
271
|
+
quality_Q: DETECT_ELECTRORECEPTION,
|
|
272
|
+
dist_Sm: dist_m,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
// Echolocation
|
|
276
|
+
if (hasEcho && canDetectByEcholocation(observer, subject, dist_m, env.noiseMul)) {
|
|
277
|
+
detections.push({
|
|
278
|
+
entityId: subject.id,
|
|
279
|
+
modality: "echolocation",
|
|
280
|
+
quality_Q: DETECT_ECHOLOCATION,
|
|
281
|
+
dist_Sm: dist_m,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
// Thermal
|
|
285
|
+
if (hasThermal && canDetectByThermalVision(observer, subject, dist_m, atmospheric.precipIntensity_Q)) {
|
|
286
|
+
detections.push({
|
|
287
|
+
entityId: subject.id,
|
|
288
|
+
modality: "thermal",
|
|
289
|
+
quality_Q: DETECT_THERMAL,
|
|
290
|
+
dist_Sm: dist_m,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
// Olfaction (atmospheric integration)
|
|
294
|
+
if (hasOlf) {
|
|
295
|
+
const from2d = { x_Sm: obsX, y_Sm: obsY };
|
|
296
|
+
const to2d = { x_Sm: subject.position_m.x, y_Sm: subject.position_m.y };
|
|
297
|
+
const mods = queryAtmosphericModifiers(from2d, to2d, atmospheric);
|
|
298
|
+
const q_olf = _olfactionQualityAtmospheric(observer, subject, dist_m, mods.scentStrength_Q, atmospheric.precipIntensity_Q);
|
|
299
|
+
if (q_olf > q(0)) {
|
|
300
|
+
detections.push({
|
|
301
|
+
entityId: subject.id,
|
|
302
|
+
modality: "olfaction",
|
|
303
|
+
quality_Q: q_olf,
|
|
304
|
+
dist_Sm: dist_m,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return { detections };
|
|
310
|
+
}
|
|
@@ -27,6 +27,17 @@ export interface ExtendedSenses {
|
|
|
27
27
|
* Precipitation disperses scent.
|
|
28
28
|
*/
|
|
29
29
|
olfactionSensitivity_Q?: Q;
|
|
30
|
+
/**
|
|
31
|
+
* Thermal (infrared) detection range [SCALE.m].
|
|
32
|
+
* Non-zero = entity detects warm-bodied targets by heat signature.
|
|
33
|
+
* Effective range scales with target thermal signature strength.
|
|
34
|
+
* Degraded by precipitation (water absorbs thermal radiation).
|
|
35
|
+
* Dead entities have no thermal signature — not detectable.
|
|
36
|
+
*
|
|
37
|
+
* Added by PA-7. Typical values: pit-viper ≈ 5 m (50 000 Sm);
|
|
38
|
+
* evolved predator ≈ 30 m (300 000 Sm).
|
|
39
|
+
*/
|
|
40
|
+
thermalVisionRange_m?: number;
|
|
30
41
|
}
|
|
31
42
|
/** Detected via echolocation — returned by canDetectExtended. */
|
|
32
43
|
export declare const DETECT_ECHOLOCATION: Q;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@its-not-rocket-science/ananke",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.56",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -185,6 +185,14 @@
|
|
|
185
185
|
"./terrain-bridge": {
|
|
186
186
|
"import": "./dist/src/terrain-bridge.js",
|
|
187
187
|
"types": "./dist/src/terrain-bridge.d.ts"
|
|
188
|
+
},
|
|
189
|
+
"./atmosphere": {
|
|
190
|
+
"import": "./dist/src/atmosphere.js",
|
|
191
|
+
"types": "./dist/src/atmosphere.d.ts"
|
|
192
|
+
},
|
|
193
|
+
"./extended-senses": {
|
|
194
|
+
"import": "./dist/src/extended-senses.js",
|
|
195
|
+
"types": "./dist/src/extended-senses.d.ts"
|
|
188
196
|
}
|
|
189
197
|
},
|
|
190
198
|
"workspaces": [
|