@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.
Files changed (118) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/LICENSE +21 -0
  3. package/README.md +79 -2
  4. package/dist/colour-chains/challenge.d.ts +3 -0
  5. package/dist/colour-chains/challenge.js +120 -0
  6. package/dist/colour-chains/content-data.d.ts +2 -0
  7. package/dist/colour-chains/content-data.js +9534 -0
  8. package/dist/colour-chains/content-types.d.ts +80 -0
  9. package/dist/colour-chains/content-types.js +1 -0
  10. package/dist/colour-chains/content.d.ts +11 -0
  11. package/dist/colour-chains/content.js +28 -0
  12. package/dist/colour-chains/engine.d.ts +19 -0
  13. package/dist/colour-chains/engine.js +493 -0
  14. package/dist/colour-chains/input.d.ts +24 -0
  15. package/dist/colour-chains/input.js +77 -0
  16. package/dist/colour-chains/match.d.ts +5 -0
  17. package/dist/colour-chains/match.js +48 -0
  18. package/dist/colour-chains/persistence.d.ts +7 -0
  19. package/dist/colour-chains/persistence.js +96 -0
  20. package/dist/colour-chains/random.d.ts +7 -0
  21. package/dist/colour-chains/random.js +49 -0
  22. package/dist/colour-chains/types.d.ts +191 -0
  23. package/dist/colour-chains/types.js +1 -0
  24. package/dist/colour-chains/validation.d.ts +16 -0
  25. package/dist/colour-chains/validation.js +80 -0
  26. package/dist/colour-chains.d.ts +8 -0
  27. package/dist/colour-chains.js +7 -0
  28. package/dist/falling-triplets/content-data.d.ts +7258 -0
  29. package/dist/falling-triplets/content-data.js +18073 -0
  30. package/dist/falling-triplets/content-types.d.ts +72 -0
  31. package/dist/falling-triplets/content-types.js +1 -0
  32. package/dist/falling-triplets/content.d.ts +11 -0
  33. package/dist/falling-triplets/content.js +39 -0
  34. package/dist/falling-triplets/engine.d.ts +26 -0
  35. package/dist/falling-triplets/engine.js +541 -0
  36. package/dist/falling-triplets/input.d.ts +18 -0
  37. package/dist/falling-triplets/input.js +41 -0
  38. package/dist/falling-triplets/match.d.ts +9 -0
  39. package/dist/falling-triplets/match.js +45 -0
  40. package/dist/falling-triplets/random.d.ts +9 -0
  41. package/dist/falling-triplets/random.js +44 -0
  42. package/dist/falling-triplets/types.d.ts +134 -0
  43. package/dist/falling-triplets/types.js +1 -0
  44. package/dist/falling-triplets.d.ts +7 -0
  45. package/dist/falling-triplets.js +6 -0
  46. package/dist/gem-swap/black-hole.d.ts +7 -0
  47. package/dist/gem-swap/black-hole.js +54 -0
  48. package/dist/gem-swap/board.d.ts +25 -0
  49. package/dist/gem-swap/board.js +89 -0
  50. package/dist/gem-swap/content.d.ts +75 -0
  51. package/dist/gem-swap/content.js +68 -0
  52. package/dist/gem-swap/engine.d.ts +25 -0
  53. package/dist/gem-swap/engine.js +689 -0
  54. package/dist/gem-swap/persistence.d.ts +17 -0
  55. package/dist/gem-swap/persistence.js +107 -0
  56. package/dist/gem-swap/practice.d.ts +4 -0
  57. package/dist/gem-swap/practice.js +57 -0
  58. package/dist/gem-swap/random.d.ts +9 -0
  59. package/dist/gem-swap/random.js +32 -0
  60. package/dist/gem-swap/specials.d.ts +13 -0
  61. package/dist/gem-swap/specials.js +300 -0
  62. package/dist/gem-swap/tools.d.ts +3 -0
  63. package/dist/gem-swap/tools.js +20 -0
  64. package/dist/gem-swap/types.d.ts +200 -0
  65. package/dist/gem-swap/types.js +1 -0
  66. package/dist/gem-swap.d.ts +5 -0
  67. package/dist/gem-swap.js +3 -0
  68. package/dist/index.d.ts +7 -0
  69. package/dist/index.js +7 -0
  70. package/dist/magnetic-blocks/engine.d.ts +11 -0
  71. package/dist/magnetic-blocks/engine.js +263 -0
  72. package/dist/magnetic-blocks/persistence.d.ts +7 -0
  73. package/dist/magnetic-blocks/persistence.js +104 -0
  74. package/dist/magnetic-blocks/physics.d.ts +30 -0
  75. package/dist/magnetic-blocks/physics.js +155 -0
  76. package/dist/magnetic-blocks/random.d.ts +6 -0
  77. package/dist/magnetic-blocks/random.js +19 -0
  78. package/dist/magnetic-blocks/types.d.ts +158 -0
  79. package/dist/magnetic-blocks/types.js +1 -0
  80. package/dist/magnetic-blocks/validation.d.ts +23 -0
  81. package/dist/magnetic-blocks/validation.js +121 -0
  82. package/dist/magnetic-blocks.d.ts +5 -0
  83. package/dist/magnetic-blocks.js +4 -0
  84. package/dist/nature/attraction.d.ts +5 -0
  85. package/dist/nature/attraction.js +136 -0
  86. package/dist/nature/environment.d.ts +91 -0
  87. package/dist/nature/environment.js +272 -0
  88. package/dist/nature/marking.d.ts +3 -0
  89. package/dist/nature/marking.js +39 -0
  90. package/dist/nature/random.d.ts +3 -0
  91. package/dist/nature/random.js +19 -0
  92. package/dist/nature/rebound.d.ts +5 -0
  93. package/dist/nature/rebound.js +98 -0
  94. package/dist/nature/state.d.ts +3 -0
  95. package/dist/nature/state.js +22 -0
  96. package/dist/nature/types.d.ts +124 -0
  97. package/dist/nature/types.js +1 -0
  98. package/dist/nature/validation.d.ts +10 -0
  99. package/dist/nature/validation.js +117 -0
  100. package/dist/nature.d.ts +8 -0
  101. package/dist/nature.js +7 -0
  102. package/dist/stone-collapse/content-data.d.ts +14218 -0
  103. package/dist/stone-collapse/content-data.js +25217 -0
  104. package/dist/stone-collapse/content-types.d.ts +77 -0
  105. package/dist/stone-collapse/content-types.js +1 -0
  106. package/dist/stone-collapse/content.d.ts +11 -0
  107. package/dist/stone-collapse/content.js +28 -0
  108. package/dist/stone-collapse/engine.d.ts +19 -0
  109. package/dist/stone-collapse/engine.js +570 -0
  110. package/dist/stone-collapse/persistence.d.ts +7 -0
  111. package/dist/stone-collapse/persistence.js +230 -0
  112. package/dist/stone-collapse/types.d.ts +216 -0
  113. package/dist/stone-collapse/types.js +1 -0
  114. package/dist/stone-collapse/validation.d.ts +21 -0
  115. package/dist/stone-collapse/validation.js +135 -0
  116. package/dist/stone-collapse.d.ts +6 -0
  117. package/dist/stone-collapse.js +4 -0
  118. package/package.json +96 -4
