@omercnet/paseo-queens 0.1.0-next.126.2
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/LICENSE +21 -0
- package/README.md +122 -0
- package/client/completion-feedback.tsx +55 -0
- package/client/composer-pill.tsx +74 -0
- package/client/contribute.tsx +34 -0
- package/client/game/curated.ts +91 -0
- package/client/game/engine.ts +199 -0
- package/client/game/index.ts +5 -0
- package/client/game/puzzles.ts +100 -0
- package/client/game/state.ts +348 -0
- package/client/game/types.ts +7 -0
- package/client/game-controls.tsx +199 -0
- package/client/game-mark.tsx +54 -0
- package/client/puzzle-selector.tsx +187 -0
- package/client/queens-board.tsx +592 -0
- package/client/queens-popover.tsx +349 -0
- package/client/queens-surface.tsx +761 -0
- package/client/use-persisted-game.ts +769 -0
- package/client/use-puzzle-catalog.ts +112 -0
- package/index.client.tsx +6 -0
- package/index.server.ts +10 -0
- package/package.json +58 -0
- package/paseo-plugin.json +4 -0
- package/scripts/import-curated-puzzles.mjs +231 -0
- package/server/curated-manifest.ts +334 -0
- package/server/puzzle-catalog.ts +70 -0
- package/shared/game-settings.ts +114 -0
- package/shared/puzzle-catalog.ts +29 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Omer Cohen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Queens
|
|
2
|
+
|
|
3
|
+
A cross-platform Queens logic game for Paseo. Play from the global sidebar or open the compact composer mini-game while agents work in the background.
|
|
4
|
+
|
|
5
|
+
## Screenshots
|
|
6
|
+
|
|
7
|
+
These screenshots use synthetic workspace and agent names on an isolated Paseo daemon.
|
|
8
|
+
|
|
9
|
+
### Full game
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
### Compact 14×14 board
|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
### Composer mini-game
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
21
|
+
## What it does
|
|
22
|
+
|
|
23
|
+
- Provides a global **Queens** sidebar surface and Command Center entry.
|
|
24
|
+
- Adds a **Queens** composer pill to every active agent.
|
|
25
|
+
- Opens a playable anchored popover on wide layouts and a compact sheet on mobile.
|
|
26
|
+
- Supports 5×5 through 14×14 boards at Beginner, Easy, Medium, and Hard levels.
|
|
27
|
+
- Stores progress, completion records, selected size, and difficulty in host-scoped settings.
|
|
28
|
+
- Shares progress and undo history between the full surface and composer mini-game.
|
|
29
|
+
- Shows the number of running agents and notifies you when they all become idle.
|
|
30
|
+
|
|
31
|
+
## How to play
|
|
32
|
+
|
|
33
|
+
Place exactly one queen in every row, column, and colored region. Queens may not touch, including diagonally.
|
|
34
|
+
|
|
35
|
+
- Tap or click once to place or remove an X.
|
|
36
|
+
- Double-tap or double-click to place or remove a queen.
|
|
37
|
+
- Drag from an empty square to mark multiple Xs.
|
|
38
|
+
- Drag from an X to erase multiple Xs.
|
|
39
|
+
- Use **Hint**, **Undo**, and **Reset** when needed.
|
|
40
|
+
|
|
41
|
+
## Puzzle catalog
|
|
42
|
+
|
|
43
|
+
The catalog contains 97,184 curated puzzles across 40 size and difficulty combinations. Puzzle layouts, classifications, and solutions are sourced from the public [Queens Ultimate](https://queensultimate.com/) puzzle corpus.
|
|
44
|
+
|
|
45
|
+
The compact groups live in an unlisted, revision-pinned [GitHub Gist](https://gist.github.com/omercnet/652af12e2640979ba7fb5dc5adddde42). The Paseo daemon fetches only the selected group, verifies its SHA-256 digest, and caches it for the session. Opening a size and difficulty for the first time therefore requires network access to `gist.githubusercontent.com`.
|
|
46
|
+
|
|
47
|
+
The importer validates every public index and source chunk, then writes the 40 upload payloads and a manifest to `/tmp/queens-curated-gist` by default:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm run import:puzzles
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
After uploading those files to an unlisted Gist, regenerate the pinned runtime manifest with the immutable Gist revision:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
QUEENS_GIST_ID=<gist-id> \
|
|
57
|
+
QUEENS_GIST_REVISION=<revision-sha> \
|
|
58
|
+
npm run import:puzzles
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
### Paseo 0.9 beta
|
|
64
|
+
|
|
65
|
+
Install the published npm package on the daemon host:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
paseo plugin install npm:@omercnet/paseo-queens
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Update an npm installation:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
paseo plugin update queens --check
|
|
75
|
+
paseo plugin update queens
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Paseo 0.8
|
|
79
|
+
|
|
80
|
+
Install from this monorepo:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
paseo plugin install omercnet/paseo-plugins:queens
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Or install a local checkout by absolute path on the daemon host:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
paseo plugin install /absolute/path/to/paseo-plugins/queens
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Open **Queens** from the sidebar or Command Center. Open any agent session to use the composer mini-game.
|
|
93
|
+
|
|
94
|
+
## Develop
|
|
95
|
+
|
|
96
|
+
Use an isolated Paseo home and explicit host. Never reload this development checkout against your default daemon.
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
npm ci
|
|
100
|
+
npm run check
|
|
101
|
+
npm run typecheck
|
|
102
|
+
npm test
|
|
103
|
+
|
|
104
|
+
paseo daemon config set daemon.listen 127.0.0.1:6802 --home /tmp/queens-dev
|
|
105
|
+
paseo daemon config set daemon.relay.enabled false --home /tmp/queens-dev
|
|
106
|
+
paseo daemon config set features.webUi.enabled true --home /tmp/queens-dev
|
|
107
|
+
paseo daemon config set pluginsEnabled true --home /tmp/queens-dev
|
|
108
|
+
paseo daemon start --home /tmp/queens-dev
|
|
109
|
+
paseo plugin install "$PWD" --host 127.0.0.1:6802
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Release Please owns package versions, changelog generation, component tags, and GitHub releases.
|
|
113
|
+
|
|
114
|
+
## Requirements
|
|
115
|
+
|
|
116
|
+
- Paseo `^0.8.0` or `^0.9.0-beta.1`
|
|
117
|
+
- Plugins enabled on the target daemon
|
|
118
|
+
- React Native-compatible Paseo client on web, desktop, iOS, or Android
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT. See [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { type StyleProp, StyleSheet, View, type ViewStyle } from "react-native";
|
|
3
|
+
|
|
4
|
+
export type CompletionFeedbackProps = {
|
|
5
|
+
solved: boolean;
|
|
6
|
+
color: string;
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
style?: StyleProp<ViewStyle>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Adds a static, non-interactive solved-state illumination around existing content.
|
|
13
|
+
* Keeping this to core View primitives makes the surface safe in every plugin runtime.
|
|
14
|
+
*/
|
|
15
|
+
export function CompletionFeedback({ solved, color, children, style }: CompletionFeedbackProps) {
|
|
16
|
+
return (
|
|
17
|
+
<View style={[styles.container, style]}>
|
|
18
|
+
{children}
|
|
19
|
+
{solved ? (
|
|
20
|
+
<View
|
|
21
|
+
pointerEvents="none"
|
|
22
|
+
accessible={false}
|
|
23
|
+
accessibilityElementsHidden
|
|
24
|
+
importantForAccessibility="no-hide-descendants"
|
|
25
|
+
style={styles.overlay}
|
|
26
|
+
>
|
|
27
|
+
<View style={[styles.tint, { backgroundColor: color }]} />
|
|
28
|
+
<View style={[styles.outline, { borderColor: color }]} />
|
|
29
|
+
</View>
|
|
30
|
+
) : null}
|
|
31
|
+
</View>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const styles = StyleSheet.create({
|
|
36
|
+
container: {
|
|
37
|
+
position: "relative",
|
|
38
|
+
overflow: "hidden",
|
|
39
|
+
borderRadius: 14,
|
|
40
|
+
},
|
|
41
|
+
overlay: {
|
|
42
|
+
...StyleSheet.absoluteFillObject,
|
|
43
|
+
borderRadius: 14,
|
|
44
|
+
},
|
|
45
|
+
tint: {
|
|
46
|
+
...StyleSheet.absoluteFillObject,
|
|
47
|
+
opacity: 0.055,
|
|
48
|
+
},
|
|
49
|
+
outline: {
|
|
50
|
+
...StyleSheet.absoluteFillObject,
|
|
51
|
+
borderWidth: 2,
|
|
52
|
+
borderRadius: 14,
|
|
53
|
+
opacity: 0.34,
|
|
54
|
+
},
|
|
55
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
2
|
+
import { QueenPillIcon, QueensPopover } from "./queens-popover";
|
|
3
|
+
|
|
4
|
+
const PAGE_LIMIT = 200;
|
|
5
|
+
const MAX_PAGES = 10;
|
|
6
|
+
|
|
7
|
+
type AgentTarget = {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly workspaceId?: string | null;
|
|
10
|
+
readonly status: string;
|
|
11
|
+
readonly archivedAt?: string | null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function registerQueensComposerPills(client: PluginClientContext) {
|
|
15
|
+
const pills = new Map<string, { workspaceId: string; remove(): void }>();
|
|
16
|
+
let stopped = false;
|
|
17
|
+
|
|
18
|
+
const removePill = (agentId: string) => {
|
|
19
|
+
pills.get(agentId)?.remove();
|
|
20
|
+
pills.delete(agentId);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const syncAgent = (agent: AgentTarget) => {
|
|
24
|
+
if (!agent.workspaceId || agent.archivedAt || agent.status === "closed") {
|
|
25
|
+
removePill(agent.id);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const current = pills.get(agent.id);
|
|
30
|
+
if (current?.workspaceId === agent.workspaceId) return;
|
|
31
|
+
removePill(agent.id);
|
|
32
|
+
const pill = client.addComposerPill({
|
|
33
|
+
id: "queens",
|
|
34
|
+
workspaceId: agent.workspaceId,
|
|
35
|
+
agentId: agent.id,
|
|
36
|
+
button: {
|
|
37
|
+
title: "Queens",
|
|
38
|
+
label: "Queens",
|
|
39
|
+
icon: QueenPillIcon,
|
|
40
|
+
behavior: { kind: "popover", Content: QueensPopover },
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
pills.set(agent.id, { workspaceId: agent.workspaceId, remove: pill.remove });
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const unsubscribe = client.paseo.agents.subscribe((update) => {
|
|
47
|
+
if (update.kind === "remove") {
|
|
48
|
+
removePill(update.agentId);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
syncAgent(update.agent);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
void (async () => {
|
|
55
|
+
let cursor: string | undefined;
|
|
56
|
+
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
57
|
+
const result = await client.paseo.agents.list({
|
|
58
|
+
sort: [{ key: "updated_at", direction: "desc" }],
|
|
59
|
+
page: { limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
|
|
60
|
+
});
|
|
61
|
+
if (stopped) return;
|
|
62
|
+
for (const { agent } of result.entries) syncAgent(agent);
|
|
63
|
+
cursor = result.pageInfo.hasMore ? (result.pageInfo.nextCursor ?? undefined) : undefined;
|
|
64
|
+
if (!cursor) return;
|
|
65
|
+
}
|
|
66
|
+
})().catch(() => undefined);
|
|
67
|
+
|
|
68
|
+
return () => {
|
|
69
|
+
stopped = true;
|
|
70
|
+
unsubscribe();
|
|
71
|
+
for (const pill of pills.values()) pill.remove();
|
|
72
|
+
pills.clear();
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
2
|
+
import { registerQueensComposerPills } from "./composer-pill";
|
|
3
|
+
import { PaseoQueensSurface } from "./queens-surface";
|
|
4
|
+
import { disposePersistedGames } from "./use-persisted-game";
|
|
5
|
+
|
|
6
|
+
export function registerPaseoQueensClient(client: PluginClientContext) {
|
|
7
|
+
const cleanups = [
|
|
8
|
+
disposePersistedGames,
|
|
9
|
+
client.addSurface("queens", PaseoQueensSurface),
|
|
10
|
+
registerQueensComposerPills(client),
|
|
11
|
+
client.addSidebarItem({
|
|
12
|
+
id: "queens",
|
|
13
|
+
title: "Queens",
|
|
14
|
+
icon: "Crown",
|
|
15
|
+
surface: "queens",
|
|
16
|
+
}),
|
|
17
|
+
client.addCommandCenterItem({
|
|
18
|
+
id: "open-queens",
|
|
19
|
+
title: "Open Queens",
|
|
20
|
+
icon: "Crown",
|
|
21
|
+
keywords: ["queens", "logic", "puzzle", "game"],
|
|
22
|
+
context: "global",
|
|
23
|
+
onSelect({ openSurface }) {
|
|
24
|
+
openSurface("queens");
|
|
25
|
+
},
|
|
26
|
+
}),
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
return () => {
|
|
30
|
+
for (const cleanup of cleanups.reverse()) cleanup();
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default registerPaseoQueensClient;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { PuzzleDifficulty } from "../../shared/puzzle-catalog";
|
|
2
|
+
import type { Puzzle } from "./types";
|
|
3
|
+
|
|
4
|
+
export type EncodedPuzzleDeck = {
|
|
5
|
+
readonly size: number;
|
|
6
|
+
readonly difficulty: PuzzleDifficulty;
|
|
7
|
+
readonly count: number;
|
|
8
|
+
readonly recordBytes: number;
|
|
9
|
+
readonly data: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type CuratedPuzzleDeck = {
|
|
13
|
+
readonly key: string;
|
|
14
|
+
readonly size: number;
|
|
15
|
+
readonly difficulty: PuzzleDifficulty;
|
|
16
|
+
readonly puzzles: readonly Puzzle[];
|
|
17
|
+
readonly solutions: Readonly<Record<string, readonly number[]>>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const BASE64_VALUES = new Int16Array(128);
|
|
21
|
+
BASE64_VALUES.fill(-1);
|
|
22
|
+
for (let index = 0; index < 26; index += 1) {
|
|
23
|
+
BASE64_VALUES[65 + index] = index;
|
|
24
|
+
BASE64_VALUES[97 + index] = index + 26;
|
|
25
|
+
}
|
|
26
|
+
for (let index = 0; index < 10; index += 1) BASE64_VALUES[48 + index] = index + 52;
|
|
27
|
+
BASE64_VALUES[43] = 62;
|
|
28
|
+
BASE64_VALUES[47] = 63;
|
|
29
|
+
|
|
30
|
+
function decodeBase64(encoded: string): Uint8Array {
|
|
31
|
+
const padding = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0;
|
|
32
|
+
const output = new Uint8Array((encoded.length * 3) / 4 - padding);
|
|
33
|
+
let accumulator = 0;
|
|
34
|
+
let bits = 0;
|
|
35
|
+
let outputIndex = 0;
|
|
36
|
+
for (let index = 0; index < encoded.length; index += 1) {
|
|
37
|
+
const code = encoded.charCodeAt(index);
|
|
38
|
+
if (code === 61) break;
|
|
39
|
+
const value = code < BASE64_VALUES.length ? BASE64_VALUES[code] : -1;
|
|
40
|
+
if (value < 0) throw new RangeError("Curated puzzle data contains invalid base64.");
|
|
41
|
+
accumulator = (accumulator << 6) | value;
|
|
42
|
+
bits += 6;
|
|
43
|
+
if (bits >= 8) {
|
|
44
|
+
bits -= 8;
|
|
45
|
+
output[outputIndex] = (accumulator >> bits) & 255;
|
|
46
|
+
outputIndex += 1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (outputIndex !== output.length) throw new RangeError("Curated puzzle data is truncated.");
|
|
50
|
+
return output;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function decodePuzzleDeck(encoded: EncodedPuzzleDeck): CuratedPuzzleDeck {
|
|
54
|
+
const key = `${encoded.size}-${encoded.difficulty}`;
|
|
55
|
+
|
|
56
|
+
const bytes = decodeBase64(encoded.data);
|
|
57
|
+
if (bytes.length !== encoded.count * encoded.recordBytes) {
|
|
58
|
+
throw new RangeError(`Curated ${key} deck has an invalid byte length.`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const regionBytes = Math.ceil((encoded.size * encoded.size) / 2);
|
|
62
|
+
const expectedRecordBytes = 2 + regionBytes + encoded.size;
|
|
63
|
+
if (encoded.recordBytes !== expectedRecordBytes) {
|
|
64
|
+
throw new RangeError(`Curated ${key} deck has an invalid record size.`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const puzzles: Puzzle[] = [];
|
|
68
|
+
const solutions: Record<string, readonly number[]> = {};
|
|
69
|
+
for (let record = 0; record < encoded.count; record += 1) {
|
|
70
|
+
let offset = record * encoded.recordBytes;
|
|
71
|
+
const sourceId = (bytes[offset] << 8) | bytes[offset + 1];
|
|
72
|
+
offset += 2;
|
|
73
|
+
const id = `queens-${encoded.size}x${encoded.size}-${encoded.difficulty}-${sourceId}`;
|
|
74
|
+
const regions = Array<number>(encoded.size * encoded.size);
|
|
75
|
+
for (let cell = 0; cell < regions.length; cell += 2) {
|
|
76
|
+
const packed = bytes[offset + Math.floor(cell / 2)];
|
|
77
|
+
regions[cell] = packed >> 4;
|
|
78
|
+
if (cell + 1 < regions.length) regions[cell + 1] = packed & 15;
|
|
79
|
+
}
|
|
80
|
+
offset += regionBytes;
|
|
81
|
+
const solution = Array<number>(encoded.size);
|
|
82
|
+
for (let row = 0; row < encoded.size; row += 1) {
|
|
83
|
+
solution[row] = row * encoded.size + bytes[offset + row];
|
|
84
|
+
}
|
|
85
|
+
puzzles.push({ id, size: encoded.size, regions });
|
|
86
|
+
solutions[id] = solution;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const deck = { key, size: encoded.size, difficulty: encoded.difficulty, puzzles, solutions };
|
|
90
|
+
return deck;
|
|
91
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { CellState, Puzzle } from "./types";
|
|
2
|
+
|
|
3
|
+
function assertSize(size: number): void {
|
|
4
|
+
if (!Number.isInteger(size) || size < 1) {
|
|
5
|
+
throw new RangeError("Board size must be a positive integer.");
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function assertPuzzle(puzzle: Puzzle): void {
|
|
10
|
+
assertSize(puzzle.size);
|
|
11
|
+
|
|
12
|
+
const expectedCellCount = puzzle.size * puzzle.size;
|
|
13
|
+
if (puzzle.regions.length !== expectedCellCount) {
|
|
14
|
+
throw new RangeError(`Puzzle must contain exactly ${expectedCellCount} region cells.`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const regions = new Set<number>();
|
|
18
|
+
for (const region of puzzle.regions) {
|
|
19
|
+
if (!Number.isInteger(region) || region < 0 || region >= puzzle.size) {
|
|
20
|
+
throw new RangeError(`Region ids must be integers from 0 to ${puzzle.size - 1}.`);
|
|
21
|
+
}
|
|
22
|
+
regions.add(region);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (regions.size !== puzzle.size) {
|
|
26
|
+
throw new RangeError(`Puzzle must contain all ${puzzle.size} region ids.`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function assertCells(puzzle: Puzzle, cells: readonly CellState[]): void {
|
|
31
|
+
const expectedCellCount = puzzle.size * puzzle.size;
|
|
32
|
+
if (cells.length !== expectedCellCount) {
|
|
33
|
+
throw new RangeError(`Board must contain exactly ${expectedCellCount} cells.`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createEmptyCells(size: number): CellState[] {
|
|
38
|
+
assertSize(size);
|
|
39
|
+
return Array.from({ length: size * size }, () => "empty" as const);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function setCellStates(
|
|
43
|
+
cells: readonly CellState[],
|
|
44
|
+
indexes: readonly number[],
|
|
45
|
+
state: CellState,
|
|
46
|
+
): readonly CellState[] {
|
|
47
|
+
let next: CellState[] | null = null;
|
|
48
|
+
for (const index of indexes) {
|
|
49
|
+
if (!Number.isInteger(index) || index < 0 || index >= cells.length) {
|
|
50
|
+
throw new RangeError("Cell index is outside the board.");
|
|
51
|
+
}
|
|
52
|
+
if (cells[index] === state) continue;
|
|
53
|
+
|
|
54
|
+
next ??= cells.slice();
|
|
55
|
+
next[index] = state;
|
|
56
|
+
}
|
|
57
|
+
return next ?? cells;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function addGroupConflicts(
|
|
61
|
+
groups: ReadonlyMap<number, readonly number[]>,
|
|
62
|
+
conflicts: Set<number>,
|
|
63
|
+
): void {
|
|
64
|
+
for (const indexes of groups.values()) {
|
|
65
|
+
if (indexes.length > 1) {
|
|
66
|
+
for (const index of indexes) conflicts.add(index);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function addToGroup(groups: Map<number, number[]>, key: number, index: number): void {
|
|
71
|
+
const group = groups.get(key);
|
|
72
|
+
if (group) group.push(index);
|
|
73
|
+
else groups.set(key, [index]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function findConflicts(puzzle: Puzzle, cells: readonly CellState[]): Set<number> {
|
|
77
|
+
assertPuzzle(puzzle);
|
|
78
|
+
assertCells(puzzle, cells);
|
|
79
|
+
|
|
80
|
+
const { size } = puzzle;
|
|
81
|
+
const markedIndexes: number[] = [];
|
|
82
|
+
const rows = new Map<number, number[]>();
|
|
83
|
+
const columns = new Map<number, number[]>();
|
|
84
|
+
const regions = new Map<number, number[]>();
|
|
85
|
+
|
|
86
|
+
for (let index = 0; index < cells.length; index += 1) {
|
|
87
|
+
if (cells[index] !== "marked") continue;
|
|
88
|
+
|
|
89
|
+
markedIndexes.push(index);
|
|
90
|
+
const row = Math.floor(index / size);
|
|
91
|
+
const column = index % size;
|
|
92
|
+
const region = puzzle.regions[index];
|
|
93
|
+
addToGroup(rows, row, index);
|
|
94
|
+
addToGroup(columns, column, index);
|
|
95
|
+
addToGroup(regions, region, index);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const conflicts = new Set<number>();
|
|
99
|
+
addGroupConflicts(rows, conflicts);
|
|
100
|
+
addGroupConflicts(columns, conflicts);
|
|
101
|
+
addGroupConflicts(regions, conflicts);
|
|
102
|
+
|
|
103
|
+
const marked = new Set(markedIndexes);
|
|
104
|
+
for (const index of markedIndexes) {
|
|
105
|
+
const row = Math.floor(index / size);
|
|
106
|
+
const column = index % size;
|
|
107
|
+
|
|
108
|
+
for (let rowOffset = -1; rowOffset <= 1; rowOffset += 1) {
|
|
109
|
+
for (let columnOffset = -1; columnOffset <= 1; columnOffset += 1) {
|
|
110
|
+
if (rowOffset === 0 && columnOffset === 0) continue;
|
|
111
|
+
|
|
112
|
+
const adjacentRow = row + rowOffset;
|
|
113
|
+
const adjacentColumn = column + columnOffset;
|
|
114
|
+
if (
|
|
115
|
+
adjacentRow < 0 ||
|
|
116
|
+
adjacentRow >= size ||
|
|
117
|
+
adjacentColumn < 0 ||
|
|
118
|
+
adjacentColumn >= size
|
|
119
|
+
) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const adjacentIndex = adjacentRow * size + adjacentColumn;
|
|
124
|
+
if (marked.has(adjacentIndex)) {
|
|
125
|
+
conflicts.add(index);
|
|
126
|
+
conflicts.add(adjacentIndex);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return conflicts;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function isSolved(puzzle: Puzzle, cells: readonly CellState[]): boolean {
|
|
136
|
+
const conflicts = findConflicts(puzzle, cells);
|
|
137
|
+
|
|
138
|
+
let markedCount = 0;
|
|
139
|
+
const markedRows = new Set<number>();
|
|
140
|
+
const markedColumns = new Set<number>();
|
|
141
|
+
const markedRegions = new Set<number>();
|
|
142
|
+
|
|
143
|
+
for (let index = 0; index < cells.length; index += 1) {
|
|
144
|
+
if (cells[index] !== "marked") continue;
|
|
145
|
+
markedCount += 1;
|
|
146
|
+
markedRows.add(Math.floor(index / puzzle.size));
|
|
147
|
+
markedColumns.add(index % puzzle.size);
|
|
148
|
+
markedRegions.add(puzzle.regions[index]);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
markedCount === puzzle.size &&
|
|
153
|
+
markedRows.size === puzzle.size &&
|
|
154
|
+
markedColumns.size === puzzle.size &&
|
|
155
|
+
markedRegions.size === puzzle.size &&
|
|
156
|
+
conflicts.size === 0
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function countSolutions(puzzle: Puzzle, limit = Number.POSITIVE_INFINITY): number {
|
|
161
|
+
assertPuzzle(puzzle);
|
|
162
|
+
if (limit !== Number.POSITIVE_INFINITY && (!Number.isInteger(limit) || limit < 0)) {
|
|
163
|
+
throw new RangeError("Solution limit must be a non-negative integer or Infinity.");
|
|
164
|
+
}
|
|
165
|
+
if (limit === 0) return 0;
|
|
166
|
+
|
|
167
|
+
const { size } = puzzle;
|
|
168
|
+
const usedColumns = new Uint8Array(size);
|
|
169
|
+
const usedRegions = new Uint8Array(size);
|
|
170
|
+
let solutions = 0;
|
|
171
|
+
|
|
172
|
+
function search(row: number, previousColumn: number): void {
|
|
173
|
+
if (solutions >= limit) return;
|
|
174
|
+
if (row === size) {
|
|
175
|
+
solutions += 1;
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (let column = 0; column < size; column += 1) {
|
|
180
|
+
const region = puzzle.regions[row * size + column];
|
|
181
|
+
if (
|
|
182
|
+
usedColumns[column] === 1 ||
|
|
183
|
+
usedRegions[region] === 1 ||
|
|
184
|
+
(row > 0 && Math.abs(column - previousColumn) <= 1)
|
|
185
|
+
) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
usedColumns[column] = 1;
|
|
190
|
+
usedRegions[region] = 1;
|
|
191
|
+
search(row + 1, column);
|
|
192
|
+
usedColumns[column] = 0;
|
|
193
|
+
usedRegions[region] = 0;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
search(0, -2);
|
|
198
|
+
return solutions;
|
|
199
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { countSolutions, createEmptyCells, findConflicts, isSolved, setCellStates } from "./engine";
|
|
2
|
+
export { PUZZLES } from "./puzzles";
|
|
3
|
+
export type { GameAction, GameState, PuzzleProgress, PuzzleSolution, TimerState } from "./state";
|
|
4
|
+
export { createGameState, gameReducer } from "./state";
|
|
5
|
+
export type { CellState, Puzzle } from "./types";
|