@johnmorrisdotca/houseki 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +79 -2
- package/dist/colour-chains/challenge.d.ts +3 -0
- package/dist/colour-chains/challenge.js +120 -0
- package/dist/colour-chains/content-data.d.ts +2 -0
- package/dist/colour-chains/content-data.js +9534 -0
- package/dist/colour-chains/content-types.d.ts +80 -0
- package/dist/colour-chains/content-types.js +1 -0
- package/dist/colour-chains/content.d.ts +11 -0
- package/dist/colour-chains/content.js +28 -0
- package/dist/colour-chains/engine.d.ts +19 -0
- package/dist/colour-chains/engine.js +493 -0
- package/dist/colour-chains/input.d.ts +24 -0
- package/dist/colour-chains/input.js +77 -0
- package/dist/colour-chains/match.d.ts +5 -0
- package/dist/colour-chains/match.js +48 -0
- package/dist/colour-chains/persistence.d.ts +7 -0
- package/dist/colour-chains/persistence.js +96 -0
- package/dist/colour-chains/random.d.ts +7 -0
- package/dist/colour-chains/random.js +49 -0
- package/dist/colour-chains/types.d.ts +191 -0
- package/dist/colour-chains/types.js +1 -0
- package/dist/colour-chains/validation.d.ts +16 -0
- package/dist/colour-chains/validation.js +80 -0
- package/dist/colour-chains.d.ts +8 -0
- package/dist/colour-chains.js +7 -0
- package/dist/falling-triplets/content-data.d.ts +7258 -0
- package/dist/falling-triplets/content-data.js +18073 -0
- package/dist/falling-triplets/content-types.d.ts +72 -0
- package/dist/falling-triplets/content-types.js +1 -0
- package/dist/falling-triplets/content.d.ts +11 -0
- package/dist/falling-triplets/content.js +39 -0
- package/dist/falling-triplets/engine.d.ts +26 -0
- package/dist/falling-triplets/engine.js +541 -0
- package/dist/falling-triplets/input.d.ts +18 -0
- package/dist/falling-triplets/input.js +41 -0
- package/dist/falling-triplets/match.d.ts +9 -0
- package/dist/falling-triplets/match.js +45 -0
- package/dist/falling-triplets/random.d.ts +9 -0
- package/dist/falling-triplets/random.js +44 -0
- package/dist/falling-triplets/types.d.ts +134 -0
- package/dist/falling-triplets/types.js +1 -0
- package/dist/falling-triplets.d.ts +7 -0
- package/dist/falling-triplets.js +6 -0
- package/dist/gem-swap/black-hole.d.ts +7 -0
- package/dist/gem-swap/black-hole.js +54 -0
- package/dist/gem-swap/board.d.ts +25 -0
- package/dist/gem-swap/board.js +89 -0
- package/dist/gem-swap/content.d.ts +75 -0
- package/dist/gem-swap/content.js +68 -0
- package/dist/gem-swap/engine.d.ts +25 -0
- package/dist/gem-swap/engine.js +689 -0
- package/dist/gem-swap/persistence.d.ts +17 -0
- package/dist/gem-swap/persistence.js +107 -0
- package/dist/gem-swap/practice.d.ts +4 -0
- package/dist/gem-swap/practice.js +57 -0
- package/dist/gem-swap/random.d.ts +9 -0
- package/dist/gem-swap/random.js +32 -0
- package/dist/gem-swap/specials.d.ts +13 -0
- package/dist/gem-swap/specials.js +300 -0
- package/dist/gem-swap/tools.d.ts +3 -0
- package/dist/gem-swap/tools.js +20 -0
- package/dist/gem-swap/types.d.ts +200 -0
- package/dist/gem-swap/types.js +1 -0
- package/dist/gem-swap.d.ts +5 -0
- package/dist/gem-swap.js +3 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/magnetic-blocks/engine.d.ts +11 -0
- package/dist/magnetic-blocks/engine.js +263 -0
- package/dist/magnetic-blocks/persistence.d.ts +7 -0
- package/dist/magnetic-blocks/persistence.js +104 -0
- package/dist/magnetic-blocks/physics.d.ts +30 -0
- package/dist/magnetic-blocks/physics.js +155 -0
- package/dist/magnetic-blocks/random.d.ts +6 -0
- package/dist/magnetic-blocks/random.js +19 -0
- package/dist/magnetic-blocks/types.d.ts +158 -0
- package/dist/magnetic-blocks/types.js +1 -0
- package/dist/magnetic-blocks/validation.d.ts +23 -0
- package/dist/magnetic-blocks/validation.js +121 -0
- package/dist/magnetic-blocks.d.ts +5 -0
- package/dist/magnetic-blocks.js +4 -0
- package/dist/nature/attraction.d.ts +5 -0
- package/dist/nature/attraction.js +136 -0
- package/dist/nature/environment.d.ts +91 -0
- package/dist/nature/environment.js +272 -0
- package/dist/nature/marking.d.ts +3 -0
- package/dist/nature/marking.js +39 -0
- package/dist/nature/random.d.ts +3 -0
- package/dist/nature/random.js +19 -0
- package/dist/nature/rebound.d.ts +5 -0
- package/dist/nature/rebound.js +98 -0
- package/dist/nature/state.d.ts +3 -0
- package/dist/nature/state.js +22 -0
- package/dist/nature/types.d.ts +124 -0
- package/dist/nature/types.js +1 -0
- package/dist/nature/validation.d.ts +10 -0
- package/dist/nature/validation.js +117 -0
- package/dist/nature.d.ts +8 -0
- package/dist/nature.js +7 -0
- package/dist/stone-collapse/content-data.d.ts +14218 -0
- package/dist/stone-collapse/content-data.js +25217 -0
- package/dist/stone-collapse/content-types.d.ts +77 -0
- package/dist/stone-collapse/content-types.js +1 -0
- package/dist/stone-collapse/content.d.ts +11 -0
- package/dist/stone-collapse/content.js +28 -0
- package/dist/stone-collapse/engine.d.ts +19 -0
- package/dist/stone-collapse/engine.js +570 -0
- package/dist/stone-collapse/persistence.d.ts +7 -0
- package/dist/stone-collapse/persistence.js +230 -0
- package/dist/stone-collapse/types.d.ts +216 -0
- package/dist/stone-collapse/types.js +1 -0
- package/dist/stone-collapse/validation.d.ts +21 -0
- package/dist/stone-collapse/validation.js +135 -0
- package/dist/stone-collapse.d.ts +6 -0
- package/dist/stone-collapse.js +4 -0
- package/package.json +96 -4
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Finds the union of every maximal horizontal, vertical, and diagonal run of at least three. */
|
|
2
|
+
export function findMatches(board, width, height) {
|
|
3
|
+
const directions = [[1, 0], [0, 1], [1, 1], [1, -1]];
|
|
4
|
+
const found = new Map();
|
|
5
|
+
for (let y = 0; y < height; y++)
|
|
6
|
+
for (let x = 0; x < width; x++) {
|
|
7
|
+
const index = y * width + x;
|
|
8
|
+
const gem = board[index];
|
|
9
|
+
if (!gem)
|
|
10
|
+
continue;
|
|
11
|
+
for (const [dx, dy] of directions) {
|
|
12
|
+
const px = x - dx, py = y - dy;
|
|
13
|
+
if (px >= 0 && px < width && py >= 0 && py < height && board[py * width + px]?.colour === gem.colour)
|
|
14
|
+
continue;
|
|
15
|
+
const run = [];
|
|
16
|
+
let cx = x, cy = y;
|
|
17
|
+
while (cx >= 0 && cx < width && cy >= 0 && cy < height && board[cy * width + cx]?.colour === gem.colour) {
|
|
18
|
+
run.push(cy * width + cx);
|
|
19
|
+
cx += dx;
|
|
20
|
+
cy += dy;
|
|
21
|
+
}
|
|
22
|
+
if (run.length >= 3) {
|
|
23
|
+
const cells = found.get(gem.colour) ?? new Set();
|
|
24
|
+
run.forEach(cell => cells.add(cell));
|
|
25
|
+
found.set(gem.colour, cells);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return [...found].map(([colour, cells]) => ({ colour, cells: [...cells].sort((a, b) => a - b) }));
|
|
30
|
+
}
|
|
31
|
+
/** Compacts each column downward while preserving the top-to-bottom order of survivors. */
|
|
32
|
+
export function compactBoard(board, width, height) {
|
|
33
|
+
const result = Array(width * height).fill(null);
|
|
34
|
+
for (let x = 0; x < width; x++) {
|
|
35
|
+
const stack = [];
|
|
36
|
+
for (let y = 0; y < height; y++) {
|
|
37
|
+
const gem = board[y * width + x];
|
|
38
|
+
if (gem)
|
|
39
|
+
stack.push(gem);
|
|
40
|
+
}
|
|
41
|
+
const offset = height - stack.length;
|
|
42
|
+
stack.forEach((gem, i) => { result[(offset + i) * width + x] = gem; });
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Colour } from './types.js';
|
|
2
|
+
/** Hashes a string seed with UTF-8 FNV-1a and returns a nonzero uint32 state. */
|
|
3
|
+
export declare function seedState(seed: string): number;
|
|
4
|
+
/** Advances the reference xorshift32 source. */
|
|
5
|
+
export declare function nextUint32(state: number): readonly [number, number];
|
|
6
|
+
/** Draws an unbiased integer and updated state using rejection sampling. */
|
|
7
|
+
export declare function nextInt(state: number, n: number): readonly [number, number];
|
|
8
|
+
/** Creates a shuffled bag with three of each available colour. */
|
|
9
|
+
export declare function drawBag(bag: readonly Colour[], state: number, count: 4 | 5 | 6): readonly [readonly Colour[], number];
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const COLOURS = ['red', 'blue', 'green', 'gold', 'purple', 'teal'];
|
|
2
|
+
/** Hashes a string seed with UTF-8 FNV-1a and returns a nonzero uint32 state. */
|
|
3
|
+
export function seedState(seed) {
|
|
4
|
+
let hash = 0x811c9dc5;
|
|
5
|
+
for (const byte of new TextEncoder().encode(seed))
|
|
6
|
+
hash = Math.imul(hash ^ byte, 0x01000193) >>> 0;
|
|
7
|
+
return hash || 0x6d2b79f5;
|
|
8
|
+
}
|
|
9
|
+
/** Advances the 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
|
+
next >>>= 0;
|
|
16
|
+
return [next, next];
|
|
17
|
+
}
|
|
18
|
+
/** Draws an unbiased integer and updated state using rejection sampling. */
|
|
19
|
+
export function nextInt(state, n) {
|
|
20
|
+
if (!Number.isInteger(n) || n < 1 || n > 0x100000000)
|
|
21
|
+
throw new RangeError('n must be an integer from 1 to 2^32');
|
|
22
|
+
const limit = Math.floor(0x100000000 / n) * n;
|
|
23
|
+
let current = state >>> 0;
|
|
24
|
+
for (;;) {
|
|
25
|
+
const [value, next] = nextUint32(current);
|
|
26
|
+
current = next;
|
|
27
|
+
if (value < limit)
|
|
28
|
+
return [value % n, current];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Creates a shuffled bag with three of each available colour. */
|
|
32
|
+
export function drawBag(bag, state, count) {
|
|
33
|
+
if (bag.length)
|
|
34
|
+
return [bag, state];
|
|
35
|
+
const values = COLOURS.slice(0, count);
|
|
36
|
+
const nextBag = [...values, ...values, ...values];
|
|
37
|
+
let current = state;
|
|
38
|
+
for (let i = nextBag.length - 1; i > 0; i--) {
|
|
39
|
+
const [j, next] = nextInt(current, i + 1);
|
|
40
|
+
current = next;
|
|
41
|
+
[nextBag[i], nextBag[j]] = [nextBag[j], nextBag[i]];
|
|
42
|
+
}
|
|
43
|
+
return [nextBag, current];
|
|
44
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/** A gem colour ID. */
|
|
2
|
+
export type Colour = 'red' | 'blue' | 'green' | 'gold' | 'purple' | 'teal';
|
|
3
|
+
/** A settled or active gem with a stable run-local identity. */
|
|
4
|
+
export interface Gem {
|
|
5
|
+
readonly id: number;
|
|
6
|
+
readonly colour: Colour;
|
|
7
|
+
readonly target?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export type Mode = 'relaxed' | 'arcade' | 'daily' | 'challenge';
|
|
10
|
+
export type Phase = 'falling' | 'clear-mark' | 'clear-remove' | 'gravity' | 'paused' | 'won' | 'lost' | 'finished';
|
|
11
|
+
export interface Piece {
|
|
12
|
+
readonly gems: readonly [Gem, Gem, Gem];
|
|
13
|
+
readonly x: number;
|
|
14
|
+
readonly y: number;
|
|
15
|
+
readonly orientation: 0 | 1 | 2;
|
|
16
|
+
readonly level: number;
|
|
17
|
+
readonly descent: number;
|
|
18
|
+
readonly lockTicks: number;
|
|
19
|
+
readonly lockResets: number;
|
|
20
|
+
readonly lockStarted: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface Settings {
|
|
23
|
+
readonly mode: Mode;
|
|
24
|
+
readonly width: number;
|
|
25
|
+
readonly height: number;
|
|
26
|
+
readonly colourCount: 4 | 5 | 6;
|
|
27
|
+
readonly seed: string;
|
|
28
|
+
readonly pieceLimit?: number;
|
|
29
|
+
readonly goal?: 'targets' | 'chain' | 'empty';
|
|
30
|
+
readonly targetIds?: readonly number[];
|
|
31
|
+
readonly minimumChain?: number;
|
|
32
|
+
readonly challengeBoard?: readonly (Gem | null)[];
|
|
33
|
+
readonly challengeQueue?: readonly Triplet[];
|
|
34
|
+
readonly witness?: readonly ChallengeHint[];
|
|
35
|
+
}
|
|
36
|
+
export type Triplet = readonly [Colour, Colour, Colour];
|
|
37
|
+
export interface ChallengeGoalTargets {
|
|
38
|
+
readonly type: 'targets';
|
|
39
|
+
readonly targetIds: readonly number[];
|
|
40
|
+
}
|
|
41
|
+
export interface ChallengeGoalChain {
|
|
42
|
+
readonly type: 'chain';
|
|
43
|
+
readonly minimumChain: number;
|
|
44
|
+
}
|
|
45
|
+
export interface ChallengeGoalEmpty {
|
|
46
|
+
readonly type: 'empty';
|
|
47
|
+
}
|
|
48
|
+
export type ChallengeGoal = ChallengeGoalTargets | ChallengeGoalChain | ChallengeGoalEmpty;
|
|
49
|
+
export interface ChallengeHint {
|
|
50
|
+
readonly x: number;
|
|
51
|
+
readonly orientation: 0 | 1 | 2;
|
|
52
|
+
}
|
|
53
|
+
export interface ChallengeSettings extends Settings {
|
|
54
|
+
readonly mode: 'challenge';
|
|
55
|
+
readonly goal: ChallengeGoal['type'];
|
|
56
|
+
readonly challengeBoard: readonly (Gem | null)[];
|
|
57
|
+
readonly challengeQueue: readonly Triplet[];
|
|
58
|
+
}
|
|
59
|
+
export interface Wave {
|
|
60
|
+
readonly clearedIds: readonly number[];
|
|
61
|
+
readonly cells: readonly number[];
|
|
62
|
+
readonly chain: number;
|
|
63
|
+
readonly points: number;
|
|
64
|
+
}
|
|
65
|
+
export interface GameState {
|
|
66
|
+
readonly game: 'falling-triplets';
|
|
67
|
+
readonly rules: 'triplets-1';
|
|
68
|
+
readonly settings: Settings;
|
|
69
|
+
readonly board: readonly (Gem | null)[];
|
|
70
|
+
readonly phase: Phase;
|
|
71
|
+
readonly pausedPhase?: Phase;
|
|
72
|
+
readonly active: Piece | null;
|
|
73
|
+
readonly next: readonly Triplet[];
|
|
74
|
+
readonly bag: readonly Colour[];
|
|
75
|
+
readonly randomState: number;
|
|
76
|
+
readonly nextId: number;
|
|
77
|
+
readonly score: number;
|
|
78
|
+
readonly maxChain: number;
|
|
79
|
+
readonly completedPieces: number;
|
|
80
|
+
readonly elapsedTicks: number;
|
|
81
|
+
readonly pieceTicks: number;
|
|
82
|
+
readonly resolutionTick: number;
|
|
83
|
+
readonly waves: readonly Wave[];
|
|
84
|
+
readonly assisted: boolean;
|
|
85
|
+
readonly hintsUsed: number;
|
|
86
|
+
readonly witnessPathMatches: boolean;
|
|
87
|
+
readonly challengeIndex?: number;
|
|
88
|
+
readonly pendingClear?: readonly number[];
|
|
89
|
+
readonly gravityBoard?: readonly (Gem | null)[];
|
|
90
|
+
readonly resolutionChain?: number;
|
|
91
|
+
readonly resolutionLevel?: number;
|
|
92
|
+
readonly recording: readonly RecordedAction[];
|
|
93
|
+
readonly reason?: string;
|
|
94
|
+
}
|
|
95
|
+
export type Action = {
|
|
96
|
+
readonly kind: 'left' | 'right' | 'soft-drop' | 'hard-drop' | 'place' | 'cycle-forward' | 'cycle-backward' | 'pause' | 'resume' | 'hint' | 'tick';
|
|
97
|
+
};
|
|
98
|
+
export interface Transition {
|
|
99
|
+
readonly state: GameState;
|
|
100
|
+
readonly events: readonly GameEvent[];
|
|
101
|
+
readonly accepted: boolean;
|
|
102
|
+
readonly reason?: string;
|
|
103
|
+
}
|
|
104
|
+
/** One accepted canonical action and its logical tick position. */
|
|
105
|
+
export interface RecordedAction {
|
|
106
|
+
readonly tick: number;
|
|
107
|
+
readonly ordinal: number;
|
|
108
|
+
readonly action: Exclude<Action, {
|
|
109
|
+
readonly kind: 'tick';
|
|
110
|
+
}>;
|
|
111
|
+
}
|
|
112
|
+
export interface GameEvent {
|
|
113
|
+
readonly type: string;
|
|
114
|
+
readonly [key: string]: unknown;
|
|
115
|
+
}
|
|
116
|
+
export interface CreateOptions {
|
|
117
|
+
readonly mode?: Exclude<Mode, 'challenge'>;
|
|
118
|
+
readonly seed?: string;
|
|
119
|
+
readonly preset?: 'compact' | 'narrow' | 'standard' | 'wide' | 'extraWide' | 'tall' | 'deep' | 'large';
|
|
120
|
+
readonly width?: number;
|
|
121
|
+
readonly height?: number;
|
|
122
|
+
readonly colourCount?: 4 | 5 | 6;
|
|
123
|
+
readonly pieceLimit?: number;
|
|
124
|
+
}
|
|
125
|
+
export interface ChallengeOptions {
|
|
126
|
+
readonly seed?: string;
|
|
127
|
+
readonly width?: number;
|
|
128
|
+
readonly height?: number;
|
|
129
|
+
readonly colourCount?: 4 | 5 | 6;
|
|
130
|
+
readonly board: readonly (Gem | null)[];
|
|
131
|
+
readonly queue: readonly Triplet[];
|
|
132
|
+
readonly goal: ChallengeGoal;
|
|
133
|
+
readonly witness?: readonly ChallengeHint[];
|
|
134
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from './falling-triplets/types.js';
|
|
2
|
+
export { findMatches, compactBoard } from './falling-triplets/match.js';
|
|
3
|
+
export { nextUint32, nextInt, seedState } from './falling-triplets/random.js';
|
|
4
|
+
export { createInputScheduler, setHeldAction, queueInputEdge, actionsForTick, releaseAllActions } from './falling-triplets/input.js';
|
|
5
|
+
export { createGame, createChallenge, restartGame, applyAction, advanceTicks, legalActions, statusOf, encodeGame, decodeGame, landingY } from './falling-triplets/engine.js';
|
|
6
|
+
export { campaignManifest, levelManifest, tutorialManifest, getLevel, createLevel, getTutorial } from './falling-triplets/content.js';
|
|
7
|
+
export type { CampaignLevel, CampaignManifest, DifficultyMetrics, LocalizedText, TutorialDefinition, TutorialStep } from './falling-triplets/content-types.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export * from './falling-triplets/types.js';
|
|
2
|
+
export { findMatches, compactBoard } from './falling-triplets/match.js';
|
|
3
|
+
export { nextUint32, nextInt, seedState } from './falling-triplets/random.js';
|
|
4
|
+
export { createInputScheduler, setHeldAction, queueInputEdge, actionsForTick, releaseAllActions } from './falling-triplets/input.js';
|
|
5
|
+
export { createGame, createChallenge, restartGame, applyAction, advanceTicks, legalActions, statusOf, encodeGame, decodeGame, landingY } from './falling-triplets/engine.js';
|
|
6
|
+
export { campaignManifest, levelManifest, tutorialManifest, getLevel, createLevel, getTutorial } from './falling-triplets/content.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { BlackHolePortal, GameState } from './types.js';
|
|
2
|
+
/** Lists the placement gem and first edge-contact wave in deterministic cell order. */
|
|
3
|
+
export declare function placementCells(state: GameState, target: number): readonly number[];
|
|
4
|
+
/** Finds the next bounded orthogonal contact wave without activating swallowed powers. */
|
|
5
|
+
export declare function contactCells(state: GameState, portal: BlackHolePortal): readonly number[];
|
|
6
|
+
/** Accounts for one portal contact wave and closes the portal exactly at zero capacity. */
|
|
7
|
+
export declare function consumePortal(state: GameState, cells: readonly number[]): BlackHolePortal | null;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { neighbors } from './board.js';
|
|
2
|
+
/** Lists the placement gem and first edge-contact wave in deterministic cell order. */
|
|
3
|
+
export function placementCells(state, target) {
|
|
4
|
+
if (!Number.isInteger(target) || target < 0 || target >= state.board.length || !state.settings.mask[target] || !state.board[target])
|
|
5
|
+
return Object.freeze([]);
|
|
6
|
+
const cells = [target];
|
|
7
|
+
const ids = new Set([state.board[target].id]);
|
|
8
|
+
for (const cell of neighbors(target, state.settings.width, state.settings.height, state.settings.mask).sort((a, b) => a - b)) {
|
|
9
|
+
const id = state.board[cell]?.id;
|
|
10
|
+
if (id === undefined || ids.has(id))
|
|
11
|
+
continue;
|
|
12
|
+
ids.add(id);
|
|
13
|
+
cells.push(cell);
|
|
14
|
+
if (cells.length === 8)
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
return Object.freeze(cells.sort((a, b) => a - b));
|
|
18
|
+
}
|
|
19
|
+
/** Finds the next bounded orthogonal contact wave without activating swallowed powers. */
|
|
20
|
+
export function contactCells(state, portal) {
|
|
21
|
+
if (portal.capacityRemaining <= 0)
|
|
22
|
+
return Object.freeze([]);
|
|
23
|
+
const consumed = new Set(portal.consumedIds);
|
|
24
|
+
const candidates = neighbors(portal.cell, state.settings.width, state.settings.height, state.settings.mask).sort((a, b) => a - b);
|
|
25
|
+
const cells = [];
|
|
26
|
+
for (const cell of candidates) {
|
|
27
|
+
const id = state.board[cell]?.id;
|
|
28
|
+
if (id === undefined || consumed.has(id))
|
|
29
|
+
continue;
|
|
30
|
+
consumed.add(id);
|
|
31
|
+
cells.push(cell);
|
|
32
|
+
if (cells.length === portal.capacityRemaining)
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
return Object.freeze(cells);
|
|
36
|
+
}
|
|
37
|
+
/** Accounts for one portal contact wave and closes the portal exactly at zero capacity. */
|
|
38
|
+
export function consumePortal(state, cells) {
|
|
39
|
+
const portal = state.blackHole;
|
|
40
|
+
if (!portal)
|
|
41
|
+
return null;
|
|
42
|
+
const seen = new Set(portal.consumedIds);
|
|
43
|
+
const ids = cells.flatMap(cell => {
|
|
44
|
+
const id = state.board[cell]?.id;
|
|
45
|
+
if (id === undefined || seen.has(id))
|
|
46
|
+
return [];
|
|
47
|
+
seen.add(id);
|
|
48
|
+
return [id];
|
|
49
|
+
});
|
|
50
|
+
const capacityRemaining = Math.max(0, portal.capacityRemaining - ids.length);
|
|
51
|
+
if (!capacityRemaining)
|
|
52
|
+
return null;
|
|
53
|
+
return Object.freeze({ ...portal, capacityRemaining, consumedIds: Object.freeze([...portal.consumedIds, ...ids]) });
|
|
54
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Gem, GemColour, GameState, Settings } from './types.js';
|
|
2
|
+
export declare const COLOURS: readonly GemColour[];
|
|
3
|
+
export declare const PRESETS: {
|
|
4
|
+
readonly compact: readonly [6, 6];
|
|
5
|
+
readonly standard: readonly [8, 8];
|
|
6
|
+
readonly wide: readonly [10, 6];
|
|
7
|
+
readonly tall: readonly [6, 10];
|
|
8
|
+
readonly extraWide: readonly [16, 10];
|
|
9
|
+
readonly deep: readonly [8, 32];
|
|
10
|
+
readonly large: readonly [12, 32];
|
|
11
|
+
};
|
|
12
|
+
/** Returns active orthogonal neighbours in row-major order. */
|
|
13
|
+
export declare function neighbors(index: number, width: number, height: number, mask: readonly boolean[]): number[];
|
|
14
|
+
/** Finds every cell participating in a horizontal or vertical run of at least three. */
|
|
15
|
+
export declare function findMatches(board: readonly (Gem | null)[], settings: Settings): readonly number[];
|
|
16
|
+
/** Tests an adjacent swap without changing either the board or random stream. */
|
|
17
|
+
export declare function matchesAfterSwap(state: GameState, from: number, to: number): readonly number[];
|
|
18
|
+
/** Enumerates legal normal-gem swaps in stable source/destination order. */
|
|
19
|
+
export declare function listLegalSwaps(state: GameState): readonly {
|
|
20
|
+
readonly kind: 'swap';
|
|
21
|
+
readonly from: number;
|
|
22
|
+
readonly to: number;
|
|
23
|
+
}[];
|
|
24
|
+
/** Checks whether a partially filled row-major board already contains a run. */
|
|
25
|
+
export declare function hasMatch(board: readonly (Gem | null)[], settings: Settings): boolean;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export const COLOURS = ['red', 'blue', 'green', 'gold', 'purple', 'teal'];
|
|
2
|
+
export const PRESETS = { compact: [6, 6], standard: [8, 8], wide: [10, 6], tall: [6, 10], extraWide: [16, 10], deep: [8, 32], large: [12, 32] };
|
|
3
|
+
/** Returns active orthogonal neighbours in row-major order. */
|
|
4
|
+
export function neighbors(index, width, height, mask) {
|
|
5
|
+
const x = index % width;
|
|
6
|
+
const y = Math.floor(index / width);
|
|
7
|
+
const out = [];
|
|
8
|
+
if (x > 0 && mask[index - 1])
|
|
9
|
+
out.push(index - 1);
|
|
10
|
+
if (x + 1 < width && mask[index + 1])
|
|
11
|
+
out.push(index + 1);
|
|
12
|
+
if (y > 0 && mask[index - width])
|
|
13
|
+
out.push(index - width);
|
|
14
|
+
if (y + 1 < height && mask[index + width])
|
|
15
|
+
out.push(index + width);
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
/** Finds every cell participating in a horizontal or vertical run of at least three. */
|
|
19
|
+
export function findMatches(board, settings) {
|
|
20
|
+
const found = new Set();
|
|
21
|
+
const { width, height, mask } = settings;
|
|
22
|
+
const scan = (cells) => {
|
|
23
|
+
let start = 0;
|
|
24
|
+
while (start < cells.length) {
|
|
25
|
+
const first = board[cells[start]];
|
|
26
|
+
if (!first) {
|
|
27
|
+
start++;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
let end = start + 1;
|
|
31
|
+
while (end < cells.length && board[cells[end]]?.colour === first.colour)
|
|
32
|
+
end++;
|
|
33
|
+
if (end - start >= 3)
|
|
34
|
+
for (let i = start; i < end; i++)
|
|
35
|
+
found.add(cells[i]);
|
|
36
|
+
start = end;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
for (let y = 0; y < height; y++) {
|
|
40
|
+
let segment = [];
|
|
41
|
+
for (let x = 0; x <= width; x++) {
|
|
42
|
+
const index = y * width + x;
|
|
43
|
+
if (x < width && mask[index])
|
|
44
|
+
segment.push(index);
|
|
45
|
+
else {
|
|
46
|
+
scan(segment);
|
|
47
|
+
segment = [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (let x = 0; x < width; x++) {
|
|
52
|
+
let segment = [];
|
|
53
|
+
for (let y = 0; y <= height; y++) {
|
|
54
|
+
const index = y * width + x;
|
|
55
|
+
if (y < height && mask[index])
|
|
56
|
+
segment.push(index);
|
|
57
|
+
else {
|
|
58
|
+
scan(segment);
|
|
59
|
+
segment = [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return [...found].sort((a, b) => a - b);
|
|
64
|
+
}
|
|
65
|
+
/** Tests an adjacent swap without changing either the board or random stream. */
|
|
66
|
+
export function matchesAfterSwap(state, from, to) {
|
|
67
|
+
const board = [...state.board];
|
|
68
|
+
[board[from], board[to]] = [board[to], board[from]];
|
|
69
|
+
return findMatches(board, state.settings);
|
|
70
|
+
}
|
|
71
|
+
/** Enumerates legal normal-gem swaps in stable source/destination order. */
|
|
72
|
+
export function listLegalSwaps(state) {
|
|
73
|
+
if (state.phase !== 'ready' || findMatches(state.board, state.settings).length)
|
|
74
|
+
return [];
|
|
75
|
+
const moves = [];
|
|
76
|
+
for (let from = 0; from < state.board.length; from++) {
|
|
77
|
+
if (!state.board[from])
|
|
78
|
+
continue;
|
|
79
|
+
for (const to of neighbors(from, state.settings.width, state.settings.height, state.settings.mask)) {
|
|
80
|
+
if (to <= from || !state.board[to] || state.board[from].colour === state.board[to].colour)
|
|
81
|
+
continue;
|
|
82
|
+
if (matchesAfterSwap(state, from, to).length)
|
|
83
|
+
moves.push({ kind: 'swap', from, to });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return moves;
|
|
87
|
+
}
|
|
88
|
+
/** Checks whether a partially filled row-major board already contains a run. */
|
|
89
|
+
export function hasMatch(board, settings) { return findMatches(board, settings).length > 0; }
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Action, CreateOptions, ReplayOperation } from './types.js';
|
|
2
|
+
export type GemSwapReviewStatus = 'human-review-pending';
|
|
3
|
+
export interface GemSwapCampaignLevel {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly number: number;
|
|
6
|
+
readonly title: {
|
|
7
|
+
readonly en: string;
|
|
8
|
+
readonly ja: string;
|
|
9
|
+
};
|
|
10
|
+
readonly options: CreateOptions;
|
|
11
|
+
readonly width: number;
|
|
12
|
+
readonly height: number;
|
|
13
|
+
readonly colourCount: number;
|
|
14
|
+
readonly seed: number;
|
|
15
|
+
readonly challenge: NonNullable<CreateOptions['challenge']>;
|
|
16
|
+
readonly witness: readonly ReplayOperation[];
|
|
17
|
+
readonly canonicalKeyHash: string;
|
|
18
|
+
readonly tags: readonly string[];
|
|
19
|
+
readonly rawMetrics: Readonly<Record<string, number>>;
|
|
20
|
+
readonly difficultyScore: number;
|
|
21
|
+
readonly difficultyMarks: number;
|
|
22
|
+
readonly gradingVersion: string;
|
|
23
|
+
readonly proofStatus: 'engine-witness-verified';
|
|
24
|
+
readonly reviewStatus: GemSwapReviewStatus;
|
|
25
|
+
}
|
|
26
|
+
export type GemSwapLessonStep = {
|
|
27
|
+
readonly kind: 'action';
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly action: Action;
|
|
30
|
+
readonly accepted: boolean;
|
|
31
|
+
readonly reason?: string;
|
|
32
|
+
readonly event?: string;
|
|
33
|
+
readonly cell?: number;
|
|
34
|
+
readonly text: {
|
|
35
|
+
readonly en: string;
|
|
36
|
+
readonly ja: string;
|
|
37
|
+
};
|
|
38
|
+
} | {
|
|
39
|
+
readonly kind: 'ticks';
|
|
40
|
+
readonly id: string;
|
|
41
|
+
readonly count: number;
|
|
42
|
+
readonly event?: string;
|
|
43
|
+
readonly outcome?: string;
|
|
44
|
+
readonly cell?: number;
|
|
45
|
+
readonly text: {
|
|
46
|
+
readonly en: string;
|
|
47
|
+
readonly ja: string;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
export interface GemSwapLesson {
|
|
51
|
+
readonly id: string;
|
|
52
|
+
readonly title: {
|
|
53
|
+
readonly en: string;
|
|
54
|
+
readonly ja: string;
|
|
55
|
+
};
|
|
56
|
+
readonly initial: CreateOptions;
|
|
57
|
+
readonly steps: readonly GemSwapLessonStep[];
|
|
58
|
+
readonly witness: readonly ReplayOperation[];
|
|
59
|
+
readonly reviewStatus: GemSwapReviewStatus;
|
|
60
|
+
}
|
|
61
|
+
export declare const GENERATION_REVISION = "gem-swap-campaign-1.3.0";
|
|
62
|
+
export declare const GRADING_VERSION = "swap-choice-forgiveness-1";
|
|
63
|
+
export declare const SAMPLE_BUDGET = 24;
|
|
64
|
+
export declare const GRADING_WEIGHTS: {
|
|
65
|
+
seededPlayoutDifficulty: number;
|
|
66
|
+
choiceDifficulty: number;
|
|
67
|
+
objectiveCoordination: number;
|
|
68
|
+
chainDepth: number;
|
|
69
|
+
witnessLength: number;
|
|
70
|
+
specialCombination: number;
|
|
71
|
+
};
|
|
72
|
+
export declare const CAMPAIGN_CHECKSUM = "eb7c54a583db428d596b8fbdab0d63a9ad39ad5b9e39948dc4ccab84ba1196aa";
|
|
73
|
+
export declare const CAMPAIGN_COUNT = 50;
|
|
74
|
+
export declare const GEM_SWAP_CAMPAIGN: readonly GemSwapCampaignLevel[];
|
|
75
|
+
export declare const GEM_SWAP_LESSONS: readonly GemSwapLesson[];
|