logic-puzzle-generator 1.0.0 → 1.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.
- package/README.md +144 -8
- package/dist/src/engine/GenerativeSession.d.ts +25 -0
- package/dist/src/engine/GenerativeSession.js +130 -26
- package/dist/src/engine/Generator.d.ts +25 -0
- package/dist/src/engine/Generator.js +92 -27
- package/dist/src/types.d.ts +14 -0
- package/package.json +9 -1
- package/dist/Clue.d.ts +0 -81
- package/dist/Clue.js +0 -37
- package/dist/Generator.d.ts +0 -58
- package/dist/Generator.js +0 -433
- package/dist/LogicGrid.d.ts +0 -70
- package/dist/LogicGrid.js +0 -188
- package/dist/Solver.d.ts +0 -29
- package/dist/Solver.js +0 -242
- package/dist/index.d.ts +0 -5
- package/dist/index.js +0 -21
- package/dist/run_generator.d.ts +0 -1
- package/dist/run_generator.js +0 -84
- package/dist/types.d.ts +0 -37
- package/dist/types.js +0 -13
package/README.md
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|

|
|
6
6
|

|
|
7
7
|
|
|
8
|
+
[View the interactive demo](https://project.joshhills.dev/logic-puzzle-generator/)
|
|
9
|
+
|
|
10
|
+

|
|
11
|
+
|
|
8
12
|
|
|
9
13
|
A TypeScript library for generating, solving, and verifying "Zebra Puzzle" style logic grid puzzles.
|
|
10
14
|
|
|
@@ -183,24 +187,156 @@ The generator solves the puzzle as it builds it. The `puzzle.proofChain` array c
|
|
|
183
187
|
### `Generator`
|
|
184
188
|
The main class. Use `new Generator(seed)` to initialize.
|
|
185
189
|
|
|
186
|
-
- `generatePuzzle(categories, target
|
|
187
|
-
- `
|
|
188
|
-
- `options.
|
|
189
|
-
- `options.
|
|
190
|
+
- `generatePuzzle(categories, target?, options?)`: Returns a `Puzzle` object.
|
|
191
|
+
- `target` (Optional): The `TargetFact` to solve for. If omitted, a random target is selected.
|
|
192
|
+
- `options.targetClueCount`: Attempt to find exact solution length. Avoids early termination.
|
|
193
|
+
- `options.maxCandidates`: Performance tuning (default 50). Limits the heuristic search width.
|
|
194
|
+
- `options.timeoutMs`: Abort generation if it exceeds this limit (default 10000ms).
|
|
195
|
+
- `options.constraints`: Filter/control generated clues.
|
|
196
|
+
- `allowedClueTypes`: `ClueType[]`.
|
|
197
|
+
- `includeSubjects`: `string[]` (New in v1.1.X). Restrict to clues involving these values.
|
|
198
|
+
- `excludeSubjects`: `string[]` (New in v1.1.X). Exclude clues involving these values.
|
|
199
|
+
- `minDeductions`: `number` (New in v1.1.X). Min new info required (default 1). Set to 0 for filler.
|
|
200
|
+
- `options.onTrace`: **(Debug)** Callback `(msg: string) => void`. Receives real-time logs about the generation process.
|
|
201
|
+
- `generatePuzzleAsync(...)`: **New in v1.1.0**. Non-blocking version of `generatePuzzle`. Returns `Promise<Puzzle>`.
|
|
190
202
|
- `getClueCountBounds(categories, target)`: Returns plausible Min/Max clue counts.
|
|
191
|
-
- `
|
|
203
|
+
- `getClueCountBoundsAsync(...)`: **New in v1.1.0**. Non-blocking version. Returns `Promise<{ min, max }>`.
|
|
204
|
+
- `startSession(categories, target?)`: [Beta] Starts a `GenerativeSession` for step-by-step interactive generation.
|
|
205
|
+
|
|
206
|
+
#### Advanced / Internal API
|
|
207
|
+
- `calculateClueScore(grid, target, deductions, clue, ...)`: **(Extensible)** detailed heuristic scoring for clue selection.
|
|
208
|
+
- `isPuzzleSolved(grid, solution, ...)`: Checks if the grid matches the unique solution.
|
|
209
|
+
- `generateAllPossibleClues(...)`: Generates every valid clue for the current configuration (unfiltered).
|
|
192
210
|
|
|
193
211
|
### Extensibility
|
|
194
212
|
The `Generator` class is designed to be extensible. Key methods like `calculateClueScore` are `public`, allowing you to extend the class and inject custom heuristics.
|
|
195
213
|
|
|
196
214
|
### `LogicGrid`
|
|
197
215
|
Manages the state of the puzzle grid (possibility matrix).
|
|
216
|
+
- `constructor(categories: CategoryConfig[])`: Initializes a new grid.
|
|
198
217
|
- `isPossible(cat1, val1, cat2, val2)`: Returns true if a connection is possible.
|
|
199
|
-
- `setPossibility(
|
|
218
|
+
- `setPossibility(cat1, val1, cat2, val2, state)`: Manually set connection state.
|
|
219
|
+
- `getPossibilitiesCount(cat1, val1, cat2)`: Returns the number of remaining possibilities for a value in a target category.
|
|
220
|
+
- `getGridStats()`: Returns `{ totalPossible, currentPossible, solutionPossible }` to track solving progress.
|
|
221
|
+
- `clone()`: Creates a deep copy of the grid.
|
|
200
222
|
|
|
201
223
|
### `Solver`
|
|
202
|
-
The logical engine.
|
|
203
|
-
- `applyClue(grid, clue)`: Applies a clue and cascades deductions.
|
|
224
|
+
The logical engine responsible for applying clues and performing deductions.
|
|
225
|
+
- `applyClue(grid, clue)`: Applies a clue and cascades deductions. Returns `{ deductions: number }`.
|
|
226
|
+
- `runDeductionLoop(grid)`: repeatedly applies elimination logic until the grid stabilizes.
|
|
227
|
+
|
|
228
|
+
#### Internal Deduction Methods
|
|
229
|
+
- `applyBinaryClue(grid, clue)`
|
|
230
|
+
- `applyOrdinalClue(grid, clue)`
|
|
231
|
+
- `applyCrossOrdinalClue(grid, clue)`
|
|
232
|
+
- `applySuperlativeClue(grid, clue)`
|
|
233
|
+
- `applyUnaryClue(grid, clue)`
|
|
234
|
+
|
|
235
|
+
### `GenerativeSession`
|
|
236
|
+
Manages a stateful, step-by-step puzzle generation process.
|
|
237
|
+
- `getNextClue(constraints?)`: Returns `{ clue: Clue | null, remaining: number, solved: boolean }`.
|
|
238
|
+
- Generates and selects the next best clue based on the current grid state.
|
|
239
|
+
-### `session.getNextClue(options?: ClueGenerationConstraints)`
|
|
240
|
+
|
|
241
|
+
Generates the next step in the puzzle, returning the best available clue based on internal scoring and provided constraints.
|
|
242
|
+
|
|
243
|
+
### `session.getTotalClueCount()`
|
|
244
|
+
|
|
245
|
+
Returns the total number of valid clues remaining in the pool.
|
|
246
|
+
|
|
247
|
+
### `session.getMatchingClueCount(options?: ClueGenerationConstraints)`
|
|
248
|
+
|
|
249
|
+
Returns the number of clues that match the current filtering criteria.
|
|
250
|
+
|
|
251
|
+
### `session.getScoredMatchingClues(options?: ClueGenerationConstraints, limit: number = 50)`
|
|
252
|
+
|
|
253
|
+
Returns a list of clues matching the constraints, sorted by heuristic score.
|
|
254
|
+
Each result object contains:
|
|
255
|
+
- `clue`: The Clue object.
|
|
256
|
+
- `score`: The calculated helpfulness score.
|
|
257
|
+
- `deductions`: The number of new cell updates this clue provides.
|
|
258
|
+
- `isDirectAnswer`: Boolean indicating if this clue directly reveals the puzzle's goal.
|
|
259
|
+
|
|
260
|
+
### `session.useClue(clue: Clue)`
|
|
261
|
+
|
|
262
|
+
Manually applies a specific clue to the board. Useful for interactive search features.
|
|
263
|
+
|
|
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.
|
|
267
|
+
- `getGrid()`: Returns the current `LogicGrid` state.
|
|
268
|
+
- `getSolution()`: Returns the target `Solution` map.
|
|
269
|
+
- `getProofChain()`: Returns the list of `Clue`s applied so far.
|
|
270
|
+
- `getValueMap()`: Returns the optimized internal value categorization map.
|
|
271
|
+
|
|
272
|
+
### Data Types
|
|
273
|
+
|
|
274
|
+
#### `CategoryConfig`
|
|
275
|
+
Configuration for a single category.
|
|
276
|
+
```typescript
|
|
277
|
+
interface CategoryConfig {
|
|
278
|
+
id: string; // e.g. "Suspect"
|
|
279
|
+
values: string[]; // e.g. ["Mustard", "Plum"...]
|
|
280
|
+
type: CategoryType; // NOMINAL | ORDINAL
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
#### `TargetFact`
|
|
285
|
+
Defines the goal of the puzzle (e.g., "Who killed Mr. Boddy?").
|
|
286
|
+
```typescript
|
|
287
|
+
interface TargetFact {
|
|
288
|
+
category1Id: string;
|
|
289
|
+
value1: string;
|
|
290
|
+
category2Id: string;
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
#### `ClueType`
|
|
295
|
+
Enum for available clue logic: `BINARY` (Direct), `ORDINAL` (Comparison), `CROSS_ORDINAL` (Relative), `SUPERLATIVE` (Min/Max), `UNARY` (Properties).
|
|
296
|
+
|
|
297
|
+
## Interactive Generation (Builder Mode)
|
|
298
|
+
|
|
299
|
+
For UIs where you want to watch the puzzle being built (or let the user manually pick the next clue type), use the `GenerativeSession`.
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
const session = generator.startSession(categories, target);
|
|
303
|
+
let solved = false;
|
|
304
|
+
|
|
305
|
+
while (!solved) {
|
|
306
|
+
// 1. Get the next best clue (optionally force a specific type)
|
|
307
|
+
const result = session.getNextClue({
|
|
308
|
+
// 1. Get the next best clue (optionally force a specific type)
|
|
309
|
+
const result = session.getNextClue({
|
|
310
|
+
allowedClueTypes: [ClueType.BINARY, ClueType.ORDINAL],
|
|
311
|
+
includeSubjects: ['Mustard', 'Plum'], // Only clues about these entities
|
|
312
|
+
excludeSubjects: ['Revolver'], // No clues dealing with Revolvers
|
|
313
|
+
minDeductions: 0 // Allow "useless" clues (flavor text)
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
if (result.clue) {
|
|
318
|
+
console.log("Next Clue:", result.clue);
|
|
319
|
+
solved = result.solved;
|
|
320
|
+
} else {
|
|
321
|
+
console.warn("No more clues available.");
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
## Error Handling
|
|
328
|
+
|
|
329
|
+
The library uses specific error types to help you debug configuration issues.
|
|
330
|
+
|
|
331
|
+
| Method | Throws | Reason |
|
|
332
|
+
| :--- | :--- | :--- |
|
|
333
|
+
| `new Generator()` | `Error` | If `seed` is invalid (NaN). |
|
|
334
|
+
| `generatePuzzle()` | `ConfigurationError` | **Configuration**: <br> - Less than 2 categories. <br> - `maxCandidates` < 1. <br> - `targetClueCount` < 1. <br> **Target Fact**: <br> Refers to non-existent category/value or uses same category twice. <br> **Constraints**: <br> - Ambiguous (Weak) types only. <br> - Requesting `ORDINAL` without Ordinal categories. <br> - Requesting `CROSS_ORDINAL` with < 2 Ordinal categories. <br> - Requesting `UNARY` (Even/Odd) without mixed numeric values. <br> **Data**: <br> - `ORDINAL` category contains non-numeric values. <br> **Runtime**: <br> - Could not find solution with exact `targetClueCount` within timeout. |
|
|
335
|
+
| `startSession()` | `ConfigurationError` | - Less than 2 categories. |
|
|
336
|
+
| `LogicGrid()` | `ConfigurationError` | - Duplicate Category IDs <br> - Duplicate Values within a category <br> - Mismatched value counts (all categories must be same size). |
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
|
|
204
340
|
|
|
205
341
|
## AI Disclosure & Liability Policy
|
|
206
342
|
|
|
@@ -15,11 +15,35 @@ export declare class GenerativeSession {
|
|
|
15
15
|
private solver;
|
|
16
16
|
private historyStack;
|
|
17
17
|
constructor(generator: Generator, categories: CategoryConfig[], solution: Solution, reverseSolution: Map<string, Map<ValueLabel, ValueLabel>>, valueMap: Map<ValueLabel, Record<string, ValueLabel>>, targetFact: TargetFact);
|
|
18
|
+
getTotalClueCount(): number;
|
|
19
|
+
getMatchingClueCount(constraints?: ClueGenerationConstraints): number;
|
|
20
|
+
getMatchingClues(constraints?: ClueGenerationConstraints, limit?: number): Clue[];
|
|
21
|
+
getScoredMatchingClues(constraints?: ClueGenerationConstraints, limit?: number): {
|
|
22
|
+
clue: Clue;
|
|
23
|
+
score: number;
|
|
24
|
+
deductions: number;
|
|
25
|
+
isDirectAnswer: boolean;
|
|
26
|
+
}[];
|
|
27
|
+
useClue(clue: Clue): {
|
|
28
|
+
remaining: number;
|
|
29
|
+
solved: boolean;
|
|
30
|
+
};
|
|
31
|
+
private filterClues;
|
|
32
|
+
private applyAndSave;
|
|
18
33
|
getNextClue(constraints?: ClueGenerationConstraints): {
|
|
19
34
|
clue: Clue | null;
|
|
20
35
|
remaining: number;
|
|
21
36
|
solved: boolean;
|
|
22
37
|
};
|
|
38
|
+
/**
|
|
39
|
+
* Asynchronously gets the next clue (non-blocking wrapper).
|
|
40
|
+
* @param constraints
|
|
41
|
+
*/
|
|
42
|
+
getNextClueAsync(constraints?: ClueGenerationConstraints): Promise<{
|
|
43
|
+
clue: Clue | null;
|
|
44
|
+
remaining: number;
|
|
45
|
+
solved: boolean;
|
|
46
|
+
}>;
|
|
23
47
|
rollbackLastClue(): {
|
|
24
48
|
success: boolean;
|
|
25
49
|
clue: Clue | null;
|
|
@@ -29,4 +53,5 @@ export declare class GenerativeSession {
|
|
|
29
53
|
getProofChain(): Clue[];
|
|
30
54
|
getSolution(): Solution;
|
|
31
55
|
getValueMap(): Map<ValueLabel, Record<string, ValueLabel>>;
|
|
56
|
+
private extractValuesFromClue;
|
|
32
57
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.GenerativeSession = void 0;
|
|
4
|
+
const types_1 = require("../types");
|
|
4
5
|
const LogicGrid_1 = require("./LogicGrid");
|
|
5
6
|
const Solver_1 = require("./Solver");
|
|
6
7
|
class GenerativeSession {
|
|
@@ -21,41 +22,116 @@ class GenerativeSession {
|
|
|
21
22
|
// We will call a public method on generator.
|
|
22
23
|
this.availableClues = this.generator.generateAllPossibleClues(categories, undefined, reverseSolution, valueMap);
|
|
23
24
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
getTotalClueCount() {
|
|
26
|
+
return this.availableClues.length;
|
|
27
|
+
}
|
|
28
|
+
getMatchingClueCount(constraints) {
|
|
29
|
+
return this.filterClues(constraints).length;
|
|
30
|
+
}
|
|
31
|
+
getMatchingClues(constraints, limit = 50) {
|
|
32
|
+
const clues = this.filterClues(constraints);
|
|
33
|
+
return clues.slice(0, limit);
|
|
34
|
+
}
|
|
35
|
+
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)
|
|
43
|
+
const tempGrid = this.grid.clone();
|
|
44
|
+
const { deductions } = this.solver.applyClue(tempGrid, clue);
|
|
45
|
+
// Calculate real score
|
|
46
|
+
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
|
+
let isDirectAnswer = false;
|
|
49
|
+
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
|
+
const isPositive = clue.operator === types_1.BinaryOperator.IS;
|
|
53
|
+
if (isPositive) {
|
|
54
|
+
const c = clue;
|
|
55
|
+
const matchForward = c.cat1 === this.targetFact.category1Id && c.val1 === this.targetFact.value1 && c.cat2 === this.targetFact.category2Id;
|
|
56
|
+
const matchReverse = c.cat2 === this.targetFact.category1Id && c.val2 === this.targetFact.value1 && c.cat1 === this.targetFact.category2Id;
|
|
57
|
+
if (matchForward || matchReverse) {
|
|
58
|
+
isDirectAnswer = true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { clue, score, deductions, isDirectAnswer };
|
|
63
|
+
});
|
|
64
|
+
// Sort by score descending
|
|
65
|
+
scored.sort((a, b) => b.score - a.score);
|
|
66
|
+
return scored.slice(0, limit);
|
|
67
|
+
}
|
|
68
|
+
useClue(clue) {
|
|
69
|
+
// Ensure the clue is removed from available if it was there
|
|
70
|
+
// (If searching, the UI passes a Clue object that should exist in our availableClues)
|
|
71
|
+
this.applyAndSave(clue);
|
|
72
|
+
return { remaining: this.availableClues.length, solved: this.generator.isPuzzleSolved(this.grid, this.solution, this.reverseSolution) };
|
|
73
|
+
}
|
|
74
|
+
filterClues(constraints) {
|
|
75
|
+
return this.availableClues.filter(clue => {
|
|
30
76
|
// 2. Constraints
|
|
31
77
|
if (constraints?.allowedClueTypes && !constraints.allowedClueTypes.includes(clue.type))
|
|
32
78
|
return false;
|
|
79
|
+
// Validation: Check for intersection
|
|
80
|
+
if (constraints?.includeSubjects && constraints?.excludeSubjects) {
|
|
81
|
+
const intersection = constraints.includeSubjects.filter(s => constraints.excludeSubjects.includes(s));
|
|
82
|
+
if (intersection.length > 0) {
|
|
83
|
+
throw new Error(`Constraint Error: The following subjects are both included and excluded: ${intersection.join(', ')}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Subject Constraints
|
|
87
|
+
if (constraints?.includeSubjects || constraints?.excludeSubjects) {
|
|
88
|
+
const valuesInClue = this.extractValuesFromClue(clue);
|
|
89
|
+
if (constraints.includeSubjects) {
|
|
90
|
+
const hasMatch = constraints.includeSubjects.some(s => valuesInClue.includes(s));
|
|
91
|
+
if (!hasMatch)
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
if (constraints.excludeSubjects) {
|
|
95
|
+
const hasMatch = constraints.excludeSubjects.some(s => valuesInClue.includes(s));
|
|
96
|
+
if (hasMatch)
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
33
100
|
return true;
|
|
34
101
|
});
|
|
102
|
+
}
|
|
103
|
+
applyAndSave(clue) {
|
|
104
|
+
// Save state
|
|
105
|
+
this.historyStack.push(this.grid.clone());
|
|
106
|
+
// Apply it
|
|
107
|
+
this.solver.applyClue(this.grid, clue);
|
|
108
|
+
this.proofChain.push(clue);
|
|
109
|
+
// Remove from available
|
|
110
|
+
const idx = this.availableClues.indexOf(clue);
|
|
111
|
+
if (idx > -1)
|
|
112
|
+
this.availableClues.splice(idx, 1);
|
|
113
|
+
}
|
|
114
|
+
getNextClue(constraints) {
|
|
115
|
+
const validClues = this.filterClues(constraints);
|
|
35
116
|
// Score them
|
|
36
117
|
let bestClue = null;
|
|
37
118
|
let bestScore = -Infinity;
|
|
38
|
-
//
|
|
39
|
-
// For interactive session, speed is less critical than quality, but we want variety.
|
|
40
|
-
// Shuffle validClues first.
|
|
41
|
-
// In-place shuffle copy
|
|
119
|
+
// Shuffle validClues first to ensure variety.
|
|
42
120
|
const candidates = [...validClues].sort(() => Math.random() - 0.5);
|
|
43
121
|
// Take top N candidates?
|
|
44
122
|
const searchLimit = 50;
|
|
45
123
|
let checked = 0;
|
|
124
|
+
const minDeductions = constraints?.minDeductions ?? 0;
|
|
46
125
|
for (const clue of candidates) {
|
|
47
126
|
checked++;
|
|
48
127
|
if (checked > searchLimit && bestClue)
|
|
49
128
|
break; // found something good enough?
|
|
50
|
-
|
|
51
|
-
//
|
|
52
|
-
);
|
|
53
|
-
// Wait, publicCalculateScore needs `deductions`.
|
|
54
|
-
// So we must clone grid and apply.
|
|
129
|
+
// Score Logic
|
|
130
|
+
// We must clone grid and apply to see deductions.
|
|
55
131
|
const tempGrid = this.grid.clone();
|
|
56
132
|
const { deductions } = this.solver.applyClue(tempGrid, clue);
|
|
57
|
-
|
|
58
|
-
|
|
133
|
+
// Check Deduction Floor
|
|
134
|
+
if (deductions < minDeductions) {
|
|
59
135
|
continue;
|
|
60
136
|
}
|
|
61
137
|
// Re-calc score with deductions
|
|
@@ -66,19 +142,28 @@ class GenerativeSession {
|
|
|
66
142
|
}
|
|
67
143
|
}
|
|
68
144
|
if (bestClue) {
|
|
69
|
-
|
|
70
|
-
this.historyStack.push(this.grid.clone());
|
|
71
|
-
// Apply it
|
|
72
|
-
this.solver.applyClue(this.grid, bestClue);
|
|
73
|
-
this.proofChain.push(bestClue);
|
|
74
|
-
// Remove from available
|
|
75
|
-
const idx = this.availableClues.indexOf(bestClue);
|
|
76
|
-
if (idx > -1)
|
|
77
|
-
this.availableClues.splice(idx, 1);
|
|
145
|
+
this.applyAndSave(bestClue);
|
|
78
146
|
return { clue: bestClue, remaining: this.availableClues.length, solved: this.generator.isPuzzleSolved(this.grid, this.solution, this.reverseSolution) };
|
|
79
147
|
}
|
|
80
148
|
return { clue: null, remaining: validClues.length, solved: this.generator.isPuzzleSolved(this.grid, this.solution, this.reverseSolution) };
|
|
81
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Asynchronously gets the next clue (non-blocking wrapper).
|
|
152
|
+
* @param constraints
|
|
153
|
+
*/
|
|
154
|
+
async getNextClueAsync(constraints) {
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
setTimeout(() => {
|
|
157
|
+
try {
|
|
158
|
+
const result = this.getNextClue(constraints);
|
|
159
|
+
resolve(result);
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
reject(e);
|
|
163
|
+
}
|
|
164
|
+
}, 0);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
82
167
|
rollbackLastClue() {
|
|
83
168
|
if (this.historyStack.length === 0)
|
|
84
169
|
return { success: false, clue: null };
|
|
@@ -105,5 +190,24 @@ class GenerativeSession {
|
|
|
105
190
|
getProofChain() { return this.proofChain; }
|
|
106
191
|
getSolution() { return this.solution; }
|
|
107
192
|
getValueMap() { return this.valueMap; }
|
|
193
|
+
extractValuesFromClue(clue) {
|
|
194
|
+
const values = [];
|
|
195
|
+
// Extract based on type (simpler than full traversal)
|
|
196
|
+
// We cast values to string for easy comparison
|
|
197
|
+
const add = (v) => { if (v !== undefined)
|
|
198
|
+
values.push(String(v)); };
|
|
199
|
+
// Common fields
|
|
200
|
+
// @ts-ignore
|
|
201
|
+
add(clue.val1);
|
|
202
|
+
// @ts-ignore
|
|
203
|
+
add(clue.val2);
|
|
204
|
+
// @ts-ignore
|
|
205
|
+
add(clue.item1Val);
|
|
206
|
+
// @ts-ignore
|
|
207
|
+
add(clue.item2Val);
|
|
208
|
+
// @ts-ignore
|
|
209
|
+
add(clue.targetVal);
|
|
210
|
+
return values;
|
|
211
|
+
}
|
|
108
212
|
}
|
|
109
213
|
exports.GenerativeSession = GenerativeSession;
|
|
@@ -96,6 +96,31 @@ export declare class Generator {
|
|
|
96
96
|
* @param config - Generation options.
|
|
97
97
|
*/
|
|
98
98
|
generatePuzzle(categories: CategoryConfig[], target?: TargetFact, config?: GeneratorOptions): Puzzle;
|
|
99
|
+
/**
|
|
100
|
+
* Helper to validate a target against categories
|
|
101
|
+
*/
|
|
102
|
+
private validateTarget;
|
|
103
|
+
/**
|
|
104
|
+
* Helper to generate a random target
|
|
105
|
+
*/
|
|
106
|
+
private generateRandomTarget;
|
|
107
|
+
/**
|
|
108
|
+
* Asynchronously generates a puzzle (non-blocking wrapper).
|
|
109
|
+
* @param categories
|
|
110
|
+
* @param target
|
|
111
|
+
* @param config
|
|
112
|
+
*/
|
|
113
|
+
generatePuzzleAsync(categories: CategoryConfig[], target?: TargetFact, config?: GeneratorOptions): Promise<Puzzle>;
|
|
114
|
+
/**
|
|
115
|
+
* Asynchronously estimates clue count bounds (non-blocking wrapper).
|
|
116
|
+
* @param categories
|
|
117
|
+
* @param target
|
|
118
|
+
* @param maxIterations
|
|
119
|
+
*/
|
|
120
|
+
getClueCountBoundsAsync(categories: CategoryConfig[], target: TargetFact, maxIterations?: number): Promise<{
|
|
121
|
+
min: number;
|
|
122
|
+
max: number;
|
|
123
|
+
}>;
|
|
99
124
|
/**
|
|
100
125
|
* Starts an interactive generative session.
|
|
101
126
|
* @param categories
|
|
@@ -81,41 +81,106 @@ class Generator {
|
|
|
81
81
|
* @param config - Generation options.
|
|
82
82
|
*/
|
|
83
83
|
generatePuzzle(categories, target, config = {}) {
|
|
84
|
+
// Validation:
|
|
85
|
+
// 1. Min Categories
|
|
86
|
+
if (categories.length < 2) {
|
|
87
|
+
throw new errors_1.ConfigurationError('Puzzle must have at least 2 categories.');
|
|
88
|
+
}
|
|
84
89
|
const { targetClueCount, maxCandidates = 50, timeoutMs = 10000 } = config;
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
90
|
+
// 2. Validate Target
|
|
91
|
+
const finalTarget = target || this.generateRandomTarget(categories);
|
|
92
|
+
this.validateTarget(categories, finalTarget);
|
|
93
|
+
// 3. Constraints
|
|
94
|
+
// Check for impossible requests
|
|
95
|
+
const constraints = config.constraints;
|
|
96
|
+
if (constraints?.allowedClueTypes) {
|
|
97
|
+
const types = constraints.allowedClueTypes;
|
|
98
|
+
const hasOrdinalCategory = categories.some(c => c.type === types_1.CategoryType.ORDINAL);
|
|
99
|
+
const requestedOrdinal = types.includes(types_1.ClueType.ORDINAL);
|
|
100
|
+
const requestedCrossOrdinal = types.includes(types_1.ClueType.CROSS_ORDINAL);
|
|
101
|
+
if (requestedOrdinal && !hasOrdinalCategory) {
|
|
102
|
+
// If Binary is allowed, we can fallback to just Binary.
|
|
103
|
+
// Only throw if we strictly CANNOT satisfy this without Ordinal categories.
|
|
104
|
+
if (!types.includes(types_1.ClueType.BINARY)) {
|
|
105
|
+
throw new errors_1.ConfigurationError('Invalid Constraints: Ordinal-based clue types were requested, but no Ordinal categories exist. Please add an ordinal category or allow Binary clues.');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (requestedCrossOrdinal) {
|
|
109
|
+
const ordinalCount = categories.filter(c => c.type === types_1.CategoryType.ORDINAL).length;
|
|
110
|
+
if (ordinalCount < 2) {
|
|
111
|
+
throw new errors_1.ConfigurationError('Invalid Constraints: Cross-Ordinal clues require at least 2 Ordinal Categories.');
|
|
112
|
+
}
|
|
100
113
|
}
|
|
101
|
-
const c1 = categories[cat1Idx];
|
|
102
|
-
const c2 = categories[cat2Idx];
|
|
103
|
-
const valIdx = Math.floor(this.random() * c1.values.length);
|
|
104
|
-
finalTarget = {
|
|
105
|
-
category1Id: c1.id,
|
|
106
|
-
value1: c1.values[valIdx],
|
|
107
|
-
category2Id: c2.id
|
|
108
|
-
};
|
|
109
114
|
}
|
|
110
|
-
|
|
115
|
+
return this.internalGenerate(categories, finalTarget, 'standard', { maxCandidates, targetClueCount, timeoutMs, constraints: config.constraints, onTrace: config.onTrace });
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Helper to validate a target against categories
|
|
119
|
+
*/
|
|
120
|
+
validateTarget(categories, target) {
|
|
111
121
|
const catIds = new Set(categories.map(c => c.id));
|
|
112
|
-
if (!catIds.has(
|
|
122
|
+
if (!catIds.has(target.category1Id) || !catIds.has(target.category2Id)) {
|
|
113
123
|
throw new errors_1.ConfigurationError('Target fact refers to non-existent categories.');
|
|
114
124
|
}
|
|
115
|
-
if (
|
|
125
|
+
if (target.category1Id === target.category2Id) {
|
|
116
126
|
throw new errors_1.ConfigurationError('Target fact must refer to two different categories.');
|
|
117
127
|
}
|
|
118
|
-
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Helper to generate a random target
|
|
131
|
+
*/
|
|
132
|
+
generateRandomTarget(categories) {
|
|
133
|
+
const cat1Idx = Math.floor(this.random() * categories.length);
|
|
134
|
+
let cat2Idx = Math.floor(this.random() * categories.length);
|
|
135
|
+
while (cat2Idx === cat1Idx) {
|
|
136
|
+
cat2Idx = Math.floor(this.random() * categories.length);
|
|
137
|
+
}
|
|
138
|
+
const c1 = categories[cat1Idx];
|
|
139
|
+
const c2 = categories[cat2Idx];
|
|
140
|
+
const valIdx = Math.floor(this.random() * c1.values.length);
|
|
141
|
+
return {
|
|
142
|
+
category1Id: c1.id,
|
|
143
|
+
value1: c1.values[valIdx],
|
|
144
|
+
category2Id: c2.id
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Asynchronously generates a puzzle (non-blocking wrapper).
|
|
149
|
+
* @param categories
|
|
150
|
+
* @param target
|
|
151
|
+
* @param config
|
|
152
|
+
*/
|
|
153
|
+
async generatePuzzleAsync(categories, target, config = {}) {
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
setTimeout(() => {
|
|
156
|
+
try {
|
|
157
|
+
const result = this.generatePuzzle(categories, target, config);
|
|
158
|
+
resolve(result);
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
reject(e);
|
|
162
|
+
}
|
|
163
|
+
}, 0);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Asynchronously estimates clue count bounds (non-blocking wrapper).
|
|
168
|
+
* @param categories
|
|
169
|
+
* @param target
|
|
170
|
+
* @param maxIterations
|
|
171
|
+
*/
|
|
172
|
+
async getClueCountBoundsAsync(categories, target, maxIterations = 10) {
|
|
173
|
+
return new Promise((resolve, reject) => {
|
|
174
|
+
setTimeout(() => {
|
|
175
|
+
try {
|
|
176
|
+
const result = this.getClueCountBounds(categories, target, maxIterations);
|
|
177
|
+
resolve(result);
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
reject(e);
|
|
181
|
+
}
|
|
182
|
+
}, 0);
|
|
183
|
+
});
|
|
119
184
|
}
|
|
120
185
|
/**
|
|
121
186
|
* Starts an interactive generative session.
|
package/dist/src/types.d.ts
CHANGED
|
@@ -83,4 +83,18 @@ export interface ClueGenerationConstraints {
|
|
|
83
83
|
* Use this to control difficulty or puzzle attributes.
|
|
84
84
|
*/
|
|
85
85
|
allowedClueTypes?: ClueType[];
|
|
86
|
+
/**
|
|
87
|
+
* If provided, only clues that refer to these specific values (as subjects or objects) will be generated.
|
|
88
|
+
* Useful for "Ask this suspect" mechanics.
|
|
89
|
+
*/
|
|
90
|
+
includeSubjects?: string[];
|
|
91
|
+
/**
|
|
92
|
+
* If provided, clues referring to these values will be excluded.
|
|
93
|
+
*/
|
|
94
|
+
excludeSubjects?: string[];
|
|
95
|
+
/**
|
|
96
|
+
* Minimum number of new deductions this clue must provide to be considered valid.
|
|
97
|
+
* Default is 0 (allow all clues). Set to 1 to ensure progress.
|
|
98
|
+
*/
|
|
99
|
+
minDeductions?: number;
|
|
86
100
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "logic-puzzle-generator",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public",
|
|
6
|
+
"registry": "https://registry.npmjs.org/"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/joshhills/logic-puzzle-generator.git"
|
|
11
|
+
},
|
|
4
12
|
"description": "A headless, TypeScript-based Logic Grid Puzzle Engine.",
|
|
5
13
|
"main": "dist/src/index.js",
|
|
6
14
|
"types": "dist/src/index.d.ts",
|