@johnmorrisdotca/houseki 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +272 -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 +78 -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 +42 -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 +271 -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 +134 -0
  116. package/dist/stone-collapse.d.ts +6 -0
  117. package/dist/stone-collapse.js +4 -0
  118. package/package.json +118 -4
@@ -0,0 +1,98 @@
1
+ import { nextNatureUint32 } from './random.js';
2
+ import { freezeNatureState } from './state.js';
3
+ import { NatureOptionsError, validatePowerDropRequest, validateState } from './validation.js';
4
+ function at(state, x, y) { return y * state.width + x; }
5
+ function freeze(value) {
6
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
7
+ Object.freeze(value);
8
+ for (const child of Object.values(value))
9
+ freeze(child);
10
+ }
11
+ return value;
12
+ }
13
+ function fits(state, piece) {
14
+ const own = new Set(piece.map(cell => `${cell.x},${cell.y}`));
15
+ if (own.size !== piece.length)
16
+ return false;
17
+ return piece.every(cell => cell.x >= 0 && cell.x < state.width && cell.y >= 0 && cell.y < state.height && state.activeCells[at(state, cell.x, cell.y)] && state.cells[at(state, cell.x, cell.y)] === null);
18
+ }
19
+ function translate(piece, dx, dy) {
20
+ return piece.map(cell => ({ ...cell, x: cell.x + dx, y: cell.y + dy }));
21
+ }
22
+ function turnAroundPivot(piece, pivotId, quarterTurn) {
23
+ if (quarterTurn === 0)
24
+ return piece.map(cell => ({ ...cell }));
25
+ const pivot = piece.find(cell => cell.id === pivotId);
26
+ return piece.map(cell => {
27
+ const dx = cell.x - pivot.x, dy = cell.y - pivot.y;
28
+ return { ...cell, x: pivot.x + (quarterTurn === 1 ? -dy : dy), y: pivot.y + (quarterTurn === 1 ? dx : -dx) };
29
+ });
30
+ }
31
+ function movesBetween(before, after) {
32
+ const previous = new Map(before.map(cell => [cell.id, cell]));
33
+ return after.flatMap(cell => {
34
+ const from = previous.get(cell.id);
35
+ return from.x === cell.x && from.y === cell.y ? [] : [{ id: cell.id, from: { x: from.x, y: from.y }, to: { x: cell.x, y: cell.y } }];
36
+ });
37
+ }
38
+ function preferredDirection(state, request) {
39
+ if (state.rules.reboundDirectionMode === 'seeded') {
40
+ const [randomState, value] = nextNatureUint32(state.randomState);
41
+ return [value % 2 === 0 ? 'left' : 'right', randomState];
42
+ }
43
+ if (request.lastHorizontalDirection)
44
+ return [request.lastHorizontalDirection, state.randomState];
45
+ return [state.committedPlacements % 2 === 0 ? 'left' : 'right', state.randomState];
46
+ }
47
+ /** Preview a single normal landing and, for a power drop, at most one validated one-cell rebound. */
48
+ export function previewPowerDrop(state, request) {
49
+ validateState(state);
50
+ validatePowerDropRequest(state, request);
51
+ let normal = request.piece.map(cell => ({ ...cell }));
52
+ while (true) {
53
+ const lower = translate(normal, 0, 1);
54
+ if (!fits(state, lower))
55
+ break;
56
+ normal = lower;
57
+ }
58
+ const eligible = request.power && state.rules.reboundEnabled;
59
+ let randomStateAfter = state.randomState;
60
+ const attempts = [];
61
+ let finalCells = normal;
62
+ let selectedDirection;
63
+ let selectedTurn = 0;
64
+ if (eligible) {
65
+ const [first, nextState] = preferredDirection(state, request);
66
+ randomStateAfter = nextState;
67
+ const directions = [first, first === 'left' ? 'right' : 'left'];
68
+ const turn = request.quarterTurn ?? 0;
69
+ const rotated = turnAroundPivot(normal, request.pivotId, turn);
70
+ for (const direction of directions) {
71
+ const candidate = translate(rotated, direction === 'left' ? -1 : 1, 0);
72
+ const valid = fits(state, candidate);
73
+ attempts.push({ direction, quarterTurn: turn, valid });
74
+ if (valid) {
75
+ finalCells = candidate;
76
+ selectedDirection = direction;
77
+ selectedTurn = turn;
78
+ break;
79
+ }
80
+ }
81
+ }
82
+ return freeze({ normalLanding: normal, attempts, rebound: selectedDirection !== undefined, ...(selectedDirection ? { selectedDirection } : {}), quarterTurn: selectedTurn, finalCells, moves: movesBetween(request.piece, finalCells), randomStateAfter });
83
+ }
84
+ /** Commit a previewed landing as one immutable operation. A failed preview leaves the original state untouched. */
85
+ export function applyPowerDrop(state, request) {
86
+ const preview = previewPowerDrop(state, request);
87
+ const cells = [...state.cells];
88
+ for (const pieceCell of preview.finalCells) {
89
+ const index = at(state, pieceCell.x, pieceCell.y);
90
+ if (cells[index] !== null)
91
+ throw new NatureOptionsError('piece-collision', 'Power-drop destination is occupied');
92
+ cells[index] = { id: pieceCell.id, kind: 'stone', colour: pieceCell.colour, ...(pieceCell.magnetic ? { magnetic: true } : {}), ...(pieceCell.anchored ? { anchored: true } : {}) };
93
+ }
94
+ const maxId = preview.finalCells.reduce((maximum, cell) => Math.max(maximum, cell.id), 0);
95
+ const next = freezeNatureState({ ...state, cells, randomState: preview.randomStateAfter, nextId: Math.max(state.nextId, maxId + 1), committedPlacements: state.committedPlacements + 1 });
96
+ const event = freeze({ type: 'power-drop-landed', power: request.power, rebound: preview.rebound, ...(preview.selectedDirection ? { direction: preview.selectedDirection } : {}), quarterTurn: preview.quarterTurn, moves: preview.moves, ids: preview.finalCells.map(cell => cell.id) });
97
+ return { state: next, accepted: true, events: [event], preview };
98
+ }
@@ -0,0 +1,3 @@
1
+ import type { NatureOptions, NatureState } from './types.js';
2
+ export declare function freezeNatureState(state: NatureState): NatureState;
3
+ export declare function createNatureState(options: NatureOptions): NatureState;
@@ -0,0 +1,22 @@
1
+ import { NatureOptionsError, validateNatureOptions } from './validation.js';
2
+ import { seedNatureState } from './random.js';
3
+ export function freezeNatureState(state) {
4
+ const cells = Object.freeze(state.cells.map(entity => entity ? Object.freeze({ ...entity }) : null));
5
+ const activeCells = Object.freeze([...state.activeCells]);
6
+ const rules = Object.freeze({ ...state.rules });
7
+ return Object.freeze({ ...state, cells, activeCells, rules });
8
+ }
9
+ export function createNatureState(options) {
10
+ validateNatureOptions(options);
11
+ const cells = options.cells ?? Array(options.width * options.height).fill(null);
12
+ const maxId = cells.reduce((max, entity) => Math.max(max, entity?.id ?? 0), 0);
13
+ if (maxId >= 0x8000_0000)
14
+ throw new NatureOptionsError('invalid-cell', 'No stable IDs remain');
15
+ const seed = options.seed ?? 'houseki-shizen-default';
16
+ return freezeNatureState({
17
+ width: options.width, height: options.height,
18
+ activeCells: options.activeCells ?? Array(options.width * options.height).fill(true),
19
+ cells, seed, randomState: seedNatureState(seed), nextId: maxId + 1, committedPlacements: 0,
20
+ rules: { magneticEnabled: options.magneticEnabled ?? true, reboundEnabled: options.reboundEnabled ?? true, reboundDirectionMode: options.reboundDirectionMode ?? 'input-or-alternate' }
21
+ });
22
+ }
@@ -0,0 +1,124 @@
1
+ export type HorizontalDirection = 'left' | 'right';
2
+ export type ReboundDirectionMode = 'input-or-alternate' | 'seeded';
3
+ export type QuarterTurn = -1 | 0 | 1;
4
+ /** Stable board occupant. Obstacles collide and block attraction but cannot move or attract. */
5
+ export interface NatureEntity {
6
+ readonly id: number;
7
+ readonly kind: 'stone' | 'obstacle';
8
+ readonly colour?: string;
9
+ readonly magnetic?: boolean;
10
+ readonly anchored?: boolean;
11
+ }
12
+ export interface NaturePieceCell extends NatureEntity {
13
+ readonly kind: 'stone';
14
+ readonly x: number;
15
+ readonly y: number;
16
+ }
17
+ export interface NatureRules {
18
+ readonly magneticEnabled: boolean;
19
+ readonly reboundEnabled: boolean;
20
+ readonly reboundDirectionMode: ReboundDirectionMode;
21
+ }
22
+ export interface NatureOptions {
23
+ readonly width: number;
24
+ readonly height: number;
25
+ readonly seed?: string;
26
+ /** Row-major passability mask. False cells are permanent gaps and cannot hold pieces. */
27
+ readonly activeCells?: readonly boolean[];
28
+ /** Row-major initial occupants; false mask cells must be empty. */
29
+ readonly cells?: readonly (NatureEntity | null)[];
30
+ readonly magneticEnabled?: boolean;
31
+ readonly reboundEnabled?: boolean;
32
+ readonly reboundDirectionMode?: ReboundDirectionMode;
33
+ }
34
+ export interface NatureState {
35
+ readonly width: number;
36
+ readonly height: number;
37
+ readonly activeCells: readonly boolean[];
38
+ readonly cells: readonly (NatureEntity | null)[];
39
+ readonly seed: string;
40
+ readonly randomState: number;
41
+ readonly nextId: number;
42
+ readonly committedPlacements: number;
43
+ readonly rules: NatureRules;
44
+ }
45
+ export interface NatureMove {
46
+ readonly id: number;
47
+ readonly from: {
48
+ readonly x: number;
49
+ readonly y: number;
50
+ };
51
+ readonly to: {
52
+ readonly x: number;
53
+ readonly y: number;
54
+ };
55
+ }
56
+ export interface AttractionPairPreview {
57
+ readonly ids: readonly [number, number];
58
+ readonly axis: 'horizontal' | 'vertical';
59
+ readonly emptyGap: number;
60
+ readonly midpoint: {
61
+ readonly x: number;
62
+ readonly y: number;
63
+ };
64
+ readonly anchoredId?: number;
65
+ readonly moves: readonly NatureMove[];
66
+ }
67
+ export interface MagneticPreview {
68
+ readonly pairs: readonly AttractionPairPreview[];
69
+ readonly moves: readonly NatureMove[];
70
+ readonly cells: readonly (NatureEntity | null)[];
71
+ }
72
+ export interface PowerDropRequest {
73
+ readonly piece: readonly NaturePieceCell[];
74
+ readonly pivotId: number;
75
+ readonly power: boolean;
76
+ readonly lastHorizontalDirection?: HorizontalDirection;
77
+ /** A caller-selected quarter-turn applied with the one-cell rebound. */
78
+ readonly quarterTurn?: QuarterTurn;
79
+ }
80
+ export interface ReboundAttemptPreview {
81
+ readonly direction: HorizontalDirection;
82
+ readonly quarterTurn: QuarterTurn;
83
+ readonly valid: boolean;
84
+ }
85
+ export interface PowerDropPreview {
86
+ readonly normalLanding: readonly NaturePieceCell[];
87
+ readonly attempts: readonly ReboundAttemptPreview[];
88
+ readonly rebound: boolean;
89
+ readonly selectedDirection?: HorizontalDirection;
90
+ readonly quarterTurn: QuarterTurn;
91
+ readonly finalCells: readonly NaturePieceCell[];
92
+ readonly moves: readonly NatureMove[];
93
+ readonly randomStateAfter: number;
94
+ }
95
+ export type NatureEvent = {
96
+ readonly type: 'magnetic-pulse';
97
+ readonly pairs: readonly AttractionPairPreview[];
98
+ readonly moves: readonly NatureMove[];
99
+ } | {
100
+ readonly type: 'magnetic-stones-marked';
101
+ readonly ids: readonly number[];
102
+ readonly randomState: number;
103
+ } | {
104
+ readonly type: 'power-drop-landed';
105
+ readonly power: boolean;
106
+ readonly rebound: boolean;
107
+ readonly direction?: HorizontalDirection;
108
+ readonly quarterTurn: QuarterTurn;
109
+ readonly moves: readonly NatureMove[];
110
+ readonly ids: readonly number[];
111
+ };
112
+ export interface NatureTransition {
113
+ readonly state: NatureState;
114
+ readonly accepted: boolean;
115
+ readonly reason?: string;
116
+ readonly events: readonly NatureEvent[];
117
+ readonly preview?: MagneticPreview | PowerDropPreview;
118
+ }
119
+ export interface MarkingOptions {
120
+ readonly probability: number;
121
+ readonly maximum?: number;
122
+ readonly anchoredProbability?: number;
123
+ }
124
+ export type NatureErrorCode = 'invalid-nature-options' | 'invalid-grid-size' | 'invalid-seed' | 'invalid-mask' | 'invalid-cell' | 'duplicate-id' | 'invalid-marking-options' | 'invalid-power-drop' | 'piece-collision' | 'invalid-piece-position';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ import type { MarkingOptions, NatureEntity, NatureErrorCode, NatureOptions, NatureState, PowerDropRequest } from './types.js';
2
+ export declare class NatureOptionsError extends RangeError {
3
+ readonly code: NatureErrorCode;
4
+ constructor(code: NatureErrorCode, message: string);
5
+ }
6
+ export declare function validateNatureOptions(options: NatureOptions): void;
7
+ export declare function validateEntity(value: unknown, ids: Set<number>, label: string): asserts value is NatureEntity;
8
+ export declare function validateState(state: NatureState): void;
9
+ export declare function validateMarkingOptions(options: MarkingOptions): void;
10
+ export declare function validatePowerDropRequest(state: NatureState, request: PowerDropRequest): void;
@@ -0,0 +1,117 @@
1
+ export class NatureOptionsError extends RangeError {
2
+ code;
3
+ constructor(code, message) { super(message); this.name = 'NatureOptionsError'; this.code = code; }
4
+ }
5
+ const ownKeysOnly = (value, allowed, code, label) => {
6
+ if (Object.keys(value).some(key => !allowed.includes(key)))
7
+ throw new NatureOptionsError(code, `${label} contains an unsupported field`);
8
+ };
9
+ export function validateNatureOptions(options) {
10
+ if (!options || typeof options !== 'object' || Array.isArray(options))
11
+ throw new NatureOptionsError('invalid-nature-options', 'Nature options must be an object');
12
+ ownKeysOnly(options, ['width', 'height', 'seed', 'activeCells', 'cells', 'magneticEnabled', 'reboundEnabled', 'reboundDirectionMode'], 'invalid-nature-options', 'Nature options');
13
+ if (!Number.isSafeInteger(options.width) || !Number.isSafeInteger(options.height) || options.width < 1 || options.height < 1 || options.width > 32 || options.height > 32 || options.width * options.height > 1024)
14
+ throw new NatureOptionsError('invalid-grid-size', 'Grid dimensions must be integers from 1 to 32 with at most 1024 cells');
15
+ if (options.seed !== undefined && (typeof options.seed !== 'string' || options.seed.length < 1 || options.seed.length > 200))
16
+ throw new NatureOptionsError('invalid-seed', 'Seed must contain 1–200 characters');
17
+ const size = options.width * options.height;
18
+ if (options.activeCells !== undefined && (!Array.isArray(options.activeCells) || options.activeCells.length !== size || options.activeCells.some(value => typeof value !== 'boolean')))
19
+ throw new NatureOptionsError('invalid-mask', 'Active-cell mask must contain one boolean per grid cell');
20
+ if (options.cells !== undefined && (!Array.isArray(options.cells) || options.cells.length !== size))
21
+ throw new NatureOptionsError('invalid-cell', 'Cells must contain one entry per grid cell');
22
+ if (options.magneticEnabled !== undefined && typeof options.magneticEnabled !== 'boolean' || options.reboundEnabled !== undefined && typeof options.reboundEnabled !== 'boolean')
23
+ throw new NatureOptionsError('invalid-nature-options', 'Feature flags must be boolean');
24
+ if (options.reboundDirectionMode !== undefined && options.reboundDirectionMode !== 'input-or-alternate' && options.reboundDirectionMode !== 'seeded')
25
+ throw new NatureOptionsError('invalid-nature-options', 'Unsupported rebound direction mode');
26
+ const ids = new Set();
27
+ for (let index = 0; index < (options.cells?.length ?? 0); index++) {
28
+ const entity = options.cells[index];
29
+ if (entity === null)
30
+ continue;
31
+ validateEntity(entity, ids, `Grid cell ${index}`);
32
+ if (options.activeCells?.[index] === false)
33
+ throw new NatureOptionsError('invalid-cell', `Grid cell ${index} is masked but occupied`);
34
+ }
35
+ }
36
+ export function validateEntity(value, ids, label) {
37
+ if (!value || typeof value !== 'object' || Array.isArray(value))
38
+ throw new NatureOptionsError('invalid-cell', `${label} must be null or an entity`);
39
+ const entity = value;
40
+ ownKeysOnly(value, ['id', 'kind', 'colour', 'magnetic', 'anchored'], 'invalid-cell', label);
41
+ if (!Number.isSafeInteger(entity.id) || entity.id < 1 || entity.id > 0x7fff_ffff || ids.has(entity.id))
42
+ throw new NatureOptionsError(ids.has(entity.id) ? 'duplicate-id' : 'invalid-cell', `${label} has an invalid or duplicate stable ID`);
43
+ if (entity.kind !== 'stone' && entity.kind !== 'obstacle')
44
+ throw new NatureOptionsError('invalid-cell', `${label} has an invalid kind`);
45
+ if (entity.kind === 'stone') {
46
+ if (typeof entity.colour !== 'string' || entity.colour.length < 1 || entity.colour.length > 32)
47
+ throw new NatureOptionsError('invalid-cell', `${label} must have a colour`);
48
+ if (entity.magnetic !== undefined && typeof entity.magnetic !== 'boolean' || entity.anchored !== undefined && typeof entity.anchored !== 'boolean')
49
+ throw new NatureOptionsError('invalid-cell', `${label} magnetic properties must be boolean`);
50
+ if (entity.anchored && !entity.magnetic)
51
+ throw new NatureOptionsError('invalid-cell', `${label} cannot be anchored unless it is magnetic`);
52
+ }
53
+ else if ('colour' in entity || 'magnetic' in entity || 'anchored' in entity)
54
+ throw new NatureOptionsError('invalid-cell', `${label} obstacle cannot have stone properties`);
55
+ ids.add(entity.id);
56
+ }
57
+ export function validateState(state) {
58
+ if (!state || typeof state !== 'object')
59
+ throw new NatureOptionsError('invalid-nature-options', 'Nature state must be an object');
60
+ ownKeysOnly(state, ['width', 'height', 'activeCells', 'cells', 'seed', 'randomState', 'nextId', 'committedPlacements', 'rules'], 'invalid-nature-options', 'Nature state');
61
+ if (!Array.isArray(state.activeCells) || !Array.isArray(state.cells) || !state.rules || typeof state.rules !== 'object' || Array.isArray(state.rules))
62
+ throw new NatureOptionsError('invalid-nature-options', 'Nature state is missing its grid or rules');
63
+ ownKeysOnly(state.rules, ['magneticEnabled', 'reboundEnabled', 'reboundDirectionMode'], 'invalid-nature-options', 'Nature rules');
64
+ if (typeof state.rules.magneticEnabled !== 'boolean' || typeof state.rules.reboundEnabled !== 'boolean' || (state.rules.reboundDirectionMode !== 'input-or-alternate' && state.rules.reboundDirectionMode !== 'seeded'))
65
+ throw new NatureOptionsError('invalid-nature-options', 'Nature rules are malformed');
66
+ validateNatureOptions({ width: state.width, height: state.height, seed: state.seed, activeCells: state.activeCells, cells: state.cells, magneticEnabled: state.rules?.magneticEnabled, reboundEnabled: state.rules?.reboundEnabled, reboundDirectionMode: state.rules?.reboundDirectionMode });
67
+ if (!Number.isSafeInteger(state.randomState) || state.randomState < 0 || state.randomState > 0xffff_ffff || !Number.isSafeInteger(state.nextId) || state.nextId < 1 || state.nextId > 0x8000_0000 || !Number.isSafeInteger(state.committedPlacements) || state.committedPlacements < 0)
68
+ throw new NatureOptionsError('invalid-nature-options', 'Nature state counters are malformed');
69
+ const maxId = state.cells.reduce((maximum, entity) => Math.max(maximum, entity?.id ?? 0), 0);
70
+ if (state.nextId <= maxId)
71
+ throw new NatureOptionsError('invalid-nature-options', 'nextId must be greater than all board IDs');
72
+ }
73
+ export function validateMarkingOptions(options) {
74
+ if (!options || typeof options !== 'object' || Array.isArray(options))
75
+ throw new NatureOptionsError('invalid-marking-options', 'Marking options must be an object');
76
+ ownKeysOnly(options, ['probability', 'maximum', 'anchoredProbability'], 'invalid-marking-options', 'Marking options');
77
+ if (typeof options.probability !== 'number' || !Number.isFinite(options.probability) || options.probability < 0 || options.probability > 1)
78
+ throw new NatureOptionsError('invalid-marking-options', 'Magnetic probability must be between zero and one');
79
+ if (options.maximum !== undefined && (!Number.isSafeInteger(options.maximum) || options.maximum < 0 || options.maximum > 1024))
80
+ throw new NatureOptionsError('invalid-marking-options', 'Maximum magnetic count must be an integer from zero to 1024');
81
+ if (options.anchoredProbability !== undefined && (typeof options.anchoredProbability !== 'number' || !Number.isFinite(options.anchoredProbability) || options.anchoredProbability < 0 || options.anchoredProbability > 1))
82
+ throw new NatureOptionsError('invalid-marking-options', 'Anchored probability must be between zero and one');
83
+ }
84
+ export function validatePowerDropRequest(state, request) {
85
+ if (!request || typeof request !== 'object' || Array.isArray(request))
86
+ throw new NatureOptionsError('invalid-power-drop', 'Power-drop request must be an object');
87
+ ownKeysOnly(request, ['piece', 'pivotId', 'power', 'lastHorizontalDirection', 'quarterTurn'], 'invalid-power-drop', 'Power-drop request');
88
+ if (!Array.isArray(request.piece) || request.piece.length < 1 || request.piece.length > 16 || !Number.isSafeInteger(request.pivotId) || typeof request.power !== 'boolean')
89
+ throw new NatureOptionsError('invalid-power-drop', 'Power-drop requires 1–16 cells, a pivot ID and a power flag');
90
+ if (request.lastHorizontalDirection !== undefined && request.lastHorizontalDirection !== 'left' && request.lastHorizontalDirection !== 'right')
91
+ throw new NatureOptionsError('invalid-power-drop', 'Last horizontal direction must be left or right');
92
+ const turns = [-1, 0, 1];
93
+ if (request.quarterTurn !== undefined && !turns.includes(request.quarterTurn))
94
+ throw new NatureOptionsError('invalid-power-drop', 'Quarter-turn must be -1, 0 or 1');
95
+ const ids = new Set(state.cells.flatMap(entity => entity ? [entity.id] : []));
96
+ const pieceIds = new Set();
97
+ let hasPivot = false;
98
+ for (const [index, cell] of request.piece.entries()) {
99
+ if (!cell || typeof cell !== 'object' || Array.isArray(cell))
100
+ throw new NatureOptionsError('invalid-power-drop', `Piece cell ${index} is malformed`);
101
+ ownKeysOnly(cell, ['id', 'kind', 'colour', 'magnetic', 'anchored', 'x', 'y'], 'invalid-power-drop', `Piece cell ${index}`);
102
+ const { x, y, ...entity } = cell;
103
+ if (!Number.isSafeInteger(x) || !Number.isSafeInteger(y) || entity.kind !== 'stone')
104
+ throw new NatureOptionsError('invalid-power-drop', `Piece cell ${index} has invalid coordinates or is not a stone`);
105
+ validateEntity(entity, ids, `Piece cell ${index}`);
106
+ pieceIds.add(entity.id);
107
+ if (entity.id === request.pivotId)
108
+ hasPivot = true;
109
+ const at = y * state.width + x;
110
+ if (x < 0 || x >= state.width || y < 0 || y >= state.height || !state.activeCells[at] || state.cells[at] !== null)
111
+ throw new NatureOptionsError('invalid-piece-position', `Piece cell ${index} starts outside an empty active grid cell`);
112
+ }
113
+ if (!hasPivot || pieceIds.size !== request.piece.length)
114
+ throw new NatureOptionsError('invalid-power-drop', 'Piece cells need unique IDs and must include the pivot ID');
115
+ if (new Set(request.piece.map(cell => `${cell.x},${cell.y}`)).size !== request.piece.length)
116
+ throw new NatureOptionsError('invalid-power-drop', 'Piece cells cannot overlap');
117
+ }
@@ -0,0 +1,8 @@
1
+ export * from './nature/types.js';
2
+ export { NatureOptionsError } from './nature/validation.js';
3
+ export { createNatureState } from './nature/state.js';
4
+ export { markMagneticStones } from './nature/marking.js';
5
+ export { applyMagneticPulse, previewMagneticPulse } from './nature/attraction.js';
6
+ export { applyPowerDrop, previewPowerDrop } from './nature/rebound.js';
7
+ export { applyEarthquake, applyLightning, isEnvironmentTurnScheduled, previewEarthquake, previewFault, previewJumble, previewLightning } from './nature/environment.js';
8
+ export type { EarthquakeKind, EarthquakeOptions, EnvironmentEvent, EnvironmentPreview, EnvironmentSchedule, EnvironmentTransition, FaultPreview, JumblePreview, LightningOptions, LightningPreview } from './nature/environment.js';
package/dist/nature.js ADDED
@@ -0,0 +1,7 @@
1
+ export * from './nature/types.js';
2
+ export { NatureOptionsError } from './nature/validation.js';
3
+ export { createNatureState } from './nature/state.js';
4
+ export { markMagneticStones } from './nature/marking.js';
5
+ export { applyMagneticPulse, previewMagneticPulse } from './nature/attraction.js';
6
+ export { applyPowerDrop, previewPowerDrop } from './nature/rebound.js';
7
+ export { applyEarthquake, applyLightning, isEnvironmentTurnScheduled, previewEarthquake, previewFault, previewJumble, previewLightning } from './nature/environment.js';