jaml-ui 0.11.1 → 0.14.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.
@@ -0,0 +1,9 @@
1
+ import React from "react";
2
+ export type JamlAestheticOption = "Palindrome" | "Psychosis" | "Gross" | "Nsfw" | "Funny" | "Balatro";
3
+ export interface JamlAestheticSelectorProps {
4
+ value?: JamlAestheticOption | null;
5
+ onChange: (aesthetic: JamlAestheticOption | null, numericValue: number) => void;
6
+ className?: string;
7
+ style?: React.CSSProperties;
8
+ }
9
+ export declare function JamlAestheticSelector({ value, onChange, className, style }: JamlAestheticSelectorProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,36 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { JimboColorOption } from "../ui/tokens.js";
4
+ import { JimboText } from "../ui/jimboText.js";
5
+ const AESTHETICS = [
6
+ { id: "Palindrome", value: 0, label: "Palindrome", desc: "Seeds that read the same forwards and backwards" },
7
+ { id: "Psychosis", value: 1, label: "Psychosis", desc: "Unsettling or eerie seed patterns" },
8
+ { id: "Gross", value: 2, label: "Gross", desc: "Seeds with crude or disgusting words" },
9
+ { id: "Nsfw", value: 3, label: "NSFW", desc: "Seeds with adult content" },
10
+ { id: "Funny", value: 4, label: "Funny", desc: "Seeds that spell funny words" },
11
+ { id: "Balatro", value: 5, label: "Balatro", desc: "Seeds referencing the game itself" },
12
+ ];
13
+ export function JamlAestheticSelector({ value, onChange, className, style }) {
14
+ return (_jsxs("div", { className: className, style: {
15
+ display: "flex",
16
+ flexDirection: "column",
17
+ gap: 4,
18
+ ...style,
19
+ }, children: [_jsx(JimboText, { size: "xs", tone: "grey", uppercase: true, children: "Seed Aesthetics" }), _jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: 4 }, children: AESTHETICS.map((a) => {
20
+ const isActive = value === a.id;
21
+ return (_jsx("button", { type: "button", onClick: () => onChange(isActive ? null : a.id, a.value), title: a.desc, style: {
22
+ padding: "4px 10px",
23
+ borderRadius: 6,
24
+ border: `2px solid ${isActive ? JimboColorOption.GOLD : JimboColorOption.PANEL_EDGE}`,
25
+ background: isActive ? `${JimboColorOption.GOLD}22` : JimboColorOption.DARKEST,
26
+ color: isActive ? JimboColorOption.GOLD_TEXT : JimboColorOption.GREY,
27
+ cursor: "pointer",
28
+ fontSize: 11,
29
+ fontWeight: 700,
30
+ fontFamily: "m6x11plus, monospace",
31
+ textTransform: "uppercase",
32
+ letterSpacing: 0.5,
33
+ transition: "border-color 100ms, background 100ms",
34
+ }, children: a.label }, a.id));
35
+ }) })] }));
36
+ }
@@ -0,0 +1,21 @@
1
+ import type { AnalyzerAnteView, AnalyzerItem } from "./AnalyzerExplorer.js";
2
+ import type { AnalyzerLive } from "../hooks/useAnalyzer.js";
3
+ import { type AnalyzerStreamKey } from "../hooks/analyzerStreamRegistry.js";
4
+ export interface JamlAnalyzerFullscreenProps {
5
+ /** Per-ante summaries from useAnalyzer.antes. */
6
+ antes: AnalyzerAnteView[];
7
+ /** Live ctx from useAnalyzer.live; null disables additional stream lanes. */
8
+ live: AnalyzerLive | null;
9
+ /** Stream lanes to surface. Defaults to shop + soul jokers. */
10
+ enabledStreams?: AnalyzerStreamKey[];
11
+ /** Called when the user toggles a stream in the picker. Owners persist if desired. */
12
+ onEnabledStreamsChange?: (next: AnalyzerStreamKey[]) => void;
13
+ /** Hide the built-in stream picker overlay (e.g. when host renders its own). */
14
+ hidePicker?: boolean;
15
+ /** Pull size on each lazy load. */
16
+ chunkSize?: number;
17
+ className?: string;
18
+ }
19
+ export declare function JamlAnalyzerFullscreen({ antes, live, enabledStreams, onEnabledStreamsChange, hidePicker, chunkSize, className, }: JamlAnalyzerFullscreenProps): import("react/jsx-runtime").JSX.Element;
20
+ export type { AnalyzerItem };
21
+ export { ANALYZER_STREAM_META, type AnalyzerStreamKey } from "../hooks/analyzerStreamRegistry.js";
@@ -0,0 +1,368 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { JamlBoss, JamlGameCard, JamlTag, JamlVoucher, resolveAnalyzerShopItem } from "./GameCard.js";
5
+ import { useMotelyStream } from "../hooks/useShopStream.js";
6
+ import { ANALYZER_STREAM_META, DEFAULT_ENABLED_STREAMS, buildStreamHandle, } from "../hooks/analyzerStreamRegistry.js";
7
+ import { JimboColorOption, withAlpha } from "../ui/tokens.js";
8
+ const C = JimboColorOption;
9
+ const TONE_COLORS = {
10
+ gold: C.GOLD_TEXT,
11
+ purple: C.TAROT_BUTTON,
12
+ blue: C.PLANET_BUTTON,
13
+ spectral: C.SPECTRAL_BUTTON,
14
+ default: C.GOLD_TEXT,
15
+ };
16
+ export function JamlAnalyzerFullscreen({ antes, live, enabledStreams, onEnabledStreamsChange, hidePicker = false, chunkSize = 12, className = "", }) {
17
+ const [internalEnabled, setInternalEnabled] = useState(enabledStreams ?? DEFAULT_ENABLED_STREAMS);
18
+ const effectiveEnabled = enabledStreams ?? internalEnabled;
19
+ const setEnabled = useCallback((next) => {
20
+ setInternalEnabled(next);
21
+ onEnabledStreamsChange?.(next);
22
+ }, [onEnabledStreamsChange]);
23
+ const scrollRef = useRef(null);
24
+ const sectionRefs = useRef(new Map());
25
+ const [currentAnte, setCurrentAnte] = useState(antes[0]?.ante ?? 1);
26
+ const [pickerOpen, setPickerOpen] = useState(false);
27
+ useEffect(() => {
28
+ const root = scrollRef.current;
29
+ if (!root)
30
+ return;
31
+ const observer = new IntersectionObserver((entries) => {
32
+ const top = entries
33
+ .filter((e) => e.isIntersecting)
34
+ .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
35
+ if (!top)
36
+ return;
37
+ const ante = Number(top.target.dataset.ante);
38
+ if (!Number.isNaN(ante))
39
+ setCurrentAnte(ante);
40
+ }, { root, threshold: [0.4, 0.6, 0.8] });
41
+ sectionRefs.current.forEach((el) => observer.observe(el));
42
+ return () => observer.disconnect();
43
+ }, [antes]);
44
+ const scrollToAnte = useCallback((ante) => {
45
+ sectionRefs.current.get(ante)?.scrollIntoView({ behavior: "smooth", block: "start" });
46
+ }, []);
47
+ return (_jsxs("div", { className: className, style: styles.root, children: [_jsx("div", { ref: scrollRef, style: styles.scroller, children: antes.map((ante) => (_jsx(AnteSection, { ante: ante, live: live, enabledStreams: effectiveEnabled, chunkSize: chunkSize, registerRef: (el) => {
48
+ if (el)
49
+ sectionRefs.current.set(ante.ante, el);
50
+ else
51
+ sectionRefs.current.delete(ante.ante);
52
+ } }, ante.ante))) }), _jsx(SideRail, { antes: antes.map((a) => a.ante), currentAnte: currentAnte, onJump: scrollToAnte }), !hidePicker && (_jsx(StreamPicker, { enabled: effectiveEnabled, onChange: setEnabled, open: pickerOpen, onToggle: () => setPickerOpen((v) => !v) }))] }));
53
+ }
54
+ function AnteSection({ ante, live, enabledStreams, chunkSize, registerRef }) {
55
+ return (_jsxs("section", { ref: registerRef, "data-ante": ante.ante, style: styles.section, children: [_jsxs("header", { style: styles.header, children: [_jsxs("div", { children: [_jsx("div", { style: styles.anteLabel, children: "ANTE" }), _jsx("div", { style: styles.anteNumber, children: ante.ante })] }), ante.voucher && (_jsxs("div", { style: styles.voucherBlock, children: [_jsx(JamlVoucher, { voucherName: ante.voucher, scale: 0.85 }), _jsx("div", { style: styles.voucherCaption, children: ante.voucher })] }))] }), _jsxs("div", { style: styles.blindRow, children: [_jsx(BlindCell, { label: "SMALL", tag: ante.smallBlindTag }), _jsx(BlindCell, { label: "BIG", tag: ante.bigBlindTag }), ante.boss && (_jsxs("div", { style: styles.bossCell, children: [_jsx("div", { style: styles.cellLabel, children: "BOSS" }), _jsx(JamlBoss, { bossName: ante.boss, scale: 0.7 }), _jsx("div", { style: styles.cellCaption, children: ante.boss })] }))] }), ante.packs && ante.packs.length > 0 && (_jsxs("div", { style: styles.streamLane, children: [_jsx("div", { style: styles.streamLabel, children: "PACKS" }), _jsx("div", { style: styles.packRow, children: ante.packs.map((pack, i) => (_jsx("div", { style: styles.packPill, children: pack }, `${ante.ante}-pack-${i}`))) })] })), enabledStreams.map((key) => {
56
+ const isShop = key === "shop";
57
+ const initialItems = isShop
58
+ ? (ante.shop ?? []).map((item) => ({
59
+ id: item.id,
60
+ name: item.name,
61
+ value: item.value,
62
+ }))
63
+ : [];
64
+ return (_jsx(StreamLane, { ante: ante.ante, streamKey: key, live: live, chunkSize: chunkSize, initialItems: initialItems }, `${ante.ante}-${key}`));
65
+ })] }));
66
+ }
67
+ function StreamLane({ ante, streamKey, live, chunkSize, initialItems }) {
68
+ const meta = ANALYZER_STREAM_META[streamKey];
69
+ const handle = useMemo(() => (live ? buildStreamHandle(live, ante, streamKey) : null), [live, ante, streamKey]);
70
+ const stream = useMotelyStream(handle?.initStream ?? null, handle?.nextItem ?? null, [ante, streamKey, live?.seed, live?.deck, live?.stake], initialItems);
71
+ const desired = live?.desiredNames ?? new Set();
72
+ const toneColor = TONE_COLORS[meta.tone] ?? TONE_COLORS.default;
73
+ return (_jsxs("div", { style: styles.streamLane, children: [_jsxs("div", { style: { ...styles.streamLabel, color: toneColor }, children: [meta.label.toUpperCase(), stream.items.length > 0 ? ` · ${stream.items.length}` : ""] }), _jsx(ShopRow, { items: stream.items, desired: desired, loadingMore: stream.loadingMore, ready: stream.ready, onPullMore: () => stream.pullMore(chunkSize) }), stream.error && _jsxs("div", { style: styles.errorLine, children: ["stream error: ", stream.error] })] }));
74
+ }
75
+ function BlindCell({ label, tag }) {
76
+ if (!tag)
77
+ return null;
78
+ return (_jsxs("div", { style: styles.blindCell, children: [_jsx("div", { style: styles.cellLabel, children: label }), _jsx(JamlTag, { tagName: tag, scale: 0.7 }), _jsx("div", { style: styles.cellCaption, children: tag })] }));
79
+ }
80
+ function ShopRow({ items, desired, loadingMore, ready, onPullMore }) {
81
+ const sentinelRef = useRef(null);
82
+ const lastTriggerRef = useRef(0);
83
+ useEffect(() => {
84
+ const el = sentinelRef.current;
85
+ if (!el || !ready)
86
+ return;
87
+ const observer = new IntersectionObserver((entries) => {
88
+ if (entries[0]?.isIntersecting && !loadingMore) {
89
+ const now = Date.now();
90
+ if (now - lastTriggerRef.current < 200)
91
+ return;
92
+ lastTriggerRef.current = now;
93
+ onPullMore();
94
+ }
95
+ }, { root: el.parentElement, threshold: 0.1, rootMargin: "0px 200px 0px 0px" });
96
+ observer.observe(el);
97
+ return () => observer.disconnect();
98
+ }, [ready, loadingMore, onPullMore]);
99
+ return (_jsxs("div", { style: styles.shopRow, children: [items.map((item) => (_jsx(ShopItem, { item: item, desired: desired.has(item.name.toLowerCase()) }, item.id))), _jsx("div", { ref: sentinelRef, style: styles.sentinel, children: loadingMore ? "…" : "" })] }));
100
+ }
101
+ function ShopItem({ item, desired }) {
102
+ const resolved = resolveAnalyzerShopItem({ id: item.id, name: item.name, value: item.value });
103
+ const wrapperStyle = {
104
+ flexShrink: 0,
105
+ position: "relative",
106
+ filter: desired ? `drop-shadow(0 0 6px ${C.GOLD})` : undefined,
107
+ };
108
+ const labelStyle = {
109
+ fontSize: 10,
110
+ color: desired ? C.GOLD : C.GREY,
111
+ textAlign: "center",
112
+ marginTop: 2,
113
+ maxWidth: 80,
114
+ overflow: "hidden",
115
+ textOverflow: "ellipsis",
116
+ whiteSpace: "nowrap",
117
+ };
118
+ return (_jsxs("div", { style: wrapperStyle, children: [resolved.kind === "joker" || resolved.kind === "consumable" || resolved.kind === "playing" ? (_jsx(JamlGameCard, { card: resolved.card, type: resolved.type })) : resolved.kind === "voucher" ? (_jsx(JamlVoucher, { voucherName: resolved.voucherName, scale: 0.7 })) : (_jsx("div", { style: styles.unknownTile, children: ("label" in resolved ? resolved.label : item.name) || "?" })), _jsx("div", { style: labelStyle, children: item.name })] }));
119
+ }
120
+ function SideRail({ antes, currentAnte, onJump }) {
121
+ return (_jsx("div", { style: styles.sideRail, children: antes.map((ante) => {
122
+ const active = ante === currentAnte;
123
+ return (_jsx("button", { type: "button", onClick: () => onJump(ante), "aria-label": `Jump to ante ${ante}`, style: {
124
+ ...styles.sideRailDot,
125
+ background: active ? C.GOLD : withAlpha(C.WHITE, 0.25),
126
+ transform: active ? "scale(1.4)" : "scale(1)",
127
+ boxShadow: active ? `0 0 8px ${C.GOLD}` : "none",
128
+ } }, ante));
129
+ }) }));
130
+ }
131
+ function StreamPicker({ enabled, onChange, open, onToggle }) {
132
+ const enabledSet = new Set(enabled);
133
+ const all = Object.values(ANALYZER_STREAM_META);
134
+ function toggle(key) {
135
+ const next = new Set(enabledSet);
136
+ if (next.has(key))
137
+ next.delete(key);
138
+ else
139
+ next.add(key);
140
+ onChange(all.map((m) => m.key).filter((k) => next.has(k)));
141
+ }
142
+ return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", onClick: onToggle, style: styles.pickerButton, "aria-label": "Toggle stream picker", children: open ? "✕" : "≡" }), open && (_jsxs("div", { style: styles.pickerPanel, children: [_jsx("div", { style: styles.pickerHeader, children: "STREAMS" }), all.map((meta) => {
143
+ const isOn = enabledSet.has(meta.key);
144
+ const tone = TONE_COLORS[meta.tone] ?? TONE_COLORS.default;
145
+ return (_jsxs("button", { type: "button", onClick: () => toggle(meta.key), style: {
146
+ ...styles.pickerChip,
147
+ borderColor: isOn ? tone : withAlpha(C.WHITE, 0.15),
148
+ color: isOn ? tone : C.GREY,
149
+ background: isOn ? withAlpha(tone, 0.1) : "transparent",
150
+ }, children: [isOn ? "●" : "○", " ", meta.label] }, meta.key));
151
+ })] }))] }));
152
+ }
153
+ const styles = {
154
+ root: {
155
+ position: "relative",
156
+ width: "100%",
157
+ height: "100svh",
158
+ background: C.DARKEST,
159
+ color: C.WHITE,
160
+ fontFamily: "var(--font-sans, m6x11plus), monospace",
161
+ overflow: "hidden",
162
+ },
163
+ scroller: {
164
+ width: "100%",
165
+ height: "100%",
166
+ overflowY: "scroll",
167
+ scrollSnapType: "y mandatory",
168
+ scrollBehavior: "smooth",
169
+ WebkitOverflowScrolling: "touch",
170
+ },
171
+ section: {
172
+ width: "100%",
173
+ minHeight: "100svh",
174
+ scrollSnapAlign: "start",
175
+ padding: "20px 16px 28px",
176
+ display: "flex",
177
+ flexDirection: "column",
178
+ gap: 12,
179
+ boxSizing: "border-box",
180
+ borderBottom: `1px solid ${withAlpha(C.WHITE, 0.05)}`,
181
+ },
182
+ header: {
183
+ display: "flex",
184
+ justifyContent: "space-between",
185
+ alignItems: "flex-start",
186
+ gap: 12,
187
+ },
188
+ anteLabel: {
189
+ fontSize: 12,
190
+ color: C.GREY,
191
+ letterSpacing: "0.16em",
192
+ },
193
+ anteNumber: {
194
+ fontSize: 72,
195
+ color: C.GOLD,
196
+ lineHeight: 0.9,
197
+ textShadow: `3px 3px 0 ${C.BLACK}`,
198
+ },
199
+ voucherBlock: {
200
+ display: "flex",
201
+ flexDirection: "column",
202
+ alignItems: "center",
203
+ gap: 4,
204
+ },
205
+ voucherCaption: {
206
+ fontSize: 10,
207
+ color: C.PURPLE,
208
+ letterSpacing: "0.1em",
209
+ },
210
+ blindRow: {
211
+ display: "flex",
212
+ gap: 16,
213
+ flexWrap: "wrap",
214
+ },
215
+ blindCell: {
216
+ display: "flex",
217
+ flexDirection: "column",
218
+ alignItems: "center",
219
+ gap: 4,
220
+ },
221
+ bossCell: {
222
+ display: "flex",
223
+ flexDirection: "column",
224
+ alignItems: "center",
225
+ gap: 4,
226
+ },
227
+ cellLabel: {
228
+ fontSize: 10,
229
+ color: C.GREY,
230
+ letterSpacing: "0.12em",
231
+ },
232
+ cellCaption: {
233
+ fontSize: 10,
234
+ color: C.WHITE,
235
+ maxWidth: 90,
236
+ textAlign: "center",
237
+ overflow: "hidden",
238
+ textOverflow: "ellipsis",
239
+ whiteSpace: "nowrap",
240
+ },
241
+ streamLane: {
242
+ display: "flex",
243
+ flexDirection: "column",
244
+ gap: 6,
245
+ minHeight: 0,
246
+ },
247
+ streamLabel: {
248
+ fontSize: 11,
249
+ color: C.GOLD_TEXT,
250
+ letterSpacing: "0.16em",
251
+ },
252
+ shopRow: {
253
+ display: "flex",
254
+ gap: 10,
255
+ overflowX: "auto",
256
+ overflowY: "hidden",
257
+ paddingBottom: 6,
258
+ scrollbarWidth: "thin",
259
+ },
260
+ packRow: {
261
+ display: "flex",
262
+ flexWrap: "wrap",
263
+ gap: 6,
264
+ },
265
+ packPill: {
266
+ padding: "4px 10px",
267
+ background: withAlpha(C.PANEL_EDGE, 0.9),
268
+ border: `1px solid ${C.INNER_BORDER}`,
269
+ borderRadius: 4,
270
+ fontSize: 11,
271
+ color: C.WHITE,
272
+ },
273
+ sentinel: {
274
+ flexShrink: 0,
275
+ width: 28,
276
+ display: "flex",
277
+ alignItems: "center",
278
+ justifyContent: "center",
279
+ color: C.GREY,
280
+ fontSize: 14,
281
+ },
282
+ unknownTile: {
283
+ width: 71,
284
+ height: 95,
285
+ border: `1px dashed ${C.GREY}`,
286
+ borderRadius: 4,
287
+ display: "flex",
288
+ alignItems: "center",
289
+ justifyContent: "center",
290
+ fontSize: 9,
291
+ color: C.GREY,
292
+ padding: 4,
293
+ textAlign: "center",
294
+ },
295
+ errorLine: {
296
+ fontSize: 10,
297
+ color: C.RED,
298
+ },
299
+ sideRail: {
300
+ position: "absolute",
301
+ top: "50%",
302
+ right: 6,
303
+ transform: "translateY(-50%)",
304
+ display: "flex",
305
+ flexDirection: "column",
306
+ gap: 8,
307
+ zIndex: 5,
308
+ pointerEvents: "auto",
309
+ },
310
+ sideRailDot: {
311
+ width: 8,
312
+ height: 8,
313
+ borderRadius: "50%",
314
+ border: "none",
315
+ cursor: "pointer",
316
+ padding: 0,
317
+ transition: "transform 0.15s ease, background 0.15s ease, box-shadow 0.15s ease",
318
+ },
319
+ pickerButton: {
320
+ position: "absolute",
321
+ top: 12,
322
+ right: 12,
323
+ width: 32,
324
+ height: 32,
325
+ borderRadius: 4,
326
+ border: `1px solid ${withAlpha(C.WHITE, 0.2)}`,
327
+ background: withAlpha(C.DARK_GREY, 0.85),
328
+ color: C.WHITE,
329
+ fontSize: 16,
330
+ cursor: "pointer",
331
+ zIndex: 6,
332
+ fontFamily: "inherit",
333
+ },
334
+ pickerPanel: {
335
+ position: "absolute",
336
+ top: 50,
337
+ right: 12,
338
+ width: 220,
339
+ maxHeight: "70vh",
340
+ overflowY: "auto",
341
+ padding: 10,
342
+ background: withAlpha(C.DARK_GREY, 0.95),
343
+ border: `1px solid ${withAlpha(C.WHITE, 0.15)}`,
344
+ borderRadius: 6,
345
+ display: "flex",
346
+ flexDirection: "column",
347
+ gap: 4,
348
+ zIndex: 6,
349
+ backdropFilter: "blur(4px)",
350
+ },
351
+ pickerHeader: {
352
+ fontSize: 10,
353
+ color: C.GREY,
354
+ letterSpacing: "0.16em",
355
+ marginBottom: 4,
356
+ },
357
+ pickerChip: {
358
+ padding: "6px 10px",
359
+ border: "1px solid",
360
+ borderRadius: 4,
361
+ fontSize: 11,
362
+ fontFamily: "inherit",
363
+ cursor: "pointer",
364
+ textAlign: "left",
365
+ transition: "all 0.12s ease",
366
+ },
367
+ };
368
+ export { ANALYZER_STREAM_META } from "../hooks/analyzerStreamRegistry.js";
@@ -0,0 +1,9 @@
1
+ import React from "react";
2
+ export interface JamlSeedInputProps {
3
+ value?: string;
4
+ onChange?: (seed: string) => void;
5
+ placeholder?: string;
6
+ className?: string;
7
+ style?: React.CSSProperties;
8
+ }
9
+ export declare function JamlSeedInput({ value, onChange, placeholder, className, style }: JamlSeedInputProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,30 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from "react";
4
+ import { JimboColorOption } from "../ui/tokens.js";
5
+ import { JimboText } from "../ui/jimboText.js";
6
+ const SEED_PATTERN = /^[A-Z0-9]{0,8}$/;
7
+ export function JamlSeedInput({ value, onChange, placeholder = "Enter seed (e.g. J4SPZMWW)", className, style }) {
8
+ const [internal, setInternal] = useState(value ?? "");
9
+ const display = value ?? internal;
10
+ const isValid = display.length === 0 || SEED_PATTERN.test(display);
11
+ const handleChange = (e) => {
12
+ const raw = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 8);
13
+ setInternal(raw);
14
+ onChange?.(raw);
15
+ };
16
+ return (_jsxs("div", { className: className, style: { display: "flex", flexDirection: "column", gap: 4, ...style }, children: [_jsx(JimboText, { size: "xs", tone: "grey", uppercase: true, children: "Seed" }), _jsx("input", { type: "text", value: display, onChange: handleChange, placeholder: placeholder, maxLength: 8, spellCheck: false, autoComplete: "off", style: {
17
+ padding: "6px 10px",
18
+ borderRadius: 6,
19
+ border: `2px solid ${!isValid ? JimboColorOption.RED : display.length === 8 ? JimboColorOption.GREEN : JimboColorOption.PANEL_EDGE}`,
20
+ background: JimboColorOption.DARKEST,
21
+ color: JimboColorOption.GOLD_TEXT,
22
+ fontSize: 16,
23
+ fontWeight: 900,
24
+ fontFamily: "m6x11plus, monospace",
25
+ letterSpacing: 2,
26
+ textTransform: "uppercase",
27
+ outline: "none",
28
+ transition: "border-color 100ms",
29
+ } }), display.length > 0 && display.length < 8 && (_jsxs(JimboText, { size: "xs", tone: "grey", children: [8 - display.length, " more characters"] }))] }));
30
+ }
@@ -0,0 +1,11 @@
1
+ import React from "react";
2
+ import type { SearchStatus } from "../hooks/useSearch.js";
3
+ export interface JamlSpeedometerProps {
4
+ seedsPerSecond: number;
5
+ totalSearched: bigint;
6
+ matchingSeeds: bigint;
7
+ status: SearchStatus;
8
+ className?: string;
9
+ style?: React.CSSProperties;
10
+ }
11
+ export declare function JamlSpeedometer({ seedsPerSecond, totalSearched, matchingSeeds, status, className, style, }: JamlSpeedometerProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,54 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { JimboColorOption } from "../ui/tokens.js";
4
+ import { JimboText } from "../ui/jimboText.js";
5
+ function formatCount(n) {
6
+ if (n >= 1000000000n)
7
+ return `${(Number(n / 1000000n) / 1000).toFixed(1)}B`;
8
+ if (n >= 1000000n)
9
+ return `${(Number(n / 1000n) / 1000).toFixed(1)}M`;
10
+ if (n >= 1000n)
11
+ return `${(Number(n) / 1000).toFixed(1)}K`;
12
+ return n.toString();
13
+ }
14
+ function formatSpeed(sps) {
15
+ if (sps >= 1_000_000)
16
+ return `${(sps / 1_000_000).toFixed(1)}M`;
17
+ if (sps >= 1_000)
18
+ return `${(sps / 1_000).toFixed(0)}K`;
19
+ return sps.toString();
20
+ }
21
+ function needleAngle(sps) {
22
+ if (sps <= 0)
23
+ return -90;
24
+ const maxLog = Math.log10(5_000_000);
25
+ const clamped = Math.min(sps, 5_000_000);
26
+ const pct = Math.log10(Math.max(clamped, 1)) / maxLog;
27
+ return -90 + pct * 180;
28
+ }
29
+ export function JamlSpeedometer({ seedsPerSecond, totalSearched, matchingSeeds, status, className, style, }) {
30
+ const isActive = status === "running" || status === "booting";
31
+ const angle = needleAngle(seedsPerSecond);
32
+ const speedColor = seedsPerSecond >= 500_000
33
+ ? JimboColorOption.GREEN
34
+ : seedsPerSecond >= 100_000
35
+ ? JimboColorOption.GOLD
36
+ : seedsPerSecond > 0
37
+ ? JimboColorOption.ORANGE
38
+ : JimboColorOption.GREY;
39
+ return (_jsxs("div", { className: className, style: {
40
+ display: "flex",
41
+ flexDirection: "column",
42
+ alignItems: "center",
43
+ gap: 6,
44
+ padding: "12px 16px",
45
+ borderRadius: 10,
46
+ background: `${JimboColorOption.DARKEST}cc`,
47
+ border: `1px solid ${JimboColorOption.PANEL_EDGE}`,
48
+ ...style,
49
+ }, children: [_jsx("div", { style: { position: "relative", width: 120, height: 68, overflow: "hidden" }, children: _jsxs("svg", { viewBox: "0 0 120 68", width: 120, height: 68, children: [_jsx("path", { d: "M 10 65 A 50 50 0 0 1 110 65", fill: "none", stroke: JimboColorOption.DARK_GREY, strokeWidth: 6, strokeLinecap: "round" }), isActive && (_jsx("path", { d: "M 10 65 A 50 50 0 0 1 110 65", fill: "none", stroke: speedColor, strokeWidth: 6, strokeLinecap: "round", strokeDasharray: "157", strokeDashoffset: 157 - (157 * ((angle + 90) / 180)), style: { transition: "stroke-dashoffset 300ms ease, stroke 300ms ease" } })), _jsx("line", { x1: 60, y1: 65, x2: 60, y2: 20, stroke: isActive ? JimboColorOption.RED : JimboColorOption.GREY, strokeWidth: 2, strokeLinecap: "round", style: {
50
+ transformOrigin: "60px 65px",
51
+ transform: `rotate(${angle}deg)`,
52
+ transition: "transform 300ms ease",
53
+ } }), _jsx("circle", { cx: 60, cy: 65, r: 4, fill: JimboColorOption.RED })] }) }), _jsxs("div", { style: { textAlign: "center" }, children: [_jsx("div", { style: { fontSize: 20, fontWeight: 900, fontFamily: "m6x11plus, monospace", color: isActive ? speedColor : JimboColorOption.GREY }, children: isActive ? formatSpeed(seedsPerSecond) : "---" }), _jsx(JimboText, { size: "xs", tone: "grey", uppercase: true, children: "seeds / sec" })] }), _jsxs("div", { style: { display: "flex", gap: 16, marginTop: 2 }, children: [_jsxs("div", { style: { textAlign: "center" }, children: [_jsx("div", { style: { fontSize: 13, fontWeight: 700, fontFamily: "m6x11plus, monospace", color: JimboColorOption.WHITE }, children: formatCount(totalSearched) }), _jsx(JimboText, { size: "xs", tone: "grey", uppercase: true, children: "searched" })] }), _jsxs("div", { style: { textAlign: "center" }, children: [_jsx("div", { style: { fontSize: 13, fontWeight: 700, fontFamily: "m6x11plus, monospace", color: JimboColorOption.GREEN_TEXT }, children: formatCount(matchingSeeds) }), _jsx(JimboText, { size: "xs", tone: "grey", uppercase: true, children: "matches" })] })] })] }));
54
+ }
@@ -0,0 +1,35 @@
1
+ import type { StreamItem } from "./useShopStream.js";
2
+ import type { AnalyzerLive } from "./useAnalyzer.js";
3
+ /**
4
+ * Registry of motely-wasm streams the fullscreen analyzer can surface as
5
+ * lanes. Each entry knows how to (a) open a stream against a live ctx +
6
+ * runState for a given ante, and (b) pull the next item.
7
+ *
8
+ * Streams that produce raw item *values* (jokers, tarots, planets,
9
+ * spectrals) are normalized through `motelyItemDisplayNameFromValue` so
10
+ * downstream rendering uses one shared resolver. Pack-contents streams
11
+ * return arrays in a single call; we flatten to per-card items keyed by
12
+ * pack-pull index so React keys stay stable across reloads.
13
+ */
14
+ export type AnalyzerStreamKey = "shop" | "soulJoker" | "rareTagJoker" | "uncommonTagJoker" | "riffRaffJoker" | "buffoonJoker" | "judgementJoker" | "wraithJoker" | "shopJoker" | "shopTarot" | "shopPlanet" | "shopSpectral" | "purpleSealTarot";
15
+ export interface AnalyzerStreamMeta {
16
+ key: AnalyzerStreamKey;
17
+ label: string;
18
+ /** Tone hint for the lane header. */
19
+ tone: "gold" | "purple" | "blue" | "spectral" | "default";
20
+ /** Whether this stream is on by default in the picker. */
21
+ defaultEnabled: boolean;
22
+ }
23
+ export declare const ANALYZER_STREAM_META: Record<AnalyzerStreamKey, AnalyzerStreamMeta>;
24
+ export declare const DEFAULT_ENABLED_STREAMS: AnalyzerStreamKey[];
25
+ interface StreamHandle {
26
+ initStream: () => void;
27
+ nextItem: () => StreamItem;
28
+ }
29
+ /**
30
+ * Build init/next callbacks for a (key, ante) pair against the live ctx.
31
+ * The component wraps these with useMotelyStream. Items are normalized to
32
+ * { id, name, value } so the same ShopItem renderer can show every lane.
33
+ */
34
+ export declare function buildStreamHandle(live: AnalyzerLive, ante: number, key: AnalyzerStreamKey): StreamHandle | null;
35
+ export {};
@@ -0,0 +1,148 @@
1
+ "use client";
2
+ /* eslint-disable @typescript-eslint/no-explicit-any */
3
+ import { motelyItemDisplayNameFromValue } from "../motelyDisplay.js";
4
+ export const ANALYZER_STREAM_META = {
5
+ shop: { key: "shop", label: "Shop", tone: "default", defaultEnabled: true },
6
+ shopJoker: { key: "shopJoker", label: "Shop Jokers", tone: "default", defaultEnabled: false },
7
+ soulJoker: { key: "soulJoker", label: "Soul (Legendary)", tone: "gold", defaultEnabled: true },
8
+ rareTagJoker: { key: "rareTagJoker", label: "Rare Tag Jokers", tone: "default", defaultEnabled: false },
9
+ uncommonTagJoker: { key: "uncommonTagJoker", label: "Uncommon Tag Jokers", tone: "default", defaultEnabled: false },
10
+ riffRaffJoker: { key: "riffRaffJoker", label: "Riff Raff", tone: "default", defaultEnabled: false },
11
+ buffoonJoker: { key: "buffoonJoker", label: "Buffoon Pack Jokers", tone: "default", defaultEnabled: false },
12
+ judgementJoker: { key: "judgementJoker", label: "Judgement Jokers", tone: "purple", defaultEnabled: false },
13
+ wraithJoker: { key: "wraithJoker", label: "Wraith Jokers", tone: "spectral", defaultEnabled: false },
14
+ shopTarot: { key: "shopTarot", label: "Shop Tarots", tone: "purple", defaultEnabled: false },
15
+ shopPlanet: { key: "shopPlanet", label: "Shop Planets", tone: "blue", defaultEnabled: false },
16
+ shopSpectral: { key: "shopSpectral", label: "Shop Spectrals", tone: "spectral", defaultEnabled: false },
17
+ purpleSealTarot: { key: "purpleSealTarot", label: "Purple Seal Tarots", tone: "purple", defaultEnabled: false },
18
+ };
19
+ export const DEFAULT_ENABLED_STREAMS = (Object.values(ANALYZER_STREAM_META)
20
+ .filter((m) => m.defaultEnabled)
21
+ .map((m) => m.key));
22
+ /**
23
+ * Build init/next callbacks for a (key, ante) pair against the live ctx.
24
+ * The component wraps these with useMotelyStream. Items are normalized to
25
+ * { id, name, value } so the same ShopItem renderer can show every lane.
26
+ */
27
+ export function buildStreamHandle(live, ante, key) {
28
+ const ctx = live.ctx;
29
+ const Motely = live.Motely;
30
+ const runState = live.runStates[ante];
31
+ let stream = null;
32
+ let cursor = 0;
33
+ const idBase = `${ante}-${key}`;
34
+ function nameFromValue(value) {
35
+ return motelyItemDisplayNameFromValue(value);
36
+ }
37
+ function joker(streamFactoryName, nextName) {
38
+ return {
39
+ initStream: () => {
40
+ stream = ctx[streamFactoryName](ante, Motely.MotelyJokerStreamFlags.Default);
41
+ cursor = 0;
42
+ },
43
+ nextItem: () => {
44
+ const r = ctx[nextName](stream);
45
+ const value = r.joker?.value ?? r.item?.value ?? 0;
46
+ const id = `${idBase}-${cursor++}`;
47
+ return { id, name: nameFromValue(value), value };
48
+ },
49
+ };
50
+ }
51
+ function fixedRarityJoker(streamFactoryName, nextName) {
52
+ return {
53
+ initStream: () => {
54
+ stream = ctx[streamFactoryName](ante, Motely.MotelyJokerFixedRarityStreamFlags.Default);
55
+ cursor = 0;
56
+ },
57
+ nextItem: () => {
58
+ const r = ctx[nextName](stream);
59
+ const value = r.joker?.value ?? 0;
60
+ const id = `${idBase}-${cursor++}`;
61
+ return { id, name: nameFromValue(value), value };
62
+ },
63
+ };
64
+ }
65
+ function tarot(streamFactoryName, nextName) {
66
+ return {
67
+ initStream: () => {
68
+ stream = ctx[streamFactoryName](ante);
69
+ cursor = 0;
70
+ },
71
+ nextItem: () => {
72
+ const r = ctx[nextName](stream);
73
+ const value = r.tarot?.value ?? r.item?.value ?? 0;
74
+ const id = `${idBase}-${cursor++}`;
75
+ return { id, name: nameFromValue(value), value };
76
+ },
77
+ };
78
+ }
79
+ function planet(streamFactoryName, nextName) {
80
+ return {
81
+ initStream: () => {
82
+ stream = ctx[streamFactoryName](ante);
83
+ cursor = 0;
84
+ },
85
+ nextItem: () => {
86
+ const r = ctx[nextName](stream);
87
+ const value = r.planet?.value ?? 0;
88
+ const id = `${idBase}-${cursor++}`;
89
+ return { id, name: nameFromValue(value), value };
90
+ },
91
+ };
92
+ }
93
+ function spectral(streamFactoryName, nextName) {
94
+ return {
95
+ initStream: () => {
96
+ stream = ctx[streamFactoryName](ante);
97
+ cursor = 0;
98
+ },
99
+ nextItem: () => {
100
+ const r = ctx[nextName](stream);
101
+ const value = r.spectral?.value ?? 0;
102
+ const id = `${idBase}-${cursor++}`;
103
+ return { id, name: nameFromValue(value), value };
104
+ },
105
+ };
106
+ }
107
+ switch (key) {
108
+ case "shop":
109
+ return {
110
+ initStream: () => {
111
+ stream = ctx.createShopItemStream(ante, runState, Motely.MotelyShopStreamFlags.Default, Motely.MotelyJokerStreamFlags.Default);
112
+ cursor = 0;
113
+ },
114
+ nextItem: () => {
115
+ const r = ctx.getNextShopItem(stream);
116
+ const value = r.item.value;
117
+ const id = `${idBase}-${cursor++}`;
118
+ return { id, name: nameFromValue(value), value };
119
+ },
120
+ };
121
+ case "shopJoker":
122
+ return joker("createShopJokerStream", "getNextShopJoker");
123
+ case "soulJoker":
124
+ return fixedRarityJoker("createSoulJokerStream", "getNextSoulJoker");
125
+ case "rareTagJoker":
126
+ return fixedRarityJoker("createRareTagJokerStream", "getNextRareTagJoker");
127
+ case "uncommonTagJoker":
128
+ return fixedRarityJoker("createUncommonTagJokerStream", "getNextUncommonTagJoker");
129
+ case "riffRaffJoker":
130
+ return fixedRarityJoker("createRiffRaffJokerStream", "getNextRiffRaffJoker");
131
+ case "buffoonJoker":
132
+ return joker("createBuffoonPackJokerStream", "getNextBuffoonPackJoker");
133
+ case "judgementJoker":
134
+ return joker("createJudgementJokerStream", "getNextJudgementJoker");
135
+ case "wraithJoker":
136
+ return joker("createWraithJokerStream", "getNextWraithJoker");
137
+ case "shopTarot":
138
+ return tarot("createShopTarotStream", "getNextShopTarot");
139
+ case "shopPlanet":
140
+ return planet("createShopPlanetStream", "getNextShopPlanet");
141
+ case "shopSpectral":
142
+ return spectral("createShopSpectralStream", "getNextShopSpectral");
143
+ case "purpleSealTarot":
144
+ return tarot("createPurpleSealTarotStream", "getNextPurpleSealTarot");
145
+ default:
146
+ return null;
147
+ }
148
+ }
@@ -1 +1 @@
1
- export declare const SEARCH_WORKER_CODE = "\nlet MotelyWasm = null;\nlet MotelyWasmEvents = null;\nlet activeSearch = null;\n\nself.addEventListener('message', async function(e) {\n const msg = e.data;\n\n if (msg.type === 'init') {\n try {\n const mod = await import(msg.url);\n await mod.default.boot();\n MotelyWasm = mod.MotelyWasm;\n MotelyWasmEvents = mod.MotelyWasmEvents;\n self.postMessage({ type: 'ready' });\n } catch (err) {\n self.postMessage({ type: 'error', message: String(err) });\n }\n return;\n }\n\n if (msg.type === 'start') {\n if (!MotelyWasm) { self.postMessage({ type: 'error', message: 'Not initialized' }); return; }\n const validation = MotelyWasm.validateJaml(msg.jaml);\n if (validation !== 'valid') { self.postMessage({ type: 'error', message: validation }); return; }\n\n let rId, pId, cId;\n function cleanup() {\n MotelyWasmEvents.onResult.unsubscribeById(rId);\n MotelyWasmEvents.onProgress.unsubscribeById(pId);\n MotelyWasmEvents.onComplete.unsubscribeById(cId);\n activeSearch = null;\n }\n\n rId = MotelyWasmEvents.onResult.subscribe(function(seed, score) {\n self.postMessage({ type: 'result', seed, score });\n });\n pId = MotelyWasmEvents.onProgress.subscribe(function(searched, matching) {\n self.postMessage({ type: 'progress', searched: searched.toString(), matching: matching.toString() });\n });\n cId = MotelyWasmEvents.onComplete.subscribe(function(status, searched, matched) {\n cleanup();\n self.postMessage({ type: 'complete', status, searched: searched.toString(), matched: matched.toString() });\n });\n\n try {\n activeSearch = MotelyWasm.startRandomSearch(msg.jaml, msg.count);\n } catch (err) {\n cleanup();\n self.postMessage({ type: 'error', message: String(err) });\n }\n return;\n }\n\n if (msg.type === 'stop') {\n if (activeSearch) { activeSearch.cancel(); activeSearch = null; }\n self.postMessage({ type: 'cancelled' });\n }\n});\n";
1
+ export declare const SEARCH_WORKER_CODE = "\nlet MotelyWasm = null;\nlet MotelyWasmEvents = null;\nlet Filters = null;\nlet activeSearch = null;\n\nself.addEventListener('message', async function(e) {\n const msg = e.data;\n\n if (msg.type === 'init') {\n try {\n const mod = await import(msg.url);\n await mod.default.boot();\n MotelyWasm = mod.MotelyWasm;\n MotelyWasmEvents = mod.MotelyWasmEvents;\n Filters = mod.Filters;\n self.postMessage({ type: 'ready' });\n } catch (err) {\n self.postMessage({ type: 'error', message: String(err) });\n }\n return;\n }\n\n if (msg.type === 'start') {\n if (!MotelyWasm) { self.postMessage({ type: 'error', message: 'Not initialized' }); return; }\n const validation = MotelyWasm.validateJaml(msg.jaml);\n if (validation !== 'valid') { self.postMessage({ type: 'error', message: validation }); return; }\n\n let rId, pId, cId;\n function cleanup() {\n MotelyWasmEvents.onResult.unsubscribeById(rId);\n MotelyWasmEvents.onProgress.unsubscribeById(pId);\n MotelyWasmEvents.onComplete.unsubscribeById(cId);\n activeSearch = null;\n }\n\n rId = MotelyWasmEvents.onResult.subscribe(function(seed, score, tallyColumns) {\n self.postMessage({ type: 'result', seed, score, tallyColumns: Array.from(tallyColumns) });\n });\n pId = MotelyWasmEvents.onProgress.subscribe(function(searched, matching) {\n self.postMessage({ type: 'progress', searched: searched.toString(), matching: matching.toString() });\n });\n cId = MotelyWasmEvents.onComplete.subscribe(function(status, searched, matched) {\n cleanup();\n self.postMessage({ type: 'complete', status, searched: searched.toString(), matched: matched.toString() });\n });\n\n try {\n const mode = msg.mode || 'random';\n\n if (mode === 'random') {\n activeSearch = MotelyWasm.startRandomSearch(msg.jaml, msg.count);\n } else if (mode === 'aesthetic') {\n activeSearch = MotelyWasm.startAestheticSearch(msg.jaml, msg.aesthetic);\n } else if (mode === 'seedList') {\n activeSearch = MotelyWasm.startSeedListSearch(msg.jaml, msg.seeds);\n } else if (mode === 'keyword') {\n activeSearch = MotelyWasm.startKeywordSearch(msg.jaml, msg.keywords, msg.padding || '');\n } else if (mode === 'sequential') {\n activeSearch = MotelyWasm.startSequentialSearch(msg.jaml, msg.batchCharCount, BigInt(msg.startBatch), BigInt(msg.endBatch));\n } else {\n self.postMessage({ type: 'error', message: 'Unknown search mode: ' + mode });\n cleanup();\n return;\n }\n } catch (err) {\n cleanup();\n self.postMessage({ type: 'error', message: String(err) });\n }\n return;\n }\n\n if (msg.type === 'stop') {\n if (activeSearch) { activeSearch.cancel(); activeSearch = null; }\n self.postMessage({ type: 'cancelled' });\n }\n\n if (msg.type === 'get_tally_labels') {\n if (!MotelyWasm) { self.postMessage({ type: 'error', message: 'Not initialized' }); return; }\n try {\n const labels = MotelyWasm.getTallyLabels(msg.jaml);\n self.postMessage({ type: 'tally_labels', labels });\n } catch (err) {\n self.postMessage({ type: 'error', message: String(err) });\n }\n }\n});\n";
@@ -1,8 +1,7 @@
1
- // Worker code as an inline string — created as a Blob URL at runtime.
2
- // This avoids bundler/import.meta.url issues when shipped as an npm package.
3
1
  export const SEARCH_WORKER_CODE = `
4
2
  let MotelyWasm = null;
5
3
  let MotelyWasmEvents = null;
4
+ let Filters = null;
6
5
  let activeSearch = null;
7
6
 
8
7
  self.addEventListener('message', async function(e) {
@@ -14,6 +13,7 @@ self.addEventListener('message', async function(e) {
14
13
  await mod.default.boot();
15
14
  MotelyWasm = mod.MotelyWasm;
16
15
  MotelyWasmEvents = mod.MotelyWasmEvents;
16
+ Filters = mod.Filters;
17
17
  self.postMessage({ type: 'ready' });
18
18
  } catch (err) {
19
19
  self.postMessage({ type: 'error', message: String(err) });
@@ -34,8 +34,8 @@ self.addEventListener('message', async function(e) {
34
34
  activeSearch = null;
35
35
  }
36
36
 
37
- rId = MotelyWasmEvents.onResult.subscribe(function(seed, score) {
38
- self.postMessage({ type: 'result', seed, score });
37
+ rId = MotelyWasmEvents.onResult.subscribe(function(seed, score, tallyColumns) {
38
+ self.postMessage({ type: 'result', seed, score, tallyColumns: Array.from(tallyColumns) });
39
39
  });
40
40
  pId = MotelyWasmEvents.onProgress.subscribe(function(searched, matching) {
41
41
  self.postMessage({ type: 'progress', searched: searched.toString(), matching: matching.toString() });
@@ -46,7 +46,23 @@ self.addEventListener('message', async function(e) {
46
46
  });
47
47
 
48
48
  try {
49
- activeSearch = MotelyWasm.startRandomSearch(msg.jaml, msg.count);
49
+ const mode = msg.mode || 'random';
50
+
51
+ if (mode === 'random') {
52
+ activeSearch = MotelyWasm.startRandomSearch(msg.jaml, msg.count);
53
+ } else if (mode === 'aesthetic') {
54
+ activeSearch = MotelyWasm.startAestheticSearch(msg.jaml, msg.aesthetic);
55
+ } else if (mode === 'seedList') {
56
+ activeSearch = MotelyWasm.startSeedListSearch(msg.jaml, msg.seeds);
57
+ } else if (mode === 'keyword') {
58
+ activeSearch = MotelyWasm.startKeywordSearch(msg.jaml, msg.keywords, msg.padding || '');
59
+ } else if (mode === 'sequential') {
60
+ activeSearch = MotelyWasm.startSequentialSearch(msg.jaml, msg.batchCharCount, BigInt(msg.startBatch), BigInt(msg.endBatch));
61
+ } else {
62
+ self.postMessage({ type: 'error', message: 'Unknown search mode: ' + mode });
63
+ cleanup();
64
+ return;
65
+ }
50
66
  } catch (err) {
51
67
  cleanup();
52
68
  self.postMessage({ type: 'error', message: String(err) });
@@ -58,5 +74,15 @@ self.addEventListener('message', async function(e) {
58
74
  if (activeSearch) { activeSearch.cancel(); activeSearch = null; }
59
75
  self.postMessage({ type: 'cancelled' });
60
76
  }
77
+
78
+ if (msg.type === 'get_tally_labels') {
79
+ if (!MotelyWasm) { self.postMessage({ type: 'error', message: 'Not initialized' }); return; }
80
+ try {
81
+ const labels = MotelyWasm.getTallyLabels(msg.jaml);
82
+ self.postMessage({ type: 'tally_labels', labels });
83
+ } catch (err) {
84
+ self.postMessage({ type: 'error', message: String(err) });
85
+ }
86
+ }
61
87
  });
62
88
  `;
@@ -1,9 +1,31 @@
1
1
  import type { AnalyzerAnteView } from "../components/AnalyzerExplorer.js";
2
2
  export type AnalyzerStatus = "idle" | "running" | "done" | "error";
3
+ export type MotelyJsRunState = {
4
+ voucherBitfield: number;
5
+ bossBitfield: number;
6
+ };
7
+ /**
8
+ * Snapshot of the live search context after `analyze()` resolves, exposed so
9
+ * higher-level components can open additional streams (shop chunks, soul-
10
+ * joker chunks, pack contents) on demand without re-walking the seed. The
11
+ * `runStates` map is keyed by ante and holds the runState AFTER the boss +
12
+ * voucher have been resolved for that ante — i.e. the correct input for
13
+ * `createShopItemStream(ante, runState, ...)`.
14
+ */
15
+ export interface AnalyzerLive {
16
+ ctx: any;
17
+ Motely: any;
18
+ runStates: Record<number, MotelyJsRunState>;
19
+ desiredNames: ReadonlySet<string>;
20
+ seed: string;
21
+ deck: string;
22
+ stake: string;
23
+ }
3
24
  export declare function useAnalyzer(motelyWasmUrl: string): {
4
25
  antes: AnalyzerAnteView[];
5
26
  status: AnalyzerStatus;
6
27
  error: string | null;
7
28
  analyze: (seed: string, deck: string, stake: string, jaml?: string) => Promise<void>;
8
29
  clearError: () => void;
30
+ live: AnalyzerLive | null;
9
31
  };
@@ -7,8 +7,10 @@ export function useAnalyzer(motelyWasmUrl) {
7
7
  const [antes, setAntes] = useState([]);
8
8
  const [status, setStatus] = useState("idle");
9
9
  const [error, setError] = useState(null);
10
+ const [live, setLive] = useState(null);
10
11
  const analyze = useCallback(async (seed, deck, stake, jaml) => {
11
12
  setAntes([]);
13
+ setLive(null);
12
14
  setStatus("running");
13
15
  setError(null);
14
16
  try {
@@ -26,6 +28,7 @@ export function useAnalyzer(motelyWasmUrl) {
26
28
  const bossStream = ctx.createBossStream();
27
29
  let runState = { voucherBitfield: 0, bossBitfield: 0 };
28
30
  const results = [];
31
+ const runStates = {};
29
32
  for (let ante = 1; ante <= 8; ante++) {
30
33
  const bossResult = ctx.getNextBossForAnte(bossStream, ante, runState);
31
34
  const bossName = Motely.MotelyBossBlind[bossResult.boss] ?? `Unknown(${bossResult.boss})`;
@@ -33,6 +36,7 @@ export function useAnalyzer(motelyWasmUrl) {
33
36
  const voucherResult = ctx.getAnteFirstVoucher(ante, runState);
34
37
  const voucherName = Motely.MotelyVoucher[voucherResult.voucher] ?? `Unknown(${voucherResult.voucher})`;
35
38
  runState = voucherResult.runState;
39
+ runStates[ante] = { ...runState };
36
40
  const tagStream = ctx.createTagStream(ante);
37
41
  const tag1 = ctx.getNextTag(tagStream);
38
42
  const tag2 = ctx.getNextTag(tagStream);
@@ -61,6 +65,7 @@ export function useAnalyzer(motelyWasmUrl) {
61
65
  });
62
66
  }
63
67
  setAntes(results);
68
+ setLive({ ctx, Motely, runStates, desiredNames, seed, deck, stake });
64
69
  setStatus("done");
65
70
  }
66
71
  catch (e) {
@@ -72,5 +77,5 @@ export function useAnalyzer(motelyWasmUrl) {
72
77
  setError(null);
73
78
  setStatus((s) => (s === "error" ? "idle" : s));
74
79
  }, []);
75
- return { antes, status, error, analyze, clearError };
80
+ return { antes, status, error, analyze, clearError, live };
76
81
  }
@@ -1,6 +1,7 @@
1
1
  export interface SearchResult {
2
2
  seed: string;
3
3
  score: number;
4
+ tallyColumns?: number[];
4
5
  }
5
6
  export type SearchStatus = "idle" | "booting" | "running" | "completed" | "cancelled" | "error";
6
7
  export interface UseSearchState {
@@ -9,14 +10,22 @@ export interface UseSearchState {
9
10
  matchingSeeds: bigint;
10
11
  status: SearchStatus;
11
12
  error: string | null;
13
+ seedsPerSecond: number;
14
+ tallyLabels: string[];
12
15
  }
13
16
  export declare function useSearch(motelyWasmUrl: string): {
14
17
  start: (jaml: string, count: number) => void;
18
+ startAesthetic: (jaml: string, aesthetic: number) => void;
19
+ startSeedList: (jaml: string, seeds: string[]) => void;
20
+ startKeyword: (jaml: string, keywords: string, padding?: string) => void;
15
21
  cancel: () => void;
16
22
  clearError: () => void;
23
+ fetchTallyLabels: (jaml: string) => void;
17
24
  results: SearchResult[];
18
25
  totalSearched: bigint;
19
26
  matchingSeeds: bigint;
20
27
  status: SearchStatus;
21
28
  error: string | null;
29
+ seedsPerSecond: number;
30
+ tallyLabels: string[];
22
31
  };
@@ -8,16 +8,20 @@ function createWorker(motelyWasmUrl) {
8
8
  worker.postMessage({ type: "init", url: motelyWasmUrl });
9
9
  return worker;
10
10
  }
11
+ const INITIAL_STATE = {
12
+ results: [],
13
+ totalSearched: 0n,
14
+ matchingSeeds: 0n,
15
+ status: "idle",
16
+ error: null,
17
+ seedsPerSecond: 0,
18
+ tallyLabels: [],
19
+ };
11
20
  export function useSearch(motelyWasmUrl) {
12
- const [state, setState] = useState({
13
- results: [],
14
- totalSearched: 0n,
15
- matchingSeeds: 0n,
16
- status: "idle",
17
- error: null,
18
- });
21
+ const [state, setState] = useState(INITIAL_STATE);
19
22
  const workerRef = useRef(null);
20
23
  const readyRef = useRef(false);
24
+ const speedRef = useRef({ lastSearched: 0n, lastTime: 0, ema: 0 });
21
25
  useEffect(() => {
22
26
  setState((s) => ({ ...s, status: "booting" }));
23
27
  const worker = createWorker(motelyWasmUrl);
@@ -30,16 +34,51 @@ export function useSearch(motelyWasmUrl) {
30
34
  setState((s) => s.status === "booting" ? { ...s, status: "idle" } : s);
31
35
  }
32
36
  else if (msg.type === "result") {
33
- setState((s) => ({ ...s, results: [...s.results, { seed: msg.seed, score: msg.score }] }));
37
+ setState((s) => ({
38
+ ...s,
39
+ results: [...s.results, {
40
+ seed: msg.seed,
41
+ score: msg.score,
42
+ tallyColumns: msg.tallyColumns,
43
+ }],
44
+ }));
34
45
  }
35
46
  else if (msg.type === "progress") {
36
- setState((s) => ({ ...s, totalSearched: BigInt(msg.searched), matchingSeeds: BigInt(msg.matching) }));
47
+ const searched = BigInt(msg.searched);
48
+ const matching = BigInt(msg.matching);
49
+ const now = performance.now();
50
+ const ref = speedRef.current;
51
+ let sps = ref.ema;
52
+ if (ref.lastTime > 0) {
53
+ const dtMs = now - ref.lastTime;
54
+ if (dtMs > 0) {
55
+ const delta = Number(searched - ref.lastSearched);
56
+ const instantSps = delta / (dtMs / 1000);
57
+ sps = ref.ema === 0 ? instantSps : ref.ema * 0.7 + instantSps * 0.3;
58
+ }
59
+ }
60
+ ref.lastSearched = searched;
61
+ ref.lastTime = now;
62
+ ref.ema = sps;
63
+ setState((s) => ({ ...s, totalSearched: searched, matchingSeeds: matching, seedsPerSecond: Math.round(sps) }));
37
64
  }
38
65
  else if (msg.type === "complete") {
39
- setState((s) => ({ ...s, status: msg.status === "Completed" ? "completed" : "error", error: msg.status !== "Completed" ? msg.status : null, totalSearched: BigInt(msg.searched), matchingSeeds: BigInt(msg.matched) }));
66
+ speedRef.current = { lastSearched: 0n, lastTime: 0, ema: 0 };
67
+ setState((s) => ({
68
+ ...s,
69
+ status: msg.status === "Completed" ? "completed" : "error",
70
+ error: msg.status !== "Completed" ? msg.status : null,
71
+ totalSearched: BigInt(msg.searched),
72
+ matchingSeeds: BigInt(msg.matched),
73
+ seedsPerSecond: 0,
74
+ }));
40
75
  }
41
76
  else if (msg.type === "cancelled") {
42
- setState((s) => ({ ...s, status: "cancelled" }));
77
+ speedRef.current = { lastSearched: 0n, lastTime: 0, ema: 0 };
78
+ setState((s) => ({ ...s, status: "cancelled", seedsPerSecond: 0 }));
79
+ }
80
+ else if (msg.type === "tally_labels") {
81
+ setState((s) => ({ ...s, tallyLabels: msg.labels }));
43
82
  }
44
83
  else if (msg.type === "error") {
45
84
  setState((s) => ({ ...s, status: "error", error: msg.message }));
@@ -50,13 +89,15 @@ export function useSearch(motelyWasmUrl) {
50
89
  workerRef.current = null;
51
90
  };
52
91
  }, [motelyWasmUrl]);
53
- const start = useCallback((jaml, count) => {
92
+ const sendStart = useCallback((payload) => {
54
93
  const worker = workerRef.current;
55
94
  if (!worker)
56
95
  return;
57
- setState({ results: [], totalSearched: 0n, matchingSeeds: 0n, status: "running", error: null });
96
+ speedRef.current = { lastSearched: 0n, lastTime: 0, ema: 0 };
97
+ setState({ ...INITIAL_STATE, status: "running", tallyLabels: state.tallyLabels });
98
+ const send = () => worker.postMessage(payload);
58
99
  if (readyRef.current) {
59
- worker.postMessage({ type: "start", jaml, count });
100
+ send();
60
101
  }
61
102
  else {
62
103
  const orig = worker.onmessage;
@@ -64,16 +105,31 @@ export function useSearch(motelyWasmUrl) {
64
105
  orig?.call(worker, e);
65
106
  if (e.data.type === "ready") {
66
107
  worker.onmessage = orig;
67
- worker.postMessage({ type: "start", jaml, count });
108
+ send();
68
109
  }
69
110
  };
70
111
  }
71
- }, []);
112
+ }, [state.tallyLabels]);
113
+ const start = useCallback((jaml, count) => {
114
+ sendStart({ type: "start", mode: "random", jaml, count });
115
+ }, [sendStart]);
116
+ const startAesthetic = useCallback((jaml, aesthetic) => {
117
+ sendStart({ type: "start", mode: "aesthetic", jaml, aesthetic });
118
+ }, [sendStart]);
119
+ const startSeedList = useCallback((jaml, seeds) => {
120
+ sendStart({ type: "start", mode: "seedList", jaml, seeds });
121
+ }, [sendStart]);
122
+ const startKeyword = useCallback((jaml, keywords, padding) => {
123
+ sendStart({ type: "start", mode: "keyword", jaml, keywords, padding });
124
+ }, [sendStart]);
72
125
  const cancel = useCallback(() => {
73
126
  workerRef.current?.postMessage({ type: "stop" });
74
127
  }, []);
75
128
  const clearError = useCallback(() => {
76
129
  setState((s) => (s.error || s.status === "error" ? { ...s, error: null, status: "idle" } : s));
77
130
  }, []);
78
- return { ...state, start, cancel, clearError };
131
+ const fetchTallyLabels = useCallback((jaml) => {
132
+ workerRef.current?.postMessage({ type: "get_tally_labels", jaml });
133
+ }, []);
134
+ return { ...state, start, startAesthetic, startSeedList, startKeyword, cancel, clearError, fetchTallyLabels };
79
135
  }
package/dist/index.d.ts CHANGED
@@ -3,6 +3,8 @@ export { Layer, type LayerOptions } from "./render/Layer.js";
3
3
  export { JamlCardRenderer, type JamlCardRendererProps } from "./render/CanvasRenderer.js";
4
4
  export { JamlGameCard, JamlVoucher, JamlTag, JamlBoss, resolveAnalyzerShopItem, type JamlGameCardProps, type AnalyzerShopItem, type AnalyzerResolvedItem, } from "./components/GameCard.js";
5
5
  export { AnalyzerExplorer, type AnalyzerAnteView, type AnalyzerBadge, type AnalyzerExplorerProps, type AnalyzerFact, type AnalyzerHighlight, type AnalyzerItem, } from "./components/AnalyzerExplorer.js";
6
+ export { JamlAnalyzerFullscreen, type JamlAnalyzerFullscreenProps, } from "./components/JamlAnalyzerFullscreen.js";
7
+ export { ANALYZER_STREAM_META, DEFAULT_ENABLED_STREAMS, type AnalyzerStreamKey, type AnalyzerStreamMeta, } from "./hooks/analyzerStreamRegistry.js";
6
8
  export { JamlMapPreview, type JamlMapPreviewProps } from "./components/JamlMapPreview.js";
7
9
  export { JamlIde, type JamlIdeProps, type JamlIdeSearchResult, type JamlVisualFilter, type JamlVisualClause, type JamlZone, } from "./components/JamlIde.js";
8
10
  export { JamlIdeVisual, type JamlIdeVisualProps, } from "./components/JamlIdeVisual.js";
@@ -16,9 +18,13 @@ export { JimboTooltip, type JimboTooltipProps, type JimboTooltipMode, type Jimbo
16
18
  export { JamlIdeToolbar, type JamlIdeMode, type JamlIdeToolbarProps, } from "./components/JamlIdeToolbar.js";
17
19
  export { CardList, type CardListProps } from "./components/CardList.js";
18
20
  export { CardFan, type CardFanProps } from "./components/CardFan.js";
21
+ export { RealPlayingCard, type CardSuit, type CardRank, type CardEnhancement, type CardSeal, type CardEdition, } from "./components/PlayingCard.js";
19
22
  export { DeckSprite, DECK_SPRITE_POS, STAKE_SPRITE_POS, type DeckSpriteProps, } from "./components/DeckSprite.js";
20
23
  export { MotelyVersionBadge, type MotelyVersionBadgeProps, type MotelyCapabilities, } from "./components/MotelyVersionBadge.js";
21
24
  export { extractVisualJamlItems, type JamlPreviewGroups, type JamlPreviewItem, type JamlPreviewSection, type JamlPreviewVisualType, } from "./utils/jamlMapPreview.js";
22
25
  export { useMotelyStream, type StreamItem, type StreamState } from "./hooks/useShopStream.js";
23
26
  export { useSearch, type SearchResult, type SearchStatus, type UseSearchState, } from "./hooks/useSearch.js";
24
- export { useAnalyzer, type AnalyzerStatus, } from "./hooks/useAnalyzer.js";
27
+ export { useAnalyzer, type AnalyzerStatus, type AnalyzerLive, type MotelyJsRunState, } from "./hooks/useAnalyzer.js";
28
+ export { JamlSpeedometer, type JamlSpeedometerProps, } from "./components/JamlSpeedometer.js";
29
+ export { JamlAestheticSelector, type JamlAestheticSelectorProps, type JamlAestheticOption, } from "./components/JamlAestheticSelector.js";
30
+ export { JamlSeedInput, type JamlSeedInputProps, } from "./components/JamlSeedInput.js";
package/dist/index.js CHANGED
@@ -4,6 +4,8 @@ export { Layer } from "./render/Layer.js";
4
4
  export { JamlCardRenderer } from "./render/CanvasRenderer.js";
5
5
  export { JamlGameCard, JamlVoucher, JamlTag, JamlBoss, resolveAnalyzerShopItem, } from "./components/GameCard.js";
6
6
  export { AnalyzerExplorer, } from "./components/AnalyzerExplorer.js";
7
+ export { JamlAnalyzerFullscreen, } from "./components/JamlAnalyzerFullscreen.js";
8
+ export { ANALYZER_STREAM_META, DEFAULT_ENABLED_STREAMS, } from "./hooks/analyzerStreamRegistry.js";
7
9
  export { JamlMapPreview } from "./components/JamlMapPreview.js";
8
10
  export { JamlIde, } from "./components/JamlIde.js";
9
11
  export { JamlIdeVisual, } from "./components/JamlIdeVisual.js";
@@ -17,9 +19,13 @@ export { JimboTooltip, } from "./ui/jimboTooltip.js";
17
19
  export { JamlIdeToolbar, } from "./components/JamlIdeToolbar.js";
18
20
  export { CardList } from "./components/CardList.js";
19
21
  export { CardFan } from "./components/CardFan.js";
22
+ export { RealPlayingCard, } from "./components/PlayingCard.js";
20
23
  export { DeckSprite, DECK_SPRITE_POS, STAKE_SPRITE_POS, } from "./components/DeckSprite.js";
21
24
  export { MotelyVersionBadge, } from "./components/MotelyVersionBadge.js";
22
25
  export { extractVisualJamlItems, } from "./utils/jamlMapPreview.js";
23
26
  export { useMotelyStream } from "./hooks/useShopStream.js";
24
27
  export { useSearch, } from "./hooks/useSearch.js";
25
28
  export { useAnalyzer, } from "./hooks/useAnalyzer.js";
29
+ export { JamlSpeedometer, } from "./components/JamlSpeedometer.js";
30
+ export { JamlAestheticSelector, } from "./components/JamlAestheticSelector.js";
31
+ export { JamlSeedInput, } from "./components/JamlSeedInput.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jaml-ui",
3
- "version": "0.11.1",
3
+ "version": "0.14.0",
4
4
  "description": "Balatro rendering components, sprite metadata, and optional Motely helpers for React apps.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",