@futdevpro/fsm-dynamo 1.20.101 → 1.20.103

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 (23) hide show
  1. package/build/_modules/game/_models/guided-onboarding.control-model.d.ts +81 -0
  2. package/build/_modules/game/_models/guided-onboarding.control-model.d.ts.map +1 -0
  3. package/build/_modules/game/_models/guided-onboarding.control-model.js +491 -0
  4. package/build/_modules/game/_models/guided-onboarding.control-model.js.map +1 -0
  5. package/build/_modules/game/_models/guided-onboarding.interface.d.ts +102 -0
  6. package/build/_modules/game/_models/guided-onboarding.interface.d.ts.map +1 -0
  7. package/build/_modules/game/_models/guided-onboarding.interface.js +3 -0
  8. package/build/_modules/game/_models/guided-onboarding.interface.js.map +1 -0
  9. package/build/_modules/game/_models/input-action-registry.control-model.d.ts +3 -1
  10. package/build/_modules/game/_models/input-action-registry.control-model.d.ts.map +1 -1
  11. package/build/_modules/game/_models/input-action-registry.control-model.js +13 -0
  12. package/build/_modules/game/_models/input-action-registry.control-model.js.map +1 -1
  13. package/build/_modules/game/_models/input-action.interface.d.ts +12 -0
  14. package/build/_modules/game/_models/input-action.interface.d.ts.map +1 -1
  15. package/build/_modules/game/index.d.ts +2 -0
  16. package/build/_modules/game/index.d.ts.map +1 -1
  17. package/build/_modules/game/index.js +2 -0
  18. package/build/_modules/game/index.js.map +1 -1
  19. package/build-esm/_modules/game/_models/guided-onboarding.control-model.js +486 -0
  20. package/build-esm/_modules/game/_models/guided-onboarding.interface.js +1 -0
  21. package/build-esm/_modules/game/_models/input-action-registry.control-model.js +13 -0
  22. package/build-esm/_modules/game/index.js +2 -0
  23. package/package.json +1 -1
