create_character.js

import { createAppearance } from "./create_appearance.js";
import { createCharacterName } from "./create_character_name.js";
import { createKit } from "./create_kit.js";
import { database as professionDb } from "./professions.js";
import { format, article } from "./string_utils.js";
import { logger, sum } from "./utils.js";
import { nonNegativeGaussian, random, roll, shuffle, test } from "./random_utils.js";
import { TRAITS as T } from "./constants.js";
import { without } from "./set_utils.js";
import Character from "./models/character.js";

const MAX_TRAIT_VALUE = 4;
const INNATE = professionDb.findOne({ name: "Innate" });
const HISTORIES = Object.freeze([
  "Before the collapse, was {0}",
  "Was {0} up until the war",
  "Was {0} before the war",
]);
const NAVY_RANKS = Object.freeze([
  "",
  "Seaman",
  "Petty Officer",
  "Ensign",
  "Lieutenant",
  "Lieutenant Commander",
  "Commander",
  "Captain",
  "Admiral",
]);
const POLICE_RANKS = Object.freeze([
  "Officer",
  ["Officer", "Trooper"],
  "Detective",
  "Sergeant",
  ["Captain", "Deputy Marshal"],
  ["Inspector", "Marshal"],
  ["Deputy Chief", "Undersheriff"],
  ["Chief", "Sheriff"],
]);
const MIL_RANKS = Object.freeze([
  "",
  "Private",
  "Corporal",
  "Sergeant",
  "Lieutenant",
  "Captain",
  "Major",
  "Colonel",
  "General",
]);

function addTrait(map, name, start) {
  if (!map[name]) {
    map[name] = start;
  }
}

