companion.ts 3.6 KB

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