@jgengine/core 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +2 -0
  3. package/dist/ai/spawnDirector.d.ts +5 -0
  4. package/dist/ai/spawnDirector.js +13 -1
  5. package/dist/anim/easing.d.ts +14 -0
  6. package/dist/anim/easing.js +55 -0
  7. package/dist/behaviour/behaviour.d.ts +136 -0
  8. package/dist/behaviour/behaviour.js +388 -0
  9. package/dist/cards/cardPile.d.ts +1 -0
  10. package/dist/cards/cardPile.js +12 -3
  11. package/dist/combat/abilityKit.d.ts +5 -0
  12. package/dist/combat/abilityKit.js +10 -0
  13. package/dist/combat/effects.d.ts +1 -1
  14. package/dist/combat/effects.js +9 -6
  15. package/dist/combat/projectiles.d.ts +42 -5
  16. package/dist/combat/projectiles.js +98 -12
  17. package/dist/combat/telegraph.d.ts +5 -1
  18. package/dist/combat/telegraph.js +14 -0
  19. package/dist/commands/commandRegistry.d.ts +1 -1
  20. package/dist/commands/commandRegistry.js +2 -1
  21. package/dist/data/dataSource.d.ts +26 -0
  22. package/dist/data/dataSource.js +93 -0
  23. package/dist/data/devProxy.d.ts +11 -0
  24. package/dist/data/devProxy.js +38 -0
  25. package/dist/data/fetchJson.d.ts +20 -0
  26. package/dist/data/fetchJson.js +43 -0
  27. package/dist/data/jsonDataSource.d.ts +4 -0
  28. package/dist/data/jsonDataSource.js +6 -0
  29. package/dist/devtools/devtools.d.ts +157 -0
  30. package/dist/devtools/devtools.js +484 -0
  31. package/dist/devtools/transformTunables.d.ts +14 -0
  32. package/dist/devtools/transformTunables.js +38 -0
  33. package/dist/game/chat.d.ts +66 -0
  34. package/dist/game/chat.js +165 -0
  35. package/dist/game/defineGame.d.ts +8 -2
  36. package/dist/game/defineGame.js +2 -1
  37. package/dist/game/events.d.ts +25 -0
  38. package/dist/game/levelSequence.d.ts +44 -0
  39. package/dist/game/levelSequence.js +78 -0
  40. package/dist/game/playableGame.d.ts +237 -6
  41. package/dist/game/playableGame.js +6 -1
  42. package/dist/game/progression.d.ts +1 -0
  43. package/dist/game/progression.js +23 -2
  44. package/dist/game/race.d.ts +5 -0
  45. package/dist/game/race.js +20 -0
  46. package/dist/game/social.d.ts +42 -0
  47. package/dist/game/social.js +122 -0
  48. package/dist/game/spawnPoints.d.ts +19 -0
  49. package/dist/game/spawnPoints.js +23 -0
  50. package/dist/game/worldItem.d.ts +3 -0
  51. package/dist/game/worldItem.js +12 -6
  52. package/dist/input/actionBindings.d.ts +10 -0
  53. package/dist/input/actionBindings.js +14 -0
  54. package/dist/input/gestureSurface.d.ts +31 -0
  55. package/dist/input/gestureSurface.js +120 -0
  56. package/dist/input/pointer.d.ts +45 -0
  57. package/dist/input/pointer.js +48 -0
  58. package/dist/input/touchScheme.d.ts +83 -0
  59. package/dist/input/touchScheme.js +115 -0
  60. package/dist/item/affix.d.ts +1 -1
  61. package/dist/item/affix.js +1 -19
  62. package/dist/meta/changelog.d.ts +1 -1
  63. package/dist/meta/changelog.js +37 -1
  64. package/dist/movement/movementModel.d.ts +36 -3
  65. package/dist/movement/movementModel.js +81 -5
  66. package/dist/movement/voxelController.d.ts +61 -0
  67. package/dist/movement/voxelController.js +229 -0
  68. package/dist/multiplayer/chatContract.d.ts +42 -0
  69. package/dist/multiplayer/chatContract.js +57 -0
  70. package/dist/multiplayer/identity.d.ts +13 -0
  71. package/dist/multiplayer/identity.js +20 -0
  72. package/dist/multiplayer/poseSyncGate.d.ts +1 -0
  73. package/dist/multiplayer/poseSyncGate.js +13 -1
  74. package/dist/multiplayer/presenceModel.d.ts +3 -0
  75. package/dist/multiplayer/presenceModel.js +15 -1
  76. package/dist/multiplayer/voiceContract.d.ts +44 -0
  77. package/dist/multiplayer/voiceContract.js +108 -0
  78. package/dist/nav/navConstrain.d.ts +14 -0
  79. package/dist/nav/navConstrain.js +26 -0
  80. package/dist/nav/navFromEnvironment.d.ts +11 -0
  81. package/dist/nav/navFromEnvironment.js +16 -0
  82. package/dist/nav/navGrid.d.ts +10 -0
  83. package/dist/nav/navGrid.js +18 -1
  84. package/dist/nav/pathFollow.d.ts +4 -2
  85. package/dist/nav/pathFollow.js +5 -3
  86. package/dist/physics/ballisticSweep.d.ts +18 -0
  87. package/dist/physics/ballisticSweep.js +49 -0
  88. package/dist/physics/forceVolume.js +5 -5
  89. package/dist/physics/physicsWorld.d.ts +41 -2
  90. package/dist/physics/physicsWorld.js +232 -40
  91. package/dist/physics/spatialGrid.d.ts +1 -1
  92. package/dist/physics/spatialGrid.js +5 -0
  93. package/dist/physics/vehicleBody.d.ts +6 -0
  94. package/dist/physics/vehicleBody.js +16 -17
  95. package/dist/puzzle/cellGrid.d.ts +26 -0
  96. package/dist/puzzle/cellGrid.js +110 -0
  97. package/dist/puzzle/fallingPiece.d.ts +24 -0
  98. package/dist/puzzle/fallingPiece.js +51 -0
  99. package/dist/random/nameGen.d.ts +18 -0
  100. package/dist/random/nameGen.js +35 -0
  101. package/dist/random/rng.d.ts +2 -0
  102. package/dist/random/rng.js +23 -0
  103. package/dist/runtime/adapter.d.ts +35 -0
  104. package/dist/runtime/adapter.js +37 -1
  105. package/dist/runtime/cameraDirector.d.ts +10 -0
  106. package/dist/runtime/cameraDirector.js +25 -0
  107. package/dist/runtime/gameContext.d.ts +44 -2
  108. package/dist/runtime/gameContext.js +100 -4
  109. package/dist/runtime/hostPersistence.d.ts +21 -0
  110. package/dist/runtime/hostPersistence.js +50 -0
  111. package/dist/runtime/inputSnapshot.d.ts +7 -0
  112. package/dist/runtime/inputSnapshot.js +12 -0
  113. package/dist/runtime/motionIntents.d.ts +25 -0
  114. package/dist/runtime/motionIntents.js +25 -0
  115. package/dist/runtime/transport.d.ts +32 -0
  116. package/dist/scene/entityStore.d.ts +8 -0
  117. package/dist/scene/entityStore.js +25 -2
  118. package/dist/scene/movementSpeed.d.ts +35 -0
  119. package/dist/scene/movementSpeed.js +20 -0
  120. package/dist/scene/objectQuery.d.ts +18 -0
  121. package/dist/scene/objectQuery.js +108 -0
  122. package/dist/scene/objectStore.d.ts +11 -0
  123. package/dist/scene/objectStore.js +109 -4
  124. package/dist/scene/paintLayer.d.ts +15 -0
  125. package/dist/scene/paintLayer.js +53 -0
  126. package/dist/scene/spatial.d.ts +21 -0
  127. package/dist/scene/spatial.js +63 -1
  128. package/dist/sensor/concealment.d.ts +21 -0
  129. package/dist/sensor/concealment.js +63 -0
  130. package/dist/sensor/freezeMonitor.d.ts +17 -0
  131. package/dist/sensor/freezeMonitor.js +35 -0
  132. package/dist/session/roles.d.ts +6 -0
  133. package/dist/session/roles.js +32 -0
  134. package/dist/session/roundState.d.ts +41 -17
  135. package/dist/session/roundState.js +67 -38
  136. package/dist/tactics/fallingGrid.d.ts +62 -0
  137. package/dist/tactics/fallingGrid.js +158 -0
  138. package/dist/tactics/tacticalGrid.d.ts +11 -0
  139. package/dist/tactics/tacticalGrid.js +17 -0
  140. package/dist/time/simClock.d.ts +25 -1
  141. package/dist/time/simClock.js +28 -3
  142. package/dist/turn/intent.d.ts +14 -0
  143. package/dist/turn/intent.js +29 -0
  144. package/dist/turn/turnLoop.d.ts +2 -0
  145. package/dist/turn/turnLoop.js +8 -1
  146. package/dist/world/environmentSummary.d.ts +53 -0
  147. package/dist/world/environmentSummary.js +129 -0
  148. package/dist/world/features.d.ts +87 -6
  149. package/dist/world/features.js +52 -2
  150. package/dist/world/gridInstances.d.ts +13 -0
  151. package/dist/world/gridInstances.js +22 -0
  152. package/dist/world/placementController.d.ts +22 -1
  153. package/dist/world/placementController.js +56 -0
  154. package/dist/world/terrain.d.ts +23 -1
  155. package/dist/world/terrain.js +64 -1
  156. package/dist/world/voxelField.d.ts +54 -0
  157. package/dist/world/voxelField.js +241 -0
  158. package/llms.txt +4102 -0
  159. package/package.json +3 -2
