@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,200 @@
|
|
|
1
|
+
/** A colour used by a normal gem. */
|
|
2
|
+
export type GemColour = 'red' | 'blue' | 'green' | 'gold' | 'purple' | 'teal';
|
|
3
|
+
export type SpecialKind = 'row-beam' | 'column-beam' | 'bomb' | 'colour-burst';
|
|
4
|
+
export type ToolKind = 'bomb' | 'row-clear' | 'colour-clear' | 'black-hole';
|
|
5
|
+
export type OrdinaryToolKind = Exclude<ToolKind, 'black-hole'>;
|
|
6
|
+
export type ToolInventory = Readonly<Record<OrdinaryToolKind, number>>;
|
|
7
|
+
export interface BlackHolePortal {
|
|
8
|
+
readonly cell: number;
|
|
9
|
+
readonly capacityRemaining: number;
|
|
10
|
+
readonly movesRemaining: number;
|
|
11
|
+
readonly consumedIds: readonly number[];
|
|
12
|
+
}
|
|
13
|
+
/** A gem has a stable identity and stored colour until it is cleared. Missing kind means normal. */
|
|
14
|
+
export interface Gem {
|
|
15
|
+
readonly id: number;
|
|
16
|
+
readonly colour: GemColour;
|
|
17
|
+
readonly kind?: SpecialKind;
|
|
18
|
+
}
|
|
19
|
+
export interface PlannedSpecial {
|
|
20
|
+
readonly cell: number;
|
|
21
|
+
readonly kind: SpecialKind;
|
|
22
|
+
}
|
|
23
|
+
export type BoardPreset = 'compact' | 'standard' | 'wide' | 'tall' | 'extraWide' | 'deep' | 'large';
|
|
24
|
+
export type BoardShape = 'heart' | 'star' | 'hexagon';
|
|
25
|
+
export type GamePhase = 'ready' | 'clear-mark' | 'clear-remove' | 'gravity' | 'refill' | 'finished';
|
|
26
|
+
export type GameMode = 'relaxed' | 'arcade' | 'daily' | 'challenge';
|
|
27
|
+
export type GameOutcome = 'won' | 'lost' | 'finished' | null;
|
|
28
|
+
export type Goal = {
|
|
29
|
+
readonly kind: 'score';
|
|
30
|
+
readonly target: number;
|
|
31
|
+
} | {
|
|
32
|
+
readonly kind: 'collect';
|
|
33
|
+
readonly colour: GemColour;
|
|
34
|
+
readonly target: number;
|
|
35
|
+
} | {
|
|
36
|
+
readonly kind: 'chain';
|
|
37
|
+
readonly target: number;
|
|
38
|
+
} | {
|
|
39
|
+
readonly kind: 'seals';
|
|
40
|
+
};
|
|
41
|
+
export interface Seal {
|
|
42
|
+
readonly cell: number;
|
|
43
|
+
readonly layers: number;
|
|
44
|
+
}
|
|
45
|
+
export interface ChallengeRules {
|
|
46
|
+
readonly goals: readonly Goal[];
|
|
47
|
+
readonly moveLimit?: number;
|
|
48
|
+
readonly seals?: readonly Seal[];
|
|
49
|
+
}
|
|
50
|
+
export type ReplayOperation = {
|
|
51
|
+
readonly kind: 'action';
|
|
52
|
+
readonly action: Action;
|
|
53
|
+
} | {
|
|
54
|
+
readonly kind: 'ticks';
|
|
55
|
+
readonly count: number;
|
|
56
|
+
} | {
|
|
57
|
+
readonly kind: 'time';
|
|
58
|
+
readonly milliseconds: number;
|
|
59
|
+
};
|
|
60
|
+
export interface Settings {
|
|
61
|
+
readonly width: number;
|
|
62
|
+
readonly height: number;
|
|
63
|
+
readonly colourCount: 4 | 5 | 6;
|
|
64
|
+
readonly seed: string | number;
|
|
65
|
+
readonly tools: boolean;
|
|
66
|
+
readonly advancedTools: boolean;
|
|
67
|
+
readonly shape?: BoardShape;
|
|
68
|
+
readonly mask: readonly boolean[];
|
|
69
|
+
}
|
|
70
|
+
export interface GameState {
|
|
71
|
+
readonly game: 'gem-swap';
|
|
72
|
+
readonly rules: 'swap-1';
|
|
73
|
+
readonly settings: Settings;
|
|
74
|
+
readonly initialOptions: CreateOptions;
|
|
75
|
+
readonly mode: GameMode;
|
|
76
|
+
readonly dailyDate: string | null;
|
|
77
|
+
readonly challenge: ChallengeRules | null;
|
|
78
|
+
readonly seals: readonly Seal[];
|
|
79
|
+
readonly sealsCleared: number;
|
|
80
|
+
readonly elapsedMs: number;
|
|
81
|
+
readonly outcome: GameOutcome;
|
|
82
|
+
readonly clearedByColour: Readonly<Record<GemColour, number>>;
|
|
83
|
+
readonly bestChain: number;
|
|
84
|
+
readonly history: readonly ReplayOperation[];
|
|
85
|
+
/** Row-major. Mask gaps and the holes in clear phases contain null. */
|
|
86
|
+
readonly board: readonly (Gem | null)[];
|
|
87
|
+
readonly phase: GamePhase;
|
|
88
|
+
readonly score: number;
|
|
89
|
+
readonly moves: number;
|
|
90
|
+
readonly swapCount: number;
|
|
91
|
+
readonly randomState: number;
|
|
92
|
+
readonly nextId: number;
|
|
93
|
+
readonly usedFallback: boolean;
|
|
94
|
+
readonly wave: number;
|
|
95
|
+
readonly resolutionTick: number;
|
|
96
|
+
readonly pendingCells: readonly number[];
|
|
97
|
+
readonly pendingSpecials: readonly PlannedSpecial[];
|
|
98
|
+
readonly gravityBoard: readonly (Gem | null)[] | null;
|
|
99
|
+
readonly inventory: ToolInventory;
|
|
100
|
+
readonly selectedTool: ToolKind | null;
|
|
101
|
+
readonly toolTarget: number | null;
|
|
102
|
+
readonly toolPreview: readonly number[];
|
|
103
|
+
readonly toolProgress: number;
|
|
104
|
+
readonly toolAwardCursor: number;
|
|
105
|
+
readonly assisted: boolean;
|
|
106
|
+
/** Remains true across cascades originating from one tool use. */
|
|
107
|
+
readonly toolWaveActive: boolean;
|
|
108
|
+
readonly blackHoleCharges: 0 | 1;
|
|
109
|
+
readonly blackHoleProgress: number;
|
|
110
|
+
readonly blackHole: BlackHolePortal | null;
|
|
111
|
+
readonly blackHoleMovePending: boolean;
|
|
112
|
+
readonly blackHoleContactPending: boolean;
|
|
113
|
+
readonly pendingBlackHole: boolean;
|
|
114
|
+
}
|
|
115
|
+
/** Swaps two row-major cell addresses. */
|
|
116
|
+
export interface SwapAction {
|
|
117
|
+
readonly kind: 'swap';
|
|
118
|
+
readonly from: number;
|
|
119
|
+
readonly to: number;
|
|
120
|
+
}
|
|
121
|
+
export interface SelectToolAction {
|
|
122
|
+
readonly kind: 'select-tool';
|
|
123
|
+
readonly tool: ToolKind;
|
|
124
|
+
}
|
|
125
|
+
export interface TargetToolAction {
|
|
126
|
+
readonly kind: 'target-tool';
|
|
127
|
+
readonly cell: number;
|
|
128
|
+
}
|
|
129
|
+
export interface ConfirmToolAction {
|
|
130
|
+
readonly kind: 'confirm-tool';
|
|
131
|
+
}
|
|
132
|
+
export interface CancelToolAction {
|
|
133
|
+
readonly kind: 'cancel-tool';
|
|
134
|
+
}
|
|
135
|
+
export interface UndoAction {
|
|
136
|
+
readonly kind: 'undo';
|
|
137
|
+
}
|
|
138
|
+
export interface HintAction {
|
|
139
|
+
readonly kind: 'hint';
|
|
140
|
+
}
|
|
141
|
+
export interface ReshuffleAction {
|
|
142
|
+
readonly kind: 'reshuffle';
|
|
143
|
+
}
|
|
144
|
+
export type Action = SwapAction | SelectToolAction | TargetToolAction | ConfirmToolAction | CancelToolAction | UndoAction | HintAction | ReshuffleAction;
|
|
145
|
+
export interface GameEvent {
|
|
146
|
+
readonly type: string;
|
|
147
|
+
readonly [key: string]: unknown;
|
|
148
|
+
}
|
|
149
|
+
export interface Transition {
|
|
150
|
+
readonly state: GameState;
|
|
151
|
+
readonly events: readonly GameEvent[];
|
|
152
|
+
readonly accepted: boolean;
|
|
153
|
+
readonly reason?: string;
|
|
154
|
+
}
|
|
155
|
+
export interface CreateOptions {
|
|
156
|
+
readonly preset?: BoardPreset;
|
|
157
|
+
readonly width?: number;
|
|
158
|
+
readonly height?: number;
|
|
159
|
+
readonly colourCount?: 4 | 5 | 6;
|
|
160
|
+
readonly seed?: string | number;
|
|
161
|
+
readonly tools?: boolean;
|
|
162
|
+
readonly advancedTools?: boolean;
|
|
163
|
+
readonly shape?: BoardShape;
|
|
164
|
+
/** Row-major active-cell mask. Requires explicit custom dimensions. */
|
|
165
|
+
readonly mask?: readonly boolean[];
|
|
166
|
+
readonly mode?: GameMode;
|
|
167
|
+
/** Required for Daily; a UTC calendar date in YYYY-MM-DD form. */
|
|
168
|
+
readonly dailyDate?: string;
|
|
169
|
+
/** Challenge-only finite goals and optional legal-swap budget. */
|
|
170
|
+
readonly challenge?: ChallengeRules;
|
|
171
|
+
}
|
|
172
|
+
export interface GameStatus {
|
|
173
|
+
readonly phase: GamePhase;
|
|
174
|
+
readonly score: number;
|
|
175
|
+
readonly moves: number;
|
|
176
|
+
readonly legalMoveCount: number;
|
|
177
|
+
readonly usedFallback: boolean;
|
|
178
|
+
readonly availableToolCount: number;
|
|
179
|
+
readonly inventory: ToolInventory;
|
|
180
|
+
readonly toolProgress: number;
|
|
181
|
+
readonly toolAwardCursor: number;
|
|
182
|
+
readonly assisted: boolean;
|
|
183
|
+
readonly blackHoleCharges: 0 | 1;
|
|
184
|
+
readonly blackHoleProgress: number;
|
|
185
|
+
readonly blackHole: BlackHolePortal | null;
|
|
186
|
+
readonly mode: GameMode;
|
|
187
|
+
readonly outcome: GameOutcome;
|
|
188
|
+
readonly remainingMoves: number | null;
|
|
189
|
+
readonly elapsedMs: number;
|
|
190
|
+
readonly remainingMs: number | null;
|
|
191
|
+
readonly goals: readonly Goal[];
|
|
192
|
+
readonly goalProgress: readonly {
|
|
193
|
+
readonly kind: Goal['kind'];
|
|
194
|
+
readonly current: number;
|
|
195
|
+
readonly target: number;
|
|
196
|
+
readonly colour?: GemColour;
|
|
197
|
+
readonly complete: boolean;
|
|
198
|
+
}[];
|
|
199
|
+
readonly bestChain: number;
|
|
200
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type { Action, BlackHolePortal, BoardPreset, BoardShape, CancelToolAction, ChallengeRules, ConfirmToolAction, CreateOptions, GameEvent, GameMode, GameOutcome, GamePhase, GameState, GameStatus, Gem, GemColour, Goal, HintAction, OrdinaryToolKind, PlannedSpecial, ReplayOperation, ReshuffleAction, Seal, SelectToolAction, Settings, SpecialKind, SwapAction, TargetToolAction, ToolInventory, ToolKind, Transition, UndoAction } from './gem-swap/types.js';
|
|
2
|
+
export { GemSwapOptionsError, createGame, applyAction, advanceTicks, advanceTime, legalActions, statusOf, hintGame, reshuffleGame, undoGame } from './gem-swap/engine.js';
|
|
3
|
+
export { ReplayError, encodeGame, decodeGame, restartGame, validateChallengeWitness } from './gem-swap/persistence.js';
|
|
4
|
+
export { CAMPAIGN_CHECKSUM, CAMPAIGN_COUNT, GENERATION_REVISION, GRADING_VERSION, GRADING_WEIGHTS, SAMPLE_BUDGET, GEM_SWAP_CAMPAIGN, GEM_SWAP_LESSONS } from './gem-swap/content.js';
|
|
5
|
+
export type { GemSwapCampaignLevel, GemSwapLesson, GemSwapLessonStep, GemSwapReviewStatus } from './gem-swap/content.js';
|
package/dist/gem-swap.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { GemSwapOptionsError, createGame, applyAction, advanceTicks, advanceTime, legalActions, statusOf, hintGame, reshuffleGame, undoGame } from './gem-swap/engine.js';
|
|
2
|
+
export { ReplayError, encodeGame, decodeGame, restartGame, validateChallengeWitness } from './gem-swap/persistence.js';
|
|
3
|
+
export { CAMPAIGN_CHECKSUM, CAMPAIGN_COUNT, GENERATION_REVISION, GRADING_VERSION, GRADING_WEIGHTS, SAMPLE_BUDGET, GEM_SWAP_CAMPAIGN, GEM_SWAP_LESSONS } from './gem-swap/content.js';
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Original immutable game engines, grouped by game. */
|
|
2
|
+
export * as fallingTriplets from './falling-triplets.js';
|
|
3
|
+
export * as stoneCollapse from './stone-collapse.js';
|
|
4
|
+
export * as colourChains from './colour-chains.js';
|
|
5
|
+
export * as gemSwap from './gem-swap.js';
|
|
6
|
+
export * as nature from './nature.js';
|
|
7
|
+
export * as magneticBlocks from './magnetic-blocks.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Original immutable game engines, grouped by game. */
|
|
2
|
+
export * as fallingTriplets from './falling-triplets.js';
|
|
3
|
+
export * as stoneCollapse from './stone-collapse.js';
|
|
4
|
+
export * as colourChains from './colour-chains.js';
|
|
5
|
+
export * as gemSwap from './gem-swap.js';
|
|
6
|
+
export * as nature from './nature.js';
|
|
7
|
+
export * as magneticBlocks from './magnetic-blocks.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Action, GameState, GameStatus, Transition } from './types.js';
|
|
2
|
+
/** Creates an immutable seeded 2×2-block game with a fixed queue and explicit floor schedule. */
|
|
3
|
+
export declare function createGame(options?: import('./types.js').CreateOptions): GameState;
|
|
4
|
+
/** Applies one movement, rotation, floor-switch or placement action without mutation. */
|
|
5
|
+
export declare function applyAction(state: GameState, action: Action): Transition;
|
|
6
|
+
/** Advances fixed 60 Hz time, resolving phases in all modes and falling/locking only in Arcade. */
|
|
7
|
+
export declare function advanceTicks(state: GameState, ticks: number): Transition;
|
|
8
|
+
/** Lists accepted controls for the current falling or resolution phase. */
|
|
9
|
+
export declare function legalActions(state: GameState): readonly Action[];
|
|
10
|
+
/** Returns a compact immutable status including current and upcoming floor state. */
|
|
11
|
+
export declare function statusOf(state: GameState): GameStatus;
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { nextInt, seedState } from './random.js';
|
|
2
|
+
import { allMatchedCells, blockBonds, blockCells, canPlace, impactSupportIds, landingY, matchGroups, removeIds, settle, MARK_TICKS, REMOVE_TICKS, GRAVITY_TICKS } from './physics.js';
|
|
3
|
+
import { COLOURS, validateOptions } from './validation.js';
|
|
4
|
+
function floorAt(schedule, placement) {
|
|
5
|
+
switch (schedule.kind) {
|
|
6
|
+
case 'frequent': return placement % 2 === 0 ? 'magnetic' : 'calm';
|
|
7
|
+
case 'occasional': return placement % 4 === 0 ? 'magnetic' : 'calm';
|
|
8
|
+
case 'infrequent': return placement % 8 === 0 ? 'magnetic' : 'calm';
|
|
9
|
+
case 'mid-level': return placement === schedule.placement ? 'magnetic' : 'calm';
|
|
10
|
+
case 'fixed': return schedule.floor;
|
|
11
|
+
case 'authored': return schedule.magneticPlacements.includes(placement) ? 'magnetic' : schedule.defaultFloor ?? 'calm';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function drawQueue(seed, count, colourCount) {
|
|
15
|
+
let randomState = seedState(`magnetic-blocks|magnetic-blocks-1|${String(seed)}`);
|
|
16
|
+
const queue = [];
|
|
17
|
+
for (let piece = 0; piece < count; piece++) {
|
|
18
|
+
const colours = [];
|
|
19
|
+
for (let i = 0; i < 4; i++) {
|
|
20
|
+
const [index, next] = nextInt(randomState, colourCount);
|
|
21
|
+
randomState = next;
|
|
22
|
+
colours.push(COLOURS[index]);
|
|
23
|
+
}
|
|
24
|
+
queue.push(Object.freeze(colours));
|
|
25
|
+
}
|
|
26
|
+
return { queue: Object.freeze(queue), randomState };
|
|
27
|
+
}
|
|
28
|
+
function freezeBlock(block) { return Object.freeze({ ...block, gems: Object.freeze(block.gems.map(gem => Object.freeze({ ...gem }))) }); }
|
|
29
|
+
function freezeState(state) { return Object.freeze(state); }
|
|
30
|
+
function freezeBonds(bonds) { return Object.freeze(bonds.map(edge => Object.freeze({ a: edge.a, b: edge.b })).sort((a, b) => a.a - b.a || a.b - b.b)); }
|
|
31
|
+
function empty(state, reason) { return { state, events: Object.freeze([]), accepted: false, reason }; }
|
|
32
|
+
function completeGoal(state) {
|
|
33
|
+
const goal = state.settings.goal;
|
|
34
|
+
if (!goal)
|
|
35
|
+
return false;
|
|
36
|
+
return goal.kind === 'clear-all' ? state.board.every(gem => gem === null) : goal.targetIds.every(id => !state.board.some(gem => gem?.id === id));
|
|
37
|
+
}
|
|
38
|
+
function spawn(state) {
|
|
39
|
+
if (completeGoal(state))
|
|
40
|
+
return [{ ...state, phase: 'won', active: null, reason: 'goal-complete' }, [{ type: 'run-ended', result: 'won', reason: 'goal-complete', score: state.score }]];
|
|
41
|
+
if (state.placements >= state.settings.pieceLimit || !state.queue.length)
|
|
42
|
+
return [{ ...state, phase: 'finished', active: null, reason: 'queue-exhausted' }, [{ type: 'run-ended', result: 'finished', reason: 'queue-exhausted', score: state.score }]];
|
|
43
|
+
const [colours, ...rest] = state.queue, gems = colours.map((colour, index) => ({ id: state.nextId + index, colour }));
|
|
44
|
+
const block = freezeBlock({ x: Math.floor(state.settings.width / 2) - 1, y: 0, orientation: 0, gems, descent: 0, lockTicks: 0, lockResets: 0, lockStarted: false });
|
|
45
|
+
const floor = floorAt(state.settings.schedule, state.placements + 1);
|
|
46
|
+
if (!canPlace(block, state.board, state.settings.width, state.settings.height, state.settings.mask))
|
|
47
|
+
return [{ ...state, queue: Object.freeze(rest), nextId: state.nextId + 4, active: null, phase: 'lost', floor, nextFloor: floorAt(state.settings.schedule, state.placements + 2), reason: 'top-out' }, [{ type: 'run-ended', result: 'lost', reason: 'top-out', score: state.score }]];
|
|
48
|
+
const next = { ...state, queue: Object.freeze(rest), nextId: state.nextId + 4, active: block, phase: 'falling', floor, nextFloor: floorAt(state.settings.schedule, state.placements + 2), chain: 0, reason: undefined };
|
|
49
|
+
return [next, [{ type: 'piece-spawned', ids: block.gems.map(gem => gem.id), x: block.x, y: block.y, floor, nextFloor: next.nextFloor }]];
|
|
50
|
+
}
|
|
51
|
+
function startMatchOrSpawn(state) {
|
|
52
|
+
const groups = matchGroups(state.board, state.settings.width, state.settings.height, state.settings.mask), cells = allMatchedCells(groups);
|
|
53
|
+
if (cells.length)
|
|
54
|
+
return [{ ...state, phase: 'clear-mark', pendingClear: Object.freeze([...cells]), resolutionTick: 0, chain: state.chain + 1, maxChain: Math.max(state.maxChain, state.chain + 1), gravityBoard: null }, [{ type: 'match-marked', chain: state.chain + 1, cells, ids: cells.map(index => state.board[index].id) }]];
|
|
55
|
+
if (completeGoal(state))
|
|
56
|
+
return [{ ...state, phase: 'won', active: null, reason: 'goal-complete' }, [{ type: 'run-ended', result: 'won', reason: 'goal-complete', score: state.score }]];
|
|
57
|
+
return spawn(state);
|
|
58
|
+
}
|
|
59
|
+
function place(state, action) {
|
|
60
|
+
const hardDrop = action.kind === 'hard-drop';
|
|
61
|
+
const active = state.active;
|
|
62
|
+
const { width, height, mask, magneticImpact } = state.settings;
|
|
63
|
+
const landing = { ...active, y: landingY(active, state.board, width, height, mask) };
|
|
64
|
+
const floor = state.pendingFloorOverride ?? state.floor;
|
|
65
|
+
let board = [...state.board], bonds = [...state.bonds], impactIds = Object.freeze([]);
|
|
66
|
+
const events = [];
|
|
67
|
+
if (floor === 'magnetic' && magneticImpact && hardDrop) {
|
|
68
|
+
impactIds = impactSupportIds(landing, board, width, height, mask);
|
|
69
|
+
if (impactIds.length) {
|
|
70
|
+
const remove = new Set(impactIds);
|
|
71
|
+
board = board.map(gem => gem && remove.has(gem.id) ? null : gem);
|
|
72
|
+
bonds = bonds.filter(edge => !remove.has(edge.a) && !remove.has(edge.b));
|
|
73
|
+
events.push({ type: 'magnetic-impact', floor, removedIds: impactIds, cells: impactIds.map(id => state.board.findIndex(gem => gem?.id === id)) });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (const { index, gem } of blockCells(landing, width))
|
|
77
|
+
board[index] = gem;
|
|
78
|
+
bonds = floor === 'calm' ? [...bonds, ...blockBonds(landing)] : [];
|
|
79
|
+
const settled = settle(board, bonds, floor, width, height, mask);
|
|
80
|
+
const placements = state.placements + 1, charges = state.pendingFloorOverride && state.floorSwitchCharges ? 0 : state.floorSwitchCharges;
|
|
81
|
+
const next = { ...state, board: Object.freeze(board), bonds: freezeBonds(settled.bonds), active: null, phase: 'gravity', score: state.score, moves: state.moves + 1, placements, floor, nextFloor: floorAt(state.settings.schedule, placements + 1), floorSwitchCharges: charges, pendingFloorOverride: null, pendingClear: Object.freeze([]), gravityBoard: settled.board, resolutionTick: 0, impactRemovedIds: Object.freeze([...impactIds]), chain: 0 };
|
|
82
|
+
events.push({ type: 'piece-locked', ids: active.gems.map(gem => gem.id), cells: blockCells(landing, width).map(entry => entry.index), floor, hardDrop, consumedFloorSwitch: Boolean(state.pendingFloorOverride) });
|
|
83
|
+
return { state: next, events: Object.freeze(events), accepted: true };
|
|
84
|
+
}
|
|
85
|
+
function gravityResult(state) {
|
|
86
|
+
const board = state.gravityBoard ?? state.board;
|
|
87
|
+
const next = { ...state, board, gravityBoard: null, resolutionTick: 0 };
|
|
88
|
+
const [continued, events] = startMatchOrSpawn(next);
|
|
89
|
+
return [continued, [{ type: 'gravity-completed', floor: state.floor, board }, ...events]];
|
|
90
|
+
}
|
|
91
|
+
function core(state, action) {
|
|
92
|
+
if (!action || typeof action !== 'object' || !['left', 'right', 'soft-drop', 'rotate-clockwise', 'rotate-anticlockwise', 'hard-drop', 'land', 'pause', 'resume', 'set-floor-override', 'cancel-floor-override'].includes(action.kind ?? ''))
|
|
93
|
+
return empty(state, 'invalid-action');
|
|
94
|
+
const allowed = action.kind === 'set-floor-override' ? ['kind', 'floor'] : ['kind'];
|
|
95
|
+
if (Object.keys(action).some(key => !allowed.includes(key)) || action.kind === 'set-floor-override' && !['calm', 'magnetic'].includes(action.floor))
|
|
96
|
+
return empty(state, 'invalid-action');
|
|
97
|
+
if (action.kind === 'pause') {
|
|
98
|
+
if (!['falling', 'clear-mark', 'clear-remove', 'gravity'].includes(state.phase))
|
|
99
|
+
return empty(state, 'cannot-pause');
|
|
100
|
+
return { state: { ...state, phase: 'paused', pausedPhase: state.phase }, events: [{ type: 'paused' }], accepted: true };
|
|
101
|
+
}
|
|
102
|
+
if (action.kind === 'resume') {
|
|
103
|
+
if (state.phase !== 'paused')
|
|
104
|
+
return empty(state, 'not-paused');
|
|
105
|
+
const { pausedPhase, ...rest } = state;
|
|
106
|
+
return { state: { ...rest, phase: pausedPhase ?? 'falling' }, events: [{ type: 'resumed' }], accepted: true };
|
|
107
|
+
}
|
|
108
|
+
if (state.phase !== 'falling' || !state.active)
|
|
109
|
+
return empty(state, 'action-unavailable');
|
|
110
|
+
if (action.kind === 'set-floor-override') {
|
|
111
|
+
if (!state.settings.floorSwitch || !state.floorSwitchCharges)
|
|
112
|
+
return empty(state, 'floor-switch-unavailable');
|
|
113
|
+
const next = { ...state, pendingFloorOverride: action.floor };
|
|
114
|
+
return { state: next, events: [{ type: 'floor-override-selected', floor: action.floor, charges: state.floorSwitchCharges }], accepted: true };
|
|
115
|
+
}
|
|
116
|
+
if (action.kind === 'cancel-floor-override') {
|
|
117
|
+
if (!state.pendingFloorOverride)
|
|
118
|
+
return empty(state, 'no-floor-override');
|
|
119
|
+
return { state: { ...state, pendingFloorOverride: null }, events: [{ type: 'floor-override-cancelled', charges: state.floorSwitchCharges }], accepted: true };
|
|
120
|
+
}
|
|
121
|
+
const active = state.active, { width, height, mask } = state.settings;
|
|
122
|
+
if (action.kind === 'hard-drop') {
|
|
123
|
+
let landed = active;
|
|
124
|
+
while (canPlace({ ...landed, y: landed.y + 1 }, state.board, width, height, mask))
|
|
125
|
+
landed = { ...landed, y: landed.y + 1 };
|
|
126
|
+
return place({ ...state, active: freezeBlock(landed) }, action);
|
|
127
|
+
}
|
|
128
|
+
if (action.kind === 'land')
|
|
129
|
+
return place(state, action);
|
|
130
|
+
if (action.kind === 'soft-drop') {
|
|
131
|
+
if (!canPlace({ ...active, y: active.y + 1 }, state.board, width, height, mask))
|
|
132
|
+
return place(state, action);
|
|
133
|
+
const moved = { ...active, y: active.y + 1, descent: 0 };
|
|
134
|
+
const grounded = !canPlace({ ...moved, y: moved.y + 1 }, state.board, width, height, mask);
|
|
135
|
+
const next = { ...state, active: freezeBlock({ ...moved, ...(state.settings.mode === 'arcade' && grounded ? { lockStarted: true, lockTicks: 0 } : {}) }) };
|
|
136
|
+
return { state: next, events: [{ type: 'piece-moved', direction: 'down', y: next.active.y }], accepted: true };
|
|
137
|
+
}
|
|
138
|
+
if (action.kind === 'left' || action.kind === 'right') {
|
|
139
|
+
const x = active.x + (action.kind === 'left' ? -1 : 1), moved = { ...active, x };
|
|
140
|
+
if (!canPlace(moved, state.board, width, height, mask))
|
|
141
|
+
return empty(state, 'blocked');
|
|
142
|
+
const grounded = !canPlace({ ...active, y: active.y + 1 }, state.board, width, height, mask);
|
|
143
|
+
const reset = state.settings.mode === 'arcade' && grounded && active.lockResets < 8;
|
|
144
|
+
const nextGrounded = !canPlace({ ...moved, y: moved.y + 1 }, state.board, width, height, mask);
|
|
145
|
+
return { state: { ...state, active: freezeBlock({ ...moved, lockTicks: reset ? 0 : active.lockTicks, lockResets: active.lockResets + (reset ? 1 : 0), lockStarted: reset ? nextGrounded : nextGrounded || active.lockStarted, ...(state.settings.mode === 'arcade' && !nextGrounded && active.lockResets < 8 ? { lockTicks: 0, lockStarted: false } : {}) }) }, events: [{ type: 'piece-moved', direction: action.kind, x }], accepted: true };
|
|
146
|
+
}
|
|
147
|
+
const orientation = ((active.orientation + (action.kind === 'rotate-clockwise' ? 1 : 3)) % 4);
|
|
148
|
+
const rotated = { ...active, orientation };
|
|
149
|
+
if (!canPlace(rotated, state.board, width, height, mask))
|
|
150
|
+
return empty(state, 'blocked');
|
|
151
|
+
const grounded = !canPlace({ ...active, y: active.y + 1 }, state.board, width, height, mask);
|
|
152
|
+
const reset = state.settings.mode === 'arcade' && grounded && active.lockResets < 8;
|
|
153
|
+
return { state: { ...state, active: freezeBlock({ ...rotated, lockTicks: reset ? 0 : active.lockTicks, lockResets: active.lockResets + (reset ? 1 : 0), lockStarted: reset ? true : active.lockStarted }) }, events: [{ type: 'piece-rotated', orientation, ids: rotated.gems.map(gem => gem.id), cells: blockCells(rotated, width).map(item => item.index) }], accepted: true };
|
|
154
|
+
}
|
|
155
|
+
/** Creates an immutable seeded 2×2-block game with a fixed queue and explicit floor schedule. */
|
|
156
|
+
export function createGame(options = {}) {
|
|
157
|
+
const valid = validateOptions(options);
|
|
158
|
+
let randomState, queue;
|
|
159
|
+
if (valid.queue) {
|
|
160
|
+
queue = valid.queue;
|
|
161
|
+
randomState = seedState(`magnetic-blocks|magnetic-blocks-1|${String(valid.seed)}`);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
const generated = drawQueue(valid.seed, valid.pieceLimit, valid.colourCount);
|
|
165
|
+
queue = generated.queue;
|
|
166
|
+
randomState = generated.randomState;
|
|
167
|
+
}
|
|
168
|
+
const nextId = Math.max(0, ...valid.initialBoard.flatMap(gem => gem ? [gem.id] : [])) + 1;
|
|
169
|
+
const settings = Object.freeze({ mode: valid.mode, width: valid.width, height: valid.height, colourCount: valid.colourCount, seed: valid.seed, mask: valid.mask, schedule: valid.schedule, floorSwitch: valid.floorSwitch, magneticImpact: valid.magneticImpact, pieceLimit: valid.pieceLimit, initialBoard: valid.initialBoard, bonds: valid.bonds, ...(valid.queue ? { initialQueue: valid.queue } : {}), ...(valid.goal ? { goal: valid.goal } : {}) });
|
|
170
|
+
const state = { game: 'magnetic-blocks', rules: 'magnetic-blocks-1', settings, board: valid.initialBoard, bonds: valid.bonds, active: null, queue, randomState, nextId, phase: 'falling', score: 0, moves: 0, placements: 0, chain: 0, maxChain: 0, floor: floorAt(valid.schedule, 1), nextFloor: floorAt(valid.schedule, 2), floorSwitchCharges: valid.floorSwitch ? 1 : 0, pendingFloorOverride: null, pendingClear: Object.freeze([]), gravityBoard: null, resolutionTick: 0, impactRemovedIds: Object.freeze([]), elapsedTicks: 0, recording: Object.freeze([]) };
|
|
171
|
+
if (matchGroups(state.board, valid.width, valid.height, valid.mask).length)
|
|
172
|
+
throw new RangeError('Initial board must not contain a clearable group');
|
|
173
|
+
const [started] = spawn(state);
|
|
174
|
+
return freezeState(started);
|
|
175
|
+
}
|
|
176
|
+
/** Applies one movement, rotation, floor-switch or placement action without mutation. */
|
|
177
|
+
export function applyAction(state, action) {
|
|
178
|
+
const result = core(state, action);
|
|
179
|
+
if (!result.accepted)
|
|
180
|
+
return { ...result, state: freezeState(result.state) };
|
|
181
|
+
const previous = state.recording.at(-1);
|
|
182
|
+
const ordinal = previous?.tick === state.elapsedTicks ? previous.ordinal + 1 : 0;
|
|
183
|
+
const recorded = Object.freeze({ tick: state.elapsedTicks, ordinal, action: Object.freeze({ ...action }) });
|
|
184
|
+
const next = freezeState({ ...result.state, recording: Object.freeze([...state.recording, recorded]) });
|
|
185
|
+
return { ...result, state: next };
|
|
186
|
+
}
|
|
187
|
+
/** Advances fixed 60 Hz time, resolving phases in all modes and falling/locking only in Arcade. */
|
|
188
|
+
export function advanceTicks(state, ticks) {
|
|
189
|
+
if (!Number.isInteger(ticks) || ticks < 0 || ticks > 3600)
|
|
190
|
+
throw new RangeError('ticks must be an integer from 0 to 3600');
|
|
191
|
+
let current = state;
|
|
192
|
+
const events = [];
|
|
193
|
+
for (let i = 0; i < ticks; i++) {
|
|
194
|
+
if (['paused', 'won', 'lost', 'finished'].includes(current.phase))
|
|
195
|
+
break;
|
|
196
|
+
if (current.phase === 'falling' && !current.active)
|
|
197
|
+
break;
|
|
198
|
+
current = { ...current, elapsedTicks: current.elapsedTicks + 1 };
|
|
199
|
+
if (current.phase === 'falling') {
|
|
200
|
+
if (current.settings.mode === 'relaxed' || !current.active)
|
|
201
|
+
continue;
|
|
202
|
+
const active = current.active, grounded = !canPlace({ ...active, y: active.y + 1 }, current.board, current.settings.width, current.settings.height, current.settings.mask);
|
|
203
|
+
if (grounded) {
|
|
204
|
+
const lockTicks = active.lockStarted ? active.lockTicks + 1 : 0;
|
|
205
|
+
current = { ...current, active: freezeBlock({ ...active, lockStarted: true, lockTicks }) };
|
|
206
|
+
if (lockTicks >= 24) {
|
|
207
|
+
const result = place(current, { kind: 'land' });
|
|
208
|
+
current = result.state;
|
|
209
|
+
events.push(...result.events);
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const interval = Math.max(6, Math.round(60 * 0.85 ** Math.floor(current.placements / 30)));
|
|
214
|
+
if (active.descent + 1 >= interval) {
|
|
215
|
+
const fallen = freezeBlock({ ...active, y: active.y + 1, descent: 0 });
|
|
216
|
+
const nowGrounded = !canPlace({ ...fallen, y: fallen.y + 1 }, current.board, current.settings.width, current.settings.height, current.settings.mask);
|
|
217
|
+
current = { ...current, active: freezeBlock({ ...fallen, lockStarted: nowGrounded ? true : active.lockResets < 8 ? false : active.lockStarted, lockTicks: nowGrounded || active.lockResets >= 8 ? active.lockTicks : 0 }) };
|
|
218
|
+
events.push({ type: 'piece-fell', id: active.gems[0].id, fromY: active.y, toY: active.y + 1, grounded: nowGrounded });
|
|
219
|
+
}
|
|
220
|
+
else
|
|
221
|
+
current = { ...current, active: freezeBlock({ ...active, descent: active.descent + 1, ...(active.lockResets < 8 ? { lockTicks: 0, lockStarted: false } : {}) }) };
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const resolutionTick = current.resolutionTick + 1;
|
|
225
|
+
current = { ...current, resolutionTick };
|
|
226
|
+
if (current.phase === 'clear-mark' && resolutionTick >= MARK_TICKS) {
|
|
227
|
+
const removed = removeIds(current.board, current.bonds, current.pendingClear);
|
|
228
|
+
const remainingIds = new Set(removed.board.flatMap(gem => gem ? [gem.id] : []));
|
|
229
|
+
const gravity = settle(removed.board, removed.bonds, current.floor, current.settings.width, current.settings.height, current.settings.mask);
|
|
230
|
+
const points = removed.ids.length * 10 * Math.max(1, current.chain);
|
|
231
|
+
current = { ...current, board: removed.board, bonds: gravity.bonds, score: current.score + points, phase: 'clear-remove', resolutionTick: 0, gravityBoard: gravity.board, pendingClear: Object.freeze([]) };
|
|
232
|
+
events.push({ type: 'cells-removed', ids: removed.ids, board: current.board, points, remainingIds: [...remainingIds] }, { type: 'score-changed', score: current.score, points });
|
|
233
|
+
}
|
|
234
|
+
else if (current.phase === 'clear-remove' && resolutionTick >= REMOVE_TICKS) {
|
|
235
|
+
current = { ...current, phase: 'gravity', resolutionTick: 0 };
|
|
236
|
+
events.push({ type: 'gravity-started', floor: current.floor, board: current.board, settledBoard: current.gravityBoard });
|
|
237
|
+
}
|
|
238
|
+
else if (current.phase === 'gravity' && resolutionTick >= GRAVITY_TICKS) {
|
|
239
|
+
const result = gravityResult(current);
|
|
240
|
+
current = result[0];
|
|
241
|
+
events.push(...result[1]);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return { state: freezeState(current), events: Object.freeze(events), accepted: true };
|
|
245
|
+
}
|
|
246
|
+
/** Lists accepted controls for the current falling or resolution phase. */
|
|
247
|
+
export function legalActions(state) {
|
|
248
|
+
if (state.phase === 'paused')
|
|
249
|
+
return Object.freeze([{ kind: 'resume' }]);
|
|
250
|
+
if (['clear-mark', 'clear-remove', 'gravity'].includes(state.phase))
|
|
251
|
+
return Object.freeze([{ kind: 'pause' }]);
|
|
252
|
+
if (state.phase === 'falling' && state.active)
|
|
253
|
+
return Object.freeze([
|
|
254
|
+
...(canPlace({ ...state.active, x: state.active.x - 1 }, state.board, state.settings.width, state.settings.height, state.settings.mask) ? [{ kind: 'left' }] : []),
|
|
255
|
+
...(canPlace({ ...state.active, x: state.active.x + 1 }, state.board, state.settings.width, state.settings.height, state.settings.mask) ? [{ kind: 'right' }] : []),
|
|
256
|
+
{ kind: 'soft-drop' }, { kind: 'rotate-clockwise' }, { kind: 'rotate-anticlockwise' }, { kind: 'hard-drop' }, { kind: 'land' },
|
|
257
|
+
...(state.settings.floorSwitch && state.floorSwitchCharges ? [{ kind: 'set-floor-override', floor: 'calm' }, { kind: 'set-floor-override', floor: 'magnetic' }] : []),
|
|
258
|
+
...(state.pendingFloorOverride ? [{ kind: 'cancel-floor-override' }] : []), { kind: 'pause' }
|
|
259
|
+
]);
|
|
260
|
+
return Object.freeze([]);
|
|
261
|
+
}
|
|
262
|
+
/** Returns a compact immutable status including current and upcoming floor state. */
|
|
263
|
+
export function statusOf(state) { return Object.freeze({ phase: state.phase, score: state.score, moves: state.moves, placements: state.placements, floor: state.pendingFloorOverride ?? state.floor, nextFloor: state.nextFloor, floorSwitchCharges: state.floorSwitchCharges, pendingFloorOverride: state.pendingFloorOverride, maxChain: state.maxChain, ...(state.reason ? { reason: state.reason } : {}) }); }
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { GameState } from './types.js';
|
|
2
|
+
/** Restarts the same seed, custom starting board, fixed queue, rules and floor options. */
|
|
3
|
+
export declare function restartGame(state: GameState): GameState;
|
|
4
|
+
/** Encodes canonical settings, accepted tick-stamped actions and a verified replay checkpoint. */
|
|
5
|
+
export declare function encodeGame(state: GameState): string;
|
|
6
|
+
/** Replays bounded canonical input and verifies every saved checkpoint field. */
|
|
7
|
+
export declare function decodeGame(text: string): GameState;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { advanceTicks, applyAction, createGame } from './engine.js';
|
|
2
|
+
const RULES = 'magnetic-blocks-1';
|
|
3
|
+
const MAX_SAVE_BYTES = 2 * 1024 * 1024;
|
|
4
|
+
const MAX_ACTIONS = 100_000;
|
|
5
|
+
const MAX_TICKS = 600_000;
|
|
6
|
+
const ACTIONS = ['left', 'right', 'soft-drop', 'rotate-clockwise', 'rotate-anticlockwise', 'hard-drop', 'land', 'pause', 'resume', 'set-floor-override', 'cancel-floor-override'];
|
|
7
|
+
function stable(value, depth = 0) {
|
|
8
|
+
if (depth > 60)
|
|
9
|
+
throw new TypeError('Save data is nested too deeply');
|
|
10
|
+
if (Array.isArray(value))
|
|
11
|
+
return `[${value.map(item => stable(item, depth + 1)).join(',')}]`;
|
|
12
|
+
if (value && typeof value === 'object')
|
|
13
|
+
return `{${Object.entries(value).filter(([, item]) => item !== undefined).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${stable(item, depth + 1)}`).join(',')}}`;
|
|
14
|
+
return JSON.stringify(value) ?? 'null';
|
|
15
|
+
}
|
|
16
|
+
function optionsFromSettings(settings) {
|
|
17
|
+
return {
|
|
18
|
+
mode: settings.mode, width: settings.width, height: settings.height, colourCount: settings.colourCount,
|
|
19
|
+
seed: settings.seed, mask: settings.mask, initialBoard: settings.initialBoard, bonds: settings.bonds,
|
|
20
|
+
...(settings.initialQueue ? { queue: settings.initialQueue } : {}), pieceLimit: settings.pieceLimit,
|
|
21
|
+
schedule: settings.schedule, floorSwitch: settings.floorSwitch, magneticImpact: settings.magneticImpact,
|
|
22
|
+
...(settings.goal ? { goal: settings.goal } : {}),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function createFromSettings(settings) {
|
|
26
|
+
if (!settings || typeof settings !== 'object' || Array.isArray(settings))
|
|
27
|
+
throw new TypeError('Save has invalid settings');
|
|
28
|
+
const state = createGame(optionsFromSettings(settings));
|
|
29
|
+
if (stable(state.settings) !== stable(settings))
|
|
30
|
+
throw new TypeError('Save settings are not canonical');
|
|
31
|
+
return state;
|
|
32
|
+
}
|
|
33
|
+
function replay(settings, actions, elapsedTicks) {
|
|
34
|
+
let state = createFromSettings(settings);
|
|
35
|
+
let previousTick = -1, previousOrdinal = -1;
|
|
36
|
+
for (let index = 0; index < actions.length; index++) {
|
|
37
|
+
const raw = actions[index];
|
|
38
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
39
|
+
throw new TypeError(`Invalid action record ${index + 1}`);
|
|
40
|
+
const item = raw;
|
|
41
|
+
if (Object.keys(item).some(key => !['tick', 'ordinal', 'action'].includes(key)) || !Number.isSafeInteger(item.tick) || item.tick < 0 || item.tick > elapsedTicks || !Number.isSafeInteger(item.ordinal) || !item.action || typeof item.action !== 'object' || Array.isArray(item.action) || !ACTIONS.includes(item.action.kind))
|
|
42
|
+
throw new TypeError(`Invalid action record ${index + 1}`);
|
|
43
|
+
const actionKeys = item.action.kind === 'set-floor-override' ? ['kind', 'floor'] : ['kind'];
|
|
44
|
+
if (Object.keys(item.action).some(key => !actionKeys.includes(key)))
|
|
45
|
+
throw new TypeError(`Invalid action fields at record ${index + 1}`);
|
|
46
|
+
if (item.action.kind === 'set-floor-override' && item.action.floor !== 'calm' && item.action.floor !== 'magnetic')
|
|
47
|
+
throw new TypeError(`Invalid floor override at record ${index + 1}`);
|
|
48
|
+
if (item.tick < previousTick || item.ordinal !== (item.tick === previousTick ? previousOrdinal + 1 : 0))
|
|
49
|
+
throw new TypeError(`Invalid action ordering at record ${index + 1}`);
|
|
50
|
+
while (state.elapsedTicks < item.tick) {
|
|
51
|
+
const before = state.elapsedTicks;
|
|
52
|
+
state = advanceTicks(state, Math.min(3600, item.tick - state.elapsedTicks)).state;
|
|
53
|
+
if (state.elapsedTicks === before)
|
|
54
|
+
throw new TypeError(`Action occurs after the run stopped at record ${index + 1}`);
|
|
55
|
+
}
|
|
56
|
+
const result = applyAction(state, item.action);
|
|
57
|
+
if (!result.accepted)
|
|
58
|
+
throw new TypeError(`Illegal action at record ${index + 1}: ${result.reason ?? 'rejected'}`);
|
|
59
|
+
state = result.state;
|
|
60
|
+
previousTick = item.tick;
|
|
61
|
+
previousOrdinal = item.ordinal;
|
|
62
|
+
}
|
|
63
|
+
while (state.elapsedTicks < elapsedTicks) {
|
|
64
|
+
const before = state.elapsedTicks;
|
|
65
|
+
state = advanceTicks(state, Math.min(3600, elapsedTicks - state.elapsedTicks)).state;
|
|
66
|
+
if (state.elapsedTicks === before)
|
|
67
|
+
throw new TypeError('Checkpoint time exceeds the running game clock');
|
|
68
|
+
}
|
|
69
|
+
return state;
|
|
70
|
+
}
|
|
71
|
+
/** Restarts the same seed, custom starting board, fixed queue, rules and floor options. */
|
|
72
|
+
export function restartGame(state) { return createFromSettings(state.settings); }
|
|
73
|
+
/** Encodes canonical settings, accepted tick-stamped actions and a verified replay checkpoint. */
|
|
74
|
+
export function encodeGame(state) {
|
|
75
|
+
if (!Number.isSafeInteger(state.elapsedTicks) || state.elapsedTicks < 0 || state.elapsedTicks > MAX_TICKS || state.recording.length > MAX_ACTIONS)
|
|
76
|
+
throw new RangeError('Run exceeds replay limits');
|
|
77
|
+
const derived = replay(state.settings, state.recording, state.elapsedTicks);
|
|
78
|
+
if (stable(derived) !== stable(state))
|
|
79
|
+
throw new TypeError('State does not match its recorded actions and clock');
|
|
80
|
+
const text = JSON.stringify({ format: 1, game: state.game, rules: state.rules, settings: state.settings, actions: state.recording, elapsedTicks: state.elapsedTicks, checkpoint: state });
|
|
81
|
+
if (new TextEncoder().encode(text).byteLength > MAX_SAVE_BYTES)
|
|
82
|
+
throw new RangeError('Save exceeds 2 MiB');
|
|
83
|
+
return text;
|
|
84
|
+
}
|
|
85
|
+
/** Replays bounded canonical input and verifies every saved checkpoint field. */
|
|
86
|
+
export function decodeGame(text) {
|
|
87
|
+
if (typeof text !== 'string' || new TextEncoder().encode(text).byteLength > MAX_SAVE_BYTES)
|
|
88
|
+
throw new RangeError('Save must be text no larger than 2 MiB');
|
|
89
|
+
let value;
|
|
90
|
+
try {
|
|
91
|
+
value = JSON.parse(text);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
throw new TypeError('Save is not valid JSON');
|
|
95
|
+
}
|
|
96
|
+
if (!value || value.format !== 1 || value.game !== 'magnetic-blocks' || value.rules !== RULES || !value.settings || !Array.isArray(value.actions) || !value.checkpoint || Object.keys(value).some(key => !['format', 'game', 'rules', 'settings', 'actions', 'elapsedTicks', 'checkpoint'].includes(key)))
|
|
97
|
+
throw new TypeError('Unsupported or invalid Magnetic Blocks save');
|
|
98
|
+
if (!Number.isSafeInteger(value.elapsedTicks) || value.elapsedTicks < 0 || value.elapsedTicks > MAX_TICKS || value.actions.length > MAX_ACTIONS)
|
|
99
|
+
throw new RangeError('Save exceeds replay limits');
|
|
100
|
+
const state = replay(value.settings, value.actions, value.elapsedTicks);
|
|
101
|
+
if (state.game !== value.game || state.rules !== value.rules || stable(state) !== stable(value.checkpoint))
|
|
102
|
+
throw new TypeError('Save checkpoint does not match its action recording');
|
|
103
|
+
return state;
|
|
104
|
+
}
|