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);
}