@@ -1,31 +1,47 @@
1
- const PHASE_ORDER = ["buy", "live", "end"];
1
+ const DEFAULT_PHASE_ORDER = ["buy", "live", "end"];
2
2
  export function lossBonusFor(rule, streak) {
3
3
  if (rule === undefined)
4
4
  return 0;
5
5
  return Math.min(rule.max, rule.base + rule.step * Math.max(0, streak));
6
6
  }
7
+ function normalizeTeams(teams) {
8
+ if (teams.length === 0)
9
+ return [{ id: "a" }, { id: "b" }];
10
+ const normalized = [];
11
+ for (const team of teams) {
12
+ normalized.push(typeof team === "string" ? { id: team } : team);
13
+ }
14
+ return normalized;
15
+ }
16
+ function isConcludable(order, phase) {
17
+ const idx = order.indexOf(phase);
18
+ return idx > 0 && idx < order.length - 1;
19
+ }
7
20
  export function createRoundState(config) {
21
+ const order = config.phaseOrder ?? DEFAULT_PHASE_ORDER;
8
22
  const durations = config.phases;
9
- const teams = config.teams.length > 0 ? [...config.teams] : ["a", "b"];
23
+ const teamRecords = normalizeTeams(config.teams);
24
+ const teamIds = teamRecords.map((t) => t.id);
25
+ const roles = {};
10
26
  const scores = {};
11
27
  const lossStreaks = {};
12
- for (const team of teams) {
13
- scores[team] = 0;
14
- lossStreaks[team] = 0;
28
+ for (const team of teamRecords) {
29
+ roles[team.id] = team.role;
30
+ scores[team.id] = 0;
31
+ lossStreaks[team.id] = 0;
15
32
  }
16
33
  let round = 1;
17
- let phase = "buy";
18
- let timeLeft = durations.buy;
34
+ let phase = order[0];
35
+ let timeLeft = durations[phase] ?? 0;
19
36
  let matchOver = false;
20
37
  let pendingWinner = null;
21
38
  const hooks = new Set();
22
39
  function durationOf(next) {
23
- return durations[next];
40
+ return durations[next] ?? 0;
24
41
  }
25
42
  function nextPhaseOf(current) {
26
- if (current === "end")
27
- return "buy";
28
- return PHASE_ORDER[PHASE_ORDER.indexOf(current) + 1];
43
+ const idx = order.indexOf(current);
44
+ return idx >= order.length - 1 ? order[0] : order[idx + 1];
29
45
  }
30
46
  function economyFor(team) {
31
47
  const won = pendingWinner === team;
@@ -38,12 +54,12 @@ export function createRoundState(config) {
38
54
  }
39
55
  function settleEconomy(winner) {
40
56
  const economy = [];
41
- for (const team of teams) {
57
+ for (const team of teamIds) {
42
58
  const won = team === winner;
43
59
  const reward = won ? config.winReward ?? 0 : lossBonusFor(config.lossBonus, lossStreaks[team]);
44
60
  economy.push({ team, reward, won, lossStreak: lossStreaks[team] });
45
61
  }
46
- for (const team of teams) {
62
+ for (const team of teamIds) {
47
63
  if (team === winner)
48
64
  lossStreaks[team] = 0;
49
65
  else
@@ -58,24 +74,48 @@ export function createRoundState(config) {
58
74
  }
59
75
  function endPhase(events) {
60
76
  const ending = phase;
61
- if (ending === "end") {
77
+ if (ending === order[order.length - 1]) {
62
78
  if (config.maxRounds !== undefined && round >= config.maxRounds) {
63
79
  matchOver = true;
64
- events.push({ kind: "phase.end", round, phase: ending, nextPhase: "buy" });
80
+ events.push({ kind: "phase.end", round, phase: ending, nextPhase: order[0] });
65
81
  events.push({ kind: "match.end", round });
66
82
  for (const hook of hooks)
67
- hook(ending, "buy", round);
83
+ hook(ending, order[0], round);
68
84
  return;
69
85
  }
70
86
  round += 1;
71
87
  pendingWinner = null;
72
88
  }
73
89
  const next = nextPhaseOf(ending);
74
- events.push({ kind: "phase.end", round: ending === "end" ? round - 1 : round, phase: ending, nextPhase: next });
90
+ const endedRound = ending === order[order.length - 1] ? round - 1 : round;
91
+ events.push({ kind: "phase.end", round: endedRound, phase: ending, nextPhase: next });
75
92
  for (const hook of hooks)
76
- hook(ending, next, ending === "end" ? round - 1 : round);
93
+ hook(ending, next, endedRound);
77
94
  enter(next, events);
78
95
  }
96
+ function currentSnapshot() {
97
+ return {
98
+ round,
99
+ phase,
100
+ timeLeft: Math.max(0, timeLeft),
101
+ scores: { ...scores },
102
+ lossStreaks: { ...lossStreaks },
103
+ roles: { ...roles },
104
+ matchOver,
105
+ };
106
+ }
107
+ function concludeRoundImpl(winner) {
108
+ if (matchOver || !isConcludable(order, phase))
109
+ return [];
110
+ const events = [];
111
+ pendingWinner = winner;
112
+ scores[winner] = (scores[winner] ?? 0) + 1;
113
+ events.push({ kind: "round.win", round, winner });
114
+ const economy = settleEconomy(winner);
115
+ events.push({ kind: "round.economy", round, winner, economy });
116
+ endPhase(events);
117
+ return events;
118
+ }
79
119
  return {
80
120
  tick(dt) {
81
121
  if (dt <= 0 || matchOver)
@@ -83,9 +123,9 @@ export function createRoundState(config) {
83
123
  const events = [];
84
124
  timeLeft -= dt;
85
125
  let guard = 0;
86
- while (timeLeft <= 0 && !matchOver && guard < PHASE_ORDER.length + 2) {
126
+ while (timeLeft <= 0 && !matchOver && guard < order.length + 2) {
87
127
  const carry = timeLeft;
88
- if (phase === "live")
128
+ if (isConcludable(order, phase))
89
129
  pendingWinner = null;
90
130
  endPhase(events);
91
131
  if (!matchOver)
@@ -94,17 +134,12 @@ export function createRoundState(config) {
94
134
  }
95
135
  return events;
96
136
  },
97
- concludeRound(winner) {
98
- if (matchOver || phase !== "live")
137
+ concludeRound: concludeRoundImpl,
138
+ evaluate() {
139
+ if (config.winCondition === undefined)
99
140
  return [];
100
- const events = [];
101
- pendingWinner = winner;
102
- scores[winner] = (scores[winner] ?? 0) + 1;
103
- events.push({ kind: "round.win", round, winner });
104
- const economy = settleEconomy(winner);
105
- events.push({ kind: "round.economy", round, winner, economy });
106
- endPhase(events);
107
- return events;
141
+ const winner = config.winCondition(currentSnapshot());
142
+ return winner === null ? [] : concludeRoundImpl(winner);
108
143
  },
109
144
  onPhaseEnd(hook) {
110
145
  hooks.add(hook);
@@ -117,13 +152,7 @@ export function createRoundState(config) {
117
152
  timeLeft: () => Math.max(0, timeLeft),
118
153
  score: (team) => scores[team] ?? 0,
119
154
  economyFor,
120
- snapshot: () => ({
121
- round,
122
- phase,
123
- timeLeft: Math.max(0, timeLeft),
124
- scores: { ...scores },
125
- lossStreaks: { ...lossStreaks },
126
- matchOver,
127
- }),
155
+ roleOf: (team) => roles[team],
156
+ snapshot: currentSnapshot,
128
157
  };
129
158
  }
@@ -0,0 +1,62 @@
1
+ export type FallingGridCell = readonly [number, number];
2
+ export interface FallingGridConfig {
3
+ cols: number;
4
+ rows: number;
5
+ /** Milliseconds a grounded piece may sit before `lockExpired` fires. Defaults to 500. */
6
+ lockDelayMs?: number;
7
+ }
8
+ /** Elapsed grounded time for the lock-delay state machine. `null` means airborne / not timing. */
9
+ export interface LockState {
10
+ elapsedMs: number;
11
+ }
12
+ export interface FallingGridSnapshot<TCell> {
13
+ cols: number;
14
+ rows: number;
15
+ cells: readonly (TCell | null)[];
16
+ }
17
+ export interface FallingGrid<TCell> {
18
+ readonly cols: number;
19
+ readonly rows: number;
20
+ occupied(col: number, row: number): boolean;
21
+ cellAt(col: number, row: number): TCell | null;
22
+ setCell(col: number, row: number, value: TCell): void;
23
+ clearCell(col: number, row: number): void;
24
+ /** Bounds + collision check for a footprint (`cells`, relative to `offset`). Rows above the grid (`row < 0`) are allowed through, mirroring a piece spawning above the visible board; only columns are bounds-checked there. */
25
+ canPlace(cells: readonly FallingGridCell[], offset: FallingGridCell): boolean;
26
+ /** Writes `value` into every in-bounds cell of the footprint if `canPlace` allows it. Cells above the grid are silently skipped. Returns whether the placement happened. */
27
+ place(cells: readonly FallingGridCell[], offset: FallingGridCell, value: TCell): boolean;
28
+ /** Row indices that are fully occupied, in ascending order. */
29
+ fullRows(): readonly number[];
30
+ /** Removes the given rows and shifts every row above them down, backfilling the top with empty rows. Returns the number of rows cleared. */
31
+ clearRows(rows: readonly number[]): number;
32
+ /** Column-gravity collapse: every occupied cell in each column falls to the lowest free row below it, preserving relative order. Returns the number of cells that moved. */
33
+ settle(): number;
34
+ /** Advances (or starts) a lock-delay timer while `grounded`, and clears it the instant the piece is airborne. */
35
+ advanceLock(state: LockState | null, grounded: boolean, dtMs: number): LockState | null;
36
+ /** Whether a lock-delay timer has run out `lockDelayMs`. */
37
+ lockExpired(state: LockState | null): boolean;
38
+ snapshot(): FallingGridSnapshot<TCell>;
39
+ /** Clears every cell back to empty. */
40
+ reset(): void;
41
+ }
42
+ export interface GravityIntervalConfig {
43
+ /** Interval at level 0, in milliseconds. Defaults to 800. */
44
+ baseMs?: number;
45
+ /** Milliseconds shaved off per level. Defaults to 70. */
46
+ perLevel?: number;
47
+ /** Floor the interval never drops below, in milliseconds. Defaults to 50. */
48
+ minMs?: number;
49
+ }
50
+ /**
51
+ * Gravity tick interval for a given level: linear speed-up with a floor, generalizing
52
+ * `Games/block-stacker`'s `gravityInterval` (`max(0.05, 0.8 - level * 0.07)` seconds) to milliseconds
53
+ * with configurable base/step/floor.
54
+ */
55
+ export declare function gravityIntervalMs(level: number, config?: GravityIntervalConfig): number;
56
+ /**
57
+ * A game-agnostic cell grid for gravity-driven falling-block mechanics: bounded occupancy,
58
+ * footprint placement/collision, line-clear collapse, column-gravity settle (for match-3/sand
59
+ * style cascades), and a pure lock-delay helper. Extracted from `Games/block-stacker`'s tetris
60
+ * logic so any falling-block game can adopt it instead of reimplementing the grid.
61
+ */
62
+ export declare function createFallingGrid<TCell>(config: FallingGridConfig): FallingGrid<TCell>;
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Gravity tick interval for a given level: linear speed-up with a floor, generalizing
3
+ * `Games/block-stacker`'s `gravityInterval` (`max(0.05, 0.8 - level * 0.07)` seconds) to milliseconds
4
+ * with configurable base/step/floor.
5
+ */
6
+ export function gravityIntervalMs(level, config = {}) {
7
+ const baseMs = config.baseMs ?? 800;
8
+ const perLevel = config.perLevel ?? 70;
9
+ const minMs = config.minMs ?? 50;
10
+ return Math.max(minMs, baseMs - level * perLevel);
11
+ }
12
+ /**
13
+ * A game-agnostic cell grid for gravity-driven falling-block mechanics: bounded occupancy,
14
+ * footprint placement/collision, line-clear collapse, column-gravity settle (for match-3/sand
15
+ * style cascades), and a pure lock-delay helper. Extracted from `Games/block-stacker`'s tetris
16
+ * logic so any falling-block game can adopt it instead of reimplementing the grid.
17
+ */
18
+ export function createFallingGrid(config) {
19
+ const cols = config.cols;
20
+ const rows = config.rows;
21
+ const lockDelayMs = config.lockDelayMs ?? 500;
22
+ if (cols <= 0 || rows <= 0)
23
+ throw new Error("falling grid needs positive dimensions");
24
+ let cells = new Array(cols * rows).fill(null);
25
+ function index(col, row) {
26
+ return row * cols + col;
27
+ }
28
+ function inBounds(col, row) {
29
+ return col >= 0 && col < cols && row >= 0 && row < rows;
30
+ }
31
+ function cellAt(col, row) {
32
+ if (!inBounds(col, row))
33
+ return null;
34
+ return cells[index(col, row)] ?? null;
35
+ }
36
+ function occupied(col, row) {
37
+ return cellAt(col, row) !== null;
38
+ }
39
+ function setCell(col, row, value) {
40
+ if (!inBounds(col, row))
41
+ return;
42
+ cells[index(col, row)] = value;
43
+ }
44
+ function clearCell(col, row) {
45
+ if (!inBounds(col, row))
46
+ return;
47
+ cells[index(col, row)] = null;
48
+ }
49
+ function canPlace(footprint, offset) {
50
+ for (const [dc, dr] of footprint) {
51
+ const col = offset[0] + dc;
52
+ const row = offset[1] + dr;
53
+ if (col < 0 || col >= cols || row >= rows)
54
+ return false;
55
+ if (row >= 0 && occupied(col, row))
56
+ return false;
57
+ }
58
+ return true;
59
+ }
60
+ function place(footprint, offset, value) {
61
+ if (!canPlace(footprint, offset))
62
+ return false;
63
+ for (const [dc, dr] of footprint) {
64
+ const col = offset[0] + dc;
65
+ const row = offset[1] + dr;
66
+ if (row >= 0)
67
+ setCell(col, row, value);
68
+ }
69
+ return true;
70
+ }
71
+ function fullRows() {
72
+ const full = [];
73
+ for (let row = 0; row < rows; row += 1) {
74
+ let rowFull = true;
75
+ for (let col = 0; col < cols; col += 1) {
76
+ if (!occupied(col, row)) {
77
+ rowFull = false;
78
+ break;
79
+ }
80
+ }
81
+ if (rowFull)
82
+ full.push(row);
83
+ }
84
+ return full;
85
+ }
86
+ function clearRows(rowsToClear) {
87
+ const toClear = new Set(rowsToClear);
88
+ if (toClear.size === 0)
89
+ return 0;
90
+ const kept = [];
91
+ for (let row = 0; row < rows; row += 1) {
92
+ if (toClear.has(row))
93
+ continue;
94
+ kept.push(cells.slice(index(0, row), index(0, row) + cols));
95
+ }
96
+ const cleared = rows - kept.length;
97
+ const next = [];
98
+ for (let i = 0; i < cleared; i += 1)
99
+ next.push(...new Array(cols).fill(null));
100
+ for (const row of kept)
101
+ next.push(...row);
102
+ cells = next;
103
+ return cleared;
104
+ }
105
+ function settle() {
106
+ let moved = 0;
107
+ for (let col = 0; col < cols; col += 1) {
108
+ const values = [];
109
+ for (let row = 0; row < rows; row += 1) {
110
+ const value = cellAt(col, row);
111
+ if (value !== null)
112
+ values.push({ row, value });
113
+ }
114
+ for (let row = 0; row < rows; row += 1)
115
+ clearCell(col, row);
116
+ const count = values.length;
117
+ for (let i = 0; i < count; i += 1) {
118
+ const newRow = rows - count + i;
119
+ const { row: oldRow, value } = values[i];
120
+ setCell(col, newRow, value);
121
+ if (oldRow !== newRow)
122
+ moved += 1;
123
+ }
124
+ }
125
+ return moved;
126
+ }
127
+ function advanceLock(state, grounded, dtMs) {
128
+ if (!grounded)
129
+ return null;
130
+ return { elapsedMs: (state?.elapsedMs ?? 0) + dtMs };
131
+ }
132
+ function lockExpired(state) {
133
+ return state !== null && state.elapsedMs >= lockDelayMs;
134
+ }
135
+ function snapshot() {
136
+ return { cols, rows, cells: cells.slice() };
137
+ }
138
+ function reset() {
139
+ cells = new Array(cols * rows).fill(null);
140
+ }
141
+ return {
142
+ cols,
143
+ rows,
144
+ occupied,
145
+ cellAt,
146
+ setCell,
147
+ clearCell,
148
+ canPlace,
149
+ place,
150
+ fullRows,
151
+ clearRows,
152
+ settle,
153
+ advanceLock,
154
+ lockExpired,
155
+ snapshot,
156
+ reset,
157
+ };
158
+ }
@@ -1,9 +1,16 @@
1
1
  export type Tile = readonly [number, number];
2
+ /** Maps abstract `[col, row]` tiles onto world-space `[x, z]`, mirroring `navGrid`'s bounds+cellSize convention. */
3
+ export interface TacticalGridWorldConfig {
4
+ /** World-space `[x, z]` of the min corner of tile `[0, 0]`. */
5
+ origin: readonly [number, number];
6
+ tileSize: number;
7
+ }
2
8
  export interface TacticalGridConfig {
3
9
  width: number;
4
10
  height: number;
5
11
  blocked?: readonly Tile[];
6
12
  diagonal?: boolean;
13
+ world?: TacticalGridWorldConfig;
7
14
  }
8
15
  export interface ReachableOptions {
9
16
  passThrough?: readonly string[];
@@ -53,5 +60,9 @@ export interface TacticalGrid {
53
60
  push(id: string, direction: Tile, options?: PushOptions): PushResult;
54
61
  capture(): TacticalGridSnapshot;
55
62
  restore(snapshot: TacticalGridSnapshot): void;
63
+ /** World `[x, z]` to the containing tile, or `null` when outside the grid. Requires `config.world`. */
64
+ worldToTile(x: number, z: number): Tile | null;
65
+ /** A tile's world-space center. Requires `config.world`. */
66
+ tileToWorld(tile: Tile): readonly [number, number];
56
67
  }
57
68
  export declare function createTacticalGrid(config: TacticalGridConfig): TacticalGrid;
@@ -12,9 +12,24 @@ export function createTacticalGrid(config) {
12
12
  const blocked = new Set((config.blocked ?? []).map(key));
13
13
  const occupants = new Map();
14
14
  const tiles = new Map();
15
+ const world = config.world;
15
16
  function inBounds(tile) {
16
17
  return tile[0] >= 0 && tile[0] < width && tile[1] >= 0 && tile[1] < height;
17
18
  }
19
+ function requireWorld() {
20
+ if (world === undefined)
21
+ throw new Error("tacticalGrid: worldToTile/tileToWorld require config.world.");
22
+ return world;
23
+ }
24
+ function worldToTile(x, z) {
25
+ const { origin, tileSize } = requireWorld();
26
+ const tile = [Math.floor((x - origin[0]) / tileSize), Math.floor((z - origin[1]) / tileSize)];
27
+ return inBounds(tile) ? tile : null;
28
+ }
29
+ function tileToWorld(tile) {
30
+ const { origin, tileSize } = requireWorld();
31
+ return [origin[0] + (tile[0] + 0.5) * tileSize, origin[1] + (tile[1] + 0.5) * tileSize];
32
+ }
18
33
  function isBlocked(tile) {
19
34
  return blocked.has(key(tile));
20
35
  }
@@ -223,6 +238,8 @@ export function createTacticalGrid(config) {
223
238
  reachable,
224
239
  path,
225
240
  push,
241
+ worldToTile,
242
+ tileToWorld,
226
243
  capture: () => {
227
244
  const occ = {};
228
245
  for (const [k, unitId] of occupants)
@@ -9,6 +9,10 @@ export interface TimeConfig {
9
9
  start?: number;
10
10
  /** Boot with the clock paused. Default false. */
11
11
  startPaused?: boolean;
12
+ /** In-world days per year, driving `calendar().year`/`dayOfYear`/`yearFraction`. Default 365. */
13
+ daysPerYear?: number;
14
+ /** Named season segments spanning the year in equal shares; when set, `calendar().season` names the current one. */
15
+ seasons?: readonly string[];
12
16
  }
13
17
  export interface CalendarTime {
14
18
  /** Total game-seconds since the world started. */
@@ -20,14 +24,24 @@ export interface CalendarTime {
20
24
  second: number;
21
25
  /** Progress through the current day, 0..1. */
22
26
  dayFraction: number;
27
+ /** 0-based year index, using `daysPerYear` (default 365). */
28
+ year: number;
29
+ /** 0-based day index within the current year. */
30
+ dayOfYear: number;
31
+ /** Progress through the current year, 0..1. */
32
+ yearFraction: number;
33
+ /** Current season name, present only when `TimeConfig.seasons` is configured. */
34
+ season?: string;
23
35
  }
24
36
  export interface ClockSnapshot {
25
37
  now: number;
26
38
  paused: boolean;
27
- /** Effective multiplier applied to real time — 0 while paused. */
39
+ /** Speed-control multiplier — 0 while paused. The full game-time rate is `speed × timescale × scale`. */
28
40
  speed: number;
29
41
  /** The non-zero multiplier `play()` resumes to. */
30
42
  playSpeed: number;
43
+ /** Global slow-motion/fast-forward multiplier; 1 unless `setTimescale` was called. */
44
+ timescale: number;
31
45
  scale: number;
32
46
  speeds: readonly number[];
33
47
  calendar: CalendarTime;
@@ -47,6 +61,16 @@ export interface SimClock {
47
61
  setSpeed(multiplier: number): void;
48
62
  /** Step to the next configured speed (wraps); always unpauses. */
49
63
  cycleSpeed(): void;
64
+ /** Current global timescale multiplier; 1 by default. */
65
+ timescale(): number;
66
+ /**
67
+ * Global slow-motion/fast-forward multiplier composed with the speed control —
68
+ * `advance` returns `realDt × scale × speed × timescale`. `0` freezes game time
69
+ * without flipping pause state (`0.08` = bullet time, `2` = fast-forward); negative
70
+ * or non-finite values clamp to 0. Timescale is host-authoritative: in multiplayer
71
+ * it belongs to the host simulation, never to an individual client.
72
+ */
73
+ setTimescale(multiplier: number): void;
50
74
  /** Run `callback` once, `seconds` of game-time from now. Returns a cancel handle. */
51
75
  after(seconds: number, callback: () => void): () => void;
52
76
  /** Run `callback` every `seconds` of game-time. Returns a cancel handle. */
@@ -7,9 +7,12 @@ export function createSimClock(options = {}) {
7
7
  const configuredSpeeds = config.speeds === undefined ? [1, 2, 3, 4] : config.speeds.filter((value) => value > 0);
8
8
  const speeds = configuredSpeeds.length > 0 ? configuredSpeeds : [1];
9
9
  const dayLength = config.dayLength !== undefined && config.dayLength > 0 ? config.dayLength : SECONDS_PER_GAME_DAY;
10
+ const daysPerYear = config.daysPerYear !== undefined && config.daysPerYear > 0 ? config.daysPerYear : 365;
11
+ const seasons = config.seasons;
10
12
  let now = config.start !== undefined && config.start > 0 ? config.start : 0;
11
13
  let paused = config.startPaused ?? false;
12
14
  let playSpeed = speeds[0];
15
+ let timescale = 1;
13
16
  let nextTimerId = 0;
14
17
  const timers = new Map();
15
18
  let displayMinute = minuteIndex(now, dayLength);
@@ -17,14 +20,26 @@ export function createSimClock(options = {}) {
17
20
  const day = Math.floor(now / dayLength);
18
21
  const dayFraction = (now - day * dayLength) / dayLength;
19
22
  const secondOfDay = dayFraction * 24 * 3600;
20
- return {
23
+ const year = Math.floor(day / daysPerYear);
24
+ const dayOfYear = day - year * daysPerYear;
25
+ const yearFraction = (dayOfYear + dayFraction) / daysPerYear;
26
+ const result = {
21
27
  totalSeconds: now,
22
28
  day,
23
29
  hour: Math.floor(secondOfDay / 3600) % 24,
24
30
  minute: Math.floor(secondOfDay / 60) % 60,
25
31
  second: Math.floor(secondOfDay) % 60,
26
32
  dayFraction,
33
+ year,
34
+ dayOfYear,
35
+ yearFraction,
27
36
  };
37
+ if (seasons !== undefined && seasons.length > 0) {
38
+ const segmentLength = daysPerYear / seasons.length;
39
+ const index = Math.min(seasons.length - 1, Math.floor(dayOfYear / segmentLength));
40
+ result.season = seasons[index];
41
+ }
42
+ return result;
28
43
  }
29
44
  function fireDueTimers() {
30
45
  let fires = 0;
@@ -52,9 +67,9 @@ export function createSimClock(options = {}) {
52
67
  }
53
68
  }
54
69
  function advance(realDt) {
55
- if (paused || realDt <= 0)
70
+ if (paused || realDt <= 0 || timescale === 0)
56
71
  return 0;
57
- const gameDt = realDt * scale * playSpeed;
72
+ const gameDt = realDt * scale * playSpeed * timescale;
58
73
  now += gameDt;
59
74
  fireDueTimers();
60
75
  const minute = minuteIndex(now, dayLength);
@@ -91,6 +106,13 @@ export function createSimClock(options = {}) {
91
106
  const index = speeds.indexOf(playSpeed);
92
107
  setSpeed(speeds[(index + 1) % speeds.length]);
93
108
  }
109
+ function setTimescale(multiplier) {
110
+ const next = Number.isFinite(multiplier) && multiplier > 0 ? multiplier : 0;
111
+ if (next === timescale)
112
+ return;
113
+ timescale = next;
114
+ onChange();
115
+ }
94
116
  function register(due, interval, callback) {
95
117
  const id = nextTimerId;
96
118
  nextTimerId += 1;
@@ -108,6 +130,7 @@ export function createSimClock(options = {}) {
108
130
  paused,
109
131
  speed: paused ? 0 : playSpeed,
110
132
  playSpeed,
133
+ timescale,
111
134
  scale,
112
135
  speeds,
113
136
  calendar: calendar(),
@@ -119,6 +142,8 @@ export function createSimClock(options = {}) {
119
142
  toggle: () => (paused ? play() : pause()),
120
143
  setSpeed,
121
144
  cycleSpeed,
145
+ timescale: () => timescale,
146
+ setTimescale,
122
147
  after: (seconds, callback) => register(now + Math.max(0, seconds), null, callback),
123
148
  every: (seconds, callback) => {
124
149
  const interval = Math.max(Number.EPSILON, seconds);
@@ -0,0 +1,14 @@
1
+ export interface DeclaredIntent<TKind extends string = string> {
2
+ kind: TKind;
3
+ magnitude?: number;
4
+ targetId?: string;
5
+ note?: string;
6
+ }
7
+ export interface IntentBoard<TKind extends string = string> {
8
+ declare(participantId: string, intent: DeclaredIntent<TKind>): void;
9
+ peek(participantId: string): DeclaredIntent<TKind> | null;
10
+ all(): readonly [string, DeclaredIntent<TKind>][];
11
+ consume(participantId: string): DeclaredIntent<TKind> | null;
12
+ clear(participantId?: string): void;
13
+ }
14
+ export declare function createIntentBoard<TKind extends string = string>(): IntentBoard<TKind>;
@@ -0,0 +1,29 @@
1
+ export function createIntentBoard() {
2
+ const intents = new Map();
3
+ return {
4
+ declare(participantId, intent) {
5
+ intents.set(participantId, { ...intent });
6
+ },
7
+ peek(participantId) {
8
+ const intent = intents.get(participantId);
9
+ return intent === undefined ? null : { ...intent };
10
+ },
11
+ all() {
12
+ return [...intents.entries()].map(([participantId, intent]) => [participantId, { ...intent }]);
13
+ },
14
+ consume(participantId) {
15
+ const intent = intents.get(participantId);
16
+ if (intent === undefined)
17
+ return null;
18
+ intents.delete(participantId);
19
+ return intent;
20
+ },
21
+ clear(participantId) {
22
+ if (participantId === undefined) {
23
+ intents.clear();
24
+ return;
25
+ }
26
+ intents.delete(participantId);
27
+ },
28
+ };
29
+ }
@@ -12,6 +12,8 @@ export interface TurnLoopConfig {
12
12
  commit?: {
13
13
  mode: CommitMode;
14
14
  };
15
+ onTurnStart?(participantId: string): void;
16
+ onTurnEnd?(participantId: string): void;
15
17
  }
16
18
  export interface PoolState {
17
19
  id: string;
@@ -27,6 +27,8 @@ export function createTurnLoop(config) {
27
27
  }
28
28
  for (const id of order)
29
29
  resetPools(id);
30
+ if (activeIndex >= 0)
31
+ config.onTurnStart?.(order[activeIndex]);
30
32
  function active() {
31
33
  return activeIndex >= 0 && activeIndex < order.length ? order[activeIndex] : null;
32
34
  }
@@ -48,6 +50,7 @@ export function createTurnLoop(config) {
48
50
  activeIndex = -1;
49
51
  return state();
50
52
  }
53
+ const previous = active();
51
54
  phaseIndex = 0;
52
55
  if (activeIndex < 0) {
53
56
  activeIndex = 0;
@@ -59,9 +62,13 @@ export function createTurnLoop(config) {
59
62
  else {
60
63
  activeIndex += 1;
61
64
  }
65
+ if (previous !== null)
66
+ config.onTurnEnd?.(previous);
62
67
  const current = active();
63
- if (current !== null)
68
+ if (current !== null) {
64
69
  resetPools(current);
70
+ config.onTurnStart?.(current);
71
+ }
65
72
  return state();
66
73
  }
67
74
  function advancePhase() {