@@ -0,0 +1,486 @@
1
+ import { DyFM_Log } from '../../../_collections/utils/log.util';
2
+ import { DyFM_Error } from '../../../_models/control-models/error.control-model';
3
+ const ISSUER = 'DyFM_GuidedOnboarding_ControlModel';
4
+ /**
5
+ * BFR-WARFACTORY-014 — content-agnostic guided onboarding: chapters of steps with STABLE ids, declarative commands and
6
+ * predicates resolved against the game's capability surface, state-regression recovery, bounded timeouts,
7
+ * skip / restart / resume, and a versioned, migratable progress state.
8
+ *
9
+ * It owns no timer and no DOM: the driver (a game loop tick, a state-change event, an Angular effect) calls
10
+ * `evaluate()`; the UI layer renders `position()` and listens to `onEvent()`. The clock is injectable, so timeouts are
11
+ * deterministic in tests.
12
+ */
13
+ export class DyFM_GuidedOnboarding_ControlModel {
14
+ catalog;
15
+ capabilities;
16
+ now;
17
+ steps = [];
18
+ chapterCount;
19
+ status = 'not-started';
20
+ currentIndex = -1;
21
+ enteredAtMs = 0;
22
+ completed = new Set();
23
+ skipped = new Set();
24
+ listeners = [];
25
+ /** `importState` restored an active step, but it was not entered yet (`resume`). */
26
+ pendingResume = false;
27
+ /** A transition (with a possibly async command) is running — a re-entrant `evaluate` waits it out. */
28
+ transitioning = false;
29
+ /** Validates the catalog against the capabilities; any finding throws `DYFM-ONBOARDING-CATALOG` listing them all. */
30
+ constructor(catalog, capabilities, now = () => Date.now()) {
31
+ this.catalog = catalog;
32
+ this.capabilities = capabilities;
33
+ this.now = now;
34
+ const findings = DyFM_GuidedOnboarding_ControlModel.validateCatalog(catalog, capabilities);
35
+ if (findings.length) {
36
+ throw new DyFM_Error({
37
+ message: `DyFM_GuidedOnboarding: invalid catalog — ${findings.map((f) => `[${f.code}] ${f.path}: ${f.message}`).join(' · ')}`,
38
+ errorCode: 'DYFM-ONBOARDING-CATALOG',
39
+ issuerService: ISSUER,
40
+ });
41
+ }
42
+ catalog.chapters.forEach((chapter, chapterIndex) => {
43
+ chapter.steps.forEach((step) => {
44
+ this.steps.push({ chapterId: chapter.chapterId, chapterIndex: chapterIndex, step: step });
45
+ });
46
+ });
47
+ this.chapterCount = catalog.chapters.length;
48
+ }
49
+ /**
50
+ * Every problem of a catalog, as data (never throws). With `capabilities`, each ref must name a registered command /
51
+ * predicate — a typo is caught when the game boots, not when a player reaches that step.
52
+ */
53
+ static validateCatalog(catalog, capabilities) {
54
+ const findings = [];
55
+ const add = (code, path, message) => findings.push({ code: code, path: path, message: message });
56
+ if (!catalog || !Array.isArray(catalog.chapters) || !catalog.chapters.length) {
57
+ add('ONB-EMPTY', 'chapters', 'a catalog needs at least one chapter');
58
+ return findings;
59
+ }
60
+ if (catalog.catalogVersion === undefined || catalog.catalogVersion === null || catalog.catalogVersion === '') {
61
+ add('ONB-VERSION', 'catalogVersion', 'required — saves are migrated by it');
62
+ }
63
+ const chapterIds = new Set();
64
+ const stepIds = new Set();
65
+ catalog.chapters.forEach((chapter, c) => {
66
+ if (!chapter?.chapterId || chapterIds.has(chapter.chapterId)) {
67
+ add('ONB-CHAPTER-ID', `chapters[${c}]`, `chapter id must be non-empty and unique (${chapter?.chapterId})`);
68
+ }
69
+ chapterIds.add(chapter?.chapterId);
70
+ if (!Array.isArray(chapter?.steps) || !chapter.steps.length) {
71
+ add('ONB-CHAPTER-EMPTY', `chapters[${c}]`, `chapter '${chapter?.chapterId}' has no steps`);
72
+ return;
73
+ }
74
+ chapter.steps.forEach((step, s) => {
75
+ if (!step?.stepId || stepIds.has(step.stepId)) {
76
+ add('ONB-STEP-ID', `chapters[${c}].steps[${s}]`, `step id must be non-empty and unique across the catalog (${step?.stepId})`);
77
+ }
78
+ stepIds.add(step?.stepId);
79
+ });
80
+ });
81
+ const checkRef = (ref, kind, path) => {
82
+ if (ref === undefined) {
83
+ return;
84
+ }
85
+ if (!ref || typeof ref.name !== 'string' || !ref.name) {
86
+ add('ONB-REF', path, 'a ref needs a non-empty name');
87
+ }
88
+ else if (capabilities && typeof capabilities[kind]?.[ref.name] !== 'function') {
89
+ add('ONB-REF-UNKNOWN', path, `'${ref.name}' is not a registered ${kind === 'commands' ? 'command' : 'predicate'}`);
90
+ }
91
+ };
92
+ catalog.chapters.forEach((chapter, c) => {
93
+ (chapter?.steps ?? []).forEach((step, s) => {
94
+ const path = `chapters[${c}].steps[${s}]`;
95
+ checkRef(step?.command, 'commands', `${path}.command`);
96
+ checkRef(step?.completion, 'predicates', `${path}.completion`);
97
+ checkRef(step?.stateValidator, 'predicates', `${path}.stateValidator`);
98
+ (step?.fallbackStepIds ?? []).forEach((fallback) => {
99
+ if (fallback === step.stepId || !stepIds.has(fallback)) {
100
+ add('ONB-FALLBACK', `${path}.fallbackStepIds`, `'${fallback}' is not another step of the catalog`);
101
+ }
102
+ });
103
+ if (step?.timeoutMs !== undefined && !(Number.isFinite(step.timeoutMs) && step.timeoutMs > 0)) {
104
+ add('ONB-TIMEOUT', `${path}.timeoutMs`, `must be a positive number (${step.timeoutMs})`);
105
+ }
106
+ });
107
+ });
108
+ for (const source of Object.keys(catalog.migrations ?? {})) {
109
+ if (stepIds.has(source)) {
110
+ add('ONB-MIGRATION-SOURCE-LIVE', `migrations.${source}`, 'the source id still exists — a rule for a live step would hijack it');
111
+ continue;
112
+ }
113
+ const seen = new Set([source]);
114
+ let target = (catalog.migrations)[source];
115
+ while (target !== undefined && !stepIds.has(target) && (catalog.migrations)[target] !== undefined) {
116
+ if (seen.has(target)) {
117
+ add('ONB-MIGRATION-CYCLE', `migrations.${source}`, `cycle through '${target}'`);
118
+ target = undefined;
119
+ break;
120
+ }
121
+ seen.add(target);
122
+ target = (catalog.migrations)[target];
123
+ }
124
+ if (target !== undefined && !stepIds.has(target)) {
125
+ add('ONB-MIGRATION-TARGET', `migrations.${source}`, `resolves to '${target}', which is not a step of the catalog`);
126
+ }
127
+ }
128
+ return findings;
129
+ }
130
+ /** Subscribe; returns the unsubscribe. A throwing listener is logged and isolated — it never breaks the runtime. */
131
+ onEvent(listener) {
132
+ this.listeners.push(listener);
133
+ return () => {
134
+ const index = this.listeners.indexOf(listener);
135
+ if (index >= 0) {
136
+ this.listeners.splice(index, 1);
137
+ }
138
+ };
139
+ }
140
+ getStatus() {
141
+ return this.status;
142
+ }
143
+ /** Where the player is; `null` unless active. */
144
+ position() {
145
+ if (this.status !== 'active' || this.currentIndex < 0) {
146
+ return null;
147
+ }
148
+ const current = this.steps[this.currentIndex];
149
+ const chapterSteps = this.steps
150
+ .filter((s) => s.chapterIndex === current.chapterIndex);
151
+ return {
152
+ chapterId: current.chapterId,
153
+ chapterIndex: current.chapterIndex,
154
+ chapterCount: this.chapterCount,
155
+ step: current.step,
156
+ stepIndexInChapter: chapterSteps.indexOf(current),
157
+ chapterStepCount: chapterSteps.length,
158
+ doneCount: this.completed.size + this.skipped.size,
159
+ totalCount: this.steps.length,
160
+ manual: !current.step.completion,
161
+ };
162
+ }
163
+ /** Starts a not-yet-started onboarding at its first step (no-op otherwise — a finished one is `restart`ed). */
164
+ async start(context) {
165
+ if (this.status !== 'not-started') {
166
+ return;
167
+ }
168
+ this.status = 'active';
169
+ await this.transition(async () => this.enter(0, context, true, false));
170
+ }
171
+ /**
172
+ * One check of the active step: state regression → fallback · completion → next step · timeout → recorded and left.
173
+ * Call it from whatever drives the game (tick, state event); it never waits by itself.
174
+ */
175
+ async evaluate(context) {
176
+ if (this.status !== 'active' || this.transitioning) {
177
+ return this.status === 'active' ? 'waiting' : 'inactive';
178
+ }
179
+ this.assertResumed();
180
+ const step = this.steps[this.currentIndex].step;
181
+ if (step.stateValidator && !this.predicate(step.stateValidator, context, step.stepId)) {
182
+ const visited = new Set([step.stepId]);
183
+ for (const fallbackId of step.fallbackStepIds ?? []) {
184
+ if (visited.has(fallbackId)) {
185
+ continue;
186
+ }
187
+ visited.add(fallbackId);
188
+ const index = this.indexOf(fallbackId);
189
+ const fallback = this.steps[index].step;
190
+ if (fallback.stateValidator && !this.predicate(fallback.stateValidator, context, fallback.stepId)) {
191
+ continue;
192
+ }
193
+ // Everything from the fallback on has to be done again.
194
+ for (let i = index; i <= Math.max(index, this.currentIndex); i++) {
195
+ this.completed.delete(this.steps[i].step.stepId);
196
+ this.skipped.delete(this.steps[i].step.stepId);
197
+ }
198
+ this.emit({ type: 'regressed', stepId: fallbackId, fromStepId: step.stepId, chapterId: this.steps[index].chapterId });
199
+ await this.transition(async () => this.enter(index, context, true, false));
200
+ return 'regressed';
201
+ }
202
+ }
203
+ if (step.completion && this.predicate(step.completion, context, step.stepId)) {
204
+ await this.transition(async () => this.leave('completed', context));
205
+ return 'advanced';
206
+ }
207
+ if (step.timeoutMs !== undefined && this.now() - this.enteredAtMs >= step.timeoutMs) {
208
+ await this.transition(async () => this.leave('timed-out', context));
209
+ return 'timed-out';
210
+ }
211
+ return 'waiting';
212
+ }
213
+ /** The player acknowledges a MANUAL step (no completion predicate); a gameplay step throws `DYFM-ONBOARDING-NOT-MANUAL`. */
214
+ async next(context) {
215
+ const step = this.activeStep('next');
216
+ if (step.completion) {
217
+ throw new DyFM_Error({
218
+ message: `DyFM_GuidedOnboarding: step '${step.stepId}' completes by '${step.completion.name}', not by "Next" — use skip('step') to leave it`,
219
+ errorCode: 'DYFM-ONBOARDING-NOT-MANUAL',
220
+ issuerService: ISSUER,
221
+ });
222
+ }
223
+ await this.transition(async () => this.leave('completed', context));
224
+ }
225
+ /** Skip the active step, the rest of its chapter, or the whole onboarding. */
226
+ async skip(context, scope) {
227
+ const step = this.activeStep('skip');
228
+ if (scope === 'all') {
229
+ this.status = 'skipped';
230
+ this.currentIndex = -1;
231
+ this.emit({ type: 'skipped', stepId: step.stepId });
232
+ return;
233
+ }
234
+ if (scope === 'chapter') {
235
+ const chapterIndex = this.steps[this.currentIndex].chapterIndex;
236
+ for (let i = this.currentIndex + 1; i < this.steps.length && this.steps[i].chapterIndex === chapterIndex; i++) {
237
+ const id = this.steps[i].step.stepId;
238
+ if (!this.completed.has(id) && !this.skipped.has(id)) {
239
+ this.skipped.add(id);
240
+ this.emit({ type: 'step-skipped', stepId: id, chapterId: this.steps[i].chapterId });
241
+ }
242
+ }
243
+ }
244
+ await this.transition(async () => this.leave('skipped', context));
245
+ }
246
+ /** Start over — all of it, or from a chapter on (earlier chapters keep their progress). */
247
+ async restart(context, fromChapterId) {
248
+ const from = fromChapterId === undefined
249
+ ? 0
250
+ : this.steps.findIndex((s) => s.chapterId === fromChapterId);
251
+ if (from < 0) {
252
+ throw new DyFM_Error({
253
+ message: `DyFM_GuidedOnboarding: restart from unknown chapter '${fromChapterId}'`,
254
+ errorCode: 'DYFM-ONBOARDING-UNKNOWN-CHAPTER',
255
+ issuerService: ISSUER,
256
+ });
257
+ }
258
+ for (let i = from; i < this.steps.length; i++) {
259
+ this.completed.delete(this.steps[i].step.stepId);
260
+ this.skipped.delete(this.steps[i].step.stepId);
261
+ }
262
+ this.status = 'active';
263
+ this.pendingResume = false;
264
+ this.emit({ type: 'restarted', chapterId: this.steps[from].chapterId });
265
+ await this.transition(async () => this.enter(from, context, true, false));
266
+ }
267
+ /** The progress as data (`dyfm-onboarding/1`); ids in catalog order, so two equal states serialise identically. */
268
+ exportState() {
269
+ const inOrder = (set) => this.steps
270
+ .map((s) => s.step.stepId).filter((id) => set.has(id));
271
+ const state = {
272
+ schema: 'dyfm-onboarding/1',
273
+ catalogVersion: this.catalog.catalogVersion,
274
+ status: this.status,
275
+ completedStepIds: inOrder(this.completed),
276
+ skippedStepIds: inOrder(this.skipped),
277
+ };
278
+ if (this.status === 'active' && this.currentIndex >= 0) {
279
+ state.stepId = this.steps[this.currentIndex].step.stepId;
280
+ }
281
+ return state;
282
+ }
283
+ /**
284
+ * Restores a save onto THIS catalog: ids are rewritten by the migration rules, vanished ids are dropped, and an
285
+ * active step that no longer exists (or is already done) continues at the first unfinished step. No command runs —
286
+ * an active onboarding must be `resume`d, and until then `evaluate` / `next` / `skip` throw
287
+ * `DYFM-ONBOARDING-NOT-RESUMED` (a forgotten resume would otherwise leave the player stuck, silently).
288
+ */
289
+ importState(state) {
290
+ if (!state || state.schema !== 'dyfm-onboarding/1'
291
+ || !['not-started', 'active', 'completed', 'skipped'].includes(state.status)
292
+ || !Array.isArray(state.completedStepIds) || !Array.isArray(state.skippedStepIds)) {
293
+ throw new DyFM_Error({
294
+ message: `DyFM_GuidedOnboarding: not a 'dyfm-onboarding/1' state (schema: ${String(state?.schema)}, status: ${String(state?.status)})`,
295
+ errorCode: 'DYFM-ONBOARDING-STATE',
296
+ issuerService: ISSUER,
297
+ });
298
+ }
299
+ const report = { migrated: state.catalogVersion !== this.catalog.catalogVersion, renamed: {}, dropped: [] };
300
+ const resolve = (id) => {
301
+ let current = id;
302
+ for (let hops = 0; this.indexOf(current, false) < 0; hops++) {
303
+ const next = this.catalog.migrations?.[current];
304
+ if (next === undefined || hops > this.steps.length) {
305
+ if (!report.dropped.includes(id)) {
306
+ report.dropped.push(id);
307
+ }
308
+ return undefined;
309
+ }
310
+ current = next;
311
+ }
312
+ if (current !== id) {
313
+ report.renamed[id] = current;
314
+ }
315
+ return current;
316
+ };
317
+ this.completed.clear();
318
+ this.skipped.clear();
319
+ state.completedStepIds.forEach((id) => {
320
+ const resolved = resolve(id);
321
+ if (resolved) {
322
+ this.completed.add(resolved);
323
+ }
324
+ });
325
+ state.skippedStepIds.forEach((id) => {
326
+ const resolved = resolve(id);
327
+ if (resolved && !this.completed.has(resolved)) {
328
+ this.skipped.add(resolved);
329
+ }
330
+ });
331
+ this.status = state.status;
332
+ this.currentIndex = -1;
333
+ this.pendingResume = false;
334
+ if (state.status === 'active') {
335
+ const resolved = state.stepId === undefined ? undefined : resolve(state.stepId);
336
+ let index = resolved === undefined ? -1 : this.indexOf(resolved);
337
+ if (index < 0 || this.isDone(index)) {
338
+ index = this.steps.findIndex((s, i) => !this.isDone(i));
339
+ if (index >= 0) {
340
+ report.resumedAt = this.steps[index].step.stepId;
341
+ }
342
+ }
343
+ if (index < 0) {
344
+ this.status = 'completed';
345
+ }
346
+ else {
347
+ this.currentIndex = index;
348
+ this.pendingResume = true;
349
+ }
350
+ }
351
+ report.migrated = report.migrated || Object.keys(report.renamed).length > 0 || report.dropped.length > 0
352
+ || report.resumedAt !== undefined;
353
+ return report;
354
+ }
355
+ /**
356
+ * Enters the restored step after `importState`. Its command is NOT run again by default (it already ran before the
357
+ * save — e.g. placing a building twice would corrupt the game); pass `rerunCommand` for idempotent commands.
358
+ */
359
+ async resume(context, options = {}) {
360
+ if (!this.pendingResume) {
361
+ return;
362
+ }
363
+ this.pendingResume = false;
364
+ await this.transition(async () => this.enter(this.currentIndex, context, options.rerunCommand === true, true));
365
+ }
366
+ /** Leaves the active step (completed / skipped / timed out) and enters the next unfinished one — or finishes. */
367
+ async leave(how, context) {
368
+ const from = this.steps[this.currentIndex];
369
+ if (how === 'completed') {
370
+ this.completed.add(from.step.stepId);
371
+ this.emit({ type: 'step-completed', stepId: from.step.stepId, chapterId: from.chapterId });
372
+ }
373
+ else {
374
+ // A timed-out step is recorded as skipped: the player is not sent back into it on resume.
375
+ this.skipped.add(from.step.stepId);
376
+ this.emit({ type: how === 'skipped' ? 'step-skipped' : 'step-timed-out', stepId: from.step.stepId, chapterId: from.chapterId });
377
+ }
378
+ let next = -1;
379
+ for (let i = this.currentIndex + 1; i < this.steps.length; i++) {
380
+ if (!this.isDone(i)) {
381
+ next = i;
382
+ break;
383
+ }
384
+ }
385
+ if (next < 0 || this.steps[next].chapterIndex !== from.chapterIndex) {
386
+ const chapterDone = this.steps
387
+ .filter((s) => s.chapterIndex === from.chapterIndex)
388
+ .every((s) => this.completed.has(s.step.stepId));
389
+ if (chapterDone) {
390
+ this.emit({ type: 'chapter-completed', chapterId: from.chapterId });
391
+ }
392
+ }
393
+ if (next < 0) {
394
+ this.status = 'completed';
395
+ this.currentIndex = -1;
396
+ this.emit({ type: 'completed' });
397
+ return;
398
+ }
399
+ await this.enter(next, context, true, false);
400
+ }
401
+ /** Makes `index` the active step; runs its command (a failing command is reported and the step stays active). */
402
+ async enter(index, context, runCommand, resumed) {
403
+ const entry = this.steps[index];
404
+ this.currentIndex = index;
405
+ this.enteredAtMs = this.now();
406
+ this.emit(resumed
407
+ ? { type: 'step-entered', stepId: entry.step.stepId, chapterId: entry.chapterId, resumed: true }
408
+ : { type: 'step-entered', stepId: entry.step.stepId, chapterId: entry.chapterId });
409
+ if (runCommand && entry.step.command) {
410
+ try {
411
+ await this.capabilities.commands[entry.step.command.name](context, entry.step.command.args);
412
+ }
413
+ catch (error) {
414
+ this.emit({ type: 'command-failed', stepId: entry.step.stepId, chapterId: entry.chapterId, error: error });
415
+ }
416
+ }
417
+ }
418
+ /** Evaluates a predicate; a throwing one becomes a debuggable `DYFM-ONBOARDING-PREDICATE` (step + predicate named). */
419
+ predicate(ref, context, stepId) {
420
+ try {
421
+ return this.capabilities.predicates[ref.name](context, ref.args) === true;
422
+ }
423
+ catch (error) {
424
+ throw new DyFM_Error({
425
+ message: `DyFM_GuidedOnboarding: predicate '${ref.name}' of step '${stepId}' threw`,
426
+ errorCode: 'DYFM-ONBOARDING-PREDICATE',
427
+ issuerService: ISSUER,
428
+ error: error,
429
+ });
430
+ }
431
+ }
432
+ async transition(run) {
433
+ this.transitioning = true;
434
+ try {
435
+ await run();
436
+ }
437
+ finally {
438
+ this.transitioning = false;
439
+ }
440
+ }
441
+ activeStep(operation) {
442
+ if (this.status !== 'active' || this.currentIndex < 0) {
443
+ throw new DyFM_Error({
444
+ message: `DyFM_GuidedOnboarding: '${operation}' needs an active onboarding (status: ${this.status})`,
445
+ errorCode: 'DYFM-ONBOARDING-INACTIVE',
446
+ issuerService: ISSUER,
447
+ });
448
+ }
449
+ this.assertResumed();
450
+ return this.steps[this.currentIndex].step;
451
+ }
452
+ assertResumed() {
453
+ if (this.pendingResume) {
454
+ throw new DyFM_Error({
455
+ message: 'DyFM_GuidedOnboarding: the imported onboarding is active but not resumed — call resume(context) first',
456
+ errorCode: 'DYFM-ONBOARDING-NOT-RESUMED',
457
+ issuerService: ISSUER,
458
+ });
459
+ }
460
+ }
461
+ isDone(index) {
462
+ const id = this.steps[index].step.stepId;
463
+ return this.completed.has(id) || this.skipped.has(id);
464
+ }
465
+ indexOf(stepId, required = true) {
466
+ const index = this.steps.findIndex((s) => s.step.stepId === stepId);
467
+ if (index < 0 && required) {
468
+ throw new DyFM_Error({
469
+ message: `DyFM_GuidedOnboarding: unknown step '${stepId}'`,
470
+ errorCode: 'DYFM-ONBOARDING-UNKNOWN-STEP',
471
+ issuerService: ISSUER,
472
+ });
473
+ }
474
+ return index;
475
+ }
476
+ emit(event) {
477
+ for (const listener of [...this.listeners]) {
478
+ try {
479
+ listener(event);
480
+ }
481
+ catch (error) {
482
+ DyFM_Log.error(`${ISSUER}: an onEvent listener threw on '${event.type}'`, error);
483
+ }
484
+ }
485
+ }
486
+ }
@@ -84,6 +84,19 @@ export class DyFM_InputActionRegistry_ControlModel {
84
84
  const chosen = best.slot;
85
85
  return { actionId: chosen.actionId, slotId: chosen.slotId, payload: chosen.payload };
86
86
  }
