source dump of claude code
at main 133 lines 3.7 kB view raw
1import { getGlobalConfig } from '../utils/config.js' 2import { 3 type Companion, 4 type CompanionBones, 5 EYES, 6 HATS, 7 RARITIES, 8 RARITY_WEIGHTS, 9 type Rarity, 10 SPECIES, 11 STAT_NAMES, 12 type StatName, 13} from './types.js' 14 15// Mulberry32 — tiny seeded PRNG, good enough for picking ducks 16function mulberry32(seed: number): () => number { 17 let a = seed >>> 0 18 return function () { 19 a |= 0 20 a = (a + 0x6d2b79f5) | 0 21 let t = Math.imul(a ^ (a >>> 15), 1 | a) 22 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t 23 return ((t ^ (t >>> 14)) >>> 0) / 4294967296 24 } 25} 26 27function hashString(s: string): number { 28 if (typeof Bun !== 'undefined') { 29 return Number(BigInt(Bun.hash(s)) & 0xffffffffn) 30 } 31 let h = 2166136261 32 for (let i = 0; i < s.length; i++) { 33 h ^= s.charCodeAt(i) 34 h = Math.imul(h, 16777619) 35 } 36 return h >>> 0 37} 38 39function pick<T>(rng: () => number, arr: readonly T[]): T { 40 return arr[Math.floor(rng() * arr.length)]! 41} 42 43function rollRarity(rng: () => number): Rarity { 44 const total = Object.values(RARITY_WEIGHTS).reduce((a, b) => a + b, 0) 45 let roll = rng() * total 46 for (const rarity of RARITIES) { 47 roll -= RARITY_WEIGHTS[rarity] 48 if (roll < 0) return rarity 49 } 50 return 'common' 51} 52 53const RARITY_FLOOR: Record<Rarity, number> = { 54 common: 5, 55 uncommon: 15, 56 rare: 25, 57 epic: 35, 58 legendary: 50, 59} 60 61// One peak stat, one dump stat, rest scattered. Rarity bumps the floor. 62function rollStats( 63 rng: () => number, 64 rarity: Rarity, 65): Record<StatName, number> { 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 = {} as Record<StatName, number> 72 for (const name of STAT_NAMES) { 73 if (name === peak) { 74 stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30)) 75 } else if (name === dump) { 76 stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15)) 77 } else { 78 stats[name] = floor + Math.floor(rng() * 40) 79 } 80 } 81 return stats 82} 83 84const SALT = 'friend-2026-401' 85 86export type Roll = { 87 bones: CompanionBones 88 inspirationSeed: number 89} 90 91function rollFrom(rng: () => number): Roll { 92 const rarity = rollRarity(rng) 93 const bones: CompanionBones = { 94 rarity, 95 species: pick(rng, SPECIES), 96 eye: pick(rng, EYES), 97 hat: rarity === 'common' ? 'none' : pick(rng, HATS), 98 shiny: rng() < 0.01, 99 stats: rollStats(rng, rarity), 100 } 101 return { bones, inspirationSeed: Math.floor(rng() * 1e9) } 102} 103 104// Called from three hot paths (500ms sprite tick, per-keystroke PromptInput, 105// per-turn observer) with the same userId → cache the deterministic result. 106let rollCache: { key: string; value: Roll } | undefined 107export function roll(userId: string): Roll { 108 const key = userId + SALT 109 if (rollCache?.key === key) return rollCache.value 110 const value = rollFrom(mulberry32(hashString(key))) 111 rollCache = { key, value } 112 return value 113} 114 115export function rollWithSeed(seed: string): Roll { 116 return rollFrom(mulberry32(hashString(seed))) 117} 118 119export function companionUserId(): string { 120 const config = getGlobalConfig() 121 return config.oauthAccount?.accountUuid ?? config.userID ?? 'anon' 122} 123 124// Regenerate bones from userId, merge with stored soul. Bones never persist 125// so species renames and SPECIES-array edits can't break stored companions, 126// and editing config.companion can't fake a rarity. 127export function getCompanion(): Companion | undefined { 128 const stored = getGlobalConfig().companion 129 if (!stored) return undefined 130 const { bones } = roll(companionUserId()) 131 // bones last so stale bones fields in old-format configs get overridden 132 return { ...stored, ...bones } 133}