@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,80 @@
|
|
|
1
|
+
import type { ChallengeGoal, Colour, Gem, Orientation, WitnessStep } from './types.js';
|
|
2
|
+
export interface LocalizedText {
|
|
3
|
+
readonly en: string;
|
|
4
|
+
readonly ja: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ChainDifficultyMetrics {
|
|
7
|
+
readonly placementProbes: number;
|
|
8
|
+
readonly legalPlacements: number;
|
|
9
|
+
readonly goalPreservingPlacements: number;
|
|
10
|
+
readonly forcedPlacementShare: number;
|
|
11
|
+
readonly seededPlayoutSamples: number;
|
|
12
|
+
readonly seededPlayoutQueueDepth: number;
|
|
13
|
+
readonly seededPlayoutSuccesses: number;
|
|
14
|
+
readonly seededPlayoutSuccessRate: number;
|
|
15
|
+
readonly setupPairsBeforePayoff: number;
|
|
16
|
+
readonly requiredSetupPairs: number;
|
|
17
|
+
readonly verifiedSetupDependencies: number;
|
|
18
|
+
readonly requiredChainDepth: number;
|
|
19
|
+
readonly requiredRotations: number;
|
|
20
|
+
readonly requiredWallKicks: number;
|
|
21
|
+
readonly splitLandingDependencies: number;
|
|
22
|
+
/** Number of committed pair placements in the supplied winning plan. */
|
|
23
|
+
readonly planningLength: number;
|
|
24
|
+
}
|
|
25
|
+
export interface ChainCampaignLevel {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly number: number;
|
|
28
|
+
readonly title: LocalizedText;
|
|
29
|
+
readonly canonicalKeyHash: string;
|
|
30
|
+
readonly width: number;
|
|
31
|
+
readonly height: number;
|
|
32
|
+
readonly colourCount: 4 | 5 | 6;
|
|
33
|
+
readonly seed: string;
|
|
34
|
+
readonly board: readonly (Gem | null)[];
|
|
35
|
+
readonly queue: readonly (readonly [Colour, Colour])[];
|
|
36
|
+
readonly goal: ChallengeGoal;
|
|
37
|
+
readonly witness: readonly WitnessStep[];
|
|
38
|
+
readonly tags: readonly string[];
|
|
39
|
+
readonly rawMetrics: ChainDifficultyMetrics;
|
|
40
|
+
readonly score: number;
|
|
41
|
+
readonly marks: 1 | 2 | 3 | 4 | 5;
|
|
42
|
+
readonly gradingVersion: string;
|
|
43
|
+
readonly proofStatus: 'engine-witness-verified';
|
|
44
|
+
readonly reviewStatus: 'human-review-pending' | 'human-reviewed';
|
|
45
|
+
}
|
|
46
|
+
export interface ChainCampaignManifest {
|
|
47
|
+
readonly count: number;
|
|
48
|
+
readonly candidatePoolCount: number;
|
|
49
|
+
readonly generationRevision: string;
|
|
50
|
+
readonly gradingVersion: string;
|
|
51
|
+
readonly category: string;
|
|
52
|
+
readonly curationPolicy: string;
|
|
53
|
+
readonly orderingPolicy: string;
|
|
54
|
+
readonly grading: Readonly<Record<string, number | string>>;
|
|
55
|
+
readonly sampleBudget: number;
|
|
56
|
+
readonly checksum: string;
|
|
57
|
+
readonly levels: readonly ChainCampaignLevel[];
|
|
58
|
+
}
|
|
59
|
+
export interface ChainTutorialStep {
|
|
60
|
+
readonly instruction: LocalizedText;
|
|
61
|
+
readonly action: string;
|
|
62
|
+
}
|
|
63
|
+
export interface ChainTutorialDefinition {
|
|
64
|
+
readonly id: string;
|
|
65
|
+
readonly title: LocalizedText;
|
|
66
|
+
readonly objective: LocalizedText;
|
|
67
|
+
readonly setup: {
|
|
68
|
+
readonly board: readonly (Gem | null)[];
|
|
69
|
+
readonly queue: readonly (readonly [Colour, Colour])[];
|
|
70
|
+
readonly goal: ChallengeGoal;
|
|
71
|
+
readonly witness: readonly WitnessStep[];
|
|
72
|
+
};
|
|
73
|
+
readonly steps: readonly ChainTutorialStep[];
|
|
74
|
+
readonly tags: readonly string[];
|
|
75
|
+
}
|
|
76
|
+
export interface ChainContentData {
|
|
77
|
+
readonly campaign: ChainCampaignManifest;
|
|
78
|
+
readonly tutorials: readonly ChainTutorialDefinition[];
|
|
79
|
+
}
|
|
80
|
+
export type { Colour, Orientation, WitnessStep, ChallengeGoal };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ChainCampaignLevel, ChainCampaignManifest, ChainTutorialDefinition } from './content-types.js';
|
|
2
|
+
import type { GameState } from './types.js';
|
|
3
|
+
export declare const campaignManifest: ChainCampaignManifest;
|
|
4
|
+
export declare const levelManifest: readonly ChainCampaignLevel[];
|
|
5
|
+
export declare const tutorialManifest: readonly ChainTutorialDefinition[];
|
|
6
|
+
/** Find a campaign challenge by stable content ID or its current one-based number. */
|
|
7
|
+
export declare function getLevel(id: string | number): ChainCampaignLevel | undefined;
|
|
8
|
+
/** Create a fresh engine-validated challenge from a stable content ID or display number. */
|
|
9
|
+
export declare function createLevel(id: string | number): GameState;
|
|
10
|
+
/** Find a persistent interactive lesson by its stable ID. */
|
|
11
|
+
export declare function getTutorial(id: string): ChainTutorialDefinition | undefined;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { contentData } from './content-data.js';
|
|
2
|
+
import { createChallenge } from './challenge.js';
|
|
3
|
+
function deepFreeze(value) {
|
|
4
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
5
|
+
Object.freeze(value);
|
|
6
|
+
for (const child of Object.values(value))
|
|
7
|
+
deepFreeze(child);
|
|
8
|
+
}
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
export const campaignManifest = deepFreeze(contentData.campaign);
|
|
12
|
+
export const levelManifest = campaignManifest.levels;
|
|
13
|
+
export const tutorialManifest = deepFreeze(contentData.tutorials);
|
|
14
|
+
/** Find a campaign challenge by stable content ID or its current one-based number. */
|
|
15
|
+
export function getLevel(id) {
|
|
16
|
+
if (typeof id === 'number')
|
|
17
|
+
return Number.isInteger(id) ? levelManifest.find(level => level.number === id) : undefined;
|
|
18
|
+
return levelManifest.find(level => level.id === id);
|
|
19
|
+
}
|
|
20
|
+
/** Create a fresh engine-validated challenge from a stable content ID or display number. */
|
|
21
|
+
export function createLevel(id) {
|
|
22
|
+
const level = getLevel(id);
|
|
23
|
+
if (!level)
|
|
24
|
+
throw new RangeError(`Unknown Colour Chains level: ${String(id)}`);
|
|
25
|
+
return createChallenge({ id: level.id, seed: level.seed, width: level.width, height: level.height, colourCount: level.colourCount, board: level.board, queue: level.queue, goal: level.goal, witness: level.witness });
|
|
26
|
+
}
|
|
27
|
+
/** Find a persistent interactive lesson by its stable ID. */
|
|
28
|
+
export function getTutorial(id) { return tutorialManifest.find(tutorial => tutorial.id === id); }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { PowerDropPreview } from '../nature/types.js';
|
|
2
|
+
import type { Action, Colour, CreateOptions, GameState, GameStatus, Gem, Landing, Settings, Transition } from './types.js';
|
|
3
|
+
export { StoneChainsOptionsError } from './validation.js';
|
|
4
|
+
/** Applies one immutable accepted action and records its logical-tick position. */
|
|
5
|
+
export declare function applyAction(state: GameState, action: Action): Transition;
|
|
6
|
+
/** Advances deterministic 60 Hz simulation and resolution; Relaxed and Challenges do not force a falling clock. */
|
|
7
|
+
export declare function advanceTicks(state: GameState, ticks: number): Transition;
|
|
8
|
+
/** Lists only actions accepted by the current game state. */
|
|
9
|
+
export declare function legalActions(state: GameState): readonly Action[];
|
|
10
|
+
/** Returns the exact independent landing cells for the current rigid pair without consuming random values. */
|
|
11
|
+
export declare function landingCells(state: GameState): Landing | null;
|
|
12
|
+
/** Returns the Shizen hard-drop landing and one-step rebound preview without changing state. */
|
|
13
|
+
export declare function powerDropPreview(state: GameState): PowerDropPreview | null;
|
|
14
|
+
/** Returns score, mode, chain and completion fields derived from state. */
|
|
15
|
+
export declare function statusOf(state: GameState): GameStatus;
|
|
16
|
+
/** Creates a base challenge state with a previously validated visible board and pair queue. @internal */
|
|
17
|
+
export declare function initialChallengeState(settings: Settings, visibleBoard: readonly (Gem | null)[], queue: readonly (readonly [Colour, Colour])[]): GameState;
|
|
18
|
+
/** Creates a deterministic seeded game with one active pair and three previews. */
|
|
19
|
+
export declare function createGame(options?: CreateOptions): GameState;
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import { compactBoard, findGroups } from './match.js';
|
|
2
|
+
import { drawPair, seedState } from './random.js';
|
|
3
|
+
import { applyEarthquake, applyLightning, applyMagneticPulse, createNatureState, isEnvironmentTurnScheduled, markMagneticStones, previewPowerDrop } from '../nature.js';
|
|
4
|
+
import { normalizeOptions } from './validation.js';
|
|
5
|
+
export { StoneChainsOptionsError } from './validation.js';
|
|
6
|
+
const HIDDEN = 3;
|
|
7
|
+
const RULES = 'chains-1';
|
|
8
|
+
const OFFSETS = { up: [0, -1], right: [1, 0], down: [0, 1], left: [-1, 0] };
|
|
9
|
+
const ORIENTATIONS = ['up', 'right', 'down', 'left'];
|
|
10
|
+
const KICKS = [[0, 0], [-1, 0], [1, 0], [0, -1]];
|
|
11
|
+
const indexOf = (x, y, width) => (y + HIDDEN) * width + x;
|
|
12
|
+
const emptyTransition = (state, reason) => ({ state, events: [], accepted: false, reason });
|
|
13
|
+
function cellFor(pair, satellite) {
|
|
14
|
+
const [dx, dy] = OFFSETS[pair.orientation];
|
|
15
|
+
return satellite ? { x: pair.pivot.x + dx, y: pair.pivot.y + dy } : pair.pivot;
|
|
16
|
+
}
|
|
17
|
+
function occupied(state, x, y) {
|
|
18
|
+
return x < 0 || x >= state.settings.width || y < -HIDDEN || y >= state.settings.height || state.board[indexOf(x, y, state.settings.width)] !== null;
|
|
19
|
+
}
|
|
20
|
+
function pairFits(state, pair) {
|
|
21
|
+
const a = cellFor(pair, false);
|
|
22
|
+
const b = cellFor(pair, true);
|
|
23
|
+
return (a.x !== b.x || a.y !== b.y) && !occupied(state, a.x, a.y) && !occupied(state, b.x, b.y);
|
|
24
|
+
}
|
|
25
|
+
function grounded(state, pair) { return !pairFits(state, { ...pair, pivot: { x: pair.pivot.x, y: pair.pivot.y + 1 } }); }
|
|
26
|
+
function rigidLanding(state, initial) {
|
|
27
|
+
let pair = initial;
|
|
28
|
+
while (pairFits(state, { ...pair, pivot: { x: pair.pivot.x, y: pair.pivot.y + 1 } }))
|
|
29
|
+
pair = { ...pair, pivot: { x: pair.pivot.x, y: pair.pivot.y + 1 } };
|
|
30
|
+
return pair;
|
|
31
|
+
}
|
|
32
|
+
function makePair(state, colours, magnets) {
|
|
33
|
+
const level = state.settings.mode === 'arcade' || state.settings.mode === 'daily' ? 1 + Math.floor(state.completedPairs / 30) : 1;
|
|
34
|
+
const gems = [{ id: state.nextId, colour: colours[0] }, { id: state.nextId + 1, colour: colours[1] }];
|
|
35
|
+
if (magnets?.[0])
|
|
36
|
+
gems[0] = { ...gems[0], magnetic: true };
|
|
37
|
+
if (magnets?.[1])
|
|
38
|
+
gems[1] = { ...gems[1], magnetic: true };
|
|
39
|
+
return { pivot: { x: Math.floor((state.settings.width - 1) / 2), y: -2 }, orientation: 'up', gems, level, gravityTicks: 0, groundedTicks: 0, resetCount: 0, groundedStarted: false };
|
|
40
|
+
}
|
|
41
|
+
function magneticFlags(seed, pairIndex, colours) {
|
|
42
|
+
const board = createNatureState({ width: 2, height: 1, seed: `colour-chains|shizen|${seed}|pair-${pairIndex}`, cells: [{ id: 1, kind: 'stone', colour: colours[0] }, { id: 2, kind: 'stone', colour: colours[1] }] });
|
|
43
|
+
const marked = markMagneticStones(board, { probability: 0.08, maximum: 1 }).state;
|
|
44
|
+
return [marked.cells[0]?.magnetic === true, marked.cells[1]?.magnetic === true];
|
|
45
|
+
}
|
|
46
|
+
function natureBoard(state) {
|
|
47
|
+
const width = state.settings.width, height = state.settings.height;
|
|
48
|
+
const cells = state.board.slice(width * HIDDEN).map(gem => gem ? { id: gem.id, kind: 'stone', colour: gem.colour, ...(gem.magnetic ? { magnetic: true } : {}) } : null);
|
|
49
|
+
return createNatureState({ width, height, seed: `colour-chains|shizen|${state.settings.seed}`, cells });
|
|
50
|
+
}
|
|
51
|
+
function naturePulse(state, board) {
|
|
52
|
+
const width = state.settings.width, height = state.settings.height;
|
|
53
|
+
const cells = board.slice(width * HIDDEN).map(gem => gem ? { id: gem.id, kind: 'stone', colour: gem.colour, ...(gem.magnetic ? { magnetic: true } : {}) } : null);
|
|
54
|
+
const result = applyMagneticPulse(createNatureState({ width, height, seed: `colour-chains|shizen|${state.settings.seed}`, cells }));
|
|
55
|
+
if (!result.events.length)
|
|
56
|
+
return { board, events: [] };
|
|
57
|
+
const updated = [...board];
|
|
58
|
+
for (let i = 0; i < result.state.cells.length; i++) {
|
|
59
|
+
const entity = result.state.cells[i];
|
|
60
|
+
updated[i + width * HIDDEN] = entity ? { id: entity.id, colour: entity.colour, ...(entity.magnetic ? { magnetic: true } : {}) } : null;
|
|
61
|
+
}
|
|
62
|
+
return { board: updated, events: result.events.map(event => ({ ...event })) };
|
|
63
|
+
}
|
|
64
|
+
function boardWithNatureCells(state, board, cells) {
|
|
65
|
+
const width = state.settings.width, updated = [...board];
|
|
66
|
+
for (let index = 0; index < cells.length; index++) {
|
|
67
|
+
const entity = cells[index];
|
|
68
|
+
updated[index + width * HIDDEN] = entity?.kind === 'stone' ? { id: entity.id, colour: entity.colour, ...(entity.magnetic ? { magnetic: true } : {}) } : null;
|
|
69
|
+
}
|
|
70
|
+
return updated;
|
|
71
|
+
}
|
|
72
|
+
function applyWeather(state) {
|
|
73
|
+
const nature = natureBoard(state);
|
|
74
|
+
const turn = state.completedPairs;
|
|
75
|
+
const interval = state.settings.weather === 'frequent' ? 2 : 8;
|
|
76
|
+
const eventIndex = Math.floor(turn / interval) - 1;
|
|
77
|
+
const kind = eventIndex % 2 === 0 ? 'jumble' : 'lightning';
|
|
78
|
+
if (kind === 'jumble') {
|
|
79
|
+
const result = applyEarthquake(createNatureState({ width: nature.width, height: nature.height, seed: `${state.settings.seed}|arashi|turn-${turn}`, cells: nature.cells }), { kind: 'jumble' });
|
|
80
|
+
return { board: boardWithNatureCells(state, state.board, result.state.cells), events: [{ type: 'weather-triggered', kind, turn }, ...result.events.map(event => ({ ...event }))] };
|
|
81
|
+
}
|
|
82
|
+
const result = applyLightning(createNatureState({ width: nature.width, height: nature.height, seed: `${state.settings.seed}|arashi|turn-${turn}`, cells: nature.cells }));
|
|
83
|
+
return { board: boardWithNatureCells(state, state.board, result.state.cells), events: [{ type: 'weather-triggered', kind, turn }, ...result.events.map(event => ({ ...event }))] };
|
|
84
|
+
}
|
|
85
|
+
function takeNextPair(state) {
|
|
86
|
+
const colours = state.next[0];
|
|
87
|
+
const draw = drawPair(state);
|
|
88
|
+
const next = [...state.next.slice(1), draw[0]];
|
|
89
|
+
const flags = state.settings.nature ? state.nextMagnetic?.[0] : undefined;
|
|
90
|
+
const active = makePair(state, colours, flags);
|
|
91
|
+
const nextMagnetic = state.settings.nature ? [...(state.nextMagnetic?.slice(1) ?? []), magneticFlags(state.settings.seed, state.completedPairs + 3, draw[0])] : undefined;
|
|
92
|
+
let current = { ...state, active, next, ...(nextMagnetic ? { nextMagnetic } : {}), bag: draw[1], randomState: draw[2], nextId: state.nextId + 2 };
|
|
93
|
+
if (!pairFits(current, active))
|
|
94
|
+
current = { ...current, active: null, phase: 'lost', reason: 'spawn-collision' };
|
|
95
|
+
return [current, [current.phase === 'lost' ? { type: 'run-ended', reason: 'spawn-collision' } : { type: 'pair-spawned', ids: active.gems.map(gem => gem.id), colours }]];
|
|
96
|
+
}
|
|
97
|
+
function movedEntries(before, after, width) {
|
|
98
|
+
const origin = new Map();
|
|
99
|
+
before.forEach((gem, index) => { if (gem)
|
|
100
|
+
origin.set(gem.id, index); });
|
|
101
|
+
return after.flatMap((gem, index) => { if (!gem)
|
|
102
|
+
return []; const from = origin.get(gem.id); if (from === index)
|
|
103
|
+
return []; return [{ id: gem.id, from: { x: from % width, y: Math.floor(from / width) - HIDDEN }, to: { x: index % width, y: Math.floor(index / width) - HIDDEN } }]; });
|
|
104
|
+
}
|
|
105
|
+
function satisfied(state) {
|
|
106
|
+
const goal = state.settings.goal;
|
|
107
|
+
if (!goal)
|
|
108
|
+
return false;
|
|
109
|
+
if (goal.kind === 'clear-targets')
|
|
110
|
+
return goal.targetIds.every(id => !state.board.some(gem => gem?.id === id));
|
|
111
|
+
if (goal.kind === 'minimum-chain')
|
|
112
|
+
return state.maxChain >= goal.chain;
|
|
113
|
+
return state.board.every(gem => gem === null);
|
|
114
|
+
}
|
|
115
|
+
function terminalAfterResolution(state) {
|
|
116
|
+
if (state.settings.mode === 'challenge' && satisfied(state))
|
|
117
|
+
return [{ ...state, phase: 'won', active: null, reason: 'goal-complete' }, [{ type: 'run-ended', reason: 'goal-complete', score: state.score }]];
|
|
118
|
+
if (state.board.slice(0, state.settings.width * HIDDEN).some(gem => gem !== null))
|
|
119
|
+
return [{ ...state, phase: 'lost', active: null, reason: 'top-out' }, [{ type: 'run-ended', reason: 'top-out' }]];
|
|
120
|
+
let current = state;
|
|
121
|
+
const weatherEvents = [];
|
|
122
|
+
if (state.settings.weather && state.weatherResolvedPairs !== state.completedPairs) {
|
|
123
|
+
const turn = state.completedPairs;
|
|
124
|
+
current = { ...current, weatherResolvedPairs: turn };
|
|
125
|
+
if (isEnvironmentTurnScheduled(turn, { kind: state.settings.weather })) {
|
|
126
|
+
const effect = applyWeather(current);
|
|
127
|
+
weatherEvents.push(...effect.events);
|
|
128
|
+
current = { ...current, board: effect.board };
|
|
129
|
+
const groups = findGroups(current.board, current.settings.width, current.settings.height + HIDDEN);
|
|
130
|
+
if (groups.length) {
|
|
131
|
+
const cells = [...new Set(groups.flat())].sort((a, b) => a - b);
|
|
132
|
+
return [{ ...current, phase: 'clear-mark', active: null, clearCells: cells, gravityBoard: null, resolutionTick: 0 }, [...weatherEvents, { type: 'match-marked', chain: current.waves.length + 1, cells, ids: cells.map(index => current.board[index].id), groups }]];
|
|
133
|
+
}
|
|
134
|
+
const settled = compactBoard(current.board, current.settings.width, current.settings.height + HIDDEN);
|
|
135
|
+
const movement = movedEntries(current.board, settled, current.settings.width);
|
|
136
|
+
if (movement.length)
|
|
137
|
+
return [{ ...current, phase: 'gravity', active: null, gravityBoard: settled, resolutionTick: 0 }, [...weatherEvents, { type: 'cells-fell', cells: movement, before: current.board, after: settled }]];
|
|
138
|
+
current = { ...current, board: settled };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (current.settings.mode === 'challenge') {
|
|
142
|
+
const nextIndex = current.completedPairs;
|
|
143
|
+
if (nextIndex >= (current.settings.queue?.length ?? 0))
|
|
144
|
+
return [{ ...current, phase: 'lost', active: null, reason: 'challenge-queue-exhausted' }, [...weatherEvents, { type: 'run-ended', reason: 'challenge-queue-exhausted' }]];
|
|
145
|
+
const queue = current.settings.queue;
|
|
146
|
+
const colours = queue[nextIndex];
|
|
147
|
+
const pair = makePair(current, colours);
|
|
148
|
+
const next = { ...current, active: pair, next: queue.slice(nextIndex + 1, nextIndex + 4), nextId: current.nextId + 2 };
|
|
149
|
+
if (!pairFits(next, pair))
|
|
150
|
+
return [{ ...next, active: null, phase: 'lost', reason: 'spawn-collision' }, [...weatherEvents, { type: 'run-ended', reason: 'spawn-collision' }]];
|
|
151
|
+
return [next, [...weatherEvents, { type: 'pair-spawned', ids: pair.gems.map(gem => gem.id), colours }]];
|
|
152
|
+
}
|
|
153
|
+
if (current.settings.pairLimit !== undefined && current.completedPairs >= current.settings.pairLimit)
|
|
154
|
+
return [{ ...current, phase: 'finished', active: null, reason: 'pair-limit' }, [...weatherEvents, { type: 'run-ended', reason: 'pair-limit', score: current.score }]];
|
|
155
|
+
const [spawned, events] = takeNextPair(current);
|
|
156
|
+
return [spawned, [...weatherEvents, ...events]];
|
|
157
|
+
}
|
|
158
|
+
function startWave(state, chain) {
|
|
159
|
+
const groups = findGroups(state.board, state.settings.width, state.settings.height + HIDDEN);
|
|
160
|
+
if (!groups.length) {
|
|
161
|
+
let score = state.score;
|
|
162
|
+
let allClears = state.allClears;
|
|
163
|
+
const events = [];
|
|
164
|
+
if (state.resolutionHadClear && state.board.every(gem => gem === null) && (state.settings.mode === 'arcade' || state.settings.mode === 'daily')) {
|
|
165
|
+
const bonus = 500 * (state.resolutionLevel ?? 1);
|
|
166
|
+
score += bonus;
|
|
167
|
+
allClears++;
|
|
168
|
+
events.push({ type: 'all-clear', points: bonus, score, count: allClears });
|
|
169
|
+
}
|
|
170
|
+
const ended = { ...state, score, allClears, active: null, phase: 'falling', clearCells: [], gravityBoard: null, resolutionTick: 0 };
|
|
171
|
+
return terminalAfterResolution(ended);
|
|
172
|
+
}
|
|
173
|
+
const cells = [...new Set(groups.flat())].sort((a, b) => a - b);
|
|
174
|
+
return [{ ...state, phase: 'clear-mark', clearCells: cells, gravityBoard: null, resolutionTick: 0 }, [{ type: 'match-marked', chain, cells, ids: cells.map(index => state.board[index].id), groups }]];
|
|
175
|
+
}
|
|
176
|
+
function lock(state, pair, points = 0) {
|
|
177
|
+
const board = [...state.board];
|
|
178
|
+
const positions = [cellFor(pair, false), cellFor(pair, true)];
|
|
179
|
+
for (const [i, cell] of positions.entries()) {
|
|
180
|
+
if (occupied(state, cell.x, cell.y))
|
|
181
|
+
return { state: { ...state, phase: 'lost', active: null, reason: 'spawn-collision' }, events: [{ type: 'run-ended', reason: 'spawn-collision' }], accepted: true };
|
|
182
|
+
board[indexOf(cell.x, cell.y, state.settings.width)] = pair.gems[i];
|
|
183
|
+
}
|
|
184
|
+
const compacted = state.settings.nature ? board : compactBoard(board, state.settings.width, state.settings.height + HIDDEN);
|
|
185
|
+
const witness = state.settings.witness?.[state.completedPairs];
|
|
186
|
+
const onPath = state.witnessIndex >= 0 && (!witness || witness.pivotX === pair.pivot.x && witness.orientation === pair.orientation);
|
|
187
|
+
const witnessIndex = state.settings.mode === 'challenge' ? (onPath ? state.completedPairs + 1 : -1) : -1;
|
|
188
|
+
const next = { ...state, board: compacted, active: null, score: state.score + points, completedPairs: state.completedPairs + 1, waves: [], resolutionTick: 0, clearCells: [], gravityBoard: null, resolutionHadClear: false, resolutionLevel: pair.level, witnessIndex, ...(state.settings.nature ? { naturePulsePending: false } : {}) };
|
|
189
|
+
const events = [{ type: 'pair-locked', cells: positions, ids: pair.gems.map(gem => gem.id), ...(points ? { points, score: next.score } : {}) }];
|
|
190
|
+
if (state.settings.nature) {
|
|
191
|
+
const groups = findGroups(board, state.settings.width, state.settings.height + HIDDEN);
|
|
192
|
+
if (groups.length) {
|
|
193
|
+
const cells = [...new Set(groups.flat())].sort((a, b) => a - b);
|
|
194
|
+
const marked = { ...next, naturePulsePending: true, phase: 'clear-mark', clearCells: cells };
|
|
195
|
+
return { state: marked, events: [...events, { type: 'match-marked', chain: 1, cells, ids: cells.map(index => board[index].id), groups }], accepted: true };
|
|
196
|
+
}
|
|
197
|
+
const pulse = naturePulse(next, board);
|
|
198
|
+
const afterPulse = pulse.board;
|
|
199
|
+
const pulseGroups = findGroups(afterPulse, state.settings.width, state.settings.height + HIDDEN);
|
|
200
|
+
if (pulseGroups.length) {
|
|
201
|
+
const cells = [...new Set(pulseGroups.flat())].sort((a, b) => a - b);
|
|
202
|
+
const marked = { ...next, board: afterPulse, phase: 'clear-mark', clearCells: cells };
|
|
203
|
+
return { state: marked, events: [...events, ...pulse.events, { type: 'match-marked', chain: 1, cells, ids: cells.map(index => afterPulse[index].id), groups: pulseGroups }], accepted: true };
|
|
204
|
+
}
|
|
205
|
+
const settled = compactBoard(afterPulse, state.settings.width, state.settings.height + HIDDEN);
|
|
206
|
+
const movement = movedEntries(afterPulse, settled, state.settings.width);
|
|
207
|
+
if (movement.length)
|
|
208
|
+
return { state: { ...next, board: afterPulse, phase: 'gravity', gravityBoard: settled }, events: [...events, ...pulse.events, { type: 'cells-fell', cells: movement, before: afterPulse, after: settled }], accepted: true };
|
|
209
|
+
const [resolved, waveEvents] = startWave({ ...next, board: settled }, 1);
|
|
210
|
+
return { state: resolved, events: [...events, ...pulse.events, ...waveEvents], accepted: true };
|
|
211
|
+
}
|
|
212
|
+
const movement = movedEntries(board, compacted, state.settings.width);
|
|
213
|
+
if (movement.length)
|
|
214
|
+
return { state: { ...next, board, phase: 'gravity', gravityBoard: compacted }, events: [...events, { type: 'cells-fell', cells: movement, before: board, after: compacted }], accepted: true };
|
|
215
|
+
const [resolved, waveEvents] = startWave({ ...next, board: compacted }, 1);
|
|
216
|
+
return { state: resolved, events: [...events, ...waveEvents], accepted: true };
|
|
217
|
+
}
|
|
218
|
+
function rebound(state, pair) {
|
|
219
|
+
const landing = rigidLanding(state, pair);
|
|
220
|
+
const nature = natureBoard(state);
|
|
221
|
+
const piece = [cellFor(landing, false), cellFor(landing, true)].map((cell, index) => ({
|
|
222
|
+
id: landing.gems[index].id, kind: 'stone', colour: landing.gems[index].colour,
|
|
223
|
+
...(landing.gems[index].magnetic ? { magnetic: true } : {}), x: cell.x, y: cell.y,
|
|
224
|
+
}));
|
|
225
|
+
if (piece.some(cell => cell.y < 0 || cell.y >= state.settings.height))
|
|
226
|
+
return null;
|
|
227
|
+
const direction = state.lastHorizontalDirection ?? (state.completedPairs % 2 === 0 ? 'left' : 'right');
|
|
228
|
+
const preview = previewPowerDrop(nature, { piece, pivotId: landing.gems[0].id, power: true, lastHorizontalDirection: direction });
|
|
229
|
+
const pivot = preview.finalCells.find(cell => cell.id === landing.gems[0].id);
|
|
230
|
+
return { pair: { ...landing, pivot: { x: pivot.x, y: pivot.y } }, preview };
|
|
231
|
+
}
|
|
232
|
+
function moved(state, action) {
|
|
233
|
+
const pair = state.active;
|
|
234
|
+
const dx = action === 'left' ? -1 : action === 'right' ? 1 : 0;
|
|
235
|
+
const dy = action === 'down' ? 1 : 0;
|
|
236
|
+
const nextPair = { ...pair, pivot: { x: pair.pivot.x + dx, y: pair.pivot.y + dy }, ...(action === 'down' ? { gravityTicks: 0 } : {}) };
|
|
237
|
+
if (!pairFits(state, nextPair))
|
|
238
|
+
return emptyTransition(state, 'blocked');
|
|
239
|
+
const wasGrounded = grounded(state, pair);
|
|
240
|
+
const isGrounded = grounded(state, nextPair);
|
|
241
|
+
const canReset = wasGrounded && action !== 'down' && pair.resetCount < 8;
|
|
242
|
+
const resetCount = pair.resetCount + (canReset ? 1 : 0);
|
|
243
|
+
const groundedTicks = canReset ? 0 : pair.groundedTicks;
|
|
244
|
+
const groundedStarted = canReset ? isGrounded : pair.groundedStarted;
|
|
245
|
+
return { state: { ...state, active: { ...nextPair, resetCount, groundedTicks, groundedStarted }, ...(state.settings.nature && (action === 'left' || action === 'right') ? { lastHorizontalDirection: action } : {}) }, events: [{ type: action === 'down' ? 'pair-soft-dropped' : 'pair-moved', action, from: pair.pivot, to: nextPair.pivot }], accepted: true };
|
|
246
|
+
}
|
|
247
|
+
function rotate(state, clockwise) {
|
|
248
|
+
const pair = state.active;
|
|
249
|
+
const oldIndex = ORIENTATIONS.indexOf(pair.orientation);
|
|
250
|
+
const orientation = ORIENTATIONS[(oldIndex + (clockwise ? 1 : 3)) % 4];
|
|
251
|
+
for (const [dx, dy] of KICKS) {
|
|
252
|
+
const candidate = { ...pair, pivot: { x: pair.pivot.x + dx, y: pair.pivot.y + dy }, orientation };
|
|
253
|
+
if (!pairFits(state, candidate))
|
|
254
|
+
continue;
|
|
255
|
+
const wasGrounded = grounded(state, pair);
|
|
256
|
+
const isGrounded = grounded(state, candidate);
|
|
257
|
+
const canReset = wasGrounded && pair.resetCount < 8;
|
|
258
|
+
return { state: { ...state, active: { ...candidate, resetCount: pair.resetCount + (canReset ? 1 : 0), groundedTicks: canReset ? 0 : pair.groundedTicks, groundedStarted: canReset ? isGrounded : pair.groundedStarted } }, events: [{ type: 'pair-rotated', direction: clockwise ? 'clockwise' : 'anticlockwise', from: pair.orientation, to: orientation, kick: { x: dx, y: dy } }], accepted: true };
|
|
259
|
+
}
|
|
260
|
+
return emptyTransition(state, 'blocked-rotation');
|
|
261
|
+
}
|
|
262
|
+
function applyCore(state, action) {
|
|
263
|
+
if (!action || typeof action !== 'object' || !['left', 'right', 'down', 'rotate-clockwise', 'rotate-anticlockwise', 'hard-drop', 'place', 'pause', 'resume', 'hint'].includes(action.kind ?? '') || Object.keys(action).some(key => key !== 'kind'))
|
|
264
|
+
return emptyTransition(state, 'invalid-action');
|
|
265
|
+
if (action.kind === 'pause') {
|
|
266
|
+
if (!['falling', 'clear-mark', 'clear-remove', 'gravity'].includes(state.phase))
|
|
267
|
+
return emptyTransition(state, 'cannot-pause');
|
|
268
|
+
return { state: { ...state, phase: 'paused', pausedPhase: state.phase, assisted: state.assisted || state.settings.mode === 'arcade' || state.settings.mode === 'daily' }, events: [{ type: 'paused' }], accepted: true };
|
|
269
|
+
}
|
|
270
|
+
if (action.kind === 'resume') {
|
|
271
|
+
if (state.phase !== 'paused')
|
|
272
|
+
return emptyTransition(state, 'not-paused');
|
|
273
|
+
const { pausedPhase, ...rest } = state;
|
|
274
|
+
return { state: { ...rest, phase: pausedPhase ?? 'falling' }, events: [{ type: 'resumed' }], accepted: true };
|
|
275
|
+
}
|
|
276
|
+
if (action.kind === 'hint') {
|
|
277
|
+
const witness = state.settings.witness?.[state.completedPairs];
|
|
278
|
+
if (state.settings.mode !== 'challenge' || state.phase !== 'falling' || !state.active || state.witnessIndex !== state.completedPairs || !witness)
|
|
279
|
+
return emptyTransition(state, 'no-proved-hint-available');
|
|
280
|
+
return { state: { ...state, assisted: true }, events: [{ type: 'hint-shown', pair: state.completedPairs, ...witness, assisted: true }], accepted: true };
|
|
281
|
+
}
|
|
282
|
+
if (state.phase !== 'falling' || !state.active)
|
|
283
|
+
return emptyTransition(state, 'not-ready');
|
|
284
|
+
const pair = state.active;
|
|
285
|
+
switch (action.kind) {
|
|
286
|
+
case 'left':
|
|
287
|
+
case 'right': return moved(state, action.kind);
|
|
288
|
+
case 'down':
|
|
289
|
+
if (!pairFits(state, { ...pair, pivot: { x: pair.pivot.x, y: pair.pivot.y + 1 } }))
|
|
290
|
+
return emptyTransition(state, 'blocked');
|
|
291
|
+
{
|
|
292
|
+
const result = moved(state, 'down');
|
|
293
|
+
const points = state.settings.mode === 'arcade' || state.settings.mode === 'daily' ? 1 : 0;
|
|
294
|
+
const score = state.score + points;
|
|
295
|
+
return { ...result, state: { ...result.state, score }, events: points ? [...result.events, { type: 'score-changed', score, points }] : result.events };
|
|
296
|
+
}
|
|
297
|
+
case 'rotate-clockwise': return rotate(state, true);
|
|
298
|
+
case 'rotate-anticlockwise': return rotate(state, false);
|
|
299
|
+
case 'place':
|
|
300
|
+
if (state.settings.mode !== 'relaxed')
|
|
301
|
+
return emptyTransition(state, 'place-only-in-relaxed');
|
|
302
|
+
return lock(state, pair);
|
|
303
|
+
case 'hard-drop': {
|
|
304
|
+
if (state.settings.mode === 'relaxed')
|
|
305
|
+
return emptyTransition(state, 'hard-drop-disabled');
|
|
306
|
+
const normal = rigidLanding(state, pair);
|
|
307
|
+
const reboundResult = state.settings.nature ? rebound(state, normal) : null;
|
|
308
|
+
const landing = reboundResult?.pair ?? normal;
|
|
309
|
+
const points = state.settings.mode === 'arcade' || state.settings.mode === 'daily' ? (normal.pivot.y - pair.pivot.y) * 2 : 0;
|
|
310
|
+
const result = lock(state, landing, points);
|
|
311
|
+
return { ...result, events: [{ type: 'pair-dropped', from: pair.pivot, to: landing.pivot, points, score: state.score + points }, ...(reboundResult ? [{ type: 'power-drop-landed', power: true, rebound: reboundResult.preview.rebound, ...(reboundResult.preview.selectedDirection ? { direction: reboundResult.preview.selectedDirection } : {}), quarterTurn: reboundResult.preview.quarterTurn, moves: reboundResult.preview.moves, cells: reboundResult.preview.finalCells.map(cell => ({ id: cell.id, x: cell.x, y: cell.y })), ids: pair.gems.map(gem => gem.id) }] : []), ...result.events] };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/** Applies one immutable accepted action and records its logical-tick position. */
|
|
316
|
+
export function applyAction(state, action) {
|
|
317
|
+
const result = applyCore(state, action);
|
|
318
|
+
if (!result.accepted)
|
|
319
|
+
return result;
|
|
320
|
+
const previous = state.recording.at(-1);
|
|
321
|
+
const ordinal = previous?.tick === state.elapsedTicks ? previous.ordinal + 1 : 0;
|
|
322
|
+
return { ...result, state: { ...result.state, recording: [...state.recording, { tick: state.elapsedTicks, ordinal, action }] } };
|
|
323
|
+
}
|
|
324
|
+
function advanceResolution(state) {
|
|
325
|
+
let next = { ...state, resolutionTick: state.resolutionTick + 1 };
|
|
326
|
+
const events = [];
|
|
327
|
+
if (next.phase === 'clear-mark' && next.resolutionTick >= 7) {
|
|
328
|
+
next = { ...next, phase: 'clear-remove', resolutionTick: 0 };
|
|
329
|
+
events.push({ type: 'clear-removal-started', chain: next.waves.length + 1, cells: next.clearCells });
|
|
330
|
+
}
|
|
331
|
+
else if (next.phase === 'clear-remove' && next.resolutionTick >= 6) {
|
|
332
|
+
const clear = new Set(next.clearCells);
|
|
333
|
+
const ids = [...clear].map(index => next.board[index].id);
|
|
334
|
+
const chain = next.waves.length + 1;
|
|
335
|
+
const level = next.resolutionLevel ?? 1;
|
|
336
|
+
const points = 10 * clear.size * level * chain;
|
|
337
|
+
const board = next.board.map((gem, index) => clear.has(index) ? null : gem);
|
|
338
|
+
const wave = { chain, cells: [...clear], ids, points };
|
|
339
|
+
const score = next.score + points;
|
|
340
|
+
next = { ...next, board, score, waves: [...next.waves, wave], maxChain: Math.max(next.maxChain, chain), phase: 'gravity', resolutionTick: 0, clearCells: [], resolutionHadClear: true };
|
|
341
|
+
events.push({ type: 'cells-cleared', chain, cells: [...clear], ids, points, score });
|
|
342
|
+
if (next.settings.nature && next.naturePulsePending) {
|
|
343
|
+
const pulse = naturePulse(next, board);
|
|
344
|
+
const afterPulse = pulse.board;
|
|
345
|
+
events.push(...pulse.events);
|
|
346
|
+
const groups = findGroups(afterPulse, next.settings.width, next.settings.height + HIDDEN);
|
|
347
|
+
next = { ...next, board: afterPulse, naturePulsePending: false, phase: 'falling' };
|
|
348
|
+
if (groups.length) {
|
|
349
|
+
const cells = [...new Set(groups.flat())].sort((a, b) => a - b);
|
|
350
|
+
next = { ...next, phase: 'clear-mark', clearCells: cells, gravityBoard: null, resolutionTick: 0 };
|
|
351
|
+
events.push({ type: 'match-marked', chain: next.waves.length + 1, cells, ids: cells.map(index => afterPulse[index].id), groups });
|
|
352
|
+
return { state: next, events, accepted: true };
|
|
353
|
+
}
|
|
354
|
+
const gravityBoard = compactBoard(afterPulse, next.settings.width, next.settings.height + HIDDEN);
|
|
355
|
+
next = { ...next, gravityBoard, phase: 'gravity' };
|
|
356
|
+
}
|
|
357
|
+
else
|
|
358
|
+
next = { ...next, gravityBoard: compactBoard(board, next.settings.width, next.settings.height + HIDDEN) };
|
|
359
|
+
}
|
|
360
|
+
else if (next.phase === 'gravity' && next.resolutionTick >= 9) {
|
|
361
|
+
const before = next.board;
|
|
362
|
+
const after = next.gravityBoard ?? next.board;
|
|
363
|
+
const falling = movedEntries(before, after, next.settings.width);
|
|
364
|
+
next = { ...next, board: after, gravityBoard: null, resolutionTick: 0, phase: 'falling' };
|
|
365
|
+
if (falling.length)
|
|
366
|
+
events.push({ type: 'cells-fell', cells: falling, before, after });
|
|
367
|
+
const result = startWave(next, next.waves.length + 1);
|
|
368
|
+
next = result[0];
|
|
369
|
+
events.push(...result[1]);
|
|
370
|
+
}
|
|
371
|
+
return { state: next, events, accepted: true };
|
|
372
|
+
}
|
|
373
|
+
function lockGrounded(state) {
|
|
374
|
+
return lock(state, rigidLanding(state, state.active));
|
|
375
|
+
}
|
|
376
|
+
/** Advances deterministic 60 Hz simulation and resolution; Relaxed and Challenges do not force a falling clock. */
|
|
377
|
+
export function advanceTicks(state, ticks) {
|
|
378
|
+
if (!Number.isInteger(ticks) || ticks < 0 || ticks > 3600)
|
|
379
|
+
throw new RangeError('ticks must be an integer from 0 to 3600');
|
|
380
|
+
let current = state;
|
|
381
|
+
const events = [];
|
|
382
|
+
for (let i = 0; i < ticks && !['paused', 'won', 'lost', 'finished'].includes(current.phase); i++) {
|
|
383
|
+
current = { ...current, elapsedTicks: current.elapsedTicks + 1 };
|
|
384
|
+
if (current.phase !== 'falling') {
|
|
385
|
+
const result = advanceResolution(current);
|
|
386
|
+
current = result.state;
|
|
387
|
+
events.push(...result.events);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (!current.active || current.settings.mode === 'relaxed' || current.settings.mode === 'challenge')
|
|
391
|
+
continue;
|
|
392
|
+
const pair = current.active;
|
|
393
|
+
if (grounded(current, pair)) {
|
|
394
|
+
if (!pair.groundedStarted)
|
|
395
|
+
current = { ...current, active: { ...pair, groundedStarted: true, groundedTicks: 0 } };
|
|
396
|
+
else {
|
|
397
|
+
const groundedTicks = pair.groundedTicks + 1;
|
|
398
|
+
current = { ...current, active: { ...pair, groundedTicks } };
|
|
399
|
+
if (groundedTicks >= 24) {
|
|
400
|
+
const result = lockGrounded(current);
|
|
401
|
+
current = result.state;
|
|
402
|
+
events.push(...result.events);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
const interval = Math.max(6, Math.round(60 * 0.85 ** (pair.level - 1)));
|
|
408
|
+
const gravityTicks = pair.gravityTicks + 1;
|
|
409
|
+
if (gravityTicks >= interval) {
|
|
410
|
+
const nextPair = { ...pair, pivot: { x: pair.pivot.x, y: pair.pivot.y + 1 }, gravityTicks: 0 };
|
|
411
|
+
current = { ...current, active: { ...nextPair, ...(grounded(current, nextPair) ? { groundedStarted: false } : {}) } };
|
|
412
|
+
events.push({ type: 'pair-fell', from: pair.pivot, to: nextPair.pivot });
|
|
413
|
+
}
|
|
414
|
+
else
|
|
415
|
+
current = { ...current, active: { ...pair, gravityTicks } };
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return { state: current, events, accepted: true };
|
|
419
|
+
}
|
|
420
|
+
/** Lists only actions accepted by the current game state. */
|
|
421
|
+
export function legalActions(state) {
|
|
422
|
+
if (state.phase === 'paused')
|
|
423
|
+
return [{ kind: 'resume' }];
|
|
424
|
+
if (['clear-mark', 'clear-remove', 'gravity'].includes(state.phase))
|
|
425
|
+
return [{ kind: 'pause' }];
|
|
426
|
+
if (state.phase !== 'falling' || !state.active)
|
|
427
|
+
return [];
|
|
428
|
+
const actions = [];
|
|
429
|
+
for (const kind of ['left', 'right'])
|
|
430
|
+
if (moved(state, kind).accepted)
|
|
431
|
+
actions.push({ kind });
|
|
432
|
+
if (rotate(state, true).accepted)
|
|
433
|
+
actions.push({ kind: 'rotate-clockwise' });
|
|
434
|
+
if (rotate(state, false).accepted)
|
|
435
|
+
actions.push({ kind: 'rotate-anticlockwise' });
|
|
436
|
+
actions.push({ kind: 'down' });
|
|
437
|
+
if (state.settings.mode === 'relaxed')
|
|
438
|
+
actions.push({ kind: 'place' });
|
|
439
|
+
else
|
|
440
|
+
actions.push({ kind: 'hard-drop' });
|
|
441
|
+
if (state.settings.witness?.[state.completedPairs] && state.witnessIndex === state.completedPairs)
|
|
442
|
+
actions.push({ kind: 'hint' });
|
|
443
|
+
actions.push({ kind: 'pause' });
|
|
444
|
+
return actions;
|
|
445
|
+
}
|
|
446
|
+
/** Returns the exact independent landing cells for the current rigid pair without consuming random values. */
|
|
447
|
+
export function landingCells(state) {
|
|
448
|
+
if (!state.active)
|
|
449
|
+
return null;
|
|
450
|
+
const pair = rigidLanding(state, state.active);
|
|
451
|
+
const board = [...state.board];
|
|
452
|
+
for (const [i, cell] of [cellFor(pair, false), cellFor(pair, true)].entries())
|
|
453
|
+
board[indexOf(cell.x, cell.y, state.settings.width)] = pair.gems[i];
|
|
454
|
+
const settled = compactBoard(board, state.settings.width, state.settings.height + HIDDEN);
|
|
455
|
+
return pair.gems.map(gem => { const index = settled.findIndex(cell => cell?.id === gem.id); return { x: index % state.settings.width, y: Math.floor(index / state.settings.width) - HIDDEN }; });
|
|
456
|
+
}
|
|
457
|
+
/** Returns the Shizen hard-drop landing and one-step rebound preview without changing state. */
|
|
458
|
+
export function powerDropPreview(state) {
|
|
459
|
+
if (!state.settings.nature || state.settings.mode === 'relaxed' || !state.active)
|
|
460
|
+
return null;
|
|
461
|
+
return rebound(state, state.active)?.preview ?? null;
|
|
462
|
+
}
|
|
463
|
+
/** Returns score, mode, chain and completion fields derived from state. */
|
|
464
|
+
export function statusOf(state) { return { mode: state.settings.mode, phase: state.phase, score: state.score, maxChain: state.maxChain, allClears: state.allClears, completedPairs: state.completedPairs, assisted: state.assisted, ...(state.reason ? { reason: state.reason } : {}) }; }
|
|
465
|
+
/** Creates a base challenge state with a previously validated visible board and pair queue. @internal */
|
|
466
|
+
export function initialChallengeState(settings, visibleBoard, queue) {
|
|
467
|
+
const board = Array(settings.width * (settings.height + HIDDEN)).fill(null);
|
|
468
|
+
for (let i = 0; i < visibleBoard.length; i++)
|
|
469
|
+
board[i + settings.width * HIDDEN] = visibleBoard[i];
|
|
470
|
+
const maxId = Math.max(0, ...visibleBoard.flatMap(gem => gem ? [gem.id] : []));
|
|
471
|
+
const base = { game: 'colour-chains', rules: RULES, settings, board: Object.freeze(board), phase: 'falling', active: null, next: Object.freeze(queue.slice(1, 4)), bag: [], randomState: seedState(`colour-chains|${RULES}|${settings.seed}`), nextId: maxId + 1, score: 0, maxChain: 0, allClears: 0, completedPairs: 0, resolutionTick: 0, waves: [], clearCells: [], gravityBoard: null, resolutionHadClear: false, elapsedTicks: 0, assisted: false, witnessIndex: 0, recording: [] };
|
|
472
|
+
const active = makePair(base, queue[0]);
|
|
473
|
+
return { ...base, active, nextId: maxId + 3 };
|
|
474
|
+
}
|
|
475
|
+
/** Creates a deterministic seeded game with one active pair and three previews. */
|
|
476
|
+
export function createGame(options = {}) {
|
|
477
|
+
const valid = normalizeOptions(options);
|
|
478
|
+
let state = { game: 'colour-chains', rules: RULES, settings: valid, board: Array(valid.width * (valid.height + HIDDEN)).fill(null), phase: 'falling', active: null, next: [], bag: [], randomState: seedState(`colour-chains|${RULES}|${valid.seed}`), nextId: 1, score: 0, maxChain: 0, allClears: 0, completedPairs: 0, resolutionTick: 0, waves: [], clearCells: [], gravityBoard: null, resolutionHadClear: false, elapsedTicks: 0, assisted: false, witnessIndex: -1, recording: [], ...(valid.weather ? { weatherResolvedPairs: 0 } : {}) };
|
|
479
|
+
const first = drawPair(state);
|
|
480
|
+
state = { ...state, bag: first[1], randomState: first[2] };
|
|
481
|
+
const active = makePair(state, first[0], valid.nature ? magneticFlags(valid.seed, 0, first[0]) : undefined);
|
|
482
|
+
state = { ...state, active, nextId: 3 };
|
|
483
|
+
const next = [];
|
|
484
|
+
const nextMagnetic = [];
|
|
485
|
+
for (let i = 0; i < 3; i++) {
|
|
486
|
+
const drawn = drawPair(state);
|
|
487
|
+
next.push(drawn[0]);
|
|
488
|
+
if (valid.nature)
|
|
489
|
+
nextMagnetic.push(magneticFlags(valid.seed, i + 1, drawn[0]));
|
|
490
|
+
state = { ...state, bag: drawn[1], randomState: drawn[2] };
|
|
491
|
+
}
|
|
492
|
+
return { ...state, next, ...(valid.nature ? { nextMagnetic } : {}) };
|
|
493
|
+
}
|