function train(profession, character, points) {
  var map = {};
  profession.seeds.forEach(function (seed) {
    if (character.trait(seed) < MAX_TRAIT_VALUE) {
      addTrait(map, seed, 1);
      points--;
    }
  });
  // Force grouping into a subset of supplemental skills, enough to
  // assign all the points (at a limit of 4)
  var limit = Math.ceil(points / 4) - profession.seeds.length + roll("1d2");
  shuffle(profession.traits);
  profession.traits.forEach(function (trait, index) {
    if (index <= limit) {
      addTrait(map, trait, 0);
    }
  });
  var allTraitNames = Object.keys(map);

  // Okay, the starting points, minus the mandatory seed points, can
  // be less than zero. So test explicitly for that.
  while (points > 0 && allTraitNames.length) {
    var traitName = random(allTraitNames);
    // prevents an infinite loop if points exceeds sum of max of all traits, and
    // also ensures that existing trait values are accounted for when honoring
    // maximum values.
    if (map[traitName] + character.trait(traitName) >= MAX_TRAIT_VALUE) {
      allTraitNames = without(allTraitNames, traitName);
      continue;
    }
    map[traitName]++;
    points--;
  }
  for (var prop in map) {
    if (map[prop] > 0) {
      character.changeTrait(prop, map[prop]);
    }
  }
  // All the tags of the profession are carried forward in the character,
  // and the character can be queried rather than keeping the profession
  // around.
  profession.tags.forEach((tag) => character.tags.add(tag));
  console.log(character.tags);

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

// The gang generator uses this...it needs a clean-up
export function assignRank(profession, character) {
  var name = profession.names[0];
  var rank = null;
  // TODO: Could use gaussian spread here.
  var level = Math.round(
    Math.max(character.trait("Military"), character.trait("Government")) * 1.5
  );
  // TODO: Could use a table here.
  if (name === "Navy" || name === "Coast Guard") {
    rank = NAVY_RANKS[roll(level)];
  } else if (name === "Police") {
    rank = POLICE_RANKS[roll(level)];
    if (rank instanceof Array) {
      rank = random(rank);
    }
  } else {
    rank = MIL_RANKS[roll(level)];
  }
  character.honorific = rank ? name + " " + rank : name;
}

function trainAndEquipChild(character) {
  delete character.profession;
  delete character.honorific;
  // make a child instead.
  if (character.age > 4) {
    train(INNATE, character, roll("1d4-1"));
    // But don’t call children "attractive" or "charming".
    delete character.traits[T.ATTRACTIVE];
    delete character.traits[T.CHARMING];
  }
}

function trainAndEquipAdult(character, profession, params) {
  let startingTraitPoints = sum(Object.values(params.traits));
  console.debug("startingTraitPoints", startingTraitPoints);
  let traitPoints = 8 + ~~(params.age / 10) - startingTraitPoints;
  console.debug("traitPoints", traitPoints);

  let innateTraitPoints = roll("2d2-1");
  console.debug("innateTraitPoints", innateTraitPoints);
  train(INNATE, character, innateTraitPoints);

  // 27 is arbitrary, it gives the character a couple of years to have had a profession.
  // For higher-status professions like a doctor, cutoff is 30.
  let pre = professionDb.findOne({ tags: "pre" });
  console.debug("pre", pre.names[0]);
  // Your social status before the war determines your status afterwards. (always?)
  let prestige = pre.typeOf("prestige");
  let post = profession || professionDb.findOne({ tags: prestige + " post" });
  let cutoffAge = pre.is("high") ? 30 : 27;
  console.debug("post", post.names[0], "cut-off age", cutoffAge);

  if (/*post.not("pre") && */ character.age > cutoffAge) {
    console.debug("character has pre-war experience");
    let weight = (character.age - 10) / character.age;
    let prePoints = Math.floor(traitPoints * weight);
    let postPoints = traitPoints - prePoints;

    // two profession, pre/post war
    train(pre, character, prePoints);
    // transfer their pre-war title, if any, into their history and remove it from
    // their current designation
    let title = character.honorific ? character.honorific : random(pre.names);
    let hsy = random(HISTORIES);
    if (title === "dr") {
      title = "doctor";
    }
    character.description.push(format(hsy, article(title.toLowerCase())) + ".");
    delete character.honorific;

    train(post, character, postPoints);
  } else {
    // just use the post profession
    train(post, character, traitPoints);
  }
  character.profession = random(post.names);

  var bags = createKit({
    profession: post.names[0],
    gender: character.gender,
    traveling: params.possessions === "Traveling",
  });
  character.clothing = bags.clothing;
  if (params.possessions !== "None") {
    character.possessions = bags.possessions;
  }
}

/**
 * Post collapse professions only.
 * @returns a list of profession names
 */
export function getProfessions() {
  return professionDb
    .findAll({ tags: "post" })
    .map((row) => row.names[0])
    .concat("Soldier")
    .sort();
}

export function createHeritage() {
  return test(20) ? "latino" : "anglo";
}

export function createGender() {
  return random(["male", "female"]);
}

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

export function createCharacter({
  possessions = "None",
  age = roll("14+3d12"),
  traits = {},
  gender = createGender(),
  heritage = createHeritage(), // duplicated in naming function
  name,
  profession = professionDb.findOne(),
} = {}) {
  logger.start("createCharacter", { possessions, age, traits, gender, heritage, name, profession });

  // Name can be passed as a partially constructed argument (in family, it provides only the
  // last name, for example). Fully constructed it here if needed.
  name = createCharacterName({ ...name, gender, heritage });

  // if profession was provided as a string, it's the name of a profession, so get that
  if (typeof profession === "string") {
    profession = professionDb.findOne({ name: profession });
  }

  let params = { gender, heritage, name, age, traits, profession };
  let character = new Character(params);
  if (character.age < 17) {
    trainAndEquipChild(character);
  } else {
    // Not tailored towards children... may eventually be fixed
    character.description.push(createAppearance({ character }));

    trainAndEquipAdult(character, profession, { ...params, possessions });
  }

  if (character.heritage === "latino") {
    // Latinos may also speak some spanish outside of training (of course this is
    // true of others, and not covered...maybe we need different innate "professions" for
    // things like studying abroad).
    character.changeTrait("Spanish", nonNegativeGaussian(1.5));
  }
  return logger.end(character);
}