4.9 KiB
trmn/people
- Architecture
- PRNG Isolation
- Death Age Sampling
- Post-Apply Hooks (inside simulateLife day loop)
- Family Generation
- Events
- Gotchas
Architecture
Each NPC is a PersonRecord stored in people: Map<personId, PersonRecord> 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.
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'
}
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).
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
}
deriveNpcSeed(masterSeed, salt) = splitmix32(splitmix32(masterSeed) ^ salt)
Death Age Sampling
Algebraic Gompertz inverse-CDF — no per-NPC simulation loop needed:
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));
}
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 tos.friendList, increments.friendLifetimeIndexlose_friend→ splice froms.friendList, roll cause (death 20% / drifted 50% / moved 30%); if death, upgrade eventLog entry fromlose_friendtofriend_deathmove_city→ splice last min(5, friendList.length) friends, mark lostCause='moved'first_child/subsequent_child→ create child PersonRecord, push tos.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 withpersonIdfamily_member_death(loop, step 4) — fires when any non-friend NPC'sdeathDay === d; logged withpersonIdandattended(70% probability)friend_death(post-apply of lose_friend) — replaceslose_friendin eventLog when cause roll < 0.20
Gotchas
stats.mjsand any eventLog consumer must usee.idnotedirectly — entries are now{id, personId?, attended?}objects, not strings. Broke three spots in stats.mjs (all fixed).friendListandfriendCountare kept in sync manually after every mutation. If they diverge, prereqfriendCount > 0fireslose_friendbutfriendListis empty → guard:if (ev.id === 'lose_friend' && s.friendList.length > 0).- Friend
birthDaycan 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_bornuses a dynamically builtmergedSchedule(SCHEDULED + sibling events, sorted). The existing static SCHEDULED array is unchanged.