:PROPERTIES: :ID: 423cac95-a80d-4db6-8bef-14297fb38437 :END: #+title: trmn/people #+filetags: :project: :knowledge: :people: ** Architecture Each NPC is a ~PersonRecord~ stored in ~people: Map~ returned by ~simulateLife()~. Family is generated at the top of ~simulateLife()~ via ~generateFamily(masterSeed)~ before the day loop. Friends/children are created lazily via post-apply hooks when their triggering events fire. #+begin_src js PersonRecord { id, role, // e.g. 'mother', 'sibling_0', 'friend_3', 'child_0' seed, // uint32, derived from masterSeed + role salt sex, // 0=female 1=male birthDay, // days from protagonist day 0 (negative = already born) deathDay, // days from protagonist day 0 hairType, // 0–3 hairColor, // 0–3 eyeColor, // 0–3 // friends only: metOnDay?, // day protagonist gained this friend lostOnDay?, // day the friendship ended lostCause?, // 'death' | 'drifted' | 'moved' } #+end_src ** PRNG Isolation Protagonist's daily stream (~makeRng(masterSeed, d)~) is NEVER consumed by NPC logic. NPC logic uses two isolated streams: - Each NPC's own rng: ~makeNpcRng(deriveNpcSeed(masterSeed, salt))~ — for visual attrs + death age - Shared per-day NPC stream: ~makeRng(deriveNpcSeed(masterSeed, 0xffff0000), d)~ — for attendance rolls and lose_friend cause/pick rolls Reserved day slots: ~day = -1~ (protagonist traits, existing), ~day = -2~ (family layout offsets, new). #+begin_src js const ROLE_SALTS = { mother: 0x00010000, father: 0x00020000, maternal_grandmother: 0x00030000, maternal_grandfather: 0x00040000, paternal_grandmother: 0x00050000, paternal_grandfather: 0x00060000, sibling_0..3: 0x00070000–0x000a0000, // friends: 0x01000000 + friendLifetimeIndex // children: 0x02000000 + childLifetimeIndex // npc-stream: 0xffff0000 } #+end_src ~deriveNpcSeed(masterSeed, salt) = splitmix32(splitmix32(masterSeed) ^ salt)~ ** Death Age Sampling Algebraic Gompertz inverse-CDF — no per-NPC simulation loop needed: #+begin_src js function sampleDeathAge(rng) { const healthOffset = (rng() - 0.5) * 15; // draw 1 const u = clamp(rng(), 0.001, 0.999); // draw 2 const t = Math.log(1 - (Math.log(1-u) * 0.095 / 0.0003)) / 0.095; return Math.round(clamp(t + healthOffset, 1, 108)); } #+end_src Draw order per NPC rng: sex → hairType → hairColor → eyeColor → healthOffset → u (sampleDeathAge). ** Post-Apply Hooks (inside simulateLife day loop) After ~Object.assign(s, ev.apply(...))~, five events trigger NPC record creation/mutation: - ~gain_friend~ → create PersonRecord, push to ~s.friendList~, increment ~s.friendLifetimeIndex~ - ~lose_friend~ → splice from ~s.friendList~, roll cause (death 20% / drifted 50% / moved 30%); if death, upgrade eventLog entry from ~lose_friend~ to ~friend_death~ - ~move_city~ → splice last min(5, friendList.length) friends, mark lostCause='moved' - ~first_child~ / ~subsequent_child~ → create child PersonRecord, push to ~s.childrenList~ State additions to ~defaultState()~: ~friendList [], friendLifetimeIndex 0, childrenList [], childLifetimeIndex 0~ (These are not packed into the Float32Array buffer; only ~friendCount~ / ~numChildren~ are packed.) ** Family Generation ~generateFamily(masterSeed)~ uses ~makeRng(masterSeed, -2)~ to draw birth offsets: - Mother: 20–35 years before protagonist; Father: 20–38 years before - Each grandparent: 20–35 years before their respective parent - Siblings: 0–3, each within ±8 years of protagonist (some already born at day 0, some born later) Grandparents with ~deathDay < 0~ were dead before protagonist was born — valid, just not triggering events. ** Events - ~sibling_born~ (scheduled) — fires on sibling's birthDay if > 0; no state change, just logged with ~personId~ - ~family_member_death~ (loop, step 4) — fires when any non-friend NPC's ~deathDay === d~; logged with ~personId~ and ~attended~ (70% probability) - ~friend_death~ (post-apply of lose_friend) — replaces ~lose_friend~ in eventLog when cause roll < 0.20 ** Gotchas - ~stats.mjs~ and any eventLog consumer must use ~e.id~ not ~e~ directly — entries are now ~{id, personId?, attended?}~ objects, not strings. Broke three spots in stats.mjs (all fixed). - ~friendList~ and ~friendCount~ are kept in sync manually after every mutation. If they diverge, prereq ~friendCount > 0~ fires ~lose_friend~ but ~friendList~ is empty → guard: ~if (ev.id === 'lose_friend' && s.friendList.length > 0)~. - Friend ~birthDay~ can be positive (friend younger than protagonist). At young protagonist ages (age 2), the ±15yr range means some friends haven't been born yet at meeting time. Cosmetic only — not used in any gameplay logic. - ~sibling_born~ uses a dynamically built ~mergedSchedule~ (SCHEDULED + sibling events, sorted). The existing static SCHEDULED array is unchanged.