buddy-reroll 0.2.0 → 0.3.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,183 @@
1
+ const ORIGINAL_SALT = "friend-2026-401";
2
+ const SALT_LEN = ORIGINAL_SALT.length;
3
+
4
+ const RARITIES = ["common", "uncommon", "rare", "epic", "legendary"];
5
+ const RARITY_WEIGHTS = { common: 60, uncommon: 25, rare: 10, epic: 4, legendary: 1 };
6
+ const RARITY_TOTAL = Object.values(RARITY_WEIGHTS).reduce((a, b) => a + b, 0);
7
+ const RARITY_FLOOR = { common: 5, uncommon: 15, rare: 25, epic: 35, legendary: 50 };
8
+
9
+ const SPECIES = [
10
+ "duck", "goose", "blob", "cat", "dragon", "octopus", "owl", "penguin",
11
+ "turtle", "snail", "ghost", "axolotl", "capybara", "cactus", "robot",
12
+ "rabbit", "mushroom", "chonk",
13
+ ];
14
+
15
+ const EYES = ["·", "✦", "×", "◉", "@", "°"];
16
+ const HATS = ["none", "crown", "tophat", "propeller", "halo", "wizard", "beanie", "tinyduck"];
17
+ const STAT_NAMES = ["DEBUGGING", "PATIENCE", "CHAOS", "WISDOM", "SNARK"];
18
+
19
+ const RARITY_LABELS = {
20
+ common: "Common (60%)",
21
+ uncommon: "Uncommon (25%)",
22
+ rare: "Rare (10%)",
23
+ epic: "Epic (4%)",
24
+ legendary: "Legendary (1%)",
25
+ };
26
+
27
+ function mulberry32(seed) {
28
+ let a = seed >>> 0;
29
+ return () => {
30
+ a |= 0;
31
+ a = (a + 0x6d2b79f5) | 0;
32
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
33
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
34
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
35
+ };
36
+ }
37
+
38
+ import { wyhash } from "./wyhash.js";
39
+
40
+ function hashString(value) {
41
+ if (typeof Bun !== "undefined") return Number(BigInt(Bun.hash(value)) & 0xffffffffn);
42
+ return Number(wyhash(0n, value) & 0xffffffffn);
43
+ }
44
+
45
+ function pick(rng, items) {
46
+ return items[Math.floor(rng() * items.length)];
47
+ }
48
+
49
+ function rollRarity(rng) {
50
+ let roll = rng() * RARITY_TOTAL;
51
+ for (const rarity of RARITIES) {
52
+ roll -= RARITY_WEIGHTS[rarity];
53
+ if (roll < 0) return rarity;
54
+ }
55
+ return "common";
56
+ }
57
+
58
+ function rollFrom(salt, userId) {
59
+ const rng = mulberry32(hashString(userId + salt));
60
+ const rarity = rollRarity(rng);
61
+ const species = pick(rng, SPECIES);
62
+ const eye = pick(rng, EYES);
63
+ const hat = rarity === "common" ? "none" : pick(rng, HATS);
64
+ const shiny = rng() < 0.01;
65
+
66
+ const floor = RARITY_FLOOR[rarity];
67
+ const peak = pick(rng, STAT_NAMES);
68
+ let dump = pick(rng, STAT_NAMES);
69
+ while (dump === peak) dump = pick(rng, STAT_NAMES);
70
+
71
+ const stats = {};
72
+ for (const name of STAT_NAMES) {
73
+ if (name === peak) stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30));
74
+ else if (name === dump) stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15));
75
+ else stats[name] = floor + Math.floor(rng() * 40);
76
+ }
77
+
78
+ return { rarity, species, eye, hat, shiny, stats, peak, dump };
79
+ }
80
+
81
+ function findCurrentSalt(binaryData) {
82
+ if (binaryData.includes(Buffer.from(ORIGINAL_SALT))) return ORIGINAL_SALT;
83
+
84
+ const text = binaryData.toString("latin1");
85
+
86
+ const patchedSaltPatterns = [
87
+ new RegExp(`x{${SALT_LEN - 8}}\\d{8}`, "g"),
88
+ new RegExp(`friend-\\d{4}-.{${SALT_LEN - 12}}`, "g"),
89
+ ];
90
+ for (const pattern of patchedSaltPatterns) {
91
+ let match = pattern.exec(text);
92
+ while (match !== null) {
93
+ if (match[0].length === SALT_LEN) return match[0];
94
+ match = pattern.exec(text);
95
+ }
96
+ }
97
+
98
+ const saltRegex = new RegExp(`"([a-zA-Z0-9_-]{${SALT_LEN}})"`, "g");
99
+ const candidates = new Set();
100
+ const markers = ["rollRarity", "CompanionBones", "inspirationSeed", "companionUserId"];
101
+ for (const marker of markers) {
102
+ const markerIndex = text.indexOf(marker);
103
+ if (markerIndex === -1) continue;
104
+
105
+ const window = text.slice(Math.max(0, markerIndex - 5000), Math.min(text.length, markerIndex + 5000));
106
+ let match = saltRegex.exec(window);
107
+ while (match !== null) {
108
+ candidates.add(match[1]);
109
+ match = saltRegex.exec(window);
110
+ }
111
+ }
112
+
113
+ for (const candidate of candidates) {
114
+ if (/[\d-]/.test(candidate)) return candidate;
115
+ }
116
+
117
+ return null;
118
+ }
119
+
120
+ function matches(roll, target) {
121
+ if (target.species && roll.species !== target.species) return false;
122
+ if (target.rarity && roll.rarity !== target.rarity) return false;
123
+ if (target.eye && roll.eye !== target.eye) return false;
124
+ if (target.hat && roll.hat !== target.hat) return false;
125
+ if (target.shiny !== undefined && roll.shiny !== target.shiny) return false;
126
+ if (target.peak && roll.peak !== target.peak) return false;
127
+ if (target.dump && roll.dump !== target.dump) return false;
128
+ return true;
129
+ }
130
+
131
+ async function bruteForce(userId, target, onProgress) {
132
+ const startTime = Date.now();
133
+ let checked = 0;
134
+
135
+ const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_";
136
+ const suffixLen = SALT_LEN - "friend-2026-".length;
137
+ if (suffixLen > 0 && suffixLen <= 4) {
138
+ const generateSuffixes = function* (prefix, depth) {
139
+ if (depth === 0) {
140
+ yield prefix;
141
+ return;
142
+ }
143
+ for (const char of chars) yield* generateSuffixes(prefix + char, depth - 1);
144
+ };
145
+
146
+ for (const suffix of generateSuffixes("", suffixLen)) {
147
+ const salt = `friend-2026-${suffix}`;
148
+ checked++;
149
+ const result = rollFrom(salt, userId);
150
+ if (matches(result, target)) return { salt, result, checked, elapsed: Date.now() - startTime };
151
+ }
152
+ }
153
+
154
+ for (let index = 0; index < 1_000_000_000; index++) {
155
+ const salt = String(index).padStart(SALT_LEN, "x");
156
+ checked++;
157
+ const result = rollFrom(salt, userId);
158
+ if (matches(result, target)) return { salt, result, checked, elapsed: Date.now() - startTime };
159
+
160
+ if (checked % 100_000 === 0) {
161
+ await new Promise((resolve) => setTimeout(resolve, 0));
162
+ }
163
+ if (checked % 5_000_000 === 0 && onProgress) {
164
+ onProgress(checked, Date.now() - startTime);
165
+ }
166
+ }
167
+
168
+ return null;
169
+ }
170
+
171
+ export {
172
+ EYES,
173
+ HATS,
174
+ RARITIES,
175
+ RARITY_LABELS,
176
+ RARITY_WEIGHTS,
177
+ SPECIES,
178
+ STAT_NAMES,
179
+ bruteForce,
180
+ findCurrentSalt,
181
+ matches,
182
+ rollFrom,
183
+ };
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { rollFrom, matches, findCurrentSalt } from "./companion.js";
3
+
4
+ describe("rollFrom", () => {
5
+ it("returns deterministic result for same salt + userId", () => {
6
+ const a = rollFrom("friend-2026-401", "test-user");
7
+ const b = rollFrom("friend-2026-401", "test-user");
8
+ expect(a).toEqual(b);
9
+ });
10
+
11
+ it("returns different results for different salts", () => {
12
+ const a = rollFrom("friend-2026-401", "test-user");
13
+ const b = rollFrom("friend-2026-402", "test-user");
14
+ expect(a.species === b.species && a.rarity === b.rarity && a.eye === b.eye).toBe(false);
15
+ });
16
+
17
+ it("returns all required fields", () => {
18
+ const result = rollFrom("friend-2026-401", "test-user");
19
+ expect(result).toHaveProperty("rarity");
20
+ expect(result).toHaveProperty("species");
21
+ expect(result).toHaveProperty("eye");
22
+ expect(result).toHaveProperty("hat");
23
+ expect(result).toHaveProperty("shiny");
24
+ expect(result).toHaveProperty("stats");
25
+ });
26
+
27
+ it("returns valid rarity", () => {
28
+ const validRarities = ["common", "uncommon", "rare", "epic", "legendary"];
29
+ const result = rollFrom("friend-2026-401", "test-user");
30
+ expect(validRarities).toContain(result.rarity);
31
+ });
32
+
33
+ it("common rarity always has hat=none", () => {
34
+ let found = false;
35
+ for (let i = 0; i < 1000 && !found; i++) {
36
+ const salt = String(i).padStart(15, "x");
37
+ const result = rollFrom(salt, "test-user");
38
+ if (result.rarity === "common") {
39
+ expect(result.hat).toBe("none");
40
+ found = true;
41
+ }
42
+ }
43
+ expect(found).toBe(true);
44
+ });
45
+
46
+ it("stats values are within valid range (1-100)", () => {
47
+ const result = rollFrom("friend-2026-401", "test-user");
48
+ for (const value of Object.values(result.stats)) {
49
+ expect(value).toBeGreaterThanOrEqual(1);
50
+ expect(value).toBeLessThanOrEqual(100);
51
+ }
52
+ });
53
+ });
54
+
55
+ describe("matches", () => {
56
+ const roll = { species: "duck", rarity: "common", eye: "·", hat: "none", shiny: false };
57
+
58
+ it("matches when target is empty", () => {
59
+ expect(matches(roll, {})).toBe(true);
60
+ });
61
+
62
+ it("matches exact species", () => {
63
+ expect(matches(roll, { species: "duck" })).toBe(true);
64
+ expect(matches(roll, { species: "cat" })).toBe(false);
65
+ });
66
+
67
+ it("matches partial target", () => {
68
+ expect(matches(roll, { species: "duck", rarity: "common" })).toBe(true);
69
+ expect(matches(roll, { species: "duck", rarity: "rare" })).toBe(false);
70
+ });
71
+
72
+ it("matches shiny flag", () => {
73
+ expect(matches(roll, { shiny: false })).toBe(true);
74
+ expect(matches(roll, { shiny: true })).toBe(false);
75
+ });
76
+
77
+ it("ignores undefined shiny in target", () => {
78
+ expect(matches(roll, { species: "duck", shiny: undefined })).toBe(true);
79
+ });
80
+
81
+ it("matches peak stat", () => {
82
+ const rollWithStats = rollFrom("friend-2026-401", "test-user");
83
+ expect(matches(rollWithStats, { peak: rollWithStats.peak })).toBe(true);
84
+ const otherStats = ["DEBUGGING", "PATIENCE", "CHAOS", "WISDOM", "SNARK"].filter(s => s !== rollWithStats.peak);
85
+ expect(matches(rollWithStats, { peak: otherStats[0] })).toBe(false);
86
+ });
87
+
88
+ it("matches dump stat", () => {
89
+ const rollWithStats = rollFrom("friend-2026-401", "test-user");
90
+ expect(matches(rollWithStats, { dump: rollWithStats.dump })).toBe(true);
91
+ const otherStats = ["DEBUGGING", "PATIENCE", "CHAOS", "WISDOM", "SNARK"].filter(s => s !== rollWithStats.dump);
92
+ expect(matches(rollWithStats, { dump: otherStats[0] })).toBe(false);
93
+ });
94
+
95
+ it("ignores peak/dump when not in target", () => {
96
+ const rollWithStats = rollFrom("friend-2026-401", "test-user");
97
+ expect(matches(rollWithStats, { species: rollWithStats.species })).toBe(true);
98
+ });
99
+ });
100
+
101
+ describe("rollFrom peak/dump fields", () => {
102
+ it("returns peak and dump stat names", () => {
103
+ const result = rollFrom("friend-2026-401", "test-user");
104
+ const validStats = ["DEBUGGING", "PATIENCE", "CHAOS", "WISDOM", "SNARK"];
105
+ expect(validStats).toContain(result.peak);
106
+ expect(validStats).toContain(result.dump);
107
+ expect(result.peak).not.toBe(result.dump);
108
+ });
109
+
110
+ it("peak stat has highest value, dump has lowest", () => {
111
+ const result = rollFrom("friend-2026-401", "test-user");
112
+ expect(result.stats[result.peak]).toBeGreaterThanOrEqual(result.stats[result.dump]);
113
+ });
114
+ });
115
+
116
+ describe("findCurrentSalt", () => {
117
+ const ORIGINAL_SALT = "friend-2026-401";
118
+
119
+ it("finds original salt in binary data", () => {
120
+ const data = Buffer.from(`some binary data ${ORIGINAL_SALT} more data`);
121
+ expect(findCurrentSalt(data)).toBe(ORIGINAL_SALT);
122
+ });
123
+
124
+ it("finds patched salt (numeric pattern)", () => {
125
+ const patchedSalt = "xxxxxxx00000001";
126
+ const data = Buffer.from(`some binary data "${patchedSalt}" rollRarity more data`);
127
+ expect(findCurrentSalt(data)).toBe(patchedSalt);
128
+ });
129
+
130
+ it("returns null when no salt found", () => {
131
+ const data = Buffer.from("no salt here at all");
132
+ expect(findCurrentSalt(data)).toBeNull();
133
+ });
134
+ });
package/lib/doctor.js ADDED
@@ -0,0 +1,73 @@
1
+ import { getClaudeBinaryOverride, getClaudeConfigDir, findBinaryPath, findConfigPath, getPatchability } from "./runtime.js";
2
+
3
+ function buildStatus(binaryPath, configPath, patchability) {
4
+ if (!binaryPath && !configPath) return "missing-binary-and-config";
5
+ if (!binaryPath) return "missing-binary";
6
+ if (!configPath) return "missing-config";
7
+ if (!patchability.ok) return "read-only";
8
+ return "ready";
9
+ }
10
+
11
+ function buildNextStep(status) {
12
+ switch (status) {
13
+ case "missing-binary-and-config":
14
+ return "Install Claude Code, make sure `claude` is discoverable on PATH, or set `CLAUDE_BINARY_PATH` and `CLAUDE_CONFIG_DIR`.";
15
+ case "missing-binary":
16
+ return "Make sure `claude` is discoverable on PATH or set `CLAUDE_BINARY_PATH` to the real Claude executable.";
17
+ case "missing-config":
18
+ return "Open Claude Code once so it writes config, or point `CLAUDE_CONFIG_DIR` to the correct config directory.";
19
+ case "read-only":
20
+ return "Use a user-writable Claude install, or point `CLAUDE_BINARY_PATH` to a writable Claude binary copy; `--current` can still work with a read-only install.";
21
+ default:
22
+ return "`--current` and reroll commands are ready to run on this machine.";
23
+ }
24
+ }
25
+
26
+ export function getDoctorReport() {
27
+ const binaryOverride = getClaudeBinaryOverride();
28
+ const configDir = getClaudeConfigDir();
29
+ const binaryPath = findBinaryPath();
30
+ const configPath = findConfigPath();
31
+ const patchability = binaryPath
32
+ ? getPatchability(binaryPath)
33
+ : { ok: false, message: "Claude Code binary path is missing.", backupPath: null };
34
+ const status = buildStatus(binaryPath, configPath, patchability);
35
+
36
+ return {
37
+ status,
38
+ binaryOverride,
39
+ configDir,
40
+ binaryPath,
41
+ configPath,
42
+ patchability,
43
+ canRunCurrent: Boolean(binaryPath && configPath),
44
+ nextStep: buildNextStep(status),
45
+ };
46
+ }
47
+
48
+ export function formatDoctorReport(report, title = "Doctor report") {
49
+ const lines = [
50
+ title,
51
+ ` Status: ${report.status}`,
52
+ ` Binary override: ${report.binaryOverride ?? "not set"}`,
53
+ ` Config dir: ${report.configDir}`,
54
+ ` Binary path: ${report.binaryPath ?? "not found"}`,
55
+ ` Config path: ${report.configPath ?? "not found"}`,
56
+ ` Read current: ${report.canRunCurrent ? "yes" : "no"}`,
57
+ ];
58
+
59
+ if (report.binaryPath) {
60
+ lines.push(` Patchability: ${report.patchability.ok ? "writable" : "read-only"}`);
61
+ lines.push(` Backup path: ${report.patchability.backupPath ?? "not available"}`);
62
+ } else {
63
+ lines.push(" Patchability: not available");
64
+ lines.push(" Backup path: not available");
65
+ }
66
+
67
+ if (!report.patchability.ok && report.patchability.message) {
68
+ lines.push(` Note: ${report.patchability.message}`);
69
+ }
70
+
71
+ lines.push(` Next step: ${report.nextStep}`);
72
+ return lines.join("\n");
73
+ }
@@ -0,0 +1,43 @@
1
+ import { RARITY_WEIGHTS } from "./companion.js";
2
+
3
+ export function estimateAttempts(target) {
4
+ if (!target || Object.keys(target).length === 0) return 1;
5
+
6
+ let probability = 1;
7
+
8
+ if (target.species) probability *= 1 / 18;
9
+ if (target.rarity) probability *= RARITY_WEIGHTS[target.rarity] / 100;
10
+ if (target.eye) probability *= 1 / 6;
11
+ if (target.hat && target.rarity !== "common") probability *= 1 / 8;
12
+ if (target.shiny === true) probability *= 0.01;
13
+ if (target.peak) probability *= 1 / 5;
14
+ if (target.dump) probability *= 1 / 4;
15
+
16
+ return Math.round(1 / probability);
17
+ }
18
+
19
+ export function formatProgress(attempts, elapsed, expected, workers) {
20
+ const pct = Math.min(100, Math.round((attempts / expected) * 100));
21
+ const rate = attempts / (elapsed / 1000);
22
+
23
+ let rateStr;
24
+ if (rate >= 1_000_000) {
25
+ rateStr = (rate / 1_000_000).toFixed(1) + "M tries/s";
26
+ } else if (rate >= 1_000) {
27
+ rateStr = (rate / 1_000).toFixed(1) + "k tries/s";
28
+ } else {
29
+ rateStr = rate.toFixed(1) + " tries/s";
30
+ }
31
+
32
+ const remaining = Math.max(0, (expected - attempts) / rate);
33
+ let etaStr;
34
+ if (remaining < 60) {
35
+ etaStr = Math.round(remaining) + "s";
36
+ } else {
37
+ const minutes = Math.floor(remaining / 60);
38
+ const seconds = Math.round(remaining % 60);
39
+ etaStr = minutes + "m " + seconds + "s";
40
+ }
41
+
42
+ return `${pct}% | ${rateStr} | ~${etaStr} left | ${workers} cores`;
43
+ }
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { estimateAttempts, formatProgress } from "./estimator.js";
3
+
4
+ describe("estimateAttempts", () => {
5
+ it("returns 1 for empty target", () => {
6
+ expect(estimateAttempts({})).toBe(1);
7
+ });
8
+
9
+ it("returns 18 for species only", () => {
10
+ expect(estimateAttempts({ species: "duck" })).toBe(18);
11
+ });
12
+
13
+ it("returns 30 for species + common rarity", () => {
14
+ expect(estimateAttempts({ species: "duck", rarity: "common" })).toBe(30);
15
+ });
16
+
17
+ it("returns 1800 for species + legendary rarity", () => {
18
+ expect(estimateAttempts({ species: "duck", rarity: "legendary" })).toBe(1800);
19
+ });
20
+
21
+ it("returns 180 for species + rarity + eye", () => {
22
+ expect(estimateAttempts({ species: "duck", rarity: "common", eye: "·" })).toBe(180);
23
+ });
24
+
25
+ it("returns 3456 for species + rarity + eye + hat (non-common)", () => {
26
+ expect(estimateAttempts({ species: "duck", rarity: "uncommon", eye: "·", hat: "crown" })).toBe(3456);
27
+ });
28
+
29
+ it("returns 1800 for shiny only", () => {
30
+ expect(estimateAttempts({ shiny: true })).toBe(100);
31
+ });
32
+
33
+ it("returns 5 for peak only", () => {
34
+ expect(estimateAttempts({ peak: "DEBUGGING" })).toBe(5);
35
+ });
36
+
37
+ it("returns 4 for dump only", () => {
38
+ expect(estimateAttempts({ dump: "DEBUGGING" })).toBe(4);
39
+ });
40
+
41
+ it("returns large number for full combo", () => {
42
+ const result = estimateAttempts({
43
+ species: "dragon",
44
+ rarity: "legendary",
45
+ eye: "✦",
46
+ hat: "propeller",
47
+ shiny: true,
48
+ peak: "WISDOM",
49
+ dump: "SNARK",
50
+ });
51
+ expect(result).toBeGreaterThan(1_000_000);
52
+ });
53
+
54
+ it("does not multiply by hat for common rarity", () => {
55
+ const withoutHat = estimateAttempts({ species: "duck", rarity: "common" });
56
+ const withHat = estimateAttempts({ species: "duck", rarity: "common", hat: "none" });
57
+ expect(withoutHat).toBe(withHat);
58
+ });
59
+ });
60
+
61
+ describe("formatProgress", () => {
62
+ it("formats progress with millions of salts per second", () => {
63
+ const result = formatProgress(5_000_000, 2000, 10_000_000, 8);
64
+ expect(result).toContain("50%");
65
+ expect(result).toContain("2.5M tries/s");
66
+ expect(result).toContain("8 cores");
67
+ });
68
+
69
+ it("formats progress with thousands of salts per second", () => {
70
+ const result = formatProgress(50_000, 5000, 100_000, 4);
71
+ expect(result).toContain("50%");
72
+ expect(result).toContain("10.0k tries/s");
73
+ expect(result).toContain("4 cores");
74
+ });
75
+
76
+ it("formats ETA in seconds", () => {
77
+ const result = formatProgress(9_000_000, 1000, 10_000_000, 8);
78
+ expect(result).toContain("left");
79
+ expect(result).toContain("s");
80
+ });
81
+
82
+ it("formats ETA in minutes and seconds", () => {
83
+ const result = formatProgress(1_000_000, 1000, 100_000_000, 8);
84
+ expect(result).toContain("m");
85
+ });
86
+
87
+ it("caps percentage at 100", () => {
88
+ const result = formatProgress(10_000_000, 1000, 10_000_000, 8);
89
+ expect(result).toContain("100%");
90
+ });
91
+
92
+ it("handles zero elapsed time gracefully", () => {
93
+ const result = formatProgress(1_000_000, 1, 10_000_000, 8);
94
+ expect(result).toContain("%");
95
+ expect(result).toContain("tries/s");
96
+ });
97
+ });
package/lib/finder.js ADDED
@@ -0,0 +1,104 @@
1
+ import { spawn } from "child_process";
2
+ import { cpus } from "os";
3
+ import { fileURLToPath } from "url";
4
+ import { join, dirname } from "path";
5
+ import { estimateAttempts } from "./estimator.js";
6
+ import { rollFrom } from "./companion.js";
7
+
8
+ const WORKER_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "..", "scripts", "worker.js");
9
+
10
+ export async function parallelBruteForce(userId, target, onProgress) {
11
+ const numWorkers = Math.max(1, Math.min(cpus().length, 8));
12
+ const expected = estimateAttempts(target);
13
+
14
+ return new Promise((resolve, reject) => {
15
+ const children = [];
16
+ const workerStdout = [];
17
+ const workerAttempts = [];
18
+ let resolved = false;
19
+ let exited = 0;
20
+
21
+ function killAll() {
22
+ for (const child of children) {
23
+ try { child.kill(); } catch {}
24
+ }
25
+ }
26
+
27
+ for (let i = 0; i < numWorkers; i++) {
28
+ workerStdout[i] = "";
29
+ workerAttempts[i] = 0;
30
+
31
+ const child = spawn(process.execPath, [WORKER_SCRIPT, userId, JSON.stringify(target)], {
32
+ stdio: ["ignore", "pipe", "pipe"],
33
+ });
34
+ children.push(child);
35
+
36
+ child.stdout.on("data", (chunk) => {
37
+ workerStdout[i] += chunk.toString();
38
+ });
39
+
40
+ child.stderr.on("data", (chunk) => {
41
+ const lines = chunk.toString().split("\n").filter(Boolean);
42
+ for (const line of lines) {
43
+ try {
44
+ const progress = JSON.parse(line);
45
+ if (progress.attempts) {
46
+ workerAttempts[i] = progress.attempts;
47
+ const totalAttempts = workerAttempts.reduce((a, b) => a + b, 0);
48
+ const elapsed = progress.elapsed || 0;
49
+ if (onProgress) {
50
+ onProgress(totalAttempts, elapsed, expected, numWorkers);
51
+ }
52
+ }
53
+ } catch {}
54
+ }
55
+ });
56
+
57
+ child.on("close", (code) => {
58
+ exited++;
59
+ if (resolved) return;
60
+
61
+ if (code === 0 && workerStdout[i].trim()) {
62
+ resolved = true;
63
+ killAll();
64
+
65
+ try {
66
+ const result = JSON.parse(workerStdout[i].trim());
67
+ const totalAttempts = workerAttempts.reduce((a, b) => a + b, 0);
68
+ const roll = rollFrom(result.salt, userId);
69
+ resolve({
70
+ salt: result.salt,
71
+ result: roll,
72
+ checked: Math.max(totalAttempts, result.attempts),
73
+ elapsed: result.elapsed,
74
+ workers: numWorkers,
75
+ });
76
+ } catch (err) {
77
+ reject(err);
78
+ }
79
+ return;
80
+ }
81
+
82
+ if (exited === numWorkers && !resolved) {
83
+ resolve(null);
84
+ }
85
+ });
86
+
87
+ child.on("error", () => {
88
+ exited++;
89
+ if (exited === numWorkers && !resolved) {
90
+ resolve(null);
91
+ }
92
+ });
93
+ }
94
+
95
+ const timeoutMs = Math.max(600_000, Math.ceil(expected / 50_000_000) * 60_000 + 600_000);
96
+ setTimeout(() => {
97
+ if (!resolved) {
98
+ resolved = true;
99
+ killAll();
100
+ resolve(null);
101
+ }
102
+ }, timeoutMs);
103
+ });
104
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { parallelBruteForce } from "./finder.js";
3
+
4
+ describe("parallelBruteForce", () => {
5
+ it("finds matching salt for simple target", async () => {
6
+ const result = await parallelBruteForce("test-user", { species: "duck" }, null);
7
+ expect(result).not.toBeNull();
8
+ expect(result.salt).toBeDefined();
9
+ expect(result.result.species).toBe("duck");
10
+ expect(result.checked).toBeGreaterThan(0);
11
+ expect(result.elapsed).toBeGreaterThanOrEqual(0);
12
+ expect(result.workers).toBeGreaterThanOrEqual(1);
13
+ }, 30_000);
14
+
15
+ it("calls onProgress callback", async () => {
16
+ let progressCalled = false;
17
+ await parallelBruteForce("test-user", { species: "cat" }, (attempts, elapsed, expected, workers) => {
18
+ if (attempts > 0) progressCalled = true;
19
+ });
20
+ }, 30_000);
21
+
22
+ it("returns result with compatible shape", async () => {
23
+ const result = await parallelBruteForce("test-user", { species: "blob" }, null);
24
+ expect(result).toHaveProperty("salt");
25
+ expect(result).toHaveProperty("result");
26
+ expect(result).toHaveProperty("checked");
27
+ expect(result).toHaveProperty("elapsed");
28
+ expect(result.result).toHaveProperty("rarity");
29
+ expect(result.result).toHaveProperty("species");
30
+ expect(result.result).toHaveProperty("eye");
31
+ expect(result.result).toHaveProperty("hat");
32
+ expect(result.result).toHaveProperty("shiny");
33
+ expect(result.result).toHaveProperty("stats");
34
+ }, 30_000);
35
+ });