create_character.js

import { createAppearance } from "./create_appearance.js";
import { createCharacterName } from "./create_character_name.js";
import { createKit } from "./create_kit.js";
import { createProfession } from "./create_profession.js";
import { format, article } from "./string_utils.js";
import { logger, sum } from "./utils.js";
import { nonNegativeGaussian, random, roll, test } from "./random_utils.js";
import { ADULTHOOD } from "./constants.js";
import Character from "./models/character.js";
import { difference, intersection } from "./set_utils.js";

const MAX_TRAIT_VALUE = 4;
const INNATE = createProfession({ name: "Innate" });
const INNATE_CHILD = createProfession({ name: "Innate Child" });
const HISTORIES = Object.freeze([
  "Before the collapse, was {0}",
  "Was {0} up until the war",
  "Was {0} before the war",
]);

export const createHeritage = () => (test(20) ? "latino" : "anglo");

export const createGender = () => random(["male", "female"]);

export const createAdultAge = () => 18 + nonNegativeGaussian(7);

function newPrestige(profession) {
  if (test(80)) {
    return profession.typeOf("prestige");
  }
  if (profession.has("prestige:high") || profession.has("prestige:low")) {
    return "prestige:normal";
  }
  return random(["prestige:low", "prestige:high"]);
}

function trainAdult(prof, character, points) {
  // if this is a profession change, record the prior profession into character's history
  if (character.profession && character.profession !== prof) {
    honorificsToHistory(character);
  }
  character.profession = prof;
  train(prof, character, points);
}

function train(profession, character, points) {
  const { seeds, traits } = profession;
  console.log(`training profession ${profession.names[0]} for ${points} points`);

  let i = 0;
  while (points > 0 && i < 200) {
    i++;
    let existingTraits = Object.keys(character.traits);
    let unusedSeeds = difference(seeds, existingTraits);
    let usedSeeds = intersection(seeds, existingTraits);
    let unusedTraits = difference(traits, existingTraits);
    let usedTraits = intersection(traits, existingTraits);

    let num = roll(100);
    let trait = null;
    if (num <= 35 && usedSeeds.length) {
      // 35%
      trait = random(usedSeeds);
      console.log("Selected", trait, "from used seeds", usedSeeds.join(", "));
    } else if (num <= 70 && usedTraits.length) {
      // 35%
      trait = random(usedTraits);
      console.log("Selected", trait, "from used traits", usedTraits.join(", "));
    } else if (num <= 90 && unusedSeeds.length) {
      // 20%
      trait = random(unusedSeeds);
      console.log("Selected", trait, "from unused seeds", unusedSeeds.join(", "));
    } else {
      // 10%
      trait = random(unusedTraits);
      console.log("Selected", trait, "from unused traits", unusedTraits.join(", "));
    }
    if (character.trait(trait) < MAX_TRAIT_VALUE) {
      character.changeTrait(trait, 1);
      points -= 1;
    }
  }
  profession.tags.forEach((tag) => character.tags.add(tag));
  addHonorifics(profession, character);
}

function addHonorifics(profession, character) {
  if (profession.ranks.length) {
    assignRank(profession, character);
  } else if (profession.postprocess === "doctor") {
    character.honorific = "Dr.";
  } else if (profession.postprocess === "phd") {
    character.degree = "Ph.D.";
  }
}

function assignRank(profession, character) {
  const profName = profession.names[0];
  let chanceOfRank = (character.trait("Military") + character.trait("Government")) * 20;
  let level = test(chanceOfRank) ? random(nonNegativeGaussian(2, 0)) : 0;

  const rank = profession.ranks[level % profession.ranks.length];
  character.honorific = rank ? `${profName} ${rank}` : profName;
}

function honorificsToHistory(character) {
  let title = character.honorific ?? random(character.profession.names);
  let history = random(HISTORIES);
  if (title === "dr") {
    title = "doctor";
  }
  character.description.push(format(history, article(title.toLowerCase())) + ".");
}

