@its-not-rocket-science/ananke 0.1.55 → 0.1.57
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 +39 -0
- package/dist/src/extended-senses.d.ts +146 -0
- package/dist/src/extended-senses.js +310 -0
- package/dist/src/host-loop.d.ts +188 -0
- package/dist/src/host-loop.js +185 -0
- package/dist/src/sim/sensory-extended.d.ts +11 -0
- package/package.json +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,45 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## [0.1.57] — 2026-03-30
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **PA-8 — Host Integration SDKs (complete):**
|
|
14
|
+
- `src/host-loop.ts` (new): stable, versioned wire-format protocol for the Ananke sidecar ↔ renderer bridge. All values on the wire are real SI units (floats, not fixed-point).
|
|
15
|
+
- **Wire types**: `BridgeVec3`, `BridgeCondition`, `BridgeAnimation`, `BridgePoseModifier`, `BridgeGrappleConstraint`, `BridgeEntitySnapshot`, `BridgeFrame`, `HostLoopConfig`.
|
|
16
|
+
- **`serializeBridgeFrame(world, config)`**: canonical serializer — converts `WorldState` to `BridgeFrame`. Replaces per-sidecar serializer duplications in Unity and Godot reference implementations.
|
|
17
|
+
- **`derivePrimaryState(animation)`**: maps `AnimationHints` to a single state string (`"idle"` | `"attack"` | `"flee"` | `"prone"` | `"unconscious"` | `"dead"`). Suitable for top-level renderer state machines.
|
|
18
|
+
- **`derivePoseOffset(segmentId, impairmentQ)`**: anatomical local-space bone offset at a given impairment level (real metres), for injury deformation blend shapes.
|
|
19
|
+
- Constants: `BRIDGE_SCHEMA_VERSION = "ananke.bridge.frame.v1"`, `DEFAULT_TICK_HZ = 20`, `DEFAULT_BRIDGE_PORT = 3001`, `DEFAULT_BRIDGE_HOST`, `DEFAULT_STREAM_PATH`.
|
|
20
|
+
- `"./host-loop"` subpath export added to `package.json`.
|
|
21
|
+
- **Reference sidecar updates**: both `ananke-unity-reference` and `ananke-godot-reference` sidecars updated to v0.1.57 dependency and refactored to import `serializeBridgeFrame` from `@its-not-rocket-science/ananke/host-loop` — local serialization code removed.
|
|
22
|
+
- **Quickstart guides** (new):
|
|
23
|
+
- `docs/quickstart-unity.md`: 15-minute Unity integration guide (sidecar → WebSocket → `AnankeReceiver` → `AnimationDriver` → your mesh).
|
|
24
|
+
- `docs/quickstart-godot.md`: 15-minute Godot 4 integration guide (GDScript and C# addon variants).
|
|
25
|
+
- `docs/quickstart-web.md`: Three.js browser integration guide (zero-build-step HTML example + `serializeBridgeFrame` sidecar recipe).
|
|
26
|
+
- 41 new tests (188 test files, 5,553 tests total). Coverage: 97.10% stmt, 88.05% branch, 95.81% func. Build: clean.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## [0.1.56] — 2026-03-30
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- **PA-7 — Advanced Non-Visual Sensory Systems (complete):**
|
|
35
|
+
- `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.
|
|
36
|
+
- `src/extended-senses.ts` (new): unified extended-senses module with `AtmosphericState` integration (PA-6).
|
|
37
|
+
- Body-plan predicates: `hasEcholocation`, `hasElectroreception`, `hasThermalVision`, `hasOlfaction`, `dominantSense` (priority: electroreception > echolocation > thermal > olfaction > vision).
|
|
38
|
+
- `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).
|
|
39
|
+
- `canDetectByThermalVision(observer, subject, dist_m, precipIntensity?)`: effective range = baseRange × signature / SCALE.Q × (1 − precipIntensity × 0.60). Detection quality `DETECT_THERMAL = q(0.35)`.
|
|
40
|
+
- `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.
|
|
41
|
+
- `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.
|
|
42
|
+
- 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`.
|
|
43
|
+
- `"./extended-senses"` subpath export added to `package.json`.
|
|
44
|
+
- 60 new tests (187 test files, 5,512 tests total). Build: clean.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
9
48
|
## [0.1.55] — 2026-03-30
|
|
10
49
|
|
|
11
50
|
### Added
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type { WorldState } from "./sim/world.js";
|
|
2
|
+
import { type AnimationHints } from "./model3d.js";
|
|
3
|
+
/** Wire schema identifier — included in every BridgeFrame. */
|
|
4
|
+
export declare const BRIDGE_SCHEMA_VERSION: "ananke.bridge.frame.v1";
|
|
5
|
+
/** Default sidecar tick rate (Hz). */
|
|
6
|
+
export declare const DEFAULT_TICK_HZ = 20;
|
|
7
|
+
/** Default sidecar WebSocket/HTTP port. */
|
|
8
|
+
export declare const DEFAULT_BRIDGE_PORT = 3001;
|
|
9
|
+
/** Default sidecar host. */
|
|
10
|
+
export declare const DEFAULT_BRIDGE_HOST = "127.0.0.1";
|
|
11
|
+
/** Default WebSocket stream path. */
|
|
12
|
+
export declare const DEFAULT_STREAM_PATH = "/stream";
|
|
13
|
+
/**
|
|
14
|
+
* 3D vector in real metres (float).
|
|
15
|
+
* Converts from fixed-point SCALE.m: `x_m = x_Sm / SCALE.m`.
|
|
16
|
+
*/
|
|
17
|
+
export interface BridgeVec3 {
|
|
18
|
+
x: number;
|
|
19
|
+
y: number;
|
|
20
|
+
z: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Entity physiological condition (Q-values as [0, 1] floats).
|
|
24
|
+
* Divide the underlying Q value by SCALE.Q (10 000) to get floats.
|
|
25
|
+
*/
|
|
26
|
+
export interface BridgeCondition {
|
|
27
|
+
/** Shock intensity. q(0) = no shock; q(1.0) = incapacitating. */
|
|
28
|
+
shockQ: number;
|
|
29
|
+
/** Fear intensity. q(0) = calm; q(1.0) = panic. */
|
|
30
|
+
fearQ: number;
|
|
31
|
+
/** Consciousness level. q(0) = unconscious; q(1.0) = fully alert. */
|
|
32
|
+
consciousnessQ: number;
|
|
33
|
+
/** Cumulative fluid loss. q(0) = none; q(1.0) = lethal. */
|
|
34
|
+
fluidLossQ: number;
|
|
35
|
+
/** True if the entity is clinically dead. */
|
|
36
|
+
dead: boolean;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Animation blend weights and state flags for a renderer character controller.
|
|
40
|
+
* All Q-values are [0, 1] floats.
|
|
41
|
+
*
|
|
42
|
+
* Locomotive blends are mutually exclusive; typically only one is nonzero.
|
|
43
|
+
*/
|
|
44
|
+
export interface BridgeAnimation {
|
|
45
|
+
idle: number;
|
|
46
|
+
walk: number;
|
|
47
|
+
run: number;
|
|
48
|
+
sprint: number;
|
|
49
|
+
crawl: number;
|
|
50
|
+
guardingQ: number;
|
|
51
|
+
attackingQ: number;
|
|
52
|
+
shockQ: number;
|
|
53
|
+
fearQ: number;
|
|
54
|
+
prone: boolean;
|
|
55
|
+
unconscious: boolean;
|
|
56
|
+
dead: boolean;
|
|
57
|
+
/** Dominant animation state as a single string (see `derivePrimaryState`). */
|
|
58
|
+
primaryState: string;
|
|
59
|
+
/** Max locomotion blend weight — useful for speed-parameterised blend trees. */
|
|
60
|
+
locomotionBlend: number;
|
|
61
|
+
/** Worst-case injury deformation weight across all body segments. */
|
|
62
|
+
injuryWeight: number;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Per-body-segment pose modifier — drives deformation or damage blend shapes.
|
|
66
|
+
* Q-values are [0, 1] floats.
|
|
67
|
+
*/
|
|
68
|
+
export interface BridgePoseModifier {
|
|
69
|
+
segmentId: string;
|
|
70
|
+
/** Overall deformation blend: max(structuralQ, surfaceQ). */
|
|
71
|
+
impairmentQ: number;
|
|
72
|
+
structuralQ: number;
|
|
73
|
+
surfaceQ: number;
|
|
74
|
+
/**
|
|
75
|
+
* Anatomical offset for this segment at full impairment, in real metres.
|
|
76
|
+
* Apply to the bone's local position to show slumping/collapse.
|
|
77
|
+
*/
|
|
78
|
+
localOffset_m: BridgeVec3;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Grapple constraint describing hold/held relationships between entities.
|
|
82
|
+
*/
|
|
83
|
+
export interface BridgeGrappleConstraint {
|
|
84
|
+
isHolder: boolean;
|
|
85
|
+
holdingEntityId: number;
|
|
86
|
+
isHeld: boolean;
|
|
87
|
+
heldByIds: number[];
|
|
88
|
+
/** Grapple positional state. */
|
|
89
|
+
position: "standing" | "prone" | "pinned" | "mounted";
|
|
90
|
+
/** Grip strength [0, 1]. */
|
|
91
|
+
gripQ: number;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Complete per-entity snapshot for one simulation tick.
|
|
95
|
+
*/
|
|
96
|
+
export interface BridgeEntitySnapshot {
|
|
97
|
+
entityId: number;
|
|
98
|
+
teamId: number;
|
|
99
|
+
tick: number;
|
|
100
|
+
/** World position in real metres. */
|
|
101
|
+
position_m: BridgeVec3;
|
|
102
|
+
/** Velocity in real m/s. */
|
|
103
|
+
velocity_mps: BridgeVec3;
|
|
104
|
+
/** Normalised facing direction (unit vector). */
|
|
105
|
+
facing: BridgeVec3;
|
|
106
|
+
/** Mass in real kg. */
|
|
107
|
+
massKg: number;
|
|
108
|
+
/** Centre-of-gravity offset from foot position (real metres). */
|
|
109
|
+
cogOffset_m: {
|
|
110
|
+
x: number;
|
|
111
|
+
y: number;
|
|
112
|
+
};
|
|
113
|
+
animation: BridgeAnimation;
|
|
114
|
+
pose: BridgePoseModifier[];
|
|
115
|
+
grapple: BridgeGrappleConstraint;
|
|
116
|
+
condition: BridgeCondition;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Complete serialized frame for one simulation tick.
|
|
120
|
+
* JSON-encoded and sent over WebSocket / HTTP.
|
|
121
|
+
*/
|
|
122
|
+
export interface BridgeFrame {
|
|
123
|
+
/** Fixed schema identifier — check this before deserializing. */
|
|
124
|
+
schema: typeof BRIDGE_SCHEMA_VERSION;
|
|
125
|
+
scenarioId: string;
|
|
126
|
+
tick: number;
|
|
127
|
+
tickHz: number;
|
|
128
|
+
/** ISO 8601 generation timestamp — for latency diagnostics only. */
|
|
129
|
+
generatedAt: string;
|
|
130
|
+
entities: BridgeEntitySnapshot[];
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Sidecar configuration — passed to `serializeBridgeFrame` and used by
|
|
134
|
+
* host loop implementations.
|
|
135
|
+
*/
|
|
136
|
+
export interface HostLoopConfig {
|
|
137
|
+
/** Stable identifier for this scenario (e.g. `"knight-vs-brawler"`). */
|
|
138
|
+
scenarioId: string;
|
|
139
|
+
/** Simulation tick rate in Hz. Default: `DEFAULT_TICK_HZ` (20). */
|
|
140
|
+
tickHz?: number;
|
|
141
|
+
/** Listening port. Default: `DEFAULT_BRIDGE_PORT` (3001). */
|
|
142
|
+
port?: number;
|
|
143
|
+
/** Listening host. Default: `DEFAULT_BRIDGE_HOST`. */
|
|
144
|
+
host?: string;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Derive a single animation state string from `AnimationHints`.
|
|
148
|
+
*
|
|
149
|
+
* Priority: dead > unconscious > prone/crawl > attack > flee (run/sprint) > idle.
|
|
150
|
+
* Renderer character controllers use this to drive top-level state machines
|
|
151
|
+
* when a detailed blend tree is not available.
|
|
152
|
+
*
|
|
153
|
+
* @returns One of: `"dead"` | `"unconscious"` | `"prone"` | `"attack"` | `"flee"` | `"idle"`
|
|
154
|
+
*/
|
|
155
|
+
export declare function derivePrimaryState(animation: AnimationHints): string;
|
|
156
|
+
/**
|
|
157
|
+
* Anatomical local-space offset for a body segment at maximum impairment.
|
|
158
|
+
*
|
|
159
|
+
* Applied as: `bone.localPosition += poseOffset * impairmentQ`.
|
|
160
|
+
* Values are in real metres (float).
|
|
161
|
+
*
|
|
162
|
+
* @param segmentId Canonical segment identifier (e.g. `"head"`, `"leftArm"`).
|
|
163
|
+
* @param impairmentQ Impairment blend weight [0, 1] float.
|
|
164
|
+
* @returns Local-space offset in real metres.
|
|
165
|
+
*/
|
|
166
|
+
export declare function derivePoseOffset(segmentId: string, impairmentQ: number): BridgeVec3;
|
|
167
|
+
/**
|
|
168
|
+
* Serialize a complete simulation tick into the stable bridge wire format.
|
|
169
|
+
*
|
|
170
|
+
* This is the canonical sidecar serializer. Replaces per-project
|
|
171
|
+
* `serialiseFrame` implementations in Unity and Godot sidecars.
|
|
172
|
+
*
|
|
173
|
+
* @param world Current world state after `stepWorld()`.
|
|
174
|
+
* @param config Sidecar configuration.
|
|
175
|
+
* @returns A `BridgeFrame` safe to `JSON.stringify` and send over WebSocket.
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* ```ts
|
|
179
|
+
* import { serializeBridgeFrame } from "@its-not-rocket-science/ananke/host-loop";
|
|
180
|
+
*
|
|
181
|
+
* function tick() {
|
|
182
|
+
* stepWorld(world, commands, ctx);
|
|
183
|
+
* const frame = serializeBridgeFrame(world, { scenarioId: "my-duel", tickHz: 20 });
|
|
184
|
+
* broadcast(JSON.stringify(frame));
|
|
185
|
+
* }
|
|
186
|
+
* ```
|
|
187
|
+
*/
|
|
188
|
+
export declare function serializeBridgeFrame(world: WorldState, config: HostLoopConfig): BridgeFrame;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// src/host-loop.ts — PA-8: Host Integration Bridge Protocol
|
|
2
|
+
//
|
|
3
|
+
// Stable, versioned wire format for the Ananke sidecar ↔ renderer bridge.
|
|
4
|
+
// All values on the wire are in real SI units (floats).
|
|
5
|
+
//
|
|
6
|
+
// Usage in a sidecar:
|
|
7
|
+
// import { serializeBridgeFrame, HostLoopConfig } from "@ananke/host-loop";
|
|
8
|
+
//
|
|
9
|
+
// Usage in a renderer client (Unity, Godot, Web):
|
|
10
|
+
// const frame: BridgeFrame = JSON.parse(rawWebSocketMessage);
|
|
11
|
+
// // field names and types are stable across minor versions
|
|
12
|
+
//
|
|
13
|
+
// Schema version string: BRIDGE_SCHEMA_VERSION.
|
|
14
|
+
// Increment the version suffix (v1 → v2) only on breaking wire changes.
|
|
15
|
+
import { SCALE, q, clampQ, qMul } from "./units.js";
|
|
16
|
+
import { deriveAnimationHints, derivePoseModifiers, deriveGrappleConstraint, deriveMassDistribution, } from "./model3d.js";
|
|
17
|
+
// ── Protocol constants ─────────────────────────────────────────────────────────
|
|
18
|
+
/** Wire schema identifier — included in every BridgeFrame. */
|
|
19
|
+
export const BRIDGE_SCHEMA_VERSION = "ananke.bridge.frame.v1";
|
|
20
|
+
/** Default sidecar tick rate (Hz). */
|
|
21
|
+
export const DEFAULT_TICK_HZ = 20;
|
|
22
|
+
/** Default sidecar WebSocket/HTTP port. */
|
|
23
|
+
export const DEFAULT_BRIDGE_PORT = 3001;
|
|
24
|
+
/** Default sidecar host. */
|
|
25
|
+
export const DEFAULT_BRIDGE_HOST = "127.0.0.1";
|
|
26
|
+
/** Default WebSocket stream path. */
|
|
27
|
+
export const DEFAULT_STREAM_PATH = "/stream";
|
|
28
|
+
// ── Primary state derivation ──────────────────────────────────────────────────
|
|
29
|
+
/**
|
|
30
|
+
* Derive a single animation state string from `AnimationHints`.
|
|
31
|
+
*
|
|
32
|
+
* Priority: dead > unconscious > prone/crawl > attack > flee (run/sprint) > idle.
|
|
33
|
+
* Renderer character controllers use this to drive top-level state machines
|
|
34
|
+
* when a detailed blend tree is not available.
|
|
35
|
+
*
|
|
36
|
+
* @returns One of: `"dead"` | `"unconscious"` | `"prone"` | `"attack"` | `"flee"` | `"idle"`
|
|
37
|
+
*/
|
|
38
|
+
export function derivePrimaryState(animation) {
|
|
39
|
+
if (animation.dead)
|
|
40
|
+
return "dead";
|
|
41
|
+
if (animation.unconscious)
|
|
42
|
+
return "unconscious";
|
|
43
|
+
if (animation.prone || animation.crawl > 0)
|
|
44
|
+
return "prone";
|
|
45
|
+
if (animation.attackingQ > 0)
|
|
46
|
+
return "attack";
|
|
47
|
+
if (animation.sprint > 0 || animation.run > 0)
|
|
48
|
+
return "flee";
|
|
49
|
+
return "idle";
|
|
50
|
+
}
|
|
51
|
+
// ── Pose offset per segment ───────────────────────────────────────────────────
|
|
52
|
+
/**
|
|
53
|
+
* Anatomical local-space offset for a body segment at maximum impairment.
|
|
54
|
+
*
|
|
55
|
+
* Applied as: `bone.localPosition += poseOffset * impairmentQ`.
|
|
56
|
+
* Values are in real metres (float).
|
|
57
|
+
*
|
|
58
|
+
* @param segmentId Canonical segment identifier (e.g. `"head"`, `"leftArm"`).
|
|
59
|
+
* @param impairmentQ Impairment blend weight [0, 1] float.
|
|
60
|
+
* @returns Local-space offset in real metres.
|
|
61
|
+
*/
|
|
62
|
+
export function derivePoseOffset(segmentId, impairmentQ) {
|
|
63
|
+
// 6% of stature at full impairment (clamp to [0, SCALE.Q])
|
|
64
|
+
const weightQ = clampQ(Math.round(impairmentQ * SCALE.Q), q(0), SCALE.Q);
|
|
65
|
+
const offsetQ = qMul(weightQ, q(0.06));
|
|
66
|
+
const offset = offsetQ / SCALE.Q;
|
|
67
|
+
// `+ 0` normalises IEEE-754 negative-zero (−0) to plain 0.
|
|
68
|
+
switch (segmentId) {
|
|
69
|
+
case "head": return { x: 0, y: (-offset * 0.35) + 0, z: 0 };
|
|
70
|
+
case "torso":
|
|
71
|
+
case "thorax":
|
|
72
|
+
case "abdomen": return { x: 0, y: (-offset * 0.50) + 0, z: 0 };
|
|
73
|
+
case "leftArm": return { x: (-offset) + 0, y: 0, z: 0 };
|
|
74
|
+
case "rightArm": return { x: offset, y: 0, z: 0 };
|
|
75
|
+
case "leftLeg": return { x: (-offset * 0.45) + 0, y: (-offset) + 0, z: 0 };
|
|
76
|
+
case "rightLeg": return { x: offset * 0.45, y: (-offset) + 0, z: 0 };
|
|
77
|
+
default: return { x: 0, y: 0, z: 0 };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// ── Internal helpers ──────────────────────────────────────────────────────────
|
|
81
|
+
function scaleVec3(v, divisor) {
|
|
82
|
+
return { x: v.x / divisor, y: v.y / divisor, z: v.z / divisor };
|
|
83
|
+
}
|
|
84
|
+
function normalizeFacing(v) {
|
|
85
|
+
const mag = Math.hypot(v.x, v.y, v.z) || 1;
|
|
86
|
+
return { x: v.x / mag, y: v.y / mag, z: v.z / mag };
|
|
87
|
+
}
|
|
88
|
+
function buildBridgeAnimation(animation, pose) {
|
|
89
|
+
const locomotionBlend = Math.max(animation.idle, animation.walk, animation.run, animation.sprint, animation.crawl) / SCALE.Q;
|
|
90
|
+
const injuryWeight = pose.reduce((worst, m) => Math.max(worst, m.impairmentQ), 0) / SCALE.Q;
|
|
91
|
+
return {
|
|
92
|
+
idle: animation.idle / SCALE.Q,
|
|
93
|
+
walk: animation.walk / SCALE.Q,
|
|
94
|
+
run: animation.run / SCALE.Q,
|
|
95
|
+
sprint: animation.sprint / SCALE.Q,
|
|
96
|
+
crawl: animation.crawl / SCALE.Q,
|
|
97
|
+
guardingQ: animation.guardingQ / SCALE.Q,
|
|
98
|
+
attackingQ: animation.attackingQ / SCALE.Q,
|
|
99
|
+
shockQ: animation.shockQ / SCALE.Q,
|
|
100
|
+
fearQ: animation.fearQ / SCALE.Q,
|
|
101
|
+
prone: animation.prone,
|
|
102
|
+
unconscious: animation.unconscious,
|
|
103
|
+
dead: animation.dead,
|
|
104
|
+
primaryState: derivePrimaryState(animation),
|
|
105
|
+
locomotionBlend,
|
|
106
|
+
injuryWeight,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function buildBridgeGrapple(grapple) {
|
|
110
|
+
return {
|
|
111
|
+
isHolder: grapple.isHolder,
|
|
112
|
+
holdingEntityId: grapple.holdingEntityId ?? 0,
|
|
113
|
+
isHeld: grapple.isHeld,
|
|
114
|
+
heldByIds: grapple.heldByIds,
|
|
115
|
+
position: grapple.position,
|
|
116
|
+
gripQ: grapple.gripQ / SCALE.Q,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function buildBridgeCondition(entity) {
|
|
120
|
+
return {
|
|
121
|
+
shockQ: entity.injury.shock / SCALE.Q,
|
|
122
|
+
fearQ: (entity.condition.fearQ ?? 0) / SCALE.Q,
|
|
123
|
+
consciousnessQ: entity.injury.consciousness / SCALE.Q,
|
|
124
|
+
fluidLossQ: entity.injury.fluidLoss / SCALE.Q,
|
|
125
|
+
dead: entity.injury.dead,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function buildEntitySnapshot(entity, tick) {
|
|
129
|
+
const animation = deriveAnimationHints(entity);
|
|
130
|
+
const pose = derivePoseModifiers(entity);
|
|
131
|
+
const grapple = deriveGrappleConstraint(entity);
|
|
132
|
+
const mass = deriveMassDistribution(entity);
|
|
133
|
+
return {
|
|
134
|
+
entityId: entity.id,
|
|
135
|
+
teamId: entity.teamId,
|
|
136
|
+
tick,
|
|
137
|
+
position_m: scaleVec3(entity.position_m, SCALE.m),
|
|
138
|
+
velocity_mps: scaleVec3(entity.velocity_mps, SCALE.m),
|
|
139
|
+
facing: normalizeFacing(entity.action.facingDirQ),
|
|
140
|
+
massKg: mass.totalMass_kg / SCALE.kg,
|
|
141
|
+
cogOffset_m: mass.cogOffset_m,
|
|
142
|
+
animation: buildBridgeAnimation(animation, pose),
|
|
143
|
+
pose: pose.map(pm => ({
|
|
144
|
+
segmentId: pm.segmentId,
|
|
145
|
+
impairmentQ: pm.impairmentQ / SCALE.Q,
|
|
146
|
+
structuralQ: pm.structuralQ / SCALE.Q,
|
|
147
|
+
surfaceQ: pm.surfaceQ / SCALE.Q,
|
|
148
|
+
localOffset_m: derivePoseOffset(pm.segmentId, pm.impairmentQ / SCALE.Q),
|
|
149
|
+
})),
|
|
150
|
+
grapple: buildBridgeGrapple(grapple),
|
|
151
|
+
condition: buildBridgeCondition(entity),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
155
|
+
/**
|
|
156
|
+
* Serialize a complete simulation tick into the stable bridge wire format.
|
|
157
|
+
*
|
|
158
|
+
* This is the canonical sidecar serializer. Replaces per-project
|
|
159
|
+
* `serialiseFrame` implementations in Unity and Godot sidecars.
|
|
160
|
+
*
|
|
161
|
+
* @param world Current world state after `stepWorld()`.
|
|
162
|
+
* @param config Sidecar configuration.
|
|
163
|
+
* @returns A `BridgeFrame` safe to `JSON.stringify` and send over WebSocket.
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* ```ts
|
|
167
|
+
* import { serializeBridgeFrame } from "@its-not-rocket-science/ananke/host-loop";
|
|
168
|
+
*
|
|
169
|
+
* function tick() {
|
|
170
|
+
* stepWorld(world, commands, ctx);
|
|
171
|
+
* const frame = serializeBridgeFrame(world, { scenarioId: "my-duel", tickHz: 20 });
|
|
172
|
+
* broadcast(JSON.stringify(frame));
|
|
173
|
+
* }
|
|
174
|
+
* ```
|
|
175
|
+
*/
|
|
176
|
+
export function serializeBridgeFrame(world, config) {
|
|
177
|
+
return {
|
|
178
|
+
schema: BRIDGE_SCHEMA_VERSION,
|
|
179
|
+
scenarioId: config.scenarioId,
|
|
180
|
+
tick: world.tick,
|
|
181
|
+
tickHz: config.tickHz ?? DEFAULT_TICK_HZ,
|
|
182
|
+
generatedAt: new Date().toISOString(),
|
|
183
|
+
entities: world.entities.map(e => buildEntitySnapshot(e, world.tick)),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
@@ -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.57",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -189,6 +189,14 @@
|
|
|
189
189
|
"./atmosphere": {
|
|
190
190
|
"import": "./dist/src/atmosphere.js",
|
|
191
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"
|
|
196
|
+
},
|
|
197
|
+
"./host-loop": {
|
|
198
|
+
"import": "./dist/src/host-loop.js",
|
|
199
|
+
"types": "./dist/src/host-loop.d.ts"
|
|
192
200
|
}
|
|
193
201
|
},
|
|
194
202
|
"workspaces": [
|