@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
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
import { type PluginSurfaceProps, usePaseo } from "@getpaseo/plugin/client";
|
|
2
|
+
import { useToast } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
ActivityIndicator,
|
|
6
|
+
Pressable,
|
|
7
|
+
ScrollView,
|
|
8
|
+
StyleSheet,
|
|
9
|
+
Text,
|
|
10
|
+
type TextStyle,
|
|
11
|
+
View,
|
|
12
|
+
type ViewStyle,
|
|
13
|
+
} from "react-native";
|
|
14
|
+
import { CompletionFeedback } from "./completion-feedback";
|
|
15
|
+
import { findConflicts } from "./game";
|
|
16
|
+
import type { CuratedPuzzleDeck } from "./game/curated";
|
|
17
|
+
import { GameControls } from "./game-controls";
|
|
18
|
+
import { GameMark } from "./game-mark";
|
|
19
|
+
import { PuzzleSelector } from "./puzzle-selector";
|
|
20
|
+
import { QueensBoard } from "./queens-board";
|
|
21
|
+
import { usePersistedGame } from "./use-persisted-game";
|
|
22
|
+
import { type PuzzleCatalogState, usePuzzleCatalog } from "./use-puzzle-catalog";
|
|
23
|
+
|
|
24
|
+
const EMPTY_CONFLICTS: ReadonlySet<number> = new Set();
|
|
25
|
+
const AGENT_PAGE_LIMIT = 200;
|
|
26
|
+
const AGENT_MAX_PAGES = 10;
|
|
27
|
+
const AGENT_BACKSTOP_REFRESH_MS = 30_000;
|
|
28
|
+
|
|
29
|
+
export function PaseoQueensSurface(props: PluginSurfaceProps) {
|
|
30
|
+
return <QueensGame key={props.host.id} {...props} />;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function QueensGame(props: PluginSurfaceProps) {
|
|
34
|
+
const catalog = usePuzzleCatalog();
|
|
35
|
+
if (!catalog.deck) {
|
|
36
|
+
return (
|
|
37
|
+
<View
|
|
38
|
+
style={{
|
|
39
|
+
flex: 1,
|
|
40
|
+
alignItems: "center",
|
|
41
|
+
justifyContent: "center",
|
|
42
|
+
gap: 10,
|
|
43
|
+
padding: 24,
|
|
44
|
+
backgroundColor: props.theme.colors.surface0,
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
{catalog.loading ? <ActivityIndicator color={props.theme.colors.accent} /> : null}
|
|
48
|
+
<Text
|
|
49
|
+
accessibilityRole={catalog.error ? "alert" : undefined}
|
|
50
|
+
style={{
|
|
51
|
+
color: catalog.error
|
|
52
|
+
? props.theme.colors.statusDanger
|
|
53
|
+
: props.theme.colors.foregroundMuted,
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
{catalog.error ?? "Loading curated puzzles…"}
|
|
57
|
+
</Text>
|
|
58
|
+
{catalog.error ? (
|
|
59
|
+
<Pressable
|
|
60
|
+
accessibilityRole="button"
|
|
61
|
+
accessibilityLabel="Retry puzzle download"
|
|
62
|
+
onPress={catalog.retry}
|
|
63
|
+
style={{
|
|
64
|
+
minHeight: 40,
|
|
65
|
+
justifyContent: "center",
|
|
66
|
+
paddingHorizontal: 16,
|
|
67
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
68
|
+
borderColor: props.theme.colors.border,
|
|
69
|
+
borderRadius: 8,
|
|
70
|
+
backgroundColor: props.theme.colors.surface2,
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
<Text style={{ color: props.theme.colors.foreground, fontWeight: "700" }}>Retry</Text>
|
|
74
|
+
</Pressable>
|
|
75
|
+
) : null}
|
|
76
|
+
</View>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return (
|
|
80
|
+
<QueensLoadedGame
|
|
81
|
+
key={`${props.host.id}:${catalog.deck.key}`}
|
|
82
|
+
{...props}
|
|
83
|
+
deck={catalog.deck}
|
|
84
|
+
catalog={catalog}
|
|
85
|
+
/>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function QueensLoadedGame({
|
|
90
|
+
theme,
|
|
91
|
+
layout,
|
|
92
|
+
host,
|
|
93
|
+
deck,
|
|
94
|
+
catalog,
|
|
95
|
+
}: PluginSurfaceProps & {
|
|
96
|
+
readonly deck: CuratedPuzzleDeck;
|
|
97
|
+
readonly catalog: PuzzleCatalogState;
|
|
98
|
+
}) {
|
|
99
|
+
const session = usePersistedGame(`${host.id}:${deck.key}`, deck.puzzles, deck.solutions);
|
|
100
|
+
const paseo = usePaseo();
|
|
101
|
+
const toast = useToast();
|
|
102
|
+
const [runningAgentCount, setRunningAgentCount] = useState<number | null>(null);
|
|
103
|
+
const previousRunningAgentCount = useRef<number | null>(null);
|
|
104
|
+
const [boardGestureActive, setBoardGestureActive] = useState(false);
|
|
105
|
+
const styles = useMemo(() => createStyles(theme, layout.compact), [layout.compact, theme]);
|
|
106
|
+
const state = session.status === "ready" ? session.state : null;
|
|
107
|
+
const puzzle = state?.puzzles[state.activePuzzleIndex];
|
|
108
|
+
const progress = state?.progress[state.activePuzzleIndex];
|
|
109
|
+
const cells = progress?.cells;
|
|
110
|
+
const conflicts = useMemo(
|
|
111
|
+
() => (puzzle && cells ? findConflicts(puzzle, cells) : EMPTY_CONFLICTS),
|
|
112
|
+
[cells, puzzle],
|
|
113
|
+
);
|
|
114
|
+
const dispatch = session.status === "ready" ? session.dispatch : null;
|
|
115
|
+
const timerRunning = progress?.timer.startedAt !== null && progress?.timer.completedAt === null;
|
|
116
|
+
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (!dispatch || !timerRunning) return;
|
|
119
|
+
|
|
120
|
+
const interval = setInterval(() => {
|
|
121
|
+
dispatch({ type: "tick", now: Date.now() });
|
|
122
|
+
}, 1_000);
|
|
123
|
+
|
|
124
|
+
return () => clearInterval(interval);
|
|
125
|
+
}, [dispatch, timerRunning]);
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
let mounted = true;
|
|
129
|
+
let request = 0;
|
|
130
|
+
|
|
131
|
+
const refresh = async () => {
|
|
132
|
+
const currentRequest = ++request;
|
|
133
|
+
let running = 0;
|
|
134
|
+
let cursor: string | undefined;
|
|
135
|
+
try {
|
|
136
|
+
for (let page = 0; page < AGENT_MAX_PAGES; page += 1) {
|
|
137
|
+
const result = await paseo.agents.list({
|
|
138
|
+
sort: [{ key: "updated_at", direction: "desc" }],
|
|
139
|
+
page: { limit: AGENT_PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
|
|
140
|
+
});
|
|
141
|
+
for (const entry of result.entries) {
|
|
142
|
+
if (entry.agent.status === "running" || entry.agent.status === "initializing") {
|
|
143
|
+
running += 1;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
cursor = result.pageInfo.hasMore ? (result.pageInfo.nextCursor ?? undefined) : undefined;
|
|
147
|
+
if (!cursor) break;
|
|
148
|
+
}
|
|
149
|
+
if (mounted && currentRequest === request) setRunningAgentCount(running);
|
|
150
|
+
} catch {
|
|
151
|
+
// Preserve the last known count when the host is temporarily unavailable.
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
void refresh();
|
|
156
|
+
const unsubscribe = paseo.agents.subscribe(() => void refresh());
|
|
157
|
+
const backstop = setInterval(() => void refresh(), AGENT_BACKSTOP_REFRESH_MS);
|
|
158
|
+
return () => {
|
|
159
|
+
mounted = false;
|
|
160
|
+
clearInterval(backstop);
|
|
161
|
+
unsubscribe();
|
|
162
|
+
};
|
|
163
|
+
}, [paseo]);
|
|
164
|
+
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
const previous = previousRunningAgentCount.current;
|
|
167
|
+
if (previous !== null && previous > 0 && runningAgentCount === 0) {
|
|
168
|
+
toast.show("All agents are idle. Back to work!", { variant: "success" });
|
|
169
|
+
}
|
|
170
|
+
previousRunningAgentCount.current = runningAgentCount;
|
|
171
|
+
}, [runningAgentCount, toast]);
|
|
172
|
+
|
|
173
|
+
if (session.status === "loading") {
|
|
174
|
+
return (
|
|
175
|
+
<View style={styles.screen}>
|
|
176
|
+
<ScrollView
|
|
177
|
+
style={styles.messageScroll}
|
|
178
|
+
contentContainerStyle={styles.messageScrollContent}
|
|
179
|
+
>
|
|
180
|
+
<View accessibilityLiveRegion="polite" style={styles.messageCard}>
|
|
181
|
+
<ActivityIndicator color={theme.colors.accent} />
|
|
182
|
+
<Text accessibilityRole="header" style={styles.messageTitle}>
|
|
183
|
+
Loading saved game
|
|
184
|
+
</Text>
|
|
185
|
+
<Text style={styles.messageBody}>Restoring your puzzle progress.</Text>
|
|
186
|
+
{session.saveError ? (
|
|
187
|
+
<Text accessibilityRole="alert" style={styles.errorText}>
|
|
188
|
+
{session.saveError}
|
|
189
|
+
</Text>
|
|
190
|
+
) : null}
|
|
191
|
+
</View>
|
|
192
|
+
</ScrollView>
|
|
193
|
+
</View>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (session.status === "error") {
|
|
198
|
+
return (
|
|
199
|
+
<View style={styles.screen}>
|
|
200
|
+
<ScrollView
|
|
201
|
+
style={styles.messageScroll}
|
|
202
|
+
contentContainerStyle={styles.messageScrollContent}
|
|
203
|
+
>
|
|
204
|
+
<View style={styles.messageCard}>
|
|
205
|
+
<Text accessibilityRole="header" style={styles.messageTitle}>
|
|
206
|
+
Saved game unavailable
|
|
207
|
+
</Text>
|
|
208
|
+
<Text accessibilityRole="alert" style={styles.errorText}>
|
|
209
|
+
{session.error}
|
|
210
|
+
</Text>
|
|
211
|
+
<RecoveryButton
|
|
212
|
+
label="Reload"
|
|
213
|
+
disabled={session.saving}
|
|
214
|
+
onPress={() => void session.reload()}
|
|
215
|
+
styles={styles}
|
|
216
|
+
/>
|
|
217
|
+
</View>
|
|
218
|
+
</ScrollView>
|
|
219
|
+
</View>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (session.status === "invalid") {
|
|
224
|
+
return (
|
|
225
|
+
<View style={styles.screen}>
|
|
226
|
+
<ScrollView
|
|
227
|
+
style={styles.messageScroll}
|
|
228
|
+
contentContainerStyle={styles.messageScrollContent}
|
|
229
|
+
>
|
|
230
|
+
<View style={styles.messageCard}>
|
|
231
|
+
<Text accessibilityRole="header" style={styles.messageTitle}>
|
|
232
|
+
Saved game needs attention
|
|
233
|
+
</Text>
|
|
234
|
+
<Text accessibilityRole="alert" style={styles.errorText}>
|
|
235
|
+
{session.error}
|
|
236
|
+
</Text>
|
|
237
|
+
<Text style={styles.messageBody}>
|
|
238
|
+
Reload to try again, or reset the invalid data and start fresh.
|
|
239
|
+
</Text>
|
|
240
|
+
<View style={styles.recoveryActions}>
|
|
241
|
+
<RecoveryButton
|
|
242
|
+
label="Reload"
|
|
243
|
+
disabled={session.saving}
|
|
244
|
+
onPress={() => void session.reload()}
|
|
245
|
+
styles={styles}
|
|
246
|
+
/>
|
|
247
|
+
<RecoveryButton
|
|
248
|
+
label="Reset saved game"
|
|
249
|
+
disabled={session.saving}
|
|
250
|
+
onPress={() => void session.reset()}
|
|
251
|
+
styles={styles}
|
|
252
|
+
/>
|
|
253
|
+
</View>
|
|
254
|
+
</View>
|
|
255
|
+
</ScrollView>
|
|
256
|
+
</View>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!state || !puzzle || !progress) {
|
|
261
|
+
return (
|
|
262
|
+
<View style={styles.screen}>
|
|
263
|
+
<ScrollView
|
|
264
|
+
style={styles.messageScroll}
|
|
265
|
+
contentContainerStyle={styles.messageScrollContent}
|
|
266
|
+
>
|
|
267
|
+
<View style={styles.messageCard}>
|
|
268
|
+
<Text accessibilityRole="header" style={styles.messageTitle}>
|
|
269
|
+
Puzzle unavailable
|
|
270
|
+
</Text>
|
|
271
|
+
<Text accessibilityRole="alert" style={styles.errorText}>
|
|
272
|
+
The saved game does not contain a playable puzzle.
|
|
273
|
+
</Text>
|
|
274
|
+
<RecoveryButton
|
|
275
|
+
label="Reload"
|
|
276
|
+
disabled={session.saving}
|
|
277
|
+
onPress={() => void session.reload()}
|
|
278
|
+
styles={styles}
|
|
279
|
+
/>
|
|
280
|
+
</View>
|
|
281
|
+
</ScrollView>
|
|
282
|
+
</View>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
const readyState = state;
|
|
286
|
+
const readyDispatch = session.dispatch;
|
|
287
|
+
const hintUsed = session.hintUsed;
|
|
288
|
+
|
|
289
|
+
const markedCount = progress.cells.reduce(
|
|
290
|
+
(count, cell) => count + (cell === "marked" ? 1 : 0),
|
|
291
|
+
0,
|
|
292
|
+
);
|
|
293
|
+
const hasProgress =
|
|
294
|
+
progress.timer.startedAt !== null || progress.cells.some((cell) => cell !== "empty");
|
|
295
|
+
const puzzleNumber = state.activePuzzleIndex + 1;
|
|
296
|
+
const puzzleLabel = puzzle.id.split("-").pop() ?? String(puzzleNumber);
|
|
297
|
+
const statusText = progress.solved
|
|
298
|
+
? hintUsed
|
|
299
|
+
? "Assisted — solved with every queen in a safe place."
|
|
300
|
+
: "Solved — every queen has a safe place."
|
|
301
|
+
: conflicts.size > 0
|
|
302
|
+
? `${conflicts.size} conflicting ${conflicts.size === 1 ? "square" : "squares"}`
|
|
303
|
+
: `${markedCount} of ${puzzle.size} queens placed`;
|
|
304
|
+
const statusColor = progress.solved
|
|
305
|
+
? theme.colors.statusSuccess
|
|
306
|
+
: conflicts.size > 0
|
|
307
|
+
? theme.colors.statusDanger
|
|
308
|
+
: theme.colors.foregroundMuted;
|
|
309
|
+
const agentStatusText =
|
|
310
|
+
runningAgentCount === null
|
|
311
|
+
? "Checking agents…"
|
|
312
|
+
: runningAgentCount === 0
|
|
313
|
+
? "All agents idle"
|
|
314
|
+
: `${runningAgentCount} ${runningAgentCount === 1 ? "agent" : "agents"} running`;
|
|
315
|
+
const agentStatusColor =
|
|
316
|
+
runningAgentCount === null
|
|
317
|
+
? theme.colors.foregroundMuted
|
|
318
|
+
: runningAgentCount === 0
|
|
319
|
+
? theme.colors.statusSuccess
|
|
320
|
+
: theme.colors.accent;
|
|
321
|
+
|
|
322
|
+
const selectPuzzle = (index: number) => {
|
|
323
|
+
const clampedIndex = Math.min(Math.max(index, 0), readyState.puzzles.length - 1);
|
|
324
|
+
readyDispatch({ type: "select-puzzle", index: clampedIndex, now: Date.now() });
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<View style={styles.screen}>
|
|
329
|
+
<ScrollView scrollEnabled={!boardGestureActive} contentContainerStyle={styles.scrollContent}>
|
|
330
|
+
<View style={styles.gameShell}>
|
|
331
|
+
<View style={styles.heading}>
|
|
332
|
+
<View style={styles.eyebrowRow}>
|
|
333
|
+
<View style={styles.eyebrowLine} />
|
|
334
|
+
<Text style={styles.eyebrow}>
|
|
335
|
+
LOGIC, {puzzle.size} BY {puzzle.size}
|
|
336
|
+
</Text>
|
|
337
|
+
<View style={styles.eyebrowLine} />
|
|
338
|
+
</View>
|
|
339
|
+
<View style={styles.titleRow}>
|
|
340
|
+
<GameMark size={layout.compact ? 28 : 34} color={theme.colors.accent} />
|
|
341
|
+
<Text accessibilityRole="header" style={styles.title}>
|
|
342
|
+
Queens
|
|
343
|
+
</Text>
|
|
344
|
+
</View>
|
|
345
|
+
<Text style={styles.instructions}>
|
|
346
|
+
Place one queen in every row, column, and region. Queens cannot touch, even
|
|
347
|
+
diagonally. Tap once to toggle an X, double tap to toggle a queen, or drag from an
|
|
348
|
+
empty square to mark Xs and from an X to erase them.
|
|
349
|
+
</Text>
|
|
350
|
+
</View>
|
|
351
|
+
|
|
352
|
+
<PuzzleSelector
|
|
353
|
+
size={catalog.size}
|
|
354
|
+
difficulty={catalog.difficulty}
|
|
355
|
+
disabled={catalog.loading || session.saving || boardGestureActive}
|
|
356
|
+
compact={layout.compact}
|
|
357
|
+
theme={theme}
|
|
358
|
+
onChange={(size, difficulty) => {
|
|
359
|
+
void catalog.select(size, difficulty);
|
|
360
|
+
}}
|
|
361
|
+
/>
|
|
362
|
+
{catalog.error ? (
|
|
363
|
+
<Text accessibilityRole="alert" style={styles.errorText}>
|
|
364
|
+
{catalog.error}
|
|
365
|
+
</Text>
|
|
366
|
+
) : null}
|
|
367
|
+
|
|
368
|
+
<View style={styles.puzzleMeta}>
|
|
369
|
+
<View style={styles.metaItem}>
|
|
370
|
+
<Text style={styles.puzzleMetaLabel}>PUZZLE</Text>
|
|
371
|
+
<Text style={styles.puzzleMetaValue}>
|
|
372
|
+
#{puzzleLabel} · {puzzleNumber}/{state.puzzles.length}
|
|
373
|
+
</Text>
|
|
374
|
+
</View>
|
|
375
|
+
<View style={styles.metaDivider} />
|
|
376
|
+
<View style={styles.metaItem}>
|
|
377
|
+
<Text style={styles.puzzleMetaLabel}>TIME</Text>
|
|
378
|
+
<Text
|
|
379
|
+
accessibilityLabel={`Elapsed time ${formatElapsedAccessible(progress.timer.elapsedMs)}`}
|
|
380
|
+
style={styles.timerValue}
|
|
381
|
+
>
|
|
382
|
+
{formatElapsed(progress.timer.elapsedMs)}
|
|
383
|
+
</Text>
|
|
384
|
+
</View>
|
|
385
|
+
<View style={styles.metaDivider} />
|
|
386
|
+
<View style={styles.metaItem}>
|
|
387
|
+
<Text style={styles.puzzleMetaLabel}>SYNC</Text>
|
|
388
|
+
<Text style={styles.puzzleMetaValue}>{session.saving ? "Saving…" : "Saved"}</Text>
|
|
389
|
+
</View>
|
|
390
|
+
{hintUsed ? (
|
|
391
|
+
<>
|
|
392
|
+
<View style={styles.metaDivider} />
|
|
393
|
+
<View style={styles.metaItem}>
|
|
394
|
+
<Text style={styles.puzzleMetaLabel}>RUN</Text>
|
|
395
|
+
<Text style={styles.puzzleMetaValue}>Assisted</Text>
|
|
396
|
+
</View>
|
|
397
|
+
</>
|
|
398
|
+
) : null}
|
|
399
|
+
</View>
|
|
400
|
+
|
|
401
|
+
{session.saveError ? (
|
|
402
|
+
<View accessibilityRole="alert" style={styles.saveErrorCard}>
|
|
403
|
+
<View style={styles.saveErrorCopy}>
|
|
404
|
+
<Text style={styles.saveErrorTitle}>Progress not saved</Text>
|
|
405
|
+
<Text style={styles.saveErrorBody}>{session.saveError}</Text>
|
|
406
|
+
</View>
|
|
407
|
+
<RecoveryButton
|
|
408
|
+
label="Reload saved game"
|
|
409
|
+
disabled={session.saving}
|
|
410
|
+
onPress={() => void session.reload()}
|
|
411
|
+
styles={styles}
|
|
412
|
+
/>
|
|
413
|
+
</View>
|
|
414
|
+
) : null}
|
|
415
|
+
|
|
416
|
+
<CompletionFeedback
|
|
417
|
+
solved={progress.solved}
|
|
418
|
+
color={theme.colors.statusSuccess}
|
|
419
|
+
style={styles.boardFeedback}
|
|
420
|
+
>
|
|
421
|
+
<QueensBoard
|
|
422
|
+
key={puzzle.id}
|
|
423
|
+
puzzle={puzzle}
|
|
424
|
+
cells={progress.cells}
|
|
425
|
+
conflicts={conflicts}
|
|
426
|
+
solved={progress.solved}
|
|
427
|
+
disabled={progress.solved}
|
|
428
|
+
compact={layout.compact}
|
|
429
|
+
theme={theme}
|
|
430
|
+
onGestureActiveChange={setBoardGestureActive}
|
|
431
|
+
onSetCells={(indexes, cellState) => {
|
|
432
|
+
readyDispatch({ type: "set-cells", indexes, state: cellState, now: Date.now() });
|
|
433
|
+
}}
|
|
434
|
+
/>
|
|
435
|
+
</CompletionFeedback>
|
|
436
|
+
|
|
437
|
+
<View style={styles.statusRail}>
|
|
438
|
+
<View style={styles.statusCard} accessibilityLiveRegion="polite">
|
|
439
|
+
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
|
|
440
|
+
<Text style={[styles.statusText, { color: statusColor }]}>{statusText}</Text>
|
|
441
|
+
</View>
|
|
442
|
+
<View style={styles.statusCard} accessibilityLiveRegion="polite">
|
|
443
|
+
<View style={[styles.statusDot, { backgroundColor: agentStatusColor }]} />
|
|
444
|
+
<Text style={[styles.statusText, { color: agentStatusColor }]}>
|
|
445
|
+
{agentStatusText}
|
|
446
|
+
</Text>
|
|
447
|
+
</View>
|
|
448
|
+
</View>
|
|
449
|
+
|
|
450
|
+
<GameControls
|
|
451
|
+
canUndo={progress.history.length > 0}
|
|
452
|
+
canHint={!progress.solved}
|
|
453
|
+
canReset={hasProgress}
|
|
454
|
+
canGoPrevious={state.activePuzzleIndex > 0}
|
|
455
|
+
canGoNext={state.activePuzzleIndex < state.puzzles.length - 1}
|
|
456
|
+
saving={session.saving}
|
|
457
|
+
disabled={false}
|
|
458
|
+
theme={theme}
|
|
459
|
+
onUndo={() => {
|
|
460
|
+
readyDispatch({ type: "undo", now: Date.now() });
|
|
461
|
+
}}
|
|
462
|
+
onHint={() => {
|
|
463
|
+
readyDispatch({ type: "hint", now: Date.now() });
|
|
464
|
+
}}
|
|
465
|
+
onReset={() => {
|
|
466
|
+
readyDispatch({ type: "reset", now: Date.now() });
|
|
467
|
+
}}
|
|
468
|
+
onPrevious={() => selectPuzzle(readyState.activePuzzleIndex - 1)}
|
|
469
|
+
onNext={() => selectPuzzle(readyState.activePuzzleIndex + 1)}
|
|
470
|
+
/>
|
|
471
|
+
</View>
|
|
472
|
+
</ScrollView>
|
|
473
|
+
</View>
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
interface RecoveryStyles {
|
|
478
|
+
readonly recoveryButton: ViewStyle;
|
|
479
|
+
readonly disabled: ViewStyle;
|
|
480
|
+
readonly pressed: ViewStyle;
|
|
481
|
+
readonly recoveryButtonText: TextStyle;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
interface RecoveryButtonProps {
|
|
485
|
+
readonly label: string;
|
|
486
|
+
readonly disabled: boolean;
|
|
487
|
+
readonly onPress: () => void;
|
|
488
|
+
readonly styles: RecoveryStyles;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function RecoveryButton({ label, disabled, onPress, styles }: RecoveryButtonProps) {
|
|
492
|
+
return (
|
|
493
|
+
<Pressable
|
|
494
|
+
accessibilityRole="button"
|
|
495
|
+
accessibilityLabel={label}
|
|
496
|
+
accessibilityState={{ disabled }}
|
|
497
|
+
disabled={disabled}
|
|
498
|
+
onPress={onPress}
|
|
499
|
+
style={({ pressed }) => [
|
|
500
|
+
styles.recoveryButton,
|
|
501
|
+
disabled && styles.disabled,
|
|
502
|
+
pressed && styles.pressed,
|
|
503
|
+
]}
|
|
504
|
+
>
|
|
505
|
+
<Text style={styles.recoveryButtonText}>{label}</Text>
|
|
506
|
+
</Pressable>
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function formatElapsed(elapsedMs: number): string {
|
|
511
|
+
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1_000));
|
|
512
|
+
const hours = Math.floor(totalSeconds / 3_600);
|
|
513
|
+
const minutes = Math.floor((totalSeconds % 3_600) / 60);
|
|
514
|
+
const seconds = totalSeconds % 60;
|
|
515
|
+
|
|
516
|
+
return hours > 0
|
|
517
|
+
? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
|
|
518
|
+
: `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function formatElapsedAccessible(elapsedMs: number): string {
|
|
522
|
+
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1_000));
|
|
523
|
+
const hours = Math.floor(totalSeconds / 3_600);
|
|
524
|
+
const minutes = Math.floor((totalSeconds % 3_600) / 60);
|
|
525
|
+
const seconds = totalSeconds % 60;
|
|
526
|
+
const parts = [];
|
|
527
|
+
if (hours > 0) parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`);
|
|
528
|
+
if (minutes > 0) parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`);
|
|
529
|
+
parts.push(`${seconds} ${seconds === 1 ? "second" : "seconds"}`);
|
|
530
|
+
return parts.join(", ");
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function createStyles(theme: PluginSurfaceProps["theme"], compact: boolean) {
|
|
534
|
+
return StyleSheet.create({
|
|
535
|
+
screen: {
|
|
536
|
+
flex: 1,
|
|
537
|
+
alignItems: "center",
|
|
538
|
+
justifyContent: "center",
|
|
539
|
+
backgroundColor: theme.colors.surface0,
|
|
540
|
+
},
|
|
541
|
+
messageScroll: {
|
|
542
|
+
width: "100%",
|
|
543
|
+
},
|
|
544
|
+
messageScrollContent: {
|
|
545
|
+
flexGrow: 1,
|
|
546
|
+
alignItems: "center",
|
|
547
|
+
justifyContent: "center",
|
|
548
|
+
padding: 24,
|
|
549
|
+
},
|
|
550
|
+
scrollContent: {
|
|
551
|
+
flexGrow: 1,
|
|
552
|
+
alignItems: compact ? "stretch" : "center",
|
|
553
|
+
justifyContent: compact ? "flex-start" : "center",
|
|
554
|
+
paddingHorizontal: compact ? 14 : 28,
|
|
555
|
+
paddingVertical: compact ? 18 : 32,
|
|
556
|
+
},
|
|
557
|
+
gameShell: {
|
|
558
|
+
width: compact ? undefined : "100%",
|
|
559
|
+
maxWidth: 620,
|
|
560
|
+
alignSelf: compact ? "stretch" : "center",
|
|
561
|
+
alignItems: "center",
|
|
562
|
+
gap: compact ? 14 : 20,
|
|
563
|
+
},
|
|
564
|
+
heading: {
|
|
565
|
+
width: "100%",
|
|
566
|
+
alignItems: "center",
|
|
567
|
+
gap: compact ? 7 : 9,
|
|
568
|
+
},
|
|
569
|
+
eyebrowRow: {
|
|
570
|
+
width: "100%",
|
|
571
|
+
maxWidth: 330,
|
|
572
|
+
flexDirection: "row",
|
|
573
|
+
alignItems: "center",
|
|
574
|
+
gap: 10,
|
|
575
|
+
},
|
|
576
|
+
eyebrowLine: {
|
|
577
|
+
flex: 1,
|
|
578
|
+
height: StyleSheet.hairlineWidth,
|
|
579
|
+
backgroundColor: theme.colors.border,
|
|
580
|
+
},
|
|
581
|
+
eyebrow: {
|
|
582
|
+
color: theme.colors.foregroundMuted,
|
|
583
|
+
fontSize: 10,
|
|
584
|
+
fontWeight: "700",
|
|
585
|
+
letterSpacing: 1.6,
|
|
586
|
+
},
|
|
587
|
+
titleRow: {
|
|
588
|
+
flexDirection: "row",
|
|
589
|
+
alignItems: "center",
|
|
590
|
+
gap: compact ? 9 : 12,
|
|
591
|
+
},
|
|
592
|
+
title: {
|
|
593
|
+
color: theme.colors.foreground,
|
|
594
|
+
fontSize: compact ? 30 : 38,
|
|
595
|
+
lineHeight: compact ? 36 : 45,
|
|
596
|
+
fontWeight: "800",
|
|
597
|
+
letterSpacing: -1,
|
|
598
|
+
},
|
|
599
|
+
instructions: {
|
|
600
|
+
maxWidth: 540,
|
|
601
|
+
color: theme.colors.foregroundMuted,
|
|
602
|
+
fontSize: compact ? 12 : 13,
|
|
603
|
+
lineHeight: compact ? 17 : 19,
|
|
604
|
+
textAlign: "center",
|
|
605
|
+
},
|
|
606
|
+
puzzleMeta: {
|
|
607
|
+
flexDirection: "row",
|
|
608
|
+
alignItems: "center",
|
|
609
|
+
justifyContent: "center",
|
|
610
|
+
gap: compact ? 8 : 12,
|
|
611
|
+
},
|
|
612
|
+
metaItem: {
|
|
613
|
+
alignItems: "center",
|
|
614
|
+
gap: 2,
|
|
615
|
+
},
|
|
616
|
+
puzzleMetaLabel: {
|
|
617
|
+
color: theme.colors.foregroundMuted,
|
|
618
|
+
fontSize: compact ? 9 : 11,
|
|
619
|
+
fontWeight: "700",
|
|
620
|
+
letterSpacing: 1.1,
|
|
621
|
+
},
|
|
622
|
+
puzzleMetaValue: {
|
|
623
|
+
color: theme.colors.foreground,
|
|
624
|
+
minWidth: compact ? 52 : 60,
|
|
625
|
+
fontSize: compact ? 11 : 13,
|
|
626
|
+
fontWeight: "700",
|
|
627
|
+
textAlign: "center",
|
|
628
|
+
},
|
|
629
|
+
timerValue: {
|
|
630
|
+
color: theme.colors.foreground,
|
|
631
|
+
fontSize: compact ? 12 : 15,
|
|
632
|
+
fontWeight: "800",
|
|
633
|
+
fontVariant: ["tabular-nums"],
|
|
634
|
+
},
|
|
635
|
+
metaDivider: {
|
|
636
|
+
width: StyleSheet.hairlineWidth,
|
|
637
|
+
height: 28,
|
|
638
|
+
backgroundColor: theme.colors.border,
|
|
639
|
+
},
|
|
640
|
+
boardFeedback: {
|
|
641
|
+
width: "100%",
|
|
642
|
+
maxWidth: compact ? 360 : 440,
|
|
643
|
+
},
|
|
644
|
+
statusRail: {
|
|
645
|
+
width: "100%",
|
|
646
|
+
flexDirection: "row",
|
|
647
|
+
flexWrap: "wrap",
|
|
648
|
+
alignItems: "center",
|
|
649
|
+
justifyContent: "center",
|
|
650
|
+
gap: 8,
|
|
651
|
+
},
|
|
652
|
+
statusCard: {
|
|
653
|
+
minHeight: 34,
|
|
654
|
+
flexDirection: "row",
|
|
655
|
+
alignItems: "center",
|
|
656
|
+
justifyContent: "center",
|
|
657
|
+
gap: 8,
|
|
658
|
+
paddingHorizontal: 14,
|
|
659
|
+
paddingVertical: 8,
|
|
660
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
661
|
+
borderColor: theme.colors.border,
|
|
662
|
+
borderRadius: 999,
|
|
663
|
+
backgroundColor: theme.colors.surface1,
|
|
664
|
+
},
|
|
665
|
+
statusDot: {
|
|
666
|
+
width: 7,
|
|
667
|
+
height: 7,
|
|
668
|
+
borderRadius: 4,
|
|
669
|
+
},
|
|
670
|
+
statusText: {
|
|
671
|
+
fontSize: 12,
|
|
672
|
+
fontWeight: "600",
|
|
673
|
+
},
|
|
674
|
+
messageCard: {
|
|
675
|
+
width: "100%",
|
|
676
|
+
maxWidth: 460,
|
|
677
|
+
alignItems: "center",
|
|
678
|
+
gap: 12,
|
|
679
|
+
padding: compact ? 20 : 28,
|
|
680
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
681
|
+
borderColor: theme.colors.border,
|
|
682
|
+
borderRadius: 14,
|
|
683
|
+
backgroundColor: theme.colors.surface1,
|
|
684
|
+
},
|
|
685
|
+
messageTitle: {
|
|
686
|
+
color: theme.colors.foreground,
|
|
687
|
+
fontSize: compact ? 20 : 24,
|
|
688
|
+
lineHeight: compact ? 25 : 30,
|
|
689
|
+
fontWeight: "800",
|
|
690
|
+
textAlign: "center",
|
|
691
|
+
},
|
|
692
|
+
messageBody: {
|
|
693
|
+
color: theme.colors.foregroundMuted,
|
|
694
|
+
fontSize: 13,
|
|
695
|
+
lineHeight: 19,
|
|
696
|
+
textAlign: "center",
|
|
697
|
+
},
|
|
698
|
+
errorText: {
|
|
699
|
+
color: theme.colors.statusDanger,
|
|
700
|
+
fontSize: 12,
|
|
701
|
+
lineHeight: 18,
|
|
702
|
+
textAlign: "center",
|
|
703
|
+
},
|
|
704
|
+
recoveryActions: {
|
|
705
|
+
flexDirection: "row",
|
|
706
|
+
flexWrap: "wrap",
|
|
707
|
+
justifyContent: "center",
|
|
708
|
+
gap: 8,
|
|
709
|
+
},
|
|
710
|
+
recoveryButton: {
|
|
711
|
+
minHeight: 40,
|
|
712
|
+
justifyContent: "center",
|
|
713
|
+
paddingHorizontal: 15,
|
|
714
|
+
paddingVertical: 9,
|
|
715
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
716
|
+
borderColor: theme.colors.border,
|
|
717
|
+
borderRadius: 9,
|
|
718
|
+
backgroundColor: theme.colors.surface2,
|
|
719
|
+
},
|
|
720
|
+
recoveryButtonText: {
|
|
721
|
+
color: theme.colors.foreground,
|
|
722
|
+
fontSize: 12,
|
|
723
|
+
fontWeight: "700",
|
|
724
|
+
textAlign: "center",
|
|
725
|
+
},
|
|
726
|
+
saveErrorCard: {
|
|
727
|
+
width: "100%",
|
|
728
|
+
flexDirection: compact ? "column" : "row",
|
|
729
|
+
alignItems: "center",
|
|
730
|
+
justifyContent: "space-between",
|
|
731
|
+
gap: 12,
|
|
732
|
+
padding: 12,
|
|
733
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
734
|
+
borderColor: theme.colors.statusDanger,
|
|
735
|
+
borderRadius: 10,
|
|
736
|
+
backgroundColor: theme.colors.surface1,
|
|
737
|
+
},
|
|
738
|
+
saveErrorCopy: {
|
|
739
|
+
flex: compact ? undefined : 1,
|
|
740
|
+
alignItems: compact ? "center" : "flex-start",
|
|
741
|
+
gap: 3,
|
|
742
|
+
},
|
|
743
|
+
saveErrorBody: {
|
|
744
|
+
color: theme.colors.statusDanger,
|
|
745
|
+
fontSize: 12,
|
|
746
|
+
lineHeight: 18,
|
|
747
|
+
textAlign: compact ? "center" : "left",
|
|
748
|
+
},
|
|
749
|
+
saveErrorTitle: {
|
|
750
|
+
color: theme.colors.statusDanger,
|
|
751
|
+
fontSize: 12,
|
|
752
|
+
fontWeight: "800",
|
|
753
|
+
},
|
|
754
|
+
disabled: {
|
|
755
|
+
opacity: 0.45,
|
|
756
|
+
},
|
|
757
|
+
pressed: {
|
|
758
|
+
opacity: 0.7,
|
|
759
|
+
},
|
|
760
|
+
});
|
|
761
|
+
}
|