Files
l5rx-chiaroscuro/system/scripts/actors/character-generator.js
2022-02-25 10:17:28 +01:00

676 lines
22 KiB
JavaScript

/**
* L5R Character generator base object
*/
export class CharacterGenerator {
/**
* Base age (minimal)
*/
static baseAge = 15;
/**
* Payload Object
*/
data = {
avgRingsValue: 3, // 1-5
clan: "random",
family: "",
gender: "male",
age: CharacterGenerator.baseAge,
maritalStatus: "",
};
/**
* Initialize the generator
* @param {number} avgRingsValue number between 1 and 5
* @param {string} clanName random|crab|crane...
* @param {string} gender random|male|female
*/
constructor({ avgRingsValue = 3, clanName = "random", gender = "random" }) {
if (!CONFIG.l5r5e.families.has(clanName)) {
clanName = "random";
}
if (clanName === "random") {
clanName = CharacterGenerator._getRandomArrayValue(Array.from(CONFIG.l5r5e.families.keys()));
}
if (gender === "random" || !["male", "female"].includes(gender)) {
gender = Math.random() > 0.5 ? "male" : "female";
}
this.data.avgRingsValue = CharacterGenerator.sanitizeMinMax(avgRingsValue);
this.data.clan = clanName;
this.data.family = CharacterGenerator._getRandomFamily(clanName);
this.data.gender = gender;
}
/**
* Return true if the gender is Female
* @return {boolean}
*/
get isFemale() {
return this.data.gender === "female";
}
/**
* Return a random value for this array
* @param {String[]} array
* @return {String}
* @private
*/
static _getRandomArrayValue(array) {
return array[Math.floor(Math.random() * array.length)] ?? "";
}
/**
* Return a random value between min and max
* @param {number} min
* @param {number} max
* @return {number}
* @private
*/
static _randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1) + min);
}
/**
* Always return a number between 1 and 5
* @param {number} number
* @return {number}
*/
static sanitizeMinMax(number) {
return Math.min(5, Math.max(1, number));
}
/**
* Return a Item from this pack (by id if provided, or random otherwise)
* @param {string} packName
* @param {string} id
* @return {Promise<Document|undefined>}
* @private
*/
static async _getItemFromPack(packName, id = null) {
const comp = await game.packs.get(packName);
if (!comp) {
console.log(`L5R5E | Pack not found[${packName}]`);
return;
}
if (id) {
return comp.getDocument(id);
}
if (!comp.indexed) {
await comp.getDocuments();
}
return comp.getDocument(CharacterGenerator._getRandomArrayValue(Array.from(comp.keys())));
}
/**
* Generate and return a family name for this clan
* @param {string} clanName
* @return {string}
* @private
*/
static _getRandomFamily(clanName) {
let originClan = clanName;
// Ronin specific, can be any other family name
if (clanName === "ronin") {
originClan = CharacterGenerator._getRandomArrayValue(
Array.from(CONFIG.l5r5e.families.keys()).filter((e) => e !== "ronin")
);
}
return CharacterGenerator._getRandomArrayValue(CONFIG.l5r5e.families.get(originClan));
}
/**
* Generate and return a firstname
* @return {Promise<string>}
*/
static async getRandomizedFirstname(isFemale, clan) {
let table = `Japanese names (${isFemale ? "Female" : "Male"})`;
switch (clan) {
case "ivory_kingdoms":
table = "Ivory Kingdoms names";
break;
case "qamarist":
table = "Qamarist names";
break;
case "ujik":
table = "Ujik names";
break;
}
const randomNames = await game.l5r5e.HelpersL5r5e.drawManyFromPack("l5r5e.core-name-tables", table, 1, {
displayChat: false,
});
return randomNames?.results[0]?.data.text || "";
}
/**
* Generate the actor age
* @param {number} avgRingsValue
* @return {number}
*/
static genAge(avgRingsValue) {
return CharacterGenerator._randomInt(
CharacterGenerator.baseAge,
avgRingsValue * 10 + CharacterGenerator.baseAge
);
}
/**
* Return the marriage state
* @param {number} age
* @return {string} unmarried|betrothed|married|widowed
*/
static genMaritalStatus(age) {
const rng = Math.random();
if (age < 20) {
return rng < 0.1 ? "married" : rng < 0.4 ? "betrothed" : "unmarried";
}
if (age < 30) {
return rng < 0.4 ? "married" : rng < 0.7 ? "betrothed" : "unmarried";
}
return rng < 0.8 ? "married" : rng < 0.9 ? "widowed" : "unmarried";
}
/**
* Generate the marital partner
* @param {string} maritalStatus unmarried|betrothed|married|widowed
* @param {number} avgRingsValue
* @param {string} clan
* @param {string} family
* @param {boolean} isFemale
* @return {Promise<{age: number, name: string, clan: string, family: string, female: boolean}>}
*/
static async genMaritalPartner(maritalStatus, avgRingsValue, clan, family, isFemale) {
const alreadyMerged = maritalStatus !== "betrothed";
const partner = {
age: CharacterGenerator.genAge(avgRingsValue),
clan: "",
family: "",
female: Math.random() > 0.9 ? isFemale : !isFemale,
name: "",
};
partner.clan =
alreadyMerged || Math.random() > 0.7
? clan
: CharacterGenerator._getRandomArrayValue(Array.from(CONFIG.l5r5e.families.keys()));
partner.family = alreadyMerged ? family : CharacterGenerator._getRandomFamily(partner.clan);
partner.name = await CharacterGenerator.getRandomizedFirstname(partner.female, partner.clan);
return partner;
}
/**
* Generate children
* @param {number} age Current npc age
* @param {string} clan Current npc clan
* @return {Promise<string[]>}
*/
static async genChildren(age, clan) {
const childs = [];
let ageLoop = Math.max(0, age - CharacterGenerator.baseAge - 1);
while (ageLoop > 0) {
const childAge = CharacterGenerator._randomInt(1, ageLoop);
if (Math.random() > 0.66) {
const childIsFemale = Math.random() > 0.5;
const childName = await CharacterGenerator.getRandomizedFirstname(childIsFemale, clan);
childs.push(
`${childName} (${childAge}, ${game.i18n.localize(
"l5r5e.social.gender." + (childIsFemale ? "female" : "male")
)})`
);
}
ageLoop -= childAge + 1;
}
return childs;
}
/**
* Generate Honor, Glory and Status values
* @param {number} age
* @param {string} clan
* @return {{honor: number, glory: number, status: number}}
*/
static genSocialStanding(age, clan) {
const karma = Math.random() < 0.66 ? 1 : -1;
const rng = (initial, variation) => {
return initial + CharacterGenerator._randomInt(5, variation) * karma;
};
let honor = rng(34, age / 2);
switch (clan) {
case "lion":
honor += 10;
break;
case "scorpion":
honor -= 10;
break;
}
return {
honor,
glory: rng(40, age / 3),
status: rng(30, age / 4),
};
}
/**
* Modify the current actor datas with selected options
*
* @param {ActorL5r5e} actor Actor object
* @param {Object} generate
* @param {boolean} generate.name If true generate a new name
* @param {boolean} generate.identity If true generate Clan, Gender, Age, Marital status
* @param {boolean} generate.attributes If true generate Rings, attributes, skills and confrontation ranks
* @param {boolean} generate.demeanor If true generate Demeanor and rings affinities
* @param {boolean} generate.peculiarities If true generate Advantage and Disadvantage
* @param {boolean} generate.items If true generate Armor, Weapons and Items
* @param {boolean} generate.techniques If true generate Shuji, Katas...
* @param {boolean} generate.narrative If true generate Narrative and fluff
* @return {Promise<Object>}
*/
async toActor(
actor,
generate = {
name: true,
attributes: true,
identity: true,
demeanor: true,
peculiarities: true,
items: true,
techniques: true,
narrative: true,
}
) {
const actorDatas = actor.data.data;
const isNpc = actor.type === "npc";
// Need to set some required values
this.data.age = actorDatas.identity.age || CharacterGenerator.genAge(this.data.avgRingsValue);
this.data.maritalStatus =
actorDatas.identity.marital_status || CharacterGenerator.genMaritalStatus(this.data.age);
actorDatas.identity.clan = this.data.clan;
actorDatas.identity.family = this.data.family;
actorDatas.identity.female = this.isFemale;
// Name
let newName = actor.data.name;
if (generate.name) {
newName =
this.data.family +
" " +
(await CharacterGenerator.getRandomizedFirstname(this.isFemale, this.data.clan));
}
// Identity
if (generate.identity) {
actorDatas.identity.age = CharacterGenerator.genAge(this.data.avgRingsValue);
actorDatas.identity.marital_status = CharacterGenerator.genMaritalStatus(this.data.age);
await this._generateNotes(actorDatas);
}
// Img (only if system defaults)
const folder = "systems/l5r5e/assets/icons/actors";
const newImg = [
`${folder}/npc.svg`,
`${folder}/traditional-japanese-man.svg`,
`${folder}/traditional-japanese-woman.svg`,
].includes(actor.data.img)
? `${folder}/traditional-japanese-${this.isFemale ? "woman" : "man"}.svg`
: actor.data.img;
// Generate attributes & Social Standing
if (generate.attributes) {
// Generate attributes (rings, attributes, skills, confrontation ranks)
this._generateAttributes(isNpc, actorDatas);
// Social Standing
const social = CharacterGenerator.genSocialStanding(this.data.age, this.data.clan);
actorDatas.social.honor = social.honor;
actorDatas.social.glory = social.glory;
actorDatas.social.status = social.status;
}
// Demeanor (npc only)
if (isNpc && generate.demeanor) {
this._generateDemeanor(actorDatas);
}
// Item types
if (generate.peculiarities || generate.items || generate.techniques) {
const newItemsData = [];
// Advantage / Disadvantage
if (generate.peculiarities) {
await this._generatePeculiarities(actor, newItemsData);
}
// Items
if (generate.items) {
await this._generateItems(actor, newItemsData);
}
// Techniques
if (generate.techniques) {
await this._generateTechniques(actor, newItemsData);
}
// Add to actor
if (newItemsData.length > 0) {
await actor.createEmbeddedDocuments("Item", newItemsData);
}
}
// Narrative
if (generate.narrative) {
this._generateNarrative(actorDatas);
}
// return actor data
return {
img: newImg,
name: newName,
data: actorDatas,
};
}
//<editor-fold desc="toActor generators">
/**
* Generate attributes (rings, attributes, skills, confrontation ranks)
* @param {boolean} isNpc
* @param {DocumentData.data} actorDatas
* @private
*/
_generateAttributes(isNpc, actorDatas) {
const stats = { min: 5, max: 1 };
// Rings
CONFIG.l5r5e.stances.forEach((ringName) => {
// avgRingsValue + (-1|0|1)
actorDatas.rings[ringName] = CharacterGenerator.sanitizeMinMax(
this.data.avgRingsValue - 1 + Math.floor(Math.random() * 3)
);
stats.min = Math.min(stats.min, actorDatas.rings[ringName]);
stats.max = Math.max(stats.max, actorDatas.rings[ringName]);
});
// Attributes
game.l5r5e.ActorL5r5e.computeDerivedAttributes(actorDatas);
// Skills
if (isNpc) {
Object.keys(actorDatas.skills).forEach(
(skillName) => (actorDatas.skills[skillName] = Math.floor(Math.random() * stats.max))
);
} else {
// TODO PC skills
}
// Confrontation ranks (npc only)
if (isNpc) {
actorDatas.conflict_rank.martial = this.data.avgRingsValue + actorDatas.skills.martial;
actorDatas.conflict_rank.social = this.data.avgRingsValue + actorDatas.skills.social;
}
}
/**
* Generate Demeanor (npc only)
* @param {DocumentData.data} actorDatas
* @private
*/
_generateDemeanor(actorDatas) {
// demeanor { id: "adaptable", mod: { fire: 2, earth: -2 } },
const demeanor = CharacterGenerator._getRandomArrayValue(CONFIG.l5r5e.demeanorList);
actorDatas.attitude = game.i18n.localize("l5r5e.demeanor." + demeanor.id);
actorDatas.rings_affinities = foundry.utils.mergeObject(
{
earth: 0,
air: 0,
water: 0,
fire: 0,
void: 0,
},
demeanor.mod
);
}
/**
* Generate Advantages and Disadvantages
* @param {ActorL5r5e} actor
* @param {DocumentData[]} newItemsData
* @return {Promise<void>}
* @private
*/
async _generatePeculiarities(actor, newItemsData) {
// Clear actor peculiarities
const deleteIds = actor.data.items.filter((e) => e.type === "peculiarity").map((e) => e.id);
if (deleteIds.length > 0) {
await actor.deleteEmbeddedDocuments("Item", deleteIds);
}
// Add 1 each peculiarity
for (const pack of ["adversities", "distinctions", "passions", "anxieties"]) {
const item = await CharacterGenerator._getItemFromPack(`l5r5e.core-peculiarities-${pack}`);
if (item) {
newItemsData.push(foundry.utils.duplicate(item.data));
}
}
}
/**
* Generate Armor, Weapons, Items
* @param {ActorL5r5e} actor
* @param {DocumentData[]} newItemsData
* @return {Promise<void>}
* @private
*/
async _generateItems(actor, newItemsData) {
// Clear actor items
const deleteIds = actor.data.items.filter((e) => ["armor", "weapon", "item"].includes(e.type)).map((e) => e.id);
if (deleteIds.length > 0) {
await actor.deleteEmbeddedDocuments("Item", deleteIds);
}
// Items
const itemCfg = {
armors: [
"L5RCoreArm000009", // Common Clothes
],
weapons: [
"L5RCoreWea000036", // Punch
"L5RCoreWea000037", // Kick
"L5RCoreWea000009", // Wakizashi
"L5RCoreWea000007", // Katana
"L5RCoreWea000019", // Knife
],
items: [
null, // Random item
],
};
if (this.data.clan === "crab") {
itemCfg.armors.push("L5RCoreArm000001"); // Ashigaru Armor
itemCfg.weapons.push("L5RCoreWea000017"); // Tetsubō
}
for (const pack in itemCfg) {
for (const itemId of itemCfg[pack]) {
const item = await CharacterGenerator._getItemFromPack(`l5r5e.core-${pack}`, itemId);
if (item) {
newItemsData.push(foundry.utils.duplicate(item.data));
}
}
}
}
/**
* Generate Techniques
* @param {ActorL5r5e} actor
* @param {DocumentData[]} newItemsData
* @return {Promise<void>}
* @private
*/
async _generateTechniques(actor, newItemsData) {
// Clear actor items
const deleteIds = actor.data.items.filter((e) => e.type === "technique").map((e) => e.id);
if (deleteIds.length > 0) {
await actor.deleteEmbeddedDocuments("Item", deleteIds);
}
const avgrv = this.data.avgRingsValue;
/**
* Techs config
*
* exemple: {
* probability: .7,
* skill: {
* grp_name: "scholar",
* value_min: 1,
* },
* qty: {
* min: 1,
* max: avgrv,
* },
* },
*/
const techCfg = {
kata: {
probability: 1,
skill: {
grp_name: "martial",
value_min: 1,
},
qty: {
min: 1,
max: avgrv,
},
},
kiho: {
probability: 0.1,
skill: {
grp_name: "martial",
value_min: 1,
},
},
ninjutsu: {
probability: 0.1,
skill: {
grp_name: "martial",
value_min: 1,
},
},
shuji: {
probability: 1,
qty: {
min: 1,
},
},
rituals: {
probability: 0.2,
},
maho: {
probability: 0.1,
},
invocations: {
probability: 0.3,
skill: {
grp_name: "scholar",
value_min: 1,
},
qty: {
min: 2,
max: Math.max(2, avgrv),
},
},
};
for (const pack in techCfg) {
const cfg = techCfg[pack];
// Minimum skill required (npc only for now)
if (!!cfg.skill && actor.data.data.skills[cfg.skill.grp_name] < cfg.skill.value_min) {
continue;
}
// Check probabilities to have more than min qty
let qtyMax = cfg.qty?.min ?? 0;
if (Math.random() < cfg.probability) {
qtyMax = CharacterGenerator._randomInt(cfg.qty?.min ?? 0, cfg.qty?.max ?? avgrv);
}
for (let qty = 0; qty < qtyMax; qty++) {
// Rank is limited by avgRingsValue
let item;
do {
item = await CharacterGenerator._getItemFromPack(`l5r5e.core-techniques-${pack}`);
} while (item && item.data.data.rank > avgrv);
if (item) {
newItemsData.push(foundry.utils.duplicate(item.data));
}
} // fr qty
} // fr techCfg
}
/**
* Fill notes with some values that don't appear in sheet
* @param {DocumentData.data} actorDatas
* @return {Promise<void>}
* @private
*/
async _generateNotes(actorDatas) {
actorDatas.notes =
`${game.i18n.localize("l5r5e.social.age")}: ${this.data.age}</p>` +
`<p>${game.i18n.localize("l5r5e.social.gender.title")}: ${game.i18n.localize(
"l5r5e.social.gender." + this.data.gender
)}</p>` +
`<p>${game.i18n.localize("l5r5e.clan")}: ${game.i18n.localize("l5r5e.clans." + this.data.clan)}</p>` +
`<p>${game.i18n.localize("l5r5e.social.marital_status.title")}: ${game.i18n.localize(
"l5r5e.social.marital_status." + this.data.maritalStatus
)}</p>`;
// Define partner identity
if (this.data.maritalStatus !== "unmarried") {
const partner = await CharacterGenerator.genMaritalPartner(
this.data.maritalStatus,
this.data.avgRingsValue,
this.data.clan,
this.data.family,
this.isFemale
);
actorDatas.notes +=
"<p>" +
`${game.i18n.localize("l5r5e.social.marital_status.partner")}:` +
` ${partner.family} ${partner.name}` +
` (${partner.age}, ${game.i18n.localize(
"l5r5e.social.gender." + (partner.female ? "female" : "male")
)})` +
"</p>";
// Childs
const childs = await CharacterGenerator.genChildren(Math.min(this.data.age, partner.age), this.data.clan);
if (childs.length > 0) {
actorDatas.notes += `<p>${game.i18n.localize("l5r5e.social.children")}: ${childs.join(", ")}</p>`;
}
}
}
/**
* Generate Narrative fluff
* @param {DocumentData.data} actorDatas
* @private
*/
_generateNarrative(actorDatas) {
// TODO generateNarrative
// actorDatas.description = '';
}
//</editor-fold>
}