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