Files
roam/trmnpeople.org
T
2026-06-17 07:36:46 +03:00

4.9 KiB
Raw Blame History

trmn/people

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,          // 03
  hairColor,         // 03
  eyeColor,          // 03
  // 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: 0x000700000x000a0000,
  // 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 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: 2035 years before protagonist; Father: 2038 years before
  • Each grandparent: 2035 years before their respective parent
  • Siblings: 03, 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.