@johnmorrisdotca/houseki 0.0.0-stage → 0.1.1

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 (118) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +272 -2
  4. package/dist/colour-chains/challenge.d.ts +3 -0
  5. package/dist/colour-chains/challenge.js +120 -0
  6. package/dist/colour-chains/content-data.d.ts +2 -0
  7. package/dist/colour-chains/content-data.js +9534 -0
  8. package/dist/colour-chains/content-types.d.ts +80 -0
  9. package/dist/colour-chains/content-types.js +1 -0
  10. package/dist/colour-chains/content.d.ts +11 -0
  11. package/dist/colour-chains/content.js +28 -0
  12. package/dist/colour-chains/engine.d.ts +19 -0
  13. package/dist/colour-chains/engine.js +493 -0
  14. package/dist/colour-chains/input.d.ts +24 -0
  15. package/dist/colour-chains/input.js +78 -0
  16. package/dist/colour-chains/match.d.ts +5 -0
  17. package/dist/colour-chains/match.js +48 -0
  18. package/dist/colour-chains/persistence.d.ts +7 -0
  19. package/dist/colour-chains/persistence.js +96 -0
  20. package/dist/colour-chains/random.d.ts +7 -0
  21. package/dist/colour-chains/random.js +49 -0
  22. package/dist/colour-chains/types.d.ts +191 -0
  23. package/dist/colour-chains/types.js +1 -0
  24. package/dist/colour-chains/validation.d.ts +16 -0
  25. package/dist/colour-chains/validation.js +80 -0
  26. package/dist/colour-chains.d.ts +8 -0
  27. package/dist/colour-chains.js +7 -0
  28. package/dist/falling-triplets/content-data.d.ts +7258 -0
  29. package/dist/falling-triplets/content-data.js +18073 -0
  30. package/dist/falling-triplets/content-types.d.ts +72 -0
  31. package/dist/falling-triplets/content-types.js +1 -0
  32. package/dist/falling-triplets/content.d.ts +11 -0
  33. package/dist/falling-triplets/content.js +39 -0
  34. package/dist/falling-triplets/engine.d.ts +26 -0
  35. package/dist/falling-triplets/engine.js +541 -0
  36. package/dist/falling-triplets/input.d.ts +18 -0
  37. package/dist/falling-triplets/input.js +42 -0
  38. package/dist/falling-triplets/match.d.ts +9 -0
  39. package/dist/falling-triplets/match.js +45 -0
  40. package/dist/falling-triplets/random.d.ts +9 -0
  41. package/dist/falling-triplets/random.js +44 -0
  42. package/dist/falling-triplets/types.d.ts +134 -0
  43. package/dist/falling-triplets/types.js +1 -0
  44. package/dist/falling-triplets.d.ts +7 -0
  45. package/dist/falling-triplets.js +6 -0
  46. package/dist/gem-swap/black-hole.d.ts +7 -0
  47. package/dist/gem-swap/black-hole.js +54 -0
  48. package/dist/gem-swap/board.d.ts +25 -0
  49. package/dist/gem-swap/board.js +89 -0
  50. package/dist/gem-swap/content.d.ts +75 -0
  51. package/dist/gem-swap/content.js +68 -0
  52. package/dist/gem-swap/engine.d.ts +25 -0
  53. package/dist/gem-swap/engine.js +689 -0
  54. package/dist/gem-swap/persistence.d.ts +17 -0
  55. package/dist/gem-swap/persistence.js +107 -0
  56. package/dist/gem-swap/practice.d.ts +4 -0
  57. package/dist/gem-swap/practice.js +57 -0
  58. package/dist/gem-swap/random.d.ts +9 -0
  59. package/dist/gem-swap/random.js +32 -0
  60. package/dist/gem-swap/specials.d.ts +13 -0
  61. package/dist/gem-swap/specials.js +300 -0
  62. package/dist/gem-swap/tools.d.ts +3 -0
  63. package/dist/gem-swap/tools.js +20 -0
  64. package/dist/gem-swap/types.d.ts +200 -0
  65. package/dist/gem-swap/types.js +1 -0
  66. package/dist/gem-swap.d.ts +5 -0
  67. package/dist/gem-swap.js +3 -0
  68. package/dist/index.d.ts +7 -0
  69. package/dist/index.js +7 -0
  70. package/dist/magnetic-blocks/engine.d.ts +11 -0
  71. package/dist/magnetic-blocks/engine.js +263 -0
  72. package/dist/magnetic-blocks/persistence.d.ts +7 -0
  73. package/dist/magnetic-blocks/persistence.js +104 -0
  74. package/dist/magnetic-blocks/physics.d.ts +30 -0
  75. package/dist/magnetic-blocks/physics.js +155 -0
  76. package/dist/magnetic-blocks/random.d.ts +6 -0
  77. package/dist/magnetic-blocks/random.js +19 -0
  78. package/dist/magnetic-blocks/types.d.ts +158 -0
  79. package/dist/magnetic-blocks/types.js +1 -0
  80. package/dist/magnetic-blocks/validation.d.ts +23 -0
  81. package/dist/magnetic-blocks/validation.js +121 -0
  82. package/dist/magnetic-blocks.d.ts +5 -0
  83. package/dist/magnetic-blocks.js +4 -0
  84. package/dist/nature/attraction.d.ts +5 -0
  85. package/dist/nature/attraction.js +136 -0
  86. package/dist/nature/environment.d.ts +91 -0
  87. package/dist/nature/environment.js +271 -0
  88. package/dist/nature/marking.d.ts +3 -0
  89. package/dist/nature/marking.js +39 -0
  90. package/dist/nature/random.d.ts +3 -0
  91. package/dist/nature/random.js +19 -0
  92. package/dist/nature/rebound.d.ts +5 -0
  93. package/dist/nature/rebound.js +98 -0
  94. package/dist/nature/state.d.ts +3 -0
  95. package/dist/nature/state.js +22 -0
  96. package/dist/nature/types.d.ts +124 -0
  97. package/dist/nature/types.js +1 -0
  98. package/dist/nature/validation.d.ts +10 -0
  99. package/dist/nature/validation.js +117 -0
  100. package/dist/nature.d.ts +8 -0
  101. package/dist/nature.js +7 -0
  102. package/dist/stone-collapse/content-data.d.ts +14218 -0
  103. package/dist/stone-collapse/content-data.js +25217 -0
  104. package/dist/stone-collapse/content-types.d.ts +77 -0
  105. package/dist/stone-collapse/content-types.js +1 -0
  106. package/dist/stone-collapse/content.d.ts +11 -0
  107. package/dist/stone-collapse/content.js +28 -0
  108. package/dist/stone-collapse/engine.d.ts +19 -0
  109. package/dist/stone-collapse/engine.js +570 -0
  110. package/dist/stone-collapse/persistence.d.ts +7 -0
  111. package/dist/stone-collapse/persistence.js +230 -0
  112. package/dist/stone-collapse/types.d.ts +216 -0
  113. package/dist/stone-collapse/types.js +1 -0
  114. package/dist/stone-collapse/validation.d.ts +21 -0
  115. package/dist/stone-collapse/validation.js +134 -0
  116. package/dist/stone-collapse.d.ts +6 -0
  117. package/dist/stone-collapse.js +4 -0
  118. package/package.json +118 -4
