@jgengine/core 0.26.0 → 0.28.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 +174 -0
- package/dist/ai/policeResponse.d.ts +253 -0
- package/dist/ai/policeResponse.js +421 -0
- package/dist/anim/actionLayer.d.ts +47 -0
- package/dist/anim/actionLayer.js +63 -0
- package/dist/anim/ikSolver.d.ts +14 -0
- package/dist/anim/ikSolver.js +37 -0
- package/dist/anim/locomotionGraph.d.ts +58 -3
- package/dist/anim/locomotionGraph.js +142 -15
- package/dist/anim/ragdollRig.d.ts +53 -0
- package/dist/anim/ragdollRig.js +132 -0
- package/dist/anim/skeletonStandard.d.ts +140 -0
- package/dist/anim/skeletonStandard.js +326 -0
- package/dist/combat/damageRuntime.d.ts +14 -1
- package/dist/combat/damageRuntime.js +62 -4
- package/dist/combat/death.js +7 -3
- package/dist/combat/deathReason.d.ts +7 -1
- package/dist/combat/effects.d.ts +25 -0
- package/dist/combat/effects.js +16 -0
- package/dist/combat/statusRuntime.d.ts +2 -0
- package/dist/combat/statusRuntime.js +1 -0
- package/dist/economy/loanBook.d.ts +182 -0
- package/dist/economy/loanBook.js +303 -0
- package/dist/economy/market.d.ts +161 -0
- package/dist/economy/market.js +248 -0
- package/dist/economy/transactionLedger.d.ts +162 -0
- package/dist/economy/transactionLedger.js +301 -0
- package/dist/editor/creatorStorage.js +3 -1
- package/dist/editor/simulation.d.ts +5 -2
- package/dist/editor/simulation.js +29 -19
- package/dist/game/cameraConfig.d.ts +14 -0
- package/dist/game/clipRoles.d.ts +5 -4
- package/dist/game/clipRoles.js +34 -4
- package/dist/game/events.d.ts +27 -0
- package/dist/game/gameSave.d.ts +142 -0
- package/dist/game/gameSave.js +174 -0
- package/dist/game/playableGame.d.ts +74 -6
- package/dist/game/saveSlotIndex.d.ts +54 -0
- package/dist/game/saveSlotIndex.js +71 -0
- package/dist/game/saveSlots.d.ts +2 -0
- package/dist/game/saveSlots.js +1 -1
- package/dist/game/saveStore.d.ts +8 -0
- package/dist/game/saveStore.js +16 -0
- package/dist/gameplay.d.ts +17 -2
- package/dist/gameplay.js +15 -0
- package/dist/inventory/carryLoad.d.ts +48 -0
- package/dist/inventory/carryLoad.js +44 -0
- package/dist/inventory/equipment.d.ts +95 -0
- package/dist/inventory/equipment.js +169 -0
- package/dist/inventory/inventoryModel.d.ts +15 -6
- package/dist/inventory/inventoryModel.js +54 -5
- package/dist/inventory/shapedGrid.d.ts +26 -0
- package/dist/inventory/shapedGrid.js +47 -0
- package/dist/loot/lootGen.d.ts +186 -0
- package/dist/loot/lootGen.js +437 -0
- package/dist/loot/lootWeapon.d.ts +35 -0
- package/dist/loot/lootWeapon.js +70 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +156 -1
- package/dist/mission/compositions.d.ts +85 -0
- package/dist/mission/compositions.js +130 -0
- package/dist/mission/mission.d.ts +435 -0
- package/dist/mission/mission.js +770 -0
- package/dist/mission/triggers.d.ts +61 -0
- package/dist/mission/triggers.js +108 -0
- package/dist/nav/laneGraph.d.ts +178 -0
- package/dist/nav/laneGraph.js +671 -0
- package/dist/nav/traffic.d.ts +222 -0
- package/dist/nav/traffic.js +918 -0
- package/dist/physics/kinematicVehicle.d.ts +36 -0
- package/dist/physics/kinematicVehicle.js +15 -1
- package/dist/physics/vehicleDamage.d.ts +136 -0
- package/dist/physics/vehicleDamage.js +214 -0
- package/dist/physics/vehicleParts.d.ts +62 -0
- package/dist/physics/vehicleParts.js +65 -0
- package/dist/progression/gearScore.d.ts +56 -0
- package/dist/progression/gearScore.js +59 -0
- package/dist/progression/skillTree.d.ts +129 -0
- package/dist/progression/skillTree.js +265 -0
- package/dist/progression/xpCurve.d.ts +60 -0
- package/dist/progression/xpCurve.js +141 -0
- package/dist/runtime/context/combat.js +11 -0
- package/dist/scene/builtinSceneKinds.js +2 -0
- package/dist/settings/accessibilitySettings.d.ts +81 -0
- package/dist/settings/accessibilitySettings.js +152 -0
- package/dist/settings/graphicsProfile.d.ts +3 -0
- package/dist/settings/graphicsProfile.js +4 -0
- package/dist/settings/settingsModel.d.ts +1 -1
- package/dist/settings/settingsModel.js +1 -0
- package/dist/sim/nearAgents.d.ts +8 -1
- package/dist/sim/nearAgents.js +34 -17
- package/dist/sim/offScreen.d.ts +2 -1
- package/dist/sim/offScreen.js +14 -3
- package/dist/sim/population.d.ts +147 -0
- package/dist/sim/population.js +182 -0
- package/dist/sim/queue.d.ts +90 -0
- package/dist/sim/queue.js +171 -0
- package/dist/sim/rides.d.ts +287 -0
- package/dist/sim/rides.js +471 -0
- package/dist/sim/routine.js +19 -11
- package/dist/ui/loadingTips.d.ts +41 -0
- package/dist/ui/loadingTips.js +64 -0
- package/dist/ui/menuNav.d.ts +79 -0
- package/dist/ui/menuNav.js +147 -0
- package/dist/ui/menuTransition.d.ts +50 -0
- package/dist/ui/menuTransition.js +67 -0
- package/dist/ui.d.ts +2 -0
- package/dist/ui.js +2 -0
- package/dist/vfx/effectLibrary.d.ts +98 -0
- package/dist/vfx/effectLibrary.js +181 -0
- package/dist/vfx/particleBudget.d.ts +40 -0
- package/dist/vfx/particleBudget.js +39 -0
- package/dist/vfx/particleDirector.d.ts +16 -0
- package/dist/vfx/particleDirector.js +18 -0
- package/dist/world/authoredSimulation.js +7 -1
- package/dist/world/captureCamera.d.ts +30 -0
- package/dist/world/captureCamera.js +19 -0
- package/dist/world/decalKind.d.ts +48 -0
- package/dist/world/decalKind.js +86 -0
- package/dist/world/features.d.ts +18 -5
- package/dist/world/features.js +1 -0
- package/dist/world/grassKind.d.ts +5 -0
- package/dist/world/grassKind.js +3 -0
- package/dist/world/terraform.d.ts +8 -0
- package/dist/world/terrain.d.ts +1 -0
- package/dist/world/terrain.js +2 -0
- package/dist/world.d.ts +7 -2
- package/dist/world.js +5 -0
- package/package.json +1 -1
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-combat/SKILL.md +9 -1
- package/skills/jgengine-combat/api.md +5 -4
- package/skills/jgengine-combat/reference.md +4 -0
- package/skills/jgengine-gameplay/SKILL.md +86 -0
- package/skills/jgengine-gameplay/api.md +485 -16
- package/skills/jgengine-gameplay/capabilities.md +131 -4
- package/skills/jgengine-gameplay/reference-systems.md +118 -0
- package/skills/jgengine-world/SKILL.md +8 -0
- package/skills/jgengine-world/api.md +243 -11
- package/skills/jgengine-world/capabilities.md +61 -1
- package/skills/jgengine-world/recipes/character-animation.md +25 -2
- package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
- package/skills/jgengine-world/reference.md +1 -1
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Missions as data: a mission is a graph of objectives a state machine walks, with named fail
|
|
3
|
+
* states, per-objective timers, conditional branches and rewards. Nothing here is a genre kit —
|
|
4
|
+
* escort, chase and defend are compositions of the same objective kinds, built in `compositions.ts`.
|
|
5
|
+
*
|
|
6
|
+
* The machine is pure, serializable and bounded: one active mission, one objective's worth of
|
|
7
|
+
* work per tick, and every fact it reads is supplied by the caller.
|
|
8
|
+
*/
|
|
9
|
+
import { devtools } from "../devtools/devtools.js";
|
|
10
|
+
import { applyQuestRewards } from "../game/quest.js";
|
|
11
|
+
import { clamp } from "../math/scalar.js";
|
|
12
|
+
import { resolveRng } from "../random/resolveRng.js";
|
|
13
|
+
/** Objective kinds that are satisfied only by a counter — a mission made of these alone is a lint error. */
|
|
14
|
+
export const MISSION_COUNTER_KINDS = ["kill", "collect"];
|
|
15
|
+
/**
|
|
16
|
+
* True when an objective kind completes from a counter rather than a world condition — the test the
|
|
17
|
+
* counter-only lint is built on.
|
|
18
|
+
*
|
|
19
|
+
* @capability mission-validation tell whether an objective kind completes from a counter alone
|
|
20
|
+
*/
|
|
21
|
+
export function isCounterObjective(kind) {
|
|
22
|
+
return kind === "kill" || kind === "collect";
|
|
23
|
+
}
|
|
24
|
+
/** Default proximity radius (m) for a `reach` / `interact` objective. */
|
|
25
|
+
export const DEFAULT_REACH_RADIUS = 2.5;
|
|
26
|
+
/** Default keep-close distance (m) for a `pursue` objective. */
|
|
27
|
+
export const DEFAULT_PURSUE_DISTANCE = 3;
|
|
28
|
+
/** A pursuit escapes at this multiple of its keep-close distance when none is declared. */
|
|
29
|
+
export const DEFAULT_ESCAPE_MULTIPLE = 3;
|
|
30
|
+
function distance(a, b) {
|
|
31
|
+
return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
|
|
32
|
+
}
|
|
33
|
+
function cloneState(state) {
|
|
34
|
+
return {
|
|
35
|
+
...state,
|
|
36
|
+
path: [...state.path],
|
|
37
|
+
progress: { ...state.progress },
|
|
38
|
+
facts: { ...state.facts },
|
|
39
|
+
timers: { ...state.timers },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function counterKey(objective) {
|
|
43
|
+
return objective.counter ?? objective.id;
|
|
44
|
+
}
|
|
45
|
+
function eventMatches(filter, event) {
|
|
46
|
+
if (filter.kind !== undefined && filter.kind !== event.kind)
|
|
47
|
+
return false;
|
|
48
|
+
if (filter.target !== undefined && filter.target !== event.target)
|
|
49
|
+
return false;
|
|
50
|
+
if (filter.by !== undefined && filter.by !== event.by)
|
|
51
|
+
return false;
|
|
52
|
+
const tags = event.tags ?? [];
|
|
53
|
+
for (const required of filter.tags ?? [])
|
|
54
|
+
if (!tags.includes(required))
|
|
55
|
+
return false;
|
|
56
|
+
for (const banned of filter.withoutTags ?? [])
|
|
57
|
+
if (tags.includes(banned))
|
|
58
|
+
return false;
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
function hasFailState(def, id) {
|
|
62
|
+
return def.fail?.some((entry) => entry.id === id) ?? false;
|
|
63
|
+
}
|
|
64
|
+
function objectiveLabel(objective) {
|
|
65
|
+
if (objective.label !== undefined && objective.label !== "")
|
|
66
|
+
return objective.label;
|
|
67
|
+
switch (objective.kind) {
|
|
68
|
+
case "reach":
|
|
69
|
+
return `Reach ${objective.marker ?? objective.actor ?? objective.id}`;
|
|
70
|
+
case "interact":
|
|
71
|
+
return `Interact at ${objective.marker ?? objective.id}`;
|
|
72
|
+
case "survive":
|
|
73
|
+
return `Hold for ${objective.duration ?? 0}s`;
|
|
74
|
+
case "pursue":
|
|
75
|
+
return `Stay on ${objective.actor ?? objective.id}`;
|
|
76
|
+
case "kill":
|
|
77
|
+
return `Defeat ${objective.count ?? 1} ${counterKey(objective)}`;
|
|
78
|
+
case "collect":
|
|
79
|
+
return `Collect ${objective.count ?? 1} ${counterKey(objective)}`;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function defaultFailStateId(objective, def) {
|
|
83
|
+
const named = objective.minSurvivorsFail ?? objective.failOn?.[0]?.to;
|
|
84
|
+
if (named !== undefined && def.fail?.some((state) => state.id === named))
|
|
85
|
+
return named;
|
|
86
|
+
return def.fail?.[0]?.id ?? "failed";
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Apply a mission's rewards through the same appliers quests use, then roll any named loot tables.
|
|
90
|
+
* Economy, XP, items and unlocks go through {@link applyQuestRewards}; loot is a table id the game's
|
|
91
|
+
* loot registry resolves, so a mission never grows its own reward path.
|
|
92
|
+
*
|
|
93
|
+
* @capability mission-rewards pay mission rewards through the quest appliers and the loot registry
|
|
94
|
+
*/
|
|
95
|
+
export function applyMissionRewards(rewards, appliers) {
|
|
96
|
+
const fail = applyQuestRewards(rewards, appliers);
|
|
97
|
+
if (fail !== null)
|
|
98
|
+
return fail;
|
|
99
|
+
for (const table of rewards.loot ?? [])
|
|
100
|
+
appliers.grantLoot?.(table);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Run missions as a state machine. One mission is active at a time; `tick` advances it against the
|
|
105
|
+
* world the caller supplies, moving through objectives on completion, branching on facts, and landing
|
|
106
|
+
* in a named fail state on a fail condition, an expiry or a `minSurvivors` breach. Completion pays
|
|
107
|
+
* rewards through {@link applyMissionRewards} appliers the caller wires, and a mission's `next` chains
|
|
108
|
+
* the follow-up. Escort, chase and defend are compositions of the same objective kinds
|
|
109
|
+
* (`compositions.ts`), never three bespoke systems.
|
|
110
|
+
*
|
|
111
|
+
* Cost per tick is bounded by the active objective's own fail list plus one goal test.
|
|
112
|
+
*
|
|
113
|
+
* @capability mission-runtime missions as a data-driven objective state machine with fail states, timers, branches and rewards
|
|
114
|
+
*/
|
|
115
|
+
export function createMissionRuntime(config) {
|
|
116
|
+
const defs = new Map();
|
|
117
|
+
for (const def of config.missions)
|
|
118
|
+
defs.set(def.id, def);
|
|
119
|
+
const markers = config.markers ?? {};
|
|
120
|
+
let rng = config.rng;
|
|
121
|
+
let state = null;
|
|
122
|
+
let ticks = 0;
|
|
123
|
+
let started = 0;
|
|
124
|
+
let completed = 0;
|
|
125
|
+
let failed = 0;
|
|
126
|
+
let branches = 0;
|
|
127
|
+
let timeouts = 0;
|
|
128
|
+
let slowestTickMs = 0;
|
|
129
|
+
const listeners = new Set();
|
|
130
|
+
const readout = {
|
|
131
|
+
missionId: "",
|
|
132
|
+
title: "",
|
|
133
|
+
status: "active",
|
|
134
|
+
objectiveId: null,
|
|
135
|
+
label: "",
|
|
136
|
+
objectiveElapsed: 0,
|
|
137
|
+
remaining: null,
|
|
138
|
+
progress: 0,
|
|
139
|
+
counted: 0,
|
|
140
|
+
count: 0,
|
|
141
|
+
step: 0,
|
|
142
|
+
steps: 0,
|
|
143
|
+
score: 0,
|
|
144
|
+
outcome: null,
|
|
145
|
+
reason: null,
|
|
146
|
+
path: [],
|
|
147
|
+
};
|
|
148
|
+
function rollRng() {
|
|
149
|
+
if (rng === undefined)
|
|
150
|
+
rng = resolveRng(undefined, "createMissionRuntime");
|
|
151
|
+
return rng();
|
|
152
|
+
}
|
|
153
|
+
function notify() {
|
|
154
|
+
for (const listener of listeners)
|
|
155
|
+
listener();
|
|
156
|
+
}
|
|
157
|
+
function activeDef() {
|
|
158
|
+
if (state === null)
|
|
159
|
+
return null;
|
|
160
|
+
return defs.get(state.missionId) ?? null;
|
|
161
|
+
}
|
|
162
|
+
function objectiveOf(def, id) {
|
|
163
|
+
if (id === null)
|
|
164
|
+
return null;
|
|
165
|
+
return def.objectives.find((objective) => objective.id === id) ?? null;
|
|
166
|
+
}
|
|
167
|
+
function factValue(name, input) {
|
|
168
|
+
if (name === "score")
|
|
169
|
+
return state?.score ?? 0;
|
|
170
|
+
const fromInput = input.facts?.[name];
|
|
171
|
+
if (fromInput !== undefined)
|
|
172
|
+
return fromInput;
|
|
173
|
+
return state?.facts[name];
|
|
174
|
+
}
|
|
175
|
+
function matches(condition, input) {
|
|
176
|
+
const value = factValue(condition.fact, input);
|
|
177
|
+
let ok = true;
|
|
178
|
+
if (condition.is !== undefined)
|
|
179
|
+
ok = ok && value === condition.is;
|
|
180
|
+
if (condition.atLeast !== undefined) {
|
|
181
|
+
ok = ok && typeof value === "number" && value >= condition.atLeast;
|
|
182
|
+
}
|
|
183
|
+
if (condition.atMost !== undefined) {
|
|
184
|
+
ok = ok && typeof value === "number" && value <= condition.atMost;
|
|
185
|
+
}
|
|
186
|
+
if (condition.is === undefined && condition.atLeast === undefined && condition.atMost === undefined) {
|
|
187
|
+
ok = value !== undefined;
|
|
188
|
+
}
|
|
189
|
+
return condition.not === true ? !ok : ok;
|
|
190
|
+
}
|
|
191
|
+
function conditionHolds(condition, input) {
|
|
192
|
+
return Array.isArray(condition)
|
|
193
|
+
? condition.every((entry) => matches(entry, input))
|
|
194
|
+
: matches(condition, input);
|
|
195
|
+
}
|
|
196
|
+
function survivorsOf(input) {
|
|
197
|
+
if (input.survivors !== undefined)
|
|
198
|
+
return input.survivors;
|
|
199
|
+
let alive = 0;
|
|
200
|
+
for (const actor of Object.values(input.actors ?? {}))
|
|
201
|
+
if (actor.alive !== false)
|
|
202
|
+
alive += 1;
|
|
203
|
+
return alive;
|
|
204
|
+
}
|
|
205
|
+
function positionOf(actorId, input) {
|
|
206
|
+
if (actorId === undefined)
|
|
207
|
+
return undefined;
|
|
208
|
+
return input.actors?.[actorId]?.position;
|
|
209
|
+
}
|
|
210
|
+
function pursuitDistance(objective, input) {
|
|
211
|
+
const pursuer = objective.pursuer ?? config.defaultPursuer;
|
|
212
|
+
const from = positionOf(pursuer, input);
|
|
213
|
+
const to = positionOf(objective.actor, input);
|
|
214
|
+
if (from === undefined || to === undefined)
|
|
215
|
+
return undefined;
|
|
216
|
+
return distance(from, to);
|
|
217
|
+
}
|
|
218
|
+
function goalMet(objective, input) {
|
|
219
|
+
switch (objective.kind) {
|
|
220
|
+
case "reach": {
|
|
221
|
+
const from = positionOf(objective.actor, input);
|
|
222
|
+
const to = objective.marker === undefined ? undefined : markers[objective.marker];
|
|
223
|
+
if (from === undefined || to === undefined)
|
|
224
|
+
return false;
|
|
225
|
+
return distance(from, to) <= (objective.radius ?? DEFAULT_REACH_RADIUS);
|
|
226
|
+
}
|
|
227
|
+
case "interact": {
|
|
228
|
+
if (objective.actor === undefined || !(input.interact ?? []).includes(objective.actor))
|
|
229
|
+
return false;
|
|
230
|
+
const to = objective.marker === undefined ? undefined : markers[objective.marker];
|
|
231
|
+
if (to === undefined)
|
|
232
|
+
return true;
|
|
233
|
+
const from = positionOf(objective.actor, input);
|
|
234
|
+
return from !== undefined && distance(from, to) <= (objective.radius ?? DEFAULT_REACH_RADIUS);
|
|
235
|
+
}
|
|
236
|
+
case "survive":
|
|
237
|
+
return state.objectiveElapsed >= (objective.duration ?? 0);
|
|
238
|
+
case "pursue": {
|
|
239
|
+
const d = pursuitDistance(objective, input);
|
|
240
|
+
return d !== undefined && d <= (objective.distance ?? DEFAULT_PURSUE_DISTANCE);
|
|
241
|
+
}
|
|
242
|
+
case "kill":
|
|
243
|
+
case "collect": {
|
|
244
|
+
const key = counterKey(objective);
|
|
245
|
+
const counted = objective.match === undefined
|
|
246
|
+
? (input.counters?.[key] ?? 0)
|
|
247
|
+
: (state.progress[key] ?? 0);
|
|
248
|
+
return counted >= (objective.count ?? 1);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Record this tick's progress for a counter objective: matching world events for a `match` objective,
|
|
254
|
+
* or the game's cumulative counter otherwise. Mirrored into `state.progress` so `view()` and a save
|
|
255
|
+
* both see it.
|
|
256
|
+
*/
|
|
257
|
+
function accumulateEvents(objective, input) {
|
|
258
|
+
if (!isCounterObjective(objective.kind))
|
|
259
|
+
return;
|
|
260
|
+
const key = counterKey(objective);
|
|
261
|
+
const filter = objective.match;
|
|
262
|
+
if (filter === undefined) {
|
|
263
|
+
const external = input.counters?.[key];
|
|
264
|
+
if (external !== undefined)
|
|
265
|
+
state.progress[key] = external;
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (input.events === undefined)
|
|
269
|
+
return;
|
|
270
|
+
const kind = filter.kind ?? objective.kind;
|
|
271
|
+
for (const event of input.events) {
|
|
272
|
+
if (event.kind !== kind || !eventMatches(filter, event))
|
|
273
|
+
continue;
|
|
274
|
+
state.progress[key] = (state.progress[key] ?? 0) + (event.count ?? 1);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function eventFails(match, input) {
|
|
278
|
+
for (const event of input.events ?? []) {
|
|
279
|
+
if (match === undefined || eventMatches(match, event))
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
function failOf(objective, input, dt) {
|
|
285
|
+
const current = state;
|
|
286
|
+
for (const condition of objective.failOn ?? []) {
|
|
287
|
+
if (condition.kind === "actor-died") {
|
|
288
|
+
if (condition.actor !== undefined && input.actors?.[condition.actor]?.alive === false) {
|
|
289
|
+
return { state: condition.to ?? "failed", reason: condition.message ?? `${condition.actor} died` };
|
|
290
|
+
}
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (condition.kind === "timer") {
|
|
294
|
+
if (current.objectiveElapsed >= (condition.seconds ?? 0)) {
|
|
295
|
+
return { state: condition.to ?? "failed", reason: condition.message ?? "timer expired" };
|
|
296
|
+
}
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (condition.kind === "event") {
|
|
300
|
+
if (eventFails(condition.match, input)) {
|
|
301
|
+
return { state: condition.to ?? "failed", reason: condition.message ?? "wrong event" };
|
|
302
|
+
}
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (condition.kind === "left-area") {
|
|
306
|
+
const actor = condition.actor ?? objective.actor;
|
|
307
|
+
const from = positionOf(actor, input);
|
|
308
|
+
const to = condition.marker === undefined ? undefined : markers[condition.marker];
|
|
309
|
+
if (from !== undefined && to !== undefined && distance(from, to) > (condition.radius ?? DEFAULT_REACH_RADIUS)) {
|
|
310
|
+
return { state: condition.to ?? "failed", reason: condition.message ?? `${actor ?? "actor"} left the area` };
|
|
311
|
+
}
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (matches({ fact: condition.fact ?? "", is: condition.is, atLeast: condition.atLeast, atMost: condition.atMost }, input)) {
|
|
315
|
+
return { state: condition.to ?? "failed", reason: condition.message ?? `fact ${condition.fact}` };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (objective.kind === "pursue") {
|
|
319
|
+
const d = pursuitDistance(objective, input);
|
|
320
|
+
const keep = objective.distance ?? DEFAULT_PURSUE_DISTANCE;
|
|
321
|
+
const escape = objective.escapeDistance ?? keep * DEFAULT_ESCAPE_MULTIPLE;
|
|
322
|
+
if (d !== undefined && d > escape) {
|
|
323
|
+
const held = (current.timers[objective.id] ?? 0) + dt;
|
|
324
|
+
current.timers[objective.id] = held;
|
|
325
|
+
if (held >= (objective.escapeGrace ?? 0)) {
|
|
326
|
+
return { state: objective.escapeFail ?? "failed", reason: `${objective.actor ?? "target"} escaped` };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
else if (current.timers[objective.id] !== undefined) {
|
|
330
|
+
current.timers[objective.id] = 0;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (objective.minSurvivors !== undefined && survivorsOf(input) < objective.minSurvivors) {
|
|
334
|
+
return {
|
|
335
|
+
state: defaultFailStateId(objective, activeDef()),
|
|
336
|
+
reason: `survivors below ${objective.minSurvivors}`,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
function objectiveReward(def, objectiveId) {
|
|
342
|
+
return def.objectiveRewards?.[objectiveId];
|
|
343
|
+
}
|
|
344
|
+
function enterObjective(def, objectiveId) {
|
|
345
|
+
const objective = objectiveOf(def, objectiveId);
|
|
346
|
+
if (objective === null)
|
|
347
|
+
return [];
|
|
348
|
+
state.objective = objectiveId;
|
|
349
|
+
state.objectiveElapsed = 0;
|
|
350
|
+
state.timers = {};
|
|
351
|
+
state.path.push(objectiveId);
|
|
352
|
+
return [{ kind: "objective", missionId: def.id, objectiveId, label: objectiveLabel(objective) }];
|
|
353
|
+
}
|
|
354
|
+
function startMission(missionId, facts) {
|
|
355
|
+
const def = defs.get(missionId);
|
|
356
|
+
if (def === undefined)
|
|
357
|
+
return [];
|
|
358
|
+
const first = def.start ?? def.objectives[0]?.id;
|
|
359
|
+
if (first === undefined)
|
|
360
|
+
return [];
|
|
361
|
+
state = {
|
|
362
|
+
missionId,
|
|
363
|
+
status: "active",
|
|
364
|
+
objective: null,
|
|
365
|
+
path: [],
|
|
366
|
+
objectiveElapsed: 0,
|
|
367
|
+
elapsed: 0,
|
|
368
|
+
progress: {},
|
|
369
|
+
facts: { ...facts },
|
|
370
|
+
timers: {},
|
|
371
|
+
score: 0,
|
|
372
|
+
outcome: null,
|
|
373
|
+
reason: null,
|
|
374
|
+
};
|
|
375
|
+
started += 1;
|
|
376
|
+
if (def.rollout !== undefined && def.rollout.options.length > 0) {
|
|
377
|
+
const index = clamp(Math.floor(rollRng() * def.rollout.options.length), 0, def.rollout.options.length - 1);
|
|
378
|
+
state.facts[def.rollout.fact] = def.rollout.options[index];
|
|
379
|
+
}
|
|
380
|
+
const events = [{ kind: "started", missionId, mission: def }];
|
|
381
|
+
events.push(...enterObjective(def, first));
|
|
382
|
+
return events;
|
|
383
|
+
}
|
|
384
|
+
function finish(def) {
|
|
385
|
+
const current = state;
|
|
386
|
+
current.status = "completed";
|
|
387
|
+
current.objective = null;
|
|
388
|
+
current.outcome = "done";
|
|
389
|
+
completed += 1;
|
|
390
|
+
const events = [
|
|
391
|
+
{ kind: "completed", missionId: def.id, score: current.score, rewards: def.rewards ?? null },
|
|
392
|
+
];
|
|
393
|
+
if (def.rewards !== undefined) {
|
|
394
|
+
events.push({ kind: "reward", missionId: def.id, objectiveId: null, rewards: def.rewards });
|
|
395
|
+
}
|
|
396
|
+
if (def.next !== undefined && defs.has(def.next)) {
|
|
397
|
+
events.push({ kind: "chained", missionId: def.id, nextMissionId: def.next });
|
|
398
|
+
events.push(...startMission(def.next, undefined));
|
|
399
|
+
}
|
|
400
|
+
return events;
|
|
401
|
+
}
|
|
402
|
+
function failMission(def, failState, reason) {
|
|
403
|
+
const current = state;
|
|
404
|
+
const named = def.fail?.find((entry) => entry.id === failState);
|
|
405
|
+
const message = named?.message ?? reason;
|
|
406
|
+
current.status = "failed";
|
|
407
|
+
current.objective = null;
|
|
408
|
+
current.outcome = failState;
|
|
409
|
+
current.reason = message;
|
|
410
|
+
failed += 1;
|
|
411
|
+
return [{
|
|
412
|
+
kind: "failed",
|
|
413
|
+
missionId: def.id,
|
|
414
|
+
state: failState,
|
|
415
|
+
reason: message,
|
|
416
|
+
}];
|
|
417
|
+
}
|
|
418
|
+
function route(def, from, to, label) {
|
|
419
|
+
if (to === "done")
|
|
420
|
+
return finish(def);
|
|
421
|
+
if (hasFailState(def, to))
|
|
422
|
+
return failMission(def, to, `branch to ${to}`);
|
|
423
|
+
const events = [];
|
|
424
|
+
if (label !== undefined) {
|
|
425
|
+
branches += 1;
|
|
426
|
+
events.push({ kind: "branch", missionId: def.id, from: from.id, to, label });
|
|
427
|
+
}
|
|
428
|
+
const entered = enterObjective(def, to);
|
|
429
|
+
if (entered.length === 0)
|
|
430
|
+
return failMission(def, "failed", `unknown objective ${to}`);
|
|
431
|
+
events.push(...entered);
|
|
432
|
+
return events;
|
|
433
|
+
}
|
|
434
|
+
function completeObjective(def, objective, input) {
|
|
435
|
+
const current = state;
|
|
436
|
+
if (objective.kind === "survive")
|
|
437
|
+
current.score += survivorsOf(input);
|
|
438
|
+
const events = [];
|
|
439
|
+
const reward = objectiveReward(def, objective.id);
|
|
440
|
+
if (reward !== undefined) {
|
|
441
|
+
events.push({ kind: "reward", missionId: def.id, objectiveId: objective.id, rewards: reward });
|
|
442
|
+
}
|
|
443
|
+
for (const branch of objective.branches ?? []) {
|
|
444
|
+
if (conditionHolds(branch.when, input)) {
|
|
445
|
+
events.push(...route(def, objective, branch.to, branch.label ?? `branch ${branch.to}`));
|
|
446
|
+
return events;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
events.push(...route(def, objective, objective.next ?? "done"));
|
|
450
|
+
return events;
|
|
451
|
+
}
|
|
452
|
+
function advance(dt, input) {
|
|
453
|
+
if (state === null || state.status !== "active")
|
|
454
|
+
return [];
|
|
455
|
+
const def = activeDef();
|
|
456
|
+
if (def === null)
|
|
457
|
+
return [];
|
|
458
|
+
state.elapsed += dt;
|
|
459
|
+
state.objectiveElapsed += dt;
|
|
460
|
+
const objective = objectiveOf(def, state.objective);
|
|
461
|
+
if (objective === null)
|
|
462
|
+
return [];
|
|
463
|
+
accumulateEvents(objective, input);
|
|
464
|
+
const fail = failOf(objective, input, dt);
|
|
465
|
+
if (fail !== null)
|
|
466
|
+
return failMission(def, fail.state, fail.reason);
|
|
467
|
+
if (objective.timeout !== undefined && state.objectiveElapsed >= objective.timeout) {
|
|
468
|
+
timeouts += 1;
|
|
469
|
+
const to = objective.onTimeout ?? "failed";
|
|
470
|
+
const events = [{ kind: "timeout", missionId: def.id, objectiveId: objective.id, to }];
|
|
471
|
+
if (to === "done")
|
|
472
|
+
return [...events, ...finish(def)];
|
|
473
|
+
if (hasFailState(def, to))
|
|
474
|
+
return [...events, ...failMission(def, to, `timeout on ${objective.id}`)];
|
|
475
|
+
events.push(...enterObjective(def, to));
|
|
476
|
+
return events;
|
|
477
|
+
}
|
|
478
|
+
if (!goalMet(objective, input))
|
|
479
|
+
return [];
|
|
480
|
+
return completeObjective(def, objective, input);
|
|
481
|
+
}
|
|
482
|
+
if (config.skipValidation !== true) {
|
|
483
|
+
const issues = validateMissionCatalog(config.missions, { markerIds: Object.keys(markers) });
|
|
484
|
+
const blocking = issues.filter((issue) => issue.severity === "error");
|
|
485
|
+
if (blocking.length > 0) {
|
|
486
|
+
throw new Error(`mission catalog invalid:\n${blocking.map((issue) => ` ${issue.code} ${issue.path} ${issue.message}`).join("\n")}`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
function probeLine() {
|
|
490
|
+
if (state === null)
|
|
491
|
+
return `${defs.size} missions idle ${ticks} ticks`;
|
|
492
|
+
return `${defs.size} missions "${state.missionId}" ${state.status} ${state.objective ?? "-"} ${state.path.length} steps ${state.score} score ${ticks} ticks ${slowestTickMs.toFixed(2)} ms slowest`;
|
|
493
|
+
}
|
|
494
|
+
const unregister = config.devtoolsName === undefined ? null : devtools.probes.register(config.devtoolsName, () => probeLine());
|
|
495
|
+
return {
|
|
496
|
+
missionIds: () => [...defs.keys()],
|
|
497
|
+
mission(id) {
|
|
498
|
+
return defs.get(id) ?? null;
|
|
499
|
+
},
|
|
500
|
+
start(missionId, facts) {
|
|
501
|
+
const events = startMission(missionId, facts);
|
|
502
|
+
if (events.length === 0 || state === null)
|
|
503
|
+
return null;
|
|
504
|
+
notify();
|
|
505
|
+
return cloneState(state);
|
|
506
|
+
},
|
|
507
|
+
tick(dt, input = {}) {
|
|
508
|
+
const begin = performance.now();
|
|
509
|
+
const events = advance(dt, input);
|
|
510
|
+
const elapsedMs = performance.now() - begin;
|
|
511
|
+
if (elapsedMs > slowestTickMs)
|
|
512
|
+
slowestTickMs = elapsedMs;
|
|
513
|
+
ticks += 1;
|
|
514
|
+
if (events.length > 0)
|
|
515
|
+
notify();
|
|
516
|
+
return events;
|
|
517
|
+
},
|
|
518
|
+
signal(missionId, objectiveId) {
|
|
519
|
+
if (state === null || state.missionId !== missionId || state.status !== "active")
|
|
520
|
+
return [];
|
|
521
|
+
const def = activeDef();
|
|
522
|
+
if (def === null)
|
|
523
|
+
return [];
|
|
524
|
+
const objective = objectiveOf(def, objectiveId);
|
|
525
|
+
if (objective === null || state.objective !== objectiveId)
|
|
526
|
+
return [];
|
|
527
|
+
const events = completeObjective(def, objective, {});
|
|
528
|
+
if (events.length > 0)
|
|
529
|
+
notify();
|
|
530
|
+
return events;
|
|
531
|
+
},
|
|
532
|
+
fail(missionId, failState, reason) {
|
|
533
|
+
if (state === null || state.missionId !== missionId || state.status !== "active")
|
|
534
|
+
return [];
|
|
535
|
+
const def = activeDef();
|
|
536
|
+
if (def === null)
|
|
537
|
+
return [];
|
|
538
|
+
const events = failMission(def, failState, reason ?? `failed at ${failState}`);
|
|
539
|
+
notify();
|
|
540
|
+
return events;
|
|
541
|
+
},
|
|
542
|
+
abandon() {
|
|
543
|
+
if (state === null)
|
|
544
|
+
return;
|
|
545
|
+
state = null;
|
|
546
|
+
notify();
|
|
547
|
+
},
|
|
548
|
+
state() {
|
|
549
|
+
return state;
|
|
550
|
+
},
|
|
551
|
+
view() {
|
|
552
|
+
if (state === null)
|
|
553
|
+
return null;
|
|
554
|
+
const def = activeDef();
|
|
555
|
+
if (def === null)
|
|
556
|
+
return null;
|
|
557
|
+
const objective = objectiveOf(def, state.objective);
|
|
558
|
+
readout.missionId = state.missionId;
|
|
559
|
+
readout.title = def.title;
|
|
560
|
+
readout.status = state.status;
|
|
561
|
+
readout.objectiveId = state.objective;
|
|
562
|
+
readout.label = objective === null ? "" : objectiveLabel(objective);
|
|
563
|
+
readout.objectiveElapsed = state.objectiveElapsed;
|
|
564
|
+
readout.remaining = objective?.timeout === undefined ? null : Math.max(0, objective.timeout - state.objectiveElapsed);
|
|
565
|
+
const counted = objective === null || !isCounterObjective(objective.kind)
|
|
566
|
+
? 0
|
|
567
|
+
: (state.progress[counterKey(objective)] ?? 0);
|
|
568
|
+
const count = objective !== null && isCounterObjective(objective.kind) ? (objective.count ?? 1) : 0;
|
|
569
|
+
readout.counted = counted;
|
|
570
|
+
readout.count = count;
|
|
571
|
+
readout.progress = count === 0 ? 0 : clamp(counted / count, 0, 1);
|
|
572
|
+
readout.step = state.path.length;
|
|
573
|
+
readout.steps = def.objectives.length;
|
|
574
|
+
readout.score = state.score;
|
|
575
|
+
readout.outcome = state.outcome;
|
|
576
|
+
readout.reason = state.reason;
|
|
577
|
+
readout.path = state.path;
|
|
578
|
+
return readout;
|
|
579
|
+
},
|
|
580
|
+
subscribe(listener) {
|
|
581
|
+
listeners.add(listener);
|
|
582
|
+
return () => listeners.delete(listener);
|
|
583
|
+
},
|
|
584
|
+
stats: () => ({ missions: defs.size, ticks, started, completed, failed, branches, timeouts, slowestTickMs }),
|
|
585
|
+
snapshot: () => ({ state: state === null ? null : cloneState(state), ticks, started, completed, failed, branches, timeouts }),
|
|
586
|
+
restore(next) {
|
|
587
|
+
state = next.state === null ? null : cloneState(next.state);
|
|
588
|
+
ticks = next.ticks;
|
|
589
|
+
started = next.started;
|
|
590
|
+
completed = next.completed;
|
|
591
|
+
failed = next.failed;
|
|
592
|
+
branches = next.branches;
|
|
593
|
+
timeouts = next.timeouts;
|
|
594
|
+
notify();
|
|
595
|
+
},
|
|
596
|
+
dispose() {
|
|
597
|
+
unregister?.();
|
|
598
|
+
listeners.clear();
|
|
599
|
+
},
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
function successTargets(objective) {
|
|
603
|
+
const out = [];
|
|
604
|
+
for (const branch of objective.branches ?? [])
|
|
605
|
+
out.push(branch.to);
|
|
606
|
+
out.push(objective.next ?? "done");
|
|
607
|
+
return out;
|
|
608
|
+
}
|
|
609
|
+
function counterOnlyPath(def, byId) {
|
|
610
|
+
const start = def.start ?? def.objectives[0]?.id;
|
|
611
|
+
if (start === undefined)
|
|
612
|
+
return false;
|
|
613
|
+
const seen = new Set();
|
|
614
|
+
const stack = [start];
|
|
615
|
+
while (stack.length > 0) {
|
|
616
|
+
const id = stack.pop();
|
|
617
|
+
if (seen.has(id))
|
|
618
|
+
continue;
|
|
619
|
+
seen.add(id);
|
|
620
|
+
const objective = byId.get(id);
|
|
621
|
+
if (objective === undefined || !isCounterObjective(objective.kind))
|
|
622
|
+
continue;
|
|
623
|
+
for (const target of successTargets(objective)) {
|
|
624
|
+
if (target === "done")
|
|
625
|
+
return true;
|
|
626
|
+
if (hasFailState(def, target))
|
|
627
|
+
continue;
|
|
628
|
+
stack.push(target);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Validate a mission catalog without mutation: duplicate ids, unknown branch/next targets, unknown
|
|
635
|
+
* fail states and markers, per-kind required fields, unreachable objectives, and the counter-only
|
|
636
|
+
* rule — a mission that has a completion path made entirely of `kill`/`collect` objectives is an
|
|
637
|
+
* error, because it can be won on a counter alone.
|
|
638
|
+
*
|
|
639
|
+
* Games run this over their mission catalogs (and {@link createMissionRuntime} runs it at
|
|
640
|
+
* construction), so a counter-only mission fails rather than shipping.
|
|
641
|
+
*
|
|
642
|
+
* @capability mission-validation lint mission catalogs, including failing a mission that completes on a counter alone
|
|
643
|
+
*/
|
|
644
|
+
export function validateMissionCatalog(missions, options = {}) {
|
|
645
|
+
const issues = [];
|
|
646
|
+
const markerIds = options.markerIds === undefined ? null : new Set(options.markerIds);
|
|
647
|
+
const seenMissions = new Set();
|
|
648
|
+
for (const [index, def] of missions.entries()) {
|
|
649
|
+
const base = `/${index}`;
|
|
650
|
+
if (seenMissions.has(def.id)) {
|
|
651
|
+
issues.push({ code: "duplicate-mission", severity: "error", missionId: def.id, path: `${base}/id`,
|
|
652
|
+
message: `Duplicate mission "${def.id}".` });
|
|
653
|
+
}
|
|
654
|
+
seenMissions.add(def.id);
|
|
655
|
+
if (def.objectives.length === 0) {
|
|
656
|
+
issues.push({ code: "empty-mission", severity: "error", missionId: def.id, path: `${base}/objectives`,
|
|
657
|
+
message: `Mission "${def.id}" has no objectives.` });
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
const byId = new Map();
|
|
661
|
+
for (const [objectiveIndex, objective] of def.objectives.entries()) {
|
|
662
|
+
const field = `${base}/objectives/${objectiveIndex}`;
|
|
663
|
+
if (byId.has(objective.id)) {
|
|
664
|
+
issues.push({ code: "duplicate-objective", severity: "error", missionId: def.id, objectiveId: objective.id,
|
|
665
|
+
path: `${field}/id`, message: `Duplicate objective "${objective.id}" in mission "${def.id}".` });
|
|
666
|
+
}
|
|
667
|
+
byId.set(objective.id, objective);
|
|
668
|
+
function require(condition, code, suffix, message) {
|
|
669
|
+
if (!condition) {
|
|
670
|
+
issues.push({ code, severity: "error", missionId: def.id, objectiveId: objective.id,
|
|
671
|
+
path: `${field}/${suffix}`, message });
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
require(objective.kind !== "reach" || objective.marker !== undefined, "missing-marker", "marker", `Objective "${objective.id}" is a reach goal with no marker.`);
|
|
675
|
+
require(objective.kind !== "reach" || objective.actor !== undefined, "missing-actor", "actor", `Objective "${objective.id}" is a reach goal with no actor.`);
|
|
676
|
+
require(objective.kind !== "survive" || objective.duration !== undefined, "missing-duration", "duration", `Objective "${objective.id}" is a survive goal with no duration.`);
|
|
677
|
+
require(objective.kind !== "pursue" || objective.actor !== undefined, "missing-target", "actor", `Objective "${objective.id}" is a pursuit with no target.`);
|
|
678
|
+
require(!isCounterObjective(objective.kind) || objective.count !== undefined, "missing-count", "count", `Objective "${objective.id}" is a counter goal with no count.`);
|
|
679
|
+
if (objective.count !== undefined && (!Number.isFinite(objective.count) || objective.count < 0)) {
|
|
680
|
+
require(false, "invalid-number", "count", `Objective "${objective.id}" count must be finite and nonnegative.`);
|
|
681
|
+
}
|
|
682
|
+
if (objective.duration !== undefined && (!Number.isFinite(objective.duration) || objective.duration < 0)) {
|
|
683
|
+
require(false, "invalid-number", "duration", `Objective "${objective.id}" duration must be finite and nonnegative.`);
|
|
684
|
+
}
|
|
685
|
+
if (markerIds !== null && objective.marker !== undefined && !markerIds.has(objective.marker)) {
|
|
686
|
+
require(false, "unknown-marker", "marker", `Objective "${objective.id}" references unknown marker "${objective.marker}".`);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
const start = def.start ?? def.objectives[0]?.id;
|
|
690
|
+
if (def.start !== undefined && !byId.has(def.start)) {
|
|
691
|
+
issues.push({ code: "missing-start", severity: "error", missionId: def.id, path: `${base}/start`,
|
|
692
|
+
message: `Mission "${def.id}" starts on unknown objective "${def.start}".` });
|
|
693
|
+
}
|
|
694
|
+
for (const [objectiveIndex, objective] of def.objectives.entries()) {
|
|
695
|
+
const field = `${base}/objectives/${objectiveIndex}`;
|
|
696
|
+
for (const [branchIndex, branch] of (objective.branches ?? []).entries()) {
|
|
697
|
+
if (branch.to !== "done" && !byId.has(branch.to) && !hasFailState(def, branch.to)) {
|
|
698
|
+
issues.push({ code: "unknown-objective", severity: "error", missionId: def.id, objectiveId: objective.id,
|
|
699
|
+
path: `${field}/branches/${branchIndex}/to`, message: `Branch target "${branch.to}" is not an objective or fail state.` });
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
const next = objective.next ?? "done";
|
|
703
|
+
if (next !== "done" && !byId.has(next) && !hasFailState(def, next)) {
|
|
704
|
+
issues.push({ code: "unknown-objective", severity: "error", missionId: def.id, objectiveId: objective.id,
|
|
705
|
+
path: `${field}/next`, message: `Next objective "${next}" is not an objective or fail state.` });
|
|
706
|
+
}
|
|
707
|
+
const onTimeout = objective.onTimeout ?? "failed";
|
|
708
|
+
if (objective.timeout !== undefined && onTimeout !== "done" && !byId.has(onTimeout) && !hasFailState(def, onTimeout)) {
|
|
709
|
+
issues.push({ code: "unknown-objective", severity: "error", missionId: def.id, objectiveId: objective.id,
|
|
710
|
+
path: `${field}/onTimeout`, message: `Timeout target "${onTimeout}" is not an objective or fail state.` });
|
|
711
|
+
}
|
|
712
|
+
for (const [failIndex, condition] of (objective.failOn ?? []).entries()) {
|
|
713
|
+
if (condition.to !== undefined && !hasFailState(def, condition.to)) {
|
|
714
|
+
issues.push({ code: "unknown-fail-state", severity: "error", missionId: def.id, objectiveId: objective.id,
|
|
715
|
+
path: `${field}/failOn/${failIndex}/to`, message: `Fail condition targets unknown fail state "${condition.to}".` });
|
|
716
|
+
}
|
|
717
|
+
if (condition.kind === "left-area") {
|
|
718
|
+
if (condition.actor === undefined && objective.actor === undefined) {
|
|
719
|
+
issues.push({ code: "missing-actor", severity: "error", missionId: def.id, objectiveId: objective.id,
|
|
720
|
+
path: `${field}/failOn/${failIndex}/actor`, message: `Left-area fail condition on "${objective.id}" watches no actor.` });
|
|
721
|
+
}
|
|
722
|
+
if (condition.marker === undefined) {
|
|
723
|
+
issues.push({ code: "missing-marker", severity: "error", missionId: def.id, objectiveId: objective.id,
|
|
724
|
+
path: `${field}/failOn/${failIndex}/marker`, message: `Left-area fail condition on "${objective.id}" has no marker.` });
|
|
725
|
+
}
|
|
726
|
+
else if (markerIds !== null && !markerIds.has(condition.marker)) {
|
|
727
|
+
issues.push({ code: "unknown-marker", severity: "error", missionId: def.id, objectiveId: objective.id,
|
|
728
|
+
path: `${field}/failOn/${failIndex}/marker`, message: `Left-area fail condition references unknown marker "${condition.marker}".` });
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
if (objective.match !== undefined && !isCounterObjective(objective.kind)) {
|
|
733
|
+
issues.push({ code: "invalid-match", severity: "error", missionId: def.id, objectiveId: objective.id,
|
|
734
|
+
path: `${field}/match`, message: `Objective "${objective.id}" is a ${objective.kind} goal; only kill and collect objectives read world events.` });
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
const reachable = new Set();
|
|
738
|
+
const queue = start === undefined ? [] : [start];
|
|
739
|
+
while (queue.length > 0) {
|
|
740
|
+
const id = queue.pop();
|
|
741
|
+
if (reachable.has(id))
|
|
742
|
+
continue;
|
|
743
|
+
reachable.add(id);
|
|
744
|
+
const objective = byId.get(id);
|
|
745
|
+
if (objective === undefined)
|
|
746
|
+
continue;
|
|
747
|
+
for (const target of successTargets(objective)) {
|
|
748
|
+
if (target !== "done" && !hasFailState(def, target))
|
|
749
|
+
queue.push(target);
|
|
750
|
+
}
|
|
751
|
+
for (const condition of objective.failOn ?? []) {
|
|
752
|
+
if (condition.to !== undefined && byId.has(condition.to))
|
|
753
|
+
queue.push(condition.to);
|
|
754
|
+
}
|
|
755
|
+
if (objective.onTimeout !== undefined && byId.has(objective.onTimeout))
|
|
756
|
+
queue.push(objective.onTimeout);
|
|
757
|
+
}
|
|
758
|
+
for (const [objectiveIndex, objective] of def.objectives.entries()) {
|
|
759
|
+
if (!reachable.has(objective.id)) {
|
|
760
|
+
issues.push({ code: "unreachable-objective", severity: "warning", missionId: def.id, objectiveId: objective.id,
|
|
761
|
+
path: `${base}/objectives/${objectiveIndex}`, message: `Objective "${objective.id}" is not reachable from the start.` });
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
if (counterOnlyPath(def, byId)) {
|
|
765
|
+
issues.push({ code: "counter-only", severity: "error", missionId: def.id, path: `${base}/objectives`,
|
|
766
|
+
message: `Mission "${def.id}" can complete on a counter alone; add a reach, interact, survive or pursue objective to its success path.` });
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return issues;
|
|
770
|
+
}
|