@futdevpro/fsm-dynamo 1.20.95 → 1.20.99

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 (53) hide show
  1. package/build/_modules/game/_collections/build-reservation.util.d.ts +31 -0
  2. package/build/_modules/game/_collections/build-reservation.util.d.ts.map +1 -0
  3. package/build/_modules/game/_collections/build-reservation.util.js +77 -0
  4. package/build/_modules/game/_collections/build-reservation.util.js.map +1 -0
  5. package/build/_modules/game/_collections/game-save.util.d.ts +46 -0
  6. package/build/_modules/game/_collections/game-save.util.d.ts.map +1 -0
  7. package/build/_modules/game/_collections/game-save.util.js +189 -0
  8. package/build/_modules/game/_collections/game-save.util.js.map +1 -0
  9. package/build/_modules/game/_models/build-reservation.interface.d.ts +70 -0
  10. package/build/_modules/game/_models/build-reservation.interface.d.ts.map +1 -0
  11. package/build/_modules/game/_models/build-reservation.interface.js +7 -0
  12. package/build/_modules/game/_models/build-reservation.interface.js.map +1 -0
  13. package/build/_modules/game/_models/flying-effect.control-model.d.ts +7 -0
  14. package/build/_modules/game/_models/flying-effect.control-model.d.ts.map +1 -1
  15. package/build/_modules/game/_models/flying-effect.control-model.js +12 -1
  16. package/build/_modules/game/_models/flying-effect.control-model.js.map +1 -1
  17. package/build/_modules/game/_models/flying-effect.interface.d.ts +3 -0
  18. package/build/_modules/game/_models/flying-effect.interface.d.ts.map +1 -1
  19. package/build/_modules/game/_models/game-save.interface.d.ts +76 -0
  20. package/build/_modules/game/_models/game-save.interface.d.ts.map +1 -0
  21. package/build/_modules/game/_models/game-save.interface.js +7 -0
  22. package/build/_modules/game/_models/game-save.interface.js.map +1 -0
  23. package/build/_modules/game/_models/simulation-clock.control-model.d.ts +39 -0
  24. package/build/_modules/game/_models/simulation-clock.control-model.d.ts.map +1 -0
  25. package/build/_modules/game/_models/simulation-clock.control-model.js +122 -0
  26. package/build/_modules/game/_models/simulation-clock.control-model.js.map +1 -0
  27. package/build/_modules/game/_models/simulation-clock.interface.d.ts +44 -0
  28. package/build/_modules/game/_models/simulation-clock.interface.d.ts.map +1 -0
  29. package/build/_modules/game/_models/simulation-clock.interface.js +3 -0
  30. package/build/_modules/game/_models/simulation-clock.interface.js.map +1 -0
  31. package/build/_modules/game/_models/work-order-scheduler.control-model.d.ts +55 -0
  32. package/build/_modules/game/_models/work-order-scheduler.control-model.d.ts.map +1 -0
  33. package/build/_modules/game/_models/work-order-scheduler.control-model.js +201 -0
  34. package/build/_modules/game/_models/work-order-scheduler.control-model.js.map +1 -0
  35. package/build/_modules/game/_models/work-order-scheduler.interface.d.ts +43 -0
  36. package/build/_modules/game/_models/work-order-scheduler.interface.d.ts.map +1 -0
  37. package/build/_modules/game/_models/work-order-scheduler.interface.js +3 -0
  38. package/build/_modules/game/_models/work-order-scheduler.interface.js.map +1 -0
  39. package/build/_modules/game/index.d.ts +8 -0
  40. package/build/_modules/game/index.d.ts.map +1 -1
  41. package/build/_modules/game/index.js +8 -0
  42. package/build/_modules/game/index.js.map +1 -1
  43. package/build-esm/_modules/game/_collections/build-reservation.util.js +72 -0
  44. package/build-esm/_modules/game/_collections/game-save.util.js +184 -0
  45. package/build-esm/_modules/game/_models/build-reservation.interface.js +5 -0
  46. package/build-esm/_modules/game/_models/flying-effect.control-model.js +12 -1
  47. package/build-esm/_modules/game/_models/game-save.interface.js +5 -0
  48. package/build-esm/_modules/game/_models/simulation-clock.control-model.js +117 -0
  49. package/build-esm/_modules/game/_models/simulation-clock.interface.js +1 -0
  50. package/build-esm/_modules/game/_models/work-order-scheduler.control-model.js +196 -0
  51. package/build-esm/_modules/game/_models/work-order-scheduler.interface.js +1 -0
  52. package/build-esm/_modules/game/index.js +8 -0
  53. package/package.json +1 -1