@@ -0,0 +1,541 @@
1
+ import { compactBoard, findMatches } from './match.js';
2
+ import { drawBag, seedState } from './random.js';
3
+ const PRESETS = { compact: [5, 13], extraWide: [12, 13], narrow: [6, 13], standard: [8, 13], wide: [10, 13], tall: [8, 17], deep: [8, 32], large: [12, 24] };
4
+ const RULES = 'triplets-1';
5
+ const HIDDEN = 3;
6
+ const MAX_VISIBLE_CELLS = 512;
7
+ function validateDimensions(width, height) {
8
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 5 || width > 16 || height < 12 || height > 32 || width * height > MAX_VISIBLE_CELLS)
9
+ throw new RangeError('Board must be 5–16 columns by 12–32 visible rows, with at most 512 cells');
10
+ }
11
+ const emptyTransition = (state, reason) => ({ state, events: [], accepted: false, reason });
12
+ const addr = (x, y, width) => (y + HIDDEN) * width + x;
13
+ function drawTriplet(state) {
14
+ let bag = state.bag;
15
+ let randomState = state.randomState;
16
+ const colours = [];
17
+ for (let i = 0; i < 3; i++) {
18
+ [bag, randomState] = drawBag(bag, randomState, state.settings.colourCount);
19
+ colours.push(bag[bag.length - 1]);
20
+ bag = bag.slice(0, -1);
21
+ }
22
+ return { colours: colours, bag, randomState };
23
+ }
24
+ function makeGem(state, colour, target = false) { return { id: state.nextId, colour, ...(target ? { target: true } : {}) }; }
25
+ function activeFromColours(state, colours, y = -3) {
26
+ return { gems: colours.map((colour, i) => makeGem({ nextId: state.nextId + i }, colour)), x: Math.floor((state.settings.width - 1) / 2), y, orientation: 0, level: state.settings.mode === 'arcade' || state.settings.mode === 'daily' ? 1 + Math.floor(state.completedPieces / 30) : 1, descent: 0, lockTicks: 0, lockResets: 0, lockStarted: false };
27
+ }
28
+ function occupied(state, piece, x = piece.x, y = piece.y) {
29
+ return piece.gems.some((_, i) => { const cy = y + i; return x < 0 || x >= state.settings.width || cy < -HIDDEN || cy >= state.settings.height || state.board[addr(x, cy, state.settings.width)] !== null; });
30
+ }
31
+ /** Returns the exact landing row for the current rigid triplet. */
32
+ export function landingY(state) {
33
+ if (!state.active)
34
+ return null;
35
+ let y = state.active.y;
36
+ while (!occupied(state, state.active, state.active.x, y + 1))
37
+ y++;
38
+ return y;
39
+ }
40
+ function advanceBag(state) {
41
+ let bag = state.bag, randomState = state.randomState;
42
+ const next = [];
43
+ for (let p = 0; p < 3; p++) {
44
+ const draw = drawTriplet({ bag, randomState, settings: state.settings });
45
+ bag = draw.bag;
46
+ randomState = draw.randomState;
47
+ next.push(draw.colours);
48
+ }
49
+ return [bag, randomState, next];
50
+ }
51
+ /** Creates a deterministic empty game with an active piece and three-piece preview. */
52
+ export function createGame(options = {}) {
53
+ if (options.preset !== undefined && !Object.hasOwn(PRESETS, options.preset))
54
+ throw new RangeError('Unsupported board preset');
55
+ const preset = PRESETS[options.preset ?? (options.mode === 'daily' ? 'narrow' : 'standard')];
56
+ const width = options.width ?? preset[0];
57
+ const height = options.height ?? preset[1];
58
+ validateDimensions(width, height);
59
+ const requestedMode = options.mode ?? 'relaxed';
60
+ if (!['relaxed', 'arcade', 'daily', 'challenge'].includes(requestedMode))
61
+ throw new RangeError('Unsupported mode');
62
+ if (requestedMode === 'challenge')
63
+ throw new RangeError('Use createChallenge with a validated starting board and finite queue');
64
+ const mode = requestedMode;
65
+ const seed = options.seed ?? 'houseki-falling-triplets';
66
+ if (typeof seed !== 'string' || seed.length < 1 || seed.length > 128)
67
+ throw new RangeError('Seed must contain 1–128 characters');
68
+ if (mode === 'daily') {
69
+ const timestamp = /^\d{4}-\d{2}-\d{2}$/.test(seed) ? Date.parse(`${seed}T00:00:00Z`) : Number.NaN;
70
+ if (!Number.isFinite(timestamp) || new Date(timestamp).toISOString().slice(0, 10) !== seed)
71
+ throw new RangeError('Daily seed must be a UTC YYYY-MM-DD date supplied by the host');
72
+ }
73
+ if (mode === 'daily' && (width !== 6 || height !== 13 || (options.colourCount ?? 5) !== 5 || (options.pieceLimit !== undefined && options.pieceLimit !== 60)))
74
+ throw new RangeError('Daily uses the fixed 6×13 well, five colours, and 60 pieces');
75
+ const colourCount = options.colourCount ?? 5;
76
+ const pieceLimit = mode === 'daily' ? 60 : options.pieceLimit;
77
+ if (pieceLimit !== undefined && (!Number.isInteger(pieceLimit) || pieceLimit < 1 || pieceLimit > 60))
78
+ throw new RangeError('Piece limit must be an integer from 1 to 60');
79
+ const settings = { mode, width, height, colourCount, seed, ...(pieceLimit === undefined ? {} : { pieceLimit }) };
80
+ if (![4, 5, 6].includes(settings.colourCount))
81
+ throw new RangeError('Colour count must be 4, 5, or 6');
82
+ const randomState = seedState(`falling-triplets|${RULES}|${seed}`);
83
+ const initial = { game: 'falling-triplets', rules: RULES, settings, board: Array(width * (height + HIDDEN)).fill(null), phase: 'falling', active: null, next: [], bag: [], randomState, nextId: 1, score: 0, maxChain: 0, completedPieces: 0, elapsedTicks: 0, pieceTicks: 0, resolutionTick: 0, waves: [], assisted: false, hintsUsed: 0, witnessPathMatches: true, recording: [] };
84
+ const first = drawTriplet(initial);
85
+ let state = { ...initial, bag: first.bag, randomState: first.randomState, hintsUsed: 0 };
86
+ const preview = advanceBag(state);
87
+ state = { ...state, bag: preview[0], randomState: preview[1], active: activeFromColours(state, first.colours), next: preview[2], nextId: 4 };
88
+ return state;
89
+ }
90
+ const VALID_COLOURS = new Set(['red', 'blue', 'green', 'gold', 'purple', 'teal']);
91
+ /** Creates a challenge after validating its authored board, finite queue, goal, and witness. */
92
+ export function createChallenge(options) {
93
+ const width = options.width ?? 6;
94
+ const height = options.height ?? 13;
95
+ const colourCount = options.colourCount ?? 5;
96
+ validateDimensions(width, height);
97
+ if (![4, 5, 6].includes(colourCount))
98
+ throw new RangeError('Colour count must be 4, 5, or 6');
99
+ const seed = options.seed ?? 'houseki-challenge';
100
+ if (typeof seed !== 'string' || seed.length < 1 || seed.length > 128)
101
+ throw new RangeError('Challenge seed must contain 1–128 characters');
102
+ if (!Array.isArray(options.board) || options.board.length !== width * height)
103
+ throw new RangeError('Challenge board must contain exactly width × height visible cells');
104
+ if (!Array.isArray(options.queue) || options.queue.length < 2 || options.queue.length > 12)
105
+ throw new RangeError('Challenge queue must contain 2–12 triplets');
106
+ const colours = ['red', 'blue', 'green', 'gold', 'purple', 'teal'].slice(0, colourCount);
107
+ const ids = new Set();
108
+ let maxId = 0;
109
+ const cleanBoard = [];
110
+ for (let i = 0; i < options.board.length; i++) {
111
+ const cell = options.board[i];
112
+ if (cell === null) {
113
+ cleanBoard.push(null);
114
+ continue;
115
+ }
116
+ if (!cell || typeof cell !== 'object' || !Number.isSafeInteger(cell.id) || cell.id < 1 || cell.id > 1_000_000_000 || ids.has(cell.id) || !VALID_COLOURS.has(cell.colour) || !colours.includes(cell.colour) || (cell.target !== undefined && typeof cell.target !== 'boolean'))
117
+ throw new TypeError(`Invalid challenge gem at cell ${i}`);
118
+ if (Object.keys(cell).some(key => !['id', 'colour', 'target'].includes(key)))
119
+ throw new TypeError(`Unexpected challenge gem field at cell ${i}`);
120
+ ids.add(cell.id);
121
+ maxId = Math.max(maxId, cell.id);
122
+ cleanBoard.push({ id: cell.id, colour: cell.colour, ...(cell.target ? { target: true } : {}) });
123
+ }
124
+ for (let x = 0; x < width; x++) {
125
+ let gap = false;
126
+ for (let y = height - 1; y >= 0; y--) {
127
+ if (cleanBoard[y * width + x] === null)
128
+ gap = true;
129
+ else if (gap)
130
+ throw new RangeError(`Challenge board is unstable in column ${x}`);
131
+ }
132
+ }
133
+ const queue = options.queue.map((triplet, index) => {
134
+ if (!Array.isArray(triplet) || triplet.length !== 3 || triplet.some(colour => !colours.includes(colour)))
135
+ throw new TypeError(`Invalid challenge triplet ${index}`);
136
+ return [...triplet];
137
+ });
138
+ if (!options.goal || typeof options.goal !== 'object' || !['targets', 'chain', 'empty'].includes(options.goal.type))
139
+ throw new TypeError('Invalid challenge goal');
140
+ let goal;
141
+ let targetIds;
142
+ let minimumChain;
143
+ if (options.goal.type === 'targets') {
144
+ const raw = options.goal;
145
+ const targetSet = raw.targetIds;
146
+ if (Object.keys(raw).some(key => !['type', 'targetIds'].includes(key)) || !Array.isArray(targetSet) || !targetSet.length || targetSet.length > cleanBoard.length || targetSet.some(id => !Number.isSafeInteger(id) || !ids.has(id)) || new Set(targetSet).size !== targetSet.length)
147
+ throw new TypeError('Target goal must reference unique starting gem IDs');
148
+ targetIds = [...targetSet];
149
+ goal = 'targets';
150
+ const marked = cleanBoard.filter((gem) => gem !== null && gem.target === true).map(gem => gem.id).sort((a, b) => a - b);
151
+ if (stable(marked) !== stable([...targetIds].sort((a, b) => a - b)))
152
+ throw new TypeError('Target flags must exactly match the target goal IDs');
153
+ }
154
+ else if (options.goal.type === 'chain') {
155
+ const raw = options.goal;
156
+ const value = raw.minimumChain;
157
+ if (Object.keys(raw).some(key => !['type', 'minimumChain'].includes(key)) || typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 12)
158
+ throw new RangeError('Minimum chain must be an integer from 1 to 12');
159
+ minimumChain = value;
160
+ goal = 'chain';
161
+ }
162
+ else {
163
+ if (Object.keys(options.goal).some(key => key !== 'type'))
164
+ throw new TypeError('Unexpected empty-goal field');
165
+ goal = 'empty';
166
+ }
167
+ if (options.witness !== undefined && (!Array.isArray(options.witness) || options.witness.length > queue.length || options.witness.some(hint => !hint || !Number.isInteger(hint.x) || hint.x < 0 || hint.x >= width || ![0, 1, 2].includes(hint.orientation) || Object.keys(hint).some(key => !['x', 'orientation'].includes(key)))))
168
+ throw new TypeError('Invalid challenge witness');
169
+ const internalBoard = Array(width * (height + HIDDEN)).fill(null);
170
+ for (let i = 0; i < cleanBoard.length; i++)
171
+ internalBoard[i + width * HIDDEN] = cleanBoard[i];
172
+ const settings = { mode: 'challenge', width, height, colourCount: colourCount, seed, goal, challengeBoard: cleanBoard, challengeQueue: queue, ...(targetIds ? { targetIds } : {}), ...(minimumChain ? { minimumChain } : {}), ...(options.witness ? { witness: options.witness.map(hint => ({ ...hint })) } : {}) };
173
+ const base = { game: 'falling-triplets', rules: RULES, settings, board: internalBoard, phase: 'falling', active: null, next: [], bag: [], randomState: seedState(`falling-triplets|${RULES}|${seed}`), nextId: maxId + 1, score: 0, maxChain: 0, completedPieces: 0, elapsedTicks: 0, pieceTicks: 0, resolutionTick: 0, waves: [], assisted: false, hintsUsed: 0, witnessPathMatches: true, challengeIndex: 0, recording: [] };
174
+ const firstPieceBase = activeFromColours(base, queue[0]);
175
+ const active = { ...firstPieceBase, gems: firstPieceBase.gems.map((gem, i) => ({ ...gem, id: maxId + 1 + i })) };
176
+ const state = { ...base, settings, board: internalBoard, active, next: queue.slice(1, 4), bag: [], nextId: maxId + 4, hintsUsed: 0, challengeIndex: 0, assisted: false };
177
+ if (occupied(state, active))
178
+ throw new RangeError('Challenge starting board blocks the first queued triplet');
179
+ if (options.witness) {
180
+ if (options.witness.length === 0)
181
+ throw new TypeError('A supplied witness must contain a complete legal solution');
182
+ let proof = state;
183
+ let proofTicks = 0;
184
+ for (const step of options.witness) {
185
+ if (proof.phase !== 'falling' || !proof.active)
186
+ break;
187
+ while (proof.active.x !== step.x) {
188
+ const action = { kind: step.x < proof.active.x ? 'left' : 'right' };
189
+ const result = applyActionCore(proof, action);
190
+ if (!result.accepted)
191
+ throw new TypeError('Challenge witness contains a blocked placement');
192
+ proof = result.state;
193
+ }
194
+ const turns = (step.orientation - proof.active.orientation + 3) % 3;
195
+ for (let i = 0; i < turns; i++) {
196
+ const result = applyActionCore(proof, { kind: 'cycle-forward' });
197
+ if (!result.accepted)
198
+ throw new TypeError('Challenge witness contains an illegal cycle');
199
+ proof = result.state;
200
+ }
201
+ const placed = applyActionCore(proof, { kind: 'hard-drop' });
202
+ if (!placed.accepted)
203
+ throw new TypeError('Challenge witness contains an illegal placement');
204
+ proof = placed.state;
205
+ while (['clear-mark', 'clear-remove', 'gravity'].includes(proof.phase) && proofTicks < 100_000) {
206
+ proof = advanceTicks(proof, 1).state;
207
+ proofTicks++;
208
+ }
209
+ if (['lost', 'finished'].includes(proof.phase))
210
+ throw new TypeError('Challenge witness ends before its goal is achieved');
211
+ if (proof.phase === 'won')
212
+ break;
213
+ }
214
+ if (proof.phase !== 'won')
215
+ throw new TypeError('Challenge witness does not solve its goal within the finite queue');
216
+ }
217
+ return state;
218
+ }
219
+ function waveCells(state) { return [...new Set(findMatches(state.board, state.settings.width, state.settings.height + HIDDEN).flatMap(match => match.cells))].sort((a, b) => a - b); }
220
+ function spawnOrEnd(state) {
221
+ const board = state.board;
222
+ const targetIds = state.settings.targetIds ?? [];
223
+ let phase = 'falling';
224
+ let reason;
225
+ if (state.settings.mode === 'challenge' && state.settings.goal === 'targets' && targetIds.length > 0 && targetIds.every(id => !board.some(g => g?.id === id)))
226
+ phase = 'won';
227
+ else if (state.settings.mode === 'challenge' && state.settings.goal === 'chain' && state.maxChain >= (state.settings.minimumChain ?? 1))
228
+ phase = 'won';
229
+ else if (state.settings.mode === 'challenge' && state.settings.goal === 'empty' && board.every(g => g === null))
230
+ phase = 'won';
231
+ else if (board.slice(0, state.settings.width * HIDDEN).some(g => g !== null)) {
232
+ phase = 'lost';
233
+ reason = 'top-out';
234
+ }
235
+ else if (state.settings.pieceLimit !== undefined && state.completedPieces >= state.settings.pieceLimit)
236
+ phase = state.settings.mode === 'daily' ? 'finished' : 'lost';
237
+ if (phase !== 'falling')
238
+ return [{ ...state, phase, active: null, ...(reason ? { reason } : {}) }, [{ type: 'run-ended', reason: reason ?? phase }]];
239
+ if (state.settings.mode === 'challenge') {
240
+ const queue = state.settings.challengeQueue;
241
+ const index = (state.challengeIndex ?? 0) + 1;
242
+ if (index >= queue.length)
243
+ return [{ ...state, phase: 'lost', active: null, next: [], reason: 'challenge-queue-exhausted' }, [{ type: 'run-ended', reason: 'challenge-queue-exhausted' }]];
244
+ const colours = queue[index];
245
+ const base = activeFromColours(state, colours);
246
+ const active = { ...base, gems: base.gems.map((gem, i) => ({ ...gem, id: state.nextId + i })) };
247
+ const next = { ...state, active, challengeIndex: index, nextId: state.nextId + 3, next: queue.slice(index + 1, index + 4) };
248
+ if (occupied(next, active))
249
+ return [{ ...next, phase: 'lost', active: null, reason: 'spawn-collision' }, [{ type: 'run-ended', reason: 'spawn-collision' }]];
250
+ return [next, [{ type: 'piece-spawned', ids: active.gems.map(g => g.id) }]];
251
+ }
252
+ const queue = [...state.next];
253
+ const colours = queue.shift();
254
+ const draw = drawTriplet(state);
255
+ queue.push(draw.colours);
256
+ const base = activeFromColours(state, colours);
257
+ const active = { ...base, gems: base.gems.map((gem, i) => ({ ...gem, id: state.nextId + i })) };
258
+ let next = { ...state, active, nextId: state.nextId + 3, next: queue, bag: draw.bag, randomState: draw.randomState };
259
+ if (occupied(next, active))
260
+ next = { ...next, phase: 'lost', active: null, reason: 'spawn-collision' };
261
+ return [next, [{ type: next.phase === 'lost' ? 'run-ended' : 'piece-spawned', ...(next.phase === 'lost' ? { reason: 'spawn-collision' } : { ids: active.gems.map(g => g.id) }) }]];
262
+ }
263
+ function beginWave(state, chain, level) {
264
+ const cells = waveCells(state);
265
+ if (!cells.length)
266
+ return spawnOrEnd({ ...state, phase: 'falling', active: null, pendingClear: undefined, resolutionChain: undefined, resolutionLevel: undefined, resolutionTick: 0 });
267
+ const ids = cells.map(cell => state.board[cell].id);
268
+ return [{ ...state, phase: 'clear-mark', active: null, pendingClear: cells, resolutionChain: chain, resolutionLevel: level, resolutionTick: 0 }, [{ type: 'match-marked', chain, cells, ids }]];
269
+ }
270
+ function lock(state) {
271
+ const piece = state.active;
272
+ const placed = [...state.board];
273
+ for (let i = 0; i < 3; i++) {
274
+ const y = piece.y + i;
275
+ const at = addr(piece.x, y, state.settings.width);
276
+ if (y < -HIDDEN || y >= state.settings.height || placed[at] !== null)
277
+ return { state: { ...state, phase: 'lost', active: null, reason: 'spawn-collision' }, events: [{ type: 'run-ended', reason: 'spawn-collision' }], accepted: true };
278
+ placed[at] = piece.gems[i];
279
+ }
280
+ const board = compactBoard(placed, state.settings.width, state.settings.height + HIDDEN);
281
+ const witnessStep = state.settings.witness?.[state.completedPieces];
282
+ const witnessPathMatches = state.witnessPathMatches && (!witnessStep || (witnessStep.x === piece.x && witnessStep.orientation === piece.orientation));
283
+ const next = { ...state, board, active: null, completedPieces: state.completedPieces + 1, witnessPathMatches, pieceTicks: 0, waves: [], resolutionTick: 0 };
284
+ const [resolved, events] = beginWave(next, 1, piece.level);
285
+ return { state: resolved, events: [{ type: 'piece-locked', x: piece.x, y: piece.y, ids: piece.gems.map(g => g.id) }, ...events], accepted: true };
286
+ }
287
+ function advanceResolution(state) {
288
+ let next = { ...state, resolutionTick: state.resolutionTick + 1 };
289
+ const events = [];
290
+ if (next.phase === 'clear-mark' && next.resolutionTick >= 7)
291
+ next = { ...next, phase: 'clear-remove', resolutionTick: 0 };
292
+ else if (next.phase === 'clear-remove' && next.resolutionTick >= 6) {
293
+ const cells = new Set(next.pendingClear ?? []);
294
+ const ids = [...cells].map(index => next.board[index].id);
295
+ const chain = next.resolutionChain ?? 1;
296
+ const points = 10 * cells.size * (next.resolutionLevel ?? 1) * chain;
297
+ const board = next.board.map((gem, index) => cells.has(index) ? null : gem);
298
+ const gravityBoard = compactBoard(board, next.settings.width, next.settings.height + HIDDEN);
299
+ const moved = gravityBoard.flatMap((gem, index) => gem && board.findIndex(old => old?.id === gem.id) !== index ? [{ id: gem.id, from: board.findIndex(old => old?.id === gem.id), to: index }] : []);
300
+ const wave = { clearedIds: ids, cells: [...cells], chain, points };
301
+ const waves = [...next.waves, wave];
302
+ const score = next.score + points;
303
+ next = { ...next, board, gravityBoard, score, waves, maxChain: Math.max(next.maxChain, chain), phase: 'gravity', pendingClear: undefined, resolutionTick: 0 };
304
+ events.push({ type: 'cells-cleared', chain, cells: [...cells], ids, points }, { type: 'score-changed', score, points });
305
+ if (!moved.length)
306
+ events.push({ type: 'gravity-started', cells: [] });
307
+ }
308
+ else if (next.phase === 'gravity' && next.resolutionTick >= 9) {
309
+ const settled = next.gravityBoard ?? next.board;
310
+ const moved = settled.flatMap((gem, index) => gem && next.board.findIndex(old => old?.id === gem.id) !== index ? [{ id: gem.id, from: next.board.findIndex(old => old?.id === gem.id), to: index }] : []);
311
+ if (moved.length)
312
+ events.push({ type: 'cells-fell', gems: moved });
313
+ const result = beginWave({ ...next, board: settled, gravityBoard: undefined, resolutionTick: 0, phase: 'falling' }, (next.resolutionChain ?? next.waves.at(-1)?.chain ?? 1) + 1, next.resolutionLevel ?? 1);
314
+ next = result[0];
315
+ events.push(...result[1]);
316
+ }
317
+ return { state: next, events, accepted: true };
318
+ }
319
+ /** Applies one immutable game action. Rejected actions preserve the original state reference. */
320
+ function applyActionCore(state, action) {
321
+ if (action.kind === 'pause') {
322
+ if (!['falling', 'clear-mark', 'clear-remove', 'gravity'].includes(state.phase))
323
+ return emptyTransition(state, 'cannot-pause');
324
+ return { state: { ...state, phase: 'paused', pausedPhase: state.phase, assisted: state.assisted || state.settings.mode === 'daily' }, events: [{ type: 'paused' }], accepted: true };
325
+ }
326
+ if (action.kind === 'resume') {
327
+ if (state.phase !== 'paused')
328
+ return emptyTransition(state, 'not-paused');
329
+ const { pausedPhase, ...rest } = state;
330
+ return { state: { ...rest, phase: pausedPhase ?? 'falling' }, events: [{ type: 'resumed' }], accepted: true };
331
+ }
332
+ if (action.kind === 'hint') {
333
+ if (state.settings.mode !== 'challenge' || state.phase !== 'falling' || !state.active)
334
+ return emptyTransition(state, 'hint-only-in-active-challenge');
335
+ const hint = state.settings.witness?.[state.completedPieces];
336
+ if (!hint || !state.witnessPathMatches)
337
+ return emptyTransition(state, 'no-witness-hint-available');
338
+ return { state: { ...state, assisted: true, hintsUsed: state.hintsUsed + 1 }, events: [{ type: 'hint-shown', piece: state.completedPieces, ...hint }], accepted: true };
339
+ }
340
+ if (state.phase !== 'falling' || !state.active)
341
+ return emptyTransition(state, 'not-falling');
342
+ const piece = state.active;
343
+ if (action.kind === 'cycle-forward' || action.kind === 'cycle-backward') {
344
+ const [a, b, c] = piece.gems;
345
+ const gems = action.kind === 'cycle-forward' ? [c, a, b] : [b, c, a];
346
+ const orientation = ((piece.orientation + (action.kind === 'cycle-forward' ? 1 : 2)) % 3);
347
+ const grounded = occupied(state, piece, piece.x, piece.y + 1);
348
+ const reset = grounded && piece.lockResets < 8;
349
+ return { state: { ...state, active: { ...piece, gems: gems, orientation, lockTicks: reset ? 0 : piece.lockTicks, lockResets: piece.lockResets + (reset ? 1 : 0), lockStarted: piece.lockStarted || grounded } }, events: [{ type: 'colours-cycled', direction: action.kind === 'cycle-forward' ? 'forward' : 'backward' }], accepted: true };
350
+ }
351
+ if (action.kind === 'left' || action.kind === 'right') {
352
+ const x = piece.x + (action.kind === 'left' ? -1 : 1);
353
+ if (occupied(state, piece, x))
354
+ return emptyTransition(state, 'blocked');
355
+ const wasGrounded = occupied(state, piece, piece.x, piece.y + 1);
356
+ const reset = wasGrounded && piece.lockResets < 8;
357
+ const isAirborne = !occupied(state, piece, x, piece.y + 1);
358
+ const leavingWithReset = wasGrounded && reset;
359
+ const lockTicks = leavingWithReset || (isAirborne && piece.lockResets < 8) ? 0 : piece.lockTicks;
360
+ return { state: { ...state, active: { ...piece, x, lockTicks, lockStarted: isAirborne ? (piece.lockResets >= 8 ? piece.lockStarted : false) : true, lockResets: piece.lockResets + (reset ? 1 : 0) } }, events: [{ type: 'piece-moved', x, y: piece.y }], accepted: true };
361
+ }
362
+ if (action.kind === 'tick')
363
+ return advanceTicks(state, 1);
364
+ if (action.kind === 'soft-drop') {
365
+ if (state.settings.mode === 'relaxed')
366
+ return emptyTransition(state, 'soft-drop-disabled');
367
+ if (occupied(state, piece, piece.x, piece.y + 1))
368
+ return { state: { ...state, active: piece }, events: [], accepted: true };
369
+ const resetsAvailable = piece.lockResets < 8;
370
+ const y = piece.y + 1;
371
+ const becameGrounded = occupied(state, piece, piece.x, y + 1);
372
+ return { state: { ...state, active: { ...piece, y, descent: 0, lockTicks: resetsAvailable ? 0 : piece.lockTicks, lockStarted: becameGrounded ? false : resetsAvailable ? false : piece.lockStarted }, score: state.score + 1 }, events: [{ type: 'piece-moved', x: piece.x, y }, { type: 'score-changed', score: state.score + 1, points: 1 }], accepted: true };
373
+ }
374
+ if (action.kind === 'place' || action.kind === 'hard-drop') {
375
+ if (action.kind === 'place' && state.settings.mode !== 'relaxed')
376
+ return emptyTransition(state, 'place-only-in-relaxed');
377
+ if (action.kind === 'hard-drop' && state.settings.mode === 'relaxed')
378
+ return emptyTransition(state, 'use-place-in-relaxed');
379
+ let y = piece.y;
380
+ while (!occupied(state, piece, piece.x, y + 1))
381
+ y++;
382
+ const distance = y - piece.y;
383
+ const points = action.kind === 'hard-drop' ? distance * 2 : 0;
384
+ const result = lock({ ...state, active: { ...piece, y }, score: state.score + points });
385
+ return points ? { ...result, events: [{ type: 'score-changed', score: state.score + points, points }, ...result.events] } : result;
386
+ }
387
+ return emptyTransition(state, 'unsupported-action');
388
+ }
389
+ /** Applies and records one immutable action; rejected actions preserve state identity. */
390
+ export function applyAction(state, action) {
391
+ const result = applyActionCore(state, action);
392
+ if (!result.accepted || action.kind === 'tick')
393
+ return result;
394
+ const last = state.recording.at(-1);
395
+ const ordinal = last?.tick === state.elapsedTicks ? last.ordinal + 1 : 0;
396
+ return { ...result, state: { ...result.state, recording: [...state.recording, { tick: state.elapsedTicks, ordinal, action }] } };
397
+ }
398
+ /** Advances fixed 60 Hz game ticks. Arcade timing is applied to the active piece. */
399
+ export function advanceTicks(state, ticks) {
400
+ if (!Number.isInteger(ticks) || ticks < 0 || ticks > 3600)
401
+ throw new RangeError('ticks must be an integer from 0 to 3600');
402
+ let current = state;
403
+ const events = [];
404
+ for (let i = 0; i < ticks && !['paused', 'won', 'lost', 'finished'].includes(current.phase); i++) {
405
+ if (current.phase !== 'falling') {
406
+ const result = advanceResolution(current);
407
+ current = { ...result.state, elapsedTicks: result.state.elapsedTicks + 1 };
408
+ events.push(...result.events);
409
+ continue;
410
+ }
411
+ if (!current.active)
412
+ break;
413
+ const active = current.active;
414
+ current = { ...current, elapsedTicks: current.elapsedTicks + 1, pieceTicks: current.pieceTicks + 1 };
415
+ if (current.settings.mode === 'relaxed')
416
+ continue;
417
+ const level = active.level;
418
+ const interval = Math.max(6, Math.round(60 * 0.85 ** (level - 1)));
419
+ const grounded = occupied(current, active, active.x, active.y + 1);
420
+ if (grounded) {
421
+ const lockStarted = active.lockStarted;
422
+ const lockTicks = lockStarted ? active.lockTicks + 1 : 0;
423
+ current = { ...current, active: { ...active, lockStarted: true, lockTicks } };
424
+ if (lockTicks >= 24) {
425
+ const result = lock(current);
426
+ current = result.state;
427
+ events.push(...result.events);
428
+ }
429
+ }
430
+ else if (active.descent + 1 >= interval) {
431
+ const y = active.y + 1;
432
+ const becomesGrounded = occupied(current, active, active.x, y + 1);
433
+ current = { ...current, active: { ...active, y, descent: 0, lockTicks: active.lockResets < 8 && !becomesGrounded ? 0 : active.lockTicks, lockStarted: becomesGrounded && active.lockResets < 8 ? true : active.lockResets < 8 ? false : active.lockStarted } };
434
+ }
435
+ else
436
+ current = { ...current, active: { ...active, descent: active.descent + 1, lockTicks: active.lockResets < 8 ? 0 : active.lockTicks, lockStarted: active.lockResets < 8 ? false : active.lockStarted } };
437
+ }
438
+ return { state: current, events, accepted: true };
439
+ }
440
+ /** Lists the core actions currently available to the player. */
441
+ export function legalActions(state) {
442
+ if (state.phase === 'paused')
443
+ return [{ kind: 'resume' }];
444
+ if (['clear-mark', 'clear-remove', 'gravity'].includes(state.phase))
445
+ return [{ kind: 'pause' }];
446
+ if (state.phase !== 'falling' || !state.active)
447
+ return [];
448
+ const piece = state.active;
449
+ const actions = [];
450
+ if (!occupied(state, piece, piece.x - 1))
451
+ actions.push({ kind: 'left' });
452
+ if (!occupied(state, piece, piece.x + 1))
453
+ actions.push({ kind: 'right' });
454
+ actions.push({ kind: 'cycle-forward' }, { kind: 'cycle-backward' });
455
+ if (state.settings.mode === 'relaxed')
456
+ actions.push({ kind: 'place' });
457
+ else
458
+ actions.push({ kind: 'soft-drop' }, { kind: 'hard-drop' });
459
+ if (state.settings.mode === 'challenge' && state.witnessPathMatches && state.settings.witness?.[state.completedPieces])
460
+ actions.push({ kind: 'hint' });
461
+ actions.push({ kind: 'pause' });
462
+ return actions;
463
+ }
464
+ /** Returns a plain status summary suitable for UI labels. */
465
+ export function statusOf(state) { return { phase: state.phase, score: state.score, maxChain: state.maxChain, ...(state.reason ? { reason: state.reason } : {}) }; }
466
+ /** Recreates the same authored mode, seed, and challenge configuration from its original setup. */
467
+ export function restartGame(state) {
468
+ if (state.settings.mode === 'challenge') {
469
+ const settings = state.settings;
470
+ const goal = settings.goal === 'targets' ? { type: 'targets', targetIds: settings.targetIds ?? [] } : settings.goal === 'chain' ? { type: 'chain', minimumChain: settings.minimumChain ?? 1 } : { type: 'empty' };
471
+ return createChallenge({ seed: settings.seed, width: settings.width, height: settings.height, colourCount: settings.colourCount, board: settings.challengeBoard ?? [], queue: settings.challengeQueue ?? [], goal, ...(settings.witness ? { witness: settings.witness } : {}) });
472
+ }
473
+ return createGame({ mode: state.settings.mode, seed: state.settings.seed, width: state.settings.width, height: state.settings.height, colourCount: state.settings.colourCount, ...(state.settings.pieceLimit === undefined ? {} : { pieceLimit: state.settings.pieceLimit }) });
474
+ }
475
+ /** Encodes the authoritative settings, action recording, and derived checkpoint. */
476
+ export function encodeGame(state) { return JSON.stringify({ format: 1, game: state.game, rules: state.rules, settings: state.settings, actions: state.recording, elapsedTicks: state.elapsedTicks, checkpoint: state }); }
477
+ function stable(value) {
478
+ if (Array.isArray(value))
479
+ return `[${value.map(stable).join(',')}]`;
480
+ if (value && typeof value === 'object')
481
+ return `{${Object.entries(value).filter(([, item]) => item !== undefined).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${stable(item)}`).join(',')}}`;
482
+ return JSON.stringify(value) ?? 'null';
483
+ }
484
+ /** Replays and validates a bounded save, then verifies its checkpoint against the derived state. */
485
+ export function decodeGame(text) {
486
+ if (text.length > 2 * 1024 * 1024)
487
+ throw new RangeError('Save exceeds 2 MiB');
488
+ const value = JSON.parse(text);
489
+ if (value.format !== 1 || value.game !== 'falling-triplets' || value.rules !== RULES || !value.settings || !Array.isArray(value.actions) || !value.checkpoint)
490
+ throw new TypeError('Unsupported or invalid Falling Triplets save');
491
+ if (value.actions.length > 10_000 || !Number.isInteger(value.elapsedTicks) || value.elapsedTicks < 0 || value.elapsedTicks > 600_000)
492
+ throw new RangeError('Save exceeds replay limits');
493
+ const s = value.settings;
494
+ let state;
495
+ if (s.mode === 'challenge') {
496
+ const goal = s.goal === 'targets' ? { type: 'targets', targetIds: s.targetIds ?? [] } : s.goal === 'chain' ? { type: 'chain', minimumChain: s.minimumChain ?? 1 } : s.goal === 'empty' ? { type: 'empty' } : null;
497
+ if (!goal)
498
+ throw new TypeError('Invalid challenge goal in save');
499
+ state = createChallenge({ seed: s.seed, width: s.width, height: s.height, colourCount: s.colourCount, board: s.challengeBoard ?? [], queue: s.challengeQueue ?? [], goal, ...(s.witness ? { witness: s.witness } : {}) });
500
+ }
501
+ else
502
+ state = createGame({ mode: s.mode, seed: s.seed, width: s.width, height: s.height, colourCount: s.colourCount, ...(s.pieceLimit === undefined ? {} : { pieceLimit: s.pieceLimit }) });
503
+ if (stable(state.settings) !== stable(s))
504
+ throw new TypeError('Save settings are not canonical');
505
+ let previousTick = 0;
506
+ let previousOrdinal = -1;
507
+ const replayRecording = [];
508
+ for (const raw of value.actions) {
509
+ if (!raw || typeof raw !== 'object')
510
+ throw new TypeError('Invalid recorded action');
511
+ const item = raw;
512
+ if (!Number.isInteger(item.tick) || item.tick < previousTick || item.tick > value.elapsedTicks || !Number.isInteger(item.ordinal) || !item.action || Object.keys(item.action).some(key => key !== 'kind') || !['left', 'right', 'soft-drop', 'hard-drop', 'place', 'cycle-forward', 'cycle-backward', 'pause', 'resume', 'hint'].includes(item.action.kind))
513
+ throw new TypeError('Invalid recorded action fields');
514
+ const expectedOrdinal = item.tick === previousTick ? previousOrdinal + 1 : 0;
515
+ if (item.ordinal !== expectedOrdinal)
516
+ throw new TypeError('Invalid action order');
517
+ while (item.tick > state.elapsedTicks) {
518
+ const prior = state.elapsedTicks;
519
+ state = advanceTicks(state, Math.min(3600, item.tick - state.elapsedTicks)).state;
520
+ if (state.elapsedTicks === prior)
521
+ throw new TypeError('Recorded action occurs after the run ended');
522
+ }
523
+ const result = applyActionCore(state, item.action);
524
+ if (!result.accepted)
525
+ throw new TypeError(`Illegal recorded action at tick ${item.tick}: ${result.reason ?? 'rejected'}`);
526
+ state = result.state;
527
+ replayRecording.push({ tick: item.tick, ordinal: item.ordinal, action: item.action });
528
+ previousTick = item.tick;
529
+ previousOrdinal = item.ordinal;
530
+ }
531
+ while (state.elapsedTicks < value.elapsedTicks) {
532
+ const prior = state.elapsedTicks;
533
+ state = advanceTicks(state, Math.min(3600, value.elapsedTicks - state.elapsedTicks)).state;
534
+ if (state.elapsedTicks === prior)
535
+ throw new TypeError('Checkpoint elapsed time exceeds the run clock');
536
+ }
537
+ state = { ...state, recording: replayRecording };
538
+ if (stable(state) !== stable(value.checkpoint))
539
+ throw new TypeError('Save checkpoint does not match its action recording');
540
+ return state;
541
+ }
@@ -0,0 +1,18 @@
1
+ import type { Action } from './types.js';
2
+ type HoldAction = 'left' | 'right' | 'soft-drop';
3
+ /** Snapshot of deterministic held-input state at a logical tick boundary. */
4
+ export interface InputSchedulerState {
5
+ readonly held: Readonly<Partial<Record<HoldAction, number>>>;
6
+ readonly queued: readonly Action[];
7
+ }
8
+ /** Creates an empty fixed-tick input scheduler state. */
9
+ export declare function createInputScheduler(): InputSchedulerState;
10
+ /** Starts or releases one held action; opposite lateral holds cancel during scheduling. */
11
+ export declare function setHeldAction(state: InputSchedulerState, action: HoldAction, held: boolean): InputSchedulerState;
12
+ /** Queues a one-shot cycle, drop, or pause edge for the next logical tick. */
13
+ export declare function queueInputEdge(state: InputSchedulerState, action: Action): InputSchedulerState;
14
+ /** Resolves canonical actions in the design's per-tick order. */
15
+ export declare function actionsForTick(state: InputSchedulerState): readonly [readonly Action[], InputSchedulerState];
16
+ /** Releases every held action after blur, pause, or pointer cancellation. */
17
+ export declare function releaseAllActions(state: InputSchedulerState): InputSchedulerState;
18
+ export {};
@@ -0,0 +1,42 @@
1
+ /** Creates an empty fixed-tick input scheduler state. */
2
+ export function createInputScheduler() { return { held: {}, queued: [] }; }
3
+ /** Starts or releases one held action; opposite lateral holds cancel during scheduling. */
4
+ export function setHeldAction(state, action, held) {
5
+ const next = { ...state.held };
6
+ if (held) {
7
+ if (next[action] === undefined)
8
+ next[action] = 0;
9
+ }
10
+ else
11
+ delete next[action];
12
+ return { ...state, held: next };
13
+ }
14
+ /** Queues a one-shot cycle, drop, or pause edge for the next logical tick. */
15
+ export function queueInputEdge(state, action) {
16
+ if (!['cycle-forward', 'cycle-backward', 'hard-drop', 'place', 'pause', 'resume'].includes(action.kind))
17
+ return state;
18
+ return { ...state, queued: [...state.queued, action] };
19
+ }
20
+ /** Resolves canonical actions in the design's per-tick order. */
21
+ export function actionsForTick(state) {
22
+ const actions = [];
23
+ const held = { ...state.held };
24
+ const left = held.left;
25
+ const right = held.right;
26
+ if (left !== undefined && right === undefined && (left === 0 || (left >= 10 && (left - 10) % 3 === 0)))
27
+ actions.push({ kind: 'left' });
28
+ if (right !== undefined && left === undefined && (right === 0 || (right >= 10 && (right - 10) % 3 === 0)))
29
+ actions.push({ kind: 'right' });
30
+ const edges = [...state.queued];
31
+ actions.push(...edges.filter(item => item.kind === 'cycle-forward' || item.kind === 'cycle-backward'));
32
+ actions.push(...edges.filter(item => item.kind === 'hard-drop' || item.kind === 'place'));
33
+ if (held['soft-drop'] !== undefined && (held['soft-drop'] === 0 || held['soft-drop'] % 2 === 0))
34
+ actions.push({ kind: 'soft-drop' });
35
+ actions.push(...edges.filter(item => item.kind === 'pause' || item.kind === 'resume'));
36
+ for (const key of Object.keys(held))
37
+ held[key] = (held[key] ?? 0) + 1;
38
+ return [actions, { held, queued: [] }];
39
+ }
40
+ /** Releases every held action after blur, pause, or pointer cancellation. */
41
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- the public signature takes the state a caller holds, though nothing of it is kept
42
+ export function releaseAllActions(state) { return { held: {}, queued: [] }; }
@@ -0,0 +1,9 @@
1
+ import type { Colour, Gem } from './types.js';
2
+ export interface MatchWave {
3
+ readonly cells: readonly number[];
4
+ readonly colour: Colour;
5
+ }
6
+ /** Finds the union of every maximal horizontal, vertical, and diagonal run of at least three. */
7
+ export declare function findMatches(board: readonly (Gem | null)[], width: number, height: number): readonly MatchWave[];
8
+ /** Compacts each column downward while preserving the top-to-bottom order of survivors. */
9
+ export declare function compactBoard(board: readonly (Gem | null)[], width: number, height: number): readonly (Gem | null)[];