@jgengine/core 0.28.0 → 0.30.0
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 +31 -0
- package/dist/agents/agent.d.ts +134 -0
- package/dist/agents/agent.js +224 -0
- package/dist/agents/mood.d.ts +74 -0
- package/dist/agents/mood.js +97 -0
- package/dist/agents/relationships.d.ts +92 -0
- package/dist/agents/relationships.js +132 -0
- package/dist/agents/schedule.d.ts +58 -0
- package/dist/agents/schedule.js +72 -0
- package/dist/ai/director/boss.d.ts +63 -0
- package/dist/ai/director/boss.js +87 -0
- package/dist/ai/director/encounter.d.ts +146 -0
- package/dist/ai/director/encounter.js +195 -0
- package/dist/ai/director/pressure.d.ts +58 -0
- package/dist/ai/director/pressure.js +70 -0
- package/dist/ai/director/roles.d.ts +61 -0
- package/dist/ai/director/roles.js +118 -0
- package/dist/ai/director/sound.d.ts +79 -0
- package/dist/ai/director/sound.js +113 -0
- package/dist/game/playableGame.d.ts +2 -2
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +30 -1
- package/dist/mission/mission.d.ts +1 -1
- package/dist/mission/mission.js +4 -1
- package/dist/progression/careerBank.d.ts +63 -0
- package/dist/progression/careerBank.js +83 -0
- package/dist/progression/gates.d.ts +66 -0
- package/dist/progression/gates.js +88 -0
- package/dist/progression/standing.d.ts +77 -0
- package/dist/progression/standing.js +151 -0
- package/dist/progression/upgrades.d.ts +70 -0
- package/dist/progression/upgrades.js +128 -0
- package/dist/ui/accessibility.d.ts +8 -0
- package/dist/ui/accessibility.js +14 -0
- package/package.json +1 -1
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-gameplay/SKILL.md +2 -0
- package/skills/jgengine-gameplay/api.md +89 -0
- package/skills/jgengine-gameplay/capabilities.md +34 -0
- package/skills/jgengine-gameplay/recipes/agent-lives.md +76 -0
- package/skills/jgengine-world/api.md +52 -0
- package/skills/jgengine-world/capabilities.md +32 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { seededRng } from "../../random/rng.js";
|
|
2
|
+
function clampInt(value, min, max) {
|
|
3
|
+
if (value === undefined || !Number.isFinite(value))
|
|
4
|
+
return min;
|
|
5
|
+
return Math.min(max, Math.max(min, Math.floor(value)));
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Expand one authored wave into the units it sends, at the times it sends them.
|
|
9
|
+
*
|
|
10
|
+
* Authoring stays data: composition (`unit`, `count`), entry (`entry`), timing (`at`, `jitterSeconds`,
|
|
11
|
+
* `durationSeconds`) and escalation (`escalates`, `elite`) all live in the
|
|
12
|
+
* definition. Pressure only *adds*: extra copies for `escalates` groups (capped at
|
|
13
|
+
* `maxExtraPerGroup`) and elite slots, and it shortens delays through `cadenceScale` — it never
|
|
14
|
+
* rewrites an authored unit or entry. Same definition, seed and pressure always produce the same
|
|
15
|
+
* schedule.
|
|
16
|
+
*
|
|
17
|
+
* @capability encounter-expand turn an authored wave into a deterministic, pressure-adjusted list of timed spawn orders
|
|
18
|
+
*/
|
|
19
|
+
export function expandWave(config, waveIndex, pressure = {}) {
|
|
20
|
+
const waves = config.definition.waves;
|
|
21
|
+
if (waveIndex < 0 || waveIndex >= waves.length)
|
|
22
|
+
return [];
|
|
23
|
+
const wave = waves[waveIndex];
|
|
24
|
+
const seed = config.seed ?? 1;
|
|
25
|
+
const defaultIntent = config.defaultIntent ?? "assault";
|
|
26
|
+
const maxExtra = Math.max(0, Math.floor(config.maxExtraPerGroup ?? 4));
|
|
27
|
+
const countBonus = clampInt(pressure.countBonus, 0, maxExtra);
|
|
28
|
+
const eliteSlots = clampInt(pressure.eliteSlots, 0, Number.MAX_SAFE_INTEGER);
|
|
29
|
+
const cadenceScale = pressure.cadenceScale === undefined || !Number.isFinite(pressure.cadenceScale) || pressure.cadenceScale <= 0
|
|
30
|
+
? 1
|
|
31
|
+
: Math.min(1, pressure.cadenceScale);
|
|
32
|
+
const rng = seededRng(`${seed}:${waveIndex}`);
|
|
33
|
+
const schedule = [];
|
|
34
|
+
let eliteUsed = 0;
|
|
35
|
+
for (let groupIndex = 0; groupIndex < wave.groups.length; groupIndex += 1) {
|
|
36
|
+
const group = wave.groups[groupIndex];
|
|
37
|
+
if (group.elite === true) {
|
|
38
|
+
if (eliteUsed >= eliteSlots)
|
|
39
|
+
continue;
|
|
40
|
+
eliteUsed += 1;
|
|
41
|
+
}
|
|
42
|
+
const authored = Math.max(0, Math.floor(group.count ?? 1));
|
|
43
|
+
const extra = group.escalates === true ? countBonus : 0;
|
|
44
|
+
const at = Math.max(0, group.at ?? 0) * cadenceScale;
|
|
45
|
+
for (let itemIndex = 0; itemIndex < authored + extra; itemIndex += 1) {
|
|
46
|
+
const jitter = group.jitterSeconds === undefined || group.jitterSeconds <= 0
|
|
47
|
+
? 0
|
|
48
|
+
: (rng() * 2 - 1) * group.jitterSeconds;
|
|
49
|
+
schedule.push({
|
|
50
|
+
groupIndex,
|
|
51
|
+
itemIndex,
|
|
52
|
+
order: {
|
|
53
|
+
wave: waveIndex,
|
|
54
|
+
waveId: wave.id,
|
|
55
|
+
unit: group.unit,
|
|
56
|
+
entry: group.entry,
|
|
57
|
+
intent: group.intent ?? defaultIntent,
|
|
58
|
+
atSeconds: Math.max(0, at + jitter),
|
|
59
|
+
group: groupIndex,
|
|
60
|
+
pressureAdded: itemIndex >= authored,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
schedule.sort((a, b) => a.order.atSeconds - b.order.atSeconds || a.groupIndex - b.groupIndex || a.itemIndex - b.itemIndex);
|
|
66
|
+
return schedule.map((entry) => entry.order);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Create a live encounter over an authored definition: open a wave, step its clock, take the orders
|
|
70
|
+
* whose time has come, and close the wave when the game says it is cleared.
|
|
71
|
+
*
|
|
72
|
+
* This is the composition layer, not a second spawn system: every order names a game-owned `unit`
|
|
73
|
+
* and `entry`, exactly like a `SpawnRequest` names an `entryId`, and the game builds the entity.
|
|
74
|
+
* Pair it with `combat/encounterSequence` when a fight needs scripted phase gates, and with
|
|
75
|
+
* `ai/waveRunner` when a wave should be drawn from a budget instead of authored.
|
|
76
|
+
*
|
|
77
|
+
* Pressure is captured when a wave opens, so a wave's composition cannot change mid-arrival; read
|
|
78
|
+
* `evaluatePressure` when the wave opens and pass the plan in.
|
|
79
|
+
*
|
|
80
|
+
* @capability encounter-runner open authored waves, emit their timed orders, and track wave progress through a save
|
|
81
|
+
*/
|
|
82
|
+
export function createEncounter(config) {
|
|
83
|
+
const waves = config.definition.waves;
|
|
84
|
+
let state = {
|
|
85
|
+
wave: 0,
|
|
86
|
+
opened: false,
|
|
87
|
+
elapsed: 0,
|
|
88
|
+
emitted: 0,
|
|
89
|
+
completedWaves: 0,
|
|
90
|
+
done: waves.length === 0,
|
|
91
|
+
countBonus: 0,
|
|
92
|
+
eliteSlots: 0,
|
|
93
|
+
cadenceScale: 1,
|
|
94
|
+
};
|
|
95
|
+
const readout = {
|
|
96
|
+
wave: 1,
|
|
97
|
+
totalWaves: waves.length,
|
|
98
|
+
waveId: null,
|
|
99
|
+
label: null,
|
|
100
|
+
total: 0,
|
|
101
|
+
emitted: 0,
|
|
102
|
+
progress: 0,
|
|
103
|
+
ready: false,
|
|
104
|
+
done: false,
|
|
105
|
+
};
|
|
106
|
+
function pressureOf() {
|
|
107
|
+
return { countBonus: state.countBonus, eliteSlots: state.eliteSlots, cadenceScale: state.cadenceScale };
|
|
108
|
+
}
|
|
109
|
+
function schedule() {
|
|
110
|
+
return state.opened ? expandWave(config, state.wave, pressureOf()) : [];
|
|
111
|
+
}
|
|
112
|
+
function durationSeconds() {
|
|
113
|
+
const wave = waves[state.wave];
|
|
114
|
+
if (wave?.durationSeconds === undefined)
|
|
115
|
+
return 0;
|
|
116
|
+
return Math.max(0, wave.durationSeconds) * state.cadenceScale;
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
openWave(index, pressure = {}) {
|
|
120
|
+
const target = index ?? state.completedWaves;
|
|
121
|
+
if (target < 0 || target >= waves.length) {
|
|
122
|
+
state = { ...state, done: true };
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
state = {
|
|
126
|
+
...state,
|
|
127
|
+
wave: target,
|
|
128
|
+
opened: true,
|
|
129
|
+
elapsed: 0,
|
|
130
|
+
emitted: 0,
|
|
131
|
+
done: false,
|
|
132
|
+
countBonus: clampInt(pressure.countBonus, 0, Math.max(0, Math.floor(config.maxExtraPerGroup ?? 4))),
|
|
133
|
+
eliteSlots: clampInt(pressure.eliteSlots, 0, Number.MAX_SAFE_INTEGER),
|
|
134
|
+
cadenceScale: pressure.cadenceScale === undefined || !Number.isFinite(pressure.cadenceScale) || pressure.cadenceScale <= 0
|
|
135
|
+
? 1
|
|
136
|
+
: Math.min(1, pressure.cadenceScale),
|
|
137
|
+
};
|
|
138
|
+
return schedule();
|
|
139
|
+
},
|
|
140
|
+
update(dt) {
|
|
141
|
+
if (!state.opened || state.done || !(dt > 0))
|
|
142
|
+
return [];
|
|
143
|
+
state = { ...state, elapsed: state.elapsed + dt };
|
|
144
|
+
const all = schedule();
|
|
145
|
+
const orders = [];
|
|
146
|
+
while (state.emitted < all.length && all[state.emitted].atSeconds <= state.elapsed + 1e-9) {
|
|
147
|
+
orders.push(all[state.emitted]);
|
|
148
|
+
state = { ...state, emitted: state.emitted + 1 };
|
|
149
|
+
}
|
|
150
|
+
return orders;
|
|
151
|
+
},
|
|
152
|
+
completeWave() {
|
|
153
|
+
if (!state.opened)
|
|
154
|
+
return;
|
|
155
|
+
const completedWaves = state.wave + 1;
|
|
156
|
+
state = {
|
|
157
|
+
...state,
|
|
158
|
+
opened: false,
|
|
159
|
+
elapsed: 0,
|
|
160
|
+
emitted: 0,
|
|
161
|
+
completedWaves,
|
|
162
|
+
wave: Math.min(completedWaves, waves.length),
|
|
163
|
+
done: completedWaves >= waves.length,
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
waveAt(index) {
|
|
167
|
+
return waves[index] ?? null;
|
|
168
|
+
},
|
|
169
|
+
view() {
|
|
170
|
+
const wave = waves[state.wave] ?? null;
|
|
171
|
+
const all = schedule();
|
|
172
|
+
const duration = durationSeconds();
|
|
173
|
+
readout.wave = Math.min(state.wave + 1, waves.length);
|
|
174
|
+
readout.waveId = wave?.id ?? null;
|
|
175
|
+
readout.label = wave?.label ?? null;
|
|
176
|
+
readout.total = all.length;
|
|
177
|
+
readout.emitted = state.emitted;
|
|
178
|
+
readout.progress =
|
|
179
|
+
all.length === 0
|
|
180
|
+
? state.opened && state.elapsed >= duration
|
|
181
|
+
? 1
|
|
182
|
+
: 0
|
|
183
|
+
: Math.min(1, state.emitted / all.length);
|
|
184
|
+
readout.ready = state.opened && state.emitted >= all.length && state.elapsed >= duration;
|
|
185
|
+
readout.done = state.done;
|
|
186
|
+
return readout;
|
|
187
|
+
},
|
|
188
|
+
snapshot() {
|
|
189
|
+
return { ...state };
|
|
190
|
+
},
|
|
191
|
+
restore(next) {
|
|
192
|
+
state = { ...next };
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encounter pressure: how hard a director leans on a player who is struggling, read from the
|
|
3
|
+
* player's own state, quantized to a tier, and bounded by the rule's cap. The plan is a value, not
|
|
4
|
+
* a callback, so a test can sweep it and a probe can print it.
|
|
5
|
+
*/
|
|
6
|
+
/** What the director reads about the player before it chooses a tier. */
|
|
7
|
+
export interface PressureReading {
|
|
8
|
+
/** Player health fraction `0..1`. Lower leans harder. */
|
|
9
|
+
health: number;
|
|
10
|
+
/** Encounter progress `0..1` — waves cleared over waves authored. Lower leans harder. */
|
|
11
|
+
progress: number;
|
|
12
|
+
/** Seconds the encounter has run. Longer leans harder, up to the window. */
|
|
13
|
+
elapsed: number;
|
|
14
|
+
}
|
|
15
|
+
/** The weights and window that turn a reading into an index, plus the hard cap. */
|
|
16
|
+
export interface PressureRule {
|
|
17
|
+
/** Weight of low player health. Default `1`. */
|
|
18
|
+
healthWeight?: number;
|
|
19
|
+
/** Weight of the encounter not being finished. Default `1`. */
|
|
20
|
+
progressWeight?: number;
|
|
21
|
+
/** Weight of time spent past `timeFloorSeconds`. Default `0.5`. */
|
|
22
|
+
timeWeight?: number;
|
|
23
|
+
/** Seconds of encounter time before the clock term starts. Default `90`. */
|
|
24
|
+
timeFloorSeconds?: number;
|
|
25
|
+
/** Seconds at which the clock term is full. Default `300`. */
|
|
26
|
+
timeWindowSeconds?: number;
|
|
27
|
+
/** Highest tier this rule can reach. Default `2`. Every plan bound derives from it. */
|
|
28
|
+
maxTier?: number;
|
|
29
|
+
}
|
|
30
|
+
/** One bounded answer to "how hard now", plus the effects the encounter layer applies. */
|
|
31
|
+
export interface PressurePlan {
|
|
32
|
+
/** `0..maxTier`. */
|
|
33
|
+
tier: number;
|
|
34
|
+
/** The weighted reading in `0..1` before quantization. */
|
|
35
|
+
index: number;
|
|
36
|
+
/** Extra units each pressure-scaled group gains: `0..maxTier`. */
|
|
37
|
+
countBonus: number;
|
|
38
|
+
/** Extra elite slots unlocked: `0..maxTier`. */
|
|
39
|
+
eliteSlots: number;
|
|
40
|
+
/** Multiplier on authored entry delays, `>= 1 / (1 + maxTier / 2)`. */
|
|
41
|
+
cadenceScale: number;
|
|
42
|
+
/** True when the tier sits on the rule's cap. */
|
|
43
|
+
atCap: boolean;
|
|
44
|
+
/** One line naming the terms, for a probe or a test failure. */
|
|
45
|
+
reason: string;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Turn one reading into a bounded pressure plan.
|
|
49
|
+
*
|
|
50
|
+
* The index is the weighted mean of three terms — how hurt the player is (`1 - health`), how far
|
|
51
|
+
* from finished the encounter is (`1 - progress`), and how long it has run — each clamped to
|
|
52
|
+
* `0..1`, then split into `maxTier + 1` bands. Because every term and every derived effect is a
|
|
53
|
+
* clamp of an authored cap, the rule cannot climb: a dead player on a stalled timer for an hour
|
|
54
|
+
* lands on `tier === maxTier` with `countBonus === maxTier`, never above it.
|
|
55
|
+
*
|
|
56
|
+
* @capability encounter-pressure bounded difficulty tier read from player health, encounter progress and time
|
|
57
|
+
*/
|
|
58
|
+
export declare function evaluatePressure(reading: PressureReading, rule?: PressureRule): PressurePlan;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encounter pressure: how hard a director leans on a player who is struggling, read from the
|
|
3
|
+
* player's own state, quantized to a tier, and bounded by the rule's cap. The plan is a value, not
|
|
4
|
+
* a callback, so a test can sweep it and a probe can print it.
|
|
5
|
+
*/
|
|
6
|
+
const RULE_DEFAULTS = {
|
|
7
|
+
healthWeight: 1,
|
|
8
|
+
progressWeight: 1,
|
|
9
|
+
timeWeight: 0.5,
|
|
10
|
+
timeFloorSeconds: 90,
|
|
11
|
+
timeWindowSeconds: 300,
|
|
12
|
+
maxTier: 2,
|
|
13
|
+
};
|
|
14
|
+
function clamp01(value) {
|
|
15
|
+
return value < 0 ? 0 : value > 1 ? 1 : value;
|
|
16
|
+
}
|
|
17
|
+
function resolveRule(rule) {
|
|
18
|
+
const maxTier = Math.max(0, Math.floor(rule.maxTier ?? RULE_DEFAULTS.maxTier));
|
|
19
|
+
return {
|
|
20
|
+
healthWeight: Math.max(0, rule.healthWeight ?? RULE_DEFAULTS.healthWeight),
|
|
21
|
+
progressWeight: Math.max(0, rule.progressWeight ?? RULE_DEFAULTS.progressWeight),
|
|
22
|
+
timeWeight: Math.max(0, rule.timeWeight ?? RULE_DEFAULTS.timeWeight),
|
|
23
|
+
timeFloorSeconds: Math.max(0, rule.timeFloorSeconds ?? RULE_DEFAULTS.timeFloorSeconds),
|
|
24
|
+
timeWindowSeconds: Math.max(1, rule.timeWindowSeconds ?? RULE_DEFAULTS.timeWindowSeconds),
|
|
25
|
+
maxTier,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** The clock term in `0..1`: zero inside the floor, full at the end of the window. */
|
|
29
|
+
function timeTerm(elapsed, rule) {
|
|
30
|
+
const span = rule.timeWindowSeconds - rule.timeFloorSeconds;
|
|
31
|
+
if (!(span > 0))
|
|
32
|
+
return elapsed >= rule.timeWindowSeconds ? 1 : 0;
|
|
33
|
+
return clamp01((elapsed - rule.timeFloorSeconds) / span);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Turn one reading into a bounded pressure plan.
|
|
37
|
+
*
|
|
38
|
+
* The index is the weighted mean of three terms — how hurt the player is (`1 - health`), how far
|
|
39
|
+
* from finished the encounter is (`1 - progress`), and how long it has run — each clamped to
|
|
40
|
+
* `0..1`, then split into `maxTier + 1` bands. Because every term and every derived effect is a
|
|
41
|
+
* clamp of an authored cap, the rule cannot climb: a dead player on a stalled timer for an hour
|
|
42
|
+
* lands on `tier === maxTier` with `countBonus === maxTier`, never above it.
|
|
43
|
+
*
|
|
44
|
+
* @capability encounter-pressure bounded difficulty tier read from player health, encounter progress and time
|
|
45
|
+
*/
|
|
46
|
+
export function evaluatePressure(reading, rule = {}) {
|
|
47
|
+
const resolved = resolveRule(rule);
|
|
48
|
+
const health = clamp01(Number.isFinite(reading.health) ? reading.health : 1);
|
|
49
|
+
const progress = clamp01(Number.isFinite(reading.progress) ? reading.progress : 1);
|
|
50
|
+
const elapsed = Number.isFinite(reading.elapsed) ? Math.max(0, reading.elapsed) : 0;
|
|
51
|
+
const time = timeTerm(elapsed, resolved);
|
|
52
|
+
const totalWeight = resolved.healthWeight + resolved.progressWeight + resolved.timeWeight;
|
|
53
|
+
const index = totalWeight <= 0
|
|
54
|
+
? 0
|
|
55
|
+
: clamp01((resolved.healthWeight * (1 - health) +
|
|
56
|
+
resolved.progressWeight * (1 - progress) +
|
|
57
|
+
resolved.timeWeight * time) /
|
|
58
|
+
totalWeight);
|
|
59
|
+
const tier = Math.min(resolved.maxTier, Math.max(0, Math.floor(index * (resolved.maxTier + 1))));
|
|
60
|
+
return {
|
|
61
|
+
tier,
|
|
62
|
+
index,
|
|
63
|
+
countBonus: tier,
|
|
64
|
+
eliteSlots: Math.max(0, tier - 1),
|
|
65
|
+
cadenceScale: 1 / (1 + tier / 2),
|
|
66
|
+
atCap: tier >= resolved.maxTier,
|
|
67
|
+
reason: `tier ${tier}/${resolved.maxTier}: health ${health.toFixed(2)}, progress ${progress.toFixed(2)}, ` +
|
|
68
|
+
`time ${time.toFixed(2)} (${elapsed.toFixed(0)}s), index ${index.toFixed(2)}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { SquadComposition, SquadRole } from "../../combat/squadTactics.js";
|
|
2
|
+
import type { Vec3 } from "../../world/geometry.js";
|
|
3
|
+
/**
|
|
4
|
+
* The five duties a fighter can be given in a fight. A duty is *who does what*; the squad role a
|
|
5
|
+
* game dials (range, angles, fire discipline) is *how that duty fights* and stays content.
|
|
6
|
+
*/
|
|
7
|
+
export type FightIntent = "retreat" | "hold" | "flank" | "cover" | "assault";
|
|
8
|
+
/** One fighter the director can order, with the state the decision reads. */
|
|
9
|
+
export interface FighterState {
|
|
10
|
+
id: string;
|
|
11
|
+
/** Position on the ground plane; only `x` and `z` are read. */
|
|
12
|
+
position: Vec3;
|
|
13
|
+
/** Health fraction `0..1`. */
|
|
14
|
+
health: number;
|
|
15
|
+
/** True while the fighter is taking fire this tick. */
|
|
16
|
+
underFire?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/** The squad as it stands and the enemy it is facing, or no enemy at all. */
|
|
19
|
+
export interface FightSituation {
|
|
20
|
+
fighters: readonly FighterState[];
|
|
21
|
+
/** Where the squad last knew the enemy to be, or null when it has no contact. */
|
|
22
|
+
threat: Vec3 | null;
|
|
23
|
+
/** Health fraction at or below which a fighter falls back. Default `0.25`. */
|
|
24
|
+
retreatBelow?: number;
|
|
25
|
+
}
|
|
26
|
+
/** What the director decided, per fighter, and why. */
|
|
27
|
+
export interface FightOrders {
|
|
28
|
+
/** Duty per fighter id. Every fighter in the situation appears exactly once. */
|
|
29
|
+
intents: Map<string, FightIntent>;
|
|
30
|
+
/** How many fighters took each duty, for a HUD or a test. */
|
|
31
|
+
counts: Record<FightIntent, number>;
|
|
32
|
+
/** One line naming the terms that drove the split. */
|
|
33
|
+
reason: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Assign each fighter a duty from the squad's own state — who is hurt, who is under fire, and who
|
|
37
|
+
* already stands on the outside of the formation — never from an authored order or a spawn index.
|
|
38
|
+
*
|
|
39
|
+
* Rules, applied in this order:
|
|
40
|
+
* 1. A fighter at or below `retreatBelow` health falls back (`retreat`).
|
|
41
|
+
* 2. With no known enemy the rest advance (`assault`); there is nothing to hold, flank or hide from.
|
|
42
|
+
* 3. Against a known enemy one fighter holds the line: the healthiest fighter closest to the
|
|
43
|
+
* squad's centre, ties broken by id, so the anchor is the one already standing there.
|
|
44
|
+
* 4. A third of what is left flanks, taken from the outside of the formation, alternating sides so
|
|
45
|
+
* a squad never stacks its flankers on one shoulder. Under fire disqualifies a flanker.
|
|
46
|
+
* 5. Everyone else fights from cover, which is where `createSquadCombat` puts them anyway.
|
|
47
|
+
*
|
|
48
|
+
* Deterministic: same situation, same orders, and input order does not change a duty.
|
|
49
|
+
*
|
|
50
|
+
* @capability fight-intents assign hold, flank, cover, assault and retreat duties from live squad state
|
|
51
|
+
*/
|
|
52
|
+
export declare function assignFightIntents(situation: FightSituation): FightOrders;
|
|
53
|
+
/**
|
|
54
|
+
* Turn duties into a squad composition `createSquadCombat` accepts: one slot per fighter, in the
|
|
55
|
+
* order the game gives its members, each mapped to the role that game dials for the duty. Build it
|
|
56
|
+
* through `composition` so squad combat executes the director's assignment instead of its own
|
|
57
|
+
* static list.
|
|
58
|
+
*
|
|
59
|
+
* @capability fight-intent-composition map assigned duties onto a squad combat composition in member order
|
|
60
|
+
*/
|
|
61
|
+
export declare function compositionForIntents(order: readonly string[], intents: ReadonlyMap<string, FightIntent>, roleFor: (intent: FightIntent) => SquadRole): SquadComposition;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
const DEFAULT_RETREAT_BELOW = 0.25;
|
|
2
|
+
function flatDistance(a, b) {
|
|
3
|
+
return Math.hypot(a[0] - b[0], a[2] - b[2]);
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Assign each fighter a duty from the squad's own state — who is hurt, who is under fire, and who
|
|
7
|
+
* already stands on the outside of the formation — never from an authored order or a spawn index.
|
|
8
|
+
*
|
|
9
|
+
* Rules, applied in this order:
|
|
10
|
+
* 1. A fighter at or below `retreatBelow` health falls back (`retreat`).
|
|
11
|
+
* 2. With no known enemy the rest advance (`assault`); there is nothing to hold, flank or hide from.
|
|
12
|
+
* 3. Against a known enemy one fighter holds the line: the healthiest fighter closest to the
|
|
13
|
+
* squad's centre, ties broken by id, so the anchor is the one already standing there.
|
|
14
|
+
* 4. A third of what is left flanks, taken from the outside of the formation, alternating sides so
|
|
15
|
+
* a squad never stacks its flankers on one shoulder. Under fire disqualifies a flanker.
|
|
16
|
+
* 5. Everyone else fights from cover, which is where `createSquadCombat` puts them anyway.
|
|
17
|
+
*
|
|
18
|
+
* Deterministic: same situation, same orders, and input order does not change a duty.
|
|
19
|
+
*
|
|
20
|
+
* @capability fight-intents assign hold, flank, cover, assault and retreat duties from live squad state
|
|
21
|
+
*/
|
|
22
|
+
export function assignFightIntents(situation) {
|
|
23
|
+
const retreatBelow = situation.retreatBelow ?? DEFAULT_RETREAT_BELOW;
|
|
24
|
+
const intents = new Map();
|
|
25
|
+
const counts = { retreat: 0, hold: 0, flank: 0, cover: 0, assault: 0 };
|
|
26
|
+
const fighters = [...situation.fighters].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
27
|
+
const assign = (fighter, intent) => {
|
|
28
|
+
intents.set(fighter.id, intent);
|
|
29
|
+
counts[intent] += 1;
|
|
30
|
+
};
|
|
31
|
+
let x = 0;
|
|
32
|
+
let z = 0;
|
|
33
|
+
for (const fighter of fighters) {
|
|
34
|
+
x += fighter.position[0];
|
|
35
|
+
z += fighter.position[2];
|
|
36
|
+
}
|
|
37
|
+
const centre = fighters.length === 0 ? [0, 0, 0] : [x / fighters.length, 0, z / fighters.length];
|
|
38
|
+
const remaining = [];
|
|
39
|
+
for (const fighter of fighters) {
|
|
40
|
+
if (fighter.health <= retreatBelow)
|
|
41
|
+
assign(fighter, "retreat");
|
|
42
|
+
else
|
|
43
|
+
remaining.push(fighter);
|
|
44
|
+
}
|
|
45
|
+
const threat = situation.threat;
|
|
46
|
+
if (threat === null) {
|
|
47
|
+
for (const fighter of remaining)
|
|
48
|
+
assign(fighter, "assault");
|
|
49
|
+
return {
|
|
50
|
+
intents,
|
|
51
|
+
counts,
|
|
52
|
+
reason: `no contact: ${counts.assault} assault, ${counts.retreat} retreat below ${retreatBelow}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const forwardLength = Math.max(0.0001, flatDistance(centre, threat));
|
|
56
|
+
const forwardX = (threat[0] - centre[0]) / forwardLength;
|
|
57
|
+
const forwardZ = (threat[2] - centre[2]) / forwardLength;
|
|
58
|
+
const sideOf = (fighter) => forwardX * (fighter.position[2] - centre[2]) - forwardZ * (fighter.position[0] - centre[0]);
|
|
59
|
+
if (remaining.length > 0) {
|
|
60
|
+
let anchor = remaining[0];
|
|
61
|
+
for (const fighter of remaining) {
|
|
62
|
+
const candidate = Math.abs(sideOf(fighter));
|
|
63
|
+
const held = Math.abs(sideOf(anchor));
|
|
64
|
+
if (candidate < held - 0.0001 || (Math.abs(candidate - held) <= 0.0001 && fighter.health > anchor.health)) {
|
|
65
|
+
anchor = fighter;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
assign(anchor, "hold");
|
|
69
|
+
}
|
|
70
|
+
const candidates = remaining
|
|
71
|
+
.filter((fighter) => intents.get(fighter.id) !== "hold" && fighter.underFire !== true)
|
|
72
|
+
.sort((a, b) => {
|
|
73
|
+
const lateral = Math.abs(sideOf(b)) - Math.abs(sideOf(a));
|
|
74
|
+
if (Math.abs(lateral) > 0.0001)
|
|
75
|
+
return lateral;
|
|
76
|
+
const health = b.health - a.health;
|
|
77
|
+
if (Math.abs(health) > 0.0001)
|
|
78
|
+
return health;
|
|
79
|
+
return a.id < b.id ? -1 : 1;
|
|
80
|
+
});
|
|
81
|
+
let flankers = Math.min(candidates.length, Math.floor(remaining.length / 3));
|
|
82
|
+
let side = 0;
|
|
83
|
+
while (flankers > 0) {
|
|
84
|
+
const pick = side === 0
|
|
85
|
+
? candidates.find((fighter) => intents.get(fighter.id) === undefined)
|
|
86
|
+
: candidates.find((fighter) => intents.get(fighter.id) === undefined && Math.sign(sideOf(fighter)) === side) ?? candidates.find((fighter) => intents.get(fighter.id) === undefined);
|
|
87
|
+
if (pick === undefined)
|
|
88
|
+
break;
|
|
89
|
+
assign(pick, "flank");
|
|
90
|
+
side = side === 0 ? -Math.sign(sideOf(pick)) : -side;
|
|
91
|
+
flankers -= 1;
|
|
92
|
+
}
|
|
93
|
+
for (const fighter of remaining)
|
|
94
|
+
if (intents.get(fighter.id) === undefined)
|
|
95
|
+
assign(fighter, "cover");
|
|
96
|
+
return {
|
|
97
|
+
intents,
|
|
98
|
+
counts,
|
|
99
|
+
reason: `${counts.hold} hold, ${counts.flank} flank, ${counts.cover} cover, ${counts.assault} assault, ` +
|
|
100
|
+
`${counts.retreat} retreat below ${retreatBelow}`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Turn duties into a squad composition `createSquadCombat` accepts: one slot per fighter, in the
|
|
105
|
+
* order the game gives its members, each mapped to the role that game dials for the duty. Build it
|
|
106
|
+
* through `composition` so squad combat executes the director's assignment instead of its own
|
|
107
|
+
* static list.
|
|
108
|
+
*
|
|
109
|
+
* @capability fight-intent-composition map assigned duties onto a squad combat composition in member order
|
|
110
|
+
*/
|
|
111
|
+
export function compositionForIntents(order, intents, roleFor) {
|
|
112
|
+
const roles = [];
|
|
113
|
+
for (const id of order) {
|
|
114
|
+
const intent = intents.get(id);
|
|
115
|
+
roles.push({ ...roleFor(intent ?? "cover"), count: 1 });
|
|
116
|
+
}
|
|
117
|
+
return { roles };
|
|
118
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { PerceptionService } from "../../sensor/perception.js";
|
|
2
|
+
import type { Vec3 } from "../../world/geometry.js";
|
|
3
|
+
/** A unit that could hear a noise: its id and where it stands when the noise lands. */
|
|
4
|
+
export interface SoundHunter {
|
|
5
|
+
id: string;
|
|
6
|
+
position: Vec3;
|
|
7
|
+
}
|
|
8
|
+
/** One noise in the world, as the game produces it. */
|
|
9
|
+
export interface NoiseEvent {
|
|
10
|
+
/** Who made the noise — the hearing owner records the memory against this id. */
|
|
11
|
+
sourceId: string;
|
|
12
|
+
position: Vec3;
|
|
13
|
+
/** Seconds on the game clock, the same clock the perception service is given. */
|
|
14
|
+
at: number;
|
|
15
|
+
/** Multiplies `hearingRange` exactly as `PerceptionStimulus.loudness` does. Default `1`. */
|
|
16
|
+
loudness?: number;
|
|
17
|
+
}
|
|
18
|
+
/** A hunter still working a noise, as the squad layer reads it this tick. */
|
|
19
|
+
export interface Investigation {
|
|
20
|
+
hunterId: string;
|
|
21
|
+
sourceId: string;
|
|
22
|
+
/** Where the noise came from: the hearing owner's remembered position. */
|
|
23
|
+
position: Vec3;
|
|
24
|
+
/** Confidence left in the memory, `0..1`; it falls as the memory decays and ends the hunt at 0. */
|
|
25
|
+
confidence: number;
|
|
26
|
+
/** Seconds since the noise landed. */
|
|
27
|
+
ageSeconds: number;
|
|
28
|
+
}
|
|
29
|
+
/** One noise and the hunters sent to it. Serializable, so a hunt survives a save. */
|
|
30
|
+
export interface SoundHuntEntry {
|
|
31
|
+
noise: NoiseEvent;
|
|
32
|
+
hunters: string[];
|
|
33
|
+
}
|
|
34
|
+
/** Serializable book-keeping: which noises are still being worked, and by whom. */
|
|
35
|
+
export interface SoundHuntSnapshot {
|
|
36
|
+
entries: SoundHuntEntry[];
|
|
37
|
+
}
|
|
38
|
+
/** Everything the hunt reads and its bounds. */
|
|
39
|
+
export interface SoundHuntConfig {
|
|
40
|
+
perception: PerceptionService;
|
|
41
|
+
/** Candidates near a noise, bounded by the caller's roster. Called once per noise, not per tick. */
|
|
42
|
+
hunters: (position: Vec3, hearingRange: number) => readonly SoundHunter[];
|
|
43
|
+
/** The same hearing range given to `createPerception`. */
|
|
44
|
+
hearingRange: number;
|
|
45
|
+
/** Most hunters one noise sends. Default `3`. */
|
|
46
|
+
maxInvestigators?: number;
|
|
47
|
+
/** Confidence at or below which a hunter gives up and returns to its post. Default `0.15`. */
|
|
48
|
+
giveUpConfidence?: number;
|
|
49
|
+
/** Longest one hunt survives, seconds. Default `20`. */
|
|
50
|
+
maxSeconds?: number;
|
|
51
|
+
/** Most noises remembered at once; the oldest is dropped first. Default `16`. */
|
|
52
|
+
maxNoises?: number;
|
|
53
|
+
}
|
|
54
|
+
/** Sound hunting: raise a noise, get the bounded set of enemies that go and look. */
|
|
55
|
+
export interface SoundHunt {
|
|
56
|
+
/**
|
|
57
|
+
* Record one noise in the hearing owner's stimulus stream and admit the nearest hunters to it.
|
|
58
|
+
* Returns the admitted hunter ids so the caller can issue its own move order now; the decay and
|
|
59
|
+
* the give-up are read back through {@link SoundHunt.investigations}.
|
|
60
|
+
*/
|
|
61
|
+
noise(event: NoiseEvent): readonly string[];
|
|
62
|
+
/** Hunters still working their noise at `nowMs`, with the memory's remaining confidence. */
|
|
63
|
+
investigations(nowMs: number): Investigation[];
|
|
64
|
+
snapshot(): SoundHuntSnapshot;
|
|
65
|
+
restore(snapshot: SoundHuntSnapshot): void;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Make enemies investigate a noise, reusing the hearing owner.
|
|
69
|
+
*
|
|
70
|
+
* The noise goes into `PerceptionService.pushStimulus` as a `sound` stimulus, so the same range,
|
|
71
|
+
* loudness and linear memory decay that every other hearing consumer gets apply here; this layer
|
|
72
|
+
* adds only the two things a squad needs: a *bound* on how many enemies chase one noise, and a
|
|
73
|
+
* *give-up* rule, both read back from that memory instead of a second copy of it. A sighting of the
|
|
74
|
+
* source during the hunt raises the same memory's confidence, so an investigation escalates into a
|
|
75
|
+
* pursuit without any bookkeeping here.
|
|
76
|
+
*
|
|
77
|
+
* @capability sound-hunting send a bounded set of nearby enemies to investigate a noise with decaying memory
|
|
78
|
+
*/
|
|
79
|
+
export declare function createSoundHunt(config: SoundHuntConfig): SoundHunt;
|