@@ -0,0 +1,43 @@
1
+ /** BFR-WARFACTORY-012 — `DyFM_WorkOrderScheduler_ControlModel` configuration. */
2
+ export interface DyFM_WorkOrderScheduler_Config {
3
+ /** Work units one worker does per simulation second. Default: `1`. */
4
+ workPerWorkerPerSecond?: number;
5
+ }
6
+ /** One work order (FIFO priority = insertion order). */
7
+ export interface DyFM_WorkOrder_State {
8
+ orderId: string;
9
+ /** Total work units needed. */
10
+ work: number;
11
+ /** Work units done so far. */
12
+ progress: number;
13
+ }
14
+ /** The save/load snapshot (versioned). */
15
+ export interface DyFM_WorkOrderScheduler_State {
16
+ schemaVersion: 1;
17
+ orders: DyFM_WorkOrder_State[];
18
+ workerIds: string[];
19
+ /** workerId → orderId. On import, stale entries are healed (dropped), never trusted. */
20
+ assignments: Record<string, string>;
21
+ }
22
+ /** What `importState` repaired — stale ownership is reported, not silently dropped. */
23
+ export interface DyFM_WorkOrderScheduler_HealReport {
24
+ /** Assignments whose worker no longer exists. */
25
+ droppedUnknownWorkers: string[];
26
+ /** Assignments pointing at an order that no longer exists (or is complete). */
27
+ droppedUnknownOrders: string[];
28
+ /** Duplicate / invalid order or worker entries skipped. */
29
+ skippedDuplicates: string[];
30
+ }
31
+ /** What an `advance` completed. */
32
+ export interface DyFM_WorkOrderScheduler_AdvanceResult {
33
+ /** Orders that reached their work this step (in FIFO order). Their workers were released. */
34
+ completedOrderIds: string[];
35
+ }
36
+ /** What a `cancel` released — atomically, with the progress so the game can refund proportionally. */
37
+ export interface DyFM_WorkOrderScheduler_CancelResult {
38
+ found: boolean;
39
+ releasedWorkerIds: string[];
40
+ /** Progress ratio at cancel time (0..1) — e.g. the base of a refund percentage. */
41
+ progressRatio: number;
42
+ }
43
+ //# sourceMappingURL=work-order-scheduler.interface.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"work-order-scheduler.interface.d.ts","sourceRoot":"","sources":["../../../../src/_modules/game/_models/work-order-scheduler.interface.ts"],"names":[],"mappings":"AAAA,iFAAiF;AACjF,MAAM,WAAW,8BAA8B;IAC7C,sEAAsE;IACtE,sBAAsB,CAAC,EAAE,MAAM,CAAC;CACjC;AAED,wDAAwD;AACxD,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,+BAA+B;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,8BAA8B;IAC9B,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,0CAA0C;AAC1C,MAAM,WAAW,6BAA6B;IAC5C,aAAa,EAAE,CAAC,CAAC;IACjB,MAAM,EAAE,oBAAoB,EAAE,CAAC;IAC/B,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,wFAAwF;IACxF,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACrC;AAED,uFAAuF;AACvF,MAAM,WAAW,kCAAkC;IACjD,iDAAiD;IACjD,qBAAqB,EAAE,MAAM,EAAE,CAAC;IAChC,+EAA+E;IAC/E,oBAAoB,EAAE,MAAM,EAAE,CAAC;IAC/B,2DAA2D;IAC3D,iBAAiB,EAAE,MAAM,EAAE,CAAC;CAC7B;AAED,mCAAmC;AACnC,MAAM,WAAW,qCAAqC;IACpD,6FAA6F;IAC7F,iBAAiB,EAAE,MAAM,EAAE,CAAC;CAC7B;AAED,sGAAsG;AACtG,MAAM,WAAW,oCAAoC;IACnD,KAAK,EAAE,OAAO,CAAC;IACf,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B,mFAAmF;IACnF,aAAa,EAAE,MAAM,CAAC;CACvB"}
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ //# sourceMappingURL=work-order-scheduler.interface.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"work-order-scheduler.interface.js","sourceRoot":"","sources":["../../../../src/_modules/game/_models/work-order-scheduler.interface.ts"],"names":[],"mappings":""}
@@ -10,6 +10,14 @@ export * from './_models/seeded-random-registry.interface';
10
10
  export * from './_models/seeded-random-registry.control-model';
11
11
  export * from './_models/flying-effect.interface';
12
12
  export * from './_models/flying-effect.control-model';
13
+ export * from './_models/simulation-clock.interface';
14
+ export * from './_models/simulation-clock.control-model';
15
+ export * from './_models/game-save.interface';
16
+ export * from './_models/work-order-scheduler.interface';
17
+ export * from './_models/work-order-scheduler.control-model';
18
+ export * from './_models/build-reservation.interface';
13
19
  export * from './_collections/audio-mixer.util';
14
20
  export * from './_collections/audio-scale.util';
