@johnmorrisdotca/houseki 0.1.0 → 0.2.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.
@@ -10,11 +10,17 @@ export interface ChainDifficultyMetrics {
10
10
  readonly forcedPlacementShare: number;
11
11
  readonly seededPlayoutSamples: number;
12
12
  readonly seededPlayoutQueueDepth: number;
13
+ /** Number of pair placements randomized in each seeded playout. */
14
+ readonly seededPlayoutVariablePrefixLength?: number;
15
+ /** Number of later witnessed placements held fixed in each seeded playout. */
16
+ readonly seededPlayoutFixedSuffixLength?: number;
13
17
  readonly seededPlayoutSuccesses: number;
14
18
  readonly seededPlayoutSuccessRate: number;
15
19
  readonly setupPairsBeforePayoff: number;
16
20
  readonly requiredSetupPairs: number;
17
21
  readonly verifiedSetupDependencies: number;
22
+ /** Number of pre-payoff steps checked by exhaustive single-step deviations. */
23
+ readonly setupDependencyProbeSteps?: number;
18
24
  readonly requiredChainDepth: number;
19
25
  readonly requiredRotations: number;
20
26
  readonly requiredWallKicks: number;
@@ -26,6 +32,10 @@ export interface ChainCampaignLevel {
26
32
  readonly id: string;
27
33
  readonly number: number;
28
34
  readonly title: LocalizedText;
35
+ readonly objective?: LocalizedText;
36
+ readonly nature?: true;
37
+ readonly weather?: 'frequent';
38
+ readonly magneticQueue?: readonly (readonly [boolean, boolean])[];
29
39
  readonly canonicalKeyHash: string;
30
40
  readonly width: number;
31
41
  readonly height: number;
@@ -43,6 +53,12 @@ export interface ChainCampaignLevel {
43
53
  readonly proofStatus: 'engine-witness-verified';
44
54
  readonly reviewStatus: 'human-review-pending' | 'human-reviewed';
45
55
  }
56
+ export interface NatureChainCampaignLevel extends ChainCampaignLevel {
57
+ readonly objective: LocalizedText;
58
+ readonly nature: true;
59
+ readonly weather?: 'frequent';
60
+ readonly magneticQueue?: readonly (readonly [boolean, boolean])[];
61
+ }
46
62
  export interface ChainCampaignManifest {
47
63
  readonly count: number;
48
64
  readonly candidatePoolCount: number;
@@ -56,6 +72,9 @@ export interface ChainCampaignManifest {
56
72
  readonly checksum: string;
57
73
  readonly levels: readonly ChainCampaignLevel[];
58
74
  }
75
+ export interface NatureChainCampaignManifest extends Omit<ChainCampaignManifest, 'levels'> {
76
+ readonly levels: readonly NatureChainCampaignLevel[];
77
+ }
59
78
  export interface ChainTutorialStep {
60
79
  readonly instruction: LocalizedText;
61
80
  readonly action: string;
@@ -76,5 +95,7 @@ export interface ChainTutorialDefinition {
76
95
  export interface ChainContentData {
77
96
  readonly campaign: ChainCampaignManifest;
78
97
  readonly tutorials: readonly ChainTutorialDefinition[];
98
+ readonly shizen?: NatureChainCampaignManifest;
99
+ readonly arashi?: NatureChainCampaignManifest;
79
100
  }
80
101
  export type { Colour, Orientation, WitnessStep, ChallengeGoal };
@@ -1,11 +1,19 @@
1
- import type { ChainCampaignLevel, ChainCampaignManifest, ChainTutorialDefinition } from './content-types.js';
1
+ import type { ChainCampaignLevel, ChainCampaignManifest, ChainTutorialDefinition, NatureChainCampaignLevel, NatureChainCampaignManifest } from './content-types.js';
2
2
  import type { GameState } from './types.js';
3
3
  export declare const campaignManifest: ChainCampaignManifest;
4
4
  export declare const levelManifest: readonly ChainCampaignLevel[];
5
5
  export declare const tutorialManifest: readonly ChainTutorialDefinition[];
6
+ export declare const shizenCampaignManifest: NatureChainCampaignManifest;
7
+ export declare const shizenLevelManifest: readonly NatureChainCampaignLevel[];
8
+ export declare const arashiCampaignManifest: NatureChainCampaignManifest;
9
+ export declare const arashiLevelManifest: readonly NatureChainCampaignLevel[];
6
10
  /** Find a campaign challenge by stable content ID or its current one-based number. */
7
11
  export declare function getLevel(id: string | number): ChainCampaignLevel | undefined;
8
12
  /** Create a fresh engine-validated challenge from a stable content ID or display number. */
9
13
  export declare function createLevel(id: string | number): GameState;
14
+ export declare function getShizenLevel(id: string | number): NatureChainCampaignLevel | undefined;
15
+ export declare function createShizenLevel(id: string | number): GameState;
16
+ export declare function getArashiLevel(id: string | number): NatureChainCampaignLevel | undefined;
17
+ export declare function createArashiLevel(id: string | number): GameState;
10
18
  /** Find a persistent interactive lesson by its stable ID. */
11
19
  export declare function getTutorial(id: string): ChainTutorialDefinition | undefined;
@@ -11,6 +11,10 @@ function deepFreeze(value) {
11
11
  export const campaignManifest = deepFreeze(contentData.campaign);
12
12
  export const levelManifest = campaignManifest.levels;
13
13
  export const tutorialManifest = deepFreeze(contentData.tutorials);
14
+ export const shizenCampaignManifest = deepFreeze((contentData.shizen ?? { ...contentData.campaign, count: 0, levels: [], checksum: '' }));
15
+ export const shizenLevelManifest = shizenCampaignManifest.levels;
16
+ export const arashiCampaignManifest = deepFreeze((contentData.arashi ?? { ...contentData.campaign, count: 0, levels: [], checksum: '' }));
17
+ export const arashiLevelManifest = arashiCampaignManifest.levels;
14
18
  /** Find a campaign challenge by stable content ID or its current one-based number. */
15
19
  export function getLevel(id) {
16
20
  if (typeof id === 'number')
@@ -24,5 +28,14 @@ export function createLevel(id) {
24
28
  throw new RangeError(`Unknown Colour Chains level: ${String(id)}`);
25
29
  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
30
  }
31
+ function createNatureLevel(level) {
32
+ 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, nature: true, ...(level.weather ? { weather: level.weather } : {}), ...(level.magneticQueue ? { magneticQueue: level.magneticQueue } : {}) });
33
+ }
34
+ export function getShizenLevel(id) { return typeof id === 'number' ? shizenLevelManifest.find(level => level.number === id) : shizenLevelManifest.find(level => level.id === id); }
35
+ export function createShizenLevel(id) { const level = getShizenLevel(id); if (!level)
36
+ throw new RangeError(`Unknown Shizen Colour Chains level: ${String(id)}`); return createNatureLevel(level); }
37
+ export function getArashiLevel(id) { return typeof id === 'number' ? arashiLevelManifest.find(level => level.number === id) : arashiLevelManifest.find(level => level.id === id); }
38
+ export function createArashiLevel(id) { const level = getArashiLevel(id); if (!level)
39
+ throw new RangeError(`Unknown Arashi Colour Chains level: ${String(id)}`); return createNatureLevel(level); }
27
40
  /** Find a persistent interactive lesson by its stable ID. */
28
41
  export function getTutorial(id) { return tutorialManifest.find(tutorial => tutorial.id === id); }
@@ -136,6 +136,8 @@ function terminalAfterResolution(state) {
136
136
  if (movement.length)
137
137
  return [{ ...current, phase: 'gravity', active: null, gravityBoard: settled, resolutionTick: 0 }, [...weatherEvents, { type: 'cells-fell', cells: movement, before: current.board, after: settled }]];
138
138
  current = { ...current, board: settled };
139
+ if (current.settings.mode === 'challenge' && satisfied(current))
140
+ return [{ ...current, phase: 'won', active: null, reason: 'goal-complete' }, [...weatherEvents, { type: 'run-ended', reason: 'goal-complete', score: current.score }]];
139
141
  }
140
142
  }
141
143
  if (current.settings.mode === 'challenge') {
@@ -144,8 +146,9 @@ function terminalAfterResolution(state) {
144
146
  return [{ ...current, phase: 'lost', active: null, reason: 'challenge-queue-exhausted' }, [...weatherEvents, { type: 'run-ended', reason: 'challenge-queue-exhausted' }]];
145
147
  const queue = current.settings.queue;
146
148
  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
+ const pair = makePair(current, colours, current.settings.magneticQueue?.[nextIndex]);
150
+ const nextMagnetic = current.settings.nature ? queue.slice(nextIndex + 1, nextIndex + 4).map((_, index) => current.settings.magneticQueue?.[nextIndex + index + 1] ?? [false, false]) : undefined;
151
+ const next = { ...current, active: pair, next: queue.slice(nextIndex + 1, nextIndex + 4), ...(nextMagnetic ? { nextMagnetic: Object.freeze(nextMagnetic) } : {}), nextId: current.nextId + 2 };
149
152
  if (!pairFits(next, pair))
150
153
  return [{ ...next, active: null, phase: 'lost', reason: 'spawn-collision' }, [...weatherEvents, { type: 'run-ended', reason: 'spawn-collision' }]];
151
154
  return [next, [...weatherEvents, { type: 'pair-spawned', ids: pair.gems.map(gem => gem.id), colours }]];
@@ -469,8 +472,9 @@ export function initialChallengeState(settings, visibleBoard, queue) {
469
472
  board[i + settings.width * HIDDEN] = visibleBoard[i];
470
473
  const maxId = Math.max(0, ...visibleBoard.flatMap(gem => gem ? [gem.id] : []));
471
474
  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 };
475
+ const active = makePair(base, queue[0], settings.magneticQueue?.[0]);
476
+ const nextMagnetic = settings.nature ? queue.slice(1, 4).map((_, index) => settings.magneticQueue?.[index + 1] ?? [false, false]) : undefined;
477
+ return { ...base, active, ...(nextMagnetic ? { nextMagnetic: Object.freeze(nextMagnetic) } : {}), nextId: maxId + 3 };
474
478
  }
475
479
  /** Creates a deterministic seeded game with one active pair and three previews. */
476
480
  export function createGame(options = {}) {
@@ -14,6 +14,7 @@ export function setHeldInput(state, control, pressed) {
14
14
  /** Queues a one-shot rotation, drop, Place, pause or resume edge for the next logical tick. */
15
15
  export function queueInputEdge(state, action) { return { ...state, queued: Object.freeze([...state.queued, action]) }; }
16
16
  /** Releases every held or queued control after focus loss, lock, resolution or pause. */
17
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- the public signature takes the state a caller holds, though nothing of it is kept
17
18
  export function releaseAllInput(state) { return createInputScheduler(); }
18
19
  /** Resolves this tick's inputs in lateral, rotation, drop, soft-drop, pause order. */
19
20
  export function actionsForTick(game, input) {
@@ -17,7 +17,7 @@ function stable(value, depth = 0) {
17
17
  function challengeOptions(settings) {
18
18
  if (settings.mode !== 'challenge' || !settings.challengeId || !settings.goal || !settings.initialBoard || !settings.queue || !settings.witness)
19
19
  throw new TypeError('Challenge settings are incomplete');
20
- return { id: settings.challengeId, width: settings.width, height: settings.height, colourCount: settings.colourCount, seed: settings.seed, board: settings.initialBoard, queue: settings.queue, goal: settings.goal, witness: settings.witness };
20
+ return { id: settings.challengeId, width: settings.width, height: settings.height, colourCount: settings.colourCount, seed: settings.seed, board: settings.initialBoard, queue: settings.queue, ...(settings.magneticQueue ? { magneticQueue: settings.magneticQueue } : {}), ...(settings.nature ? { nature: true } : {}), ...(settings.weather ? { weather: settings.weather } : {}), goal: settings.goal, witness: settings.witness };
21
21
  }
22
22
  function createFromSettings(settings) {
23
23
  if (!settings || typeof settings !== 'object' || Array.isArray(settings))
@@ -6,8 +6,8 @@ export interface Gem {
6
6
  readonly colour: Colour;
7
7
  readonly magnetic?: true;
8
8
  }
9
- /** Authored Challenge layouts do not permit Shizen-marked stones. */
10
- export type ChallengeGem = Omit<Gem, 'magnetic'>;
9
+ /** Authored challenge layouts can include explicit Shizen-marked stones. */
10
+ export type ChallengeGem = Gem;
11
11
  /** The pivot-to-satellite direction, in clockwise order. */
12
12
  export type Orientation = 'up' | 'right' | 'down' | 'left';
13
13
  /** A visible or hidden board coordinate. */
@@ -57,6 +57,7 @@ export interface Settings {
57
57
  readonly goal?: ChallengeGoal;
58
58
  readonly initialBoard?: readonly (ChallengeGem | null)[];
59
59
  readonly queue?: readonly (readonly [Colour, Colour])[];
60
+ readonly magneticQueue?: readonly (readonly [boolean, boolean])[];
60
61
  readonly witness?: readonly WitnessStep[];
61
62
  }
62
63
  /** Challenge objective; target IDs refer to the visible starting board. */
@@ -81,6 +82,9 @@ export interface ChallengeOptions {
81
82
  readonly seed?: string;
82
83
  readonly board: readonly (ChallengeGem | null)[];
83
84
  readonly queue: readonly (readonly [Colour, Colour])[];
85
+ readonly magneticQueue?: readonly (readonly [boolean, boolean])[];
86
+ readonly nature?: boolean;
87
+ readonly weather?: WeatherSchedule;
84
88
  readonly goal: ChallengeGoal;
85
89
  readonly witness?: readonly WitnessStep[];
86
90
  }
@@ -4,5 +4,5 @@ export { createGame, applyAction, advanceTicks, legalActions, statusOf, landingC
4
4
  export { createChallenge } from './colour-chains/challenge.js';
5
5
  export { restartGame, encodeGame, decodeGame } from './colour-chains/persistence.js';
6
6
  export { createInputScheduler, setHeldInput, queueInputEdge, releaseAllInput, actionsForTick, applyScheduledActions, processInputTick } from './colour-chains/input.js';
7
- export { campaignManifest, levelManifest, tutorialManifest, getLevel, createLevel, getTutorial } from './colour-chains/content.js';
8
- export type { LocalizedText, ChainDifficultyMetrics, ChainCampaignLevel, ChainCampaignManifest, ChainTutorialStep, ChainTutorialDefinition, ChainContentData } from './colour-chains/content-types.js';
7
+ export { campaignManifest, levelManifest, tutorialManifest, getLevel, createLevel, getTutorial, shizenCampaignManifest, shizenLevelManifest, getShizenLevel, createShizenLevel, arashiCampaignManifest, arashiLevelManifest, getArashiLevel, createArashiLevel } from './colour-chains/content.js';
8
+ export type { LocalizedText, ChainDifficultyMetrics, ChainCampaignLevel, ChainCampaignManifest, NatureChainCampaignLevel, NatureChainCampaignManifest, ChainTutorialStep, ChainTutorialDefinition, ChainContentData } from './colour-chains/content-types.js';
@@ -4,4 +4,4 @@ export { createGame, applyAction, advanceTicks, legalActions, statusOf, landingC
4
4
  export { createChallenge } from './colour-chains/challenge.js';
5
5
  export { restartGame, encodeGame, decodeGame } from './colour-chains/persistence.js';
6
6
  export { createInputScheduler, setHeldInput, queueInputEdge, releaseAllInput, actionsForTick, applyScheduledActions, processInputTick } from './colour-chains/input.js';
7
- export { campaignManifest, levelManifest, tutorialManifest, getLevel, createLevel, getTutorial } from './colour-chains/content.js';
7
+ export { campaignManifest, levelManifest, tutorialManifest, getLevel, createLevel, getTutorial, shizenCampaignManifest, shizenLevelManifest, getShizenLevel, createShizenLevel, arashiCampaignManifest, arashiLevelManifest, getArashiLevel, createArashiLevel } from './colour-chains/content.js';
@@ -79,7 +79,7 @@ export function createGame(options = {}) {
79
79
  const settings = { mode, width, height, colourCount, seed, ...(pieceLimit === undefined ? {} : { pieceLimit }) };
80
80
  if (![4, 5, 6].includes(settings.colourCount))
81
81
  throw new RangeError('Colour count must be 4, 5, or 6');
82
- let randomState = seedState(`falling-triplets|${RULES}|${seed}`);
82
+ const randomState = seedState(`falling-triplets|${RULES}|${seed}`);
83
83
  const initial = { game: 'falling-triplets', rules: RULES, settings, board: Array(width * (height + HIDDEN)).fill(null), phase: 'falling', active: null, next: [], bag: [], randomState, nextId: 1, score: 0, maxChain: 0, completedPieces: 0, elapsedTicks: 0, pieceTicks: 0, resolutionTick: 0, waves: [], assisted: false, hintsUsed: 0, witnessPathMatches: true, recording: [] };
84
84
  const first = drawTriplet(initial);
85
85
  let state = { ...initial, bag: first.bag, randomState: first.randomState, hintsUsed: 0 };
@@ -38,4 +38,5 @@ export function actionsForTick(state) {
38
38
  return [actions, { held, queued: [] }];
39
39
  }
40
40
  /** Releases every held action after blur, pause, or pointer cancellation. */
41
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- the public signature takes the state a caller holds, though nothing of it is kept
41
42
  export function releaseAllActions(state) { return { held: {}, queued: [] }; }
@@ -418,7 +418,7 @@ function awardTools(state, removed) {
418
418
  return { inventory: state.inventory, progress: state.toolProgress, cursor: state.toolAwardCursor, events: Object.freeze([]) };
419
419
  let progress = state.toolProgress + removed;
420
420
  let cursor = state.toolAwardCursor;
421
- let inventory = { ...state.inventory };
421
+ const inventory = { ...state.inventory };
422
422
  const events = [];
423
423
  while (progress >= 12) {
424
424
  progress -= 12;
@@ -211,7 +211,7 @@ function comboPlan(state, p, q, protectedCells, pairIds) {
211
211
  return null;
212
212
  }
213
213
  function effectCells(state, cell, kind) {
214
- const { width, height } = state.settings;
214
+ const { width } = state.settings;
215
215
  const x = cell % width;
216
216
  const y = Math.floor(cell / width);
217
217
  if (kind === 'row-beam')
@@ -1,7 +1,7 @@
1
1
  import { planWave } from './specials.js';
2
2
  /** Computes the occupied cells affected by a tool and all special activations it causes. */
3
3
  export function previewTool(state, tool, target) {
4
- const { width, height, mask } = state.settings;
4
+ const { width, mask } = state.settings;
5
5
  if (!Number.isInteger(target) || target < 0 || target >= state.board.length || !mask[target] || !state.board[target])
6
6
  return Object.freeze([]);
7
7
  const x = target % width;
@@ -0,0 +1,3 @@
1
+ import type { MagneticContentData } from './content-types.js';
2
+ /** Generated by scripts/magnetic-blocks-levels.mjs. */
3
+ export declare const contentData: MagneticContentData;