@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,17 @@
|
|
|
1
|
+
import type { CreateOptions, GameState, ReplayOperation } from './types.js';
|
|
2
|
+
export declare class ReplayError extends TypeError {
|
|
3
|
+
readonly code: string;
|
|
4
|
+
constructor(code: string, message: string);
|
|
5
|
+
}
|
|
6
|
+
/** Encodes the initial rules plus accepted actions and logical inputs in canonical JSON. */
|
|
7
|
+
export declare function encodeGame(state: GameState): string;
|
|
8
|
+
/** Validates and reconstructs a state by replay; serialized board snapshots are never trusted. */
|
|
9
|
+
export declare function decodeGame(serialized: string): GameState;
|
|
10
|
+
/** Starts the exact initial ruleset and seed again, discarding all run progress. */
|
|
11
|
+
export declare function restartGame(state: GameState): GameState;
|
|
12
|
+
/** Checks a complete authored witness through the public deterministic rules API. */
|
|
13
|
+
export declare function validateChallengeWitness(options: CreateOptions, witness: readonly ReplayOperation[]): {
|
|
14
|
+
readonly valid: boolean;
|
|
15
|
+
readonly state?: GameState;
|
|
16
|
+
readonly reason?: string;
|
|
17
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { advanceTime, advanceTicks, applyAction, createGame } from './engine.js';
|
|
2
|
+
const FORMAT = 'gem-swap-replay-1';
|
|
3
|
+
const MAX_BYTES = 262_144;
|
|
4
|
+
const MAX_OPERATIONS = 5_000;
|
|
5
|
+
const MAX_TICKS = 100_000;
|
|
6
|
+
const byteLength = (value) => new TextEncoder().encode(value).byteLength;
|
|
7
|
+
export class ReplayError extends TypeError {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, message) { super(message); this.name = 'ReplayError'; this.code = code; }
|
|
10
|
+
}
|
|
11
|
+
function canonical(value) {
|
|
12
|
+
if (Array.isArray(value))
|
|
13
|
+
return `[${value.map(canonical).join(',')}]`;
|
|
14
|
+
if (value && typeof value === 'object') {
|
|
15
|
+
const object = value;
|
|
16
|
+
return `{${Object.keys(object).sort().map(key => `${JSON.stringify(key)}:${canonical(object[key])}`).join(',')}}`;
|
|
17
|
+
}
|
|
18
|
+
return JSON.stringify(value);
|
|
19
|
+
}
|
|
20
|
+
function validateOperation(value) {
|
|
21
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
22
|
+
throw new ReplayError('invalid-operation', 'Replay operation must be an object');
|
|
23
|
+
const input = value;
|
|
24
|
+
if (input.kind === 'action' && Object.keys(input).every(key => ['kind', 'action'].includes(key)) && input.action && typeof input.action === 'object' && !Array.isArray(input.action))
|
|
25
|
+
return { kind: 'action', action: input.action };
|
|
26
|
+
if (input.kind === 'ticks' && Object.keys(input).every(key => ['kind', 'count'].includes(key)) && Number.isInteger(input.count) && input.count > 0 && input.count <= 3600)
|
|
27
|
+
return { kind: 'ticks', count: input.count };
|
|
28
|
+
if (input.kind === 'time' && Object.keys(input).every(key => ['kind', 'milliseconds'].includes(key)) && Number.isInteger(input.milliseconds) && input.milliseconds > 0 && input.milliseconds <= 180_000)
|
|
29
|
+
return { kind: 'time', milliseconds: input.milliseconds };
|
|
30
|
+
throw new ReplayError('invalid-operation', 'Replay operation is malformed or unsupported');
|
|
31
|
+
}
|
|
32
|
+
function execute(initial, operations) {
|
|
33
|
+
let state = createGame(initial);
|
|
34
|
+
let ticks = 0;
|
|
35
|
+
for (let index = 0; index < operations.length; index++) {
|
|
36
|
+
const operation = operations[index];
|
|
37
|
+
if (operation.kind === 'action') {
|
|
38
|
+
const result = applyAction(state, operation.action);
|
|
39
|
+
if (!result.accepted)
|
|
40
|
+
throw new ReplayError('rejected-action', `Replay action ${index} was rejected: ${result.reason ?? 'unknown reason'}`);
|
|
41
|
+
state = result.state;
|
|
42
|
+
}
|
|
43
|
+
else if (operation.kind === 'ticks') {
|
|
44
|
+
ticks += operation.count;
|
|
45
|
+
if (ticks > MAX_TICKS)
|
|
46
|
+
throw new ReplayError('replay-too-long', 'Replay exceeds the tick work limit');
|
|
47
|
+
state = advanceTicks(state, operation.count).state;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const result = advanceTime(state, operation.milliseconds);
|
|
51
|
+
if (!result.accepted)
|
|
52
|
+
throw new ReplayError('invalid-time-step', `Replay time step ${index} was rejected`);
|
|
53
|
+
state = result.state;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return state;
|
|
57
|
+
}
|
|
58
|
+
/** Encodes the initial rules plus accepted actions and logical inputs in canonical JSON. */
|
|
59
|
+
export function encodeGame(state) {
|
|
60
|
+
if (!state || state.game !== 'gem-swap' || state.history.length > MAX_OPERATIONS)
|
|
61
|
+
throw new ReplayError('invalid-state', 'State is invalid or exceeds the operation limit');
|
|
62
|
+
const operations = state.history.map(validateOperation);
|
|
63
|
+
const replayed = execute(state.initialOptions, operations);
|
|
64
|
+
if (canonical(replayed) !== canonical(state))
|
|
65
|
+
throw new ReplayError('checkpoint-mismatch', 'State does not match its recorded inputs');
|
|
66
|
+
const result = canonical({ format: FORMAT, initial: state.initialOptions, operations });
|
|
67
|
+
if (byteLength(result) > MAX_BYTES)
|
|
68
|
+
throw new ReplayError('save-too-large', 'Replay exceeds the 256 KiB size limit');
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
/** Validates and reconstructs a state by replay; serialized board snapshots are never trusted. */
|
|
72
|
+
export function decodeGame(serialized) {
|
|
73
|
+
if (typeof serialized !== 'string' || byteLength(serialized) > MAX_BYTES)
|
|
74
|
+
throw new ReplayError('save-too-large', 'Replay must be a string no larger than 256 KiB');
|
|
75
|
+
let parsed;
|
|
76
|
+
try {
|
|
77
|
+
parsed = JSON.parse(serialized);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
throw new ReplayError('invalid-json', 'Replay is not valid JSON');
|
|
81
|
+
}
|
|
82
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
83
|
+
throw new ReplayError('invalid-recording', 'Replay root must be an object');
|
|
84
|
+
const value = parsed;
|
|
85
|
+
if (Object.keys(value).length !== 3 || Object.keys(value).some(key => !['format', 'initial', 'operations'].includes(key)) || value.format !== FORMAT || !Array.isArray(value.operations) || value.operations.length > MAX_OPERATIONS || !value.initial || typeof value.initial !== 'object' || Array.isArray(value.initial))
|
|
86
|
+
throw new ReplayError('invalid-recording', 'Replay envelope is malformed or unsupported');
|
|
87
|
+
if (canonical(parsed) !== serialized)
|
|
88
|
+
throw new ReplayError('noncanonical-recording', 'Replay JSON must use canonical key order and encoding');
|
|
89
|
+
const operations = value.operations.map(validateOperation);
|
|
90
|
+
return execute(value.initial, operations);
|
|
91
|
+
}
|
|
92
|
+
/** Starts the exact initial ruleset and seed again, discarding all run progress. */
|
|
93
|
+
export function restartGame(state) { return createGame(state.initialOptions); }
|
|
94
|
+
/** Checks a complete authored witness through the public deterministic rules API. */
|
|
95
|
+
export function validateChallengeWitness(options, witness) {
|
|
96
|
+
try {
|
|
97
|
+
if (options.mode !== 'challenge' || !Array.isArray(witness) || witness.length > MAX_OPERATIONS)
|
|
98
|
+
throw new ReplayError('invalid-witness', 'Challenge witness or settings are invalid');
|
|
99
|
+
const state = execute(options, witness.map(validateOperation));
|
|
100
|
+
if (state.outcome !== 'won')
|
|
101
|
+
throw new ReplayError('witness-does-not-win', 'Challenge witness does not complete every goal');
|
|
102
|
+
return Object.freeze({ valid: true, state });
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
return Object.freeze({ valid: false, reason: error instanceof Error ? error.message : 'invalid-witness' });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { GameState, ReplayOperation, Transition } from './types.js';
|
|
2
|
+
export declare function undoTransition(state: GameState, replay: (options: GameState['initialOptions'], operations: readonly ReplayOperation[]) => GameState): Transition;
|
|
3
|
+
export declare function hintTransition(state: GameState): Transition;
|
|
4
|
+
export declare function reshuffleTransition(state: GameState): Transition;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { findMatches, listLegalSwaps } from './board.js';
|
|
2
|
+
import { nextInt } from './random.js';
|
|
3
|
+
function rejected(state, reason) { return { state, events: Object.freeze([]), accepted: false, reason }; }
|
|
4
|
+
function onReady(state) { return state.phase === 'ready' && findMatches(state.board, state.settings).length === 0; }
|
|
5
|
+
export function undoTransition(state, replay) {
|
|
6
|
+
if (!['relaxed', 'challenge'].includes(state.mode) || state.phase !== 'ready' || state.selectedTool)
|
|
7
|
+
return rejected(state, 'undo-unavailable');
|
|
8
|
+
const outstanding = [];
|
|
9
|
+
state.history.forEach((operation, index) => { if (operation.kind !== 'action')
|
|
10
|
+
return; if (operation.action.kind === 'swap')
|
|
11
|
+
outstanding.push(index);
|
|
12
|
+
else if (operation.action.kind === 'undo')
|
|
13
|
+
outstanding.pop(); });
|
|
14
|
+
const lastSwap = outstanding.at(-1) ?? -1;
|
|
15
|
+
if (lastSwap < 0)
|
|
16
|
+
return rejected(state, 'nothing-to-undo');
|
|
17
|
+
let restored;
|
|
18
|
+
try {
|
|
19
|
+
restored = replay(state.initialOptions, state.history.slice(0, lastSwap));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return rejected(state, 'undo-unavailable');
|
|
23
|
+
}
|
|
24
|
+
return { state: Object.freeze({ ...restored, assisted: true, history: state.history }), events: Object.freeze([{ type: 'move-undone', moves: restored.moves, assisted: true }]), accepted: true };
|
|
25
|
+
}
|
|
26
|
+
export function hintTransition(state) {
|
|
27
|
+
if (!onReady(state) || state.selectedTool)
|
|
28
|
+
return rejected(state, 'hint-unavailable');
|
|
29
|
+
const witness = listLegalSwaps(state)[0];
|
|
30
|
+
if (!witness)
|
|
31
|
+
return rejected(state, 'no-legal-swap');
|
|
32
|
+
return { state: Object.freeze({ ...state, assisted: true }), events: Object.freeze([{ type: 'hint', action: witness, guarantee: 'legal-only' }]), accepted: true };
|
|
33
|
+
}
|
|
34
|
+
export function reshuffleTransition(state) {
|
|
35
|
+
const finishedRelaxed = state.mode === 'relaxed' && state.phase === 'finished' && state.outcome === 'finished' && !findMatches(state.board, state.settings).length;
|
|
36
|
+
if (state.mode !== 'relaxed' || (!onReady(state) && !finishedRelaxed) || state.selectedTool)
|
|
37
|
+
return rejected(state, 'reshuffle-unavailable');
|
|
38
|
+
const gems = state.board.filter((item) => item !== null);
|
|
39
|
+
let randomState = state.randomState;
|
|
40
|
+
for (let attempt = 0; attempt < 128; attempt++) {
|
|
41
|
+
const shuffled = [...gems];
|
|
42
|
+
for (let index = shuffled.length - 1; index > 0; index--) {
|
|
43
|
+
const [pick, next] = nextInt(randomState, index + 1);
|
|
44
|
+
randomState = next;
|
|
45
|
+
[shuffled[index], shuffled[pick]] = [shuffled[pick], shuffled[index]];
|
|
46
|
+
}
|
|
47
|
+
const board = [...state.board];
|
|
48
|
+
let at = 0;
|
|
49
|
+
for (let index = 0; index < board.length; index++)
|
|
50
|
+
if (state.settings.mask[index])
|
|
51
|
+
board[index] = shuffled[at++];
|
|
52
|
+
if (!findMatches(board, state.settings).length && listLegalSwaps({ ...state, board }).length) {
|
|
53
|
+
return { state: Object.freeze({ ...state, board: Object.freeze(board), randomState, assisted: true, phase: 'ready', outcome: null, selectedTool: null, toolTarget: null, toolPreview: Object.freeze([]) }), events: Object.freeze([{ type: 'board-reshuffled', attempts: attempt + 1, assisted: true }]), accepted: true };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return rejected(state, 'reshuffle-failed');
|
|
57
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { GemColour } from './types.js';
|
|
2
|
+
/** Hashes a seed with FNV-1a, scoped to this game and its rule identity. */
|
|
3
|
+
export declare function seedState(seed: string | number): number;
|
|
4
|
+
/** Advances the reference xorshift32 random source. */
|
|
5
|
+
export declare function nextUint32(state: number): number;
|
|
6
|
+
/** Draws an unbiased integer and its following random state. */
|
|
7
|
+
export declare function nextInt(state: number, size: number): readonly [number, number];
|
|
8
|
+
/** Draws one uniformly selected configured normal-gem colour. */
|
|
9
|
+
export declare function drawColour(state: number, colourCount: number): readonly [GemColour, number];
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { COLOURS } from './board.js';
|
|
2
|
+
/** Hashes a seed with FNV-1a, scoped to this game and its rule identity. */
|
|
3
|
+
export function seedState(seed) {
|
|
4
|
+
let hash = 0x811c9dc5;
|
|
5
|
+
for (const byte of new TextEncoder().encode(`gem-swap|swap-1|${String(seed)}`))
|
|
6
|
+
hash = Math.imul(hash ^ byte, 0x01000193) >>> 0;
|
|
7
|
+
return hash || 0x6d2b79f5;
|
|
8
|
+
}
|
|
9
|
+
/** Advances the reference xorshift32 random source. */
|
|
10
|
+
export function nextUint32(state) {
|
|
11
|
+
let next = state >>> 0;
|
|
12
|
+
next ^= next << 13;
|
|
13
|
+
next ^= next >>> 17;
|
|
14
|
+
next ^= next << 5;
|
|
15
|
+
return next >>> 0;
|
|
16
|
+
}
|
|
17
|
+
/** Draws an unbiased integer and its following random state. */
|
|
18
|
+
export function nextInt(state, size) {
|
|
19
|
+
const limit = Math.floor(0x1_0000_0000 / size) * size;
|
|
20
|
+
let current = state >>> 0;
|
|
21
|
+
for (;;) {
|
|
22
|
+
const value = nextUint32(current);
|
|
23
|
+
current = value;
|
|
24
|
+
if (value < limit)
|
|
25
|
+
return [value % size, current];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Draws one uniformly selected configured normal-gem colour. */
|
|
29
|
+
export function drawColour(state, colourCount) {
|
|
30
|
+
const [index, next] = nextInt(state, colourCount);
|
|
31
|
+
return [COLOURS[index], next];
|
|
32
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { GameEvent, GameState, Gem, PlannedSpecial } from './types.js';
|
|
2
|
+
interface ResolutionPlan {
|
|
3
|
+
readonly cells: readonly number[];
|
|
4
|
+
readonly specials: readonly PlannedSpecial[];
|
|
5
|
+
readonly events: readonly GameEvent[];
|
|
6
|
+
}
|
|
7
|
+
/** Returns whether a swap is legal under ordinary-run and explicit-combination rules. */
|
|
8
|
+
export declare function isValidSpecialSwap(first: Gem, second: Gem, hasNaturalMatch: boolean): boolean;
|
|
9
|
+
/** Resolves one normal/special match wave into a unique clear set and protected creations. */
|
|
10
|
+
export declare function planWave(state: GameState, naturalCells: readonly number[], p: number | null, q: number | null, extraClearCells?: readonly number[]): ResolutionPlan;
|
|
11
|
+
/** Materializes newly planned powers on surviving anchor gems after a wave's removal. */
|
|
12
|
+
export declare function materializeSpecials(board: readonly (Gem | null)[], specials: readonly PlannedSpecial[]): readonly (Gem | null)[];
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
function runsOf(state) {
|
|
2
|
+
const { width, height, mask } = state.settings;
|
|
3
|
+
const runs = [];
|
|
4
|
+
const scan = (cells, axis) => {
|
|
5
|
+
let at = 0;
|
|
6
|
+
while (at < cells.length) {
|
|
7
|
+
const gem = state.board[cells[at]];
|
|
8
|
+
if (!gem) {
|
|
9
|
+
at++;
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
let end = at + 1;
|
|
13
|
+
while (end < cells.length && state.board[cells[end]]?.colour === gem.colour)
|
|
14
|
+
end++;
|
|
15
|
+
if (end - at >= 3)
|
|
16
|
+
runs.push({ cells: cells.slice(at, end), axis });
|
|
17
|
+
at = end;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
for (let y = 0; y < height; y++) {
|
|
21
|
+
let segment = [];
|
|
22
|
+
for (let x = 0; x <= width; x++) {
|
|
23
|
+
const index = y * width + x;
|
|
24
|
+
if (x < width && mask[index])
|
|
25
|
+
segment.push(index);
|
|
26
|
+
else {
|
|
27
|
+
scan(segment, 'horizontal');
|
|
28
|
+
segment = [];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (let x = 0; x < width; x++) {
|
|
33
|
+
let segment = [];
|
|
34
|
+
for (let y = 0; y <= height; y++) {
|
|
35
|
+
const index = y * width + x;
|
|
36
|
+
if (y < height && mask[index])
|
|
37
|
+
segment.push(index);
|
|
38
|
+
else {
|
|
39
|
+
scan(segment, 'vertical');
|
|
40
|
+
segment = [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return runs;
|
|
45
|
+
}
|
|
46
|
+
function compareRun(a, b, p, q, width) {
|
|
47
|
+
const hasQ = Number(b.cells.includes(q ?? -1)) - Number(a.cells.includes(q ?? -1));
|
|
48
|
+
if (hasQ)
|
|
49
|
+
return hasQ;
|
|
50
|
+
const hasP = Number(b.cells.includes(p ?? -1)) - Number(a.cells.includes(p ?? -1));
|
|
51
|
+
if (hasP)
|
|
52
|
+
return hasP;
|
|
53
|
+
const maxY = (run) => Math.max(...run.cells.map(cell => Math.floor(cell / width)));
|
|
54
|
+
const byMaxY = maxY(b) - maxY(a);
|
|
55
|
+
if (byMaxY)
|
|
56
|
+
return byMaxY;
|
|
57
|
+
const minX = (run) => Math.min(...run.cells.map(cell => cell % width));
|
|
58
|
+
const byMinX = minX(a) - minX(b);
|
|
59
|
+
if (byMinX)
|
|
60
|
+
return byMinX;
|
|
61
|
+
if (a.axis !== b.axis)
|
|
62
|
+
return a.axis === 'horizontal' ? -1 : 1;
|
|
63
|
+
return a.cells[0] - b.cells[0];
|
|
64
|
+
}
|
|
65
|
+
function chooseAnchor(cells, state, p, q) {
|
|
66
|
+
const eligible = cells.filter(cell => state.board[cell] && !state.board[cell].kind);
|
|
67
|
+
eligible.sort((a, b) => {
|
|
68
|
+
const aQ = a === q ? 0 : 1;
|
|
69
|
+
const bQ = b === q ? 0 : 1;
|
|
70
|
+
if (aQ !== bQ)
|
|
71
|
+
return aQ - bQ;
|
|
72
|
+
const aP = a === p ? 0 : 1;
|
|
73
|
+
const bP = b === p ? 0 : 1;
|
|
74
|
+
if (aP !== bP)
|
|
75
|
+
return aP - bP;
|
|
76
|
+
const yDiff = Math.floor(b / state.settings.width) - Math.floor(a / state.settings.width);
|
|
77
|
+
if (yDiff)
|
|
78
|
+
return yDiff;
|
|
79
|
+
return a % state.settings.width - b % state.settings.width;
|
|
80
|
+
});
|
|
81
|
+
return eligible[0] ?? null;
|
|
82
|
+
}
|
|
83
|
+
function planCreations(state, runs, p, q) {
|
|
84
|
+
const parents = runs.map((_, index) => index);
|
|
85
|
+
const root = (value) => parents[value] === value ? value : (parents[value] = root(parents[value]));
|
|
86
|
+
const join = (a, b) => { const ra = root(a); const rb = root(b); if (ra !== rb)
|
|
87
|
+
parents[rb] = ra; };
|
|
88
|
+
const owner = new Map();
|
|
89
|
+
for (let i = 0; i < runs.length; i++)
|
|
90
|
+
for (const cell of runs[i].cells) {
|
|
91
|
+
const prior = owner.get(cell);
|
|
92
|
+
if (prior === undefined)
|
|
93
|
+
owner.set(cell, i);
|
|
94
|
+
else
|
|
95
|
+
join(i, prior);
|
|
96
|
+
}
|
|
97
|
+
const groups = new Map();
|
|
98
|
+
for (let i = 0; i < runs.length; i++) {
|
|
99
|
+
const key = root(i);
|
|
100
|
+
const group = groups.get(key) ?? [];
|
|
101
|
+
group.push(runs[i]);
|
|
102
|
+
groups.set(key, group);
|
|
103
|
+
}
|
|
104
|
+
const planned = [];
|
|
105
|
+
for (const group of groups.values()) {
|
|
106
|
+
const burstRuns = group.filter(run => run.cells.length >= 5).sort((a, b) => b.cells.length - a.cells.length || compareRun(a, b, p, q, state.settings.width));
|
|
107
|
+
if (burstRuns.length) {
|
|
108
|
+
const run = burstRuns[0];
|
|
109
|
+
const anchor = chooseAnchor(run.cells, state, p, q);
|
|
110
|
+
if (anchor !== null)
|
|
111
|
+
planned.push({ cell: anchor, kind: 'colour-burst' });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const horizontal = group.filter(run => run.axis === 'horizontal');
|
|
115
|
+
const vertical = group.filter(run => run.axis === 'vertical');
|
|
116
|
+
const intersections = [...new Set(horizontal.flatMap(h => vertical.flatMap(v => h.cells.filter(cell => v.cells.includes(cell)))))].sort((a, b) => a - b);
|
|
117
|
+
if (intersections.length) {
|
|
118
|
+
const anchor = chooseAnchor(intersections, state, p, q);
|
|
119
|
+
if (anchor !== null)
|
|
120
|
+
planned.push({ cell: anchor, kind: 'bomb' });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const fourRuns = group.filter(run => run.cells.length === 4).sort((a, b) => compareRun(a, b, p, q, state.settings.width));
|
|
124
|
+
if (fourRuns.length) {
|
|
125
|
+
const run = fourRuns[0];
|
|
126
|
+
const anchor = chooseAnchor(run.cells, state, p, q);
|
|
127
|
+
if (anchor !== null)
|
|
128
|
+
planned.push({ cell: anchor, kind: run.axis === 'horizontal' ? 'row-beam' : 'column-beam' });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return Object.freeze(planned.sort((a, b) => a.cell - b.cell));
|
|
132
|
+
}
|
|
133
|
+
function isBurst(kind) { return kind === 'colour-burst'; }
|
|
134
|
+
function isSpecial(gem) { return Boolean(gem?.kind); }
|
|
135
|
+
/** Returns whether a swap is legal under ordinary-run and explicit-combination rules. */
|
|
136
|
+
export function isValidSpecialSwap(first, second, hasNaturalMatch) {
|
|
137
|
+
if (isSpecial(first) && isSpecial(second))
|
|
138
|
+
return true;
|
|
139
|
+
if (isBurst(first.kind) || isBurst(second.kind))
|
|
140
|
+
return true;
|
|
141
|
+
return first.colour !== second.colour && hasNaturalMatch;
|
|
142
|
+
}
|
|
143
|
+
function activeCells(state) { return state.settings.mask.flatMap((on, index) => on && state.board[index] ? [index] : []); }
|
|
144
|
+
function comboPlan(state, p, q, protectedCells, pairIds) {
|
|
145
|
+
const a = state.board[p];
|
|
146
|
+
const b = state.board[q];
|
|
147
|
+
const ak = a.kind;
|
|
148
|
+
const bk = b.kind;
|
|
149
|
+
if (!ak && !bk)
|
|
150
|
+
return null;
|
|
151
|
+
const centre = q;
|
|
152
|
+
const { width, height } = state.settings;
|
|
153
|
+
const direct = new Set([p, q]);
|
|
154
|
+
const conversions = [];
|
|
155
|
+
const row = (y) => activeCells(state).filter(cell => Math.floor(cell / width) === y);
|
|
156
|
+
const column = (x) => activeCells(state).filter(cell => cell % width === x);
|
|
157
|
+
const burst = ak === 'colour-burst' ? a : bk === 'colour-burst' ? b : null;
|
|
158
|
+
if (ak && bk) {
|
|
159
|
+
if (ak === 'colour-burst' && bk === 'colour-burst')
|
|
160
|
+
return { name: 'burst-burst', direct: activeCells(state), conversions };
|
|
161
|
+
if (ak === 'colour-burst' || bk === 'colour-burst') {
|
|
162
|
+
const power = ak === 'colour-burst' ? bk : ak;
|
|
163
|
+
if (power !== 'row-beam' && power !== 'column-beam' && power !== 'bomb')
|
|
164
|
+
return null;
|
|
165
|
+
const targetColour = ak === 'colour-burst' ? b.colour : a.colour;
|
|
166
|
+
const conversionKind = power;
|
|
167
|
+
for (const cell of activeCells(state)) {
|
|
168
|
+
const gem = state.board[cell];
|
|
169
|
+
if (gem.colour === targetColour && !pairIds.has(gem.id) && !protectedCells.has(cell))
|
|
170
|
+
conversions.push({ cell, kind: conversionKind });
|
|
171
|
+
}
|
|
172
|
+
return { name: `burst-${power}`, direct: [...direct, ...conversions.map(item => item.cell)], conversions };
|
|
173
|
+
}
|
|
174
|
+
if (ak === 'bomb' && bk === 'bomb') {
|
|
175
|
+
const x = centre % width;
|
|
176
|
+
const y = Math.floor(centre / width);
|
|
177
|
+
for (const cell of activeCells(state))
|
|
178
|
+
if (Math.abs(cell % width - x) <= 2 && Math.abs(Math.floor(cell / width) - y) <= 2)
|
|
179
|
+
direct.add(cell);
|
|
180
|
+
return { name: 'bomb-bomb', direct: [...direct], conversions };
|
|
181
|
+
}
|
|
182
|
+
if ((ak === 'row-beam' || ak === 'column-beam') && (bk === 'row-beam' || bk === 'column-beam')) {
|
|
183
|
+
for (const cell of row(Math.floor(centre / width)))
|
|
184
|
+
direct.add(cell);
|
|
185
|
+
for (const cell of column(centre % width))
|
|
186
|
+
direct.add(cell);
|
|
187
|
+
return { name: 'beam-beam', direct: [...direct], conversions };
|
|
188
|
+
}
|
|
189
|
+
const beam = ak === 'bomb' ? bk : bk === 'bomb' ? ak : null;
|
|
190
|
+
if (beam === 'row-beam' || beam === 'column-beam') {
|
|
191
|
+
const x = centre % width;
|
|
192
|
+
const y = Math.floor(centre / width);
|
|
193
|
+
for (let rowY = Math.max(0, y - 1); rowY <= Math.min(height - 1, y + 1); rowY++)
|
|
194
|
+
for (const cell of row(rowY))
|
|
195
|
+
direct.add(cell);
|
|
196
|
+
for (let columnX = Math.max(0, x - 1); columnX <= Math.min(width - 1, x + 1); columnX++)
|
|
197
|
+
for (const cell of column(columnX))
|
|
198
|
+
direct.add(cell);
|
|
199
|
+
return { name: 'beam-bomb', direct: [...direct], conversions };
|
|
200
|
+
}
|
|
201
|
+
if (ak === 'bomb' && bk === 'bomb')
|
|
202
|
+
return { name: 'bomb-bomb', direct: [...direct], conversions };
|
|
203
|
+
}
|
|
204
|
+
if (burst) {
|
|
205
|
+
const target = ak === 'colour-burst' ? b : a;
|
|
206
|
+
for (const cell of activeCells(state))
|
|
207
|
+
if (state.board[cell].colour === target.colour)
|
|
208
|
+
direct.add(cell);
|
|
209
|
+
return { name: 'burst-normal', direct: [...direct], conversions };
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
function effectCells(state, cell, kind) {
|
|
214
|
+
const { width, height } = state.settings;
|
|
215
|
+
const x = cell % width;
|
|
216
|
+
const y = Math.floor(cell / width);
|
|
217
|
+
if (kind === 'row-beam')
|
|
218
|
+
return activeCells(state).filter(index => Math.floor(index / width) === y);
|
|
219
|
+
if (kind === 'column-beam')
|
|
220
|
+
return activeCells(state).filter(index => index % width === x);
|
|
221
|
+
if (kind === 'bomb')
|
|
222
|
+
return activeCells(state).filter(index => Math.abs(index % width - x) <= 1 && Math.abs(Math.floor(index / width) - y) <= 1);
|
|
223
|
+
const colour = state.board[cell].colour;
|
|
224
|
+
return activeCells(state).filter(index => state.board[index].colour === colour);
|
|
225
|
+
}
|
|
226
|
+
/** Resolves one normal/special match wave into a unique clear set and protected creations. */
|
|
227
|
+
export function planWave(state, naturalCells, p, q, extraClearCells = []) {
|
|
228
|
+
const runs = runsOf(state);
|
|
229
|
+
const specials = planCreations(state, runs, p, q);
|
|
230
|
+
const protectedCells = new Set(specials.map(item => item.cell));
|
|
231
|
+
const pairIds = new Set(p === null || q === null ? [] : [state.board[p].id, state.board[q].id]);
|
|
232
|
+
const combo = p === null || q === null ? null : comboPlan(state, p, q, protectedCells, pairIds);
|
|
233
|
+
const consumedPairIds = combo ? pairIds : new Set();
|
|
234
|
+
const conversionKinds = new Map((combo?.conversions ?? []).map(item => [state.board[item.cell].id, item.kind]));
|
|
235
|
+
const cleared = new Set();
|
|
236
|
+
const activations = [];
|
|
237
|
+
const activationIds = new Set();
|
|
238
|
+
const events = [];
|
|
239
|
+
const activationEvents = [];
|
|
240
|
+
const addActivation = (cell, kind) => {
|
|
241
|
+
const gem = state.board[cell];
|
|
242
|
+
if (!gem || protectedCells.has(cell) || consumedPairIds.has(gem.id) || activationIds.has(gem.id))
|
|
243
|
+
return;
|
|
244
|
+
activationIds.add(gem.id);
|
|
245
|
+
activations.push({ cell, gemId: gem.id, kind });
|
|
246
|
+
};
|
|
247
|
+
const addCell = (cell) => {
|
|
248
|
+
const gem = state.board[cell];
|
|
249
|
+
if (!gem || protectedCells.has(cell))
|
|
250
|
+
return;
|
|
251
|
+
cleared.add(cell);
|
|
252
|
+
if (isSpecial(gem))
|
|
253
|
+
addActivation(cell, conversionKinds.get(gem.id) ?? gem.kind);
|
|
254
|
+
};
|
|
255
|
+
for (const cell of naturalCells)
|
|
256
|
+
addCell(cell);
|
|
257
|
+
for (const cell of extraClearCells)
|
|
258
|
+
addCell(cell);
|
|
259
|
+
if (combo) {
|
|
260
|
+
events.push({ type: 'special-combination', combo: combo.name, centre: q });
|
|
261
|
+
const convertedCells = new Set(combo.conversions.map(item => item.cell));
|
|
262
|
+
for (const cell of combo.direct) {
|
|
263
|
+
const gem = state.board[cell];
|
|
264
|
+
if (gem && !protectedCells.has(cell) && (pairIds.has(gem.id) || convertedCells.has(cell)))
|
|
265
|
+
cleared.add(cell);
|
|
266
|
+
else
|
|
267
|
+
addCell(cell);
|
|
268
|
+
}
|
|
269
|
+
for (const converted of combo.conversions) {
|
|
270
|
+
const gem = state.board[converted.cell];
|
|
271
|
+
if (!gem || protectedCells.has(converted.cell))
|
|
272
|
+
continue;
|
|
273
|
+
cleared.add(converted.cell);
|
|
274
|
+
addActivation(converted.cell, converted.kind);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
while (activations.length) {
|
|
278
|
+
activations.sort((a, b) => a.cell - b.cell || a.gemId - b.gemId);
|
|
279
|
+
const current = activations.shift();
|
|
280
|
+
const kind = current.kind;
|
|
281
|
+
activationEvents.push({ type: 'special-activated', id: current.gemId, cell: current.cell, kind });
|
|
282
|
+
for (const cell of effectCells(state, current.cell, kind))
|
|
283
|
+
addCell(cell);
|
|
284
|
+
}
|
|
285
|
+
activationEvents.sort((a, b) => Number(a.cell) - Number(b.cell) || Number(a.id) - Number(b.id));
|
|
286
|
+
events.push(...activationEvents);
|
|
287
|
+
for (const special of specials)
|
|
288
|
+
events.push({ type: 'special-planned', cell: special.cell, kind: special.kind, id: state.board[special.cell].id });
|
|
289
|
+
return { cells: Object.freeze([...cleared].sort((a, b) => a - b)), specials, events: Object.freeze(events) };
|
|
290
|
+
}
|
|
291
|
+
/** Materializes newly planned powers on surviving anchor gems after a wave's removal. */
|
|
292
|
+
export function materializeSpecials(board, specials) {
|
|
293
|
+
const next = [...board];
|
|
294
|
+
for (const planned of specials) {
|
|
295
|
+
const gem = next[planned.cell];
|
|
296
|
+
if (gem)
|
|
297
|
+
next[planned.cell] = Object.freeze({ ...gem, kind: planned.kind });
|
|
298
|
+
}
|
|
299
|
+
return Object.freeze(next);
|
|
300
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { planWave } from './specials.js';
|
|
2
|
+
/** Computes the occupied cells affected by a tool and all special activations it causes. */
|
|
3
|
+
export function previewTool(state, tool, target) {
|
|
4
|
+
const { width, height, mask } = state.settings;
|
|
5
|
+
if (!Number.isInteger(target) || target < 0 || target >= state.board.length || !mask[target] || !state.board[target])
|
|
6
|
+
return Object.freeze([]);
|
|
7
|
+
const x = target % width;
|
|
8
|
+
const y = Math.floor(target / width);
|
|
9
|
+
const targetGem = state.board[target];
|
|
10
|
+
const direct = state.board.flatMap((gem, cell) => {
|
|
11
|
+
if (!gem || !mask[cell])
|
|
12
|
+
return [];
|
|
13
|
+
if (tool === 'colour-clear')
|
|
14
|
+
return gem.colour === targetGem.colour ? [cell] : [];
|
|
15
|
+
if (tool === 'row-clear')
|
|
16
|
+
return Math.floor(cell / width) === y ? [cell] : [];
|
|
17
|
+
return Math.abs(cell % width - x) <= 1 && Math.abs(Math.floor(cell / width) - y) <= 1 ? [cell] : [];
|
|
18
|
+
});
|
|
19
|
+
return planWave(state, [], null, null, direct).cells;
|
|
20
|
+
}
|