@johnmorrisdotca/houseki 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +79 -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 +77 -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 +41 -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 +272 -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 +135 -0
- package/dist/stone-collapse.d.ts +6 -0
- package/dist/stone-collapse.js +4 -0
- package/package.json +96 -4
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
import { optionsFor, neighbors, StoneCollapseOptionsError } from './validation.js';
|
|
2
|
+
const COLOURS = ['red', 'blue', 'green', 'gold', 'purple', 'teal'];
|
|
3
|
+
const COLOURS_USED = COLOURS;
|
|
4
|
+
const RULES = 'collapse-2';
|
|
5
|
+
const MARK_TICKS = 7;
|
|
6
|
+
const REMOVE_TICKS = 6;
|
|
7
|
+
const GRAVITY_TICKS = 9;
|
|
8
|
+
const MAX_RECORDED_ACTIONS = 100_000;
|
|
9
|
+
const MAX_RECORDED_TICKS = 10_000_000;
|
|
10
|
+
function hashSeed(seed) {
|
|
11
|
+
let hash = 0x811c9dc5;
|
|
12
|
+
const bytes = new TextEncoder().encode(String(seed));
|
|
13
|
+
for (const byte of bytes)
|
|
14
|
+
hash = Math.imul(hash ^ byte, 0x01000193) >>> 0;
|
|
15
|
+
return hash || 0x6d2b79f5;
|
|
16
|
+
}
|
|
17
|
+
function nextUint32(state) {
|
|
18
|
+
let value = state >>> 0;
|
|
19
|
+
value ^= value << 13;
|
|
20
|
+
value ^= value >>> 17;
|
|
21
|
+
value ^= value << 5;
|
|
22
|
+
return value >>> 0;
|
|
23
|
+
}
|
|
24
|
+
function nextInt(state, size) {
|
|
25
|
+
const limit = Math.floor(0x1_0000_0000 / size) * size;
|
|
26
|
+
let current = state >>> 0;
|
|
27
|
+
for (;;) {
|
|
28
|
+
const value = nextUint32(current);
|
|
29
|
+
current = value;
|
|
30
|
+
if (value < limit)
|
|
31
|
+
return [value % size, current];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function drawColour(state, count) {
|
|
35
|
+
const [index, next] = nextInt(state, count);
|
|
36
|
+
return [COLOURS[index], next];
|
|
37
|
+
}
|
|
38
|
+
function groupAt(board, settings, start) {
|
|
39
|
+
const stone = board[start];
|
|
40
|
+
if (!stone)
|
|
41
|
+
return [];
|
|
42
|
+
const mask = settings.mask;
|
|
43
|
+
const found = new Set([start]);
|
|
44
|
+
const pending = [start];
|
|
45
|
+
while (pending.length) {
|
|
46
|
+
const at = pending.pop();
|
|
47
|
+
for (const next of neighbors(at, settings.width, settings.height, mask))
|
|
48
|
+
if (!found.has(next) && board[next]?.colour === stone.colour) {
|
|
49
|
+
found.add(next);
|
|
50
|
+
pending.push(next);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return [...found].sort((a, b) => a - b);
|
|
54
|
+
}
|
|
55
|
+
function hasLegalGroup(board, settings) {
|
|
56
|
+
return board.some((stone, index) => Boolean(stone && groupAt(board, settings, index).length >= 2));
|
|
57
|
+
}
|
|
58
|
+
function canUseTool(state) {
|
|
59
|
+
return state.settings.tools && state.board.some(Boolean) && (state.inventory.bomb > 0 || state.inventory.pick > 0);
|
|
60
|
+
}
|
|
61
|
+
function previewAt(state, tool, target) {
|
|
62
|
+
if (!Number.isInteger(target) || target < 0 || target >= state.board.length || !state.settings.mask[target] || !state.board[target])
|
|
63
|
+
return [];
|
|
64
|
+
if (tool === 'pick')
|
|
65
|
+
return [target];
|
|
66
|
+
const { width, height, mask } = state.settings;
|
|
67
|
+
const tx = target % width, ty = Math.floor(target / width);
|
|
68
|
+
const cells = [];
|
|
69
|
+
for (let y = Math.max(0, ty - 1); y <= Math.min(height - 1, ty + 1); y++)
|
|
70
|
+
for (let x = Math.max(0, tx - 1); x <= Math.min(width - 1, tx + 1); x++) {
|
|
71
|
+
const index = y * width + x;
|
|
72
|
+
if (mask[index] && state.board[index])
|
|
73
|
+
cells.push(index);
|
|
74
|
+
}
|
|
75
|
+
return cells;
|
|
76
|
+
}
|
|
77
|
+
function clearToolSelection(state) {
|
|
78
|
+
return { selectedTool: null, toolTarget: null, toolPreview: Object.freeze([]) };
|
|
79
|
+
}
|
|
80
|
+
function recordToolAction(state, action) {
|
|
81
|
+
return state.recording.length >= MAX_RECORDED_ACTIONS ? null : Object.freeze([...state.recording, action]);
|
|
82
|
+
}
|
|
83
|
+
function undoFrame(state) {
|
|
84
|
+
return Object.freeze({ board: state.board, score: state.score, moves: state.moves, removed: state.removed, randomState: state.randomState, nextId: state.nextId, usedFallback: state.usedFallback, finishAdjustmentApplied: state.finishAdjustmentApplied, selectedId: state.selectedId, selectedIds: state.selectedIds, previewScore: state.previewScore, witnessIndex: state.witnessIndex, inventory: state.inventory, toolProgress: state.toolProgress, toolAwardCursor: state.toolAwardCursor, selectedTool: state.selectedTool, toolTarget: state.toolTarget, toolPreview: state.toolPreview });
|
|
85
|
+
}
|
|
86
|
+
/** @internal Constructs the generator's bounded valid-pair fallback for direct invariant checks. */
|
|
87
|
+
export function fallbackBoard(mask, width, height, colourCount) {
|
|
88
|
+
const pair = mask.findIndex((on, index) => on && neighbors(index, width, height, mask).some(next => next > index));
|
|
89
|
+
if (pair < 0)
|
|
90
|
+
throw new StoneCollapseOptionsError('invalid-fallback-mask', 'Fallback requires at least one orthogonal active-cell pair');
|
|
91
|
+
const partner = neighbors(pair, width, height, mask).find(next => next > pair);
|
|
92
|
+
let id = 1;
|
|
93
|
+
return Object.freeze(mask.map((on, index) => !on ? null : Object.freeze({ id: id++, colour: index === pair || index === partner ? COLOURS_USED[0] : COLOURS_USED[(index + 1) % colourCount] })));
|
|
94
|
+
}
|
|
95
|
+
function baseState(settings, board, randomState, challenge, usedFallback = false) {
|
|
96
|
+
const initialBoard = Object.freeze([...board]);
|
|
97
|
+
const inventory = settings.tools ? { bomb: 1, pick: 1 } : { bomb: 0, pick: 0 };
|
|
98
|
+
return {
|
|
99
|
+
game: 'stone-collapse', rules: RULES, settings: Object.freeze({ ...settings, mask: Object.freeze([...settings.mask]) }), challenge,
|
|
100
|
+
initialBoard, board: initialBoard, phase: 'ready', selectedId: null, selectedIds: Object.freeze([]), previewScore: 0,
|
|
101
|
+
score: 0, moves: 0, removed: 0, randomState, nextId: Math.max(0, ...board.flatMap(stone => stone ? [stone.id] : [])) + 1,
|
|
102
|
+
usedFallback, resolutionTick: 0, pendingIds: Object.freeze([]), gravityBoard: null,
|
|
103
|
+
finishAdjustmentApplied: false, assisted: false, history: Object.freeze([]), witnessIndex: challenge ? 0 : -1,
|
|
104
|
+
elapsedTicks: 0, recording: Object.freeze([]), inventory: Object.freeze(inventory), toolProgress: 0, toolAwardCursor: 0,
|
|
105
|
+
selectedTool: null, toolTarget: null, toolPreview: Object.freeze([]),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/** Creates a seeded full board; an all-singleton draw retries before using a checked adjacent pair. */
|
|
109
|
+
export function createGame(options = {}) {
|
|
110
|
+
const valid = optionsFor(options); // All settings are checked before drawing any random values.
|
|
111
|
+
let randomState = hashSeed(`stone-collapse|${RULES}|${String(valid.seed)}`);
|
|
112
|
+
let board = [];
|
|
113
|
+
let nextId = 1;
|
|
114
|
+
let usedFallback = false;
|
|
115
|
+
let found = false;
|
|
116
|
+
for (let attempt = 0; attempt < 128 && !found; attempt++) {
|
|
117
|
+
const candidate = [];
|
|
118
|
+
let id = 1;
|
|
119
|
+
for (const active of valid.mask) {
|
|
120
|
+
if (!active)
|
|
121
|
+
candidate.push(null);
|
|
122
|
+
else {
|
|
123
|
+
const [colour, next] = drawColour(randomState, valid.colourCount);
|
|
124
|
+
randomState = next;
|
|
125
|
+
candidate.push({ id: id++, colour });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
board = candidate;
|
|
129
|
+
nextId = id;
|
|
130
|
+
const settings = { ...valid, mask: valid.mask };
|
|
131
|
+
found = hasLegalGroup(board, settings);
|
|
132
|
+
}
|
|
133
|
+
if (!found) {
|
|
134
|
+
// Preserve the consumed stream and IDs; the first adjacent active pair is a guaranteed legal move.
|
|
135
|
+
board = [...fallbackBoard(valid.mask, valid.width, valid.height, valid.colourCount)];
|
|
136
|
+
nextId = valid.mask.filter(Boolean).length + 1;
|
|
137
|
+
usedFallback = true;
|
|
138
|
+
}
|
|
139
|
+
const settings = { ...valid, mode: valid.mode, mask: valid.mask };
|
|
140
|
+
const state = baseState(settings, board, randomState, null, usedFallback);
|
|
141
|
+
return { ...state, nextId };
|
|
142
|
+
}
|
|
143
|
+
/** Creates a fixed witnessed challenge after validating its board, objective, move budget, and every witness group. */
|
|
144
|
+
export function createChallenge(options) {
|
|
145
|
+
if (!options || typeof options !== 'object' || Array.isArray(options))
|
|
146
|
+
throw new StoneCollapseOptionsError('invalid-challenge', 'Challenge options must be an object');
|
|
147
|
+
const allowedKeys = ['id', 'width', 'height', 'colourCount', 'seed', 'mask', 'initialBoard', 'goal', 'moveLimit', 'witness'];
|
|
148
|
+
if (Object.keys(options).some(key => !allowedKeys.includes(key)))
|
|
149
|
+
throw new StoneCollapseOptionsError('unknown-challenge-option', 'Challenge options contain an unsupported field');
|
|
150
|
+
if (typeof options.id !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(options.id))
|
|
151
|
+
throw new StoneCollapseOptionsError('invalid-challenge-id', 'Challenge ID must be a stable lowercase identifier');
|
|
152
|
+
const base = optionsFor({ width: options.width, height: options.height, colourCount: options.colourCount, seed: options.seed === undefined ? `challenge:${options.id}` : options.seed, ...(options.mask !== undefined ? { mask: options.mask } : {}) });
|
|
153
|
+
if (!Array.isArray(options.initialBoard) || options.initialBoard.length !== base.width * base.height)
|
|
154
|
+
throw new StoneCollapseOptionsError('invalid-challenge-board', 'Challenge board must contain one entry per cell');
|
|
155
|
+
const ids = new Set();
|
|
156
|
+
const initialBoard = Array.from(options.initialBoard, (stone, index) => {
|
|
157
|
+
if (!base.mask[index]) {
|
|
158
|
+
if (stone !== null)
|
|
159
|
+
throw new StoneCollapseOptionsError('stone-in-masked-cell', 'Masked challenge cells must be empty');
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
if (!stone || typeof stone !== 'object' || Array.isArray(stone) || Object.keys(stone).some(key => key !== 'id' && key !== 'colour') || !Number.isSafeInteger(stone.id) || stone.id < 1 || stone.id > 0x7fff_ffff || ids.has(stone.id) || !COLOURS.slice(0, base.colourCount).includes(stone.colour))
|
|
163
|
+
throw new StoneCollapseOptionsError('invalid-challenge-stone', 'Active challenge cells need unique positive IDs and configured colours');
|
|
164
|
+
ids.add(stone.id);
|
|
165
|
+
return Object.freeze({ id: stone.id, colour: stone.colour });
|
|
166
|
+
});
|
|
167
|
+
if (!options.goal || typeof options.goal !== 'object' || !['clear-all', 'clear-targets', 'score-target'].includes(options.goal.kind))
|
|
168
|
+
throw new StoneCollapseOptionsError('invalid-challenge-goal', 'Challenge goal must clear all, clear marked stones, or reach a score');
|
|
169
|
+
let goal;
|
|
170
|
+
if (options.goal.kind === 'clear-all') {
|
|
171
|
+
if (Object.keys(options.goal).some(key => key !== 'kind'))
|
|
172
|
+
throw new StoneCollapseOptionsError('invalid-challenge-goal', 'Clear-all goal contains unsupported fields');
|
|
173
|
+
goal = { kind: 'clear-all' };
|
|
174
|
+
}
|
|
175
|
+
else if (options.goal.kind === 'clear-targets') {
|
|
176
|
+
if (Object.keys(options.goal).some(key => key !== 'kind' && key !== 'targetIds'))
|
|
177
|
+
throw new StoneCollapseOptionsError('invalid-challenge-goal', 'Target goal contains unsupported fields');
|
|
178
|
+
const targets = options.goal.targetIds;
|
|
179
|
+
if (!Array.isArray(targets) || targets.length === 0 || targets.length > 144 || Array.from(targets).some(id => !Number.isSafeInteger(id) || !ids.has(id)) || new Set(targets).size !== targets.length)
|
|
180
|
+
throw new StoneCollapseOptionsError('invalid-target-ids', 'Every target ID must name one unique initial challenge stone');
|
|
181
|
+
goal = { kind: 'clear-targets', targetIds: Object.freeze(Array.from(targets).sort((a, b) => a - b)) };
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
if (Object.keys(options.goal).some(key => key !== 'kind' && key !== 'minimumScore'))
|
|
185
|
+
throw new StoneCollapseOptionsError('invalid-challenge-goal', 'Score goal contains unsupported fields');
|
|
186
|
+
const minimumScore = options.goal.minimumScore;
|
|
187
|
+
if (!Number.isSafeInteger(minimumScore) || minimumScore < 1 || minimumScore > 1_000_000_000)
|
|
188
|
+
throw new StoneCollapseOptionsError('invalid-score-goal', 'Minimum score must be an integer from 1 to 1,000,000,000');
|
|
189
|
+
goal = { kind: 'score-target', minimumScore };
|
|
190
|
+
}
|
|
191
|
+
if (options.moveLimit !== undefined && (!Number.isInteger(options.moveLimit) || options.moveLimit < 1 || options.moveLimit > 144))
|
|
192
|
+
throw new StoneCollapseOptionsError('invalid-move-limit', 'Challenge move limit must be an integer from 1 to 144');
|
|
193
|
+
if (goal.kind === 'score-target' && options.moveLimit === undefined)
|
|
194
|
+
throw new StoneCollapseOptionsError('score-goal-needs-budget', 'Score-target challenges require a finite move limit');
|
|
195
|
+
if (!Array.isArray(options.witness) || options.witness.length < 1 || options.witness.length > 72)
|
|
196
|
+
throw new StoneCollapseOptionsError('invalid-witness', 'Challenge witness must contain 1–72 group removals');
|
|
197
|
+
const witness = Array.from(options.witness, group => {
|
|
198
|
+
if (!Array.isArray(group) || group.length < 2 || group.length > 144 || Array.from(group).some(id => !Number.isSafeInteger(id) || !ids.has(id)) || new Set(group).size !== group.length)
|
|
199
|
+
throw new StoneCollapseOptionsError('invalid-witness-group', 'Each witness step must name distinct initial stone IDs');
|
|
200
|
+
return Object.freeze(Array.from(group).sort((a, b) => a - b));
|
|
201
|
+
});
|
|
202
|
+
if (options.moveLimit !== undefined && witness.length > options.moveLimit)
|
|
203
|
+
throw new StoneCollapseOptionsError('witness-exceeds-budget', 'Witness is longer than the challenge move limit');
|
|
204
|
+
const seed = base.seed;
|
|
205
|
+
const challenge = Object.freeze({ id: options.id, width: base.width, height: base.height, colourCount: base.colourCount, seed, mask: Object.freeze([...base.mask]), initialBoard: Object.freeze(initialBoard), goal, ...(options.moveLimit === undefined ? {} : { moveLimit: options.moveLimit }), witness: Object.freeze(witness) });
|
|
206
|
+
const settings = { mode: 'challenge', width: base.width, height: base.height, colourCount: base.colourCount, seed, mask: challenge.mask, challengeId: challenge.id, goal, tools: false, ...(options.moveLimit === undefined ? {} : { moveLimit: options.moveLimit }) };
|
|
207
|
+
let state = baseState(settings, challenge.initialBoard, hashSeed(`stone-collapse|${RULES}|${String(seed)}`), challenge);
|
|
208
|
+
let board = challenge.initialBoard;
|
|
209
|
+
let score = 0;
|
|
210
|
+
let moves = 0;
|
|
211
|
+
for (let index = 0; index < challenge.witness.length; index++) {
|
|
212
|
+
if (goalSatisfied({ ...state, board, score }))
|
|
213
|
+
throw new StoneCollapseOptionsError('witness-continues-after-goal', 'Challenge witness must stop at the first completed goal');
|
|
214
|
+
const group = challenge.witness[index];
|
|
215
|
+
const at = indexOfId(board, group[0]);
|
|
216
|
+
const actual = at < 0 ? [] : groupAt(board, settings, at).map(cell => board[cell].id).sort((a, b) => a - b);
|
|
217
|
+
if (actual.length !== group.length || actual.some((id, i) => id !== group[i]))
|
|
218
|
+
throw new StoneCollapseOptionsError('invalid-witness-path', `Witness group ${index + 1} is not the complete current connected group`);
|
|
219
|
+
score += groupScore(group.length);
|
|
220
|
+
moves++;
|
|
221
|
+
board = removeAndSettle({ ...state, board, settings }, group);
|
|
222
|
+
state = { ...state, board, score, moves };
|
|
223
|
+
if (!goalSatisfied(state) && (!hasLegalGroup(board, settings) || challenge.moveLimit !== undefined && moves >= challenge.moveLimit))
|
|
224
|
+
throw new StoneCollapseOptionsError('witness-does-not-complete-goal', `Witness ends before the goal after move ${moves}`);
|
|
225
|
+
}
|
|
226
|
+
if (!goalSatisfied(state))
|
|
227
|
+
throw new StoneCollapseOptionsError('witness-does-not-complete-goal', 'Challenge witness does not reach its objective');
|
|
228
|
+
return baseState(settings, challenge.initialBoard, hashSeed(`stone-collapse|${RULES}|${String(seed)}`), challenge);
|
|
229
|
+
}
|
|
230
|
+
function groupScore(size) { return 5 * size * (size - 1); }
|
|
231
|
+
function emptyTransition(state, reason) { return { state, events: [], accepted: false, reason }; }
|
|
232
|
+
function indexOfId(board, id) { return board.findIndex(stone => stone?.id === id); }
|
|
233
|
+
function removeAndSettle(state, removeIds) {
|
|
234
|
+
const remove = new Set(removeIds);
|
|
235
|
+
const board = state.board.map(stone => stone && remove.has(stone.id) ? null : stone);
|
|
236
|
+
const result = [...board];
|
|
237
|
+
const { width, height, mask } = state.settings;
|
|
238
|
+
for (let x = 0; x < width; x++) {
|
|
239
|
+
let y = 0;
|
|
240
|
+
while (y < height) {
|
|
241
|
+
while (y < height && !mask[y * width + x])
|
|
242
|
+
y++;
|
|
243
|
+
if (y >= height)
|
|
244
|
+
break;
|
|
245
|
+
const start = y;
|
|
246
|
+
while (y < height && mask[y * width + x])
|
|
247
|
+
y++;
|
|
248
|
+
const end = y;
|
|
249
|
+
const survivors = [];
|
|
250
|
+
for (let row = start; row < end; row++) {
|
|
251
|
+
const stone = board[row * width + x];
|
|
252
|
+
if (stone)
|
|
253
|
+
survivors.push(stone);
|
|
254
|
+
}
|
|
255
|
+
for (let row = start; row < end; row++)
|
|
256
|
+
result[row * width + x] = null;
|
|
257
|
+
for (let offset = 0; offset < survivors.length; offset++)
|
|
258
|
+
result[(end - survivors.length + offset) * width + x] = survivors[offset];
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (mask.every(Boolean)) {
|
|
262
|
+
const kept = [];
|
|
263
|
+
for (let x = 0; x < width; x++) {
|
|
264
|
+
const column = Array.from({ length: height }, (_, y) => result[y * width + x] ?? null);
|
|
265
|
+
if (column.some(Boolean))
|
|
266
|
+
kept.push(column);
|
|
267
|
+
}
|
|
268
|
+
for (let x = 0; x < width; x++)
|
|
269
|
+
for (let y = 0; y < height; y++)
|
|
270
|
+
result[y * width + x] = kept[x]?.[y] ?? null;
|
|
271
|
+
}
|
|
272
|
+
return Object.freeze(result);
|
|
273
|
+
}
|
|
274
|
+
function terminal(state) {
|
|
275
|
+
if (state.settings.mode === 'challenge' && state.challenge) {
|
|
276
|
+
if (goalSatisfied(state)) {
|
|
277
|
+
const bonus = state.board.every(stone => stone === null) && !state.finishAdjustmentApplied ? 1000 : 0;
|
|
278
|
+
const won = { ...state, phase: 'won', reason: 'goal-complete', score: state.score + bonus, finishAdjustmentApplied: state.finishAdjustmentApplied || bonus > 0 };
|
|
279
|
+
return [won, [{ type: 'run-ended', result: 'won', reason: 'goal-complete', bonus, score: won.score }]];
|
|
280
|
+
}
|
|
281
|
+
if (!hasLegalGroup(state.board, state.settings))
|
|
282
|
+
return [{ ...state, phase: 'lost', reason: 'no-legal-groups' }, [{ type: 'run-ended', result: 'lost', reason: 'no-legal-groups', score: state.score }]];
|
|
283
|
+
if (state.challenge.moveLimit !== undefined && state.moves >= state.challenge.moveLimit)
|
|
284
|
+
return [{ ...state, phase: 'lost', reason: 'move-limit', score: state.score }, [{ type: 'run-ended', result: 'lost', reason: 'move-limit', score: state.score }]];
|
|
285
|
+
return [state, []];
|
|
286
|
+
}
|
|
287
|
+
if (state.board.every(stone => stone === null)) {
|
|
288
|
+
if (state.finishAdjustmentApplied)
|
|
289
|
+
return [state, []];
|
|
290
|
+
return [{ ...state, phase: 'won', reason: 'board-empty', score: state.score + 1000, finishAdjustmentApplied: true }, [{ type: 'run-ended', result: 'won', reason: 'board-empty', bonus: 1000, score: state.score + 1000 }]];
|
|
291
|
+
}
|
|
292
|
+
if (!hasLegalGroup(state.board, state.settings)) {
|
|
293
|
+
if (canUseTool(state))
|
|
294
|
+
return [state, []];
|
|
295
|
+
const penalty = Math.min(state.score, 10 * state.board.filter(Boolean).length);
|
|
296
|
+
if (state.finishAdjustmentApplied)
|
|
297
|
+
return [state, []];
|
|
298
|
+
return [{ ...state, phase: 'finished', reason: 'no-legal-groups', score: state.score - penalty, finishAdjustmentApplied: true }, [{ type: 'run-ended', result: 'finished', reason: 'no-legal-groups', penalty, remaining: state.board.filter(Boolean).length, score: state.score - penalty }]];
|
|
299
|
+
}
|
|
300
|
+
return [state, []];
|
|
301
|
+
}
|
|
302
|
+
function goalSatisfied(state) {
|
|
303
|
+
const goal = state.challenge?.goal;
|
|
304
|
+
if (!goal)
|
|
305
|
+
return false;
|
|
306
|
+
if (goal.kind === 'clear-all')
|
|
307
|
+
return state.board.every(stone => stone === null);
|
|
308
|
+
if (goal.kind === 'clear-targets')
|
|
309
|
+
return goal.targetIds.every(id => !state.board.some(stone => stone?.id === id));
|
|
310
|
+
return state.score >= goal.minimumScore;
|
|
311
|
+
}
|
|
312
|
+
function confirmSelected(state) {
|
|
313
|
+
if (state.recording.length >= MAX_RECORDED_ACTIONS)
|
|
314
|
+
return emptyTransition(state, 'recording-action-limit');
|
|
315
|
+
if (state.selectedId === null || state.selectedIds.length < 2)
|
|
316
|
+
return emptyTransition(state, 'no-legal-group-selected');
|
|
317
|
+
const currentIndex = indexOfId(state.board, state.selectedId);
|
|
318
|
+
const currentIds = currentIndex < 0 ? [] : groupAt(state.board, state.settings, currentIndex).map(at => state.board[at].id);
|
|
319
|
+
if (currentIds.length !== state.selectedIds.length || currentIds.some((id, i) => id !== state.selectedIds[i]))
|
|
320
|
+
return emptyTransition(state, 'stale-selection');
|
|
321
|
+
const ids = [...state.selectedIds];
|
|
322
|
+
const points = groupScore(ids.length);
|
|
323
|
+
const score = state.score + points;
|
|
324
|
+
const gravityBoard = removeAndSettle(state, ids);
|
|
325
|
+
const moves = state.moves + 1;
|
|
326
|
+
const frame = undoFrame(state);
|
|
327
|
+
let inventory = state.inventory;
|
|
328
|
+
let toolProgress = state.toolProgress;
|
|
329
|
+
let toolAwardCursor = state.toolAwardCursor;
|
|
330
|
+
const awardEvents = [];
|
|
331
|
+
if (state.settings.tools) {
|
|
332
|
+
const total = state.toolProgress + ids.length;
|
|
333
|
+
let awards = Math.floor(total / 12);
|
|
334
|
+
toolProgress = total % 12;
|
|
335
|
+
const awardedInventory = { ...state.inventory };
|
|
336
|
+
while (awards-- > 0) {
|
|
337
|
+
const tool = toolAwardCursor === 0 ? 'bomb' : 'pick';
|
|
338
|
+
toolAwardCursor = (toolAwardCursor === 0 ? 1 : 0);
|
|
339
|
+
const discarded = awardedInventory[tool] >= 3;
|
|
340
|
+
if (!discarded)
|
|
341
|
+
awardedInventory[tool]++;
|
|
342
|
+
awardEvents.push({ type: 'tool-awarded', tool, count: awardedInventory[tool], discarded });
|
|
343
|
+
}
|
|
344
|
+
inventory = Object.freeze(awardedInventory);
|
|
345
|
+
}
|
|
346
|
+
const expected = state.challenge?.witness[state.witnessIndex];
|
|
347
|
+
const witnessIndex = expected && expected.length === ids.length && expected.every((id, i) => [...ids].sort((a, b) => a - b)[i] === id) ? state.witnessIndex + 1 : state.challenge ? -1 : -1;
|
|
348
|
+
const next = { ...state, selectedId: null, selectedIds: Object.freeze([]), previewScore: 0, ...clearToolSelection(state), inventory, toolProgress, toolAwardCursor, score, moves, phase: 'clear-mark', resolutionTick: 0, pendingIds: Object.freeze(ids), gravityBoard, history: Object.freeze([...state.history, frame]), witnessIndex, recording: Object.freeze([...state.recording, { kind: 'remove', move: moves, stoneId: state.selectedId }]) };
|
|
349
|
+
return { state: next, events: [{ type: 'group-committed', ids, points, move: moves }, { type: 'score-changed', score, points }, ...awardEvents], accepted: true };
|
|
350
|
+
}
|
|
351
|
+
function confirmTool(state) {
|
|
352
|
+
if (!state.settings.tools || !state.selectedTool || state.toolTarget === null || state.recording.length >= MAX_RECORDED_ACTIONS)
|
|
353
|
+
return emptyTransition(state, 'tool-not-ready');
|
|
354
|
+
const count = state.inventory[state.selectedTool];
|
|
355
|
+
const preview = previewAt(state, state.selectedTool, state.toolTarget);
|
|
356
|
+
if (count < 1 || !preview.length || preview.length !== state.toolPreview.length || preview.some((cell, index) => state.toolPreview[index] !== cell))
|
|
357
|
+
return emptyTransition(state, 'stale-tool-preview');
|
|
358
|
+
const ids = preview.map(index => state.board[index].id);
|
|
359
|
+
const points = 10 * ids.length;
|
|
360
|
+
const moves = state.moves + 1;
|
|
361
|
+
const inventory = Object.freeze({ ...state.inventory, [state.selectedTool]: count - 1 });
|
|
362
|
+
const frame = undoFrame(state);
|
|
363
|
+
const recording = recordToolAction(state, { kind: 'confirm-tool', move: moves });
|
|
364
|
+
if (!recording)
|
|
365
|
+
return emptyTransition(state, 'recording-action-limit');
|
|
366
|
+
const next = {
|
|
367
|
+
...state, ...clearToolSelection(state), selectedId: null, selectedIds: Object.freeze([]), previewScore: 0,
|
|
368
|
+
inventory: inventory, score: state.score + points, moves, phase: 'clear-mark', resolutionTick: 0,
|
|
369
|
+
pendingIds: Object.freeze(ids), gravityBoard: removeAndSettle(state, ids), history: Object.freeze([...state.history, frame]),
|
|
370
|
+
assisted: true, recording,
|
|
371
|
+
};
|
|
372
|
+
return { state: next, events: [{ type: 'tool-used', tool: state.selectedTool, target: state.toolTarget, cells: preview, ids, points, move: moves }, { type: 'score-changed', score: next.score, points }], accepted: true };
|
|
373
|
+
}
|
|
374
|
+
function hintGroup(state) {
|
|
375
|
+
if (state.settings.mode !== 'challenge' || !state.challenge || state.witnessIndex < 0)
|
|
376
|
+
return null;
|
|
377
|
+
const expected = state.challenge.witness[state.witnessIndex];
|
|
378
|
+
if (!expected)
|
|
379
|
+
return null;
|
|
380
|
+
const at = indexOfId(state.board, expected[0]);
|
|
381
|
+
if (at < 0)
|
|
382
|
+
return null;
|
|
383
|
+
const actual = groupAt(state.board, state.settings, at).map(cell => state.board[cell].id);
|
|
384
|
+
const sorted = [...actual].sort((a, b) => a - b);
|
|
385
|
+
return sorted.length === expected.length && sorted.every((id, i) => id === expected[i]) ? actual : null;
|
|
386
|
+
}
|
|
387
|
+
function canUndo(state) { return (state.settings.mode === 'relaxed' || state.settings.mode === 'challenge') && state.history.length > 0 && state.recording.length < MAX_RECORDED_ACTIONS; }
|
|
388
|
+
function undoCore(state) {
|
|
389
|
+
if (!canUndo(state) || !['ready', 'won', 'finished', 'lost'].includes(state.phase))
|
|
390
|
+
return emptyTransition(state, 'undo-unavailable');
|
|
391
|
+
const frame = state.history.at(-1);
|
|
392
|
+
const restored = { ...state, ...frame, phase: 'ready', resolutionTick: 0, pendingIds: Object.freeze([]), gravityBoard: null, assisted: true, history: Object.freeze(state.history.slice(0, -1)), reason: undefined, recording: Object.freeze([...state.recording, { kind: 'undo', move: frame.moves }]) };
|
|
393
|
+
return { state: restored, events: [{ type: 'move-undone', move: frame.moves, assisted: true }], accepted: true };
|
|
394
|
+
}
|
|
395
|
+
function applyCore(state, action) {
|
|
396
|
+
if (!action || typeof action !== 'object' || !['select', 'confirm', 'cancel', 'undo', 'hint', 'select-tool', 'target-tool', 'confirm-tool', 'cancel-tool'].includes(action.kind ?? '') || action.kind === 'select' && !Number.isInteger(action.stoneId) || action.kind === 'select-tool' && !['bomb', 'pick'].includes(action.tool) || action.kind === 'target-tool' && !Number.isInteger(action.cell))
|
|
397
|
+
return emptyTransition(state, 'invalid-action');
|
|
398
|
+
const actionKeys = action.kind === 'select' ? ['kind', 'stoneId'] : action.kind === 'select-tool' ? ['kind', 'tool'] : action.kind === 'target-tool' ? ['kind', 'cell'] : ['kind'];
|
|
399
|
+
if (Object.keys(action).some(key => !actionKeys.includes(key)))
|
|
400
|
+
return emptyTransition(state, 'invalid-action');
|
|
401
|
+
if (action.kind === 'undo')
|
|
402
|
+
return undoCore(state);
|
|
403
|
+
if (state.phase !== 'ready')
|
|
404
|
+
return emptyTransition(state, 'resolution-in-progress');
|
|
405
|
+
if (action.kind === 'select-tool') {
|
|
406
|
+
if (!state.settings.tools)
|
|
407
|
+
return emptyTransition(state, 'tools-disabled');
|
|
408
|
+
if (state.inventory[action.tool] < 1)
|
|
409
|
+
return emptyTransition(state, 'tool-out-of-stock');
|
|
410
|
+
if (state.recording.length > MAX_RECORDED_ACTIONS - 3)
|
|
411
|
+
return emptyTransition(state, 'recording-action-limit');
|
|
412
|
+
const recording = recordToolAction(state, action);
|
|
413
|
+
if (!recording)
|
|
414
|
+
return emptyTransition(state, 'recording-action-limit');
|
|
415
|
+
return { state: { ...state, selectedId: null, selectedIds: Object.freeze([]), previewScore: 0, selectedTool: action.tool, toolTarget: null, toolPreview: Object.freeze([]), recording }, events: [{ type: 'tool-selected', tool: action.tool, count: state.inventory[action.tool] }], accepted: true };
|
|
416
|
+
}
|
|
417
|
+
if (action.kind === 'target-tool') {
|
|
418
|
+
if (!state.settings.tools || !state.selectedTool)
|
|
419
|
+
return emptyTransition(state, 'tool-not-selected');
|
|
420
|
+
if (state.recording.length > MAX_RECORDED_ACTIONS - 2)
|
|
421
|
+
return emptyTransition(state, 'recording-action-limit');
|
|
422
|
+
const preview = previewAt(state, state.selectedTool, action.cell);
|
|
423
|
+
if (!preview.length)
|
|
424
|
+
return emptyTransition(state, 'tool-target-must-be-occupied');
|
|
425
|
+
const recording = recordToolAction(state, action);
|
|
426
|
+
if (!recording)
|
|
427
|
+
return emptyTransition(state, 'recording-action-limit');
|
|
428
|
+
const ids = preview.map(index => state.board[index].id);
|
|
429
|
+
return { state: { ...state, toolTarget: action.cell, toolPreview: Object.freeze([...preview]), recording }, events: [{ type: 'tool-targeted', tool: state.selectedTool, target: action.cell, cells: preview, ids, count: preview.length }], accepted: true };
|
|
430
|
+
}
|
|
431
|
+
if (action.kind === 'confirm-tool')
|
|
432
|
+
return confirmTool(state);
|
|
433
|
+
if (action.kind === 'cancel-tool') {
|
|
434
|
+
if (!state.selectedTool)
|
|
435
|
+
return emptyTransition(state, 'tool-not-selected');
|
|
436
|
+
const recording = recordToolAction(state, action);
|
|
437
|
+
if (!recording)
|
|
438
|
+
return emptyTransition(state, 'recording-action-limit');
|
|
439
|
+
return { state: { ...state, ...clearToolSelection(state), recording }, events: [{ type: 'tool-cancelled' }], accepted: true };
|
|
440
|
+
}
|
|
441
|
+
if (action.kind === 'hint') {
|
|
442
|
+
if (state.recording.length >= MAX_RECORDED_ACTIONS)
|
|
443
|
+
return emptyTransition(state, 'recording-action-limit');
|
|
444
|
+
const group = hintGroup(state);
|
|
445
|
+
if (!group)
|
|
446
|
+
return emptyTransition(state, state.settings.mode === 'challenge' ? 'no-proved-hint' : 'hints-disabled');
|
|
447
|
+
const stoneId = group[0];
|
|
448
|
+
return { state: { ...state, ...clearToolSelection(state), selectedId: stoneId, selectedIds: Object.freeze(group), previewScore: groupScore(group.length), assisted: true, recording: Object.freeze([...state.recording, { kind: 'hint', witnessIndex: state.witnessIndex }]) }, events: [{ type: 'hint-shown', ids: group, points: groupScore(group.length), assisted: true }], accepted: true };
|
|
449
|
+
}
|
|
450
|
+
if (action.kind === 'select') {
|
|
451
|
+
const index = indexOfId(state.board, action.stoneId);
|
|
452
|
+
if (index < 0)
|
|
453
|
+
return emptyTransition(state, 'unknown-stone');
|
|
454
|
+
const ids = groupAt(state.board, state.settings, index);
|
|
455
|
+
if (ids.length < 2)
|
|
456
|
+
return emptyTransition(state, 'choose-two-or-more-connected-stones');
|
|
457
|
+
const selectedIds = ids.map(at => state.board[at].id);
|
|
458
|
+
if (state.selectedId !== null && state.selectedIds.length === selectedIds.length && state.selectedIds.every((id, i) => id === selectedIds[i]))
|
|
459
|
+
return confirmSelected(state);
|
|
460
|
+
return { state: { ...state, ...clearToolSelection(state), selectedId: action.stoneId, selectedIds: Object.freeze(selectedIds), previewScore: groupScore(ids.length) }, events: [{ type: 'group-selected', ids: selectedIds, size: ids.length, points: groupScore(ids.length) }], accepted: true };
|
|
461
|
+
}
|
|
462
|
+
if (action.kind === 'cancel') {
|
|
463
|
+
if (state.selectedId === null)
|
|
464
|
+
return emptyTransition(state, 'nothing-selected');
|
|
465
|
+
return { state: { ...state, selectedId: null, selectedIds: Object.freeze([]), previewScore: 0 }, events: [{ type: 'selection-cleared' }], accepted: true };
|
|
466
|
+
}
|
|
467
|
+
return confirmSelected(state);
|
|
468
|
+
}
|
|
469
|
+
/** Applies a selection, confirmation, or cancellation without mutating its input. */
|
|
470
|
+
export function applyAction(state, action) { return applyCore(state, action); }
|
|
471
|
+
/** Selects the next supplied witness group only while the run still matches its path; accepted hints mark the run assisted. */
|
|
472
|
+
export function requestHint(state) { return applyCore(state, { kind: 'hint' }); }
|
|
473
|
+
/** Restores the full pre-move state in Relaxed or Challenge practice and permanently marks the attempt assisted. */
|
|
474
|
+
export function undo(state) { return undoCore(state); }
|
|
475
|
+
/** Advances the observable 7/6/9 tick removal and gravity stages, bounded to 3600 ticks. */
|
|
476
|
+
export function advanceTicks(state, ticks) {
|
|
477
|
+
if (!Number.isInteger(ticks) || ticks < 0 || ticks > 3600)
|
|
478
|
+
throw new RangeError('ticks must be an integer from 0 to 3600');
|
|
479
|
+
if (state.elapsedTicks + ticks > MAX_RECORDED_TICKS)
|
|
480
|
+
throw new RangeError('recording exceeds 10,000,000 logical ticks');
|
|
481
|
+
let current = state;
|
|
482
|
+
const events = [];
|
|
483
|
+
for (let i = 0; i < ticks; i++) {
|
|
484
|
+
if (current.phase === 'won' || current.phase === 'lost' || current.phase === 'finished')
|
|
485
|
+
break;
|
|
486
|
+
if (current.phase === 'ready')
|
|
487
|
+
break;
|
|
488
|
+
current = { ...current, elapsedTicks: current.elapsedTicks + 1 };
|
|
489
|
+
const resolutionTick = current.resolutionTick + 1;
|
|
490
|
+
if (current.phase === 'clear-mark' && resolutionTick >= MARK_TICKS) {
|
|
491
|
+
const pending = new Set(current.pendingIds);
|
|
492
|
+
const board = Object.freeze(current.board.map(stone => stone && pending.has(stone.id) ? null : stone));
|
|
493
|
+
current = { ...current, board, removed: current.removed + current.pendingIds.length, phase: 'clear-remove', resolutionTick: 0 };
|
|
494
|
+
events.push({ type: 'removal-started', ids: current.pendingIds, board: current.board });
|
|
495
|
+
}
|
|
496
|
+
else if (current.phase === 'clear-remove' && resolutionTick >= REMOVE_TICKS) {
|
|
497
|
+
current = { ...current, phase: 'gravity', resolutionTick: 0 };
|
|
498
|
+
events.push({ type: 'gravity-started', board: current.board, settledBoard: current.gravityBoard });
|
|
499
|
+
}
|
|
500
|
+
else if (current.phase === 'gravity' && resolutionTick >= GRAVITY_TICKS) {
|
|
501
|
+
current = { ...current, board: current.gravityBoard ?? current.board, gravityBoard: null, phase: 'ready', resolutionTick: 0, pendingIds: Object.freeze([]) };
|
|
502
|
+
events.push({ type: 'gravity-completed', board: current.board });
|
|
503
|
+
const ended = terminal(current);
|
|
504
|
+
current = ended[0];
|
|
505
|
+
events.push(...ended[1]);
|
|
506
|
+
}
|
|
507
|
+
else
|
|
508
|
+
current = { ...current, resolutionTick };
|
|
509
|
+
}
|
|
510
|
+
const advanced = current.elapsedTicks - state.elapsedTicks;
|
|
511
|
+
if (advanced > 0) {
|
|
512
|
+
if (current.recording.length >= MAX_RECORDED_ACTIONS)
|
|
513
|
+
return emptyTransition(state, 'recording-action-limit');
|
|
514
|
+
const recording = [...current.recording];
|
|
515
|
+
const last = recording.at(-1);
|
|
516
|
+
if (last?.kind === 'ticks')
|
|
517
|
+
recording[recording.length - 1] = { kind: 'ticks', count: last.count + advanced };
|
|
518
|
+
else
|
|
519
|
+
recording.push({ kind: 'ticks', count: advanced });
|
|
520
|
+
current = { ...current, recording: Object.freeze(recording) };
|
|
521
|
+
}
|
|
522
|
+
return { state: current, events: Object.freeze(events), accepted: true };
|
|
523
|
+
}
|
|
524
|
+
/** Lists every selection and confirmation currently accepted by the rules. */
|
|
525
|
+
export function legalActions(state) {
|
|
526
|
+
if (state.phase === 'won' || state.phase === 'finished' || state.phase === 'lost')
|
|
527
|
+
return canUndo(state) ? [{ kind: 'undo' }] : [];
|
|
528
|
+
if (state.phase !== 'ready')
|
|
529
|
+
return [];
|
|
530
|
+
const recordingRoom = state.recording.length < MAX_RECORDED_ACTIONS;
|
|
531
|
+
const actions = [];
|
|
532
|
+
for (let index = 0; index < state.board.length; index++) {
|
|
533
|
+
const stone = state.board[index];
|
|
534
|
+
if (!stone || groupAt(state.board, state.settings, index).length < 2)
|
|
535
|
+
continue;
|
|
536
|
+
if (!recordingRoom && state.selectedIds.includes(stone.id))
|
|
537
|
+
continue;
|
|
538
|
+
actions.push({ kind: 'select', stoneId: stone.id });
|
|
539
|
+
}
|
|
540
|
+
if (state.selectedId !== null) {
|
|
541
|
+
if (recordingRoom)
|
|
542
|
+
actions.push({ kind: 'confirm' });
|
|
543
|
+
actions.push({ kind: 'cancel' });
|
|
544
|
+
}
|
|
545
|
+
if (state.settings.tools) {
|
|
546
|
+
if (state.recording.length <= MAX_RECORDED_ACTIONS - 3)
|
|
547
|
+
for (const tool of ['bomb', 'pick'])
|
|
548
|
+
if (state.inventory[tool] > 0)
|
|
549
|
+
actions.push({ kind: 'select-tool', tool });
|
|
550
|
+
if (state.selectedTool) {
|
|
551
|
+
if (state.recording.length <= MAX_RECORDED_ACTIONS - 2)
|
|
552
|
+
for (let cell = 0; cell < state.board.length; cell++)
|
|
553
|
+
if (state.board[cell] && state.settings.mask[cell])
|
|
554
|
+
actions.push({ kind: 'target-tool', cell });
|
|
555
|
+
if (recordingRoom)
|
|
556
|
+
actions.push({ kind: 'cancel-tool' });
|
|
557
|
+
if (recordingRoom && state.toolTarget !== null && state.toolPreview.length)
|
|
558
|
+
actions.push({ kind: 'confirm-tool' });
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (recordingRoom && hintGroup(state))
|
|
562
|
+
actions.push({ kind: 'hint' });
|
|
563
|
+
if (canUndo(state))
|
|
564
|
+
actions.push({ kind: 'undo' });
|
|
565
|
+
return actions;
|
|
566
|
+
}
|
|
567
|
+
/** Returns the stable public game status and the remaining-stone count. */
|
|
568
|
+
export function statusOf(state) {
|
|
569
|
+
return { mode: state.settings.mode, phase: state.phase, score: state.score, moves: state.moves, removed: state.removed, remaining: state.board.filter(Boolean).length, previewScore: state.previewScore, usedFallback: state.usedFallback, assisted: state.assisted, inventory: state.inventory, toolProgress: state.toolProgress, toolAwardCursor: state.toolAwardCursor, selectedTool: state.selectedTool, toolTarget: state.toolTarget, toolPreview: state.toolPreview, ...(state.settings.goal ? { goal: state.settings.goal } : {}), ...(state.reason ? { reason: state.reason } : {}) };
|
|
570
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { GameState } from './types.js';
|
|
2
|
+
/** Starts a fresh attempt from the identical seeded board or preserved challenge definition. */
|
|
3
|
+
export declare function restartGame(state: GameState): GameState;
|
|
4
|
+
/** Encodes the canonical initial configuration, accepted replay actions, and a derived checkpoint. */
|
|
5
|
+
export declare function encodeGame(state: GameState): string;
|
|
6
|
+
/** Reconstructs and validates a bounded replay before accepting its checkpoint. */
|
|
7
|
+
export declare function decodeGame(text: string): GameState;
|