@@ -0,0 +1,30 @@
1
+ import type { Block, Bond, Floor, Gem } from './types.js';
2
+ export declare const HIDDEN_ROWS = 0;
3
+ export declare const MARK_TICKS = 7, REMOVE_TICKS = 6, GRAVITY_TICKS = 9;
4
+ export declare const cell: (x: number, y: number, width: number) => number;
5
+ /** Returns stable-gem addresses after rotating the square block around its centre. */
6
+ export declare function blockCells(block: Block, width: number): readonly {
7
+ readonly index: number;
8
+ readonly gem: Gem;
9
+ }[];
10
+ export declare function canPlace(block: Block, board: readonly (Gem | null)[], width: number, height: number, mask: readonly boolean[]): boolean;
11
+ export declare function landingY(block: Block, board: readonly (Gem | null)[], width: number, height: number, mask: readonly boolean[]): number;
12
+ /** Computes deterministic orthogonal colour components of at least four gems. */
13
+ export declare function matchGroups(board: readonly (Gem | null)[], width: number, height: number, mask: readonly boolean[]): readonly (readonly number[])[];
14
+ export declare function allMatchedCells(groups: readonly (readonly number[])[]): readonly number[];
15
+ export declare function removeIds(board: readonly (Gem | null)[], bonds: readonly Bond[], indices: readonly number[]): {
16
+ readonly board: readonly (Gem | null)[];
17
+ readonly bonds: readonly Bond[];
18
+ readonly ids: readonly number[];
19
+ };
20
+ /** Settles bonded connected components one cell per deterministic bottom/ID priority step. */
21
+ export declare function calmGravity(initial: readonly (Gem | null)[], bonds: readonly Bond[], width: number, height: number, mask: readonly boolean[]): readonly (Gem | null)[];
22
+ /** Magnetic floor removes all bonds and settles each column segment independently. */
23
+ export declare function magneticGravity(initial: readonly (Gem | null)[], width: number, height: number, mask: readonly boolean[]): readonly (Gem | null)[];
24
+ export declare function settle(initial: readonly (Gem | null)[], bonds: readonly Bond[], floor: Floor, width: number, height: number, mask: readonly boolean[]): {
25
+ readonly board: readonly (Gem | null)[];
26
+ readonly bonds: readonly Bond[];
27
+ };
28
+ /** Collects the occupied one-cell support layer under both block columns at first contact. */
29
+ export declare function impactSupportIds(block: Block, board: readonly (Gem | null)[], width: number, height: number, mask: readonly boolean[]): readonly number[];
30
+ export declare function blockBonds(block: Block): readonly Bond[];
@@ -0,0 +1,155 @@
1
+ export const HIDDEN_ROWS = 0;
2
+ export const MARK_TICKS = 7, REMOVE_TICKS = 6, GRAVITY_TICKS = 9;
3
+ export const cell = (x, y, width) => y * width + x;
4
+ const BASE = [[0, 0], [1, 0], [1, 1], [0, 1]];
5
+ /** Returns stable-gem addresses after rotating the square block around its centre. */
6
+ export function blockCells(block, width) {
7
+ return block.gems.map((gem, corner) => {
8
+ let [x, y] = BASE[corner];
9
+ for (let turn = 0; turn < block.orientation; turn++)
10
+ [x, y] = [1 - y, x];
11
+ return { index: cell(block.x + x, block.y + y, width), gem };
12
+ });
13
+ }
14
+ export function canPlace(block, board, width, height, mask) {
15
+ if (block.x < 0 || block.x + 1 >= width || block.y < 0 || block.y + 1 >= height)
16
+ return false;
17
+ return blockCells(block, width).every(({ index }) => { const x = index % width, y = Math.floor(index / width); return x >= 0 && x < width && y >= 0 && y < height && mask[index] && !board[index]; });
18
+ }
19
+ export function landingY(block, board, width, height, mask) {
20
+ let y = block.y;
21
+ while (canPlace({ ...block, y: y + 1 }, board, width, height, mask))
22
+ y++;
23
+ return y;
24
+ }
25
+ /** Computes deterministic orthogonal colour components of at least four gems. */
26
+ export function matchGroups(board, width, height, mask) {
27
+ const seen = new Set(), matches = [];
28
+ for (let start = 0; start < board.length; start++) {
29
+ const gem = board[start];
30
+ if (!gem || seen.has(start))
31
+ continue;
32
+ const group = [start], pending = [start];
33
+ seen.add(start);
34
+ while (pending.length) {
35
+ const at = pending.pop(), x = at % width, y = Math.floor(at / width);
36
+ for (const next of [x ? at - 1 : -1, x + 1 < width ? at + 1 : -1, y ? at - width : -1, y + 1 < height ? at + width : -1])
37
+ if (next >= 0 && mask[next] && !seen.has(next) && board[next]?.colour === gem.colour) {
38
+ seen.add(next);
39
+ group.push(next);
40
+ pending.push(next);
41
+ }
42
+ }
43
+ if (group.length >= 4)
44
+ matches.push(group.sort((a, b) => a - b));
45
+ }
46
+ return matches;
47
+ }
48
+ export function allMatchedCells(groups) { return [...new Set(groups.flat())].sort((a, b) => a - b); }
49
+ export function removeIds(board, bonds, indices) {
50
+ const removed = new Set(indices), ids = indices.flatMap(index => board[index] ? [board[index].id] : []), remaining = board.map((gem, index) => removed.has(index) ? null : gem), alive = new Set(remaining.flatMap(gem => gem ? [gem.id] : []));
51
+ return { board: Object.freeze(remaining), bonds: Object.freeze(bonds.filter(edge => alive.has(edge.a) && alive.has(edge.b))), ids: Object.freeze(ids) };
52
+ }
53
+ function connectedComponents(board, bonds) {
54
+ const indices = new Map();
55
+ board.forEach((gem, index) => { if (gem)
56
+ indices.set(gem.id, index); });
57
+ const edges = new Map();
58
+ for (const id of indices.keys())
59
+ edges.set(id, []);
60
+ for (const edge of bonds)
61
+ if (edges.has(edge.a) && edges.has(edge.b)) {
62
+ edges.get(edge.a).push(edge.b);
63
+ edges.get(edge.b).push(edge.a);
64
+ }
65
+ const seen = new Set(), groups = [];
66
+ for (const id of indices.keys()) {
67
+ if (seen.has(id))
68
+ continue;
69
+ const pending = [id], cells = [];
70
+ seen.add(id);
71
+ while (pending.length) {
72
+ const next = pending.pop();
73
+ cells.push(indices.get(next));
74
+ for (const neighbour of edges.get(next))
75
+ if (!seen.has(neighbour)) {
76
+ seen.add(neighbour);
77
+ pending.push(neighbour);
78
+ }
79
+ }
80
+ groups.push(cells);
81
+ }
82
+ return groups;
83
+ }
84
+ /** Settles bonded connected components one cell per deterministic bottom/ID priority step. */
85
+ export function calmGravity(initial, bonds, width, height, mask) {
86
+ let board = [...initial], moved = true, guard = width * height * height;
87
+ while (moved && guard-- > 0) {
88
+ moved = false;
89
+ const groups = connectedComponents(board, bonds).map(cells => ({ cells, bottom: Math.max(...cells.map(index => Math.floor(index / width))), id: Math.min(...cells.map(index => board[index].id)) })).sort((a, b) => b.bottom - a.bottom || a.id - b.id);
90
+ for (const group of groups) {
91
+ const own = new Set(group.cells), destinations = group.cells.map(index => index + width);
92
+ const canMove = group.cells.every((index, i) => { const y = Math.floor(index / width), dest = destinations[i]; return y + 1 < height && mask[dest] && (!board[dest] || own.has(dest)); });
93
+ if (!canMove)
94
+ continue;
95
+ const next = [...board];
96
+ for (const index of group.cells)
97
+ next[index] = null;
98
+ for (const index of group.cells)
99
+ next[index + width] = board[index];
100
+ board = next;
101
+ moved = true;
102
+ break;
103
+ }
104
+ }
105
+ if (guard <= 0)
106
+ throw new RangeError('Calm gravity did not converge within its board-derived step bound');
107
+ return Object.freeze(board);
108
+ }
109
+ /** Magnetic floor removes all bonds and settles each column segment independently. */
110
+ export function magneticGravity(initial, width, height, mask) {
111
+ const board = [...initial];
112
+ for (let x = 0; x < width; x++) {
113
+ let y = 0;
114
+ while (y < height) {
115
+ while (y < height && !mask[cell(x, y, width)])
116
+ y++;
117
+ const start = y;
118
+ while (y < height && mask[cell(x, y, width)])
119
+ y++;
120
+ const end = y, survivors = [];
121
+ for (let row = start; row < end; row++) {
122
+ const gem = board[cell(x, row, width)];
123
+ if (gem)
124
+ survivors.push(gem);
125
+ }
126
+ for (let row = start; row < end; row++)
127
+ board[cell(x, row, width)] = null;
128
+ for (let i = 0; i < survivors.length; i++)
129
+ board[cell(x, end - survivors.length + i, width)] = survivors[i];
130
+ }
131
+ }
132
+ return Object.freeze(board);
133
+ }
134
+ export function settle(initial, bonds, floor, width, height, mask) {
135
+ return floor === 'magnetic' ? { board: magneticGravity(initial, width, height, mask), bonds: Object.freeze([]) } : { board: calmGravity(initial, bonds, width, height, mask), bonds };
136
+ }
137
+ /** Collects the occupied one-cell support layer under both block columns at first contact. */
138
+ export function impactSupportIds(block, board, width, height, mask) {
139
+ const byColumn = new Map();
140
+ for (const { index } of blockCells(block, width)) {
141
+ const x = index % width, y = Math.floor(index / width);
142
+ byColumn.set(x, Math.max(byColumn.get(x) ?? -1, y));
143
+ }
144
+ const ids = new Set();
145
+ for (const [x, y] of byColumn) {
146
+ const supportY = y + 1;
147
+ if (supportY >= 0 && supportY < height) {
148
+ const index = cell(x, supportY, width);
149
+ if (mask[index] && board[index])
150
+ ids.add(board[index].id);
151
+ }
152
+ }
153
+ return Object.freeze([...ids].sort((a, b) => a - b));
154
+ }
155
+ export function blockBonds(block) { return Object.freeze([[0, 1], [1, 2], [2, 3], [3, 0]].map(([a, b]) => { const ids = [block.gems[a].id, block.gems[b].id].sort((x, y) => x - y); return Object.freeze({ a: ids[0], b: ids[1] }); }).sort((a, b) => a.a - b.a || a.b - b.b)); }
@@ -0,0 +1,6 @@
1
+ /** Hashes a numeric or string seed into the engine's non-zero xorshift state. */
2
+ export declare function seedState(seed: string | number): number;
3
+ /** Advances the fixed 32-bit xorshift source. */
4
+ export declare function nextUint32(state: number): number;
5
+ /** Draws an unbiased integer in [0, bound). */
6
+ export declare function nextInt(state: number, bound: number): readonly [number, number];
@@ -0,0 +1,19 @@
1
+ /** Hashes a numeric or string seed into the engine's non-zero xorshift state. */
2
+ export function seedState(seed) {
3
+ let hash = 0x811c9dc5;
4
+ for (const byte of new TextEncoder().encode(String(seed)))
5
+ hash = Math.imul(hash ^ byte, 0x01000193) >>> 0;
6
+ return hash || 0x6d2b79f5;
7
+ }
8
+ /** Advances the fixed 32-bit xorshift source. */
9
+ export function nextUint32(state) { let value = state >>> 0; value ^= value << 13; value ^= value >>> 17; value ^= value << 5; return value >>> 0; }
10
+ /** Draws an unbiased integer in [0, bound). */
11
+ export function nextInt(state, bound) {
12
+ const limit = Math.floor(0x1_0000_0000 / bound) * bound;
13
+ let current = state >>> 0;
14
+ for (;;) {
15
+ current = nextUint32(current);
16
+ if (current < limit)
17
+ return [current % bound, current];
18
+ }
19
+ }
@@ -0,0 +1,158 @@
1
+ /** A standard gem colour used by a magnetic block. */
2
+ export type Colour = 'red' | 'blue' | 'green' | 'gold' | 'purple' | 'teal';
3
+ export type Floor = 'calm' | 'magnetic';
4
+ export type FloorSchedule = {
5
+ readonly kind: 'frequent';
6
+ } | {
7
+ readonly kind: 'occasional';
8
+ } | {
9
+ readonly kind: 'infrequent';
10
+ } | {
11
+ readonly kind: 'mid-level';
12
+ readonly placement: number;
13
+ } | {
14
+ readonly kind: 'fixed';
15
+ readonly floor: Floor;
16
+ } | {
17
+ readonly kind: 'authored';
18
+ readonly magneticPlacements: readonly number[];
19
+ readonly defaultFloor?: Floor;
20
+ };
21
+ export type Mode = 'relaxed' | 'arcade';
22
+ export type Phase = 'falling' | 'clear-mark' | 'clear-remove' | 'gravity' | 'paused' | 'won' | 'lost' | 'finished';
23
+ export interface Gem {
24
+ readonly id: number;
25
+ readonly colour: Colour;
26
+ }
27
+ export interface Bond {
28
+ readonly a: number;
29
+ readonly b: number;
30
+ }
31
+ export interface Block {
32
+ readonly x: number;
33
+ readonly y: number;
34
+ readonly orientation: 0 | 1 | 2 | 3;
35
+ readonly gems: readonly [Gem, Gem, Gem, Gem];
36
+ readonly descent: number;
37
+ readonly lockTicks: number;
38
+ readonly lockResets: number;
39
+ readonly lockStarted: boolean;
40
+ }
41
+ export type Goal = {
42
+ readonly kind: 'clear-all';
43
+ } | {
44
+ readonly kind: 'clear-targets';
45
+ readonly targetIds: readonly number[];
46
+ };
47
+ export interface CreateOptions {
48
+ readonly mode?: Mode;
49
+ readonly width?: number;
50
+ readonly height?: number;
51
+ readonly colourCount?: 4 | 5 | 6;
52
+ readonly seed?: string | number;
53
+ readonly mask?: readonly boolean[];
54
+ readonly initialBoard?: readonly (Gem | null)[];
55
+ readonly bonds?: readonly Bond[];
56
+ readonly queue?: readonly (readonly [Colour, Colour, Colour, Colour])[];
57
+ readonly pieceLimit?: number;
58
+ readonly schedule?: FloorSchedule;
59
+ readonly floorSwitch?: boolean;
60
+ readonly magneticImpact?: boolean;
61
+ readonly goal?: Goal;
62
+ }
63
+ export interface Settings {
64
+ readonly mode: Mode;
65
+ readonly width: number;
66
+ readonly height: number;
67
+ readonly colourCount: 4 | 5 | 6;
68
+ readonly seed: string | number;
69
+ readonly mask: readonly boolean[];
70
+ readonly schedule: FloorSchedule;
71
+ readonly floorSwitch: boolean;
72
+ readonly magneticImpact: boolean;
73
+ readonly pieceLimit: number;
74
+ readonly initialBoard: readonly (Gem | null)[];
75
+ readonly bonds: readonly Bond[];
76
+ readonly initialQueue?: readonly (readonly [Colour, Colour, Colour, Colour])[];
77
+ readonly goal?: Goal;
78
+ }
79
+ export interface GameState {
80
+ readonly game: 'magnetic-blocks';
81
+ readonly rules: 'magnetic-blocks-1';
82
+ readonly settings: Settings;
83
+ readonly board: readonly (Gem | null)[];
84
+ readonly bonds: readonly Bond[];
85
+ readonly active: Block | null;
86
+ readonly queue: readonly (readonly [Colour, Colour, Colour, Colour])[];
87
+ readonly randomState: number;
88
+ readonly nextId: number;
89
+ readonly phase: Phase;
90
+ readonly pausedPhase?: Exclude<Phase, 'paused'>;
91
+ readonly score: number;
92
+ readonly moves: number;
93
+ readonly placements: number;
94
+ readonly chain: number;
95
+ readonly maxChain: number;
96
+ readonly floor: Floor;
97
+ readonly nextFloor: Floor;
98
+ readonly floorSwitchCharges: 0 | 1;
99
+ readonly pendingFloorOverride: Floor | null;
100
+ readonly pendingClear: readonly number[];
101
+ readonly gravityBoard: readonly (Gem | null)[] | null;
102
+ readonly resolutionTick: number;
103
+ readonly impactRemovedIds: readonly number[];
104
+ readonly elapsedTicks: number;
105
+ readonly recording: readonly RecordedAction[];
106
+ readonly reason?: string;
107
+ }
108
+ export type Action = {
109
+ readonly kind: 'left';
110
+ } | {
111
+ readonly kind: 'right';
112
+ } | {
113
+ readonly kind: 'soft-drop';
114
+ } | {
115
+ readonly kind: 'rotate-clockwise';
116
+ } | {
117
+ readonly kind: 'rotate-anticlockwise';
118
+ } | {
119
+ readonly kind: 'hard-drop';
120
+ } | {
121
+ readonly kind: 'land';
122
+ } | {
123
+ readonly kind: 'pause';
124
+ } | {
125
+ readonly kind: 'resume';
126
+ } | {
127
+ readonly kind: 'set-floor-override';
128
+ readonly floor: Floor;
129
+ } | {
130
+ readonly kind: 'cancel-floor-override';
131
+ };
132
+ export interface RecordedAction {
133
+ readonly tick: number;
134
+ readonly ordinal: number;
135
+ readonly action: Action;
136
+ }
137
+ export interface GameEvent {
138
+ readonly type: string;
139
+ readonly [key: string]: unknown;
140
+ }
141
+ export interface Transition {
142
+ readonly state: GameState;
143
+ readonly events: readonly GameEvent[];
144
+ readonly accepted: boolean;
145
+ readonly reason?: string;
146
+ }
147
+ export interface GameStatus {
148
+ readonly phase: Phase;
149
+ readonly score: number;
150
+ readonly moves: number;
151
+ readonly placements: number;
152
+ readonly floor: Floor;
153
+ readonly nextFloor: Floor;
154
+ readonly floorSwitchCharges: number;
155
+ readonly pendingFloorOverride: Floor | null;
156
+ readonly maxChain: number;
157
+ readonly reason?: string;
158
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import type { Bond, Colour, CreateOptions, FloorSchedule, Gem, Goal, Mode } from './types.js';
2
+ export declare class MagneticBlocksOptionsError extends RangeError {
3
+ readonly code: string;
4
+ constructor(code: string, message: string);
5
+ }
6
+ export declare const COLOURS: readonly Colour[];
7
+ export interface ValidatedOptions {
8
+ readonly mode: Mode;
9
+ readonly width: number;
10
+ readonly height: number;
11
+ readonly colourCount: 4 | 5 | 6;
12
+ readonly seed: string | number;
13
+ readonly mask: readonly boolean[];
14
+ readonly initialBoard: readonly (Gem | null)[];
15
+ readonly bonds: readonly Bond[];
16
+ readonly queue?: readonly (readonly [Colour, Colour, Colour, Colour])[];
17
+ readonly pieceLimit: number;
18
+ readonly schedule: FloorSchedule;
19
+ readonly floorSwitch: boolean;
20
+ readonly magneticImpact: boolean;
21
+ readonly goal?: Goal;
22
+ }
23
+ export declare function validateOptions(options?: CreateOptions): ValidatedOptions;
@@ -0,0 +1,121 @@
1
+ import { calmGravity } from './physics.js';
2
+ export class MagneticBlocksOptionsError extends RangeError {
3
+ code;
4
+ constructor(code, message) { super(message); this.name = 'MagneticBlocksOptionsError'; this.code = code; }
5
+ }
6
+ export const COLOURS = ['red', 'blue', 'green', 'gold', 'purple', 'teal'];
7
+ const floor = (value) => value === 'calm' || value === 'magnetic';
8
+ function validateSchedule(value) {
9
+ const schedule = value ?? { kind: 'occasional' };
10
+ if (!schedule || typeof schedule !== 'object' || Array.isArray(schedule))
11
+ throw new MagneticBlocksOptionsError('invalid-schedule', 'Floor schedule must be an object');
12
+ if (schedule.kind === 'frequent' || schedule.kind === 'occasional' || schedule.kind === 'infrequent') {
13
+ if (Object.keys(schedule).length !== 1)
14
+ throw new MagneticBlocksOptionsError('invalid-schedule', 'Floor schedule contains unsupported fields');
15
+ }
16
+ else if (schedule.kind === 'mid-level') {
17
+ if (Object.keys(schedule).some(key => key !== 'kind' && key !== 'placement') || !Number.isInteger(schedule.placement) || schedule.placement < 1 || schedule.placement > 1000)
18
+ throw new MagneticBlocksOptionsError('invalid-schedule', 'Mid-level pulse requires a placement number from 1 to 1000');
19
+ }
20
+ else if (schedule.kind === 'fixed') {
21
+ if (Object.keys(schedule).some(key => key !== 'kind' && key !== 'floor') || !floor(schedule.floor))
22
+ throw new MagneticBlocksOptionsError('invalid-schedule', 'Fixed floor must be calm or magnetic');
23
+ }
24
+ else if (schedule.kind === 'authored') {
25
+ if (Object.keys(schedule).some(key => !['kind', 'magneticPlacements', 'defaultFloor'].includes(key)) || !Array.isArray(schedule.magneticPlacements) || schedule.magneticPlacements.length > 1000 || schedule.magneticPlacements.some((n, i) => !Number.isInteger(n) || n < 1 || n > 1000 || i > 0 && n <= schedule.magneticPlacements[i - 1]) || schedule.defaultFloor !== undefined && !floor(schedule.defaultFloor))
26
+ throw new MagneticBlocksOptionsError('invalid-schedule', 'Authored magnetic placements must be sorted unique positive placement numbers');
27
+ }
28
+ else
29
+ throw new MagneticBlocksOptionsError('invalid-schedule', 'Unknown floor schedule');
30
+ return Object.freeze({ ...schedule, ...('magneticPlacements' in schedule ? { magneticPlacements: Object.freeze([...schedule.magneticPlacements]) } : {}) });
31
+ }
32
+ function validateGoal(value, ids) {
33
+ if (value === undefined)
34
+ return undefined;
35
+ if (!value || typeof value !== 'object' || Array.isArray(value))
36
+ throw new MagneticBlocksOptionsError('invalid-goal', 'Goal must be an object');
37
+ if (value.kind === 'clear-all') {
38
+ if (Object.keys(value).length !== 1)
39
+ throw new MagneticBlocksOptionsError('invalid-goal', 'Clear-all goal has unsupported fields');
40
+ return Object.freeze({ kind: 'clear-all' });
41
+ }
42
+ if (value.kind === 'clear-targets' && Object.keys(value).every(key => key === 'kind' || key === 'targetIds') && Array.isArray(value.targetIds) && value.targetIds.length > 0 && value.targetIds.every(id => Number.isSafeInteger(id) && ids.has(id)) && new Set(value.targetIds).size === value.targetIds.length)
43
+ return Object.freeze({ kind: 'clear-targets', targetIds: Object.freeze([...value.targetIds].sort((a, b) => a - b)) });
44
+ throw new MagneticBlocksOptionsError('invalid-goal', 'Goal must clear all gems or name unique initial gem IDs');
45
+ }
46
+ export function validateOptions(options = {}) {
47
+ if (!options || typeof options !== 'object' || Array.isArray(options))
48
+ throw new MagneticBlocksOptionsError('invalid-options', 'Options must be an object');
49
+ const allowed = ['mode', 'width', 'height', 'colourCount', 'seed', 'mask', 'initialBoard', 'bonds', 'queue', 'pieceLimit', 'schedule', 'floorSwitch', 'magneticImpact', 'goal'];
50
+ if (Object.keys(options).some(key => !allowed.includes(key)))
51
+ throw new MagneticBlocksOptionsError('unknown-option', 'Create options contain an unsupported field');
52
+ const mode = options.mode ?? 'relaxed';
53
+ if (mode !== 'relaxed' && mode !== 'arcade')
54
+ throw new MagneticBlocksOptionsError('invalid-mode', 'Mode must be relaxed or arcade');
55
+ const width = options.width ?? 10, height = options.height ?? 20;
56
+ if (!Number.isInteger(width) || width < 4 || width > 16 || !Number.isInteger(height) || height < 4 || height > 32 || width * height > 512)
57
+ throw new MagneticBlocksOptionsError('invalid-board-size', 'Board width must be 4–16, height 4–32, with at most 512 cells');
58
+ const colourCount = options.colourCount ?? 4;
59
+ if (![4, 5, 6].includes(colourCount))
60
+ throw new MagneticBlocksOptionsError('invalid-colour-count', 'Colour count must be 4, 5, or 6');
61
+ const seed = options.seed ?? 'houseki-magnetic-blocks';
62
+ if (typeof seed !== 'string' && (!Number.isInteger(seed) || seed < 0 || seed > 0xffff_ffff))
63
+ throw new MagneticBlocksOptionsError('invalid-seed', 'Numeric seed must be an unsigned 32-bit integer');
64
+ const mask = options.mask === undefined ? Array(width * height).fill(true) : options.mask;
65
+ if (!Array.isArray(mask) || mask.length !== width * height || mask.some(value => typeof value !== 'boolean') || mask.filter(Boolean).length < 16 || mask.filter(Boolean).length > 512)
66
+ throw new MagneticBlocksOptionsError('invalid-mask', 'Mask must contain 16–512 active cells');
67
+ const active = mask.flatMap((on, i) => on ? [i] : []), connected = new Set([active[0]]), pending = [active[0]];
68
+ while (pending.length) {
69
+ const at = pending.pop(), x = at % width, y = Math.floor(at / width);
70
+ for (const next of [x ? at - 1 : -1, x + 1 < width ? at + 1 : -1, y ? at - width : -1, y + 1 < height ? at + width : -1])
71
+ if (next >= 0 && mask[next] && !connected.has(next)) {
72
+ connected.add(next);
73
+ pending.push(next);
74
+ }
75
+ }
76
+ if (connected.size !== active.length)
77
+ throw new MagneticBlocksOptionsError('disconnected-mask', 'Mask active cells must be connected');
78
+ if (active.some(at => { const x = at % width, y = Math.floor(at / width); return ![x ? at - 1 : -1, x + 1 < width ? at + 1 : -1, y ? at - width : -1, y + 1 < height ? at + width : -1].some(next => next >= 0 && mask[next]); }))
79
+ throw new MagneticBlocksOptionsError('isolated-mask-cell', 'Every active mask cell must have an orthogonal neighbour');
80
+ const initialBoard = options.initialBoard ?? Array(width * height).fill(null);
81
+ if (!Array.isArray(initialBoard) || initialBoard.length !== width * height)
82
+ throw new MagneticBlocksOptionsError('invalid-board', 'Initial board must contain one entry per cell');
83
+ const ids = new Set();
84
+ const board = initialBoard.map((gem, at) => {
85
+ if (!mask[at]) {
86
+ if (gem !== null)
87
+ throw new MagneticBlocksOptionsError('gem-in-masked-cell', 'Masked cells must be empty');
88
+ return null;
89
+ }
90
+ if (gem === null)
91
+ return null;
92
+ if (!gem || typeof gem !== 'object' || Array.isArray(gem) || Object.keys(gem).some(key => key !== 'id' && key !== 'colour') || !Number.isSafeInteger(gem.id) || gem.id < 1 || ids.has(gem.id) || !COLOURS.slice(0, colourCount).includes(gem.colour))
93
+ throw new MagneticBlocksOptionsError('invalid-gem', 'Gems need unique positive IDs and a configured colour');
94
+ ids.add(gem.id);
95
+ return Object.freeze({ id: gem.id, colour: gem.colour });
96
+ });
97
+ const bonds = options.bonds ?? [];
98
+ if (!Array.isArray(bonds) || bonds.some(edge => !edge || typeof edge !== 'object' || Object.keys(edge).some(key => key !== 'a' && key !== 'b') || !Number.isSafeInteger(edge.a) || !Number.isSafeInteger(edge.b) || edge.a === edge.b || !ids.has(edge.a) || !ids.has(edge.b)))
99
+ throw new MagneticBlocksOptionsError('invalid-bonds', 'Bond edges must connect two unique occupied gem IDs');
100
+ const bondKeys = bonds.map(edge => `${Math.min(edge.a, edge.b)}:${Math.max(edge.a, edge.b)}`);
101
+ if (new Set(bondKeys).size !== bondKeys.length)
102
+ throw new MagneticBlocksOptionsError('duplicate-bond', 'Bond edges must be unique');
103
+ const idCells = new Map();
104
+ board.forEach((gem, index) => { if (gem)
105
+ idCells.set(gem.id, index); });
106
+ if (bonds.some(edge => { const a = idCells.get(edge.a), b = idCells.get(edge.b); return Math.abs(a % width - b % width) + Math.abs(Math.floor(a / width) - Math.floor(b / width)) !== 1; }))
107
+ throw new MagneticBlocksOptionsError('nonadjacent-bond', 'Bond edges must connect orthogonally adjacent gems');
108
+ const stableBonds = bonds.map(edge => ({ a: Math.min(edge.a, edge.b), b: Math.max(edge.a, edge.b) }));
109
+ const settledInitial = calmGravity(board, stableBonds, width, height, mask);
110
+ if (settledInitial.some((gem, index) => gem?.id !== board[index]?.id))
111
+ throw new MagneticBlocksOptionsError('unstable-initial-board', 'Initial gems must be settled under calm gravity and their declared bonds');
112
+ const queue = options.queue;
113
+ if (queue !== undefined && (!Array.isArray(queue) || queue.length < 1 || queue.length > 1000 || queue.some(piece => !Array.isArray(piece) || piece.length !== 4 || piece.some(colour => !COLOURS.slice(0, colourCount).includes(colour)))))
114
+ throw new MagneticBlocksOptionsError('invalid-queue', 'Queue must contain 1–1000 four-colour blocks');
115
+ const pieceLimit = options.pieceLimit ?? queue?.length ?? 200;
116
+ if (!Number.isInteger(pieceLimit) || pieceLimit < 1 || pieceLimit > 1000 || queue && queue.length < pieceLimit)
117
+ throw new MagneticBlocksOptionsError('invalid-piece-limit', 'Piece limit must fit the fixed queue and be 1–1000');
118
+ if (options.floorSwitch !== undefined && typeof options.floorSwitch !== 'boolean' || options.magneticImpact !== undefined && typeof options.magneticImpact !== 'boolean')
119
+ throw new MagneticBlocksOptionsError('invalid-option-flag', 'Floor Switch and Magnetic Impact settings must be booleans');
120
+ return { mode, width, height, colourCount: colourCount, seed, mask: Object.freeze([...mask]), initialBoard: Object.freeze(board), bonds: Object.freeze(stableBonds.map(edge => Object.freeze(edge)).sort((a, b) => a.a - b.a || a.b - b.b)), ...(queue ? { queue: Object.freeze(queue.map(piece => Object.freeze([...piece]))) } : {}), pieceLimit, schedule: validateSchedule(options.schedule), floorSwitch: options.floorSwitch ?? false, magneticImpact: options.magneticImpact ?? false, ...(options.goal === undefined ? {} : { goal: validateGoal(options.goal, ids) }) };
121
+ }
@@ -0,0 +1,5 @@
1
+ export { MagneticBlocksOptionsError } from './magnetic-blocks/validation.js';
2
+ export { createGame, applyAction, advanceTicks, legalActions, statusOf } from './magnetic-blocks/engine.js';
3
+ export { encodeGame, decodeGame, restartGame } from './magnetic-blocks/persistence.js';
4
+ export { blockCells, canPlace, landingY, matchGroups, removeIds, calmGravity, magneticGravity, settle, impactSupportIds, blockBonds, MARK_TICKS, REMOVE_TICKS, GRAVITY_TICKS } from './magnetic-blocks/physics.js';
5
+ export type { Action, Block, Bond, Colour, CreateOptions, Floor, FloorSchedule, GameEvent, GameState, GameStatus, Gem, Goal, Mode, Phase, RecordedAction, Settings, Transition } from './magnetic-blocks/types.js';
@@ -0,0 +1,4 @@
1
+ export { MagneticBlocksOptionsError } from './magnetic-blocks/validation.js';
2
+ export { createGame, applyAction, advanceTicks, legalActions, statusOf } from './magnetic-blocks/engine.js';
3
+ export { encodeGame, decodeGame, restartGame } from './magnetic-blocks/persistence.js';
4
+ export { blockCells, canPlace, landingY, matchGroups, removeIds, calmGravity, magneticGravity, settle, impactSupportIds, blockBonds, MARK_TICKS, REMOVE_TICKS, GRAVITY_TICKS } from './magnetic-blocks/physics.js';
@@ -0,0 +1,5 @@
1
+ import type { MagneticPreview, NatureState, NatureTransition } from './types.js';
2
+ /** Preview one bounded deterministic attraction pulse without mutating the state. */
3
+ export declare function previewMagneticPulse(state: NatureState): MagneticPreview;
4
+ /** Apply one attraction pulse. Disabled nature rules preserve the exact state reference and consume no random state. */
5
+ export declare function applyMagneticPulse(state: NatureState): NatureTransition;