21
+ export * from './_collections/game-save.util';
22
+ export * from './_collections/build-reservation.util';
15
23
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/_modules/game/index.ts"],"names":[],"mappings":"AACA,cAAc,6BAA6B,CAAC;AAG5C,cAAc,wCAAwC,CAAC;AACvD,cAAc,0CAA0C,CAAC;AACzD,cAAc,uCAAuC,CAAC;AACtD,cAAc,qCAAqC,CAAC;AACpD,cAAc,iCAAiC,CAAC;AAChD,cAAc,yCAAyC,CAAC;AACxD,cAAc,uCAAuC,CAAC;AACtD,cAAc,4CAA4C,CAAC;AAC3D,cAAc,gDAAgD,CAAC;AAC/D,cAAc,mCAAmC,CAAC;AAClD,cAAc,uCAAuC,CAAC;AAGtD,cAAc,iCAAiC,CAAC;AAChD,cAAc,iCAAiC,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/_modules/game/index.ts"],"names":[],"mappings":"AACA,cAAc,6BAA6B,CAAC;AAG5C,cAAc,wCAAwC,CAAC;AACvD,cAAc,0CAA0C,CAAC;AACzD,cAAc,uCAAuC,CAAC;AACtD,cAAc,qCAAqC,CAAC;AACpD,cAAc,iCAAiC,CAAC;AAChD,cAAc,yCAAyC,CAAC;AACxD,cAAc,uCAAuC,CAAC;AACtD,cAAc,4CAA4C,CAAC;AAC3D,cAAc,gDAAgD,CAAC;AAC/D,cAAc,mCAAmC,CAAC;AAClD,cAAc,uCAAuC,CAAC;AACtD,cAAc,sCAAsC,CAAC;AACrD,cAAc,0CAA0C,CAAC;AACzD,cAAc,+BAA+B,CAAC;AAC9C,cAAc,0CAA0C,CAAC;AACzD,cAAc,8CAA8C,CAAC;AAC7D,cAAc,uCAAuC,CAAC;AAGtD,cAAc,iCAAiC,CAAC;AAChD,cAAc,iCAAiC,CAAC;AAChD,cAAc,+BAA+B,CAAC;AAC9C,cAAc,uCAAuC,CAAC"}
@@ -15,7 +15,15 @@ tslib_1.__exportStar(require("./_models/seeded-random-registry.interface"), expo
15
15
  tslib_1.__exportStar(require("./_models/seeded-random-registry.control-model"), exports);
16
16
  tslib_1.__exportStar(require("./_models/flying-effect.interface"), exports);
17
17
  tslib_1.__exportStar(require("./_models/flying-effect.control-model"), exports);
18
+ tslib_1.__exportStar(require("./_models/simulation-clock.interface"), exports);
19
+ tslib_1.__exportStar(require("./_models/simulation-clock.control-model"), exports);
20
+ tslib_1.__exportStar(require("./_models/game-save.interface"), exports);
21
+ tslib_1.__exportStar(require("./_models/work-order-scheduler.interface"), exports);
22
+ tslib_1.__exportStar(require("./_models/work-order-scheduler.control-model"), exports);
23
+ tslib_1.__exportStar(require("./_models/build-reservation.interface"), exports);
18
24
  // COLLECTIONS
19
25
  tslib_1.__exportStar(require("./_collections/audio-mixer.util"), exports);
20
26
  tslib_1.__exportStar(require("./_collections/audio-scale.util"), exports);
27
+ tslib_1.__exportStar(require("./_collections/game-save.util"), exports);
28
+ tslib_1.__exportStar(require("./_collections/build-reservation.util"), exports);
21
29
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/_modules/game/index.ts"],"names":[],"mappings":";;;AAAA,QAAQ;AACR,sEAA4C;AAE5C,SAAS;AACT,iFAAuD;AACvD,mFAAyD;AACzD,gFAAsD;AACtD,8EAAoD;AACpD,0EAAgD;AAChD,kFAAwD;AACxD,gFAAsD;AACtD,qFAA2D;AAC3D,yFAA+D;AAC/D,4EAAkD;AAClD,gFAAsD;AAEtD,cAAc;AACd,0EAAgD;AAChD,0EAAgD"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/_modules/game/index.ts"],"names":[],"mappings":";;;AAAA,QAAQ;AACR,sEAA4C;AAE5C,SAAS;AACT,iFAAuD;AACvD,mFAAyD;AACzD,gFAAsD;AACtD,8EAAoD;AACpD,0EAAgD;AAChD,kFAAwD;AACxD,gFAAsD;AACtD,qFAA2D;AAC3D,yFAA+D;AAC/D,4EAAkD;AAClD,gFAAsD;AACtD,+EAAqD;AACrD,mFAAyD;AACzD,wEAA8C;AAC9C,mFAAyD;AACzD,uFAA6D;AAC7D,gFAAsD;AAEtD,cAAc;AACd,0EAAgD;AAChD,0EAAgD;AAChD,wEAA8C;AAC9C,gFAAsD"}
@@ -0,0 +1,72 @@
1
+ import { DyFM_Error } from '../../../_models/control-models/error.control-model';
2
+ /**
3
+ * BFR-WARFACTORY-012 (①–②) — placement preview and escrow reservation, headless and pure (the caller owns the world
4
+ * state and applies the returned refund lines).
5
+ */
6
+ export class DyFM_BuildReservation_Util {
7
+ /** ① Evaluates EVERY rule and returns the preview with all failure reasons (not just the first). */
8
+ static preview(candidate, rules) {
9
+ const reasons = [];
10
+ if (!(candidate.footprint?.width > 0) || !(candidate.footprint?.height > 0)) {
11
+ reasons.push({ code: 'invalid-footprint', message: 'the footprint must be at least 1 × 1' });
12
+ }
13
+ for (const rule of rules) {
14
+ const reason = rule(candidate);
15
+ if (reason) {
16
+ reasons.push(reason);
17
+ }
18
+ }
19
+ return { position: candidate.position, footprint: { ...candidate.footprint }, valid: !reasons.length, reasons: reasons };
20
+ }
21
+ /**
22
+ * ② Holds the cost in escrow for a VALID preview. An invalid preview or a non-positive amount throws
23
+ * (`DYFM-RESERVATION-INVALID`) — reserving an invalid placement is how resources silently disappear.
24
+ */
25
+ static reserve(reservationId, preview, sources, policy = {}) {
26
+ const ratio = policy.cancelRefundRatio ?? 1;
27
+ if (!reservationId || !preview.valid
28
+ || sources.some((s) => !s.resource || !s.sourceId || !(s.amount > 0))
29
+ || !(ratio >= 0 && ratio <= 1)) {
30
+ throw new DyFM_Error({
31
+ message: 'DyFM_BuildReservation: reserve needs an id, a VALID preview, sources with resource + sourceId + '
32
+ + `amount > 0 and a refund ratio in 0..1 (reasons: ${preview.reasons.map((r) => r.code).join(', ') || '-'})`,
33
+ errorCode: 'DYFM-RESERVATION-INVALID',
34
+ issuerService: 'DyFM_BuildReservation_Util',
35
+ });
36
+ }
37
+ return {
38
+ reservationId: reservationId,
39
+ position: preview.position,
40
+ footprint: { ...preview.footprint },
41
+ reservedCostSources: sources.map((s) => ({ ...s })),
42
+ refundPolicy: { cancelRefundRatio: ratio },
43
+ status: 'held',
44
+ };
45
+ }
46
+ /** ② The build started / finished: the escrow is consumed. Idempotent on `committed`; a released one cannot commit. */
47
+ static commit(reservation) {
48
+ if (reservation.status === 'released') {
49
+ throw new DyFM_Error({
50
+ message: `DyFM_BuildReservation: ${reservation.reservationId} was already released — it cannot be committed`,
51
+ errorCode: 'DYFM-RESERVATION-STATE',
52
+ issuerService: 'DyFM_BuildReservation_Util',
53
+ });
54
+ }
55
+ return { ...reservation, status: 'committed' };
56
+ }
57
+ /**
58
+ * ② Cancel: returns the refund lines BACK TO THEIR ORIGINAL SOURCES — `cancelRefundRatio × (1 − progressRatio)`
59
+ * of each line (floored to whole units). A second release returns nothing (no double refund).
60
+ */
61
+ static release(reservation, progressRatio = 0) {
62
+ if (reservation.status === 'released') {
63
+ return { reservation: reservation, refunds: [] };
64
+ }
65
+ const progress = Math.min(1, Math.max(0, Number.isFinite(progressRatio) ? progressRatio : 0));
66
+ const share = reservation.refundPolicy.cancelRefundRatio * (1 - progress);
67
+ const refunds = reservation.reservedCostSources
68
+ .map((s) => ({ resource: s.resource, sourceId: s.sourceId, amount: Math.floor(s.amount * share + 1e-9) }))
69
+ .filter((line) => line.amount > 0);
70
+ return { reservation: { ...reservation, status: 'released' }, refunds: refunds };
71
+ }
72
+ }
@@ -0,0 +1,184 @@
1
+ import { DyFM_Error } from '../../../_models/control-models/error.control-model';
2
+ /** The only envelope format this version reads and writes. */
3
+ export const DYFM_GAME_SAVE_FORMAT = 'dyfm-game-save/1';
4
+ /**
5
+ * BFR-WARFACTORY-011 — deterministic, headless game-save operations: seal (checksum), parse (corruption + format
6
+ * check), migrate (ordered replay with a typed failure) and conflict resolution (lineage first, then a time window).
7
+ * Nothing here reads a clock, a platform or a credential.
8
+ */
9
+ export class DyFM_GameSave_Util {
10
+ /**
11
+ * FNV-1a (32-bit, hex) over the CANONICAL JSON of the payload (object keys sorted) — the same payload always gives
12
+ * the same checksum, whatever the key order. A corruption / identity check, NOT a security signature.
13
+ */
14
+ static checksum(payload) {
15
+ const text = DyFM_GameSave_Util.canonicalJson(payload);
16
+ let hash = 0x811c9dc5;
17
+ for (let i = 0; i < text.length; i++) {
18
+ hash ^= text.charCodeAt(i);
19
+ hash = Math.imul(hash, 0x01000193) >>> 0;
20
+ }
21
+ return hash.toString(16).padStart(8, '0');
22
+ }
23
+ /** Builds an envelope with the format and the computed checksum. Invalid input throws (`DYFM-GAME-SAVE-SEAL`). */
24
+ static seal(input) {
25
+ if (!input?.gameId?.trim() || !input.slotId?.trim() || !DyFM_GameSave_Util.isVersion(input.schemaVersion)
26
+ || !Number.isFinite(input.savedAtMs) || input.payload === undefined) {
27
+ throw new DyFM_Error({
28
+ message: 'DyFM_GameSave: seal needs gameId, slotId, an integer schemaVersion ≥ 1, a finite savedAtMs and a payload',
29
+ errorCode: 'DYFM-GAME-SAVE-SEAL',
30
+ issuerService: 'DyFM_GameSave_Util',
31
+ });
32
+ }
33
+ const envelope = {
34
+ format: DYFM_GAME_SAVE_FORMAT,
35
+ gameId: input.gameId,
36
+ slotId: input.slotId,
37
+ schemaVersion: input.schemaVersion,
38
+ savedAtMs: input.savedAtMs,
39
+ checksum: DyFM_GameSave_Util.checksum(input.payload),
40
+ payload: input.payload,
41
+ };
42
+ if (input.parentChecksum) {
43
+ envelope.parentChecksum = input.parentChecksum;
44
+ }
45
+ if (input.label) {
46
+ envelope.label = input.label;
47
+ }
48
+ return envelope;
49
+ }
50
+ /** The metadata of an envelope (what `list` returns — no payload). */
51
+ static metadataOf(envelope) {
52
+ const { payload: _payload, ...metadata } = envelope;
53
+ return metadata;
54
+ }
55
+ /**
56
+ * Validates a RAW read (anything an adapter returned): shape, format, field types and the checksum. A corrupt or
57
+ * foreign save is a typed failure — never a half-loaded game.
58
+ */
59
+ static parse(raw) {
60
+ const fail = (code, message) => ({ ok: false, code: code, message: message, appliedSteps: [] });
61
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
62
+ return fail('not-an-object', 'the save is not an object');
63
+ }
64
+ const envelope = raw;
65
+ if (envelope.format !== DYFM_GAME_SAVE_FORMAT) {
66
+ return fail('unknown-format', `unknown save format: ${String(envelope.format)}`);
67
+ }
68
+ if (typeof envelope.gameId !== 'string' || typeof envelope.slotId !== 'string'
69
+ || !DyFM_GameSave_Util.isVersion(envelope.schemaVersion) || !Number.isFinite(envelope.savedAtMs)
70
+ || typeof envelope.checksum !== 'string' || !('payload' in envelope)) {
71
+ return fail('invalid-field', 'the save is missing or has an invalid gameId / slotId / schemaVersion / savedAtMs / '
72
+ + 'checksum / payload');
73
+ }
74
+ if (DyFM_GameSave_Util.checksum(envelope.payload) !== envelope.checksum) {
75
+ return fail('checksum-mismatch', `the payload does not match its checksum (${envelope.checksum}) — corrupt save`);
76
+ }
77
+ return { ok: true, envelope: envelope, appliedSteps: [] };
78
+ }
79
+ /**
80
+ * Replays the migration steps from the envelope's `schemaVersion` up to `targetVersion`, step by step, on a COPY of
81
+ * the payload; the result is re-sealed (same slot, time and lineage). A save NEWER than the target, a gap in the
82
+ * chain or a throwing step is a typed failure and the input is untouched.
83
+ */
84
+ static migrate(envelope, steps, targetVersion) {
85
+ const applied = [];
86
+ const fail = (code, message) => ({ ok: false, code: code, message: message, appliedSteps: [...applied] });
87
+ if (steps.some((step) => !(step.to > step.from))) {
88
+ return fail('invalid-step', 'every migration step must go forward (to > from)');
89
+ }
90
+ if (envelope.schemaVersion > targetVersion) {
91
+ return fail('future-version', `save schema ${envelope.schemaVersion} is newer than this game (${targetVersion}) — `
92
+ + 'an older build must not load it');
93
+ }
94
+ let version = envelope.schemaVersion;
95
+ let payload = DyFM_GameSave_Util.clone(envelope.payload);
96
+ while (version < targetVersion) {
97
+ const step = steps.find((candidate) => candidate.from === version);
98
+ if (!step || step.to > targetVersion) {
99
+ return fail('missing-step', `no migration step from schema ${version} toward ${targetVersion}`);
100
+ }
101
+ try {
102
+ payload = step.migrate(payload);
103
+ }
104
+ catch (err) {
105
+ return fail('step-failed', `migration ${step.from}→${step.to} failed: ${err instanceof Error ? err.message : String(err)}`);
106
+ }
107
+ applied.push(`${step.from}→${step.to}`);
108
+ version = step.to;
109
+ }
110
+ const migrated = {
111
+ ...envelope,
112
+ schemaVersion: version,
113
+ payload: payload,
114
+ checksum: DyFM_GameSave_Util.checksum(payload),
115
+ };
116
+ return { ok: true, envelope: migrated, appliedSteps: applied };
117
+ }
118
+ /**
119
+ * Deterministic local ↔ remote decision for ONE slot:
120
+ * none / one side missing → the existing one · same checksum → `in-sync` · proven descent (one side's
121
+ * `parentChecksum` is the other's checksum) → the descendant · both carry lineage but neither descends from the
122
+ * other → `ask-user` (a true divergence: picking one silently LOSES progress) · no lineage (legacy saves) → within
123
+ * `ambiguityWindowMs` `ask-user`, otherwise the newer one.
124
+ */
125
+ static resolveConflict(local, remote, options = {}) {
126
+ if (!local && !remote) {
127
+ return { decision: 'none', reason: 'no save on either side' };
128
+ }
129
+ if (!remote) {
130
+ return { decision: 'use-local', reason: 'only the local save exists' };
131
+ }
132
+ if (!local) {
133
+ return { decision: 'use-remote', reason: 'only the remote save exists' };
134
+ }
135
+ if (local.gameId !== remote.gameId || local.slotId !== remote.slotId) {
136
+ throw new DyFM_Error({
137
+ message: `DyFM_GameSave: resolveConflict compares ONE slot — got ${local.gameId}/${local.slotId} vs `
138
+ + `${remote.gameId}/${remote.slotId}`,
139
+ errorCode: 'DYFM-GAME-SAVE-SLOT-MISMATCH',
140
+ issuerService: 'DyFM_GameSave_Util',
141
+ });
142
+ }
143
+ if (local.checksum === remote.checksum) {
144
+ return { decision: 'in-sync', reason: 'identical payloads' };
145
+ }
146
+ if (local.parentChecksum === remote.checksum) {
147
+ return { decision: 'use-local', reason: 'the local save was made from the remote one' };
148
+ }
149
+ if (remote.parentChecksum === local.checksum) {
150
+ return { decision: 'use-remote', reason: 'the remote save was made from the local one' };
151
+ }
152
+ if (local.parentChecksum && remote.parentChecksum) {
153
+ return { decision: 'ask-user', reason: 'both sides progressed separately (diverged) — either choice loses progress' };
154
+ }
155
+ const windowMs = options.ambiguityWindowMs ?? 30_000;
156
+ const gap = local.savedAtMs - remote.savedAtMs;
157
+ if (Math.abs(gap) <= windowMs) {
158
+ return { decision: 'ask-user', reason: `no lineage and saved within ${windowMs} ms of each other` };
159
+ }
160
+ return gap > 0
161
+ ? { decision: 'use-local', reason: 'no lineage; the local save is newer' }
162
+ : { decision: 'use-remote', reason: 'no lineage; the remote save is newer' };
163
+ }
164
+ static isVersion(value) {
165
+ return typeof value === 'number' && Number.isInteger(value) && value >= 1;
166
+ }
167
+ /** JSON with sorted object keys (arrays keep their order) — the checksum input. */
168
+ static canonicalJson(value) {
169
+ return JSON.stringify(value, (_key, item) => {
170
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
171
+ return Object.keys(item).sort()
172
+ .reduce((sorted, key) => {
173
+ sorted[key] = item[key];
174
+ return sorted;
175
+ }, {});
176
+ }
177
+ return item;
178
+ }) ?? 'undefined';
179
+ }
180
+ /** A deep copy for the migration replay (a step must not be able to change the caller's envelope). */
181
+ static clone(value) {
182
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
183
+ }
184
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * BFR-WARFACTORY-012 (scope extension ①–②) — placement preview + reservation with escrow. Game-specific resources
3
+ * (drones, a hammer, the player's position) stay OUT: they are the game's extension, not these models.
4
+ */
5
+ export {};
@@ -5,6 +5,7 @@ export class DyFM_FlyingEffect_ControlModel {
5
5
  defaultDurationMs;
6
6
  defaultRisePx;
7
7
  defaultStackGapPx;
8
+ reducedMotion;
8
9
  items = [];
9
10
  sequence = 0;
10
11
  paused = false;
@@ -20,6 +21,15 @@ export class DyFM_FlyingEffect_ControlModel {
20
21
  this.defaultDurationMs = this.positive(config.defaultDurationMs ?? 1_400, 'defaultDurationMs');
21
22
  this.defaultRisePx = this.nonNegative(config.defaultRisePx ?? 52, 'defaultRisePx');
22
23
  this.defaultStackGapPx = this.nonNegative(config.defaultStackGapPx ?? 18, 'defaultStackGapPx');
24
+ this.reducedMotion = config.reducedMotion === true;
25
+ }
26
+ /**
27
+ * BFR-WARFACTORY-006 — reduced motion (e.g. the player setting or `prefers-reduced-motion`): the text no longer RISES,
28
+ * it stays at its anchor + stack offset and only fades (a fade is not motion). Takes effect from the next snapshot —
29
+ * lifetimes, stacking and expiry are unchanged, so switching it mid-game is safe.
30
+ */
31
+ setReducedMotion(reducedMotion) {
32
+ this.reducedMotion = reducedMotion === true;
23
33
  }
24
34
  spawn(command, nowMs) {
25
35
  this.validateCommand(command);
@@ -129,7 +139,7 @@ export class DyFM_FlyingEffect_ControlModel {
129
139
  mergeKey: item.mergeKey,
130
140
  progress,
131
141
  opacity: 1 - progress,
132
- offsetY: -(item.risePx * progress + item.stackGapPx * stackIndex),
142
+ offsetY: -((this.reducedMotion ? 0 : item.risePx * progress) + item.stackGapPx * stackIndex),
133
143
  stackIndex,
134
144
  };
135
145
  });
@@ -143,6 +153,7 @@ export class DyFM_FlyingEffect_ControlModel {
143
153
  expiredCount: this.expiredCount,
144
154
  evictedCount: this.evictedCount,
145
155
  paused: this.paused,
156
+ reducedMotion: this.reducedMotion,
146
157
  lastTimeMs: this.lastTimeMs,
147
158
  };
148
159
  }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * BFR-WARFACTORY-011 — headless game-save contracts. No provider credential, platform binding or game payload shape