87
+ /** Every slot, in definition order (for a remap screen). */
88
+ listSlots() {
89
+ return this.slots.map((slot) => ({
90
+ slotId: slot.slotId,
91
+ actionId: slot.actionId,
92
+ label: this.action(slot.actionId).label,
93
+ chord: { ...slot.chord },
94
+ defaultChord: { ...slot.defaultChord },
95
+ isDefault: DyFM_InputActionRegistry_ControlModel.formatChord(slot.chord)
96
+ === DyFM_InputActionRegistry_ControlModel.formatChord(slot.defaultChord),
97
+ payload: slot.payload,
98
+ }));
99
+ }
87
100
  /** The current chord of a slot. */
88
101
  chordOf(slotId) {
89
102
  const slot = this.slot(slotId);
@@ -22,6 +22,8 @@ export * from './_models/viewport.interface';
22
22
  export * from './_models/viewport.control-model';
23
23
  export * from './_models/input-action.interface';
24
24
  export * from './_models/input-action-registry.control-model';
25
+ export * from './_models/guided-onboarding.interface';
26
+ export * from './_models/guided-onboarding.control-model';
25
27
  // COLLECTIONS
26
28
  export * from './_collections/audio-mixer.util';
27
29
  export * from './_collections/audio-scale.util';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@futdevpro/fsm-dynamo",
3
- "version": "1.20.101",
3
+ "version": "1.20.103",
4
4
  "description": "Full Stack Model Collection for Dynamic (NodeJS-Typescript) Framework called Dynamo, by Future Development Ltd.",
5
5
  "DyBu_settings": {
6
6
  "packageType": "full-stack-package",