/**
 * Creates a new character with customizable attributes and generated characteristics.
 *
 * @param {Object} options - Character creation options
 * @param {string} [options.possessions="None"] - Starting possessions or equipment (can be "None", "Walk-About", or "Traveling")
 * @param {number} [options.age=roll("14+3d12")] - Character age in years
 * @param {Object} [options.traits={}] - Initial trait values (e.g., { Cunning: 2, Strong: 2 })
 * @param {string} [options.gender] - Character gender ("male" or "female"). If not provided, randomly selected.
 * @param {string} [options.heritage] - Character heritage ("latino" or "anglo" lead to specific names, but
 *    any heritage can be provided). If not provided, randomly selected.
 * @param {string} [options.name] - Character name. If not provided, a name is generated.
 * @param {string|Object} [options.preProfession] - Character pre-collapse profession/career. Can be a profession name string
 *    or profession object, selected from the values returned by the `getPreProfessions` function. The option ”soldier” will
 *    select a military branch as a pre-collapse profession.
 * @param {string|Object} [options.postProfession] - Character post-collapse profession/career. Can be a profession name string
 *    or profession object, selected from the values returned by the `getPostProfessions` function. The option ”soldier” will
 *    select a military branch as a post-collapse profession. If “soldier” is selected pre-and post-collapse, the same branch
 *    will be used for both.
 * @param {boolean} [options.isChild=false] - Whether to create a child character (affects age and training)
 * @returns {Character} A new Character instance with generated or specified attributes
 */
export function createCharacter({
  possessions = "None",
  age = roll("14+3d12"),
  traits = {},
  gender = createGender(),
  heritage = createHeritage(),
  name,
  preProfession,
  postProfession,
  isChild = false,
} = {}) {
  logger.start("createCharacter", {
    possessions,
    age,
    traits,
    gender,
    heritage,
    name,
    preProfession,
    postProfession,
    isChild,
  });

  const characterName = createCharacterName({ ...name, gender, heritage });
  if (typeof preProfession === "string") {
    preProfession = createProfession({ name: preProfession });
  }
  if (typeof postProfession === "string") {
    postProfession = createProfession({ name: postProfession });
    while (age <= 24) {
      age = roll("14+3d12");
    }
  }

  const character = new Character({
    gender,
    heritage,
    name: characterName,
    age,
    traits,
  });
  // 4 kinds of characters:
  // 1. Children (under 17)
  // 2. Adults 17 to 24 (post-collapse trained)
  // 2. Adults 25+ with profession that goes right through apocalypse
  // 3. Adults 25+ who changed profession 7 years ago
  // there are minor adjustments to account for socioeconomic background and military training

  if (isChild) {
    // a child with innate traits, but no profession
    console.log("creating child");
    character.age = roll("1d13+3");
    train(INNATE_CHILD, character, 2);
    delete character.profession;
    delete character.honorific;
  } else {
    // an adult with adult characteristics and appearance.
    character.description.push(createAppearance({ character }));
    // remove points if traits were supplied, so character creation is balanced
    const debuff = sum(Object.values(traits));

    if (age <= 24) {
      console.log("training young adult");
      let postProf = postProfession || createProfession({ tags: "post -pre" });
      train(INNATE, character, 3);
      trainAdult(postProf, character, 4 - debuff);
    } else {
      train(INNATE, character, 2);

      let prestige = postProfession?.typeOf("prestige") ?? preProfession?.typeOf("prestige") ?? "";
      const preProf = preProfession || createProfession({ tags: `pre ${prestige}` });
      prestige = newPrestige(preProf);
      // take the postProfession if it exists, or selection one of a similar prestige, unless in
      // effect the character has a military background pre/post collapse. The keep the same
      // service because it's strange to switch services at this point.
      const postProf =
        preProf.has("military") && postProfession?.has("military")
          ? preProf
          : postProfession || createProfession({ tags: `post ${prestige}` });

      if (preProf.names[0] === postProf.names[0]) {
        console.log("training adult who did not change profession");
        trainAdult(postProf, character, 5 - debuff);
      } else {
        console.log("training adult who changed professions");
        trainAdult(preProf, character, 3 - debuff / 2);
        trainAdult(postProf, character, 2 - debuff / 2);
      }
    }
  }
  // special stuff, Spanish should be part of innate because others would learn it
  character.heritage = character.name.heritage;
  delete character.name.heritage;
  delete character.name.gender;
  if (age > ADULTHOOD && (test(20) || character.heritage === "latino")) {
    character.changeTrait("Spanish", nonNegativeGaussian(1.5));
  }
  // convert this to a name, we are done with the object
  character.profession = random(character.profession?.names);

  // equip the character, they always have clothes, they may have more than that
  const bags = createKit({
    profession: character.profession,
    gender: character.gender,
    traveling: possessions === "Traveling",
  });
  character.clothing = bags.clothing;
  if (possessions !== "None") {
    character.possessions = bags.possessions;
  }
  return logger.end(character);
}