@johnmorrisdotca/houseki 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +79 -2
- package/dist/colour-chains/challenge.d.ts +3 -0
- package/dist/colour-chains/challenge.js +120 -0
- package/dist/colour-chains/content-data.d.ts +2 -0
- package/dist/colour-chains/content-data.js +9534 -0
- package/dist/colour-chains/content-types.d.ts +80 -0
- package/dist/colour-chains/content-types.js +1 -0
- package/dist/colour-chains/content.d.ts +11 -0
- package/dist/colour-chains/content.js +28 -0
- package/dist/colour-chains/engine.d.ts +19 -0
- package/dist/colour-chains/engine.js +493 -0
- package/dist/colour-chains/input.d.ts +24 -0
- package/dist/colour-chains/input.js +77 -0
- package/dist/colour-chains/match.d.ts +5 -0
- package/dist/colour-chains/match.js +48 -0
- package/dist/colour-chains/persistence.d.ts +7 -0
- package/dist/colour-chains/persistence.js +96 -0
- package/dist/colour-chains/random.d.ts +7 -0
- package/dist/colour-chains/random.js +49 -0
- package/dist/colour-chains/types.d.ts +191 -0
- package/dist/colour-chains/types.js +1 -0
- package/dist/colour-chains/validation.d.ts +16 -0
- package/dist/colour-chains/validation.js +80 -0
- package/dist/colour-chains.d.ts +8 -0
- package/dist/colour-chains.js +7 -0
- package/dist/falling-triplets/content-data.d.ts +7258 -0
- package/dist/falling-triplets/content-data.js +18073 -0
- package/dist/falling-triplets/content-types.d.ts +72 -0
- package/dist/falling-triplets/content-types.js +1 -0
- package/dist/falling-triplets/content.d.ts +11 -0
- package/dist/falling-triplets/content.js +39 -0
- package/dist/falling-triplets/engine.d.ts +26 -0
- package/dist/falling-triplets/engine.js +541 -0
- package/dist/falling-triplets/input.d.ts +18 -0
- package/dist/falling-triplets/input.js +41 -0
- package/dist/falling-triplets/match.d.ts +9 -0
- package/dist/falling-triplets/match.js +45 -0
- package/dist/falling-triplets/random.d.ts +9 -0
- package/dist/falling-triplets/random.js +44 -0
- package/dist/falling-triplets/types.d.ts +134 -0
- package/dist/falling-triplets/types.js +1 -0
- package/dist/falling-triplets.d.ts +7 -0
- package/dist/falling-triplets.js +6 -0
- package/dist/gem-swap/black-hole.d.ts +7 -0
- package/dist/gem-swap/black-hole.js +54 -0
- package/dist/gem-swap/board.d.ts +25 -0
- package/dist/gem-swap/board.js +89 -0
- package/dist/gem-swap/content.d.ts +75 -0
- package/dist/gem-swap/content.js +68 -0
- package/dist/gem-swap/engine.d.ts +25 -0
- package/dist/gem-swap/engine.js +689 -0
- package/dist/gem-swap/persistence.d.ts +17 -0
- package/dist/gem-swap/persistence.js +107 -0
- package/dist/gem-swap/practice.d.ts +4 -0
- package/dist/gem-swap/practice.js +57 -0
- package/dist/gem-swap/random.d.ts +9 -0
- package/dist/gem-swap/random.js +32 -0
- package/dist/gem-swap/specials.d.ts +13 -0
- package/dist/gem-swap/specials.js +300 -0
- package/dist/gem-swap/tools.d.ts +3 -0
- package/dist/gem-swap/tools.js +20 -0
- package/dist/gem-swap/types.d.ts +200 -0
- package/dist/gem-swap/types.js +1 -0
- package/dist/gem-swap.d.ts +5 -0
- package/dist/gem-swap.js +3 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/magnetic-blocks/engine.d.ts +11 -0
- package/dist/magnetic-blocks/engine.js +263 -0
- package/dist/magnetic-blocks/persistence.d.ts +7 -0
- package/dist/magnetic-blocks/persistence.js +104 -0
- package/dist/magnetic-blocks/physics.d.ts +30 -0
- package/dist/magnetic-blocks/physics.js +155 -0
- package/dist/magnetic-blocks/random.d.ts +6 -0
- package/dist/magnetic-blocks/random.js +19 -0
- package/dist/magnetic-blocks/types.d.ts +158 -0
- package/dist/magnetic-blocks/types.js +1 -0
- package/dist/magnetic-blocks/validation.d.ts +23 -0
- package/dist/magnetic-blocks/validation.js +121 -0
- package/dist/magnetic-blocks.d.ts +5 -0
- package/dist/magnetic-blocks.js +4 -0
- package/dist/nature/attraction.d.ts +5 -0
- package/dist/nature/attraction.js +136 -0
- package/dist/nature/environment.d.ts +91 -0
- package/dist/nature/environment.js +272 -0
- package/dist/nature/marking.d.ts +3 -0
- package/dist/nature/marking.js +39 -0
- package/dist/nature/random.d.ts +3 -0
- package/dist/nature/random.js +19 -0
- package/dist/nature/rebound.d.ts +5 -0
- package/dist/nature/rebound.js +98 -0
- package/dist/nature/state.d.ts +3 -0
- package/dist/nature/state.js +22 -0
- package/dist/nature/types.d.ts +124 -0
- package/dist/nature/types.js +1 -0
- package/dist/nature/validation.d.ts +10 -0
- package/dist/nature/validation.js +117 -0
- package/dist/nature.d.ts +8 -0
- package/dist/nature.js +7 -0
- package/dist/stone-collapse/content-data.d.ts +14218 -0
- package/dist/stone-collapse/content-data.js +25217 -0
- package/dist/stone-collapse/content-types.d.ts +77 -0
- package/dist/stone-collapse/content-types.js +1 -0
- package/dist/stone-collapse/content.d.ts +11 -0
- package/dist/stone-collapse/content.js +28 -0
- package/dist/stone-collapse/engine.d.ts +19 -0
- package/dist/stone-collapse/engine.js +570 -0
- package/dist/stone-collapse/persistence.d.ts +7 -0
- package/dist/stone-collapse/persistence.js +230 -0
- package/dist/stone-collapse/types.d.ts +216 -0
- package/dist/stone-collapse/types.js +1 -0
- package/dist/stone-collapse/validation.d.ts +21 -0
- package/dist/stone-collapse/validation.js +135 -0
- package/dist/stone-collapse.d.ts +6 -0
- package/dist/stone-collapse.js +4 -0
- package/package.json +96 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- Add original Falling Triplets, Colour Chains, Stone Collapse and Gem Swap engines with immutable state, seeded play, validated settings and canonical saves.
|
|
6
|
+
- Include 300 graded challenges and three bilingual lessons per game.
|
|
7
|
+
- Provide family-style keyboard, keypad and touch demos, configurable boards, stored tools, goal progress and shared animation preferences.
|
|
8
|
+
- Add optional Black Hole powers, Shizen magnetic stones and bounded rebound, and optional Arashi jumble/lightning schedules.
|
|
9
|
+
- Include an experimental Magnetic Blocks engine with bonded squares, magnetic floor schedules, a Floor Switch and impact drops.
|
|
10
|
+
- Publish typed entry points, a generated API reference, MIT license and reproducible package checks.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 John Morris
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,80 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Houseki · 宝石
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/johnmorrisdotca/houseki/actions/workflows/check.yml) [](LICENSE)
|
|
4
|
+
|
|
5
|
+
Original gem and stone puzzle games with immutable TypeScript rules and a shared English/Japanese player.
|
|
6
|
+
|
|
7
|
+
**Initial release:** four playable games with tested rules, campaigns and demos. Physical-device feel, fluent Japanese copy and human difficulty review remain ongoing. Magnetic Blocks and nature variants are experimental. Website integration is maintained separately.
|
|
8
|
+
|
|
9
|
+
| Game | Distinctive mechanic |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| Falling Triplets | Cycle a falling vertical triplet; clear horizontal, vertical and diagonal lines |
|
|
12
|
+
| Stone Collapse | Select connected groups; plan removal order and column compression |
|
|
13
|
+
| Colour Chains | Rotate falling pairs; build connected groups and cascading chains |
|
|
14
|
+
| Gem Swap | Swap neighbours; create and combine specials to complete objectives |
|
|
15
|
+
|
|
16
|
+
An optional experimental `magnetic-blocks` entry point provides bonded 2×2 squares, magnetic floor schedules, a one-use Floor Switch and impact drops. [Play its demo](https://johnmorrisdotca.github.io/houseki/blocks.html). It has no graded campaign yet. The `nature` entry point exposes deterministic attraction, rebound and environment utilities.
|
|
17
|
+
|
|
18
|
+

|
|
19
|
+
|
|
20
|
+

|
|
21
|
+
|
|
22
|
+

|
|
23
|
+
|
|
24
|
+

|
|
25
|
+
|
|
26
|
+
[API reference](https://johnmorrisdotca.github.io/houseki/api.html) is generated locally by `pnpm site`; the hosted page becomes available after deployment. [Incremental draft PR](https://github.com/johnmorrisdotca/houseki/pull/1).
|
|
27
|
+
|
|
28
|
+
## Design and progression
|
|
29
|
+
|
|
30
|
+
The [design pack](https://github.com/johnmorrisdotca/houseki/blob/main/docs/design/README.md) specifies rules, controls, materials, accessibility, generation, grading, persistence and acceptance checks. [Level generation](https://github.com/johnmorrisdotca/houseki/blob/main/docs/design/LEVEL-GENERATION.md) follows a deliberate complete count and measured progression: generate and prove original boards, remove duplicates, grade player-facing decisions, then sort and number from easy to hard. Stable content IDs preserve saved progress across future ordering changes.
|
|
31
|
+
|
|
32
|
+
The shared player uses the family's ivory, brass and felt palette, permanent colour symbols and configurable boards. Each set of three interactive lessons is separate from the graded campaign. Physical-device feel testing and fluent Japanese copy review are recorded independently from automated checks.
|
|
33
|
+
|
|
34
|
+
## Local development
|
|
35
|
+
|
|
36
|
+
Requires Node.js 22 or newer and pnpm.
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
pnpm install
|
|
40
|
+
pnpm build
|
|
41
|
+
pnpm test
|
|
42
|
+
pnpm demo
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The local demo instructions print its address. The first page plays Falling Triplets; `chains.html` plays Colour Chains; `tools.html` plays Gem Swap and Stone Collapse with optional stored-tool trays. The campaigns contain 100 Falling Triplets, 50 Colour Chains, 100 Stone Collapse and 50 Gem Swap challenges, ordered by measured difficulty. Finite goals show progress and remaining pieces or moves. All players share the persisted default-on Animation setting and respect reduced motion. See [CONTRIBUTING.md](https://github.com/johnmorrisdotca/houseki/blob/main/CONTRIBUTING.md) for contribution conventions and [the implementation plan](https://github.com/johnmorrisdotca/houseki/blob/main/docs/design/IMPLEMENTATION-PLAN.md) for bounded review milestones.
|
|
46
|
+
|
|
47
|
+
## Engine example
|
|
48
|
+
|
|
49
|
+
The currently reviewed Falling Triplets entry point is independent of DOM, storage and network access:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { createGame, applyAction, advanceTicks } from '@johnmorrisdotca/houseki/falling-triplets';
|
|
53
|
+
|
|
54
|
+
let game = createGame({ mode: 'relaxed', seed: 'first-game' });
|
|
55
|
+
game = applyAction(game, { kind: 'cycle-forward' }).state;
|
|
56
|
+
game = applyAction(game, { kind: 'place' }).state;
|
|
57
|
+
game = advanceTicks(game, 22).state;
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The package exposes an entry point for each game. The generated API reference lists every exported function, type and setting.
|
|
61
|
+
|
|
62
|
+
## Licence
|
|
63
|
+
|
|
64
|
+
[MIT](LICENSE), copyright John Morris. Original game content and presentation are maintained in this repository. Shared family files preserve their existing copyright notices. Report vulnerabilities through [SECURITY.md](https://github.com/johnmorrisdotca/houseki/blob/main/SECURITY.md).
|
|
65
|
+
|
|
66
|
+
## Stored tools
|
|
67
|
+
|
|
68
|
+
[Stored-tool rules](https://github.com/johnmorrisdotca/houseki/blob/main/docs/design/STORED-TOOLS.md) distinguish inventory from special gems on the board. Gem Swap offers Bomb, Row clear and Colour clear; Stone Collapse offers Bomb and Pick. Select a tool and an occupied square, inspect the highlighted effect, then confirm or cancel. Ordinary clears earn capped inventory; using tools marks the run assisted. Daily and existing authored challenges keep their declared tool-free rules. Gem Swap also offers an optional earned [Black Hole](https://github.com/johnmorrisdotca/houseki/blob/main/docs/design/BLACK-HOLE.md): consume nearby gems with a visibly shrinking capacity and bounded lifetime. Enable it before starting a casual board.
|
|
69
|
+
|
|
70
|
+
## Package entry points
|
|
71
|
+
|
|
72
|
+
Import a game directly from `@johnmorrisdotca/houseki/falling-triplets`, `/stone-collapse`, `/colour-chains` or `/gem-swap`. The root entry groups the same APIs as `fallingTriplets`, `stoneCollapse`, `colourChains` and `gemSwap` namespaces. Engines do not depend on the DOM or external services.
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { colourChains } from '@johnmorrisdotca/houseki';
|
|
76
|
+
const firstChallenge = colourChains.createLevel(1);
|
|
77
|
+
const objective = firstChallenge.settings.goal;
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Run `npm run check:package` to build, pack and install the actual tarball into a temporary consumer and check every game entry point. `npm run test:browser` verifies desktop and phone player controls, witnessed campaign wins, progress, tool use, persistence of animation preferences and reduced motion.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { applyAction, advanceTicks, initialChallengeState } from './engine.js';
|
|
2
|
+
import { StoneChainsOptionsError, validateChallengeGoal, validateSize } from './validation.js';
|
|
3
|
+
const COLOUR_NAMES = ['red', 'blue', 'green', 'gold', 'purple', 'teal'];
|
|
4
|
+
const ORIENTATIONS = ['up', 'right', 'down', 'left'];
|
|
5
|
+
function stable(value) {
|
|
6
|
+
if (Array.isArray(value))
|
|
7
|
+
return `[${value.map(stable).join(',')}]`;
|
|
8
|
+
if (value && typeof value === 'object')
|
|
9
|
+
return `{${Object.entries(value).filter(([, item]) => item !== undefined).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${stable(item)}`).join(',')}}`;
|
|
10
|
+
return JSON.stringify(value) ?? 'null';
|
|
11
|
+
}
|
|
12
|
+
function solveWitness(start, witness) {
|
|
13
|
+
let state = start;
|
|
14
|
+
let work = 0;
|
|
15
|
+
const act = (kind) => {
|
|
16
|
+
const result = applyAction(state, { kind });
|
|
17
|
+
if (!result.accepted)
|
|
18
|
+
throw new StoneChainsOptionsError('invalid-witness', `Witness action ${kind} was rejected: ${result.reason}`);
|
|
19
|
+
state = result.state;
|
|
20
|
+
if (++work > 100_000)
|
|
21
|
+
throw new StoneChainsOptionsError('witness-work-limit', 'Challenge witness exceeds bounded verification work');
|
|
22
|
+
};
|
|
23
|
+
for (let stepIndex = 0; stepIndex < witness.length; stepIndex++) {
|
|
24
|
+
const step = witness[stepIndex];
|
|
25
|
+
if (state.phase !== 'falling' || !state.active)
|
|
26
|
+
break;
|
|
27
|
+
const rotateToward = (target) => {
|
|
28
|
+
for (let count = 0; state.active.orientation !== target && count < 4; count++)
|
|
29
|
+
act('rotate-clockwise');
|
|
30
|
+
if (state.active.orientation !== target)
|
|
31
|
+
throw new StoneChainsOptionsError('invalid-witness', `Witness step ${stepIndex + 1} cannot reach its orientation`);
|
|
32
|
+
};
|
|
33
|
+
for (let count = 0; state.active.pivot.x !== step.pivotX && count < 12; count++)
|
|
34
|
+
act(state.active.pivot.x < step.pivotX ? 'right' : 'left');
|
|
35
|
+
if (state.active.pivot.x !== step.pivotX)
|
|
36
|
+
throw new StoneChainsOptionsError('invalid-witness', `Witness step ${stepIndex + 1} cannot reach its pivot column`);
|
|
37
|
+
rotateToward(step.orientation);
|
|
38
|
+
for (let count = 0; state.active.pivot.x !== step.pivotX && count < 12; count++)
|
|
39
|
+
act(state.active.pivot.x < step.pivotX ? 'right' : 'left');
|
|
40
|
+
if (state.active.pivot.x !== step.pivotX)
|
|
41
|
+
throw new StoneChainsOptionsError('invalid-witness', `Witness step ${stepIndex + 1} cannot finish at its pivot column`);
|
|
42
|
+
act('hard-drop');
|
|
43
|
+
while (['clear-mark', 'clear-remove', 'gravity'].includes(state.phase) && work < 100_000) {
|
|
44
|
+
const result = advanceTicks(state, 3600);
|
|
45
|
+
state = result.state;
|
|
46
|
+
work += 3600;
|
|
47
|
+
}
|
|
48
|
+
if (state.phase === 'lost' || state.phase === 'finished')
|
|
49
|
+
throw new StoneChainsOptionsError('invalid-witness', `Witness ends before its goal after pair ${stepIndex + 1}`);
|
|
50
|
+
if (state.phase === 'won') {
|
|
51
|
+
if (stepIndex !== witness.length - 1)
|
|
52
|
+
throw new StoneChainsOptionsError('witness-after-goal', 'Witness must stop at the first completed goal');
|
|
53
|
+
return state;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (state.phase !== 'won')
|
|
57
|
+
throw new StoneChainsOptionsError('witness-does-not-win', 'Witness does not complete the challenge goal within its finite queue');
|
|
58
|
+
return state;
|
|
59
|
+
}
|
|
60
|
+
/** Validates and creates a finite witnessed challenge. Hints use only the supplied verified pair placements. */
|
|
61
|
+
export function createChallenge(options) {
|
|
62
|
+
if (!options || typeof options !== 'object' || Array.isArray(options))
|
|
63
|
+
throw new StoneChainsOptionsError('invalid-challenge', 'Challenge options must be an object');
|
|
64
|
+
const allowed = ['id', 'width', 'height', 'colourCount', 'seed', 'board', 'queue', 'goal', 'witness'];
|
|
65
|
+
if (Object.keys(options).some(key => !allowed.includes(key)))
|
|
66
|
+
throw new StoneChainsOptionsError('unknown-challenge-option', 'Challenge options contain unsupported fields');
|
|
67
|
+
if (typeof options.id !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(options.id))
|
|
68
|
+
throw new StoneChainsOptionsError('invalid-challenge-id', 'Challenge ID must be stable lowercase text');
|
|
69
|
+
const width = options.width ?? 6;
|
|
70
|
+
const height = options.height ?? 12;
|
|
71
|
+
validateSize(width, height);
|
|
72
|
+
const colourCount = options.colourCount ?? 4;
|
|
73
|
+
if (colourCount !== 4 && colourCount !== 5 && colourCount !== 6)
|
|
74
|
+
throw new StoneChainsOptionsError('invalid-colour-count', 'Challenge colour count must be 4, 5 or 6');
|
|
75
|
+
const seed = options.seed ?? `challenge:${options.id}`;
|
|
76
|
+
if (typeof seed !== 'string' || seed.length < 1 || seed.length > 200)
|
|
77
|
+
throw new StoneChainsOptionsError('invalid-seed', 'Seed must contain 1–200 characters');
|
|
78
|
+
if (!Array.isArray(options.board) || options.board.length !== width * height)
|
|
79
|
+
throw new StoneChainsOptionsError('invalid-challenge-board', 'Challenge board must contain width × height visible cells');
|
|
80
|
+
if (!Array.isArray(options.queue) || options.queue.length < 2 || options.queue.length > 12)
|
|
81
|
+
throw new StoneChainsOptionsError('invalid-challenge-queue', 'Challenge queue must contain 2–12 pairs');
|
|
82
|
+
const allowedColours = COLOUR_NAMES.slice(0, colourCount);
|
|
83
|
+
const ids = new Set();
|
|
84
|
+
const board = options.board.map((gem, index) => {
|
|
85
|
+
if (gem === null)
|
|
86
|
+
return null;
|
|
87
|
+
if (!gem || typeof gem !== 'object' || Array.isArray(gem) || Object.keys(gem).some(key => key !== 'id' && key !== 'colour') || !Number.isSafeInteger(gem.id) || gem.id < 1 || gem.id > 0x7fff_ffff || ids.has(gem.id) || !allowedColours.includes(gem.colour))
|
|
88
|
+
throw new StoneChainsOptionsError('invalid-challenge-gem', `Invalid challenge gem at cell ${index}`);
|
|
89
|
+
ids.add(gem.id);
|
|
90
|
+
return Object.freeze({ id: gem.id, colour: gem.colour });
|
|
91
|
+
});
|
|
92
|
+
for (let x = 0; x < width; x++) {
|
|
93
|
+
let gap = false;
|
|
94
|
+
for (let y = height - 1; y >= 0; y--) {
|
|
95
|
+
if (board[y * width + x] === null)
|
|
96
|
+
gap = true;
|
|
97
|
+
else if (gap)
|
|
98
|
+
throw new StoneChainsOptionsError('unstable-challenge-board', `Starting column ${x} is not gravity-stable`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const queue = options.queue.map((pair, index) => {
|
|
102
|
+
if (!Array.isArray(pair) || pair.length !== 2 || pair.some(colour => !allowedColours.includes(colour)))
|
|
103
|
+
throw new StoneChainsOptionsError('invalid-challenge-pair', `Invalid queue pair ${index + 1}`);
|
|
104
|
+
return Object.freeze([pair[0], pair[1]]);
|
|
105
|
+
});
|
|
106
|
+
const goal = validateChallengeGoal(options.goal, ids);
|
|
107
|
+
if (!Array.isArray(options.witness) || options.witness.length < 1 || options.witness.length > queue.length)
|
|
108
|
+
throw new StoneChainsOptionsError('invalid-witness', 'A challenge requires a complete witness of 1–12 pair placements');
|
|
109
|
+
const witness = options.witness.map((step, index) => {
|
|
110
|
+
if (!step || typeof step !== 'object' || Array.isArray(step) || Object.keys(step).some(key => key !== 'pivotX' && key !== 'orientation') || !Number.isInteger(step.pivotX) || step.pivotX < 0 || step.pivotX >= width || !ORIENTATIONS.includes(step.orientation))
|
|
111
|
+
throw new StoneChainsOptionsError('invalid-witness-step', `Invalid witness placement ${index + 1}`);
|
|
112
|
+
return Object.freeze({ pivotX: step.pivotX, orientation: step.orientation });
|
|
113
|
+
});
|
|
114
|
+
const settings = Object.freeze({ mode: 'challenge', width, height, colourCount, seed, challengeId: options.id, goal, initialBoard: Object.freeze(board), queue: Object.freeze(queue), witness: Object.freeze(witness) });
|
|
115
|
+
const state = initialChallengeState(settings, settings.initialBoard, settings.queue);
|
|
116
|
+
const solved = solveWitness(state, witness);
|
|
117
|
+
if (solved.phase !== 'won' || stable(solved.settings) !== stable(settings))
|
|
118
|
+
throw new StoneChainsOptionsError('invalid-witness', 'Challenge witness validation failed');
|
|
119
|
+
return state;
|
|
120
|
+
}
|