@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,24 @@
1
+ import type { Action, GameState, HeldControl, Transition } from './types.js';
2
+ type EdgeAction = Extract<Action, {
3
+ kind: 'rotate-clockwise' | 'rotate-anticlockwise' | 'hard-drop' | 'place' | 'pause' | 'resume';
4
+ }>;
5
+ /** Deterministic held-key snapshot. Counters measure logical ticks since each press. */
6
+ export interface InputSchedulerState {
7
+ readonly held: Readonly<Partial<Record<HeldControl, number>>>;
8
+ readonly queued: readonly EdgeAction[];
9
+ }
10
+ /** Creates an empty held-input scheduler. */
11
+ export declare function createInputScheduler(): InputSchedulerState;
12
+ /** Starts or releases one held control. Repeated press edges do not reset its repeat counter. */
13
+ export declare function setHeldInput(state: InputSchedulerState, control: HeldControl, pressed: boolean): InputSchedulerState;
14
+ /** Queues a one-shot rotation, drop, Place, pause or resume edge for the next logical tick. */
15
+ export declare function queueInputEdge(state: InputSchedulerState, action: EdgeAction): InputSchedulerState;
16
+ /** Releases every held or queued control after focus loss, lock, resolution or pause. */
17
+ export declare function releaseAllInput(state: InputSchedulerState): InputSchedulerState;
18
+ /** Resolves this tick's inputs in lateral, rotation, drop, soft-drop, pause order. */
19
+ export declare function actionsForTick(game: GameState, input: InputSchedulerState): readonly [readonly Action[], InputSchedulerState];
20
+ /** Applies a scheduler batch until the pair locks or the run pauses; accepted actions are recorded by the engine. */
21
+ export declare function applyScheduledActions(game: GameState, input: InputSchedulerState, actions: readonly Action[], applyAction: (state: GameState, action: Action) => Transition): readonly [GameState, InputSchedulerState, readonly Transition[]];
22
+ /** Applies a complete deterministic held-input and simulation tick, releasing controls on lock or resolution. */
23
+ export declare function processInputTick(game: GameState, input: InputSchedulerState, apply: (state: GameState, action: Action) => Transition, tick: (state: GameState, ticks: number) => Transition): readonly [GameState, InputSchedulerState, readonly Transition[]];
24
+ export {};
@@ -0,0 +1,77 @@
1
+ /** Creates an empty held-input scheduler. */
2
+ export function createInputScheduler() { return { held: Object.freeze({}), queued: Object.freeze([]) }; }
3
+ /** Starts or releases one held control. Repeated press edges do not reset its repeat counter. */
4
+ export function setHeldInput(state, control, pressed) {
5
+ const held = { ...state.held };
6
+ if (pressed) {
7
+ if (held[control] === undefined)
8
+ held[control] = 0;
9
+ }
10
+ else
11
+ delete held[control];
12
+ return { ...state, held: Object.freeze(held) };
13
+ }
14
+ /** Queues a one-shot rotation, drop, Place, pause or resume edge for the next logical tick. */
15
+ export function queueInputEdge(state, action) { return { ...state, queued: Object.freeze([...state.queued, action]) }; }
16
+ /** Releases every held or queued control after focus loss, lock, resolution or pause. */
17
+ export function releaseAllInput(state) { return createInputScheduler(); }
18
+ /** Resolves this tick's inputs in lateral, rotation, drop, soft-drop, pause order. */
19
+ export function actionsForTick(game, input) {
20
+ if (game.phase === 'paused') {
21
+ const resume = input.queued.filter(action => action.kind === 'resume');
22
+ return [resume, createInputScheduler()];
23
+ }
24
+ if (game.phase !== 'falling' || !game.active) {
25
+ const pause = input.queued.filter(action => action.kind === 'pause');
26
+ return [pause, createInputScheduler()];
27
+ }
28
+ const actions = [];
29
+ const held = { ...input.held };
30
+ const left = held.left;
31
+ const right = held.right;
32
+ if (left !== undefined && right === undefined && (left === 0 || left >= 10 && (left - 10) % 3 === 0))
33
+ actions.push({ kind: 'left' });
34
+ if (right !== undefined && left === undefined && (right === 0 || right >= 10 && (right - 10) % 3 === 0))
35
+ actions.push({ kind: 'right' });
36
+ const edges = [...input.queued];
37
+ actions.push(...edges.filter(action => action.kind === 'rotate-clockwise' || action.kind === 'rotate-anticlockwise'));
38
+ actions.push(...edges.filter(action => action.kind === 'hard-drop' || action.kind === 'place'));
39
+ const soft = held['soft-drop'];
40
+ if (soft !== undefined && (soft === 0 || soft % 2 === 0))
41
+ actions.push({ kind: 'down' });
42
+ actions.push(...edges.filter(action => action.kind === 'pause' || action.kind === 'resume'));
43
+ for (const control of Object.keys(held))
44
+ held[control] = (held[control] ?? 0) + 1;
45
+ return [actions, { held: Object.freeze(held), queued: Object.freeze([]) }];
46
+ }
47
+ /** Applies a scheduler batch until the pair locks or the run pauses; accepted actions are recorded by the engine. */
48
+ export function applyScheduledActions(game, input, actions, applyAction) {
49
+ let state = game;
50
+ const transitions = [];
51
+ for (const action of actions) {
52
+ if (state.phase !== 'falling' && action.kind !== 'pause' && action.kind !== 'resume')
53
+ break;
54
+ if (state.phase === 'falling' && !state.active && action.kind !== 'pause')
55
+ break;
56
+ const result = applyAction(state, action);
57
+ transitions.push(result);
58
+ if (result.accepted)
59
+ state = result.state;
60
+ if (result.accepted && (action.kind === 'hard-drop' || action.kind === 'place' || action.kind === 'pause'))
61
+ break;
62
+ }
63
+ const lockedOrPaused = transitions.some(result => result.events.some(event => event.type === 'pair-locked' || event.type === 'paused')) || state.phase !== 'falling';
64
+ return [state, lockedOrPaused ? createInputScheduler() : input, transitions];
65
+ }
66
+ /** Applies a complete deterministic held-input and simulation tick, releasing controls on lock or resolution. */
67
+ export function processInputTick(game, input, apply, tick) {
68
+ const [actions, scheduled] = actionsForTick(game, input);
69
+ const [afterInput, retained, transitions] = applyScheduledActions(game, scheduled, actions, apply);
70
+ const clock = tick(afterInput, 1);
71
+ const nextGame = clock.state;
72
+ const pairId = (state) => state.active?.gems[0].id ?? null;
73
+ const pairChanged = pairId(game) !== pairId(nextGame);
74
+ const locked = transitions.some(result => result.events.some(event => event.type === 'pair-locked')) || clock.events.some(event => event.type === 'pair-locked');
75
+ const nextInput = locked || pairChanged || nextGame.phase !== 'falling' ? createInputScheduler() : retained;
76
+ return [nextGame, nextInput, [...transitions, clock]];
77
+ }
@@ -0,0 +1,5 @@
1
+ import type { Gem } from './types.js';
2
+ /** Finds maximal orthogonally connected same-colour components of at least four stones. */
3
+ export declare function findGroups(board: readonly (Gem | null)[], width: number, height: number): readonly (readonly number[])[];
4
+ /** Compacts each column independently, preserving survivor order. */
5
+ export declare function compactBoard(board: readonly (Gem | null)[], width: number, height: number): readonly (Gem | null)[];
@@ -0,0 +1,48 @@
1
+ /** Finds maximal orthogonally connected same-colour components of at least four stones. */
2
+ export function findGroups(board, width, height) {
3
+ const seen = new Set();
4
+ const groups = [];
5
+ for (let start = 0; start < board.length; start++) {
6
+ const first = board[start];
7
+ if (!first || seen.has(start))
8
+ continue;
9
+ const colour = first.colour;
10
+ const stack = [start];
11
+ const group = [];
12
+ seen.add(start);
13
+ while (stack.length) {
14
+ const at = stack.pop();
15
+ group.push(at);
16
+ const x = at % width;
17
+ const y = Math.floor(at / width);
18
+ const neighbours = [[x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]];
19
+ for (const [nx, ny] of neighbours) {
20
+ if (nx < 0 || nx >= width || ny < 0 || ny >= height)
21
+ continue;
22
+ const index = ny * width + nx;
23
+ if (!seen.has(index) && board[index]?.colour === colour) {
24
+ seen.add(index);
25
+ stack.push(index);
26
+ }
27
+ }
28
+ }
29
+ if (group.length >= 4)
30
+ groups.push(group.sort((a, b) => a - b));
31
+ }
32
+ return groups;
33
+ }
34
+ /** Compacts each column independently, preserving survivor order. */
35
+ export function compactBoard(board, width, height) {
36
+ const result = Array(width * height).fill(null);
37
+ for (let x = 0; x < width; x++) {
38
+ const stack = [];
39
+ for (let y = 0; y < height; y++) {
40
+ const gem = board[y * width + x];
41
+ if (gem)
42
+ stack.push(gem);
43
+ }
44
+ for (let i = 0; i < stack.length; i++)
45
+ result[(height - stack.length + i) * width + x] = stack[i];
46
+ }
47
+ return result;
48
+ }
@@ -0,0 +1,7 @@
1
+ import type { GameState } from './types.js';
2
+ /** Restarts the same seed/date or original witnessed challenge board and queue. */
3
+ export declare function restartGame(state: GameState): GameState;
4
+ /** Encodes canonical initial settings, accepted ordinal actions and a derived checkpoint. */
5
+ export declare function encodeGame(state: GameState): string;
6
+ /** Reconstructs a bounded action replay and rejects any checkpoint that differs from replay. */
7
+ export declare function decodeGame(text: string): GameState;
@@ -0,0 +1,96 @@
1
+ import { createChallenge } from './challenge.js';
2
+ import { advanceTicks, applyAction, createGame } from './engine.js';
3
+ const RULES = 'chains-1';
4
+ const MAX_SAVE_BYTES = 2 * 1024 * 1024;
5
+ const MAX_ACTIONS = 100_000;
6
+ const MAX_TICKS = 10_000_000;
7
+ const ACTIONS = ['left', 'right', 'down', 'rotate-clockwise', 'rotate-anticlockwise', 'hard-drop', 'place', 'pause', 'resume', 'hint'];
8
+ function stable(value, depth = 0) {
9
+ if (depth > 60)
10
+ throw new TypeError('Save checkpoint is nested too deeply');
11
+ if (Array.isArray(value))
12
+ return `[${value.map(item => stable(item, depth + 1)).join(',')}]`;
13
+ if (value && typeof value === 'object')
14
+ return `{${Object.entries(value).filter(([, item]) => item !== undefined).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${stable(item, depth + 1)}`).join(',')}}`;
15
+ return JSON.stringify(value) ?? 'null';
16
+ }
17
+ function challengeOptions(settings) {
18
+ if (settings.mode !== 'challenge' || !settings.challengeId || !settings.goal || !settings.initialBoard || !settings.queue || !settings.witness)
19
+ throw new TypeError('Challenge settings are incomplete');
20
+ return { id: settings.challengeId, width: settings.width, height: settings.height, colourCount: settings.colourCount, seed: settings.seed, board: settings.initialBoard, queue: settings.queue, goal: settings.goal, witness: settings.witness };
21
+ }
22
+ function createFromSettings(settings) {
23
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings))
24
+ throw new TypeError('Save has invalid settings');
25
+ if (settings.mode === 'challenge')
26
+ return createChallenge(challengeOptions(settings));
27
+ const options = settings.mode === 'daily' ? { mode: 'daily', dailyDate: settings.date } : { mode: settings.mode, width: settings.width, height: settings.height, colourCount: settings.colourCount, seed: settings.seed, ...(settings.nature ? { nature: true } : {}), ...(settings.weather ? { weather: settings.weather } : {}) };
28
+ const state = createGame(options);
29
+ if (stable(state.settings) !== stable(settings))
30
+ throw new TypeError('Save settings are not canonical');
31
+ return state;
32
+ }
33
+ /** Restarts the same seed/date or original witnessed challenge board and queue. */
34
+ export function restartGame(state) { return createFromSettings(state.settings); }
35
+ /** Encodes canonical initial settings, accepted ordinal actions and a derived checkpoint. */
36
+ export function encodeGame(state) {
37
+ if (!Number.isSafeInteger(state.elapsedTicks) || state.elapsedTicks < 0 || state.elapsedTicks > MAX_TICKS || state.recording.length > MAX_ACTIONS)
38
+ throw new RangeError('Run exceeds replay limits');
39
+ const text = JSON.stringify({ format: 1, game: state.game, rules: state.rules, settings: state.settings, elapsedTicks: state.elapsedTicks, actions: state.recording, checkpoint: state });
40
+ if (new TextEncoder().encode(text).byteLength > MAX_SAVE_BYTES)
41
+ throw new RangeError('Save exceeds 2 MiB');
42
+ return text;
43
+ }
44
+ /** Reconstructs a bounded action replay and rejects any checkpoint that differs from replay. */
45
+ export function decodeGame(text) {
46
+ if (typeof text !== 'string' || new TextEncoder().encode(text).byteLength > MAX_SAVE_BYTES)
47
+ throw new RangeError('Save must be text no larger than 2 MiB');
48
+ let value;
49
+ try {
50
+ value = JSON.parse(text);
51
+ }
52
+ catch {
53
+ throw new TypeError('Save is not valid JSON');
54
+ }
55
+ if (!value || value.format !== 1 || value.game !== 'colour-chains' || value.rules !== RULES || !value.settings || !Array.isArray(value.actions) || !value.checkpoint || Object.keys(value).some(key => !['format', 'game', 'rules', 'settings', 'elapsedTicks', 'actions', 'checkpoint'].includes(key)))
56
+ throw new TypeError('Unsupported or invalid Colour Chains save');
57
+ if (!Number.isSafeInteger(value.elapsedTicks) || value.elapsedTicks < 0 || value.elapsedTicks > MAX_TICKS || value.actions.length > MAX_ACTIONS)
58
+ throw new RangeError('Save exceeds replay limits');
59
+ let state = createFromSettings(value.settings);
60
+ let previousTick = -1;
61
+ let previousOrdinal = -1;
62
+ const recording = [];
63
+ for (let index = 0; index < value.actions.length; index++) {
64
+ const raw = value.actions[index];
65
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
66
+ throw new TypeError(`Invalid action record ${index + 1}`);
67
+ const item = raw;
68
+ if (Object.keys(item).some(key => !['tick', 'ordinal', 'action'].includes(key)) || !Number.isSafeInteger(item.tick) || item.tick < 0 || item.tick > value.elapsedTicks || !Number.isSafeInteger(item.ordinal) || !item.action || typeof item.action !== 'object' || Array.isArray(item.action) || Object.keys(item.action).some(key => key !== 'kind') || !ACTIONS.includes(item.action.kind))
69
+ throw new TypeError(`Invalid action record ${index + 1}`);
70
+ if (item.tick < previousTick || item.ordinal !== (item.tick === previousTick ? previousOrdinal + 1 : 0))
71
+ throw new TypeError(`Invalid action ordering at record ${index + 1}`);
72
+ while (state.elapsedTicks < item.tick) {
73
+ const prior = state.elapsedTicks;
74
+ state = advanceTicks(state, Math.min(3600, item.tick - state.elapsedTicks)).state;
75
+ if (state.elapsedTicks === prior)
76
+ throw new TypeError(`Action occurs after the run stopped at record ${index + 1}`);
77
+ }
78
+ const result = applyAction(state, item.action);
79
+ if (!result.accepted)
80
+ throw new TypeError(`Illegal action at record ${index + 1}: ${result.reason ?? 'rejected'}`);
81
+ state = result.state;
82
+ recording.push({ tick: item.tick, ordinal: item.ordinal, action: item.action });
83
+ previousTick = item.tick;
84
+ previousOrdinal = item.ordinal;
85
+ }
86
+ while (state.elapsedTicks < value.elapsedTicks) {
87
+ const prior = state.elapsedTicks;
88
+ state = advanceTicks(state, Math.min(3600, value.elapsedTicks - state.elapsedTicks)).state;
89
+ if (state.elapsedTicks === prior)
90
+ throw new TypeError('Checkpoint time exceeds the logical game clock');
91
+ }
92
+ state = { ...state, recording };
93
+ if (stable(state) !== stable(value.checkpoint))
94
+ throw new TypeError('Save checkpoint does not match its action recording');
95
+ return state;
96
+ }
@@ -0,0 +1,7 @@
1
+ import type { Colour, GameState } from './types.js';
2
+ /** Hashes a UTF-8 seed with FNV-1a, scoped to this game and rules version. */
3
+ export declare function seedState(seed: string): number;
4
+ /** Advances the package's reference xorshift32 source. */
5
+ export declare function nextUint32(state: number): number;
6
+ /** Draws one deterministic pair from a three-of-each-colour shuffled bag. */
7
+ export declare function drawPair(state: Pick<GameState, 'bag' | 'randomState' | 'settings'>): readonly [readonly [Colour, Colour], readonly Colour[], number];
@@ -0,0 +1,49 @@
1
+ const COLOURS = ['red', 'blue', 'green', 'gold', 'purple', 'teal'];
2
+ /** Hashes a UTF-8 seed with FNV-1a, scoped to this game and rules version. */
3
+ export function seedState(seed) {
4
+ let hash = 0x811c9dc5;
5
+ for (const byte of new TextEncoder().encode(`colour-chains|chains-1|${seed}`))
6
+ hash = Math.imul(hash ^ byte, 0x01000193) >>> 0;
7
+ return hash || 0x6d2b79f5;
8
+ }
9
+ /** Advances the package's reference xorshift32 source. */
10
+ export function nextUint32(state) {
11
+ let next = state >>> 0;
12
+ next ^= next << 13;
13
+ next ^= next >>> 17;
14
+ next ^= next << 5;
15
+ return next >>> 0;
16
+ }
17
+ function nextInt(state, n) {
18
+ const limit = Math.floor(0x100000000 / n) * n;
19
+ let current = state >>> 0;
20
+ for (;;) {
21
+ const next = nextUint32(current);
22
+ current = next;
23
+ if (next < limit)
24
+ return [next % n, current];
25
+ }
26
+ }
27
+ function refill(state, count) {
28
+ const values = [...COLOURS.slice(0, count), ...COLOURS.slice(0, count), ...COLOURS.slice(0, count)];
29
+ let randomState = state;
30
+ for (let i = values.length - 1; i > 0; i--) {
31
+ const [j, next] = nextInt(randomState, i + 1);
32
+ randomState = next;
33
+ [values[i], values[j]] = [values[j], values[i]];
34
+ }
35
+ return [values, randomState];
36
+ }
37
+ /** Draws one deterministic pair from a three-of-each-colour shuffled bag. */
38
+ export function drawPair(state) {
39
+ let bag = state.bag;
40
+ let randomState = state.randomState;
41
+ const colours = [];
42
+ for (let i = 0; i < 2; i++) {
43
+ if (!bag.length)
44
+ [bag, randomState] = refill(randomState, state.settings.colourCount);
45
+ colours.push(bag[bag.length - 1]);
46
+ bag = bag.slice(0, -1);
47
+ }
48
+ return [[colours[0], colours[1]], bag, randomState];
49
+ }
@@ -0,0 +1,191 @@
1
+ /** A gem colour used by Colour Chains. */
2
+ export type Colour = 'red' | 'blue' | 'green' | 'gold' | 'purple' | 'teal';
3
+ /** A settled or active stone with a stable run-local identity. */
4
+ export interface Gem {
5
+ readonly id: number;
6
+ readonly colour: Colour;
7
+ readonly magnetic?: true;
8
+ }
9
+ /** Authored Challenge layouts do not permit Shizen-marked stones. */
10
+ export type ChallengeGem = Omit<Gem, 'magnetic'>;
11
+ /** The pivot-to-satellite direction, in clockwise order. */
12
+ export type Orientation = 'up' | 'right' | 'down' | 'left';
13
+ /** A visible or hidden board coordinate. */
14
+ export interface Cell {
15
+ readonly x: number;
16
+ readonly y: number;
17
+ }
18
+ /** The active rigid pair. The pivot always retains its own gem identity. */
19
+ export interface Pair {
20
+ readonly pivot: Cell;
21
+ readonly orientation: Orientation;
22
+ readonly gems: readonly [Gem, Gem];
23
+ readonly level: number;
24
+ readonly gravityTicks: number;
25
+ readonly groundedTicks: number;
26
+ readonly resetCount: number;
27
+ readonly groundedStarted: boolean;
28
+ }
29
+ /** Supported well presets. */
30
+ export type Preset = 'narrow' | 'standard' | 'wide' | 'tall' | 'extraWide' | 'deep' | 'large';
31
+ export type Mode = 'relaxed' | 'arcade' | 'daily' | 'challenge';
32
+ export type WeatherSchedule = 'frequent' | 'rare';
33
+ export interface CreateOptions {
34
+ readonly mode?: Exclude<Mode, 'challenge'>;
35
+ readonly seed?: string;
36
+ readonly dailyDate?: string;
37
+ readonly preset?: Preset;
38
+ readonly width?: number;
39
+ readonly height?: number;
40
+ readonly colourCount?: 4 | 5 | 6;
41
+ readonly pairLimit?: number;
42
+ readonly nature?: boolean;
43
+ readonly weather?: WeatherSchedule;
44
+ }
45
+ /** Run settings fixed at game creation. */
46
+ export interface Settings {
47
+ readonly mode: Mode;
48
+ readonly width: number;
49
+ readonly height: number;
50
+ readonly colourCount: 4 | 5 | 6;
51
+ readonly seed: string;
52
+ readonly date?: string;
53
+ readonly pairLimit?: number;
54
+ readonly nature?: true;
55
+ readonly weather?: WeatherSchedule;
56
+ readonly challengeId?: string;
57
+ readonly goal?: ChallengeGoal;
58
+ readonly initialBoard?: readonly (ChallengeGem | null)[];
59
+ readonly queue?: readonly (readonly [Colour, Colour])[];
60
+ readonly witness?: readonly WitnessStep[];
61
+ }
62
+ /** Challenge objective; target IDs refer to the visible starting board. */
63
+ export type ChallengeGoal = {
64
+ readonly kind: 'clear-targets';
65
+ readonly targetIds: readonly number[];
66
+ } | {
67
+ readonly kind: 'minimum-chain';
68
+ readonly chain: number;
69
+ } | {
70
+ readonly kind: 'empty-board';
71
+ };
72
+ export interface WitnessStep {
73
+ readonly pivotX: number;
74
+ readonly orientation: Orientation;
75
+ }
76
+ export interface ChallengeOptions {
77
+ readonly id: string;
78
+ readonly width?: number;
79
+ readonly height?: number;
80
+ readonly colourCount?: 4 | 5 | 6;
81
+ readonly seed?: string;
82
+ readonly board: readonly (ChallengeGem | null)[];
83
+ readonly queue: readonly (readonly [Colour, Colour])[];
84
+ readonly goal: ChallengeGoal;
85
+ readonly witness?: readonly WitnessStep[];
86
+ }
87
+ /** Observable game phases. */
88
+ export type Phase = 'falling' | 'clear-mark' | 'clear-remove' | 'gravity' | 'paused' | 'won' | 'lost' | 'finished';
89
+ /** A scored simultaneous match wave. */
90
+ export interface Wave {
91
+ readonly chain: number;
92
+ readonly cells: readonly number[];
93
+ readonly ids: readonly number[];
94
+ readonly points: number;
95
+ }
96
+ export type PlayAction = {
97
+ readonly kind: 'left';
98
+ } | {
99
+ readonly kind: 'right';
100
+ } | {
101
+ readonly kind: 'down';
102
+ } | {
103
+ readonly kind: 'rotate-clockwise';
104
+ } | {
105
+ readonly kind: 'rotate-anticlockwise';
106
+ } | {
107
+ readonly kind: 'hard-drop';
108
+ } | {
109
+ readonly kind: 'place';
110
+ } | {
111
+ readonly kind: 'pause';
112
+ } | {
113
+ readonly kind: 'resume';
114
+ } | {
115
+ readonly kind: 'hint';
116
+ };
117
+ export type Action = PlayAction;
118
+ export type HeldControl = 'left' | 'right' | 'soft-drop';
119
+ export type InputCommand = {
120
+ readonly kind: 'hold';
121
+ readonly control: HeldControl;
122
+ readonly pressed: boolean;
123
+ } | {
124
+ readonly kind: 'edge';
125
+ readonly action: Extract<PlayAction, {
126
+ kind: 'rotate-clockwise' | 'rotate-anticlockwise' | 'hard-drop' | 'place' | 'pause' | 'resume';
127
+ }>;
128
+ };
129
+ export interface RecordedAction {
130
+ readonly tick: number;
131
+ readonly ordinal: number;
132
+ readonly action: PlayAction;
133
+ }
134
+ /** An immutable Colour Chains rules state. Board rows include the three hidden rows above the well. */
135
+ export interface GameState {
136
+ readonly game: 'colour-chains';
137
+ readonly rules: 'chains-1';
138
+ readonly settings: Settings;
139
+ readonly board: readonly (Gem | null)[];
140
+ readonly phase: Phase;
141
+ readonly pausedPhase?: Exclude<Phase, 'paused'>;
142
+ readonly active: Pair | null;
143
+ readonly next: readonly (readonly [Colour, Colour])[];
144
+ readonly bag: readonly Colour[];
145
+ readonly randomState: number;
146
+ readonly nextMagnetic?: readonly (readonly [boolean, boolean])[];
147
+ readonly lastHorizontalDirection?: 'left' | 'right';
148
+ readonly naturePulsePending?: boolean;
149
+ readonly weatherResolvedPairs?: number;
150
+ readonly nextId: number;
151
+ readonly score: number;
152
+ readonly maxChain: number;
153
+ readonly allClears: number;
154
+ readonly completedPairs: number;
155
+ readonly resolutionTick: number;
156
+ readonly resolutionLevel?: number;
157
+ readonly waves: readonly Wave[];
158
+ readonly clearCells: readonly number[];
159
+ readonly gravityBoard: readonly (Gem | null)[] | null;
160
+ readonly resolutionHadClear: boolean;
161
+ readonly elapsedTicks: number;
162
+ readonly assisted: boolean;
163
+ readonly witnessIndex: number;
164
+ readonly recording: readonly RecordedAction[];
165
+ readonly reason?: string;
166
+ }
167
+ /** A deterministic transition; rejected actions retain the exact input state. */
168
+ export interface Transition {
169
+ readonly state: GameState;
170
+ readonly events: readonly GameEvent[];
171
+ readonly accepted: boolean;
172
+ readonly reason?: string;
173
+ }
174
+ /** A domain event emitted by one transition. */
175
+ export interface GameEvent {
176
+ readonly type: string;
177
+ readonly [key: string]: unknown;
178
+ }
179
+ /** Compact player status derived from the current state. */
180
+ export interface GameStatus {
181
+ readonly mode: Mode;
182
+ readonly phase: Phase;
183
+ readonly score: number;
184
+ readonly maxChain: number;
185
+ readonly allClears: number;
186
+ readonly completedPairs: number;
187
+ readonly assisted: boolean;
188
+ readonly reason?: string;
189
+ }
190
+ /** Destination cells for the two independently settled ghost stones. */
191
+ export type Landing = readonly [Cell, Cell];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,16 @@
1
+ import type { ChallengeGoal, Colour, CreateOptions, Mode, Preset, Settings } from './types.js';
2
+ declare const PRESETS: Readonly<Record<Preset, readonly [number, number]>>;
3
+ declare const COLOURS: readonly Colour[];
4
+ /** Typed setup and authored challenge error. */
5
+ export declare class StoneChainsOptionsError extends RangeError {
6
+ readonly code: string;
7
+ constructor(code: string, message: string);
8
+ }
9
+ export interface NormalizedOptions extends Settings {
10
+ readonly mode: Exclude<Mode, 'challenge'>;
11
+ }
12
+ declare function validateSize(width: number, height: number): void;
13
+ /** Validates public modes and canonical category dimensions before a random draw. */
14
+ export declare function normalizeOptions(options: CreateOptions): NormalizedOptions;
15
+ export declare function validateChallengeGoal(goal: ChallengeGoal, ids: ReadonlySet<number>): ChallengeGoal;
16
+ export { COLOURS, PRESETS, validateSize };
@@ -0,0 +1,80 @@
1
+ const PRESETS = { narrow: [5, 12], standard: [6, 12], wide: [8, 12], tall: [6, 16], extraWide: [12, 12], deep: [8, 32], large: [12, 32] };
2
+ const COLOURS = ['red', 'blue', 'green', 'gold', 'purple', 'teal'];
3
+ /** Typed setup and authored challenge error. */
4
+ export class StoneChainsOptionsError extends RangeError {
5
+ code;
6
+ constructor(code, message) { super(message); this.name = 'StoneChainsOptionsError'; this.code = code; }
7
+ }
8
+ function validDate(value) {
9
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
10
+ return false;
11
+ const time = Date.parse(`${value}T00:00:00.000Z`);
12
+ return Number.isFinite(time) && new Date(time).toISOString().slice(0, 10) === value;
13
+ }
14
+ function validateSize(width, height) {
15
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 5 || width > 16 || height < 12 || height > 32 || width * height > 512)
16
+ throw new StoneChainsOptionsError('invalid-board-size', 'Board must be 5–16 columns by 12–32 visible rows, with at most 512 cells');
17
+ }
18
+ /** Validates public modes and canonical category dimensions before a random draw. */
19
+ export function normalizeOptions(options) {
20
+ if (!options || typeof options !== 'object' || Array.isArray(options))
21
+ throw new StoneChainsOptionsError('invalid-options', 'Options must be an object');
22
+ const keys = ['mode', 'seed', 'dailyDate', 'preset', 'width', 'height', 'colourCount', 'pairLimit', 'nature', 'weather'];
23
+ if (Object.keys(options).some(key => !keys.includes(key)))
24
+ throw new StoneChainsOptionsError('unknown-option', 'Options contain an unsupported field');
25
+ const mode = options.mode ?? 'relaxed';
26
+ if (!['relaxed', 'arcade', 'daily'].includes(mode))
27
+ throw new StoneChainsOptionsError('invalid-mode', 'Use Relaxed, Arcade or Daily; Challenges use createChallenge');
28
+ const colourCount = options.colourCount ?? 4;
29
+ if (colourCount !== 4 && colourCount !== 5 && colourCount !== 6)
30
+ throw new StoneChainsOptionsError('invalid-colour-count', 'Colour count must be 4, 5 or 6');
31
+ if (mode === 'daily') {
32
+ if (options.nature !== undefined || options.weather !== undefined)
33
+ throw new StoneChainsOptionsError('noncanonical-daily-settings', 'Daily does not use optional nature rules');
34
+ if (typeof options.dailyDate !== 'string' || !validDate(options.dailyDate))
35
+ throw new StoneChainsOptionsError('invalid-daily-date', 'Daily requires a valid UTC YYYY-MM-DD date from the host');
36
+ if (options.seed !== undefined || options.preset !== undefined || options.width !== undefined || options.height !== undefined || options.colourCount !== undefined && options.colourCount !== 4 || options.pairLimit !== undefined && options.pairLimit !== 60)
37
+ throw new StoneChainsOptionsError('noncanonical-daily-settings', 'Daily uses the standard 6×12 four-colour well and 60 resolved pairs');
38
+ return { mode, width: 6, height: 12, colourCount: 4, date: options.dailyDate, seed: `colour-chains:daily:${options.dailyDate}`, pairLimit: 60 };
39
+ }
40
+ if (options.dailyDate !== undefined)
41
+ throw new StoneChainsOptionsError('daily-date-outside-daily', 'dailyDate is supported only in Daily mode');
42
+ if (options.nature !== undefined && typeof options.nature !== 'boolean')
43
+ throw new StoneChainsOptionsError('invalid-nature-option', 'nature must be a boolean');
44
+ if (options.weather !== undefined && options.weather !== 'frequent' && options.weather !== 'rare')
45
+ throw new StoneChainsOptionsError('invalid-weather-option', 'weather must be frequent or rare');
46
+ if (options.weather !== undefined && options.nature !== true)
47
+ throw new StoneChainsOptionsError('weather-requires-nature', 'Arashi weather requires nature:true');
48
+ if (options.preset !== undefined && !Object.hasOwn(PRESETS, options.preset))
49
+ throw new StoneChainsOptionsError('invalid-preset', 'Unsupported board preset');
50
+ if (options.preset !== undefined && (options.width !== undefined || options.height !== undefined))
51
+ throw new StoneChainsOptionsError('conflicting-board-options', 'Choose a preset or custom dimensions');
52
+ const [presetWidth, presetHeight] = PRESETS[options.preset ?? 'standard'];
53
+ const width = options.width ?? presetWidth;
54
+ const height = options.height ?? presetHeight;
55
+ validateSize(width, height);
56
+ const seed = options.seed ?? 'houseki-colour-chains';
57
+ if (typeof seed !== 'string' || seed.length < 1 || seed.length > 200)
58
+ throw new StoneChainsOptionsError('invalid-seed', 'Seed must contain 1–200 characters');
59
+ if (options.pairLimit !== undefined)
60
+ throw new StoneChainsOptionsError('unsupported-pair-limit', 'Pair limits are defined only for Daily and Challenges');
61
+ return { mode, width, height, colourCount, seed, ...(options.nature === true ? { nature: true } : {}), ...(options.weather ? { weather: options.weather } : {}) };
62
+ }
63
+ export function validateChallengeGoal(goal, ids) {
64
+ if (!goal || typeof goal !== 'object' || Array.isArray(goal))
65
+ throw new StoneChainsOptionsError('invalid-goal', 'Challenge goal must be an object');
66
+ if (goal.kind === 'clear-targets') {
67
+ if (Object.keys(goal).some(key => key !== 'kind' && key !== 'targetIds') || !Array.isArray(goal.targetIds) || goal.targetIds.length < 1 || goal.targetIds.length > 512 || goal.targetIds.some(id => !Number.isSafeInteger(id) || !ids.has(id)) || new Set(goal.targetIds).size !== goal.targetIds.length)
68
+ throw new StoneChainsOptionsError('invalid-target-ids', 'Target goal requires unique IDs from the starting board');
69
+ return Object.freeze({ kind: 'clear-targets', targetIds: Object.freeze([...goal.targetIds].sort((a, b) => a - b)) });
70
+ }
71
+ if (goal.kind === 'minimum-chain') {
72
+ if (Object.keys(goal).some(key => key !== 'kind' && key !== 'chain') || !Number.isInteger(goal.chain) || goal.chain < 1 || goal.chain > 12)
73
+ throw new StoneChainsOptionsError('invalid-chain-goal', 'Minimum chain must be 1–12');
74
+ return Object.freeze({ kind: 'minimum-chain', chain: goal.chain });
75
+ }
76
+ if (goal.kind === 'empty-board' && Object.keys(goal).length === 1)
77
+ return Object.freeze({ kind: 'empty-board' });
78
+ throw new StoneChainsOptionsError('invalid-goal', 'Unsupported challenge goal');
79
+ }
80
+ export { COLOURS, PRESETS, validateSize };
@@ -0,0 +1,8 @@
1
+ export * from './colour-chains/types.js';
2
+ export { StoneChainsOptionsError } from './colour-chains/validation.js';
3
+ export { createGame, applyAction, advanceTicks, legalActions, statusOf, landingCells, powerDropPreview } from './colour-chains/engine.js';
4
+ export { createChallenge } from './colour-chains/challenge.js';
5
+ export { restartGame, encodeGame, decodeGame } from './colour-chains/persistence.js';
6
+ export { createInputScheduler, setHeldInput, queueInputEdge, releaseAllInput, actionsForTick, applyScheduledActions, processInputTick } from './colour-chains/input.js';
7
+ export { campaignManifest, levelManifest, tutorialManifest, getLevel, createLevel, getTutorial } from './colour-chains/content.js';
8
+ export type { LocalizedText, ChainDifficultyMetrics, ChainCampaignLevel, ChainCampaignManifest, ChainTutorialStep, ChainTutorialDefinition, ChainContentData } from './colour-chains/content-types.js';
@@ -0,0 +1,7 @@
1
+ export * from './colour-chains/types.js';
2
+ export { StoneChainsOptionsError } from './colour-chains/validation.js';
3
+ export { createGame, applyAction, advanceTicks, legalActions, statusOf, landingCells, powerDropPreview } from './colour-chains/engine.js';
4
+ export { createChallenge } from './colour-chains/challenge.js';
5
+ export { restartGame, encodeGame, decodeGame } from './colour-chains/persistence.js';
6
+ export { createInputScheduler, setHeldInput, queueInputEdge, releaseAllInput, actionsForTick, applyScheduledActions, processInputTick } from './colour-chains/input.js';
7
+ export { campaignManifest, levelManifest, tutorialManifest, getLevel, createLevel, getTutorial } from './colour-chains/content.js';