3
+ * lives here: the payload is opaque (`TPayload`, typically the serialized game state), adapters are the game's.
4
+ */
5
+ export {};
@@ -0,0 +1,117 @@
1
+ import { DyFM_Error } from '../../../_models/control-models/error.control-model';
2
+ /**
3
+ * BFR-WARFACTORY-010 — framework-independent, monotonic simulation clock.
4
+ *
5
+ * - The CALLER supplies real delta, speed and pause; the clock never reads wall time (deterministic, replayable).
6
+ * - Fixed-step mode (`fixedStepSeconds`): scaled time goes into an accumulator, whole steps are committed and the
7
+ * remainder carries over — at speed 10 the loop runs ten times as many steps of the same size, so fast-forward is
8
+ * behaviour-neutral. Variable mode (no `fixedStepSeconds`): one step of `realDelta * speed` (not neutral).
9
+ * - Real deltas are clamped (`maxRealDeltaSeconds`) and fixed steps capped (`maxStepsPerAdvance`); what is cut or
10
+ * dropped is reported, never silently lost.
11
+ * - `exportState` / `importState` (versioned; the legacy `{ elapsedSimulationSeconds }` shape is accepted) / `reset`.
12
+ * An invalid snapshot is rejected WITHOUT touching the running clock.
13
+ */
14
+ export class DyFM_SimulationClock_ControlModel {
15
+ fixedStepSeconds;
16
+ maxRealDeltaSeconds;
17
+ maxStepsPerAdvance;
18
+ elapsedSeconds = 0;
19
+ accumulatorSeconds = 0;
20
+ /** Creates the clock; an invalid configuration throws (`DYFM-SIM-CLOCK-CONFIG`). */
21
+ constructor(config = {}) {
22
+ this.fixedStepSeconds = config.fixedStepSeconds === undefined
23
+ ? undefined
24
+ : DyFM_SimulationClock_ControlModel.positive(config.fixedStepSeconds, 'fixedStepSeconds');
25
+ this.maxRealDeltaSeconds =
26
+ DyFM_SimulationClock_ControlModel.positive(config.maxRealDeltaSeconds ?? 0.25, 'maxRealDeltaSeconds');
27
+ this.maxStepsPerAdvance = DyFM_SimulationClock_ControlModel.positive(config.maxStepsPerAdvance ?? 240, 'maxStepsPerAdvance');
28
+ if (!Number.isInteger(this.maxStepsPerAdvance)) {
29
+ throw DyFM_SimulationClock_ControlModel.configError('maxStepsPerAdvance must be an integer');
30
+ }
31
+ }
32
+ /** Total committed simulation seconds. */
33
+ now() {
34
+ return this.elapsedSeconds;
35
+ }
36
+ /** Advances by one real frame. Paused, speed ≤ 0 or a non-finite input ⇒ nothing passes (a zero result). */
37
+ advance(input) {
38
+ const idle = {
39
+ simulationDeltaSeconds: 0, steps: 0, stepSeconds: this.fixedStepSeconds ?? 0,
40
+ clampedRealSeconds: 0, droppedSimulationSeconds: 0,
41
+ };
42
+ if (input.paused || !Number.isFinite(input.realDeltaSeconds) || !Number.isFinite(input.speed)
43
+ || input.speed <= 0 || input.realDeltaSeconds <= 0) {
44
+ return idle;
45
+ }
46
+ const realSeconds = Math.min(input.realDeltaSeconds, this.maxRealDeltaSeconds);
47
+ const clampedRealSeconds = input.realDeltaSeconds - realSeconds;
48
+ const scaledSeconds = realSeconds * input.speed;
49
+ if (this.fixedStepSeconds === undefined) {
50
+ // Variable step: one update of the scaled delta (documented as NOT behaviour-neutral across speeds)
51
+ this.elapsedSeconds += scaledSeconds;
52
+ return {
53
+ simulationDeltaSeconds: scaledSeconds, steps: 1, stepSeconds: scaledSeconds,
54
+ clampedRealSeconds: clampedRealSeconds, droppedSimulationSeconds: 0,
55
+ };
56
+ }
57
+ // Fixed step: commit whole steps, carry the remainder, cap the burst (spiral-of-death guard)
58
+ this.accumulatorSeconds += scaledSeconds;
59
+ // the epsilon keeps 3 × (1/60) from rounding down to 2 steps
60
+ const available = Math.floor(this.accumulatorSeconds / this.fixedStepSeconds + 1e-9);
61
+ const steps = Math.min(available, this.maxStepsPerAdvance);
62
+ let droppedSimulationSeconds = 0;
63
+ this.accumulatorSeconds = Math.max(0, this.accumulatorSeconds - available * this.fixedStepSeconds);
64
+ if (available > steps) {
65
+ droppedSimulationSeconds = (available - steps) * this.fixedStepSeconds;
66
+ }
67
+ this.elapsedSeconds += steps * this.fixedStepSeconds;
68
+ return {
69
+ simulationDeltaSeconds: steps * this.fixedStepSeconds, steps: steps, stepSeconds: this.fixedStepSeconds,
70
+ clampedRealSeconds: clampedRealSeconds, droppedSimulationSeconds: droppedSimulationSeconds,
71
+ };
72
+ }
73
+ /** The save/load snapshot. */
74
+ exportState() {
75
+ return { schemaVersion: 1, elapsedSimulationSeconds: this.elapsedSeconds, accumulatorSeconds: this.accumulatorSeconds };
76
+ }
77
+ /**
78
+ * Restores a snapshot; returns `false` (and changes NOTHING) when it is invalid. Accepts the legacy
79
+ * `{ elapsedSimulationSeconds }` shape (no `schemaVersion`) as an empty accumulator.
80
+ */
81
+ importState(snapshot) {
82
+ if (!snapshot || typeof snapshot !== 'object') {
83
+ return false;
84
+ }
85
+ const state = snapshot;
86
+ const accumulator = state.schemaVersion === undefined ? 0 : state.accumulatorSeconds;
87
+ if ((state.schemaVersion !== undefined && state.schemaVersion !== 1)
88
+ || !DyFM_SimulationClock_ControlModel.isNonNegative(state.elapsedSimulationSeconds)
89
+ || !DyFM_SimulationClock_ControlModel.isNonNegative(accumulator)) {
90
+ return false;
91
+ }
92
+ this.elapsedSeconds = state.elapsedSimulationSeconds;
93
+ this.accumulatorSeconds = accumulator;
94
+ return true;
95
+ }
96
+ /** A new run: zero elapsed time and accumulator. */
97
+ reset() {
98
+ this.elapsedSeconds = 0;
99
+ this.accumulatorSeconds = 0;
100
+ }
101
+ static isNonNegative(value) {
102
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0;
103
+ }
104
+ static positive(value, field) {
105
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
106
+ throw DyFM_SimulationClock_ControlModel.configError(`${field} must be a finite number > 0 (got ${value})`);
107
+ }
108
+ return value;
109
+ }
110
+ static configError(message) {
111
+ return new DyFM_Error({
112
+ message: `DyFM_SimulationClock: ${message}`,
113
+ errorCode: 'DYFM-SIM-CLOCK-CONFIG',
114
+ issuerService: 'DyFM_SimulationClock_ControlModel',
115
+ });
116
+ }
117
+ }