create_family.js

import { createCharacter } from "./create_character.js";
import { createCharacterName } from "./create_character_name.js";
import { findRelatedProfession } from "./create_profession.js";
import { gaussian, test } from "./random_utils.js";
import { logger } from "./utils.js";
import Family from "./models/family.js";
import RarityTable from "./tables/rarity_table.js";

const DECEASED = "deceased";
const LIFE_EXPECTANCY = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 100, 100];
const COPY_PROPS = [
  "profession",
  "honorific",
  "description",
  "degree",
  "traits",
  "clothing",
  "possessions",
];
const COUPLE_TYPE = new RarityTable();
COUPLE_TYPE.add("common", "married");
COUPLE_TYPE.add("uncommon", "couple");
COUPLE_TYPE.add("rare", "separated");
COUPLE_TYPE.add("rare", "divorced");

class FamilyTree {
  constructor() {
    this.women = [];
    this.all = [];
    this.generation = 1;
  }
  addPerson(person) {
    if (!person.male) {
      this.women.push(person);
    }
    this.all.push(person);
  }
  mayHaveChild(woman) {
    let youngestChildAge = ageOfYoungestChild(woman) || 4;
    if (test(youngestChildAge * 5)) {
      this.birth(woman);
    }
  }
  birth(woman) {
    const child = createChild(woman);
    woman.children = woman.children ?? [];
    woman.children.push(child);
    this.addPerson(child);
    this.generation = Math.max(this.generation, child.gen);
  }
  partner(person) {
    person.partner = createPartner(person);
    person.relationship = COUPLE_TYPE.get();
    if (!person.male && test(80)) {
      person.name.family = person.partner.name.family;
    }
    this.addPerson(person.partner);
  }
  mayDie(person) {
    if (test(chanceToDie(person))) {
      this.death(person);
    }
  }
  death(person) {
    person.status = DECEASED;
    person.description.push(`Died at age ${person.age}`);
  }
  nextYear() {
    this.living.forEach((person) => {
      // what we would really like to say is name (died at age N);
      person.age += 1;
      // this is a child who now gains adult skills
      if (person.age === 18) {
        // not sure where to get this...we need the prestige of a parent's profession
        const postProfession = findRelatedProfession({ name: person.parentProfession });
        let character = createCharacter({ gender: person.gender, age: person.age, postProfession });
        COPY_PROPS.forEach((prop) => (person[prop] = character[prop]));
      }
    });
  }
  get living() {
    return this.all.filter((ch) => ch.status !== DECEASED);
  }
  get childBearers() {
    return this.women.filter((ch) => {
      return (
        ch.status !== DECEASED &&
        ch.partner &&
        ch.partner?.status !== DECEASED &&
        ch.age >= 18 &&
        ch.age <= 40
      );
    });
  }
  get fallingInLove() {
    return this.all.filter(
      (ch) => !ch.partner && ch.status !== DECEASED && ch.age === ch.meetSomeone,
    );
  }
}

function createPartner(person) {
  const gender = person.male ? "female" : "male";
  const age = ageOfPartner(person);
  const postProfession = findRelatedProfession({ name: person.profession });
  const partner = createCharacter({ gender, age, postProfession, possessions: "None" });
  partner.gen = person.gen;
  return partner;
}

function createChild(woman) {
  const child = createCharacter({
    name: createCharacterName({
      family: woman.partner.name.family,
      heritage: test(50) ? woman.heritage : woman.partner.heritage,
    }),
    possessions: "None",
    isChild: true,
  });
  child.parentProfession = woman.profession;
  child.age = 0; // actually start at 0, isChild picks an age
  child.gen = woman.gen + 1;
  if (test(70)) {
    child.meetSomeone = ageOfMeetingPartner();
  }
  return child;
}

function chanceToDie(ch) {
  return Math.ceil(LIFE_EXPECTANCY[Math.floor(ch.age / 10)]);
}

function ageOfPartner(person) {
  let age = person.age + gaussian(1, 0, true);
  return age < 18 ? 18 : age;
}

function ageOfYoungestChild(woman) {
  if (woman.children?.length > 0) {
    return woman.children[woman.children.length - 1].age;
  }
  return 0;
}

function ageOfMeetingPartner() {
  return gaussian(3, 26);
}

// Queer couples and adopted children. Other unusual household
// arrangements as listed in the relationships file...though ultimately, we may need
// a createHousehold to deal with that, building on the relationship and family
// generator. Then we need a way to place these in the world.
/**
 * Creates a multi-generational family with characters, relationships, and a shared family name.
 * The family tree simulates births, deaths, and partnerships over time. A generation is counted
 * when a child is born into the tree.
 *
 * @param {Object} [options={}] - Family creation options
 * @param {number} [options.generations=1] - Number of generations to simulate. Higher values produce
 *    larger families with grandchildren and great-grandchildren.
 * @returns {Family} A Family instance rooted at the founding mother, containing the full family tree
 */
export function createFamily({ generations = 1 } = {}) {
  logger.start("createFamily", { generations });
  do {
    var family = createOneFamily({ generations });
  } while (family.generation !== generations);
  return logger.end(family);
}

function createOneFamily({ generations = 1 } = {}) {
  let yearsPastThreshold = gaussian(4, 14);
  let safetyLoops = gaussian(3, 80);

  const tree = new FamilyTree();
  const mother = createCharacter({ gender: "female", age: 17 });
  mother.gen = 1;
  tree.addPerson(mother);
  tree.partner(mother);

  while (yearsPastThreshold > 0 && safetyLoops > 0) {
    safetyLoops--;
    if (tree.generation >= generations) {
      yearsPastThreshold--;
    }
    tree.nextYear();
    tree.fallingInLove.forEach((person) => tree.partner(person));
    if (yearsPastThreshold > 4) {
      tree.childBearers.forEach((person) => tree.mayHaveChild(person));
    }
    tree.living.forEach(tree.mayDie.bind(tree));
  }
  return new Family({ parent: mother, generation: tree.generation });
}