logic-puzzle-generator 1.2.3 → 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 +27 -6
- package/dist/src/engine/Clue.d.ts +11 -0
- package/dist/src/engine/GenerativeSession.d.ts +18 -0
- package/dist/src/engine/GenerativeSession.js +183 -21
- package/dist/src/engine/Generator.d.ts +6 -0
- package/dist/src/engine/Generator.js +139 -0
- package/dist/src/engine/LogicGrid.d.ts +10 -0
- package/dist/src/engine/LogicGrid.js +45 -0
- package/dist/src/engine/Solver.js +4 -0
- package/dist/src/types.d.ts +5 -0
- package/package.json +2 -2
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
|
-
|
|
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.
|
|
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()
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
}
|
package/dist/src/types.d.ts
CHANGED
|
@@ -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