logic-puzzle-generator 1.2.2 → 1.2.4

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/README.md CHANGED
@@ -197,6 +197,7 @@ The main class. Use `new Generator(seed)` to initialize.
197
197
  - `includeSubjects`: `string[]` (New in v1.1.X). Restrict to clues involving these values.
198
198
  - `excludeSubjects`: `string[]` (New in v1.1.X). Exclude clues involving these values.
199
199
  - `minDeductions`: `number` (New in v1.1.X). Min new info required (default 1). Set to 0 for filler.
200
+ - `maxDeductions`: `number`. Max new info allowed. Set to 0 to force redundant clues.
200
201
  - `options.onTrace`: **(Debug)** Callback `(msg: string) => void`. Receives real-time logs about the generation process.
201
202
  - `generatePuzzleAsync(...)`: **New in v1.1.0**. Non-blocking version of `generatePuzzle`. Returns `Promise<Puzzle>`.
202
203
  - `getClueCountBounds(categories, target)`: Returns plausible Min/Max clue counts.
@@ -236,7 +237,7 @@ The logical engine responsible for applying clues and performing deductions.
236
237
  Manages a stateful, step-by-step puzzle generation process.
237
238
  - `getNextClue(constraints?)`: Returns `{ clue: Clue | null, remaining: number, solved: boolean }`.
238
239
  - Generates and selects the next best clue based on the current grid state.
239
- -### `session.getNextClue(options?: ClueGenerationConstraints)`
240
+ ### `session.getNextClue(options?: ClueGenerationConstraints)`
240
241
 
241
242
  Generates the next step in the puzzle, returning the best available clue based on internal scoring and provided constraints.
242
243
 
@@ -259,11 +260,30 @@ Each result object contains:
259
260
 
260
261
  ### `session.useClue(clue: Clue)`
261
262
 
262
- Manually applies a specific clue to the board. Useful for interactive search features.
263
+ Manually applies a specific clue to the board.
264
+ **Safety**: This method validates the clue against the hidden solution. If the clue implies a contradiction (i.e. is logically false), it throws an `Error`.
263
265
 
264
- ### `session.rollbackLastClue()`: Returns `{ success: boolean, clue: Clue | null }`. Undoes the last step.
265
- - `getNextClueAsync(constraints?)`: **New in v1.1.1**. Non-blocking version. Returns `Promise<{ clue, remaining, solved }>`.
266
- - `rollbackLastClue()`: Returns `{ success: boolean, clue: Clue | null }`. Undoes the last step.
266
+ ### `session.rollbackLastClue()`
267
+ Returns `{ success: boolean, clue: Clue | null }`. Undoes the last step.
268
+
269
+ ### `session.removeClue(index: number)`
270
+ **New in v1.2.0**. Removes a clue from the current proof chain at the specified index.
271
+ - Automatically recalculates all deductions for subsequent clues.
272
+ - Updates the "Target Solved" status if the removal breaks the logical path to the goal.
273
+
274
+ ### `session.moveClue(fromIndex: number, toIndex: number)`
275
+ **New in v1.2.0**. Reorders clues in the proof chain.
276
+ - Returns `true` if the move was valid and successful.
277
+ - Recalculates the entire puzzle state based on the new order.
278
+ - This allows for "What If?" scenarios: "What if I knew this fact earlier?"
279
+
280
+ ### `session.getTargetSolvedStepIndex()`
281
+ **New in v1.2.0**. Returns the index of the clue that first makes the Target Fact deducible.
282
+ - Returns `-1` if the target is not yet solved.
283
+ - This is dynamic: removing or reordering clues will change this index, allowing you to find the exact "Eureka!" moment in the chain.
284
+
285
+ ### `session.getNextClueAsync(constraints?)`
286
+ **New in v1.1.1**. Non-blocking version. Returns `Promise<{ clue, remaining, solved }>`.
267
287
  - `getGrid()`: Returns the current `LogicGrid` state.
268
288
  - `getSolution()`: Returns the target `Solution` map.
269
289
  - `getProofChain()`: Returns the list of `Clue`s applied so far.
@@ -310,7 +330,8 @@ while (!solved) {
310
330
  allowedClueTypes: [ClueType.BINARY, ClueType.ORDINAL],
311
331
  includeSubjects: ['Mustard', 'Plum'], // Only clues about these entities
312
332
  excludeSubjects: ['Revolver'], // No clues dealing with Revolvers
313
- minDeductions: 0 // Allow "useless" clues (flavor text)
333
+ minDeductions: 0, // Allow "useless" clues (flavor text)
334
+ maxDeductions: 0 // STRICTLY "useless" clues (0 deductions)
314
335
  });
