@futdevpro/fsm-dynamo 1.20.97 → 1.20.100

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 (46) 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/game-save.interface.d.ts +76 -0
  14. package/build/_modules/game/_models/game-save.interface.d.ts.map +1 -0
  15. package/build/_modules/game/_models/game-save.interface.js +7 -0
  16. package/build/_modules/game/_models/game-save.interface.js.map +1 -0
  17. package/build/_modules/game/_models/viewport.control-model.d.ts +58 -0
  18. package/build/_modules/game/_models/viewport.control-model.d.ts.map +1 -0
  19. package/build/_modules/game/_models/viewport.control-model.js +167 -0
  20. package/build/_modules/game/_models/viewport.control-model.js.map +1 -0
  21. package/build/_modules/game/_models/viewport.interface.d.ts +35 -0
  22. package/build/_modules/game/_models/viewport.interface.d.ts.map +1 -0
  23. package/build/_modules/game/_models/viewport.interface.js +3 -0
  24. package/build/_modules/game/_models/viewport.interface.js.map +1 -0
  25. package/build/_modules/game/_models/work-order-scheduler.control-model.d.ts +55 -0
  26. package/build/_modules/game/_models/work-order-scheduler.control-model.d.ts.map +1 -0
  27. package/build/_modules/game/_models/work-order-scheduler.control-model.js +201 -0
  28. package/build/_modules/game/_models/work-order-scheduler.control-model.js.map +1 -0
  29. package/build/_modules/game/_models/work-order-scheduler.interface.d.ts +43 -0
  30. package/build/_modules/game/_models/work-order-scheduler.interface.d.ts.map +1 -0
  31. package/build/_modules/game/_models/work-order-scheduler.interface.js +3 -0
  32. package/build/_modules/game/_models/work-order-scheduler.interface.js.map +1 -0
  33. package/build/_modules/game/index.d.ts +8 -0
  34. package/build/_modules/game/index.d.ts.map +1 -1
  35. package/build/_modules/game/index.js +8 -0
  36. package/build/_modules/game/index.js.map +1 -1
  37. package/build-esm/_modules/game/_collections/build-reservation.util.js +72 -0
  38. package/build-esm/_modules/game/_collections/game-save.util.js +184 -0
  39. package/build-esm/_modules/game/_models/build-reservation.interface.js +5 -0
  40. package/build-esm/_modules/game/_models/game-save.interface.js +5 -0
  41. package/build-esm/_modules/game/_models/viewport.control-model.js +162 -0
  42. package/build-esm/_modules/game/_models/viewport.interface.js +1 -0
  43. package/build-esm/_modules/game/_models/work-order-scheduler.control-model.js +196 -0
  44. package/build-esm/_modules/game/_models/work-order-scheduler.interface.js +1 -0
  45. package/build-esm/_modules/game/index.js +8 -0
  46. package/package.json +1 -1
