feat: culture description generator

This commit is contained in:
Lukas Obermann
2026-02-22 16:24:46 +01:00
parent 7a0d7edc69
commit d1fa45ef4c
9 changed files with 1063 additions and 148 deletions
+69 -2
View File
@@ -2,6 +2,7 @@ import { isNotNullish } from "@elyukai/utils/nullable"
import { assertExhaustive } from "@elyukai/utils/typeSafety"
import { deepEqual } from "@optolith/helpers/compare"
import { MessageFormat } from "messageformat"
import type { MessageValue } from "messageformat/functions"
import { findPackageJSON } from "node:module"
import { dirname, join } from "node:path"
import { argv } from "node:process"
@@ -76,6 +77,69 @@ const localeEnv: LocaleEnvironment = {
translate: (key, ...rest) =>
new MessageFormat(localeId, localeInstance.translations?.[key] ?? key, {
bidiIsolation: "none",
functions: {
list: (ctx, options, input): MessageValue<"list"> => {
if (!Array.isArray(input)) {
ctx.onError(
new RangeError("Input for list function must be an array"),
)
return {
type: "list",
options,
}
} else {
switch (options.type) {
case "conjunction": {
const value = conjunctionListFormat.format(input)
return {
type: "list",
options,
toString() {
return value
},
valueOf() {
return value
},
}
}
case "disjunction": {
const value = disjunctionListFormat.format(input)
return {
type: "list",
options,
toString() {
return value
},
valueOf() {
return value
},
}
}
case "unit": {
const value = unitListFormat.format(input)
return {
type: "list",
options,
toString() {
return value
},
valueOf() {
return value
},
}
}
default:
ctx.onError(
new RangeError("Unsupported list type: ${options.type}"),
)
return {
type: "list",
options,
}
}
}
},
},
}).format(rest[0] as Record<string, unknown> | undefined),
translateMap: translations => translations?.[localeId],
measurementAdjustments: {
@@ -196,7 +260,7 @@ const logSection = (
switch (section.type) {
case "labeled":
console.log(indent + styleText("bold", section.label))
logSection(section.value, level, indent + " ")
logSection(section.value, level, indent)
break
case "plain":
console.log(indent + section.text)
@@ -216,7 +280,10 @@ const logSection = (
styleText(actualLevel > 1 ? "italic" : "bold", item.label + ":"),
)
if (Array.isArray(item.value)) {
item.value.forEach(subsection => {
item.value.forEach((subsection, subsectionIndex) => {
if (subsectionIndex > 0) {
console.log()
}
logSection(subsection, actualLevel + 1, listIndent + " ")
})
} else {
+692
View File
@@ -0,0 +1,692 @@
import { allSame } from "@elyukai/utils/array/filters"
import { ensureNonEmpty, isNotEmpty } from "@elyukai/utils/array/nonEmpty"
import { sumWith } from "@elyukai/utils/array/reductions"
import { equal } from "@elyukai/utils/equality"
import { identity, on } from "@elyukai/utils/function"
import { isNotNullish, mapNullable } from "@elyukai/utils/nullable"
import { Reader } from "@elyukai/utils/reader"
import { sign } from "@elyukai/utils/string/number"
import { assertExhaustive } from "@elyukai/utils/typeSafety"
import type {
BlessedTraditionConstraint,
CommonNames,
CommonProfessionConstraints,
CommonProfessionConstraintsOperation,
CommonProfessions,
CulturalPackageItem,
MagicalTraditionConstraint,
ProfessionConstraint,
Rarity,
Skill_ID,
Weighted,
} from "optolith-database-schema/gen"
import { createEntityDescriptionCreator } from "../creator.js"
import type {
GetAllChildInstancesForParent,
GetInstanceById,
} from "../helpers/getTypes.js"
import type { TranslationKeysWithoutParams } from "../helpers/translate.js"
import type {
RawDefinitionListEntityDescriptionSectionItem,
RawEntityDescription,
RawEntityDescriptionSectionContent,
RawNestedDefinitionListEntityDescriptionSection,
} from "../index.js"
import {
renderCommonnessRatedAdvantagesOrDisadvantages,
renderValueWithPossibleTranslation,
} from "./partial/commonnessRatedAdvantagesAndDisadvantages.js"
import { getProfessionName } from "./partial/professions.js"
import { parensIf } from "./partial/rated/activatable/parensIf.js"
import { calculateAdventurePointsFromImprovementCost } from "./partial/rated/improvementCost.js"
import {
translateR,
type EnvMap,
type StdEnv,
type StdReader,
} from "./partial/reader.js"
import { MISSING_VALUE } from "./partial/unknown.js"
const renderListOperation = (
operation: CommonProfessionConstraintsOperation,
list: string[],
): StdReader<string, "t"> => {
switch (operation.kind) {
case "Intersection":
return Reader.of(list.join(", "))
case "Difference":
return translateR(
"all but {$excludedProfessions :list type=conjunction}",
{ excludedProfessions: list },
)
default:
return assertExhaustive(operation)
}
}
const renderCommonProfessionConstraints = <T>(
constraints: CommonProfessionConstraints<T>,
renderConstraint: (
constraint: T,
) => StdReader<
string | undefined,
"t" | "tm" | "lc" | "ibi",
"ProfessionVariant" | "BlessedTradition" | "MagicalTradition"
>,
): StdReader<
string,
"t" | "tm" | "lc" | "ibi",
"ProfessionVariant" | "BlessedTradition" | "MagicalTradition"
> =>
Reader.asks(({ localeCompare }: StdEnv<"lc">) =>
Reader.sequence(constraints.constraints.map(renderConstraint)).thenW(
constraintValues =>
renderListOperation(
constraints.operation,
constraintValues.filter(isNotNullish).toSorted(localeCompare),
),
),
).thenW(identity)
const renderCommonProfessionGroup = <T>(
label: TranslationKeysWithoutParams,
constraints: CommonProfessionConstraints<T> | undefined,
renderConstraint: (
constraint: T,
) => StdReader<
string | undefined,
"t" | "tm" | "lc" | "ibi",
"ProfessionVariant" | "BlessedTradition" | "MagicalTradition"
>,
) =>
translateR(label).thenW(translatedLabel =>
(constraints === undefined
? Reader.of("—")
: renderCommonProfessionConstraints(constraints, renderConstraint)
).map(translatedValue => ({
label: translatedLabel,
value: translatedValue,
})),
)
const renderRarity = (
rarity: Rarity | undefined,
): StdReader<string | undefined, "t"> => {
if (rarity === undefined) {
return Reader.of(undefined)
}
switch (rarity.kind) {
case "Rare":
return translateR("rare")
case "VeryRare":
return translateR("very rare")
default:
return assertExhaustive(rarity)
}
}
const renderWeighted = <ID extends string>(
weightedVariants: Weighted<ID> | undefined,
getName: (id: ID) => string | undefined,
): StdReader<string | undefined, "t" | "lc" | "ibi"> => {
if (weightedVariants === undefined) {
return Reader.of(undefined)
}
return Reader.asks(({ translate, localeCompare }) => {
const variants = ensureNonEmpty(
weightedVariants.elements
.map(getName)
.filter(isNotNullish)
.toSorted(localeCompare),
)
if (variants === undefined) {
return undefined
}
switch (weightedVariants.weight.kind) {
case "Mostly":
return translate("mostly {$variants :list type=conjunction}", {
variants,
})
case "Only":
return translate("only {$variants :list type=conjunction}", {
variants,
})
default:
return assertExhaustive(weightedVariants.weight)
}
})
}
const renderProfessionConstraint = (
getChildInstancesForInstanceId: GetAllChildInstancesForParent<"ProfessionVersion">,
constraint: ProfessionConstraint,
) =>
Reader.asks(({ translateMap }: StdEnv<"tm">) =>
getProfessionName(
translateMap,
getChildInstancesForInstanceId,
constraint.id,
),
).thenW(baseName =>
baseName === undefined
? Reader.of(undefined)
: Reader.sequence<
StdEnv<"t" | "tm" | "lc" | "ibi", "ProfessionVariant">,
string | undefined
>([
renderRarity(constraint.rarity),
Reader.asks(
({
translateMap,
getInstanceById,
}: StdEnv<"tm" | "ibi", "ProfessionVariant">) =>
renderWeighted(
constraint.weighted_variants,
variantId =>
translateMap(
getInstanceById("ProfessionVariant", variantId)
?.translations,
)?.name.default,
),
).thenW(identity),
]).map(
notes =>
baseName +
parensIf(ensureNonEmpty(notes.filter(isNotNullish))?.join("; ")),
),
)
const renderTraditionConstraint = <
E extends "MagicalTradition" | "BlessedTradition",
>(
getChildInstancesForInstanceId: GetAllChildInstancesForParent<"ProfessionVersion">,
entity: E,
constraint: MagicalTraditionConstraint | BlessedTraditionConstraint,
): StdReader<string | undefined, "t" | "tm" | "lc" | "ibi", E> =>
Reader.asks(({ translateMap, getInstanceById }: StdEnv<"tm" | "ibi", E>) => {
const translation = translateMap<{
name: string
nameOfBlessedOnes?: string
}>(getInstanceById(entity, constraint.id)?.translations)
return translation?.nameOfBlessedOnes ?? translation?.name
}).thenW(baseName =>
baseName === undefined
? Reader.of(undefined)
: Reader.sequence([
renderRarity(constraint.rarity),
Reader.asks(({ translateMap }: StdEnv<"tm">) =>
renderWeighted(constraint.weighted_professions, profId =>
getProfessionName(
translateMap,
getChildInstancesForInstanceId,
profId,
),
),
).thenW(identity),
]).map(
notes =>
baseName +
parensIf(ensureNonEmpty(notes.filter(isNotNullish))?.join("; ")),
),
)
const renderCommonProfessions = (
getChildInstancesForInstanceId: GetAllChildInstancesForParent<"ProfessionVersion">,
commonProfessions: CommonProfessions,
): StdReader<
| string
| [
RawEntityDescriptionSectionContent<RawNestedDefinitionListEntityDescriptionSection>,
],
"t" | "tm" | "lc" | "ibi",
"ProfessionVariant" | "BlessedTradition" | "MagicalTradition"
> => {
switch (commonProfessions.kind) {
case "Plain":
return renderCommonProfessionConstraints(
commonProfessions.Plain,
profId =>
Reader.asks(({ translateMap }) =>
getProfessionName(
translateMap,
getChildInstancesForInstanceId,
profId,
),
),
)
case "Grouped":
return renderCommonProfessionGroup(
"Mundane Professions",
commonProfessions.Grouped.mundane,
constraint =>
// switch (constraint.kind) {
// case "Profession":
renderProfessionConstraint(
getChildInstancesForInstanceId,
constraint.Profession,
),
// case "ProfessionSubgroup":
// switch (constraint.ProfessionSubgroup.kind) {
// case "Profane":
// case "Fighter":
// case "Religious":
// return Reader.of(UNHANDLED_VALUE)
// default:
// return assertExhaustive(constraint.ProfessionSubgroup)
// }
// default:
// return assertExhaustive(constraint)
// }
).then(mundane =>
renderCommonProfessionGroup(
"Magic Professions",
commonProfessions.Grouped.magic,
constraint => {
switch (constraint.kind) {
case "Profession":
return renderProfessionConstraint(
getChildInstancesForInstanceId,
constraint.Profession,
)
case "Tradition":
return renderTraditionConstraint(
getChildInstancesForInstanceId,
"MagicalTradition",
constraint.Tradition,
)
case "MagicDilettante":
return translateR("Magic Dilettante")
default:
return assertExhaustive(constraint)
}
},
).then(magic =>
renderCommonProfessionGroup(
"Blessed Professions",
commonProfessions.Grouped.blessed,
constraint =>
renderTraditionConstraint(
getChildInstancesForInstanceId,
"BlessedTradition",
constraint.Tradition,
),
).map(blessed => [
{
type: "definitionList",
style: "nested",
items: [mundane, magic, blessed],
},
]),
),
)
default:
return assertExhaustive(commonProfessions)
}
}
const renderCommonSkills = (
items: Skill_ID[] | undefined,
): StdReader<string, "t" | "tm" | "lc" | "ibi", "Skill"> =>
Reader.asks(({ translate, translateMap, getInstanceById, localeCompare }) =>
items === undefined || !isNotEmpty(items)
? translate("none")
: items
.map(
itemId =>
translateMap(getInstanceById("Skill", itemId)?.translations)
?.name,
)
.filter(isNotNullish)
.toSorted(localeCompare)
.join(", "),
)
const renderCommonNames = (
commonNames: CommonNames,
): StdReader<
(
| RawEntityDescriptionSectionContent<RawNestedDefinitionListEntityDescriptionSection>
| undefined
)[],
"lc"
> =>
Reader.asks(({ localeCompare }) => {
const groups = [
...(commonNames.first_name_groups ?? []),
...(commonNames.last_name_groups ?? []),
].map(
(group): RawDefinitionListEntityDescriptionSectionItem => ({
label: group.label,
value: group.names
.map(name => name.name + parensIf(name.note))
.toSorted(localeCompare)
.join(", "),
}),
)
const specialRules = commonNames.naming_rules
return [
mapNullable(ensureNonEmpty(groups), renderedGroups => ({
type: "definitionList",
style: "nested",
items: renderedGroups,
})),
mapNullable(specialRules, rules => ({
type: "plain",
text: rules,
})),
]
})
const renderCulturalPackage = (
items: CulturalPackageItem[],
): StdReader<
{ text: string; apValue: number },
"t" | "tm" | "lc" | "ibi",
"Skill"
> =>
Reader.asks(({ translate, translateMap, getInstanceById, localeCompare }) => {
if (!isNotEmpty(items)) {
return { text: translate("none"), apValue: 0 }
}
const processedItems = items.map(
(item): [text: string, apValue: number] => {
const instance = getInstanceById("Skill", item.id)
const instanceTranslation = translateMap(instance?.translations)
if (instance === undefined || instanceTranslation === undefined) {
return [MISSING_VALUE, 0]
}
return [
`${instanceTranslation.name ?? MISSING_VALUE} ${sign(item.points)}`,
calculateAdventurePointsFromImprovementCost(
instance.improvement_cost,
[1, item.points],
),
]
},
)
return {
text: processedItems
.map(([text]) => text)
.toSorted(localeCompare)
.join(", "),
apValue: sumWith(processedItems, ([, apValue]) => apValue),
}
})
/**
* Get a JSON representation of the rules text for a culture.
*/
export const getCultureEntityDescription = createEntityDescriptionCreator<
"Culture",
{
getInstanceById: GetInstanceById<
| "Publication"
| "Advantage"
| "Disadvantage"
| "Skill"
| "Language"
| "LanguageSpecialization"
| "Script"
| "SocialStatus"
| "ProfessionVariant"
| "MagicalTradition"
| "BlessedTradition"
>
getChildInstancesForInstanceId: GetAllChildInstancesForParent<"ProfessionVersion">
}
>(
(
{ getInstanceById, getChildInstancesForInstanceId },
{ translate, translateMap, compare: localeCompare, join: localeJoin },
{ content: entry },
): RawEntityDescription | undefined => {
const translation = translateMap(entry.translations)
if (translation === undefined) {
return undefined
}
const env = {
translate,
translateMap,
localeCompare,
getInstanceById,
} satisfies Partial<EnvMap>
const { text: culturePackageText, apValue: culturalPackageApValue } =
renderCulturalPackage(entry.cultural_package).run(env)
return {
title: translation.name,
className: "culture",
body: [
{
type: "definitionList",
items: [
{
label: translate("Language"),
value: localeJoin(
entry.language
.map(lang => {
const language = getInstanceById("Language", lang.id)
const languageTranslation = translateMap(
language?.translations,
)
if (
language === undefined ||
languageTranslation === undefined
) {
return MISSING_VALUE
}
return (
languageTranslation.name +
parensIf(
[
translateMap(
language.customSpecializations?.translations,
)?.description,
...(lang.specializations?.map(
specId =>
translateMap(
getInstanceById(
"LanguageSpecialization",
specId,
)?.translations,
)?.name,
) ?? []),
]
.filter(isNotNullish)
.toSorted(localeCompare)
.join(", "),
)
)
})
.toSorted(localeCompare),
"disjunction",
),
},
{
label: translate("Script"),
value:
entry.script === undefined
? translate("none")
: (() => {
const processedScripts = entry.script.map(
(scriptId): [string, number] => {
const script = getInstanceById("Script", scriptId)
const scriptTranslation = translateMap(
script?.translations,
)
if (
script === undefined ||
scriptTranslation === undefined
) {
return [MISSING_VALUE, 0]
}
return [scriptTranslation.name, script.ap_value ?? 0]
},
)
if (!isNotEmpty(processedScripts)) {
return translate("none")
}
if (
allSame(
processedScripts,
on(([, apValue]) => apValue, equal),
)
) {
return (
localeJoin(
processedScripts
.map(([name]) => name)
.toSorted(localeCompare),
"disjunction",
) +
parensIf(
translate("{$value} AP", {
value: processedScripts[0][1],
}),
)
)
} else {
return localeJoin(
processedScripts
.map(
([name, apValue]) =>
name +
parensIf(
translate("{$value} AP", {
value: apValue,
}),
),
)
.toSorted(localeCompare),
"disjunction",
)
}
})(),
},
{
label: translate("Area Knowledge"),
value:
translation.area_knowledge.description +
(translation.area_knowledge.examples === undefined
? ""
: parensIf(
translate("for example, {$examples}", {
examples: translation.area_knowledge.examples
.map(example => example.area)
.join(", "),
}),
)),
},
{
label: translate("Social Status"),
value: entry.social_status
.map(
status =>
translateMap(
getInstanceById("SocialStatus", status)?.translations,
)?.name ?? MISSING_VALUE,
)
.toSorted(localeCompare)
.join(", "),
},
{
label: translate("Common Professions"),
value: renderCommonProfessions(
getChildInstancesForInstanceId,
entry.common_professions,
).run(env),
},
renderValueWithPossibleTranslation(
"Common Advantages",
entry.common_advantages,
values =>
renderCommonnessRatedAdvantagesOrDisadvantages(
"Advantage",
values,
).run(env),
translation.common_advantages,
).run(env),
renderValueWithPossibleTranslation(
"Common Disadvantages",
entry.common_disadvantages,
values =>
renderCommonnessRatedAdvantagesOrDisadvantages(
"Disadvantage",
values,
).run(env),
translation.common_disadvantages,
).run(env),
renderValueWithPossibleTranslation(
"Uncommon Advantages",
entry.uncommon_advantages,
values =>
renderCommonnessRatedAdvantagesOrDisadvantages(
"Advantage",
values,
).run(env),
translation.uncommon_advantages,
).run(env),
renderValueWithPossibleTranslation(
"Uncommon Disadvantages",
entry.uncommon_disadvantages,
values =>
renderCommonnessRatedAdvantagesOrDisadvantages(
"Disadvantage",
values,
).run(env),
translation.uncommon_disadvantages,
).run(env),
{
label: translate("Common Skills"),
value: renderCommonSkills(entry.common_skills).run(env),
},
{
label: translate("Uncommon Skills"),
value: renderCommonSkills(entry.uncommon_skills).run(env),
},
{
label: translate("Common Names"),
value: renderCommonNames(translation.common_names).run(env),
},
],
},
{
type: "labeled",
label:
translate("Cultural Package {$cultureName}", {
cultureName: translation.name,
}) +
parensIf(
translate("{$value} AP", { value: culturalPackageApValue }),
),
value: {
type: "plain",
text: culturePackageText,
},
},
],
errata: translation.errata,
references: entry.src,
}
},
)
+2 -34
View File
@@ -1,5 +1,4 @@
import { partition } from "@elyukai/utils/array/groups"
import { reduceWhile } from "@elyukai/utils/array/reductions"
import { Dictionary } from "@elyukai/utils/dictionary"
import { deepEqual } from "@elyukai/utils/equality"
import { sign } from "@elyukai/utils/string/number"
@@ -28,6 +27,7 @@ import type {
LabeledEntityDescriptionSection,
RawEntityDescriptionSectionContent,
} from "../index.js"
import { getBaseProfessionPackageForCurriculum } from "./partial/professions.js"
import { parensIf } from "./partial/rated/activatable/parensIf.js"
import { MISSING_VALUE } from "./partial/unknown.js"
@@ -264,38 +264,6 @@ const renderSpellworkAdjustment = (
)?.name,
)}`
const getBaseProfessionPackage = (
getAllInstances: GetAllInstances<"Profession">,
getChildInstancesForInstanceId: GetAllChildInstancesForParent<
"ProfessionVersion" | "ProfessionPackage"
>,
idMap: IdMap,
curriculumId: string,
) => {
const baseProfession = getAllInstances("Profession").find(
profession =>
profession.content.group.kind === "Magical" &&
profession.content.group.Magical.curriculum === curriculumId,
)
if (baseProfession === undefined) {
return undefined
}
return reduceWhile(
getChildInstancesForInstanceId("ProfessionVersion", baseProfession.id),
(_acc: { id: string; content: ProfessionPackage } | undefined, version) =>
getChildInstancesForInstanceId("ProfessionPackage", version.id).find(
professionPackage =>
professionPackage.content.experience_level === undefined ||
professionPackage.content.experience_level ===
idMap.ExperienceLevel.Experienced,
),
isNotNullish,
undefined,
)
}
const renderAbilityAdjustmentName = (
translateMap: TranslateMap,
getInstanceById: GetInstanceById<
@@ -497,7 +465,7 @@ export const getCurriculumEntityDescription = createEntityDescriptionCreator<
return undefined
}
const baseProfessionPackage = getBaseProfessionPackage(
const baseProfessionPackage = getBaseProfessionPackageForCurriculum(
getAllInstances,
getChildInstancesForInstanceId,
idMap,
@@ -0,0 +1,104 @@
import { isNotEmpty } from "@elyukai/utils/array/nonEmpty"
import { mapNullable } from "@elyukai/utils/nullable"
import { Reader } from "@elyukai/utils/reader"
import { ensureNonEmpty } from "@optolith/helpers/array"
import type { CommonnessRatedAdvantageDisadvantage } from "optolith-database-schema/gen"
import type { GetInstanceById } from "../../helpers/getTypes.js"
import type {
TranslateMap,
TranslationKeysWithoutParams,
} from "../../helpers/translate.js"
import type { RawDefinitionListEntityDescriptionSectionItem } from "../../index.js"
import { parensIf } from "./rated/activatable/parensIf.js"
import type { StdReader } from "./reader.js"
import { MISSING_VALUE } from "./unknown.js"
const renderCommonnessRatedAdvantageOrDisadvantageName = <
E extends "Advantage" | "Disadvantage",
>(
translateMap: TranslateMap,
getInstanceById: GetInstanceById<E>,
entity: E,
item: CommonnessRatedAdvantageDisadvantage<string>,
): string => {
const instance = getInstanceById(entity, item.id)
const instanceTranslation = translateMap(instance?.translations)
const customTranslation = translateMap(item.translations)
const name = instanceTranslation?.name_in_library ?? instanceTranslation?.name
return name !== undefined &&
customTranslation?.options !== undefined &&
name.endsWith(")")
? `${name.slice(0, -1)}; ${customTranslation.options})`
: (name ?? MISSING_VALUE) + parensIf(customTranslation?.options)
}
/**
* Render the names of either commonness-rated advantages or commonness-rated disadvantages.
*/
export const renderCommonnessRatedAdvantagesOrDisadvantages = <
E extends "Advantage" | "Disadvantage",
>(
entity: E,
items: CommonnessRatedAdvantageDisadvantage<string>[] | undefined,
): StdReader<string, "t" | "tm" | "lc" | "ibi", E> =>
Reader.asks(({ translate, translateMap, getInstanceById, localeCompare }) =>
items === undefined || !isNotEmpty(items)
? translate("none")
: items
.map(item =>
renderCommonnessRatedAdvantageOrDisadvantageName(
translateMap,
getInstanceById,
entity,
item,
),
)
.toSorted(localeCompare)
.join(", "),
)
/**
* Render the names of commonness-rated advantages and disadvantages together.
*/
export const renderCommonnessRatedAdvantagesAndDisadvantages = (
advantages: CommonnessRatedAdvantageDisadvantage<string>[] | undefined,
disadvantages: CommonnessRatedAdvantageDisadvantage<string>[] | undefined,
): StdReader<string, "t" | "tm" | "lc" | "ibi", "Advantage" | "Disadvantage"> =>
Reader.asks(
({ translate, translateMap, getInstanceById, localeCompare }) =>
mapNullable(
ensureNonEmpty(
[
["Advantage", advantages] as const,
["Disadvantage", disadvantages] as const,
].flatMap(
([entity, items]) =>
items?.map(item =>
renderCommonnessRatedAdvantageOrDisadvantageName(
translateMap,
getInstanceById,
entity,
item,
),
) ?? [],
),
),
renderedItems => renderedItems.toSorted(localeCompare).join(", "),
) ?? translate("none"),
)
/**
* Render a value with a possible translation, falling back to explicity rendering the value if no translation is available.
*/
export const renderValueWithPossibleTranslation = <T>(
label: TranslationKeysWithoutParams,
value: T,
renderValue: (value: T) => string,
valueTranslation: string | undefined,
): StdReader<RawDefinitionListEntityDescriptionSectionItem, "t"> =>
Reader.asks(
({ translate }): RawDefinitionListEntityDescriptionSectionItem => ({
label: translate(label),
value: valueTranslation ?? renderValue(value),
}),
)
+60
View File
@@ -0,0 +1,60 @@
import { reduceWhile } from "@elyukai/utils/array/reductions"
import { isNotNullish } from "@elyukai/utils/nullable"
import type {
Profession_ID,
ProfessionPackage,
} from "optolith-database-schema/gen"
import type {
GetAllChildInstancesForParent,
GetAllInstances,
} from "../../helpers/getTypes.js"
import type { TranslateMap } from "../../helpers/translate.js"
import type { IdMap } from "../../index.js"
/**
* Finds the base profession package for a given curriculum.
*/
export const getBaseProfessionPackageForCurriculum = (
getAllInstances: GetAllInstances<"Profession">,
getChildInstancesForInstanceId: GetAllChildInstancesForParent<
"ProfessionVersion" | "ProfessionPackage"
>,
idMap: IdMap,
curriculumId: string,
) => {
const baseProfession = getAllInstances("Profession").find(
profession =>
profession.content.group.kind === "Magical" &&
profession.content.group.Magical.curriculum === curriculumId,
)
if (baseProfession === undefined) {
return undefined
}
return reduceWhile(
getChildInstancesForInstanceId("ProfessionVersion", baseProfession.id),
(_acc: { id: string; content: ProfessionPackage } | undefined, version) =>
getChildInstancesForInstanceId("ProfessionPackage", version.id).find(
professionPackage =>
professionPackage.content.experience_level === undefined ||
professionPackage.content.experience_level ===
idMap.ExperienceLevel.Experienced,
),
isNotNullish,
undefined,
)
}
/**
* Gets the name of a profession, regardless of the profession version.
*/
export const getProfessionName = (
translateMap: TranslateMap,
getChildInstancesForInstanceId: GetAllChildInstancesForParent<"ProfessionVersion">,
professionId: Profession_ID,
): string | undefined =>
translateMap(
getChildInstancesForInstanceId("ProfessionVersion", professionId)[0]
?.content.translations,
)?.name.default
@@ -1,3 +1,5 @@
import { rangeSize, type RangeBounds } from "@elyukai/utils/range"
import { assertExhaustive } from "@elyukai/utils/typeSafety"
import type { ImprovementCost } from "optolith-database-schema/gen"
import { type RawDefinitionListEntityDescriptionSectionItem } from "../../../index.js"
import { translateR, type StdReader } from "../reader.js"
@@ -19,3 +21,73 @@ export const renderImprovementCost = (
label,
value: renderImprovementCostValue(improvementCost),
}))
/**
* Calculates the adventure points for an improvement cost.
* @throws {RangeError} if the rating range is invalid (max < min).
*/
export const calculateAdventurePointsFromImprovementCost = (
improvementCost:
| ImprovementCost
| ImprovementCost["kind"]
| { kind: "E" }
| "E",
rating: number | RangeBounds = 1,
): number => {
const normalized =
typeof improvementCost === "string" ? improvementCost : improvementCost.kind
const base = (() => {
switch (normalized) {
case "A":
return 1
case "B":
return 2
case "C":
return 3
case "D":
return 4
case "E":
return 15
default:
return assertExhaustive(normalized)
}
})()
const lastRatingOfSameValue = (() => {
switch (normalized) {
case "A":
case "B":
case "C":
case "D":
return 12
case "E":
return 14
default:
return assertExhaustive(normalized)
}
})()
if (typeof rating === "number") {
return (
base *
(rating < lastRatingOfSameValue ? 1 : rating - lastRatingOfSameValue + 1)
)
} else {
const [min, max] = rating
if (max < min) {
throw new RangeError(`Invalid rating range: ${min} - ${max}`)
}
const size = rangeSize(rating)
const constantRangeSize = Math.min(size, lastRatingOfSameValue - min + 1)
const variableRangeSize = size - constantRangeSize
const constantCost = base * constantRangeSize
const variableCost =
base * (((variableRangeSize + 1) * (variableRangeSize + 2)) / 2 - 1)
return constantCost + variableCost
}
}
+21 -111
View File
@@ -1,14 +1,13 @@
import { anySameIndices } from "@elyukai/utils/array/filters"
import { ensureNonEmpty, isNotEmpty } from "@elyukai/utils/array/nonEmpty"
import { isNotEmpty } from "@elyukai/utils/array/nonEmpty"
import { deepEqual } from "@elyukai/utils/equality"
import { on } from "@elyukai/utils/function"
import { isNotNullish, mapNullable } from "@elyukai/utils/nullable"
import { isNotNullish } from "@elyukai/utils/nullable"
import { Reader } from "@elyukai/utils/reader"
import { sign } from "@elyukai/utils/string/number"
import type {
AttributeAdjustments,
AutomaticAdvantageDisadvantage,
CommonnessRatedAdvantageDisadvantage,
Culture_ID,
RaceVariant,
RaceVariantTranslation,
@@ -19,12 +18,13 @@ import type {
GetAllChildInstancesForParent,
GetInstanceById,
} from "../helpers/getTypes.js"
import type {
TranslateMap,
TranslationKeysWithoutParams,
} from "../helpers/translate.js"
import type { TranslationKeysWithoutParams } from "../helpers/translate.js"
import type { RawDefinitionListEntityDescriptionSectionItem } from "../index.js"
import { parensIf } from "./partial/rated/activatable/parensIf.js"
import {
renderCommonnessRatedAdvantagesAndDisadvantages,
renderCommonnessRatedAdvantagesOrDisadvantages,
renderValueWithPossibleTranslation,
} from "./partial/commonnessRatedAdvantagesAndDisadvantages.js"
import type { EnvMap, StdReader } from "./partial/reader.js"
import { MISSING_VALUE } from "./partial/unknown.js"
@@ -72,19 +72,6 @@ const renderAttributeAdjustmentsItem = (
},
)
const renderValueWithPossibleTranslation = <T>(
label: TranslationKeysWithoutParams,
value: T,
renderValue: (value: T) => string,
valueTranslation: string | undefined,
): StdReader<RawDefinitionListEntityDescriptionSectionItem, "t"> =>
Reader.asks(
({ translate }): RawDefinitionListEntityDescriptionSectionItem => ({
label: translate(label),
value: valueTranslation ?? renderValue(value),
}),
)
const renderVariantValues = <T>(
label: TranslationKeysWithoutParams,
variants: RaceVariant[],
@@ -161,74 +148,6 @@ const renderVariantValues = <T>(
},
)
const renderCommonnessRatedAdvantageOrDisadvantageName = <
E extends "Advantage" | "Disadvantage",
>(
translateMap: TranslateMap,
getInstanceById: GetInstanceById<E>,
entity: E,
item: CommonnessRatedAdvantageDisadvantage<string>,
): string => {
const instance = getInstanceById(entity, item.id)
const instanceTranslation = translateMap(instance?.translations)
const customTranslation = translateMap(item.translations)
const name = instanceTranslation?.name_in_library ?? instanceTranslation?.name
return name !== undefined &&
customTranslation?.options !== undefined &&
name.endsWith(")")
? `${name.slice(0, -1)}; ${customTranslation.options})`
: (name ?? MISSING_VALUE) + parensIf(customTranslation?.options)
}
const renderCommonnessRatedAdvantagesOrDisadvantages = <
E extends "Advantage" | "Disadvantage",
>(
entity: E,
items: CommonnessRatedAdvantageDisadvantage<string>[] | undefined,
): StdReader<string, "t" | "tm" | "lc" | "ibi", E> =>
Reader.asks(({ translate, translateMap, getInstanceById, localeCompare }) =>
items === undefined || !isNotEmpty(items)
? translate("none")
: items
.map(item =>
renderCommonnessRatedAdvantageOrDisadvantageName(
translateMap,
getInstanceById,
entity,
item,
),
)
.toSorted(localeCompare)
.join(", "),
)
const renderCommonnessRatedAdvantagesAndDisadvantages = (
advantages: CommonnessRatedAdvantageDisadvantage<string>[] | undefined,
disadvantages: CommonnessRatedAdvantageDisadvantage<string>[] | undefined,
): StdReader<string, "t" | "tm" | "lc" | "ibi", "Advantage" | "Disadvantage"> =>
Reader.asks(
({ translate, translateMap, getInstanceById, localeCompare }) =>
mapNullable(
ensureNonEmpty(
[
["Advantage", advantages] as const,
["Disadvantage", disadvantages] as const,
].flatMap(
([entity, items]) =>
items?.map(item =>
renderCommonnessRatedAdvantageOrDisadvantageName(
translateMap,
getInstanceById,
entity,
item,
),
) ?? [],
),
),
renderedItems => renderedItems.toSorted(localeCompare).join(", "),
) ?? translate("none"),
)
const renderAutomaticAdvantagesOrDisadvantages = <
E extends "Advantage" | "Disadvantage",
ID extends string,
@@ -256,28 +175,19 @@ const renderAutomaticAdvantagesOrDisadvantages = <
const renderCommonCultures = (
items: Culture_ID[] | undefined,
): StdReader<string, "t" | "tm" | "lc" | "lj" | "ibi", "Culture"> =>
Reader.asks(
({
translate,
translateMap,
getInstanceById,
localeCompare,
localeJoin,
}) =>
items === undefined || !isNotEmpty(items)
? translate("none")
: localeJoin(
items
.map(
itemId =>
translateMap(getInstanceById("Culture", itemId)?.translations)
?.name,
)
.filter(isNotNullish)
.toSorted(localeCompare),
"conjunction",
),
): StdReader<string, "t" | "tm" | "lc" | "ibi", "Culture"> =>
Reader.asks(({ translate, translateMap, getInstanceById, localeCompare }) =>
items === undefined || !isNotEmpty(items)
? translate("none")
: items
.map(
itemId =>
translateMap(getInstanceById("Culture", itemId)?.translations)
?.name,
)
.filter(isNotNullish)
.toSorted(localeCompare)
.join(", "),
)
/**
+2 -1
View File
@@ -23,6 +23,7 @@ import {
getConditionEntityDescription,
getMetaConditionEntityDescription,
} from "./entities/condition.js"
import { getCultureEntityDescription } from "./entities/culture.js"
import { getCurriculumEntityDescription } from "./entities/curriculum.js"
import { getDerivedCharacteristicEntityDescription } from "./entities/derivedCharacteristic.js"
import { getDiseaseEntityDescription } from "./entities/disease.js"
@@ -258,7 +259,7 @@ const registeredEntityDescriptionCreators = {
ExperienceLevel: getExperienceLevelEntityDescription,
DerivedCharacteristic: getDerivedCharacteristicEntityDescription,
Race: getRaceEntityDescription,
// Culture: getCultureEntityDescription,
Culture: getCultureEntityDescription,
// ProfessionVersion: getProfessionVersionEntityDescription,
Advantage: getActivatableEntityDescription,
Disadvantage: getActivatableEntityDescription,
@@ -0,0 +1,41 @@
import { equal, throws } from "node:assert/strict"
import { describe, it } from "node:test"
import { calculateAdventurePointsFromImprovementCost } from "../../../../src/entities/partial/rated/improvementCost.js"
describe("calculateAdventurePointsFromImprovementCost", () => {
it("calculates the correct adventure points for a single rating", () => {
equal(calculateAdventurePointsFromImprovementCost("A", 1), 1)
equal(calculateAdventurePointsFromImprovementCost("A", 12), 1)
equal(calculateAdventurePointsFromImprovementCost("A", 13), 2)
equal(calculateAdventurePointsFromImprovementCost("A", 14), 3)
equal(calculateAdventurePointsFromImprovementCost("A", 15), 4)
equal(calculateAdventurePointsFromImprovementCost("D", 1), 4)
equal(calculateAdventurePointsFromImprovementCost("D", 12), 4)
equal(calculateAdventurePointsFromImprovementCost("D", 13), 8)
equal(calculateAdventurePointsFromImprovementCost("D", 14), 12)
equal(calculateAdventurePointsFromImprovementCost("D", 15), 16)
equal(calculateAdventurePointsFromImprovementCost("E", 1), 15)
equal(calculateAdventurePointsFromImprovementCost("E", 12), 15)
equal(calculateAdventurePointsFromImprovementCost("E", 13), 15)
equal(calculateAdventurePointsFromImprovementCost("E", 14), 15)
equal(calculateAdventurePointsFromImprovementCost("E", 15), 30)
equal(calculateAdventurePointsFromImprovementCost("E", 16), 45)
})
it("calculates the correct adventure points for a rating range", () => {
equal(calculateAdventurePointsFromImprovementCost("A", [1, 1]), 1)
equal(calculateAdventurePointsFromImprovementCost("A", [1, 12]), 12)
equal(calculateAdventurePointsFromImprovementCost("A", [1, 13]), 14)
equal(calculateAdventurePointsFromImprovementCost("A", [1, 14]), 17)
equal(calculateAdventurePointsFromImprovementCost("A", [1, 15]), 21)
equal(calculateAdventurePointsFromImprovementCost("D", [1, 15]), 84)
equal(calculateAdventurePointsFromImprovementCost("E", [1, 16]), 285)
})
it("throws a RangeError for an invalid rating range", () => {
throws(() => calculateAdventurePointsFromImprovementCost("A", [13, 12]), {
name: "RangeError",
message: "Invalid rating range: 13 - 12",
})
})
})