315
336
  });
316
337
 
@@ -72,4 +72,15 @@ export interface CrossOrdinalClue {
72
72
  /** The offset from the anchor (-1 = before, +1 = after) */
73
73
  offset2: number;
74
74
  }
75
+ /**
76
+ * Union type representing any valid clue.
77
+ */
75
78
  export type Clue = BinaryClue | OrdinalClue | SuperlativeClue | UnaryClue | CrossOrdinalClue;
79
+ /**
80
+ * Extension for clues with metadata attached during generation/solving.
81
+ */
82
+ export type ClueWithMetadata = Clue & {
83
+ deductions?: number;
84
+ updates?: number;
85
+ percentComplete?: number;
86
+ };
@@ -13,16 +13,20 @@ export declare class GenerativeSession {
13
13
  private availableClues;
14
14
  private proofChain;
15
15
  private solver;
16
+ private targetSolvedStepIndex;
16
17
  private historyStack;
17
18
  constructor(generator: Generator, categories: CategoryConfig[], solution: Solution, reverseSolution: Map<string, Map<ValueLabel, ValueLabel>>, valueMap: Map<ValueLabel, Record<string, ValueLabel>>, targetFact: TargetFact);
18
19
  getTotalClueCount(): number;
20
+ getTargetSolvedStepIndex(): number;
19
21
  getMatchingClueCount(constraints?: ClueGenerationConstraints): number;
20
22
  getMatchingClues(constraints?: ClueGenerationConstraints, limit?: number): Clue[];
21
23
  getScoredMatchingClues(constraints?: ClueGenerationConstraints, limit?: number): {
22
24
  clue: Clue;
23
25
  score: number;
24
26
  deductions: number;
27
+ updates: number;
25
28
  isDirectAnswer: boolean;
29
+ percentComplete: number;
26
30
  }[];
27
31
  useClue(clue: Clue): {
28
32
  remaining: number;
@@ -30,6 +34,8 @@ export declare class GenerativeSession {
30
34
  };
31
35
  private filterClues;
32
36
  private applyAndSave;
37
+ private checkTargetSolvedInternal;
38
+ private checkTargetSolved;
33
39
  getNextClue(constraints?: ClueGenerationConstraints): {
34
40
  clue: Clue | null;
35
41
  remaining: number;
@@ -49,6 +55,18 @@ export declare class GenerativeSession {
49
55
  clue: Clue | null;
50
56
  };
51
57
  private isUseful;
58
+ /**
59
+ * Removes a clue from the proof chain at the specified index.
60
+ * Replays all subsequent clues to ensure state consistency.
61
+ * @param index Index of the clue in the proofChain (0-based)
62
+ */
63
+ removeClueAt(index: number): boolean;
64
+ /**
65
+ * Moves a clue from one index to another in the proof chain.
66
+ * Replays all clues to update metadata and target detection.
67
+ */
68
+ moveClue(fromIndex: number, toIndex: number): boolean;
69
+ private replayProofChain;
52
70
  getGrid(): LogicGrid;
53
71
  getProofChain(): Clue[];
54
72
  getSolution(): Solution;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.GenerativeSession = void 0;
4
4
  const types_1 = require("../types");
5
+ const errors_1 = require("../errors");
5
6
  const LogicGrid_1 = require("./LogicGrid");
6
7
  const Solver_1 = require("./Solver");
7
8
  class GenerativeSession {
@@ -14,9 +15,11 @@ class GenerativeSession {
14
15
  this.targetFact = targetFact;
15
16
  this.availableClues = [];
16
17
  this.proofChain = [];
18
+ this.targetSolvedStepIndex = -1; // -1 means logic not solved by clues yet
17
19
  this.historyStack = [];
18
20
  this.grid = new LogicGrid_1.LogicGrid(categories);
19
21
  this.solver = new Solver_1.Solver();
22
+ this.checkTargetSolved(); // Check initial state (unlikely solved unless trivial)
20
23
  // Initial Generation of ALL possibilities (we filter later)
21
24
  // We need access to the generator's clue generation logic.
22
25
  // We will call a public method on generator.
@@ -25,6 +28,9 @@ class GenerativeSession {
25
28
  getTotalClueCount() {
26
29
  return this.availableClues.length;
27
30
  }
31
+ getTargetSolvedStepIndex() {
32
+ return this.targetSolvedStepIndex;
33
+ }
28
34
  getMatchingClueCount(constraints) {
29
35
  return this.filterClues(constraints).length;
30
36
  }
@@ -33,22 +39,23 @@ class GenerativeSession {
33
39
  return clues.slice(0, limit);
34
40
  }
35
41
  getScoredMatchingClues(constraints, limit = 50) {
36
- const clues = this.filterClues(constraints);
37
- // Score all (or up to a reasonable hard limit to avoid perf issues, say 200)
38
- // Then sort and take top 'limit'
39
- const candidateLimit = 200;
40
- const candidates = clues.slice(0, candidateLimit);
41
- const scored = candidates.map(clue => {
42
- // Score Logic - similar to getNextClue but without the constraints checks (already done)
42
+ const validClues = this.filterClues(constraints);
43
+ const results = [];
44
+ const minDeductions = constraints?.minDeductions ?? 0;
45
+ const maxDeductions = constraints?.maxDeductions;
46
+ if (maxDeductions !== undefined && minDeductions > maxDeductions) {
47
+ throw new errors_1.ConfigurationError(`Invalid constraints: minDeductions (${minDeductions}) cannot be greater than maxDeductions (${maxDeductions}).`);
48
+ }
49
+ for (const clue of validClues) {
43
50
  const tempGrid = this.grid.clone();
44
51
  const { deductions } = this.solver.applyClue(tempGrid, clue);
45
- // Calculate real score
52
+ if (deductions < minDeductions)
53
+ continue;
54
+ if (maxDeductions !== undefined && deductions > maxDeductions)
55
+ continue;
46
56
  const score = this.generator.calculateClueScore(this.grid, this.targetFact, deductions, clue, this.proofChain, this.solution, this.reverseSolution);
47
- // Check if this is the "Direct Answer" (Positive link for the target fact)
48
57
  let isDirectAnswer = false;
49
58
  if (clue.type === types_1.ClueType.BINARY) {
50
- // Check if it links the target subject to the target category
51
- // Positive operators: IS (0). Assuming enum values.
52
59
  const isPositive = clue.operator === types_1.BinaryOperator.IS;
53
60
  if (isPositive) {
54
61
  const c = clue;
@@ -59,17 +66,28 @@ class GenerativeSession {
59
66
  }
60
67
  }
61
68
  }
62
- return { clue, score, deductions, isDirectAnswer };
63
- });
64
- // Sort by score descending
65
- scored.sort((a, b) => b.score - a.score);
66
- // Filter by minDeductions if provided
67
- // Defaults to 0 if undefined (allow all)
68
- const minDeductions = constraints?.minDeductions ?? 0;
69
- const finalResults = scored.filter(s => s.deductions >= minDeductions);
70
- return finalResults.slice(0, limit);
69
+ // Calculate Projected % Complete
70
+ const stats = tempGrid.getGridStats();
71
+ const range = stats.totalPossible - stats.solutionPossible;
72
+ const progress = stats.totalPossible - stats.currentPossible;
73
+ let percentComplete = 0;
74
+ if (range > 0) {
75
+ percentComplete = Math.min(100, Math.max(0, (progress / range) * 100));
76
+ }
77
+ else {
78
+ percentComplete = 100;
79
+ }
80
+ const updates = tempGrid.compareVisualState(this.grid);
81
+ results.push({ clue, score, deductions, updates, isDirectAnswer, percentComplete });
82
+ }
83
+ return results.sort((a, b) => b.score - a.score).slice(0, limit);
71
84
  }
72
85
  useClue(clue) {
86
+ // Validate against solution (Anti-Cheat / Logic Guard)
87
+ const isConsistent = this.generator.checkClueConsistency(clue, this.solution, this.reverseSolution, this.valueMap);
88
+ if (!isConsistent) {
89
+ throw new Error("Invalid Clue: This clue contradicts the puzzle solution.");
90
+ }
73
91
  // Ensure the clue is removed from available if it was there
74
92
  // (If searching, the UI passes a Clue object that should exist in our availableClues)
75
93
  this.applyAndSave(clue);
@@ -108,13 +126,71 @@ class GenerativeSession {
108
126
  // Save state
109
127
  this.historyStack.push(this.grid.clone());
110
128
  // Apply it
111
- this.solver.applyClue(this.grid, clue);
129
+ const result = this.solver.applyClue(this.grid, clue);
130
+ clue.deductions = result.deductions;
131
+ // Calculate % Complete
132
+ // Grid starts with 'totalPossible' and ends with 'solutionPossible'.
133
+ // % = (Total - Current) / (Total - Solution)
134
+ const stats = this.grid.getGridStats();
135
+ // Avoid division by zero if puzzle is tiny or trivial
136
+ const range = stats.totalPossible - stats.solutionPossible;
137
+ const progress = stats.totalPossible - stats.currentPossible;
138
+ if (range > 0) {
139
+ clue.percentComplete = Math.min(100, Math.max(0, (progress / range) * 100));
140
+ }
141
+ else {
142
+ clue.percentComplete = 100;
143
+ }
144
+ // Calculate Visual Updates (Red Crosses + Green Checks)
145
+ // We compare the grid before (this.historyStack.last?) application
146
+ // Wait, 'this.grid' is ALREADY modified here.
147
+ // We saved the previous state in historyStack.
148
+ const prevGrid = this.historyStack[this.historyStack.length - 1];
149
+ if (prevGrid) {
150
+ clue.updates = this.grid.compareVisualState(prevGrid);
151
+ }
152
+ else {
153
+ // Should not happen unless history empty? Initial state?
154
+ clue.updates = clue.deductions; // Fallback
155
+ }
112
156
  this.proofChain.push(clue);
157
+ // Check if target is solved now (if it wasn't before)
158
+ if (this.targetSolvedStepIndex === -1) {
159
+ const isSolved = this.checkTargetSolvedInternal();
160
+ if (isSolved) {
161
+ this.targetSolvedStepIndex = this.proofChain.length - 1;
162
+ }
163
+ }
113
164
  // Remove from available
114
165
  const idx = this.availableClues.indexOf(clue);
115
166
  if (idx > -1)
116
167
  this.availableClues.splice(idx, 1);
117
168
  }
169
+ checkTargetSolvedInternal() {
170
+ // Check if the specific target fact is confirmed in the grid
171
+ // Fact: (Cat1, Val1) is linked to (Cat2) -> ?
172
+ // We need to check if grid has determined the link.
173
+ // targetFact has Cat1, Val1, Cat2.
174
+ // Ideally we check if Possibilities(Cat1, Val1, Cat2) === 1.
175
+ // Wait, TargetFact structure:
176
+ // { category1Id, value1, category2Id, value2? }
177
+ // If value2 is present (specific pairing target), we check isFactConfirmed.
178
+ // If value2 is NOT present (general "find the husband" target), we check if ANY link is confirmed?
179
+ // Actually, usually the target is "Find the Value in Cat2 for (Cat1, Val1)".
180
+ // So we check if the Number of Possibilities for (Cat1, Val1) in Cat2 is exactly 1.
181
+ return this.grid.getPossibilitiesCount(this.targetFact.category1Id, this.targetFact.value1, this.targetFact.category2Id) === 1;
182
+ }
183
+ checkTargetSolved() {
184
+ if (this.checkTargetSolvedInternal()) {
185
+ // If solved at start (step -1?) logic handles index relative to proof chain.
186
+ // If solved initially, maybe index is -1?
187
+ // But if it's -1, it means "Before any clues".
188
+ // Let's stick to -1 being "Not Found Yet" vs "Found at Initial State"?
189
+ // If found at start, we might want to know.
190
+ // But normally target is unknown.
191
+ // Let's assume -1 is "Not Solved".
192
+ }
193
+ }
118
194
  getNextClue(constraints) {
119
195
  const validClues = this.filterClues(constraints);
120
196
  // Score them
@@ -126,6 +202,10 @@ class GenerativeSession {
126
202
  const searchLimit = 50;
127
203
  let checked = 0;
128
204
  const minDeductions = constraints?.minDeductions ?? 0;
205
+ const maxDeductions = constraints?.maxDeductions;
206
+ if (maxDeductions !== undefined && minDeductions > maxDeductions) {
207
+ throw new errors_1.ConfigurationError(`Invalid constraints: minDeductions (${minDeductions}) cannot be greater than maxDeductions (${maxDeductions}).`);
208
+ }
129
209
  for (const clue of candidates) {
130
210
  checked++;
131
211
  if (checked > searchLimit && bestClue)
@@ -138,12 +218,31 @@ class GenerativeSession {
138
218
  if (deductions < minDeductions) {
139
219
  continue;
140
220
  }
221
+ if (maxDeductions !== undefined && deductions > maxDeductions) {
222
+ continue;
223
+ }
141
224
  // Re-calc score with deductions
142
225
  const realScore = this.generator.calculateClueScore(this.grid, this.targetFact, deductions, clue, this.proofChain, this.solution, this.reverseSolution);
226
+ // Calculate Projected % Complete
227
+ const stats = tempGrid.getGridStats();
228
+ const range = stats.totalPossible - stats.solutionPossible;
229
+ const progress = stats.totalPossible - stats.currentPossible;
230
+ let percentComplete = 0;
231
+ if (range > 0) {
232
+ percentComplete = Math.min(100, Math.max(0, (progress / range) * 100));
233
+ }
234
+ else {
235
+ percentComplete = 100;
236
+ }
143
237
  if (realScore > bestScore) {
144
238
  bestScore = realScore;
145
239
  bestClue = clue;
146
240
  }
241
+ // Note: getScoredMatchingClues will need to collect this.
242
+ // But this is getNextClue.
243
+ // Wait, I need to update getScoredMatchingClues, not getNextClue loop?
244
+ // Yes, user said "search list".
245
+ // Let's modify getScoredMatchingClues below, but first let me fix the tool call target.
147
246
  }
148
247
  if (bestClue) {
149
248
  this.applyAndSave(bestClue);
@@ -189,6 +288,69 @@ class GenerativeSession {
189
288
  // If deductions > 0, it's useful.
190
289
  return true;
191
290
  }
291
+ /**
292
+ * Removes a clue from the proof chain at the specified index.
293
+ * Replays all subsequent clues to ensure state consistency.
294
+ * @param index Index of the clue in the proofChain (0-based)
295
+ */
296
+ removeClueAt(index) {
297
+ if (index < 0 || index >= this.proofChain.length)
298
+ return false;
299
+ const removedClue = this.proofChain[index];
300
+ // 1. Remove from proofChain
301
+ this.proofChain.splice(index, 1);
302
+ // 2. Return to availableClues
303
+ this.availableClues.push(removedClue);
304
+ // 3. Replay Logic
305
+ return this.replayProofChain();
306
+ }
307
+ /**
308
+ * Moves a clue from one index to another in the proof chain.
309
+ * Replays all clues to update metadata and target detection.
310
+ */
311
+ moveClue(fromIndex, toIndex) {
312
+ if (fromIndex < 0 || fromIndex >= this.proofChain.length)
313
+ return false;
314
+ if (toIndex < 0 || toIndex >= this.proofChain.length)
315
+ return false;
316
+ if (fromIndex === toIndex)
317
+ return true; // No op
318
+ const clue = this.proofChain[fromIndex];
319
+ // Remove
320
+ this.proofChain.splice(fromIndex, 1);
321
+ // Insert
322
+ this.proofChain.splice(toIndex, 0, clue);
323
+ return this.replayProofChain();
324
+ }
325
+ replayProofChain() {
326
+ // Reset grid to initial state
327
+ this.grid = new LogicGrid_1.LogicGrid(this.categories);
328
+ this.historyStack = []; // Reset history
329
+ this.solver = new Solver_1.Solver(); // Fresh solver (stateless anyway)
330
+ this.targetSolvedStepIndex = -1; // Reset target detection
331
+ // Check if solved initially (unlikely)
332
+ if (this.checkTargetSolvedInternal()) {
333
+ // If solved with 0 clues, maybe set to -1 (start)?
334
+ // Or a special value? Let's leave it -1 and handle it?
335
+ // Actually if it's solved at start, then stepIndex = -1 makes sense if we consider 0-based index of clues.
336
+ // But usually we mark "Revealed AFTER step X".
337
+ }
338
+ // Put ALL proofChain clues back into available first (so applyAndSave finds them)
339
+ // We must push them all back because applyAndSave will splice them out.
340
+ // Note: availableClues might ideally be a Set or map for speed, but array is fine for now.
341
+ // We should add them ONLY if they are not already there?
342
+ // Actually, proofChain clues are NOT in availableClues.
343
+ this.availableClues.push(...this.proofChain);
344
+ // Capture chain
345
+ const chainToReplay = [...this.proofChain];
346
+ // Clear proofChain
347
+ this.proofChain = [];
348
+ // Re-apply
349
+ for (const c of chainToReplay) {
350
+ this.applyAndSave(c);
351
+ }
352
+ return true;
353
+ }
192
354
  // Getters for UI
193
355
  getGrid() { return this.grid; }
194
356
  getProofChain() { return this.proofChain; }
@@ -140,5 +140,11 @@ export declare class Generator {
140
140
  * Higher scores represent better clues according to the current strategy.
141
141
  */
142
142
  calculateClueScore(grid: LogicGrid, target: TargetFact, deductions: number, clue: Clue, previouslySelectedClues: Clue[], solution: Solution, reverseSolution: Map<string, Map<ValueLabel, ValueLabel>>): number;
143
+ /**
144
+ * Checks if a clue is logically consistent with the provided solution.
145
+ * Returns true if the clue is TRUE in the context of the solution.
146
+ * Returns false if the clue is FALSE (contradicts the solution).
147
+ */
148
+ checkClueConsistency(clue: Clue, solution: Solution, reverseSolution: Map<string, Map<ValueLabel, ValueLabel>>, valueMap: Map<ValueLabel, Record<string, ValueLabel>>): boolean;
143
149
  isPuzzleSolved(grid: LogicGrid, solution: Solution, reverseSolution: Map<string, Map<ValueLabel, ValueLabel>>): boolean;
144
150
  }
@@ -440,6 +440,26 @@ class Generator {
440
440
  if (this.isPuzzleSolved(logicGrid, this.solution, this.reverseSolution))
441
441
  break;
442
442
  }
443
+ // ---------------------------------------------------------
444
+ // METADATA REPLAY PASS
445
+ // ---------------------------------------------------------
446
+ // Re-apply clues to a fresh grid to calculate accurate metadata (deductions, % complete)
447
+ // for the final solution view.
448
+ const replayGrid = new LogicGrid_1.LogicGrid(categories);
449
+ const replayStats = replayGrid.getGridStats();
450
+ const totalRange = replayStats.totalPossible - replayStats.solutionPossible;
451
+ for (const step of proofChain) {
452
+ const result = this.solver.applyClue(replayGrid, step.clue);
453
+ step.clue.deductions = result.deductions;
454
+ const currentStats = replayGrid.getGridStats();
455
+ const progress = currentStats.totalPossible - currentStats.currentPossible;
456
+ if (totalRange > 0) {
457
+ step.clue.percentComplete = Math.min(100, Math.max(0, (progress / totalRange) * 100));
458
+ }
459
+ else {
460
+ step.clue.percentComplete = 100;
461
+ }
462
+ }
443
463
  return {
444
464
  solution: this.solution,
445
465
  clues: proofChain.map(p => p.clue),
@@ -1096,6 +1116,125 @@ class Generator {
1096
1116
  const score = ((synergyScore * complexityBonus) + (completenessScore * 5)) * repetitionPenalty;
1097
1117
  return score;
1098
1118
  }
1119
+ /**
1120
+ * Checks if a clue is logically consistent with the provided solution.
1121
+ * Returns true if the clue is TRUE in the context of the solution.
1122
+ * Returns false if the clue is FALSE (contradicts the solution).
1123
+ */
1124
+ checkClueConsistency(clue, solution, reverseSolution, valueMap) {
1125
+ // Helper to get real value
1126
+ const getRealValue = (catId, val, targetCatId) => {
1127
+ const baseVal = reverseSolution.get(catId)?.get(val);
1128
+ if (!baseVal)
1129
+ return undefined;
1130
+ return solution[targetCatId][baseVal];
1131
+ };
1132
+ const getBaseValue = (catId, val) => {
1133
+ return reverseSolution.get(catId)?.get(val);
1134
+ };
1135
+ // Helper to get Ordinal Numeric Value
1136
+ const getOrdinalValue = (catId, val, ordinalCatId) => {
1137
+ const baseVal = getBaseValue(catId, val);
1138
+ if (!baseVal)
1139
+ return undefined;
1140
+ const mappings = valueMap.get(baseVal);
1141
+ if (!mappings)
1142
+ return undefined;
1143
+ const ordVal = mappings[ordinalCatId];
1144
+ return typeof ordVal === 'number' ? ordVal : undefined;
1145
+ };
1146
+ switch (clue.type) {
1147
+ case types_1.ClueType.BINARY: {
1148
+ const b = clue;
1149
+ const realVal2 = getRealValue(b.cat1, b.val1, b.cat2);
1150
+ if (realVal2 === undefined)
1151
+ return false; // Should not happen in consistent state
1152
+ if (b.operator === types_1.BinaryOperator.IS) {
1153
+ return realVal2 === b.val2;
1154
+ }
1155
+ else if (b.operator === types_1.BinaryOperator.IS_NOT) {
1156
+ return realVal2 !== b.val2;
1157
+ }
1158
+ break;
1159
+ }
1160
+ case types_1.ClueType.ORDINAL: {
1161
+ const o = clue;
1162
+ const v1 = getOrdinalValue(o.item1Cat, o.item1Val, o.ordinalCat);
1163
+ const v2 = getOrdinalValue(o.item2Cat, o.item2Val, o.ordinalCat);
1164
+ if (v1 === undefined || v2 === undefined)
1165
+ return false;
1166
+ switch (o.operator) {
1167
+ case types_1.OrdinalOperator.GREATER_THAN: return v1 > v2;
1168
+ case types_1.OrdinalOperator.LESS_THAN: return v1 < v2;
1169
+ case types_1.OrdinalOperator.NOT_GREATER_THAN: return v1 <= v2; // Not After
1170
+ case types_1.OrdinalOperator.NOT_LESS_THAN: return v1 >= v2; // Not Before
1171
+ }
1172
+ break;
1173
+ }
1174
+ case types_1.ClueType.SUPERLATIVE: {
1175
+ const s = clue;
1176
+ const v = getOrdinalValue(s.targetCat, s.targetVal, s.ordinalCat);
1177
+ if (v === undefined)
1178
+ return false;
1179
+ // We need the min/max of the ordinal category
1180
+ // Since we don't have categories passed in directly, we can infer from valueMap or we need categories?
1181
+ // valueMap has all values.
1182
+ // But simpler: we know mappings for ALL items in ordinal cat.
1183
+ // Let's iterate all base values to find min/max for that ordinal cat.
1184
+ let min = Infinity;
1185
+ let max = -Infinity;
1186
+ // valueMap keys are baseValues (Category[0] values)
1187
+ for (const baseVal of this.valueMap.keys()) {
1188
+ const mappings = this.valueMap.get(baseVal);
1189
+ if (mappings) {
1190
+ const ov = mappings[s.ordinalCat];
1191
+ if (typeof ov === 'number') {
1192
+ if (ov < min)
1193
+ min = ov;
1194
+ if (ov > max)
1195
+ max = ov;
1196
+ }
1197
+ }
1198
+ }
1199
+ if (min === Infinity)
1200
+ return false; // No ordinal values found?
1201
+ switch (s.operator) {
1202
+ case types_1.SuperlativeOperator.MIN: return v === min;
1203
+ case types_1.SuperlativeOperator.MAX: return v === max;
1204
+ case types_1.SuperlativeOperator.NOT_MIN: return v !== min;
1205
+ case types_1.SuperlativeOperator.NOT_MAX: return v !== max;
1206
+ }
1207
+ break;
1208
+ }
1209
+ case types_1.ClueType.UNARY: {
1210
+ const u = clue;
1211
+ const v = getOrdinalValue(u.targetCat, u.targetVal, u.ordinalCat);
1212
+ if (v === undefined)
1213
+ return false;
1214
+ switch (u.filter) {
1215
+ case types_1.UnaryFilter.IS_ODD: return (v % 2 !== 0);
1216
+ case types_1.UnaryFilter.IS_EVEN: return (v % 2 === 0);
1217
+ }
1218
+ break;
1219
+ }
1220
+ case types_1.ClueType.CROSS_ORDINAL: {
1221
+ const c = clue;
1222
+ const v1 = getOrdinalValue(c.item1Cat, c.item1Val, c.ordinal1);
1223
+ const v2 = getOrdinalValue(c.item2Cat, c.item2Val, c.ordinal2);
1224
+ if (v1 === undefined || v2 === undefined)
1225
+ return false;
1226
+ const op = c.operator;
1227
+ switch (op) {
1228
+ case types_1.OrdinalOperator.GREATER_THAN: return v1 > v2;
1229
+ case types_1.OrdinalOperator.LESS_THAN: return v1 < v2;
1230
+ case types_1.OrdinalOperator.NOT_GREATER_THAN: return v1 <= v2;
1231
+ case types_1.OrdinalOperator.NOT_LESS_THAN: return v1 >= v2;
1232
+ }
1233
+ break;
1234
+ }
1235
+ }
1236
+ return true;
1237
+ }
1099
1238
  isPuzzleSolved(grid, solution, reverseSolution) {
1100
1239
  const categories = grid.categories;
1101
1240
  const baseCategory = categories[0];
@@ -67,4 +67,14 @@ export declare class LogicGrid {
67
67
  * @returns A new LogicGrid instance with the exact same state.
68
68
  */
69
69
  clone(): LogicGrid;
70
+ /**
71
+ * Compares this grid with a previous state and counts visual updates.
72
+ * A "Visual Update" is defined as:
73
+ * 1. A cell changing from Possible (True) to Eliminated (False) [Red Cross]
74
+ * 2. A cell changing from Ambiguous (Count > 1) to Unique (Count == 1) [Green Check]
75
+ *
76
+ * @param prevGrid - The previous grid state.
77
+ * @returns The number of visual updates.
78
+ */
79
+ compareVisualState(prevGrid: LogicGrid): number;
70
80
  }
@@ -186,5 +186,50 @@ class LogicGrid {
186
186
  ]));
187
187
  return newGrid;
188
188
  }
189
+ /**
190
+ * Compares this grid with a previous state and counts visual updates.
191
+ * A "Visual Update" is defined as:
192
+ * 1. A cell changing from Possible (True) to Eliminated (False) [Red Cross]
193
+ * 2. A cell changing from Ambiguous (Count > 1) to Unique (Count == 1) [Green Check]
194
+ *
195
+ * @param prevGrid - The previous grid state.
196
+ * @returns The number of visual updates.
197
+ */
198
+ compareVisualState(prevGrid) {
199
+ let updates = 0;
200
+ const categories = this.categories;
201
+ for (const cat1 of categories) {
202
+ for (const val1 of cat1.values) {
203
+ for (const cat2 of categories) {
204
+ if (cat1.id >= cat2.id)
205
+ continue;
206
+ // 1. Check for Red Crosses (True -> False)
207
+ // We iterate individual cells
208
+ const val1Map = this.grid.get(cat1.id)?.get(val1);
209
+ const prevVal1Map = prevGrid.grid.get(cat1.id)?.get(val1);
210
+ if (val1Map && prevVal1Map) {
211
+ const vals2Arr = val1Map.get(cat2.id);
212
+ const prevVals2Arr = prevVal1Map.get(cat2.id);
213
+ if (vals2Arr && prevVals2Arr) {
214
+ for (let i = 0; i < vals2Arr.length; i++) {
215
+ // If it WAS true and IS NOW false
216
+ if (prevVals2Arr[i] && !vals2Arr[i]) {
217
+ updates++;
218
+ }
219
+ }
220
+ }
221
+ }
222
+ // 2. Check for Green Checks (Ambiguous -> Unique)
223
+ // We check the Row Context (Possibilities Count)
224
+ const prevCount = prevGrid.getPossibilitiesCount(cat1.id, val1, cat2.id);
225
+ const currCount = this.getPossibilitiesCount(cat1.id, val1, cat2.id);
226
+ if (prevCount > 1 && currCount === 1) {
227
+ updates++;
228
+ }
229
+ }
230
+ }
231
+ }
232
+ return updates;
233
+ }
189
234
  }
190
235
  exports.LogicGrid = LogicGrid;
@@ -382,6 +382,10 @@ class Solver {
382
382
  if (!cat1Config || !cat2Config)
383
383
  return 0;
384
384
  if (clue.operator === types_1.BinaryOperator.IS) {
385
+ // Count the "Positive Confirmation" (Green Check) as a deduction if it wasn't already known
386
+ if (grid.getPossibilitiesCount(clue.cat1, clue.val1, clue.cat2) > 1) {
387
+ deductions++;
388
+ }
385
389
  if (grid.isPossible(clue.cat1, clue.val1, clue.cat2, clue.val2)) {
386
390
  // This is not a deduction, but a fact application. Still, we need to eliminate other possibilities.
387
391
  }
@@ -97,4 +97,9 @@ export interface ClueGenerationConstraints {
97
97
  * Default is 0 (allow all clues). Set to 1 to ensure progress.
98
98
  */
99
99
  minDeductions?: number;
100
+ /**
101
+ * Maximum number of new deductions this clue is allowed to provide.
102
+ * Useful for finding low-impact or "filler" clues, or ensuring a clue isn't *too* powerful.
103
+ */
104
+ maxDeductions?: number;
100
105
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "logic-puzzle-generator",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -45,4 +45,4 @@
45
45
  "ts-jest": "^29.1.2",
46
46
  "typescript": "^5.4.5"
47
47
  }
48
- }
48
+ }