@@ -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 {};
@@ -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,162 @@
1
+ import { DyFM_Error } from '../../../_models/control-models/error.control-model';
2
+ /**
3
+ * BFR-WARFACTORY-013 — framework-independent zoom / pan viewport. It is deliberately NOT "the camera": the same model
4
+ * drives a world canvas and a DOM panel (e.g. a zoomable tech tree). Input handling (pointer, wheel, keys) belongs to
5
+ * a UI adapter that calls these methods.
6
+ *
7
+ * - `worldToScreen` / `screenToWorld` — the only transform; the screen centre shows `center`.
8
+ * - `zoomAt(screenPoint, factor)` — the world point UNDER the pointer stays under the pointer (wheel / pinch feel).
9
+ * - `panByScreen(dx, dy)` — drag: the content follows the pointer at any zoom.
10
+ * - `move(direction, speed, elapsedSeconds)` — elapsed-time keyboard panning; each step is capped
11
+ * (`maxMoveStepSeconds`), so a hitch never makes the camera jump; speed is in SCREEN px/s (same feel at any zoom).
12
+ * - Zoom is clamped to [minZoom, maxZoom], the centre to `worldBounds`; non-finite input is ignored, never stored.
13
+ */
14
+ export class DyFM_Viewport_ControlModel {
15
+ screenWidth;
16
+ screenHeight;
17
+ minZoom;
18
+ maxZoom;
19
+ bounds;
20
+ stepFactor;
21
+ maxMoveStep;
22
+ centerPoint = { x: 0, y: 0 };
23
+ zoomValue = 1;
24
+ /** Creates the viewport; an invalid configuration throws `DYFM-VIEWPORT-CONFIG`. */
25
+ constructor(config) {
26
+ this.minZoom = config.minZoom ?? 0.25;
27
+ this.maxZoom = config.maxZoom ?? 4;
28
+ this.stepFactor = config.zoomStepFactor ?? 1.1;
29
+ this.maxMoveStep = config.maxMoveStepSeconds ?? 0.1;
30
+ this.bounds = config.worldBounds;
31
+ const positive = (value) => Number.isFinite(value) && value > 0;
32
+ if (!positive(config.screenWidth) || !positive(config.screenHeight) || !positive(this.minZoom)
33
+ || !(this.maxZoom >= this.minZoom) || !(this.stepFactor > 1) || !positive(this.maxMoveStep)
34
+ || (this.bounds && !(this.bounds.maxX >= this.bounds.minX && this.bounds.maxY >= this.bounds.minY))) {
35
+ throw new DyFM_Error({
36
+ message: 'DyFM_Viewport: needs a positive screen size, 0 < minZoom ≤ maxZoom, zoomStepFactor > 1, '
37
+ + 'maxMoveStepSeconds > 0 and ordered worldBounds',
38
+ errorCode: 'DYFM-VIEWPORT-CONFIG',
39
+ issuerService: 'DyFM_Viewport_ControlModel',
40
+ });
41
+ }
42
+ this.screenWidth = config.screenWidth;
43
+ this.screenHeight = config.screenHeight;
44
+ this.zoomValue = this.clampZoom(1);
45
+ this.centerPoint = this.clampCenter({ x: 0, y: 0 });
46
+ }
47
+ /** The world point shown at the screen centre. */
48
+ get center() {
49
+ return { ...this.centerPoint };
50
+ }
51
+ /** Screen pixels per world unit. */
52
+ get zoom() {
53
+ return this.zoomValue;
54
+ }
55
+ /** World → screen pixels. */
56
+ worldToScreen(world) {
57
+ return {
58
+ x: (world.x - this.centerPoint.x) * this.zoomValue + this.screenWidth / 2,
59
+ y: (world.y - this.centerPoint.y) * this.zoomValue + this.screenHeight / 2,
60
+ };
61
+ }
62
+ /** Screen pixels → world. */
63
+ screenToWorld(screen) {
64
+ return {
65
+ x: (screen.x - this.screenWidth / 2) / this.zoomValue + this.centerPoint.x,
66
+ y: (screen.y - this.screenHeight / 2) / this.zoomValue + this.centerPoint.y,
67
+ };
68
+ }
69
+ /** Sets the zoom (clamped), keeping the centre. */
70
+ setZoom(zoom) {
71
+ if (Number.isFinite(zoom)) {
72
+ this.zoomValue = this.clampZoom(zoom);
73
+ }
74
+ }
75
+ /** Zooms by `factor` keeping the world point under `screenPoint` fixed on screen (then clamps). */
76
+ zoomAt(screenPoint, factor) {
77
+ if (!Number.isFinite(factor) || factor <= 0 || !Number.isFinite(screenPoint?.x) || !Number.isFinite(screenPoint?.y)) {
78
+ return;
79
+ }
80
+ const anchor = this.screenToWorld(screenPoint);
81
+ this.zoomValue = this.clampZoom(this.zoomValue * factor);
82
+ // re-centre so that `anchor` maps back to `screenPoint`
83
+ this.centerPoint = this.clampCenter({
84
+ x: anchor.x - (screenPoint.x - this.screenWidth / 2) / this.zoomValue,
85
+ y: anchor.y - (screenPoint.y - this.screenHeight / 2) / this.zoomValue,
86
+ });
87
+ }
88
+ /** `steps` notches (+ in, − out) at `screenPoint` (default: the screen centre). */
89
+ zoomStep(steps, screenPoint) {
90
+ if (Number.isFinite(steps) && steps !== 0) {
91
+ this.zoomAt(screenPoint ?? { x: this.screenWidth / 2, y: this.screenHeight / 2 }, this.stepFactor ** steps);
92
+ }
93
+ }
94
+ /** Drag by a screen delta: the content moves WITH the pointer. */
95
+ panByScreen(dx, dy) {
96
+ if (Number.isFinite(dx) && Number.isFinite(dy)) {
97
+ this.centerPoint = this.clampCenter({
98
+ x: this.centerPoint.x - dx / this.zoomValue,
99
+ y: this.centerPoint.y - dy / this.zoomValue,
100
+ });
101
+ }
102
+ }
103
+ /**
104
+ * Elapsed-time panning (keyboard): `direction` is normalised, `speedScreenPxPerSecond` is screen speed, the elapsed
105
+ * time is capped at `maxMoveStepSeconds` per call. Returns the world distance actually moved.
106
+ */
107
+ move(direction, speedScreenPxPerSecond, elapsedSeconds) {
108
+ const length = Math.hypot(direction?.x ?? NaN, direction?.y ?? NaN);
109
+ if (!Number.isFinite(length) || length === 0 || !Number.isFinite(speedScreenPxPerSecond)
110
+ || speedScreenPxPerSecond <= 0 || !Number.isFinite(elapsedSeconds) || elapsedSeconds <= 0) {
111
+ return 0;
112
+ }
113
+ const seconds = Math.min(elapsedSeconds, this.maxMoveStep);
114
+ const worldDistance = speedScreenPxPerSecond * seconds / this.zoomValue;
115
+ const before = this.centerPoint;
116
+ this.centerPoint = this.clampCenter({
117
+ x: before.x + direction.x / length * worldDistance,
118
+ y: before.y + direction.y / length * worldDistance,
119
+ });
120
+ return Math.hypot(this.centerPoint.x - before.x, this.centerPoint.y - before.y);
121
+ }
122
+ /** Centres on a world point (clamped). */
123
+ centerOn(world) {
124
+ if (Number.isFinite(world?.x) && Number.isFinite(world?.y)) {
125
+ this.centerPoint = this.clampCenter(world);
126
+ }
127
+ }
128
+ /** The screen changed size (e.g. a window resize); the centre stays. */
129
+ resize(screenWidth, screenHeight) {
130
+ if (Number.isFinite(screenWidth) && screenWidth > 0 && Number.isFinite(screenHeight) && screenHeight > 0) {
131
+ this.screenWidth = screenWidth;
132
+ this.screenHeight = screenHeight;
133
+ }
134
+ }
135
+ /** Save state. */
136
+ exportState() {
137
+ return { schemaVersion: 1, center: this.center, zoom: this.zoomValue };
138
+ }
139
+ /** Restore state (clamped to THIS viewport's limits); `false` and no change when unreadable. */
140
+ importState(state) {
141
+ const candidate = state && typeof state === 'object' ? state : null;
142
+ if (!candidate || candidate.schemaVersion !== 1 || !Number.isFinite(candidate.zoom)
143
+ || !Number.isFinite(candidate.center?.x) || !Number.isFinite(candidate.center?.y)) {
144
+ return false;
145
+ }
146
+ this.zoomValue = this.clampZoom(candidate.zoom);
147
+ this.centerPoint = this.clampCenter(candidate.center);
148
+ return true;
149
+ }
150
+ clampZoom(zoom) {
151
+ return Math.min(this.maxZoom, Math.max(this.minZoom, zoom));
152
+ }
153
+ clampCenter(point) {
154
+ if (!this.bounds) {
155
+ return { x: point.x, y: point.y };
156
+ }
157
+ return {
158
+ x: Math.min(this.bounds.maxX, Math.max(this.bounds.minX, point.x)),
159
+ y: Math.min(this.bounds.maxY, Math.max(this.bounds.minY, point.y)),
160
+ };
161
+ }
162
+ }
@@ -0,0 +1,196 @@
1
+ import { DyFM_Error } from '../../../_models/control-models/error.control-model';
2
+ /**
3
+ * BFR-WARFACTORY-012 — headless, cancel-safe scheduler of interchangeable workers over parallel work orders.
4
+ *
5
+ * Guarantees (each pinned by a spec):
6
+ * - **Release is structural, not a happy-path step:** an assignment exists only as `worker → order`; cancelling an
7
+ * order, completing it or removing (destroying) a worker deletes the entry in the same call ⇒ no stranded worker.
8
+ * - **Every pending order gets ≥ 1 worker when possible:** idle workers first; if none is idle, one is moved from the
9
+ * order that has the most (only from an order with > 1) — so one plan cannot starve the others ("frozen" plans).
10
+ * - **Stable least-assigned distribution:** extra workers go to the order with the fewest (tie → the earliest order);
11
+ * existing assignments are not reshuffled.
12
+ * - **Self-healing import:** stale ownership (unknown worker / order, duplicates) is dropped and REPORTED; importing
13
+ * the same state twice gives the same result.
14
+ * No clock, no RNG: time comes in through `advance(simulationSeconds)` (e.g. from `DyFM_SimulationClock_ControlModel`).
15
+ */
16
+ export class DyFM_WorkOrderScheduler_ControlModel {
17
+ rate;
18
+ orders = [];
19
+ workerIds = [];
20
+ /** workerId → orderId (the ONLY ownership record). */
21
+ assignments = new Map();
22
+ /** Creates the scheduler; an invalid rate throws `DYFM-WORK-ORDER-CONFIG`. */
23
+ constructor(config = {}) {
24
+ const rate = config.workPerWorkerPerSecond ?? 1;
25
+ if (!Number.isFinite(rate) || rate <= 0) {
26
+ throw DyFM_WorkOrderScheduler_ControlModel.error('DYFM-WORK-ORDER-CONFIG', `workPerWorkerPerSecond must be > 0 (${rate})`);
27
+ }
28
+ this.rate = rate;
29
+ }
30
+ /** Adds a work order at the end of the FIFO; a duplicate id or non-positive work throws `DYFM-WORK-ORDER-INVALID`. */
31
+ addOrder(order) {
32
+ if (!order?.orderId || this.orders.some((o) => o.orderId === order.orderId)
33
+ || !Number.isFinite(order.work) || order.work <= 0) {
34
+ throw DyFM_WorkOrderScheduler_ControlModel.error('DYFM-WORK-ORDER-INVALID', `order needs a unique orderId and work > 0 (${order?.orderId})`);
35
+ }
36
+ this.orders.push({ orderId: order.orderId, work: order.work, progress: Math.max(0, order.progress ?? 0) });
37
+ this.rebalance();
38
+ }
39
+ /** Adds an (idle) worker; duplicates are ignored. */
40
+ addWorker(workerId) {
41
+ if (workerId && !this.workerIds.includes(workerId)) {
42
+ this.workerIds.push(workerId);
43
+ this.rebalance();
44
+ }
45
+ }
46
+ /** Removes (destroys) a worker; its assignment goes with it. Idempotent. */
47
+ removeWorker(workerId) {
48
+ this.workerIds = this.workerIds.filter((id) => id !== workerId);
49
+ this.assignments.delete(workerId);
50
+ this.rebalance();
51
+ }
52
+ /** Cancels an order: removes it and releases its workers in the same call. Idempotent (unknown → found: false). */
53
+ cancel(orderId) {
54
+ const order = this.orders.find((o) => o.orderId === orderId);
55
+ if (!order) {
56
+ return { found: false, releasedWorkerIds: [], progressRatio: 0 };
57
+ }
58
+ const released = this.workersOf(orderId);
59
+ this.orders = this.orders.filter((o) => o.orderId !== orderId);
60
+ released.forEach((workerId) => this.assignments.delete(workerId));
61
+ this.rebalance();
62
+ return { found: true, releasedWorkerIds: released, progressRatio: Math.min(1, order.progress / order.work) };
63
+ }
64
+ /** Advances every order by its workers × rate × seconds; completed orders leave and free their workers. */
65
+ advance(simulationSeconds) {
66
+ if (!Number.isFinite(simulationSeconds) || simulationSeconds <= 0) {
67
+ return { completedOrderIds: [] };
68
+ }
69
+ const completed = [];
70
+ for (const order of this.orders) {
71
+ order.progress += this.workersOf(order.orderId).length * this.rate * simulationSeconds;
72
+ if (order.progress >= order.work) {
73
+ completed.push(order.orderId);
74
+ }
75
+ }
76
+ if (completed.length) {
77
+ this.orders = this.orders.filter((o) => !completed.includes(o.orderId));
78
+ for (const [workerId, orderId] of [...this.assignments]) {
79
+ if (completed.includes(orderId)) {
80
+ this.assignments.delete(workerId);
81
+ }
82
+ }
83
+ this.rebalance();
84
+ }
85
+ return { completedOrderIds: completed };
86
+ }
87
+ /** The workers of an order, in assignment order. */
88
+ workersOf(orderId) {
89
+ return [...this.assignments].filter(([, id]) => id === orderId)
90
+ .map(([workerId]) => workerId);
91
+ }
92
+ /** The order a worker is on (`undefined` = idle). */
93
+ orderOf(workerId) {
94
+ return this.assignments.get(workerId);
95
+ }
96
+ /** Current orders (copies, FIFO). */
97
+ getOrders() {
98
+ return this.orders.map((o) => ({ ...o }));
99
+ }
100
+ /** The save/load snapshot. */
101
+ exportState() {
102
+ return {
103
+ schemaVersion: 1,
104
+ orders: this.getOrders(),
105
+ workerIds: [...this.workerIds],
106
+ assignments: Object.fromEntries(this.assignments),
107
+ };
108
+ }
109
+ /**
110
+ * Restores a snapshot and HEALS stale ownership (assignments to unknown workers / orders, duplicates): dropped and
111
+ * reported, then the distribution rules re-apply. An unreadable snapshot throws `DYFM-WORK-ORDER-STATE`.
112
+ */
113
+ importState(state) {
114
+ if (!state || state.schemaVersion !== 1 || !Array.isArray(state.orders) || !Array.isArray(state.workerIds)
115
+ || !state.assignments || typeof state.assignments !== 'object') {
116
+ throw DyFM_WorkOrderScheduler_ControlModel.error('DYFM-WORK-ORDER-STATE', 'unreadable scheduler snapshot');
117
+ }
118
+ const report = { droppedUnknownWorkers: [], droppedUnknownOrders: [], skippedDuplicates: [] };
119
+ const orders = [];
120
+ for (const order of state.orders) {
121
+ if (!order?.orderId || orders.some((o) => o.orderId === order.orderId)
122
+ || !Number.isFinite(order.work) || order.work <= 0) {
123
+ report.skippedDuplicates.push(String(order?.orderId));
124
+ continue;
125
+ }
126
+ if (Number(order.progress) >= order.work) {
127
+ continue; // a completed order is not "active" — its assignments heal below
128
+ }
129
+ orders.push({ orderId: order.orderId, work: order.work, progress: Math.max(0, Number(order.progress) || 0) });
130
+ }
131
+ const workerIds = [];
132
+ for (const workerId of state.workerIds) {
133
+ if (!workerId || workerIds.includes(workerId)) {
134
+ report.skippedDuplicates.push(String(workerId));
135
+ continue;
136
+ }
137
+ workerIds.push(workerId);
138
+ }
139
+ const assignments = new Map();
140
+ for (const [workerId, orderId] of Object.entries(state.assignments)) {
141
+ if (!workerIds.includes(workerId)) {
142
+ report.droppedUnknownWorkers.push(workerId);
143
+ }
144
+ else if (!orders.some((o) => o.orderId === orderId)) {
145
+ report.droppedUnknownOrders.push(`${workerId}→${orderId}`);
146
+ }
147
+ else {
148
+ assignments.set(workerId, orderId);
149
+ }
150
+ }
151
+ this.orders = orders;
152
+ this.workerIds = workerIds;
153
+ this.assignments = assignments;
154
+ this.rebalance();
155
+ return report;
156
+ }
157
+ /** Applies the distribution rules (stable: existing valid assignments are kept). */
158
+ rebalance() {
159
+ const count = (orderId) => this.workersOf(orderId).length;
160
+ const idle = () => this.workerIds.filter((id) => !this.assignments.has(id));
161
+ // 1) every order without a worker gets one: an idle worker, else one moved from the richest order (> 1)
162
+ for (const order of this.orders) {
163
+ if (count(order.orderId) > 0) {
164
+ continue;
165
+ }
166
+ const free = idle()[0];
167
+ if (free) {
168
+ this.assignments.set(free, order.orderId);
169
+ continue;
170
+ }
171
+ const donor = [...this.orders]
172
+ .filter((o) => count(o.orderId) > 1)
173
+ .sort((a, b) => count(b.orderId) - count(a.orderId) || this.orders.indexOf(b) - this.orders.indexOf(a))[0];
174
+ if (donor) {
175
+ const moved = this.workersOf(donor.orderId).slice(-1)[0];
176
+ this.assignments.set(moved, order.orderId);
177
+ }
178
+ }
179
+ // 2) the remaining idle workers go to the least-assigned order (tie → the earliest)
180
+ for (const workerId of idle()) {
181
+ const target = [...this.orders]
182
+ .sort((a, b) => count(a.orderId) - count(b.orderId) || this.orders.indexOf(a) - this.orders.indexOf(b))[0];
183
+ if (!target) {
184
+ return;
185
+ }
186
+ this.assignments.set(workerId, target.orderId);
187
+ }
188
+ }
189
+ static error(code, message) {
190
+ return new DyFM_Error({
191
+ message: `DyFM_WorkOrderScheduler: ${message}`,
192
+ errorCode: code,
193
+ issuerService: 'DyFM_WorkOrderScheduler_ControlModel',
194
+ });
195
+ }
196
+ }
@@ -14,6 +14,14 @@ export * from './_models/flying-effect.interface';
14
14
  export * from './_models/flying-effect.control-model';
15
15
  export * from './_models/simulation-clock.interface';
16
16
  export * from './_models/simulation-clock.control-model';
17
+ export * from './_models/game-save.interface';
18
+ export * from './_models/work-order-scheduler.interface';
19
+ export * from './_models/work-order-scheduler.control-model';
20
+ export * from './_models/build-reservation.interface';
21
+ export * from './_models/viewport.interface';
22
+ export * from './_models/viewport.control-model';
17
23
  // COLLECTIONS
18
24
  export * from './_collections/audio-mixer.util';
19
25
  export * from './_collections/audio-scale.util';
26
+ export * from './_collections/game-save.util';
27
+ export * from './_collections/build-reservation.util';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@futdevpro/fsm-dynamo",
3
- "version": "1.20.97",
3
+ "version": "1.20.100",
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",