@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.
- package/CHANGELOG.md +41 -0
- package/LICENSE +21 -0
- package/README.md +272 -2
- package/dist/colour-chains/challenge.d.ts +3 -0
- package/dist/colour-chains/challenge.js +120 -0
- package/dist/colour-chains/content-data.d.ts +2 -0
- package/dist/colour-chains/content-data.js +9534 -0
- package/dist/colour-chains/content-types.d.ts +80 -0
- package/dist/colour-chains/content-types.js +1 -0
- package/dist/colour-chains/content.d.ts +11 -0
- package/dist/colour-chains/content.js +28 -0
- package/dist/colour-chains/engine.d.ts +19 -0
- package/dist/colour-chains/engine.js +493 -0
- package/dist/colour-chains/input.d.ts +24 -0
- package/dist/colour-chains/input.js +78 -0
- package/dist/colour-chains/match.d.ts +5 -0
- package/dist/colour-chains/match.js +48 -0
- package/dist/colour-chains/persistence.d.ts +7 -0
- package/dist/colour-chains/persistence.js +96 -0
- package/dist/colour-chains/random.d.ts +7 -0
- package/dist/colour-chains/random.js +49 -0
- package/dist/colour-chains/types.d.ts +191 -0
- package/dist/colour-chains/types.js +1 -0
- package/dist/colour-chains/validation.d.ts +16 -0
- package/dist/colour-chains/validation.js +80 -0
- package/dist/colour-chains.d.ts +8 -0
- package/dist/colour-chains.js +7 -0
- package/dist/falling-triplets/content-data.d.ts +7258 -0
- package/dist/falling-triplets/content-data.js +18073 -0
- package/dist/falling-triplets/content-types.d.ts +72 -0
- package/dist/falling-triplets/content-types.js +1 -0
- package/dist/falling-triplets/content.d.ts +11 -0
- package/dist/falling-triplets/content.js +39 -0
- package/dist/falling-triplets/engine.d.ts +26 -0
- package/dist/falling-triplets/engine.js +541 -0
- package/dist/falling-triplets/input.d.ts +18 -0
- package/dist/falling-triplets/input.js +42 -0
- package/dist/falling-triplets/match.d.ts +9 -0
- package/dist/falling-triplets/match.js +45 -0
- package/dist/falling-triplets/random.d.ts +9 -0
- package/dist/falling-triplets/random.js +44 -0
- package/dist/falling-triplets/types.d.ts +134 -0
- package/dist/falling-triplets/types.js +1 -0
- package/dist/falling-triplets.d.ts +7 -0
- package/dist/falling-triplets.js +6 -0
- package/dist/gem-swap/black-hole.d.ts +7 -0
- package/dist/gem-swap/black-hole.js +54 -0
- package/dist/gem-swap/board.d.ts +25 -0
- package/dist/gem-swap/board.js +89 -0
- package/dist/gem-swap/content.d.ts +75 -0
- package/dist/gem-swap/content.js +68 -0
- package/dist/gem-swap/engine.d.ts +25 -0
- package/dist/gem-swap/engine.js +689 -0
- package/dist/gem-swap/persistence.d.ts +17 -0
- package/dist/gem-swap/persistence.js +107 -0
- package/dist/gem-swap/practice.d.ts +4 -0
- package/dist/gem-swap/practice.js +57 -0
- package/dist/gem-swap/random.d.ts +9 -0
- package/dist/gem-swap/random.js +32 -0
- package/dist/gem-swap/specials.d.ts +13 -0
- package/dist/gem-swap/specials.js +300 -0
- package/dist/gem-swap/tools.d.ts +3 -0
- package/dist/gem-swap/tools.js +20 -0
- package/dist/gem-swap/types.d.ts +200 -0
- package/dist/gem-swap/types.js +1 -0
- package/dist/gem-swap.d.ts +5 -0
- package/dist/gem-swap.js +3 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/magnetic-blocks/engine.d.ts +11 -0
- package/dist/magnetic-blocks/engine.js +263 -0
- package/dist/magnetic-blocks/persistence.d.ts +7 -0
- package/dist/magnetic-blocks/persistence.js +104 -0
- package/dist/magnetic-blocks/physics.d.ts +30 -0
- package/dist/magnetic-blocks/physics.js +155 -0
- package/dist/magnetic-blocks/random.d.ts +6 -0
- package/dist/magnetic-blocks/random.js +19 -0
- package/dist/magnetic-blocks/types.d.ts +158 -0
- package/dist/magnetic-blocks/types.js +1 -0
- package/dist/magnetic-blocks/validation.d.ts +23 -0
- package/dist/magnetic-blocks/validation.js +121 -0
- package/dist/magnetic-blocks.d.ts +5 -0
- package/dist/magnetic-blocks.js +4 -0
- package/dist/nature/attraction.d.ts +5 -0
- package/dist/nature/attraction.js +136 -0
- package/dist/nature/environment.d.ts +91 -0
- package/dist/nature/environment.js +271 -0
- package/dist/nature/marking.d.ts +3 -0
- package/dist/nature/marking.js +39 -0
- package/dist/nature/random.d.ts +3 -0
- package/dist/nature/random.js +19 -0
- package/dist/nature/rebound.d.ts +5 -0
- package/dist/nature/rebound.js +98 -0
- package/dist/nature/state.d.ts +3 -0
- package/dist/nature/state.js +22 -0
- package/dist/nature/types.d.ts +124 -0
- package/dist/nature/types.js +1 -0
- package/dist/nature/validation.d.ts +10 -0
- package/dist/nature/validation.js +117 -0
- package/dist/nature.d.ts +8 -0
- package/dist/nature.js +7 -0
- package/dist/stone-collapse/content-data.d.ts +14218 -0
- package/dist/stone-collapse/content-data.js +25217 -0
- package/dist/stone-collapse/content-types.d.ts +77 -0
- package/dist/stone-collapse/content-types.js +1 -0
- package/dist/stone-collapse/content.d.ts +11 -0
- package/dist/stone-collapse/content.js +28 -0
- package/dist/stone-collapse/engine.d.ts +19 -0
- package/dist/stone-collapse/engine.js +570 -0
- package/dist/stone-collapse/persistence.d.ts +7 -0
- package/dist/stone-collapse/persistence.js +230 -0
- package/dist/stone-collapse/types.d.ts +216 -0
- package/dist/stone-collapse/types.js +1 -0
- package/dist/stone-collapse/validation.d.ts +21 -0
- package/dist/stone-collapse/validation.js +134 -0
- package/dist/stone-collapse.d.ts +6 -0
- package/dist/stone-collapse.js +4 -0
- package/package.json +118 -4
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
import { COLOURS, PRESETS, findMatches, listLegalSwaps, neighbors } from './board.js';
|
|
2
|
+
import { drawColour, nextInt, seedState } from './random.js';
|
|
3
|
+
import { isValidSpecialSwap, materializeSpecials, planWave } from './specials.js';
|
|
4
|
+
import { previewTool } from './tools.js';
|
|
5
|
+
import { consumePortal, contactCells, placementCells } from './black-hole.js';
|
|
6
|
+
import { hintTransition, reshuffleTransition, undoTransition } from './practice.js';
|
|
7
|
+
/** Typed error for invalid settings or a board that cannot be generated safely. */
|
|
8
|
+
export class GemSwapOptionsError extends RangeError {
|
|
9
|
+
code;
|
|
10
|
+
constructor(code, message) { super(message); this.name = 'GemSwapOptionsError'; this.code = code; }
|
|
11
|
+
}
|
|
12
|
+
function shapeMask(shape) {
|
|
13
|
+
if (shape === 'heart') {
|
|
14
|
+
const width = 10;
|
|
15
|
+
const rows = [
|
|
16
|
+
[[1, 3], [6, 8]], [[0, 4], [5, 9]], [[0, 9]], [[0, 9]], [[1, 8]], [[2, 7]], [[3, 6]], [[4, 5]], [[4, 5]], [[4, 5]],
|
|
17
|
+
];
|
|
18
|
+
return [width, rows.length, rows.flatMap(ranges => Array.from({ length: width }, (_, x) => ranges.some(([a, b]) => x >= a && x <= b)))];
|
|
19
|
+
}
|
|
20
|
+
if (shape === 'star') {
|
|
21
|
+
const width = 12;
|
|
22
|
+
const ranges = [[5, 6], [5, 6], [4, 7], [4, 7], [0, 11], [0, 11], [2, 9], [2, 9], [3, 8], [3, 8], [4, 7], [4, 7]];
|
|
23
|
+
return [width, ranges.length, ranges.flatMap(([a, b]) => Array.from({ length: width }, (_, x) => x >= a && x <= b))];
|
|
24
|
+
}
|
|
25
|
+
const width = 10;
|
|
26
|
+
const ranges = [[2, 7], [1, 8], [0, 9], [0, 9], [0, 9], [0, 9], [0, 9], [0, 9], [1, 8], [2, 7]];
|
|
27
|
+
return [width, ranges.length, ranges.flatMap(([a, b]) => Array.from({ length: width }, (_, x) => x >= a && x <= b))];
|
|
28
|
+
}
|
|
29
|
+
function validMask(mask, width, height) {
|
|
30
|
+
if (!Array.isArray(mask) || mask.length !== width * height || mask.some(v => typeof v !== 'boolean'))
|
|
31
|
+
return false;
|
|
32
|
+
const active = mask.flatMap((on, i) => on ? [i] : []);
|
|
33
|
+
if (active.length < 16 || active.length > 512 || active.some(i => neighbors(i, width, height, mask).length === 0))
|
|
34
|
+
return false;
|
|
35
|
+
const visited = new Set([active[0]]);
|
|
36
|
+
const queue = [active[0]];
|
|
37
|
+
while (queue.length)
|
|
38
|
+
for (const next of neighbors(queue.pop(), width, height, mask))
|
|
39
|
+
if (!visited.has(next)) {
|
|
40
|
+
visited.add(next);
|
|
41
|
+
queue.push(next);
|
|
42
|
+
}
|
|
43
|
+
return visited.size === active.length;
|
|
44
|
+
}
|
|
45
|
+
function validateDimensions(width, height) {
|
|
46
|
+
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 4 || width > 16 || height < 4 || height > 32 || width * height > 512) {
|
|
47
|
+
throw new GemSwapOptionsError('invalid-board-size', 'Dimensions must be width 4–16, height 4–32, and at most 512 cells');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function validateChallenge(value) {
|
|
51
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
52
|
+
throw new GemSwapOptionsError('invalid-challenge', 'Challenge rules must be an object');
|
|
53
|
+
const input = value;
|
|
54
|
+
if (Object.keys(input).some(key => !['goals', 'moveLimit', 'seals'].includes(key)) || !Array.isArray(input.goals) || input.goals.length < 1 || input.goals.length > 8)
|
|
55
|
+
throw new GemSwapOptionsError('invalid-challenge', 'A challenge requires one to eight supported goals');
|
|
56
|
+
if (input.moveLimit !== undefined && (!Number.isInteger(input.moveLimit) || input.moveLimit < 1 || input.moveLimit > 1000))
|
|
57
|
+
throw new GemSwapOptionsError('invalid-challenge', 'Challenge move limit must be from 1 to 1000');
|
|
58
|
+
const goals = input.goals.map((raw) => {
|
|
59
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
60
|
+
throw new GemSwapOptionsError('invalid-challenge-goal', 'Challenge goals must be objects');
|
|
61
|
+
const goal = raw;
|
|
62
|
+
if (goal.kind === 'score' && Object.keys(goal).every(key => ['kind', 'target'].includes(key)) && Number.isSafeInteger(goal.target) && goal.target > 0)
|
|
63
|
+
return Object.freeze({ kind: 'score', target: goal.target });
|
|
64
|
+
if (goal.kind === 'collect' && Object.keys(goal).every(key => ['kind', 'colour', 'target'].includes(key)) && COLOURS.includes(goal.colour) && Number.isSafeInteger(goal.target) && goal.target > 0)
|
|
65
|
+
return Object.freeze({ kind: 'collect', colour: goal.colour, target: goal.target });
|
|
66
|
+
if (goal.kind === 'chain' && Object.keys(goal).every(key => ['kind', 'target'].includes(key)) && Number.isInteger(goal.target) && goal.target >= 2 && goal.target <= 100)
|
|
67
|
+
return Object.freeze({ kind: 'chain', target: goal.target });
|
|
68
|
+
if (goal.kind === 'seals' && Object.keys(goal).length === 1)
|
|
69
|
+
return Object.freeze({ kind: 'seals' });
|
|
70
|
+
throw new GemSwapOptionsError('invalid-challenge-goal', 'Challenge goal is malformed or unsupported');
|
|
71
|
+
});
|
|
72
|
+
let seals;
|
|
73
|
+
if (input.seals !== undefined) {
|
|
74
|
+
if (!Array.isArray(input.seals) || input.seals.length > 512)
|
|
75
|
+
throw new GemSwapOptionsError('invalid-challenge-seals', 'Challenge seals must be a bounded list');
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
seals = Object.freeze(input.seals.map((raw) => {
|
|
78
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
79
|
+
throw new GemSwapOptionsError('invalid-challenge-seal', 'Seal entries must be objects');
|
|
80
|
+
const seal = raw;
|
|
81
|
+
if (Object.keys(seal).some(key => !['cell', 'layers'].includes(key)) || !Number.isInteger(seal.cell) || seal.cell < 0 || !Number.isInteger(seal.layers) || seal.layers < 1 || seal.layers > 3 || seen.has(seal.cell))
|
|
82
|
+
throw new GemSwapOptionsError('invalid-challenge-seal', 'Seal cell or layers are invalid or duplicated');
|
|
83
|
+
seen.add(seal.cell);
|
|
84
|
+
return Object.freeze({ cell: seal.cell, layers: seal.layers });
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
if (goals.some(goal => goal.kind === 'seals') && !seals?.length)
|
|
88
|
+
throw new GemSwapOptionsError('invalid-challenge-seals', 'A seal goal requires at least one seal');
|
|
89
|
+
return Object.freeze({ goals: Object.freeze(goals), ...(input.moveLimit === undefined ? {} : { moveLimit: input.moveLimit }), ...(seals ? { seals } : {}) });
|
|
90
|
+
}
|
|
91
|
+
function optionsFor(options) {
|
|
92
|
+
if (!options || typeof options !== 'object' || Array.isArray(options))
|
|
93
|
+
throw new GemSwapOptionsError('invalid-options', 'Options must be an object');
|
|
94
|
+
const allowed = ['preset', 'width', 'height', 'colourCount', 'seed', 'shape', 'mask', 'tools', 'advancedTools', 'mode', 'dailyDate', 'challenge'];
|
|
95
|
+
if (Object.keys(options).some(key => !allowed.includes(key)))
|
|
96
|
+
throw new GemSwapOptionsError('unknown-option', 'Options contain an unsupported field');
|
|
97
|
+
const mode = options.mode ?? 'relaxed';
|
|
98
|
+
if (!['relaxed', 'arcade', 'daily', 'challenge'].includes(mode))
|
|
99
|
+
throw new GemSwapOptionsError('invalid-mode', 'Unknown game mode');
|
|
100
|
+
const dailyDate = options.dailyDate ?? null;
|
|
101
|
+
if (mode === 'daily') {
|
|
102
|
+
const parsedDate = typeof dailyDate === 'string' ? Date.parse(`${dailyDate}T00:00:00.000Z`) : Number.NaN;
|
|
103
|
+
if (typeof dailyDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(dailyDate) || !Number.isFinite(parsedDate) || new Date(parsedDate).toISOString().slice(0, 10) !== dailyDate)
|
|
104
|
+
throw new GemSwapOptionsError('invalid-daily-date', 'Daily mode requires a valid UTC date in YYYY-MM-DD form');
|
|
105
|
+
if (options.preset !== undefined || options.width !== undefined || options.height !== undefined || options.colourCount !== undefined || options.seed !== undefined || options.tools !== undefined || options.advancedTools !== undefined || options.shape !== undefined || options.mask !== undefined || options.challenge !== undefined)
|
|
106
|
+
throw new GemSwapOptionsError('daily-settings-fixed', 'Daily uses canonical board settings and a date-derived seed');
|
|
107
|
+
}
|
|
108
|
+
else if (dailyDate !== null)
|
|
109
|
+
throw new GemSwapOptionsError('unexpected-daily-date', 'A UTC date is only valid in Daily mode');
|
|
110
|
+
const challenge = mode === 'challenge' ? validateChallenge(options.challenge) : null;
|
|
111
|
+
if (mode !== 'challenge' && options.challenge !== undefined)
|
|
112
|
+
throw new GemSwapOptionsError('unexpected-challenge', 'Challenge rules require Challenge mode');
|
|
113
|
+
if (options.tools !== undefined && typeof options.tools !== 'boolean')
|
|
114
|
+
throw new GemSwapOptionsError('invalid-tools-option', 'Tools must be enabled or disabled with a boolean');
|
|
115
|
+
if (options.advancedTools !== undefined && typeof options.advancedTools !== 'boolean')
|
|
116
|
+
throw new GemSwapOptionsError('invalid-advanced-tools-option', 'Advanced tools must be enabled or disabled with a boolean');
|
|
117
|
+
if (options.advancedTools && !options.tools)
|
|
118
|
+
throw new GemSwapOptionsError('advanced-tools-require-tools', 'Advanced tools require the stored-tool tray');
|
|
119
|
+
const dimensions = options.width !== undefined || options.height !== undefined;
|
|
120
|
+
let width;
|
|
121
|
+
let height;
|
|
122
|
+
let mask;
|
|
123
|
+
let shape;
|
|
124
|
+
if (options.shape !== undefined) {
|
|
125
|
+
if (!['heart', 'star', 'hexagon'].includes(options.shape) || options.preset !== undefined || dimensions || options.mask !== undefined)
|
|
126
|
+
throw new GemSwapOptionsError('conflicting-board-options', 'Choose one mask shape or one rectangular board');
|
|
127
|
+
shape = options.shape;
|
|
128
|
+
[width, height, mask] = shapeMask(shape);
|
|
129
|
+
}
|
|
130
|
+
else if (options.mask !== undefined) {
|
|
131
|
+
if (options.preset !== undefined || options.width === undefined || options.height === undefined)
|
|
132
|
+
throw new GemSwapOptionsError('custom-mask-dimensions-required', 'A custom mask requires explicit dimensions and cannot use a preset');
|
|
133
|
+
width = options.width;
|
|
134
|
+
height = options.height;
|
|
135
|
+
mask = options.mask;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
const presetName = options.preset ?? 'standard';
|
|
139
|
+
if (!Object.hasOwn(PRESETS, presetName))
|
|
140
|
+
throw new GemSwapOptionsError('invalid-preset', 'Unknown board preset');
|
|
141
|
+
if (dimensions && options.preset !== undefined)
|
|
142
|
+
throw new GemSwapOptionsError('conflicting-board-options', 'Choose a preset or custom dimensions');
|
|
143
|
+
const preset = PRESETS[presetName];
|
|
144
|
+
width = options.width ?? preset[0];
|
|
145
|
+
height = options.height ?? preset[1];
|
|
146
|
+
}
|
|
147
|
+
validateDimensions(width, height);
|
|
148
|
+
if (mask === undefined)
|
|
149
|
+
mask = Array(width * height).fill(true);
|
|
150
|
+
if (!validMask(mask, width, height))
|
|
151
|
+
throw new GemSwapOptionsError('invalid-mask', 'The active mask must be connected, contain 16–512 cells and have no isolated cell');
|
|
152
|
+
if (challenge?.seals?.some(seal => seal.cell >= mask.length || !mask[seal.cell]))
|
|
153
|
+
throw new GemSwapOptionsError('invalid-challenge-seal', 'Every seal must occupy an active board cell');
|
|
154
|
+
const colourCount = options.colourCount ?? 5;
|
|
155
|
+
if (colourCount !== 4 && colourCount !== 5 && colourCount !== 6)
|
|
156
|
+
throw new GemSwapOptionsError('invalid-colour-count', 'Colour count must be 4, 5, or 6');
|
|
157
|
+
if (mode === 'daily' && (options.tools || options.advancedTools))
|
|
158
|
+
throw new GemSwapOptionsError('daily-tools-disabled', 'Daily mode does not use stored tools');
|
|
159
|
+
const seed = mode === 'daily' ? `gem-swap:daily-1:${dailyDate}` : options.seed ?? 'houseki-gem-swap';
|
|
160
|
+
if (typeof seed === 'string' ? seed.length > 256 : (!Number.isInteger(seed) || seed < 0 || seed > 0xffff_ffff))
|
|
161
|
+
throw new GemSwapOptionsError('invalid-seed', 'Seed must be a string of at most 256 characters or an unsigned 32-bit integer');
|
|
162
|
+
const settings = Object.freeze({ width: width, height: height, colourCount, seed, tools: options.tools ?? false, advancedTools: options.advancedTools ?? false, ...(shape ? { shape } : {}), mask: Object.freeze([...mask]) });
|
|
163
|
+
const initialOptions = Object.freeze(mode === 'daily' ? { mode, dailyDate: dailyDate } : {
|
|
164
|
+
...(mode === 'relaxed' ? {} : { mode }),
|
|
165
|
+
...(options.preset !== undefined ? { preset: options.preset } : shape ? { shape } : { width: width, height: height, mask: Object.freeze([...mask]) }),
|
|
166
|
+
colourCount, seed, tools: options.tools ?? false, advancedTools: options.advancedTools ?? false,
|
|
167
|
+
...(challenge ? { challenge } : {}),
|
|
168
|
+
});
|
|
169
|
+
return Object.freeze({ settings, mode, dailyDate, challenge, initialOptions });
|
|
170
|
+
}
|
|
171
|
+
function gem(id, colour) { return Object.freeze({ id, colour }); }
|
|
172
|
+
/** @internal Test seam for checking the built-in standard-board fallback witness. */
|
|
173
|
+
export function fallbackBoardForTest(settings) {
|
|
174
|
+
if (settings.shape || settings.mask.some(on => !on) || settings.width !== 8 || settings.height !== 8 || settings.colourCount !== 5)
|
|
175
|
+
throw new GemSwapOptionsError('unsupported-board-generation', 'No validated stable fallback exists for these settings');
|
|
176
|
+
const board = [];
|
|
177
|
+
for (let y = 0; y < 8; y++)
|
|
178
|
+
for (let x = 0; x < 8; x++)
|
|
179
|
+
board.push(gem(y * 8 + x + 1, COLOURS[(x + 2 * y) % 5]));
|
|
180
|
+
board[3 * 8 + 3] = gem(28, COLOURS[0]);
|
|
181
|
+
board[4 * 8 + 3] = gem(36, COLOURS[1]);
|
|
182
|
+
board[4 * 8 + 4] = gem(37, COLOURS[0]);
|
|
183
|
+
board[5 * 8 + 3] = gem(44, COLOURS[0]);
|
|
184
|
+
board[6 * 8 + 3] = gem(52, COLOURS[2]);
|
|
185
|
+
// Swapping (3,4) blue with (3,3) red completes the red row at y=4, x=2..4.
|
|
186
|
+
return Object.freeze(board);
|
|
187
|
+
}
|
|
188
|
+
/** Creates a deterministic stable board with at least one legal normal swap. */
|
|
189
|
+
export function createGame(options = {}) {
|
|
190
|
+
const config = optionsFor(options);
|
|
191
|
+
const { settings } = config;
|
|
192
|
+
let randomState = seedState(settings.seed);
|
|
193
|
+
let board = [];
|
|
194
|
+
let nextId = 1;
|
|
195
|
+
let found = false;
|
|
196
|
+
for (let attempt = 0; attempt < 128 && !found; attempt++) {
|
|
197
|
+
const candidate = [];
|
|
198
|
+
let id = 1;
|
|
199
|
+
let viable = true;
|
|
200
|
+
for (let index = 0; index < settings.mask.length; index++) {
|
|
201
|
+
if (!settings.mask[index]) {
|
|
202
|
+
candidate.push(null);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const x = index % settings.width;
|
|
206
|
+
const y = Math.floor(index / settings.width);
|
|
207
|
+
const allowed = [];
|
|
208
|
+
for (let colour = 0; colour < settings.colourCount; colour++) {
|
|
209
|
+
const sameLeft = x >= 2 && settings.mask[index - 1] && settings.mask[index - 2] && candidate[index - 1]?.colour === COLOURS[colour] && candidate[index - 2]?.colour === COLOURS[colour];
|
|
210
|
+
const sameAbove = y >= 2 && settings.mask[index - settings.width] && settings.mask[index - 2 * settings.width] && candidate[index - settings.width]?.colour === COLOURS[colour] && candidate[index - 2 * settings.width]?.colour === COLOURS[colour];
|
|
211
|
+
if (!sameLeft && !sameAbove)
|
|
212
|
+
allowed.push(colour);
|
|
213
|
+
}
|
|
214
|
+
if (!allowed.length) {
|
|
215
|
+
viable = false;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
const [pick, next] = nextInt(randomState, allowed.length);
|
|
219
|
+
randomState = next;
|
|
220
|
+
candidate.push(gem(id++, COLOURS[allowed[pick]]));
|
|
221
|
+
}
|
|
222
|
+
if (viable) {
|
|
223
|
+
board = candidate;
|
|
224
|
+
nextId = id;
|
|
225
|
+
const trial = { ...initialState(settings, board, randomState, id, false, config), usedFallback: false };
|
|
226
|
+
found = !findMatches(board, settings).length && listLegalSwaps(trial).length > 0;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
let usedFallback = false;
|
|
230
|
+
if (!found) {
|
|
231
|
+
board = [...fallbackBoardForTest(settings)];
|
|
232
|
+
nextId = board.filter(Boolean).length + 1;
|
|
233
|
+
usedFallback = true;
|
|
234
|
+
const fallbackState = { ...initialState(settings, board, randomState, nextId, true, config), usedFallback: true };
|
|
235
|
+
if (findMatches(board, settings).length || listLegalSwaps(fallbackState).length === 0)
|
|
236
|
+
throw new GemSwapOptionsError('invalid-fallback', 'The validated standard fallback failed its stability or legal-move check');
|
|
237
|
+
}
|
|
238
|
+
return Object.freeze({ ...initialState(settings, Object.freeze(board), randomState, nextId, usedFallback, config) });
|
|
239
|
+
}
|
|
240
|
+
function initialState(settings, board, randomState, nextId, usedFallback, config) {
|
|
241
|
+
const start = settings.tools ? 1 : 0;
|
|
242
|
+
return { game: 'gem-swap', rules: 'swap-1', settings, initialOptions: config.initialOptions, mode: config.mode, dailyDate: config.dailyDate, challenge: config.challenge, seals: config.challenge?.seals ?? Object.freeze([]), sealsCleared: 0, elapsedMs: 0, outcome: null, clearedByColour: Object.freeze({ red: 0, blue: 0, green: 0, gold: 0, purple: 0, teal: 0 }), bestChain: 0, history: Object.freeze([]), board, phase: 'ready', score: 0, moves: 0, swapCount: 0, randomState, nextId, usedFallback, wave: 0, resolutionTick: 0, pendingCells: Object.freeze([]), pendingSpecials: Object.freeze([]), gravityBoard: null,
|
|
243
|
+
inventory: Object.freeze({ bomb: start, 'row-clear': start, 'colour-clear': start }), selectedTool: null, toolTarget: null, toolPreview: Object.freeze([]), toolProgress: 0, toolAwardCursor: 0, assisted: false, toolWaveActive: false,
|
|
244
|
+
blackHoleCharges: 0, blackHoleProgress: 0, blackHole: null, blackHoleMovePending: false, blackHoleContactPending: false, pendingBlackHole: false };
|
|
245
|
+
}
|
|
246
|
+
function rejected(state, reason) { return { state, events: Object.freeze([]), accepted: false, reason }; }
|
|
247
|
+
function adjacent(state, from, to) { return neighbors(from, state.settings.width, state.settings.height, state.settings.mask).includes(to); }
|
|
248
|
+
/** Accepts a legal orthogonal normal-gem swap, leaving invalid state identity unchanged. */
|
|
249
|
+
function applyActionBase(state, action) {
|
|
250
|
+
if (!action || typeof action !== 'object')
|
|
251
|
+
return rejected(state, 'invalid-action');
|
|
252
|
+
if (action.kind === 'undo')
|
|
253
|
+
return Object.keys(action).length === 1 ? undoTransition(state, replayOperations) : rejected(state, 'invalid-action');
|
|
254
|
+
if (action.kind === 'hint')
|
|
255
|
+
return Object.keys(action).length === 1 ? hintTransition(state) : rejected(state, 'invalid-action');
|
|
256
|
+
if (action.kind === 'reshuffle')
|
|
257
|
+
return Object.keys(action).length === 1 ? reshuffleTransition(state) : rejected(state, 'invalid-action');
|
|
258
|
+
if (action.kind !== 'swap')
|
|
259
|
+
return applyToolAction(state, action);
|
|
260
|
+
if (!Number.isInteger(action.from) || !Number.isInteger(action.to) || Object.keys(action).some(key => !['kind', 'from', 'to'].includes(key)))
|
|
261
|
+
return rejected(state, 'invalid-action');
|
|
262
|
+
if (state.phase !== 'ready')
|
|
263
|
+
return rejected(state, 'resolution-in-progress');
|
|
264
|
+
if (findMatches(state.board, state.settings).length)
|
|
265
|
+
return rejected(state, 'unstable-board');
|
|
266
|
+
const { from, to } = action;
|
|
267
|
+
if (from < 0 || to < 0 || from >= state.board.length || to >= state.board.length || from === to || !state.settings.mask[from] || !state.settings.mask[to] || !state.board[from] || !state.board[to] || !adjacent(state, from, to))
|
|
268
|
+
return rejected(state, 'not-adjacent-active-gems');
|
|
269
|
+
const board = [...state.board];
|
|
270
|
+
[board[from], board[to]] = [board[to], board[from]];
|
|
271
|
+
const matched = findMatches(board, state.settings);
|
|
272
|
+
if (!isValidSpecialSwap(board[from], board[to], matched.length > 0))
|
|
273
|
+
return rejected(state, board[from].colour === board[to].colour && !board[from].kind && !board[to].kind ? 'identical-gems' : 'swap-makes-no-match');
|
|
274
|
+
const swapped = Object.freeze({ ...state, board: Object.freeze(board) });
|
|
275
|
+
const plan = planWave(swapped, matched, from, to);
|
|
276
|
+
const next = Object.freeze({ ...swapped, phase: 'clear-mark', moves: state.moves + 1, swapCount: state.swapCount + 1, wave: 1, resolutionTick: 0, pendingCells: plan.cells, pendingSpecials: plan.specials, gravityBoard: null, selectedTool: null, toolTarget: null, toolPreview: Object.freeze([]), toolWaveActive: false, blackHoleMovePending: state.blackHole !== null, blackHoleContactPending: state.blackHole !== null });
|
|
277
|
+
return { state: next, events: Object.freeze([{ type: 'swap-accepted', from, to, ids: [state.board[from].id, state.board[to].id] }, ...(matched.length ? [{ type: 'match-marked', cells: matched, wave: 1 }] : []), ...plan.events]), accepted: true };
|
|
278
|
+
}
|
|
279
|
+
function appendHistory(state, operation) {
|
|
280
|
+
const previous = state.history.at(-1);
|
|
281
|
+
if (operation.kind === 'ticks' && previous?.kind === 'ticks' && previous.count + operation.count <= 3600)
|
|
282
|
+
return Object.freeze({ ...state, history: Object.freeze([...state.history.slice(0, -1), Object.freeze({ kind: 'ticks', count: previous.count + operation.count })]) });
|
|
283
|
+
if (operation.kind === 'time' && previous?.kind === 'time')
|
|
284
|
+
return Object.freeze({ ...state, history: Object.freeze([...state.history.slice(0, -1), Object.freeze({ kind: 'time', milliseconds: previous.milliseconds + operation.milliseconds })]) });
|
|
285
|
+
return Object.freeze({ ...state, history: Object.freeze([...state.history, Object.freeze(operation)]) });
|
|
286
|
+
}
|
|
287
|
+
/** Applies one recorded player action. Rejected actions preserve state identity and are not recorded. */
|
|
288
|
+
export function applyAction(state, action) {
|
|
289
|
+
const explicitPracticeReshuffle = action?.kind === 'reshuffle' && state.mode === 'relaxed' && state.outcome === 'finished';
|
|
290
|
+
if ((state.outcome && !explicitPracticeReshuffle) || (state.mode === 'arcade' && state.elapsedMs >= 180_000))
|
|
291
|
+
return rejected(state, 'run-ended');
|
|
292
|
+
const result = applyActionBase(state, action);
|
|
293
|
+
return result.accepted ? { ...result, state: appendHistory(result.state, { kind: 'action', action: Object.freeze({ ...action }) }) } : result;
|
|
294
|
+
}
|
|
295
|
+
const TOOL_ORDER = ['bomb', 'row-clear', 'colour-clear'];
|
|
296
|
+
function validTool(tool) { return [...TOOL_ORDER, 'black-hole'].includes(tool); }
|
|
297
|
+
function onReady(state) { return state.phase === 'ready' && findMatches(state.board, state.settings).length === 0; }
|
|
298
|
+
function applyToolAction(state, action) {
|
|
299
|
+
if (!['select-tool', 'target-tool', 'confirm-tool', 'cancel-tool'].includes(action.kind))
|
|
300
|
+
return rejected(state, 'invalid-action');
|
|
301
|
+
const fields = { 'select-tool': ['kind', 'tool'], 'target-tool': ['kind', 'cell'], 'confirm-tool': ['kind'], 'cancel-tool': ['kind'] };
|
|
302
|
+
if (Object.keys(action).some(key => !(fields[action.kind] ?? []).includes(key)))
|
|
303
|
+
return rejected(state, 'invalid-action');
|
|
304
|
+
if (!state.settings.tools)
|
|
305
|
+
return rejected(state, 'tools-disabled');
|
|
306
|
+
if (!onReady(state))
|
|
307
|
+
return rejected(state, state.phase === 'ready' ? 'unstable-board' : 'resolution-in-progress');
|
|
308
|
+
if (action.kind === 'select-tool') {
|
|
309
|
+
if (!validTool(action.tool))
|
|
310
|
+
return rejected(state, 'invalid-action');
|
|
311
|
+
if (action.tool === 'black-hole') {
|
|
312
|
+
if (!state.settings.advancedTools || state.blackHoleCharges < 1 || state.blackHole)
|
|
313
|
+
return rejected(state, 'tool-unavailable');
|
|
314
|
+
}
|
|
315
|
+
else if (state.inventory[action.tool] < 1)
|
|
316
|
+
return rejected(state, 'tool-unavailable');
|
|
317
|
+
const next = Object.freeze({ ...state, selectedTool: action.tool, toolTarget: null, toolPreview: Object.freeze([]) });
|
|
318
|
+
return { state: next, events: Object.freeze([{ type: 'tool-selected', tool: action.tool }]), accepted: true };
|
|
319
|
+
}
|
|
320
|
+
if (action.kind === 'target-tool') {
|
|
321
|
+
if (!Number.isInteger(action.cell) || !state.selectedTool || action.cell < 0 || action.cell >= state.board.length || !state.settings.mask[action.cell] || !state.board[action.cell])
|
|
322
|
+
return rejected(state, 'invalid-tool-target');
|
|
323
|
+
const preview = state.selectedTool === 'black-hole' ? placementCells(state, action.cell) : previewTool(state, state.selectedTool, action.cell);
|
|
324
|
+
const next = Object.freeze({ ...state, toolTarget: action.cell, toolPreview: preview });
|
|
325
|
+
return { state: next, events: Object.freeze([{ type: 'tool-targeted', tool: state.selectedTool, cell: action.cell, cells: preview, count: preview.length }]), accepted: true };
|
|
326
|
+
}
|
|
327
|
+
if (action.kind === 'cancel-tool') {
|
|
328
|
+
if (!state.selectedTool)
|
|
329
|
+
return rejected(state, 'no-tool-selected');
|
|
330
|
+
const next = Object.freeze({ ...state, selectedTool: null, toolTarget: null, toolPreview: Object.freeze([]) });
|
|
331
|
+
return { state: next, events: Object.freeze([{ type: 'tool-cancelled' }]), accepted: true };
|
|
332
|
+
}
|
|
333
|
+
if (!state.selectedTool || state.toolTarget === null)
|
|
334
|
+
return rejected(state, 'tool-not-ready');
|
|
335
|
+
if (state.selectedTool === 'black-hole') {
|
|
336
|
+
if (!state.settings.advancedTools || state.blackHoleCharges < 1 || state.blackHole)
|
|
337
|
+
return rejected(state, 'tool-unavailable');
|
|
338
|
+
const cells = placementCells(state, state.toolTarget);
|
|
339
|
+
if (!cells.length)
|
|
340
|
+
return rejected(state, 'invalid-tool-target');
|
|
341
|
+
const ids = cells.flatMap(cell => state.board[cell] ? [state.board[cell].id] : []);
|
|
342
|
+
const portal = Object.freeze({ cell: state.toolTarget, capacityRemaining: Math.max(0, 8 - ids.length), movesRemaining: 2, consumedIds: Object.freeze(ids) });
|
|
343
|
+
const next = Object.freeze({ ...state, phase: 'clear-mark', moves: state.moves + 1, wave: 1, resolutionTick: 0, pendingCells: cells, pendingSpecials: Object.freeze([]), gravityBoard: null, selectedTool: null, toolTarget: null, toolPreview: Object.freeze([]), assisted: true, toolWaveActive: true, pendingBlackHole: true, blackHole: portal, blackHoleCharges: 0, blackHoleMovePending: false, blackHoleContactPending: false });
|
|
344
|
+
return { state: next, events: Object.freeze([{ type: 'black-hole-opened', cell: portal.cell, capacityRemaining: portal.capacityRemaining, movesRemaining: portal.movesRemaining }, { type: 'black-hole-consumed', cells, ids }]), accepted: true };
|
|
345
|
+
}
|
|
346
|
+
if (state.inventory[state.selectedTool] < 1)
|
|
347
|
+
return rejected(state, 'tool-not-ready');
|
|
348
|
+
const preview = previewTool(state, state.selectedTool, state.toolTarget);
|
|
349
|
+
if (!preview.length)
|
|
350
|
+
return rejected(state, 'invalid-tool-target');
|
|
351
|
+
const plan = planWave(state, [], null, null, preview);
|
|
352
|
+
const inventory = Object.freeze({ ...state.inventory, [state.selectedTool]: state.inventory[state.selectedTool] - 1 });
|
|
353
|
+
const next = Object.freeze({ ...state, phase: 'clear-mark', moves: state.moves + 1, wave: 1, resolutionTick: 0, pendingCells: plan.cells, pendingSpecials: Object.freeze([]), gravityBoard: null, inventory, selectedTool: null, toolTarget: null, toolPreview: Object.freeze([]), assisted: true, toolWaveActive: true, blackHoleMovePending: state.blackHoleMovePending || state.blackHole !== null, blackHoleContactPending: state.blackHoleContactPending || state.blackHole !== null });
|
|
354
|
+
return { state: next, events: Object.freeze([{ type: 'tool-used', tool: state.selectedTool, target: state.toolTarget, cells: plan.cells, count: plan.cells.length }, ...plan.events]), accepted: true };
|
|
355
|
+
}
|
|
356
|
+
function gravityBoard(state) {
|
|
357
|
+
const { width, height, mask } = state.settings;
|
|
358
|
+
const result = [...state.board];
|
|
359
|
+
const blocked = (index) => !mask[index] || state.blackHole?.cell === index;
|
|
360
|
+
for (let x = 0; x < width; x++) {
|
|
361
|
+
let y = 0;
|
|
362
|
+
while (y < height) {
|
|
363
|
+
while (y < height && blocked(y * width + x))
|
|
364
|
+
y++;
|
|
365
|
+
if (y >= height)
|
|
366
|
+
break;
|
|
367
|
+
const start = y;
|
|
368
|
+
while (y < height && !blocked(y * width + x))
|
|
369
|
+
y++;
|
|
370
|
+
const end = y;
|
|
371
|
+
const survivors = [];
|
|
372
|
+
for (let row = start; row < end; row++) {
|
|
373
|
+
const item = state.board[row * width + x];
|
|
374
|
+
if (item)
|
|
375
|
+
survivors.push(item);
|
|
376
|
+
}
|
|
377
|
+
for (let row = start; row < end; row++)
|
|
378
|
+
result[row * width + x] = null;
|
|
379
|
+
for (let offset = 0; offset < survivors.length; offset++)
|
|
380
|
+
result[(end - survivors.length + offset) * width + x] = survivors[offset];
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return Object.freeze(result);
|
|
384
|
+
}
|
|
385
|
+
function refill(state) {
|
|
386
|
+
const { width, height, mask, colourCount } = state.settings;
|
|
387
|
+
const board = [...state.board];
|
|
388
|
+
let randomState = state.randomState;
|
|
389
|
+
let nextId = state.nextId;
|
|
390
|
+
const draws = [];
|
|
391
|
+
const blocked = (index) => !mask[index] || state.blackHole?.cell === index;
|
|
392
|
+
for (let x = 0; x < width; x++) {
|
|
393
|
+
let y = 0;
|
|
394
|
+
while (y < height) {
|
|
395
|
+
while (y < height && blocked(y * width + x))
|
|
396
|
+
y++;
|
|
397
|
+
if (y >= height)
|
|
398
|
+
break;
|
|
399
|
+
const start = y;
|
|
400
|
+
while (y < height && !blocked(y * width + x))
|
|
401
|
+
y++;
|
|
402
|
+
const end = y;
|
|
403
|
+
for (let row = end - 1; row >= start; row--) {
|
|
404
|
+
const index = row * width + x;
|
|
405
|
+
if (board[index])
|
|
406
|
+
continue;
|
|
407
|
+
const [colour, next] = drawColour(randomState, colourCount);
|
|
408
|
+
randomState = next;
|
|
409
|
+
board[index] = gem(nextId++, colour);
|
|
410
|
+
draws.push({ type: 'gem-refilled', id: nextId - 1, colour, cell: index });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return [Object.freeze(board), randomState, nextId, Object.freeze(draws)];
|
|
415
|
+
}
|
|
416
|
+
function awardTools(state, removed) {
|
|
417
|
+
if (!state.settings.tools || state.toolWaveActive)
|
|
418
|
+
return { inventory: state.inventory, progress: state.toolProgress, cursor: state.toolAwardCursor, events: Object.freeze([]) };
|
|
419
|
+
let progress = state.toolProgress + removed;
|
|
420
|
+
let cursor = state.toolAwardCursor;
|
|
421
|
+
const inventory = { ...state.inventory };
|
|
422
|
+
const events = [];
|
|
423
|
+
while (progress >= 12) {
|
|
424
|
+
progress -= 12;
|
|
425
|
+
const tool = TOOL_ORDER[cursor];
|
|
426
|
+
const before = inventory[tool];
|
|
427
|
+
if (before < 3)
|
|
428
|
+
inventory[tool] = before + 1;
|
|
429
|
+
events.push({ type: 'tool-awarded', tool, inventory: inventory[tool], discarded: before >= 3 });
|
|
430
|
+
cursor = (cursor + 1) % TOOL_ORDER.length;
|
|
431
|
+
}
|
|
432
|
+
return { inventory: Object.freeze(inventory), progress, cursor, events: Object.freeze(events) };
|
|
433
|
+
}
|
|
434
|
+
function awardBlackHole(state, removed) {
|
|
435
|
+
if (!state.settings.advancedTools || state.toolWaveActive)
|
|
436
|
+
return { charges: state.blackHoleCharges, progress: state.blackHoleProgress, events: Object.freeze([]) };
|
|
437
|
+
let progress = state.blackHoleProgress + removed;
|
|
438
|
+
let charges = state.blackHoleCharges;
|
|
439
|
+
const events = [];
|
|
440
|
+
while (progress >= 48) {
|
|
441
|
+
progress -= 48;
|
|
442
|
+
const discarded = charges >= 1;
|
|
443
|
+
if (!discarded)
|
|
444
|
+
charges = 1;
|
|
445
|
+
events.push({ type: 'black-hole-awarded', charges, discarded });
|
|
446
|
+
}
|
|
447
|
+
return { charges, progress, events: Object.freeze(events) };
|
|
448
|
+
}
|
|
449
|
+
function scheduleContact(state, board) {
|
|
450
|
+
if (!state.blackHole)
|
|
451
|
+
return null;
|
|
452
|
+
const onBoard = Object.freeze({ ...state, board });
|
|
453
|
+
const cells = contactCells(onBoard, state.blackHole);
|
|
454
|
+
if (!cells.length)
|
|
455
|
+
return null;
|
|
456
|
+
const ids = cells.flatMap(cell => board[cell] ? [board[cell].id] : []);
|
|
457
|
+
const portal = consumePortal(onBoard, cells);
|
|
458
|
+
const next = Object.freeze({ ...onBoard, phase: 'clear-mark', resolutionTick: 0, pendingCells: cells, pendingSpecials: Object.freeze([]), gravityBoard: null, pendingBlackHole: true, toolWaveActive: true, blackHole: portal, blackHoleMovePending: portal ? state.blackHoleMovePending : false, blackHoleContactPending: false });
|
|
459
|
+
const closed = portal ? [] : [{ type: 'black-hole-closed', reason: 'capacity' }];
|
|
460
|
+
return { state: next, events: Object.freeze([{ type: 'black-hole-consumed', cells, ids }, ...closed]) };
|
|
461
|
+
}
|
|
462
|
+
function goalValue(state, goal) {
|
|
463
|
+
if (goal.kind === 'score')
|
|
464
|
+
return state.score;
|
|
465
|
+
if (goal.kind === 'collect')
|
|
466
|
+
return state.clearedByColour[goal.colour];
|
|
467
|
+
if (goal.kind === 'seals')
|
|
468
|
+
return state.sealsCleared;
|
|
469
|
+
return state.bestChain;
|
|
470
|
+
}
|
|
471
|
+
function goalTarget(state, goal) { return goal.kind === 'seals' ? (state.challenge?.seals ?? []).reduce((sum, seal) => sum + seal.layers, 0) : goal.target; }
|
|
472
|
+
/** Advances marked-clear, removal, gravity and refill phases in bounded deterministic ticks. */
|
|
473
|
+
function advanceTicksBase(state, ticks) {
|
|
474
|
+
if (!Number.isInteger(ticks) || ticks < 0 || ticks > 3600)
|
|
475
|
+
throw new RangeError('ticks must be an integer from 0 to 3600');
|
|
476
|
+
let current = state;
|
|
477
|
+
const events = [];
|
|
478
|
+
for (let tick = 0; tick < ticks; tick++) {
|
|
479
|
+
if (current.phase === 'ready' || current.phase === 'finished')
|
|
480
|
+
break;
|
|
481
|
+
if (current.phase === 'clear-mark') {
|
|
482
|
+
const elapsed = current.resolutionTick + 1;
|
|
483
|
+
if (elapsed < 7) {
|
|
484
|
+
current = Object.freeze({ ...current, resolutionTick: elapsed });
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
const removed = new Set(current.pendingCells);
|
|
488
|
+
const holes = current.board.map((item, index) => removed.has(index) ? null : item);
|
|
489
|
+
const removedCount = current.pendingCells.filter(index => current.board[index]).length;
|
|
490
|
+
const clearedByColour = { ...current.clearedByColour };
|
|
491
|
+
for (const cell of current.pendingCells) {
|
|
492
|
+
const removedGem = current.board[cell];
|
|
493
|
+
if (removedGem)
|
|
494
|
+
clearedByColour[removedGem.colour]++;
|
|
495
|
+
}
|
|
496
|
+
const clearedCells = new Set(current.pendingCells);
|
|
497
|
+
let sealsCleared = current.sealsCleared;
|
|
498
|
+
const seals = current.seals.flatMap(seal => {
|
|
499
|
+
if (!clearedCells.has(seal.cell))
|
|
500
|
+
return [seal];
|
|
501
|
+
sealsCleared++;
|
|
502
|
+
return seal.layers > 1 ? [Object.freeze({ ...seal, layers: seal.layers - 1 })] : [];
|
|
503
|
+
});
|
|
504
|
+
const blackHoleRemoval = current.pendingBlackHole;
|
|
505
|
+
const points = removedCount * 10 * (blackHoleRemoval ? 1 : current.wave);
|
|
506
|
+
const earning = awardTools(current, removedCount);
|
|
507
|
+
const rareEarning = awardBlackHole(current, removedCount);
|
|
508
|
+
const createdEvents = current.pendingSpecials.map(planned => ({ type: 'special-created', cell: planned.cell, kind: planned.kind, id: current.board[planned.cell].id }));
|
|
509
|
+
const board = materializeSpecials(Object.freeze(holes), current.pendingSpecials);
|
|
510
|
+
current = Object.freeze({ ...current, board, phase: 'clear-remove', resolutionTick: 0, score: current.score + points, clearedByColour: Object.freeze(clearedByColour), seals: Object.freeze(seals), sealsCleared, pendingSpecials: Object.freeze([]), pendingBlackHole: false, inventory: earning.inventory, toolProgress: earning.progress, toolAwardCursor: earning.cursor, blackHoleCharges: rareEarning.charges, blackHoleProgress: rareEarning.progress });
|
|
511
|
+
events.push({ type: 'cells-removed', cells: current.pendingCells, wave: current.wave, points, board, source: blackHoleRemoval ? 'black-hole' : 'match' }, ...createdEvents, ...earning.events, ...rareEarning.events);
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (current.phase === 'clear-remove') {
|
|
515
|
+
const elapsed = current.resolutionTick + 1;
|
|
516
|
+
if (elapsed < 6) {
|
|
517
|
+
current = Object.freeze({ ...current, resolutionTick: elapsed });
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
current = Object.freeze({ ...current, phase: 'gravity', resolutionTick: 0, gravityBoard: gravityBoard(current) });
|
|
521
|
+
events.push({ type: 'gravity-started', board: current.board, settledBoard: current.gravityBoard });
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (current.phase === 'gravity') {
|
|
525
|
+
const elapsed = current.resolutionTick + 1;
|
|
526
|
+
if (elapsed < 9) {
|
|
527
|
+
current = Object.freeze({ ...current, resolutionTick: elapsed });
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
const board = current.gravityBoard;
|
|
531
|
+
current = Object.freeze({ ...current, phase: 'refill', resolutionTick: 0, board, gravityBoard: null });
|
|
532
|
+
events.push({ type: 'gravity-completed', board });
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
const [board, randomState, nextId, refillEvents] = refill(current);
|
|
536
|
+
const matches = findMatches(board, current.settings);
|
|
537
|
+
if (matches.length) {
|
|
538
|
+
const nextWave = current.wave + 1;
|
|
539
|
+
const plan = planWave({ ...current, board }, matches, null, null);
|
|
540
|
+
current = Object.freeze({ ...current, board, randomState, nextId, phase: 'clear-mark', wave: nextWave, resolutionTick: 0, pendingCells: plan.cells, pendingSpecials: plan.specials });
|
|
541
|
+
events.push(...refillEvents, { type: 'match-marked', cells: matches, wave: current.wave }, ...plan.events);
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
let stable = Object.freeze({ ...current, board, randomState, nextId, phase: 'ready', bestChain: Math.max(current.bestChain, current.wave), wave: 0, resolutionTick: 0, pendingCells: Object.freeze([]), pendingSpecials: Object.freeze([]), toolWaveActive: false });
|
|
545
|
+
if (stable.blackHole && stable.blackHoleContactPending) {
|
|
546
|
+
const contact = scheduleContact(stable, stable.board);
|
|
547
|
+
if (contact) {
|
|
548
|
+
current = contact.state;
|
|
549
|
+
events.push(...refillEvents, ...contact.events);
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
stable = Object.freeze({ ...stable, blackHoleContactPending: false });
|
|
553
|
+
}
|
|
554
|
+
if (stable.blackHole && stable.blackHoleMovePending) {
|
|
555
|
+
if (stable.blackHole.movesRemaining <= 1) {
|
|
556
|
+
stable = Object.freeze({ ...stable, blackHole: null, blackHoleMovePending: false, blackHoleContactPending: false, toolWaveActive: true, phase: 'gravity', gravityBoard: null });
|
|
557
|
+
const settledBoard = gravityBoard(stable);
|
|
558
|
+
stable = Object.freeze({ ...stable, gravityBoard: settledBoard });
|
|
559
|
+
current = stable;
|
|
560
|
+
events.push(...refillEvents, { type: 'black-hole-closed', reason: 'duration' }, { type: 'gravity-started', board: stable.board, settledBoard });
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
stable = Object.freeze({ ...stable, blackHole: Object.freeze({ ...stable.blackHole, movesRemaining: stable.blackHole.movesRemaining - 1 }), blackHoleMovePending: false });
|
|
564
|
+
}
|
|
565
|
+
const legal = listLegalSwaps(stable);
|
|
566
|
+
const goalWon = stable.mode === 'challenge' && (stable.challenge?.goals.every(goal => goalValue(stable, goal) >= goalTarget(stable, goal)) ?? false);
|
|
567
|
+
const timedOut = stable.mode === 'arcade' && stable.elapsedMs >= 180_000;
|
|
568
|
+
const moveExpired = (stable.mode === 'daily' && stable.swapCount >= 30) || (stable.mode === 'challenge' && stable.challenge?.moveLimit !== undefined && stable.swapCount >= stable.challenge.moveLimit);
|
|
569
|
+
const noActions = legal.length === 0 && !hasUsableTool(stable);
|
|
570
|
+
if (goalWon)
|
|
571
|
+
current = Object.freeze({ ...stable, phase: 'finished', outcome: 'won' });
|
|
572
|
+
else if (timedOut || moveExpired || noActions)
|
|
573
|
+
current = Object.freeze({ ...stable, phase: 'finished', outcome: stable.mode === 'challenge' && (moveExpired || noActions) ? 'lost' : 'finished' });
|
|
574
|
+
else
|
|
575
|
+
current = stable;
|
|
576
|
+
if (current.phase === 'finished')
|
|
577
|
+
events.push({ type: 'run-ended', result: current.outcome, reason: goalWon ? 'goals-complete' : timedOut ? 'time-limit' : moveExpired ? 'move-limit' : 'no-legal-actions' });
|
|
578
|
+
events.push(...refillEvents);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return { state: current, events: Object.freeze(events), accepted: true };
|
|
582
|
+
}
|
|
583
|
+
/** Advances deterministic resolution ticks; wall-clock time is supplied separately by the host. */
|
|
584
|
+
export function advanceTicks(state, ticks) {
|
|
585
|
+
if (!Number.isInteger(ticks) || ticks < 0 || ticks > 3600)
|
|
586
|
+
throw new RangeError('ticks must be an integer from 0 to 3600');
|
|
587
|
+
let current = state;
|
|
588
|
+
let consumed = 0;
|
|
589
|
+
const events = [];
|
|
590
|
+
while (consumed < ticks && current.phase !== 'ready' && current.phase !== 'finished') {
|
|
591
|
+
const result = advanceTicksBase(current, 1);
|
|
592
|
+
current = result.state;
|
|
593
|
+
events.push(...result.events);
|
|
594
|
+
consumed++;
|
|
595
|
+
}
|
|
596
|
+
if (consumed)
|
|
597
|
+
current = appendHistory(current, { kind: 'ticks', count: consumed });
|
|
598
|
+
return { state: current, events: Object.freeze(events), accepted: true };
|
|
599
|
+
}
|
|
600
|
+
/** Adds explicit host-measured elapsed time. Arcade runs end at 180 seconds; no clock is read here. */
|
|
601
|
+
export function advanceTime(state, milliseconds) {
|
|
602
|
+
if (!Number.isInteger(milliseconds) || milliseconds < 0 || milliseconds > 180_000)
|
|
603
|
+
throw new RangeError('milliseconds must be an integer from 0 to 180000');
|
|
604
|
+
if (state.mode !== 'arcade' || state.outcome || milliseconds === 0)
|
|
605
|
+
return { state, events: Object.freeze([]), accepted: true };
|
|
606
|
+
const elapsedMs = Math.min(180_000, state.elapsedMs + milliseconds);
|
|
607
|
+
let next = Object.freeze({ ...state, elapsedMs });
|
|
608
|
+
const events = [{ type: 'time-advanced', elapsedMs }];
|
|
609
|
+
if (elapsedMs >= 180_000 && state.phase === 'ready') {
|
|
610
|
+
next = Object.freeze({ ...next, phase: 'finished', outcome: 'finished' });
|
|
611
|
+
events.push({ type: 'run-ended', result: 'finished', reason: 'time-limit' });
|
|
612
|
+
}
|
|
613
|
+
next = appendHistory(next, { kind: 'time', milliseconds: elapsedMs - state.elapsedMs });
|
|
614
|
+
return { state: next, events: Object.freeze(events), accepted: true };
|
|
615
|
+
}
|
|
616
|
+
function replayOperations(initial, operations) {
|
|
617
|
+
let state = createGame(initial);
|
|
618
|
+
for (const operation of operations) {
|
|
619
|
+
if (operation.kind === 'action') {
|
|
620
|
+
const result = applyAction(state, operation.action);
|
|
621
|
+
if (!result.accepted)
|
|
622
|
+
throw new Error('Recorded action cannot be replayed');
|
|
623
|
+
state = result.state;
|
|
624
|
+
}
|
|
625
|
+
else if (operation.kind === 'ticks')
|
|
626
|
+
state = advanceTicks(state, operation.count).state;
|
|
627
|
+
else if (operation.kind === 'time')
|
|
628
|
+
state = advanceTime(state, operation.milliseconds).state;
|
|
629
|
+
}
|
|
630
|
+
return state;
|
|
631
|
+
}
|
|
632
|
+
/** Returns a deterministic legal swap hint and permanently marks the run assisted. */
|
|
633
|
+
export function hintGame(state) { return applyAction(state, { kind: 'hint' }); }
|
|
634
|
+
/** Shuffles existing gem records in Relaxed play, preserving their colour and special counts. */
|
|
635
|
+
export function reshuffleGame(state) { return applyAction(state, { kind: 'reshuffle' }); }
|
|
636
|
+
/** Restores the last committed swap in Relaxed or Challenge play and marks assistance. */
|
|
637
|
+
export function undoGame(state) { return applyAction(state, { kind: 'undo' }); }
|
|
638
|
+
/** Lists all legal swaps that would be accepted from the current stable state. */
|
|
639
|
+
export function legalActions(state) {
|
|
640
|
+
if (state.phase === 'finished')
|
|
641
|
+
return state.mode === 'relaxed' && state.outcome === 'finished' ? Object.freeze([{ kind: 'reshuffle' }]) : Object.freeze([]);
|
|
642
|
+
if (state.phase !== 'ready' || findMatches(state.board, state.settings).length)
|
|
643
|
+
return [];
|
|
644
|
+
const actions = [];
|
|
645
|
+
for (let from = 0; from < state.board.length; from++) {
|
|
646
|
+
if (!state.board[from])
|
|
647
|
+
continue;
|
|
648
|
+
for (const to of neighbors(from, state.settings.width, state.settings.height, state.settings.mask)) {
|
|
649
|
+
if (to <= from || !state.board[to])
|
|
650
|
+
continue;
|
|
651
|
+
const trial = [...state.board];
|
|
652
|
+
[trial[from], trial[to]] = [trial[to], trial[from]];
|
|
653
|
+
const matches = findMatches(trial, state.settings);
|
|
654
|
+
if (isValidSpecialSwap(trial[from], trial[to], matches.length > 0))
|
|
655
|
+
actions.push({ kind: 'swap', from, to });
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (state.settings.tools) {
|
|
659
|
+
for (const tool of TOOL_ORDER)
|
|
660
|
+
if (state.inventory[tool] > 0)
|
|
661
|
+
actions.push({ kind: 'select-tool', tool });
|
|
662
|
+
if (state.settings.advancedTools && state.blackHoleCharges > 0 && !state.blackHole)
|
|
663
|
+
actions.push({ kind: 'select-tool', tool: 'black-hole' });
|
|
664
|
+
if (state.selectedTool) {
|
|
665
|
+
for (let cell = 0; cell < state.board.length; cell++)
|
|
666
|
+
if (state.settings.mask[cell] && state.board[cell])
|
|
667
|
+
actions.push({ kind: 'target-tool', cell });
|
|
668
|
+
if (state.toolTarget !== null)
|
|
669
|
+
actions.push({ kind: 'confirm-tool' });
|
|
670
|
+
actions.push({ kind: 'cancel-tool' });
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
if (listLegalSwaps(state).length && !state.selectedTool)
|
|
674
|
+
actions.push({ kind: 'hint' });
|
|
675
|
+
if (state.mode === 'relaxed' && !state.selectedTool)
|
|
676
|
+
actions.push({ kind: 'reshuffle' });
|
|
677
|
+
if (['relaxed', 'challenge'].includes(state.mode) && !state.selectedTool && state.history.some(operation => operation.kind === 'action' && operation.action.kind === 'swap'))
|
|
678
|
+
actions.push({ kind: 'undo' });
|
|
679
|
+
return Object.freeze(actions);
|
|
680
|
+
}
|
|
681
|
+
/** Returns the current score, move count and accurately enumerated legal move count. */
|
|
682
|
+
function availableToolCount(state) { return TOOL_ORDER.reduce((count, tool) => count + state.inventory[tool], state.blackHoleCharges); }
|
|
683
|
+
function hasUsableTool(state) { return TOOL_ORDER.some(tool => state.inventory[tool] > 0) || (state.settings.advancedTools && state.blackHoleCharges > 0 && state.blackHole === null); }
|
|
684
|
+
export function statusOf(state) {
|
|
685
|
+
const goals = state.challenge?.goals ?? Object.freeze([]);
|
|
686
|
+
const goalProgress = goals.map(goal => ({ kind: goal.kind, ...(goal.kind === 'collect' ? { colour: goal.colour } : {}), current: goalValue(state, goal), target: goalTarget(state, goal), complete: goalValue(state, goal) >= goalTarget(state, goal) }));
|
|
687
|
+
const remainingMoves = state.mode === 'daily' ? Math.max(0, 30 - state.swapCount) : state.mode === 'challenge' && state.challenge?.moveLimit !== undefined ? Math.max(0, state.challenge.moveLimit - state.swapCount) : null;
|
|
688
|
+
return { phase: state.phase, score: state.score, moves: state.moves, legalMoveCount: legalActions(state).filter(action => action.kind === 'swap').length, usedFallback: state.usedFallback, availableToolCount: availableToolCount(state), inventory: state.inventory, toolProgress: state.toolProgress, toolAwardCursor: state.toolAwardCursor, assisted: state.assisted, blackHoleCharges: state.blackHoleCharges, blackHoleProgress: state.blackHoleProgress, blackHole: state.blackHole, mode: state.mode, outcome: state.outcome, remainingMoves, elapsedMs: state.elapsedMs, remainingMs: state.mode === 'arcade' ? Math.max(0, 180_000 - state.elapsedMs) : null, goals: Object.freeze([...goals]), goalProgress: Object.freeze(goalProgress), bestChain: state.bestChain };
|
|
689
|
+
}
|