@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,136 @@
|
|
|
1
|
+
import { validateState } from './validation.js';
|
|
2
|
+
import { freezeNatureState } from './state.js';
|
|
3
|
+
const indexOf = (x, y, width) => y * width + x;
|
|
4
|
+
const coordinates = (index, width) => ({ x: index % width, y: Math.floor(index / width) });
|
|
5
|
+
function deepFreeze(value) {
|
|
6
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
7
|
+
Object.freeze(value);
|
|
8
|
+
for (const child of Object.values(value))
|
|
9
|
+
deepFreeze(child);
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
function visiblePairCandidates(state) {
|
|
14
|
+
const magnets = state.cells.flatMap((entity, index) => entity?.kind === 'stone' && entity.magnetic ? [{ entity, index }] : []);
|
|
15
|
+
const pairs = [];
|
|
16
|
+
for (let i = 0; i < magnets.length; i++)
|
|
17
|
+
for (let j = i + 1; j < magnets.length; j++) {
|
|
18
|
+
const first = magnets[i], second = magnets[j];
|
|
19
|
+
if (first.entity.anchored && second.entity.anchored)
|
|
20
|
+
continue;
|
|
21
|
+
const p = coordinates(first.index, state.width), q = coordinates(second.index, state.width);
|
|
22
|
+
const axis = p.y === q.y ? 'horizontal' : p.x === q.x ? 'vertical' : null;
|
|
23
|
+
if (!axis)
|
|
24
|
+
continue;
|
|
25
|
+
const distance = Math.abs(p.x - q.x) + Math.abs(p.y - q.y);
|
|
26
|
+
if (distance <= 1)
|
|
27
|
+
continue;
|
|
28
|
+
const stepX = Math.sign(q.x - p.x), stepY = Math.sign(q.y - p.y);
|
|
29
|
+
let clear = true;
|
|
30
|
+
for (let offset = 1; offset < distance; offset++) {
|
|
31
|
+
const x = p.x + stepX * offset, y = p.y + stepY * offset, index = indexOf(x, y, state.width);
|
|
32
|
+
if (!state.activeCells[index] || state.cells[index] !== null) {
|
|
33
|
+
clear = false;
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (clear)
|
|
38
|
+
pairs.push({ a: first.entity, b: second.entity, aIndex: first.index, bIndex: second.index, axis, gap: distance - 1 });
|
|
39
|
+
}
|
|
40
|
+
return pairs.sort((left, right) => left.gap - right.gap || Math.min(left.a.id, left.b.id) - Math.min(right.a.id, right.b.id) || Math.max(left.a.id, left.b.id) - Math.max(right.a.id, right.b.id));
|
|
41
|
+
}
|
|
42
|
+
function selectDisjoint(state) {
|
|
43
|
+
const used = new Set();
|
|
44
|
+
const result = [];
|
|
45
|
+
for (const pair of visiblePairCandidates(state))
|
|
46
|
+
if (!used.has(pair.a.id) && !used.has(pair.b.id)) {
|
|
47
|
+
result.push(pair);
|
|
48
|
+
used.add(pair.a.id);
|
|
49
|
+
used.add(pair.b.id);
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
function findIndex(board, id) { return board.findIndex(entity => entity?.id === id); }
|
|
54
|
+
function relocate(board, from, to) {
|
|
55
|
+
const entity = board[from];
|
|
56
|
+
if (!entity || board[to] !== null)
|
|
57
|
+
return false;
|
|
58
|
+
board[to] = entity;
|
|
59
|
+
board[from] = null;
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
function stepMove(board, state, fromIndex, toX, toY) {
|
|
63
|
+
const entity = board[fromIndex];
|
|
64
|
+
const toIndex = indexOf(toX, toY, state.width);
|
|
65
|
+
if (!entity || toX < 0 || toX >= state.width || toY < 0 || toY >= state.height || !state.activeCells[toIndex] || board[toIndex] !== null)
|
|
66
|
+
return null;
|
|
67
|
+
const from = coordinates(fromIndex, state.width);
|
|
68
|
+
relocate(board, fromIndex, toIndex);
|
|
69
|
+
return { id: entity.id, from, to: { x: toX, y: toY } };
|
|
70
|
+
}
|
|
71
|
+
function movePair(board, state, pair) {
|
|
72
|
+
const localMoves = [];
|
|
73
|
+
const ids = [pair.a.id, pair.b.id].sort((a, b) => a - b);
|
|
74
|
+
let safety = state.width + state.height;
|
|
75
|
+
while (safety-- > 0) {
|
|
76
|
+
const aIndexCurrent = findIndex(board, pair.a.id), bIndexCurrent = findIndex(board, pair.b.id);
|
|
77
|
+
if (aIndexCurrent < 0 || bIndexCurrent < 0)
|
|
78
|
+
break;
|
|
79
|
+
const a = coordinates(aIndexCurrent, state.width), b = coordinates(bIndexCurrent, state.width);
|
|
80
|
+
const distance = Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
|
|
81
|
+
if (distance <= 1)
|
|
82
|
+
break;
|
|
83
|
+
const vector = { x: Math.sign(b.x - a.x), y: Math.sign(b.y - a.y) };
|
|
84
|
+
const aEntity = board[aIndexCurrent], bEntity = board[bIndexCurrent];
|
|
85
|
+
if (aEntity.anchored || bEntity.anchored) {
|
|
86
|
+
const moverIsA = bEntity.anchored;
|
|
87
|
+
const from = moverIsA ? a : b;
|
|
88
|
+
const toward = moverIsA ? vector : { x: -vector.x, y: -vector.y };
|
|
89
|
+
const move = stepMove(board, state, moverIsA ? aIndexCurrent : bIndexCurrent, from.x + toward.x, from.y + toward.y);
|
|
90
|
+
if (!move)
|
|
91
|
+
break;
|
|
92
|
+
localMoves.push(move);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (distance === 2) {
|
|
96
|
+
const moverId = Math.min(aEntity.id, bEntity.id);
|
|
97
|
+
const from = moverId === aEntity.id ? a : b;
|
|
98
|
+
const toward = moverId === aEntity.id ? vector : { x: -vector.x, y: -vector.y };
|
|
99
|
+
const move = stepMove(board, state, findIndex(board, moverId), from.x + toward.x, from.y + toward.y);
|
|
100
|
+
if (!move)
|
|
101
|
+
break;
|
|
102
|
+
localMoves.push(move);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
const moveA = { from: aIndexCurrent, to: indexOf(a.x + vector.x, a.y + vector.y, state.width), entity: aEntity, x: a.x + vector.x, y: a.y + vector.y };
|
|
106
|
+
const moveB = { from: bIndexCurrent, to: indexOf(b.x - vector.x, b.y - vector.y, state.width), entity: bEntity, x: b.x - vector.x, y: b.y - vector.y };
|
|
107
|
+
if (!state.activeCells[moveA.to] || !state.activeCells[moveB.to] || board[moveA.to] !== null || board[moveB.to] !== null || moveA.to === moveB.to)
|
|
108
|
+
break;
|
|
109
|
+
board[moveA.to] = moveA.entity;
|
|
110
|
+
board[moveA.from] = null;
|
|
111
|
+
board[moveB.to] = moveB.entity;
|
|
112
|
+
board[moveB.from] = null;
|
|
113
|
+
localMoves.push({ id: moveA.entity.id, from: a, to: { x: moveA.x, y: moveA.y } }, { id: moveB.entity.id, from: b, to: { x: moveB.x, y: moveB.y } });
|
|
114
|
+
}
|
|
115
|
+
const aIndex = findIndex(board, pair.a.id), bIndex = findIndex(board, pair.b.id);
|
|
116
|
+
const a = coordinates(aIndex, state.width), b = coordinates(bIndex, state.width);
|
|
117
|
+
return deepFreeze({ ids, axis: pair.axis, emptyGap: pair.gap, midpoint: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, ...(pair.a.anchored || pair.b.anchored ? { anchoredId: pair.a.anchored ? pair.a.id : pair.b.id } : {}), moves: localMoves });
|
|
118
|
+
}
|
|
119
|
+
/** Preview one bounded deterministic attraction pulse without mutating the state. */
|
|
120
|
+
export function previewMagneticPulse(state) {
|
|
121
|
+
validateState(state);
|
|
122
|
+
if (!state.rules.magneticEnabled)
|
|
123
|
+
return deepFreeze({ pairs: [], moves: [], cells: state.cells });
|
|
124
|
+
const board = [...state.cells];
|
|
125
|
+
const pairs = selectDisjoint(state).map(pair => movePair(board, state, pair));
|
|
126
|
+
const moves = pairs.flatMap(pair => pair.moves);
|
|
127
|
+
return deepFreeze({ pairs, moves, cells: board });
|
|
128
|
+
}
|
|
129
|
+
/** Apply one attraction pulse. Disabled nature rules preserve the exact state reference and consume no random state. */
|
|
130
|
+
export function applyMagneticPulse(state) {
|
|
131
|
+
const preview = previewMagneticPulse(state);
|
|
132
|
+
if (!state.rules.magneticEnabled || preview.moves.length === 0)
|
|
133
|
+
return { state, accepted: true, events: [], preview };
|
|
134
|
+
const next = freezeNatureState({ ...state, cells: preview.cells });
|
|
135
|
+
return { state: next, accepted: true, events: [deepFreeze({ type: 'magnetic-pulse', pairs: preview.pairs, moves: preview.moves })], preview };
|
|
136
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { NatureEntity, NatureMove, NatureState } from './types.js';
|
|
2
|
+
export type EarthquakeKind = 'fault' | 'jumble' | 'combined';
|
|
3
|
+
export type EnvironmentSchedule = {
|
|
4
|
+
readonly kind: 'frequent';
|
|
5
|
+
} | {
|
|
6
|
+
readonly kind: 'rare';
|
|
7
|
+
} | {
|
|
8
|
+
readonly kind: 'midlevel';
|
|
9
|
+
readonly turn: number;
|
|
10
|
+
} | {
|
|
11
|
+
readonly kind: 'authored';
|
|
12
|
+
readonly turns: readonly number[];
|
|
13
|
+
};
|
|
14
|
+
export interface EarthquakeOptions {
|
|
15
|
+
readonly kind: EarthquakeKind;
|
|
16
|
+
readonly enabled?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface LightningOptions {
|
|
19
|
+
readonly enabled?: boolean;
|
|
20
|
+
readonly columns?: readonly number[];
|
|
21
|
+
readonly columnCount?: 1 | 2 | 3;
|
|
22
|
+
}
|
|
23
|
+
export interface FaultPreview {
|
|
24
|
+
readonly path: readonly number[];
|
|
25
|
+
readonly fracturedCells: readonly number[];
|
|
26
|
+
readonly removedIds: readonly number[];
|
|
27
|
+
readonly cells: readonly (NatureEntity | null)[];
|
|
28
|
+
readonly activeCells: readonly boolean[];
|
|
29
|
+
readonly randomStateAfter: number;
|
|
30
|
+
}
|
|
31
|
+
export interface JumblePreview {
|
|
32
|
+
readonly patchCells: readonly number[];
|
|
33
|
+
readonly moves: readonly NatureMove[];
|
|
34
|
+
readonly changed: boolean;
|
|
35
|
+
readonly reason?: 'no-eligible-patch';
|
|
36
|
+
readonly cells: readonly (NatureEntity | null)[];
|
|
37
|
+
readonly randomStateAfter: number;
|
|
38
|
+
}
|
|
39
|
+
export interface EarthquakePreview {
|
|
40
|
+
readonly kind: EarthquakeKind;
|
|
41
|
+
readonly fault?: FaultPreview;
|
|
42
|
+
readonly jumble?: JumblePreview;
|
|
43
|
+
readonly cells: readonly (NatureEntity | null)[];
|
|
44
|
+
readonly activeCells: readonly boolean[];
|
|
45
|
+
readonly randomStateAfter: number;
|
|
46
|
+
}
|
|
47
|
+
export interface LightningPreview {
|
|
48
|
+
readonly columns: readonly number[];
|
|
49
|
+
readonly removedCells: readonly number[];
|
|
50
|
+
readonly removedIds: readonly number[];
|
|
51
|
+
readonly cells: readonly (NatureEntity | null)[];
|
|
52
|
+
readonly randomStateAfter: number;
|
|
53
|
+
}
|
|
54
|
+
export type EnvironmentPreview = EarthquakePreview | LightningPreview;
|
|
55
|
+
export type EnvironmentEvent = {
|
|
56
|
+
readonly type: 'fault-opened';
|
|
57
|
+
readonly path: readonly number[];
|
|
58
|
+
readonly fracturedCells: readonly number[];
|
|
59
|
+
readonly removedIds: readonly number[];
|
|
60
|
+
} | {
|
|
61
|
+
readonly type: 'jumble-completed';
|
|
62
|
+
readonly changed: boolean;
|
|
63
|
+
readonly reason?: 'no-eligible-patch';
|
|
64
|
+
readonly patchCells: readonly number[];
|
|
65
|
+
readonly moves: readonly NatureMove[];
|
|
66
|
+
} | {
|
|
67
|
+
readonly type: 'lightning-struck';
|
|
68
|
+
readonly columns: readonly number[];
|
|
69
|
+
readonly removedCells: readonly number[];
|
|
70
|
+
readonly removedIds: readonly number[];
|
|
71
|
+
};
|
|
72
|
+
export interface EnvironmentTransition {
|
|
73
|
+
readonly state: NatureState;
|
|
74
|
+
readonly accepted: true;
|
|
75
|
+
readonly events: readonly EnvironmentEvent[];
|
|
76
|
+
readonly preview: EnvironmentPreview;
|
|
77
|
+
}
|
|
78
|
+
/** Pure one-based schedule lookup: frequent is every second turn; rare is every eighth turn. */
|
|
79
|
+
export declare function isEnvironmentTurnScheduled(turn: number, schedule: EnvironmentSchedule): boolean;
|
|
80
|
+
/** Preview a bounded top-to-bottom fracture. The path keeps at least one column active on each side. */
|
|
81
|
+
export declare function previewFault(state: NatureState): FaultPreview;
|
|
82
|
+
/** Preview one seeded, nontrivial stone permutation inside a fully active connected rectangle no larger than 3×3. */
|
|
83
|
+
export declare function previewJumble(state: NatureState): JumblePreview;
|
|
84
|
+
/** Preview an earthquake, with Combined always fracturing before its one jumble attempt. */
|
|
85
|
+
export declare function previewEarthquake(state: NatureState, options: EarthquakeOptions): EarthquakePreview;
|
|
86
|
+
/** Commit one bounded environmental resolution; matching, gravity and score remain the host game's job. */
|
|
87
|
+
export declare function applyEarthquake(state: NatureState, options: EarthquakeOptions): EnvironmentTransition;
|
|
88
|
+
/** Preview seeded or explicitly selected columns; a mask gap or obstacle blocks the strike below it. */
|
|
89
|
+
export declare function previewLightning(state: NatureState, options?: LightningOptions): LightningPreview;
|
|
90
|
+
/** Commit one lightning strike. It removes exposed stones only and consumes no scores or tools. */
|
|
91
|
+
export declare function applyLightning(state: NatureState, options?: LightningOptions): EnvironmentTransition;
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { nextNatureUint32 } from './random.js';
|
|
2
|
+
import { freezeNatureState } from './state.js';
|
|
3
|
+
import { NatureOptionsError, validateState } from './validation.js';
|
|
4
|
+
const at = (x, y, width) => y * width + x;
|
|
5
|
+
function deepFreeze(value) {
|
|
6
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
7
|
+
Object.freeze(value);
|
|
8
|
+
for (const child of Object.values(value))
|
|
9
|
+
deepFreeze(child);
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
function drawBelow(state, bound) {
|
|
14
|
+
const [next, value] = nextNatureUint32(state);
|
|
15
|
+
return [next, value % bound];
|
|
16
|
+
}
|
|
17
|
+
/** Pure one-based schedule lookup: frequent is every second turn; rare is every eighth turn. */
|
|
18
|
+
export function isEnvironmentTurnScheduled(turn, schedule) {
|
|
19
|
+
if (!Number.isSafeInteger(turn) || turn < 1 || turn > 10_000_000 || !schedule || typeof schedule !== 'object' || Array.isArray(schedule))
|
|
20
|
+
throw new NatureOptionsError('invalid-nature-options', 'Schedule turn and policy must be valid');
|
|
21
|
+
if (schedule.kind === 'frequent' || schedule.kind === 'rare') {
|
|
22
|
+
noKeysExcept(schedule, ['kind'], 'Environment schedule');
|
|
23
|
+
return turn % (schedule.kind === 'frequent' ? 2 : 8) === 0;
|
|
24
|
+
}
|
|
25
|
+
if (schedule.kind === 'midlevel') {
|
|
26
|
+
noKeysExcept(schedule, ['kind', 'turn'], 'Environment schedule');
|
|
27
|
+
if (!Number.isSafeInteger(schedule.turn) || schedule.turn < 1 || schedule.turn > 10_000_000)
|
|
28
|
+
throw new NatureOptionsError('invalid-nature-options', 'Midlevel schedule turn is out of range');
|
|
29
|
+
return turn === schedule.turn;
|
|
30
|
+
}
|
|
31
|
+
if (schedule.kind === 'authored') {
|
|
32
|
+
noKeysExcept(schedule, ['kind', 'turns'], 'Environment schedule');
|
|
33
|
+
if (!Array.isArray(schedule.turns) || schedule.turns.length > 100_000 || schedule.turns.some((item, index) => !Number.isSafeInteger(item) || item < 1 || item > 10_000_000 || (index > 0 && item <= schedule.turns[index - 1])))
|
|
34
|
+
throw new NatureOptionsError('invalid-nature-options', 'Authored turns must be bounded, positive and strictly increasing');
|
|
35
|
+
let low = 0, high = schedule.turns.length - 1;
|
|
36
|
+
while (low <= high) {
|
|
37
|
+
const middle = (low + high) >> 1, candidate = schedule.turns[middle];
|
|
38
|
+
if (candidate === turn)
|
|
39
|
+
return true;
|
|
40
|
+
if (candidate < turn)
|
|
41
|
+
low = middle + 1;
|
|
42
|
+
else
|
|
43
|
+
high = middle - 1;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
throw new NatureOptionsError('invalid-nature-options', 'Unsupported environment schedule');
|
|
48
|
+
}
|
|
49
|
+
function noKeysExcept(value, allowed, label) {
|
|
50
|
+
if (Object.keys(value).some(key => !allowed.includes(key)))
|
|
51
|
+
throw new NatureOptionsError('invalid-nature-options', `${label} contains an unsupported field`);
|
|
52
|
+
}
|
|
53
|
+
function validateEarthquakeOptions(options) {
|
|
54
|
+
if (!options || typeof options !== 'object' || Array.isArray(options))
|
|
55
|
+
throw new NatureOptionsError('invalid-nature-options', 'Earthquake options must be an object');
|
|
56
|
+
noKeysExcept(options, ['kind', 'enabled'], 'Earthquake options');
|
|
57
|
+
if (!['fault', 'jumble', 'combined'].includes(options.kind) || options.enabled !== undefined && typeof options.enabled !== 'boolean')
|
|
58
|
+
throw new NatureOptionsError('invalid-nature-options', 'Earthquake options are malformed');
|
|
59
|
+
}
|
|
60
|
+
function validateLightningOptions(options, width) {
|
|
61
|
+
if (!options || typeof options !== 'object' || Array.isArray(options))
|
|
62
|
+
throw new NatureOptionsError('invalid-nature-options', 'Lightning options must be an object');
|
|
63
|
+
noKeysExcept(options, ['enabled', 'columns', 'columnCount'], 'Lightning options');
|
|
64
|
+
if (options.enabled !== undefined && typeof options.enabled !== 'boolean')
|
|
65
|
+
throw new NatureOptionsError('invalid-nature-options', 'Lightning enabled must be boolean');
|
|
66
|
+
if (options.columns !== undefined && options.columnCount !== undefined)
|
|
67
|
+
throw new NatureOptionsError('invalid-nature-options', 'Choose explicit lightning columns or a seeded column count');
|
|
68
|
+
if (options.columnCount !== undefined && ![1, 2, 3].includes(options.columnCount))
|
|
69
|
+
throw new NatureOptionsError('invalid-nature-options', 'Lightning column count must be one, two or three');
|
|
70
|
+
if (options.columns !== undefined && (!Array.isArray(options.columns) || options.columns.length < 1 || options.columns.length > 3 || options.columns.some(column => !Number.isSafeInteger(column) || column < 0 || column >= width) || new Set(options.columns).size !== options.columns.length))
|
|
71
|
+
throw new NatureOptionsError('invalid-nature-options', 'Lightning columns must be one to three unique in-range column indexes');
|
|
72
|
+
}
|
|
73
|
+
function faultPath(state) {
|
|
74
|
+
if (state.width < 3)
|
|
75
|
+
throw new NatureOptionsError('invalid-grid-size', 'A fault needs at least three columns to preserve playable space on both sides');
|
|
76
|
+
let randomState = state.randomState;
|
|
77
|
+
let draw;
|
|
78
|
+
[randomState, draw] = drawBelow(randomState, state.width - 2);
|
|
79
|
+
let x = draw + 1;
|
|
80
|
+
const path = [at(x, 0, state.width)];
|
|
81
|
+
for (let y = 1; y < state.height; y++) {
|
|
82
|
+
let step;
|
|
83
|
+
[randomState, step] = drawBelow(randomState, 3);
|
|
84
|
+
x = Math.max(1, Math.min(state.width - 2, x + step - 1));
|
|
85
|
+
path.push(at(x, y, state.width));
|
|
86
|
+
}
|
|
87
|
+
if (state.height > 1 && state.width > 3 && path.every((index, y) => y === 0 || index % state.width === path[y - 1] % state.width)) {
|
|
88
|
+
const row = Math.min(state.height - 1, Math.floor(state.height / 2));
|
|
89
|
+
const priorX = path[row - 1] % state.width;
|
|
90
|
+
const bentX = priorX === state.width - 2 ? priorX - 1 : priorX + 1;
|
|
91
|
+
path[row] = at(bentX, row, state.width);
|
|
92
|
+
}
|
|
93
|
+
return [path, randomState];
|
|
94
|
+
}
|
|
95
|
+
/** Preview a bounded top-to-bottom fracture. The path keeps at least one column active on each side. */
|
|
96
|
+
export function previewFault(state) {
|
|
97
|
+
validateState(state);
|
|
98
|
+
const [path, randomStateAfter] = faultPath(state);
|
|
99
|
+
const cells = [...state.cells], activeCells = [...state.activeCells];
|
|
100
|
+
const removedIds = [], fracturedCells = [];
|
|
101
|
+
for (const index of path) {
|
|
102
|
+
if (!activeCells[index])
|
|
103
|
+
continue;
|
|
104
|
+
activeCells[index] = false;
|
|
105
|
+
fracturedCells.push(index);
|
|
106
|
+
const entity = cells[index];
|
|
107
|
+
if (entity) {
|
|
108
|
+
removedIds.push(entity.id);
|
|
109
|
+
cells[index] = null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return deepFreeze({ path, fracturedCells, removedIds, cells, activeCells, randomStateAfter });
|
|
113
|
+
}
|
|
114
|
+
const appearance = (entity) => `${entity.colour}|${Boolean(entity.magnetic)}`;
|
|
115
|
+
function candidatePatches(state) {
|
|
116
|
+
const patches = [];
|
|
117
|
+
for (let height = 1; height <= Math.min(3, state.height); height++)
|
|
118
|
+
for (let width = 1; width <= Math.min(3, state.width); width++) {
|
|
119
|
+
if (width * height < 2)
|
|
120
|
+
continue;
|
|
121
|
+
for (let top = 0; top <= state.height - height; top++)
|
|
122
|
+
for (let left = 0; left <= state.width - width; left++) {
|
|
123
|
+
const cells = [], stones = [];
|
|
124
|
+
let allowed = true;
|
|
125
|
+
for (let dy = 0; dy < height && allowed; dy++)
|
|
126
|
+
for (let dx = 0; dx < width; dx++) {
|
|
127
|
+
const index = at(left + dx, top + dy, state.width);
|
|
128
|
+
const entity = state.cells[index];
|
|
129
|
+
if (!state.activeCells[index] || entity?.kind === 'obstacle' || entity?.kind === 'stone' && entity.anchored) {
|
|
130
|
+
allowed = false;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
cells.push(index);
|
|
134
|
+
if (entity?.kind === 'stone')
|
|
135
|
+
stones.push(index);
|
|
136
|
+
}
|
|
137
|
+
if (allowed && stones.length >= 2 && new Set(stones.map(index => appearance(state.cells[index]))).size > 1)
|
|
138
|
+
patches.push({ cells, stones });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return patches;
|
|
142
|
+
}
|
|
143
|
+
/** Preview one seeded, nontrivial stone permutation inside a fully active connected rectangle no larger than 3×3. */
|
|
144
|
+
export function previewJumble(state) {
|
|
145
|
+
validateState(state);
|
|
146
|
+
const candidates = candidatePatches(state);
|
|
147
|
+
if (candidates.length === 0)
|
|
148
|
+
return deepFreeze({ patchCells: [], moves: [], changed: false, reason: 'no-eligible-patch', cells: state.cells, randomStateAfter: state.randomState });
|
|
149
|
+
let randomState, selectedIndex;
|
|
150
|
+
[randomState, selectedIndex] = drawBelow(state.randomState, candidates.length);
|
|
151
|
+
const patch = candidates[selectedIndex];
|
|
152
|
+
const shuffled = [...patch.stones];
|
|
153
|
+
for (let index = shuffled.length - 1; index > 0; index--) {
|
|
154
|
+
let chosen;
|
|
155
|
+
[randomState, chosen] = drawBelow(randomState, index + 1);
|
|
156
|
+
[shuffled[index], shuffled[chosen]] = [shuffled[chosen], shuffled[index]];
|
|
157
|
+
}
|
|
158
|
+
if (shuffled.every((index, indexInOrder) => appearance(state.cells[index]) === appearance(state.cells[patch.stones[indexInOrder]]))) {
|
|
159
|
+
// A swap within identical colours would not change the puzzle.
|
|
160
|
+
const first = 0;
|
|
161
|
+
const second = shuffled.findIndex(index => appearance(state.cells[index]) !== appearance(state.cells[shuffled[first]]));
|
|
162
|
+
[shuffled[first], shuffled[second]] = [shuffled[second], shuffled[first]];
|
|
163
|
+
}
|
|
164
|
+
const cells = [...state.cells], moves = [];
|
|
165
|
+
for (let index = 0; index < patch.stones.length; index++) {
|
|
166
|
+
const from = patch.stones[index], to = shuffled[index], entity = state.cells[from];
|
|
167
|
+
cells[to] = entity;
|
|
168
|
+
if (from !== to)
|
|
169
|
+
moves.push({ id: entity.id, from: { x: from % state.width, y: Math.floor(from / state.width) }, to: { x: to % state.width, y: Math.floor(to / state.width) } });
|
|
170
|
+
}
|
|
171
|
+
for (const index of patch.stones)
|
|
172
|
+
if (!shuffled.includes(index))
|
|
173
|
+
cells[index] = null;
|
|
174
|
+
return deepFreeze({ patchCells: patch.cells, moves, changed: moves.length > 0, cells, randomStateAfter: randomState });
|
|
175
|
+
}
|
|
176
|
+
/** Preview an earthquake, with Combined always fracturing before its one jumble attempt. */
|
|
177
|
+
export function previewEarthquake(state, options) {
|
|
178
|
+
validateState(state);
|
|
179
|
+
validateEarthquakeOptions(options);
|
|
180
|
+
if (options.enabled === false)
|
|
181
|
+
return deepFreeze({ kind: options.kind, cells: state.cells, activeCells: state.activeCells, randomStateAfter: state.randomState });
|
|
182
|
+
let cells = state.cells, activeCells = state.activeCells, randomStateAfter = state.randomState;
|
|
183
|
+
let fault, jumble;
|
|
184
|
+
if (options.kind === 'fault' || options.kind === 'combined') {
|
|
185
|
+
fault = previewFault(state);
|
|
186
|
+
cells = fault.cells;
|
|
187
|
+
activeCells = fault.activeCells;
|
|
188
|
+
randomStateAfter = fault.randomStateAfter;
|
|
189
|
+
}
|
|
190
|
+
if (options.kind === 'jumble' || options.kind === 'combined') {
|
|
191
|
+
const jumbleState = fault ? freezeNatureState({ ...state, cells, activeCells, randomState: randomStateAfter }) : state;
|
|
192
|
+
jumble = previewJumble(jumbleState);
|
|
193
|
+
cells = jumble.cells;
|
|
194
|
+
randomStateAfter = jumble.randomStateAfter;
|
|
195
|
+
}
|
|
196
|
+
return deepFreeze({ kind: options.kind, ...(fault ? { fault } : {}), ...(jumble ? { jumble } : {}), cells, activeCells, randomStateAfter });
|
|
197
|
+
}
|
|
198
|
+
/** Commit one bounded environmental resolution; matching, gravity and score remain the host game's job. */
|
|
199
|
+
export function applyEarthquake(state, options) {
|
|
200
|
+
const preview = previewEarthquake(state, options);
|
|
201
|
+
if (options.enabled === false)
|
|
202
|
+
return { state, accepted: true, events: [], preview };
|
|
203
|
+
const events = [];
|
|
204
|
+
if (preview.fault)
|
|
205
|
+
events.push(deepFreeze({ type: 'fault-opened', path: preview.fault.path, fracturedCells: preview.fault.fracturedCells, removedIds: preview.fault.removedIds }));
|
|
206
|
+
if (preview.jumble)
|
|
207
|
+
events.push(deepFreeze({ type: 'jumble-completed', changed: preview.jumble.changed, ...(preview.jumble.reason ? { reason: preview.jumble.reason } : {}), patchCells: preview.jumble.patchCells, moves: preview.jumble.moves }));
|
|
208
|
+
if (events.length === 0)
|
|
209
|
+
return { state, accepted: true, events: [], preview };
|
|
210
|
+
if (preview.cells === state.cells && preview.activeCells === state.activeCells && preview.randomStateAfter === state.randomState)
|
|
211
|
+
return { state, accepted: true, events: deepFreeze(events), preview };
|
|
212
|
+
const next = freezeNatureState({ ...state, cells: preview.cells, activeCells: preview.activeCells, randomState: preview.randomStateAfter });
|
|
213
|
+
return { state: next, accepted: true, events: deepFreeze(events), preview };
|
|
214
|
+
}
|
|
215
|
+
function chooseColumns(state, options) {
|
|
216
|
+
if (options.columns !== undefined) {
|
|
217
|
+
return [[...options.columns].sort((a, b) => a - b), state.randomState];
|
|
218
|
+
}
|
|
219
|
+
let randomState = state.randomState, count;
|
|
220
|
+
if (options.columnCount !== undefined)
|
|
221
|
+
count = options.columnCount;
|
|
222
|
+
else {
|
|
223
|
+
let draw;
|
|
224
|
+
[randomState, draw] = drawBelow(randomState, 3);
|
|
225
|
+
count = draw + 1;
|
|
226
|
+
}
|
|
227
|
+
count = Math.min(count, state.width);
|
|
228
|
+
const pool = Array.from({ length: state.width }, (_, column) => column);
|
|
229
|
+
for (let index = 0; index < count; index++) {
|
|
230
|
+
let offset;
|
|
231
|
+
[randomState, offset] = drawBelow(randomState, state.width - index);
|
|
232
|
+
const swap = index + offset;
|
|
233
|
+
[pool[index], pool[swap]] = [pool[swap], pool[index]];
|
|
234
|
+
}
|
|
235
|
+
return [pool.slice(0, count).sort((a, b) => a - b), randomState];
|
|
236
|
+
}
|
|
237
|
+
/** Preview seeded or explicitly selected columns; a mask gap or obstacle blocks the strike below it. */
|
|
238
|
+
export function previewLightning(state, options = {}) {
|
|
239
|
+
validateState(state);
|
|
240
|
+
validateLightningOptions(options, state.width);
|
|
241
|
+
if (options.enabled === false)
|
|
242
|
+
return deepFreeze({ columns: [], removedCells: [], removedIds: [], cells: state.cells, randomStateAfter: state.randomState });
|
|
243
|
+
const [columns, randomStateAfter] = chooseColumns(state, options);
|
|
244
|
+
const cells = [...state.cells], removedCells = [], removedIds = [];
|
|
245
|
+
for (const column of columns) {
|
|
246
|
+
let hits = 0;
|
|
247
|
+
for (let y = 0; y < state.height && hits < 2; y++) {
|
|
248
|
+
const index = at(column, y, state.width);
|
|
249
|
+
if (!state.activeCells[index] || cells[index]?.kind === 'obstacle')
|
|
250
|
+
break;
|
|
251
|
+
const entity = cells[index];
|
|
252
|
+
if (entity?.kind === 'stone') {
|
|
253
|
+
cells[index] = null;
|
|
254
|
+
removedCells.push(index);
|
|
255
|
+
removedIds.push(entity.id);
|
|
256
|
+
hits++;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return deepFreeze({ columns, removedCells, removedIds, cells, randomStateAfter });
|
|
261
|
+
}
|
|
262
|
+
/** Commit one lightning strike. It removes exposed stones only and consumes no scores or tools. */
|
|
263
|
+
export function applyLightning(state, options = {}) {
|
|
264
|
+
const preview = previewLightning(state, options);
|
|
265
|
+
if (options.enabled === false)
|
|
266
|
+
return { state, accepted: true, events: [], preview };
|
|
267
|
+
const event = deepFreeze({ type: 'lightning-struck', columns: preview.columns, removedCells: preview.removedCells, removedIds: preview.removedIds });
|
|
268
|
+
if (preview.randomStateAfter === state.randomState && preview.removedIds.length === 0)
|
|
269
|
+
return { state, accepted: true, events: [event], preview };
|
|
270
|
+
const next = freezeNatureState({ ...state, cells: preview.cells, randomState: preview.randomStateAfter });
|
|
271
|
+
return { state: next, accepted: true, events: [event], preview };
|
|
272
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { MarkingOptions, NatureState, NatureTransition } from './types.js';
|
|
2
|
+
/** Deterministically assign magnetic markings to settled stones without changing their IDs or colours. */
|
|
3
|
+
export declare function markMagneticStones(state: NatureState, options: MarkingOptions): NatureTransition;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { validateMarkingOptions, validateState } from './validation.js';
|
|
2
|
+
import { freezeNatureState } from './state.js';
|
|
3
|
+
import { nextNatureUnit } from './random.js';
|
|
4
|
+
/** Deterministically assign magnetic markings to settled stones without changing their IDs or colours. */
|
|
5
|
+
export function markMagneticStones(state, options) {
|
|
6
|
+
validateState(state);
|
|
7
|
+
validateMarkingOptions(options);
|
|
8
|
+
if (!state.rules.magneticEnabled || options.maximum === 0 || options.probability === 0)
|
|
9
|
+
return { state, accepted: true, events: [] };
|
|
10
|
+
const maximum = options.maximum ?? state.cells.length;
|
|
11
|
+
const anchoredProbability = options.anchoredProbability ?? 0;
|
|
12
|
+
let randomState = state.randomState;
|
|
13
|
+
let marked = 0;
|
|
14
|
+
const ids = [];
|
|
15
|
+
const cells = state.cells.map(entity => {
|
|
16
|
+
if (!entity || entity.kind !== 'stone' || entity.magnetic || marked >= maximum)
|
|
17
|
+
return entity;
|
|
18
|
+
let sample;
|
|
19
|
+
[randomState, sample] = nextNatureUnit(randomState);
|
|
20
|
+
if (sample >= options.probability)
|
|
21
|
+
return entity;
|
|
22
|
+
let anchored = false;
|
|
23
|
+
if (anchoredProbability > 0) {
|
|
24
|
+
let anchorSample;
|
|
25
|
+
[randomState, anchorSample] = nextNatureUnit(randomState);
|
|
26
|
+
anchored = anchorSample < anchoredProbability;
|
|
27
|
+
}
|
|
28
|
+
marked++;
|
|
29
|
+
ids.push(entity.id);
|
|
30
|
+
return { ...entity, magnetic: true, ...(anchored ? { anchored: true } : {}) };
|
|
31
|
+
});
|
|
32
|
+
if (ids.length === 0) {
|
|
33
|
+
const unchanged = randomState === state.randomState ? state : freezeNatureState({ ...state, randomState });
|
|
34
|
+
return { state: unchanged, accepted: true, events: [] };
|
|
35
|
+
}
|
|
36
|
+
const next = freezeNatureState({ ...state, cells, randomState });
|
|
37
|
+
const event = Object.freeze({ type: 'magnetic-stones-marked', ids: Object.freeze(ids), randomState });
|
|
38
|
+
return { state: next, accepted: true, events: [event] };
|
|
39
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function seedNatureState(seed) {
|
|
2
|
+
let value = 0x811c_9dc5;
|
|
3
|
+
for (let index = 0; index < seed.length; index++) {
|
|
4
|
+
value ^= seed.charCodeAt(index);
|
|
5
|
+
value = Math.imul(value, 0x0100_0193) >>> 0;
|
|
6
|
+
}
|
|
7
|
+
return value || 0x6d2b_79f5;
|
|
8
|
+
}
|
|
9
|
+
export function nextNatureUint32(state) {
|
|
10
|
+
let next = state >>> 0;
|
|
11
|
+
next ^= next << 13;
|
|
12
|
+
next ^= next >>> 17;
|
|
13
|
+
next ^= next << 5;
|
|
14
|
+
return [next >>> 0, next >>> 0];
|
|
15
|
+
}
|
|
16
|
+
export function nextNatureUnit(state) {
|
|
17
|
+
const [next, value] = nextNatureUint32(state);
|
|
18
|
+
return [next, value / 0x1_0000_0000];
|
|
19
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { NatureState, NatureTransition, PowerDropPreview, PowerDropRequest } from './types.js';
|
|
2
|
+
/** Preview a single normal landing and, for a power drop, at most one validated one-cell rebound. */
|
|
3
|
+
export declare function previewPowerDrop(state: NatureState, request: PowerDropRequest): PowerDropPreview;
|
|
4
|
+
/** Commit a previewed landing as one immutable operation. A failed preview leaves the original state untouched. */
|
|
5
|
+
export declare function applyPowerDrop(state: NatureState, request: PowerDropRequest): NatureTransition;
|