@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/agents/agent.d.ts +134 -0
  3. package/dist/agents/agent.js +224 -0
  4. package/dist/agents/mood.d.ts +74 -0
  5. package/dist/agents/mood.js +97 -0
  6. package/dist/agents/relationships.d.ts +92 -0
  7. package/dist/agents/relationships.js +132 -0
  8. package/dist/agents/schedule.d.ts +58 -0
  9. package/dist/agents/schedule.js +72 -0
  10. package/dist/ai/director/boss.d.ts +63 -0
  11. package/dist/ai/director/boss.js +87 -0
  12. package/dist/ai/director/encounter.d.ts +146 -0
  13. package/dist/ai/director/encounter.js +195 -0
  14. package/dist/ai/director/pressure.d.ts +58 -0
  15. package/dist/ai/director/pressure.js +70 -0
  16. package/dist/ai/director/roles.d.ts +61 -0
  17. package/dist/ai/director/roles.js +118 -0
  18. package/dist/ai/director/sound.d.ts +79 -0
  19. package/dist/ai/director/sound.js +113 -0
  20. package/dist/game/playableGame.d.ts +2 -2
  21. package/dist/meta/changelog.d.ts +1 -1
  22. package/dist/meta/changelog.js +30 -1
  23. package/dist/mission/mission.d.ts +1 -1
  24. package/dist/mission/mission.js +4 -1
  25. package/dist/progression/careerBank.d.ts +63 -0
  26. package/dist/progression/careerBank.js +83 -0
  27. package/dist/progression/gates.d.ts +66 -0
  28. package/dist/progression/gates.js +88 -0
  29. package/dist/progression/standing.d.ts +77 -0
  30. package/dist/progression/standing.js +151 -0
  31. package/dist/progression/upgrades.d.ts +70 -0
  32. package/dist/progression/upgrades.js +128 -0
  33. package/dist/ui/accessibility.d.ts +8 -0
  34. package/dist/ui/accessibility.js +14 -0
  35. package/package.json +1 -1
  36. package/skills/jgengine/api.md +2 -2
  37. package/skills/jgengine-gameplay/SKILL.md +2 -0
  38. package/skills/jgengine-gameplay/api.md +89 -0
  39. package/skills/jgengine-gameplay/capabilities.md +34 -0
  40. package/skills/jgengine-gameplay/recipes/agent-lives.md +76 -0
  41. package/skills/jgengine-world/api.md +52 -0
  42. package/skills/jgengine-world/capabilities.md +32 -0
@@ -0,0 +1,132 @@
1
+ import { clamp } from "../math/scalar.js";
2
+ import { createPairKeyCodec } from "../relation/keyedValues.js";
3
+ import { crossThresholds } from "../relation/thresholds.js";
4
+ /** The pair levels every game starts from; a game overrides the words and the cut points. */
5
+ export const DEFAULT_RELATIONSHIP_MILESTONES = [
6
+ { id: "friends", label: "Friends", at: 35 },
7
+ { id: "close", label: "Close", at: 70 },
8
+ { id: "bonded", label: "Bonded", at: 95, requiresFamiliarity: 0.5 },
9
+ ];
10
+ /** What a plain social action adds: a shared moment nudges both dials, never maxes either. */
11
+ export const DEFAULT_SOCIAL_CONTACT = { familiarity: 0.08, opinion: 4 };
12
+ const DEFAULTS = {
13
+ opinionMin: -100,
14
+ opinionMax: 100,
15
+ familiarityMax: 1,
16
+ milestones: DEFAULT_RELATIONSHIP_MILESTONES,
17
+ };
18
+ /**
19
+ * Create the shared book of pair state. Keys are undirected and delimiter-safe through
20
+ * `createPairKeyCodec`, and milestone crossings come from `crossThresholds`, so a notice feed and a
21
+ * journal both read the same event.
22
+ *
23
+ * @capability relationships familiarity and opinion between agents, with a bond that changes behaviour
24
+ */
25
+ export function createRelationshipBook(policy = {}) {
26
+ let current = { ...DEFAULTS, ...policy };
27
+ const codec = createPairKeyCodec();
28
+ let pairs = new Map();
29
+ const pairKey = (a, b) => codec.key(a, b);
30
+ const read = (a, b) => pairs.get(pairKey(a, b)) ?? { familiarity: 0, opinion: 0 };
31
+ const boundaries = () => current.milestones.map((milestone) => ({ id: milestone, at: milestone.at }));
32
+ const gate = (milestone, pair) => pair.familiarity >= (milestone.requiresFamiliarity ?? 0);
33
+ const reached = (pair) => {
34
+ let best = null;
35
+ for (const milestone of current.milestones) {
36
+ if (pair.opinion < milestone.at || !gate(milestone, pair))
37
+ continue;
38
+ if (best === null || milestone.at > best.at)
39
+ best = milestone;
40
+ }
41
+ return best;
42
+ };
43
+ return {
44
+ get: read,
45
+ familiarity: (a, b) => read(a, b).familiarity,
46
+ opinion: (a, b) => read(a, b).opinion,
47
+ socialise(a, b, contact = DEFAULT_SOCIAL_CONTACT) {
48
+ const before = read(a, b);
49
+ if (a === b)
50
+ return { a, b, before, after: before, crossed: [] };
51
+ const familiarityDelta = contact.familiarity ?? 0;
52
+ const opinionDelta = contact.opinion ?? 0;
53
+ const after = {
54
+ familiarity: clamp(before.familiarity + familiarityDelta, 0, current.familiarityMax),
55
+ opinion: clamp(before.opinion + opinionDelta, current.opinionMin, current.opinionMax),
56
+ };
57
+ pairs.set(pairKey(a, b), after);
58
+ const crossed = crossThresholds(boundaries(), before.opinion, after.opinion)
59
+ .filter((crossing) => crossing.direction === "up")
60
+ .map((crossing) => crossing.id)
61
+ .filter((milestone) => gate(milestone, after));
62
+ return { a, b, before, after, crossed };
63
+ },
64
+ milestone: (a, b) => reached(read(a, b)),
65
+ label: (a, b) => (a === b ? null : reached(read(a, b))?.id ?? null),
66
+ friends(a, b) {
67
+ if (a === b)
68
+ return false;
69
+ return reached(read(a, b)) !== null;
70
+ },
71
+ bonded(a, b) {
72
+ if (a === b)
73
+ return false;
74
+ return reached(read(a, b))?.id === "bonded";
75
+ },
76
+ bondedWith(a) {
77
+ let bestId = null;
78
+ let bestOpinion = -Infinity;
79
+ for (const [key, pair] of pairs) {
80
+ const [first, second] = codec.parse(key);
81
+ const other = first === a ? second : second === a ? first : null;
82
+ if (other === null || !(pair.opinion > bestOpinion))
83
+ continue;
84
+ if (reached(pair)?.id !== "bonded")
85
+ continue;
86
+ bestOpinion = pair.opinion;
87
+ bestId = other;
88
+ }
89
+ return bestId;
90
+ },
91
+ prefersCompanyOf(a, b) {
92
+ return a !== b && reached(read(a, b)) !== null;
93
+ },
94
+ refusesToSplit(a, b) {
95
+ return a !== b && reached(read(a, b))?.id === "bonded";
96
+ },
97
+ partnersOf(a) {
98
+ const ids = [];
99
+ for (const key of pairs.keys()) {
100
+ const [first, second] = codec.parse(key);
101
+ if (first === a)
102
+ ids.push(second);
103
+ else if (second === a)
104
+ ids.push(first);
105
+ }
106
+ return ids.sort();
107
+ },
108
+ policy: () => ({ ...current, milestones: current.milestones }),
109
+ retune(patch) {
110
+ current = { ...current, ...patch };
111
+ },
112
+ snapshot: () => {
113
+ const out = {};
114
+ for (const [key, pair] of pairs)
115
+ out[key] = { ...pair };
116
+ return { pairs: out, policy: { ...current, milestones: current.milestones } };
117
+ },
118
+ restore(next) {
119
+ current = { ...DEFAULTS, ...next.policy };
120
+ const restored = new Map();
121
+ for (const [key, pair] of Object.entries(next.pairs)) {
122
+ if (!Number.isFinite(pair.familiarity) || !Number.isFinite(pair.opinion))
123
+ continue;
124
+ restored.set(key, {
125
+ familiarity: clamp(pair.familiarity, 0, current.familiarityMax),
126
+ opinion: clamp(pair.opinion, current.opinionMin, current.opinionMax),
127
+ });
128
+ }
129
+ pairs = restored;
130
+ },
131
+ };
132
+ }
@@ -0,0 +1,58 @@
1
+ import { type RoutineStopSpec } from "../sim/routine.js";
2
+ /**
3
+ * One authored stop in an agent's day. The shape is the routine owner's (`sim/routine`) plus a
4
+ * beat: the places a working stop walks in order, so a janitor on patrol is an agent with a route
5
+ * rather than a special case.
6
+ */
7
+ export interface AgentScheduleStop extends RoutineStopSpec {
8
+ /** Places walked in order while this stop holds. Omit to stay put at `place`. */
9
+ readonly beat?: readonly string[];
10
+ /** Seconds at each beat place. Default: the stop's own length split evenly across the beat. */
11
+ readonly beatDwellSeconds?: number;
12
+ }
13
+ /** An agent's whole day as data: what it does, where, and when. */
14
+ export interface AgentScheduleConfig {
15
+ readonly stops: readonly AgentScheduleStop[];
16
+ /** Cycle length in game-seconds; default one game day. */
17
+ readonly cycleSeconds?: number;
18
+ /** Injected travel time between two places when a stop does not author `travelSeconds`. */
19
+ readonly travelSeconds?: (from: string, to: string) => number;
20
+ }
21
+ /** Where a schedule puts an agent at one instant. */
22
+ export interface AgentSchedulePhase {
23
+ /** Opaque place the agent occupies, or the place it is walking from. */
24
+ readonly place: string;
25
+ /** Place it is walking to; `null` while holding. */
26
+ readonly nextPlace: string | null;
27
+ /** The stop's activity label ("work", "meal", "sleep"); `null` while travelling. */
28
+ readonly activity: string | null;
29
+ /** Where the beat posts the agent right now: a patrol place on a beating stop, `place` otherwise, `null` while travelling. */
30
+ readonly post: string | null;
31
+ readonly travelling: boolean;
32
+ /** 0 at departure to 1 on arrival. */
33
+ readonly progress: number;
34
+ /** Absolute game-second this phase began. */
35
+ readonly since: number;
36
+ /** Absolute game-second the next phase begins. */
37
+ readonly until: number;
38
+ /** Index of the stop held here, or the stop being walked away from. */
39
+ readonly stop: number;
40
+ /** Needs this stop refills on arrival, or `null`. */
41
+ readonly refill: Readonly<Record<string, number>> | null;
42
+ }
43
+ /** A day plan resolved against the world clock. No state to tick: a far agent reads the same day. */
44
+ export interface AgentSchedule {
45
+ readonly cycleSeconds: number;
46
+ readonly stops: readonly AgentScheduleStop[];
47
+ phaseAt(time: number): AgentSchedulePhase;
48
+ /** Absolute game-second of the next arrival, departure or beat step strictly after `time`. */
49
+ nextChangeAfter(time: number): number;
50
+ }
51
+ /**
52
+ * Resolve an authored day plan into a clock-driven schedule. Timing, travel and arrival slips come
53
+ * from `sim/routine`; this adds the work beat and the refills a stop carries, so an off-hours stop
54
+ * is just another authored stop and a patrol is not a second scheduler.
55
+ *
56
+ * @capability agent-schedule an authored per-agent day plan with a work beat and off-hours behaviour
57
+ */
58
+ export declare function createAgentSchedule(config: AgentScheduleConfig): AgentSchedule;
@@ -0,0 +1,72 @@
1
+ import { resolveRoutine } from "../sim/routine.js";
2
+ /**
3
+ * Resolve an authored day plan into a clock-driven schedule. Timing, travel and arrival slips come
4
+ * from `sim/routine`; this adds the work beat and the refills a stop carries, so an off-hours stop
5
+ * is just another authored stop and a patrol is not a second scheduler.
6
+ *
7
+ * @capability agent-schedule an authored per-agent day plan with a work beat and off-hours behaviour
8
+ */
9
+ export function createAgentSchedule(config) {
10
+ const timeline = resolveRoutine({
11
+ stops: config.stops,
12
+ ...(config.cycleSeconds === undefined ? {} : { cycleSeconds: config.cycleSeconds }),
13
+ ...(config.travelSeconds === undefined ? {} : { travelSeconds: config.travelSeconds }),
14
+ });
15
+ const beats = config.stops.map((stop, index) => {
16
+ const resolved = timeline.stops[index];
17
+ const places = stop.beat !== undefined && stop.beat.length > 0 ? [...stop.beat] : null;
18
+ if (places === null)
19
+ return null;
20
+ const span = Math.max(0, resolved.departAt - resolved.arriveAt);
21
+ const dwell = stop.beatDwellSeconds ?? span / places.length;
22
+ return { places, dwellSeconds: Math.max(dwell, 0) };
23
+ });
24
+ const postAt = (time, phase) => {
25
+ if (phase.travelling)
26
+ return null;
27
+ const beat = beats[phase.stop] ?? null;
28
+ if (beat === null)
29
+ return phase.place;
30
+ if (!(beat.dwellSeconds > 0))
31
+ return beat.places[0] ?? phase.place;
32
+ const elapsed = Math.max(0, time - phase.since);
33
+ const step = Math.floor(elapsed / beat.dwellSeconds) % beat.places.length;
34
+ return beat.places[step] ?? phase.place;
35
+ };
36
+ const beatOf = (phase) => {
37
+ if (phase.travelling)
38
+ return null;
39
+ const beat = beats[phase.stop] ?? null;
40
+ return beat !== null && beat.dwellSeconds > 0 ? beat : null;
41
+ };
42
+ return {
43
+ cycleSeconds: timeline.cycleSeconds,
44
+ stops: config.stops,
45
+ phaseAt(time) {
46
+ const phase = timeline.phaseAt(time);
47
+ const stop = config.stops[phase.stop];
48
+ return {
49
+ place: phase.place,
50
+ nextPlace: phase.nextPlace,
51
+ activity: phase.activity,
52
+ post: postAt(time, phase),
53
+ travelling: phase.travelling,
54
+ progress: phase.progress,
55
+ since: phase.since,
56
+ until: phase.until,
57
+ stop: phase.stop,
58
+ refill: stop.refill ?? null,
59
+ };
60
+ },
61
+ nextChangeAfter(time) {
62
+ const phaseEnd = timeline.nextChangeAfter(time);
63
+ const beat = beatOf(timeline.phaseAt(time));
64
+ if (beat === null)
65
+ return phaseEnd;
66
+ const phase = timeline.phaseAt(time);
67
+ const elapsed = Math.max(0, time - phase.since);
68
+ const beatEnd = phase.since + (Math.floor(elapsed / beat.dwellSeconds) + 1) * beat.dwellSeconds;
69
+ return beatEnd > time ? Math.min(phaseEnd, beatEnd) : phaseEnd;
70
+ },
71
+ };
72
+ }
@@ -0,0 +1,63 @@
1
+ import type { SquadRole } from "../../combat/squadTactics.js";
2
+ /** The role shape a boss fights with in one phase: the same dials a squad role uses. */
3
+ export type BossBehaviour = Omit<SquadRole, "id" | "count">;
4
+ /** The warning a boss gives while it changes phase, and how long the player has to read it. */
5
+ export interface BossTell {
6
+ /** Game-owned id: an animation, a sound, a HUD banner. */
7
+ id: string;
8
+ /** Seconds the tell stays visible after the phase changes. Default `1.5`. */
9
+ telegraphSeconds?: number;
10
+ }
11
+ /** One authored phase: the health it starts at, how the boss fights, and its tell. */
12
+ export interface BossPhase {
13
+ id: string;
14
+ /** Health fraction at or below which this phase is in effect, `0..1`. The opening phase is `1`. */
15
+ atHealth: number;
16
+ /** How the boss fights while this phase holds. */
17
+ behaviour: BossBehaviour;
18
+ /** Warning shown when the boss enters this phase. Omit for a silent phase. */
19
+ tell?: BossTell;
20
+ }
21
+ /** Serializable boss state: which phase holds, how long it has held, and the tell still on screen. */
22
+ export interface BossState {
23
+ /** Index into the authored phase list. Monotone: it never goes back down. */
24
+ phase: number;
25
+ /** Id of the current phase, for a HUD read without the definition at hand. */
26
+ phaseId: string;
27
+ /** Seconds the current phase has held. */
28
+ ageSeconds: number;
29
+ /** Tell still showing, or null. */
30
+ tellId: string | null;
31
+ /** Seconds left on the showing tell. */
32
+ tellRemaining: number;
33
+ }
34
+ /** One step of the boss clock. */
35
+ export interface BossStep {
36
+ state: BossState;
37
+ /** The phase in effect after the step. */
38
+ phase: BossPhase;
39
+ /** True on the step the phase changed. */
40
+ changed: boolean;
41
+ /** The tell to fire now, or null. Only set on a change, so a caller never replays a tell. */
42
+ tell: BossTell | null;
43
+ }
44
+ /** The phase a boss fights with at a health fraction. Pure read for a HUD; never mutates. */
45
+ export declare function bossPhase(phases: readonly BossPhase[], health: number): BossPhase;
46
+ /**
47
+ * Fresh boss state for a phase list, opening in phase 0 with no tell showing.
48
+ *
49
+ * @capability boss-phase-state fresh serializable boss phase state that opens in the first phase
50
+ */
51
+ export declare function createBossState(phases: readonly BossPhase[]): BossState;
52
+ /**
53
+ * Advance the boss by `dt` at a health fraction and return the phase in effect.
54
+ *
55
+ * A phase changes when health reaches its threshold, and it changes *behaviour with it*: the phase
56
+ * carries the same role dials squad combat executes, so a phase change is a different fight (range,
57
+ * cover, discipline) and a tell the game shows while the player has time to react. Phase order is
58
+ * monotone hand-written data, so a heal does not rewind the boss into an earlier phase and a caller
59
+ * cannot stack tells by re-entering the same threshold. Deterministic in `dt` and health.
60
+ *
61
+ * @capability boss-phases data-authored boss phases that change behaviour and fire a tell at health thresholds
62
+ */
63
+ export declare function advanceBoss(phases: readonly BossPhase[], state: BossState, health: number, dt: number): BossStep;
@@ -0,0 +1,87 @@
1
+ const DEFAULT_TELL_SECONDS = 1.5;
2
+ function validate(phases) {
3
+ if (phases.length === 0)
4
+ throw new Error("boss needs at least one phase");
5
+ for (let index = 0; index < phases.length; index += 1) {
6
+ const phase = phases[index];
7
+ if (phase.id.length === 0)
8
+ throw new Error(`boss phase ${index} has an empty id`);
9
+ if (!Number.isFinite(phase.atHealth) || phase.atHealth < 0 || phase.atHealth > 1) {
10
+ throw new Error(`boss phase "${phase.id}" atHealth must be between 0 and 1`);
11
+ }
12
+ const previous = phases[index - 1];
13
+ if (previous !== undefined && phase.atHealth > previous.atHealth) {
14
+ throw new Error(`boss phase "${phase.id}" atHealth must not exceed "${previous.id}" — phases run high to low`);
15
+ }
16
+ }
17
+ if (phases[0].atHealth < 1) {
18
+ throw new Error(`boss opening phase "${phases[0].id}" must start at atHealth 1`);
19
+ }
20
+ }
21
+ /** The phase a boss fights with at a health fraction. Pure read for a HUD; never mutates. */
22
+ export function bossPhase(phases, health) {
23
+ validate(phases);
24
+ const clamped = Math.max(0, Math.min(1, Number.isFinite(health) ? health : 1));
25
+ let current = phases[0];
26
+ for (const phase of phases)
27
+ if (clamped <= phase.atHealth)
28
+ current = phase;
29
+ return current;
30
+ }
31
+ /**
32
+ * Fresh boss state for a phase list, opening in phase 0 with no tell showing.
33
+ *
34
+ * @capability boss-phase-state fresh serializable boss phase state that opens in the first phase
35
+ */
36
+ export function createBossState(phases) {
37
+ validate(phases);
38
+ return { phase: 0, phaseId: phases[0].id, ageSeconds: 0, tellId: null, tellRemaining: 0 };
39
+ }
40
+ /**
41
+ * Advance the boss by `dt` at a health fraction and return the phase in effect.
42
+ *
43
+ * A phase changes when health reaches its threshold, and it changes *behaviour with it*: the phase
44
+ * carries the same role dials squad combat executes, so a phase change is a different fight (range,
45
+ * cover, discipline) and a tell the game shows while the player has time to react. Phase order is
46
+ * monotone hand-written data, so a heal does not rewind the boss into an earlier phase and a caller
47
+ * cannot stack tells by re-entering the same threshold. Deterministic in `dt` and health.
48
+ *
49
+ * @capability boss-phases data-authored boss phases that change behaviour and fire a tell at health thresholds
50
+ */
51
+ export function advanceBoss(phases, state, health, dt) {
52
+ validate(phases);
53
+ const clamped = Math.max(0, Math.min(1, Number.isFinite(health) ? health : 1));
54
+ const step = Number.isFinite(dt) && dt > 0 ? dt : 0;
55
+ let phaseIndex = state.phase;
56
+ let changed = false;
57
+ for (let index = phaseIndex + 1; index < phases.length; index += 1) {
58
+ if (clamped <= phases[index].atHealth) {
59
+ phaseIndex = index;
60
+ changed = true;
61
+ }
62
+ }
63
+ const phase = phases[phaseIndex] ?? phases[0];
64
+ let tellId = state.tellId;
65
+ let tellRemaining = Math.max(0, state.tellRemaining - step);
66
+ let tell = null;
67
+ if (changed) {
68
+ tell = phase.tell ?? null;
69
+ tellId = tell?.id ?? null;
70
+ tellRemaining = tell === null ? 0 : Math.max(0, tell.telegraphSeconds ?? DEFAULT_TELL_SECONDS);
71
+ }
72
+ else if (tellRemaining <= 0) {
73
+ tellId = null;
74
+ }
75
+ return {
76
+ state: {
77
+ phase: phaseIndex,
78
+ phaseId: phase.id,
79
+ ageSeconds: changed ? 0 : state.ageSeconds + step,
80
+ tellId,
81
+ tellRemaining,
82
+ },
83
+ phase,
84
+ changed,
85
+ tell,
86
+ };
87
+ }
@@ -0,0 +1,146 @@
1
+ import type { FightIntent } from "./roles.js";
2
+ /**
3
+ * One authored group inside a wave: what arrives, how many, where from, and when.
4
+ * `unit` and `entry` are game-owned ids — the engine never resolves them.
5
+ */
6
+ export interface EncounterGroup {
7
+ /** Unit id the game's catalog knows. */
8
+ unit: string;
9
+ /** Authored spawn-point key, resolved by the game against its scene. */
10
+ entry: string;
11
+ /** How many enter at this open. Default `1`. */
12
+ count?: number;
13
+ /** Seconds into the wave before this group enters. Default `0`. */
14
+ at?: number;
15
+ /** Duty the group takes when it engages. Default the encounter's `defaultIntent`. */
16
+ intent?: FightIntent;
17
+ /** True when pressure may add copies of this group. Default `false`. */
18
+ escalates?: boolean;
19
+ /** True when this group only exists once pressure unlocks an elite slot. Default `false`. */
20
+ elite?: boolean;
21
+ /** Seeded scatter of up to ±this many seconds on `at`. Default `0`. */
22
+ jitterSeconds?: number;
23
+ }
24
+ /** One authored wave. */
25
+ export interface EncounterWave {
26
+ id: string;
27
+ /** HUD or screen label. */
28
+ label?: string;
29
+ groups: readonly EncounterGroup[];
30
+ /** Seconds the wave stays open after it opens. Omit to close when the last order lands. */
31
+ durationSeconds?: number;
32
+ }
33
+ /** A whole encounter, authored as data instead of spawned in game code. */
34
+ export interface EncounterDefinition {
35
+ id: string;
36
+ waves: readonly EncounterWave[];
37
+ }
38
+ /** The subset of a pressure plan the encounter layer applies. */
39
+ export interface EncounterPressure {
40
+ /** Extra copies for `escalates` groups. */
41
+ countBonus?: number;
42
+ /** Elite slots to unlock. A group marked `elite` enters only when a slot is free. */
43
+ eliteSlots?: number;
44
+ /** Multiplier on authored entry delays (`< 1` sends them sooner). */
45
+ cadenceScale?: number;
46
+ }
47
+ /** One unit the director says should enter now. */
48
+ export interface EncounterOrder {
49
+ /** 0-based wave index. */
50
+ wave: number;
51
+ waveId: string;
52
+ unit: string;
53
+ entry: string;
54
+ intent: FightIntent;
55
+ /** Seconds after the wave opened when this unit enters. */
56
+ atSeconds: number;
57
+ /** Which authored group this order came from, for logging and tests. */
58
+ group: number;
59
+ /** True when pressure added this unit rather than the authored composition. */
60
+ pressureAdded: boolean;
61
+ }
62
+ /** Serializable encounter state. */
63
+ export interface EncounterState {
64
+ /** 0-based index of the open wave, or of the next one before it opens. */
65
+ wave: number;
66
+ opened: boolean;
67
+ /** Seconds the open wave has run. */
68
+ elapsed: number;
69
+ /** Orders already emitted for the open wave. */
70
+ emitted: number;
71
+ completedWaves: number;
72
+ done: boolean;
73
+ /** Pressure captured when the open wave opened, so re-deriving the schedule is stable. */
74
+ countBonus: number;
75
+ eliteSlots: number;
76
+ cadenceScale: number;
77
+ }
78
+ /** Pooled per-frame readout of where the encounter stands. */
79
+ export interface EncounterView {
80
+ /** 1-based wave for display. */
81
+ wave: number;
82
+ totalWaves: number;
83
+ waveId: string | null;
84
+ label: string | null;
85
+ /** Orders in the open wave, authored plus pressure-added. */
86
+ total: number;
87
+ emitted: number;
88
+ /** `0..1` through the open wave. */
89
+ progress: number;
90
+ /** True once the open wave has landed every order and its duration has run. */
91
+ ready: boolean;
92
+ done: boolean;
93
+ }
94
+ /** Live encounter: opens waves, emits timed spawn orders, and round-trips through a save. */
95
+ export interface Encounter {
96
+ /** Open a wave (next one by default) and return its full ordered schedule. */
97
+ openWave(index?: number, pressure?: EncounterPressure): EncounterOrder[];
98
+ /** Advance the open wave and return the orders whose time has come since the last step. */
99
+ update(dt: number): EncounterOrder[];
100
+ /** Mark the open wave cleared and make the next one current. */
101
+ completeWave(): void;
102
+ /** The wave definition at an index, or null past the end. */
103
+ waveAt(index: number): EncounterWave | null;
104
+ /** The pooled readout to render. */
105
+ view(): EncounterView;
106
+ snapshot(): EncounterState;
107
+ restore(state: EncounterState): void;
108
+ }
109
+ /** Construction options for {@link createEncounter}. */
110
+ export interface EncounterConfig {
111
+ definition: EncounterDefinition;
112
+ /** Seed for the per-wave entry scatter. Default `1`. */
113
+ seed?: number;
114
+ /** Duty for groups that omit `intent`. Default `"assault"`. */
115
+ defaultIntent?: FightIntent;
116
+ /** Most pressure may add to one escalating group, whatever the plan says. Default `4`. */
117
+ maxExtraPerGroup?: number;
118
+ }
119
+ /**
120
+ * Expand one authored wave into the units it sends, at the times it sends them.
121
+ *
122
+ * Authoring stays data: composition (`unit`, `count`), entry (`entry`), timing (`at`, `jitterSeconds`,
123
+ * `durationSeconds`) and escalation (`escalates`, `elite`) all live in the
124
+ * definition. Pressure only *adds*: extra copies for `escalates` groups (capped at
125
+ * `maxExtraPerGroup`) and elite slots, and it shortens delays through `cadenceScale` — it never
126
+ * rewrites an authored unit or entry. Same definition, seed and pressure always produce the same
127
+ * schedule.
128
+ *
129
+ * @capability encounter-expand turn an authored wave into a deterministic, pressure-adjusted list of timed spawn orders
130
+ */
131
+ export declare function expandWave(config: EncounterConfig, waveIndex: number, pressure?: EncounterPressure): EncounterOrder[];
132
+ /**
133
+ * Create a live encounter over an authored definition: open a wave, step its clock, take the orders
134
+ * whose time has come, and close the wave when the game says it is cleared.
135
+ *
136
+ * This is the composition layer, not a second spawn system: every order names a game-owned `unit`
137
+ * and `entry`, exactly like a `SpawnRequest` names an `entryId`, and the game builds the entity.
138
+ * Pair it with `combat/encounterSequence` when a fight needs scripted phase gates, and with
139
+ * `ai/waveRunner` when a wave should be drawn from a budget instead of authored.
140
+ *
141
+ * Pressure is captured when a wave opens, so a wave's composition cannot change mid-arrival; read
142
+ * `evaluatePressure` when the wave opens and pass the plan in.
143
+ *
144
+ * @capability encounter-runner open authored waves, emit their timed orders, and track wave progress through a save
145
+ */
146
+ export declare function createEncounter(config: EncounterConfig): Encounter;