diff --git a/src/cache/activatableSelectOptions.ts b/src/cache/activatableSelectOptions.ts new file mode 100644 index 00000000..81a09fbf --- /dev/null +++ b/src/cache/activatableSelectOptions.ts @@ -0,0 +1,1275 @@ +import type { CacheConfig } from "../cacheConfig.js" +import { TypeMap } from "../config/types.js" +import { isNotNullish } from "../helpers/nullable.js" +import { mapObject } from "../helpers/object.js" +import { assertExhaustive } from "../helpers/typeSafety.js" +import { ValidResults } from "../main.js" +import { Aspect } from "../types/Aspect.js" +import { Element } from "../types/Element.js" +import { Property } from "../types/Property.js" +import { TargetCategory } from "../types/TargetCategory.js" +import { + ExplicitSelectOption, + SelectOptions, + SkillApplications, + SkillUses, +} from "../types/_Activatable.js" +import { + AdventurePointsValue, + SelectOptionCategory, + SkillSelectOptionCategoryPrerequisite, + SpecificFromSkillSelectOptionCategoryCategory, + SpecificTargetCategory, +} from "../types/_ActivatableSelectOptionCategory.js" +import { + CeremonyIdentifier, + CloseCombatTechniqueIdentifier, + LiturgicalChantIdentifier, + RangedCombatTechniqueIdentifier, + RitualIdentifier, + SkillIdentifier, + SpellIdentifier, +} from "../types/_Identifier.js" +import { + ActivatableIdentifier, + CombatTechniqueIdentifier, + SelectOptionIdentifier, + SkillIdentifier as SkillIdentifierGroup, +} from "../types/_IdentifierGroup.js" +import { ImprovementCost } from "../types/_ImprovementCost.js" +import { LocaleMap } from "../types/_LocaleMap.js" +import { GeneralPrerequisites, PrerequisiteForLevel } from "../types/_Prerequisite.js" +import { Poison } from "../types/equipment/item/Poison.js" +import { GeneralPrerequisiteGroup } from "../types/prerequisites/PrerequisiteGroups.js" +import { Errata } from "../types/source/_Erratum.js" +import { PublicationRefs } from "../types/source/_PublicationRef.js" +import { BlessedTradition } from "../types/specialAbility/BlessedTradition.js" +import { Language } from "../types/specialAbility/sub/Language.js" + +const PRINCIPLES_ID = 31 +const PROPERTY_KNOWLEDGE_ID = 3 +const ASPECT_KNOWLEDGE_ID = 1 + +export type ResolvedSelectOption = { + id: SelectOptionIdentifier + + /** + * Sometimes, professions use specific text selections that are not + * contained in described lists. This ensures you can use them for + * professions only. They are not going to be displayed as options to the + * user. + */ + profession_only?: true + + /** + * Registers new applications, which get enabled once this entry is + * activated with its respective select option. It specifies an entry-unique + * identifier and the skill it belongs to. A translation can be left out if + * its name equals the name of the origin select option. + */ + skill_applications?: SkillApplications + + /** + * Registers uses, which get enabled once this entry is activated with its + * respective select option. It specifies an entry-unique identifier and the + * skill it belongs to. A translation can be left out if its name equals the + * name of the origin select option. + */ + skill_uses?: SkillUses + + prerequisites?: GeneralPrerequisites + + /** + * Specific binding cost for the select option. Only has an effect if the + * associated entry supports binding costs. + */ + binding_cost?: number + + /** + * Specific volume for the select option. Only has an effect if the + * associated entry supports volume values. + */ + volume?: number + + /** + * Specific AP cost for the select option. + */ + ap_value?: number + + src?: PublicationRefs + + /** + * All translations for the entry, identified by IETF language tag (BCP47). + */ + translations: LocaleMap +} + +export type ResolvedSelectOptionTranslation = { + /** + * The name of the select option. + */ + name: string + + /** + * The name of the select option when displayed in a generated + * profession text. + */ + name_in_profession?: string + + /** + * The description of the select option. Useful for Bad Habits, Trade + * Secrets and other entries where a description is available. + */ + description?: string + + errata?: Errata +} +const matchesSpecificSkillishIdList = ( + id: T, + config: SpecificFromSkillSelectOptionCategoryCategory<{ id: T }>, + equalsId: (a: T, b: T) => boolean +): boolean => { + switch (config.operation) { + case "Intersection": + return config.list.some(ref => equalsId(ref.id, id)) + case "Difference": + return !config.list.some(ref => equalsId(ref.id, id)) + default: + return assertExhaustive(config.operation) + } +} + +const getSkillishPrerequisites = ( + ps: SkillSelectOptionCategoryPrerequisite[] | undefined, + id: SkillIdentifierGroup | CombatTechniqueIdentifier +): GeneralPrerequisites | undefined => { + if (ps === undefined) { + return undefined + } + + return ps.map(p => { + switch (p.tag) { + case "Self": + return { + level: 1, + prerequisite: { + tag: "Single", + single: { + tag: "Rated", + rated: { + id, + value: p.self.value, + }, + }, + }, + } + case "SelectOption": + return { + level: 1, + prerequisite: { + tag: "Single", + single: { + tag: "Activatable", + activatable: { + id: p.select_option.id, + active: p.select_option.active, + level: p.select_option.level, + options: [id], + }, + }, + }, + } + default: + return assertExhaustive(p) + } + }) +} + +const equalsSkillishIdGroup = ( + a: SkillIdentifierGroup | CombatTechniqueIdentifier, + b: SkillIdentifierGroup | CombatTechniqueIdentifier +): boolean => { + switch (a.tag) { + case "Skill": + return b.tag === "Skill" && a.skill === b.skill + case "Spell": + return b.tag === "Spell" && a.spell === b.spell + case "Ritual": + return b.tag === "Ritual" && a.ritual === b.ritual + case "LiturgicalChant": + return b.tag === "LiturgicalChant" && a.liturgical_chant === b.liturgical_chant + case "Ceremony": + return b.tag === "Ceremony" && a.ceremony === b.ceremony + case "CloseCombatTechnique": + return ( + b.tag === "CloseCombatTechnique" && a.close_combat_technique === b.close_combat_technique + ) + case "RangedCombatTechnique": + return ( + b.tag === "RangedCombatTechnique" && a.ranged_combat_technique === b.ranged_combat_technique + ) + default: + return assertExhaustive(a) + } +} + +const getApValueForSkillish = ( + config: AdventurePointsValue | undefined, + id: SkillIdentifierGroup | CombatTechniqueIdentifier, + ic: ImprovementCost +): number | undefined => { + if (config === undefined) { + return undefined + } + + switch (config.tag) { + case "DerivedFromImprovementCost": + return ( + (() => { + switch (ic) { + case "A": + return 1 + case "B": + return 2 + case "C": + return 3 + case "D": + return 4 + default: + assertExhaustive(ic) + } + })() * + (config.derived_from_improvement_cost.multiplier ?? 1) + + (config.derived_from_improvement_cost.offset ?? 0) + ) + case "Fixed": + return ( + config.fixed.map.find(mapping => equalsSkillishIdGroup(mapping.id, id))?.ap_value ?? + config.fixed.default + ) + default: + return assertExhaustive(config) + } +} + +const getDerivedSelectOptions = ( + selectOptionCategory: SelectOptionCategory, + entryId: ActivatableIdentifier, + database: ValidResults +): ResolvedSelectOption[] => { + switch (selectOptionCategory.tag) { + case "Blessings": + return database.blessings.map(([_, blessing]) => ({ + id: { tag: "Blessing", blessing: blessing.id }, + src: blessing.src, + translations: mapObject(blessing.translations, t10n => ({ name: t10n.name })), + })) + + case "Cantrips": + return database.cantrips.map(([_, cantrip]) => ({ + id: { tag: "Cantrip", cantrip: cantrip.id }, + src: cantrip.src, + translations: mapObject(cantrip.translations, t10n => ({ name: t10n.name })), + })) + + case "TradeSecrets": + return database.tradeSecrets.map(([_, tradeSecret]) => ({ + id: { tag: "TradeSecret", trade_secret: tradeSecret.id }, + prerequisites: tradeSecret.prerequisites, + ap_value: tradeSecret.ap_value, + src: tradeSecret.src, + translations: mapObject(tradeSecret.translations, t10n => ({ + name: t10n.name, + errata: t10n.errata, + })), + })) + + case "Scripts": + return database.scripts.map(([_, script]) => ({ + id: { tag: "Script", script: script.id }, + ap_value: script.ap_value, + src: script.src, + translations: mapObject(script.translations, t10n => ({ + name: t10n.name, + errata: t10n.errata, + })), + })) + + case "AnimalShapes": { + const pathsWithOrderedIds = database.animalShapePaths.reduce>( + (acc, [id, _path]) => ({ + ...acc, + [id]: database.animalShapes + .toSorted(([_1, a], [_2, b]) => a.size.id - b.size.id) + .map(([id]) => id), + }), + {} + ) + + return database.animalShapes.map(([_, animalShape]) => { + const path = database.animalShapePaths.find(([id]) => id === animalShape.path.id)?.[1] + const size = database.animalShapeSizes.find(([id]) => id === animalShape.size.id)?.[1] + const pathIndex = + path !== undefined ? pathsWithOrderedIds[path.id]?.indexOf(animalShape.id) ?? -1 : -1 + return { + id: { tag: "AnimalShape", animal_shape: animalShape.id }, + prerequisites: + pathIndex >= 0 + ? pathIndex === 0 + ? database.animalShapePaths + .filter( + ([id]) => + id !== animalShape.path.id && pathsWithOrderedIds[id]?.[0] !== undefined + ) + .map(([id]) => ({ + level: 1, + prerequisite: { + tag: "Single", + single: { + tag: "Activatable", + activatable: { + id: entryId, + active: false, + options: [ + { tag: "AnimalShape", animal_shape: pathsWithOrderedIds[id]![0]! }, + ], + }, + }, + }, + })) + : [ + { + level: 1, + prerequisite: { + tag: "Single", + single: { + tag: "Activatable", + activatable: { + id: entryId, + active: true, + options: [ + { + tag: "AnimalShape", + animal_shape: pathsWithOrderedIds[path!.id]![pathIndex - 1]!, + }, + ], + }, + }, + }, + }, + ] + : undefined, + volume: size?.volume, + ap_value: size?.ap_value, + translations: mapObject(animalShape.translations, (t10n, lang) => ({ + name: + path?.translations[lang] !== undefined + ? `${t10n.name} (${path.translations[lang]!.name})` + : t10n.name, + })), + } + }) + } + + case "ArcaneBardTraditions": + return database.arcaneBardTraditions.map(([_, arcaneBardTradition]) => ({ + id: { tag: "ArcaneBardTradition", arcane_bard_tradition: arcaneBardTradition.id }, + prerequisites: arcaneBardTradition.prerequisites.map(p => ({ + level: 1, + prerequisite: p, + })), + translations: mapObject(arcaneBardTradition.translations, t10n => ({ + name: t10n.name, + })), + })) + + case "ArcaneDancerTraditions": + return database.arcaneDancerTraditions.map(([_, arcaneDancerTradition]) => ({ + id: { tag: "ArcaneDancerTradition", arcane_dancer_tradition: arcaneDancerTradition.id }, + prerequisites: arcaneDancerTradition.prerequisites.map(p => ({ + level: 1, + prerequisite: p, + })), + translations: mapObject(arcaneDancerTradition.translations, t10n => ({ + name: t10n.name, + })), + })) + + case "SexPractices": + return database.sexPractices.map(([_, sexPractice]) => ({ + id: { tag: "SexPractice", sex_practice: sexPractice.id }, + src: sexPractice.src, + translations: mapObject(sexPractice.translations, t10n => ({ + name: t10n.name, + })), + })) + + case "Races": + return database.races.map(([_, race]) => ({ + id: { tag: "Race", race: race.id }, + src: race.src, + translations: mapObject(race.translations, t10n => ({ + name: t10n.name, + })), + })) + + case "Cultures": + return database.cultures.map(([_, culture]) => ({ + id: { tag: "Culture", culture: culture.id }, + src: culture.src, + translations: mapObject(culture.translations, t10n => ({ + name: t10n.name, + })), + })) + + case "RacesAndCultures": + return [ + ...database.races.map( + ([_, race]): ResolvedSelectOption => ({ + id: { tag: "Race", race: race.id }, + src: race.src, + translations: mapObject(race.translations, t10n => ({ + name: t10n.name, + })), + }) + ), + ...database.cultures.map( + ([_, culture]): ResolvedSelectOption => ({ + id: { tag: "Culture", culture: culture.id }, + src: culture.src, + translations: mapObject(culture.translations, t10n => ({ + name: t10n.name, + })), + }) + ), + ] + + case "BlessedTraditions": { + const getPrerequisites = ( + blessedTradition: BlessedTradition + ): GeneralPrerequisites | undefined => { + if ( + selectOptionCategory.blessed_traditions.require_principles && + blessedTradition.associated_principles_id !== undefined + ) { + return [ + { + level: 1, + prerequisite: { + tag: "Single", + single: { + tag: "Activatable", + activatable: { + id: { tag: "Disadvantage", disadvantage: PRINCIPLES_ID }, + active: true, + options: [ + { tag: "General", general: blessedTradition.associated_principles_id }, + ], + }, + }, + }, + }, + ] + } + } + + return database.blessedTraditions.map(([_, blessedTradition]) => ({ + id: { tag: "BlessedTradition", blessed_tradition: blessedTradition.id }, + prerequisites: getPrerequisites(blessedTradition), + src: blessedTradition.src, + translations: mapObject(blessedTradition.translations, t10n => ({ + name: t10n.name, + })), + })) + } + + case "Elements": { + const mapToResolvedSelectOption = ([_, element]: [ + number, + Element + ]): ResolvedSelectOption => ({ + id: { tag: "Element", element: element.id }, + translations: mapObject(element.translations, t10n => ({ + name: t10n.name, + })), + }) + + if (selectOptionCategory.elements.specific) { + return database.elements + .filter(([id]) => + selectOptionCategory.elements.specific!.some(ref => ref.id.element === id) + ) + .map(mapToResolvedSelectOption) + } + + return database.elements.map(mapToResolvedSelectOption) + } + + case "Properties": { + const getPrerequisites = (property: Property): GeneralPrerequisites | undefined => { + if ( + selectOptionCategory.properties.require_knowledge !== undefined || + selectOptionCategory.properties.require_minimum_spellworks_on !== undefined + ) { + const knowledgePrerequisite: PrerequisiteForLevel | undefined = + selectOptionCategory.properties.require_knowledge !== undefined + ? { + level: 1, + prerequisite: { + tag: "Single", + single: { + tag: "Activatable", + activatable: { + id: { + tag: "MagicalSpecialAbility", + magical_special_ability: PROPERTY_KNOWLEDGE_ID, + }, + active: true, + options: [{ tag: "Property", property: property.id }], + }, + }, + }, + } + : undefined + + const minimumSpellworksPrerequisite: + | PrerequisiteForLevel + | undefined = + selectOptionCategory.properties.require_minimum_spellworks_on !== undefined + ? { + level: 1, + prerequisite: { + tag: "Single", + single: { + tag: "RatedMinimumNumber", + rated_minimum_number: { + number: + selectOptionCategory.properties.require_minimum_spellworks_on.number, + value: selectOptionCategory.properties.require_minimum_spellworks_on.rating, + targets: { + tag: "Spellworks", + spellworks: { + id: { tag: "Property", property: property.id }, + }, + }, + }, + }, + }, + } + : undefined + return [knowledgePrerequisite, minimumSpellworksPrerequisite].filter(isNotNullish) + } + } + + return database.properties.map(([_, property]) => ({ + id: { tag: "Property", property: property.id }, + prerequisites: getPrerequisites(property), + translations: mapObject(property.translations, t10n => ({ + name: t10n.name, + })), + })) + } + + case "Aspects": { + const getPrerequisites = (aspect: Aspect): GeneralPrerequisites | undefined => { + if ( + selectOptionCategory.aspects.require_knowledge !== undefined || + selectOptionCategory.aspects.require_minimum_liturgies_on !== undefined + ) { + const knowledgePrerequisite: PrerequisiteForLevel | undefined = + selectOptionCategory.aspects.require_knowledge !== undefined + ? { + level: 1, + prerequisite: { + tag: "Single", + single: { + tag: "Activatable", + activatable: { + id: { + tag: "KarmaSpecialAbility", + karma_special_ability: ASPECT_KNOWLEDGE_ID, + }, + active: true, + options: [{ tag: "Property", property: aspect.id }], + }, + }, + }, + } + : undefined + + const minimumSpellworksPrerequisite: + | PrerequisiteForLevel + | undefined = + selectOptionCategory.aspects.require_minimum_liturgies_on !== undefined + ? { + level: 1, + prerequisite: { + tag: "Single", + single: { + tag: "RatedMinimumNumber", + rated_minimum_number: { + number: selectOptionCategory.aspects.require_minimum_liturgies_on.number, + value: selectOptionCategory.aspects.require_minimum_liturgies_on.rating, + targets: { + tag: "Liturgies", + liturgies: { + id: { tag: "Aspect", aspect: aspect.id }, + }, + }, + }, + }, + }, + } + : undefined + return [knowledgePrerequisite, minimumSpellworksPrerequisite].filter(isNotNullish) + } + } + + if (selectOptionCategory.aspects.use_master_of_suffix_as_name === true) { + return database.aspects + .map( + ([_, aspect]): ResolvedSelectOption => ({ + id: { tag: "Aspect", aspect: aspect.id }, + prerequisites: getPrerequisites(aspect), + translations: mapObject(aspect.translations, t10n => + t10n.master_of_aspect_suffix === undefined + ? undefined + : { + name: t10n.master_of_aspect_suffix, + } + ), + }) + ) + .filter(value => Object.keys(value.translations).length > 0) + } + + return database.aspects.map(([_, aspect]) => ({ + id: { tag: "Aspect", aspect: aspect.id }, + prerequisites: getPrerequisites(aspect), + translations: mapObject(aspect.translations, t10n => ({ + name: t10n.name, + })), + })) + } + + case "Diseases": + return database.diseases.map(([_, disease]) => ({ + id: { tag: "Disease", disease: disease.id }, + ap_value: + selectOptionCategory.diseases.use_half_level_as_ap_value === true + ? Math.round(disease.level / 3) + : disease.level, + src: disease.src, + translations: mapObject(disease.translations, t10n => ({ + name: t10n.name, + })), + })) + + case "Poisons": { + const getLevel = (poison: Poison): number => { + switch (poison.source_type.tag) { + case "AnimalVenom": + return poison.source_type.animal_venom.level + case "AlchemicalPoison": + return 6 + case "MineralPoison": + return poison.source_type.mineral_poison.level + case "PlantPoison": + return poison.source_type.plant_poison.level + case "DemonicPoison": + switch (poison.source_type.demonic_poison.level.tag) { + case "Constant": + return poison.source_type.demonic_poison.level.constant.value + case "QualityLevel": + return 6 + default: + return assertExhaustive(poison.source_type.demonic_poison.level) + } + default: + return assertExhaustive(poison.source_type) + } + } + + return database.poisons.map(([_, poison]) => ({ + id: { tag: "Poison", poison: poison.id }, + ap_value: + selectOptionCategory.poisons.use_half_level_as_ap_value === true + ? Math.round(getLevel(poison) / 3) + : getLevel(poison), + src: poison.src, + translations: mapObject(poison.translations, t10n => ({ + name: t10n.name, + })), + })) + } + + case "Languages": { + const getPrerequisites = (language: Language): GeneralPrerequisites | undefined => { + if (selectOptionCategory.languages.prerequisites !== undefined) { + return selectOptionCategory.languages.prerequisites.map(config => ({ + level: 1, + prerequisite: { + tag: "Single", + single: { + tag: "Activatable", + activatable: { + id: config.select_option.id, + active: config.select_option.active, + level: config.select_option.level, + options: [{ tag: "Language", language: language.id }], + }, + }, + }, + })) + } + } + + return database.languages.map(([_, language]) => ({ + id: { tag: "Language", language: language.id }, + prerequisites: getPrerequisites(language), + src: language.src, + translations: mapObject(language.translations, t10n => ({ + name: t10n.name, + })), + })) + } + + case "Skills": { + const apValueGen = selectOptionCategory.skills.ap_value + + return selectOptionCategory.skills.categories.flatMap(category => { + switch (category.tag) { + case "Skills": + return database.skills + .filter(([_, skill]) => { + const matchesGroupRequirement = + category.skills.groups === undefined || + category.skills.groups.some( + ref => ref.id.skill_group === skill.group.id.skill_group + ) + + const matchesIdRequirement = + category.skills.specific === undefined || + matchesSpecificSkillishIdList( + { tag: "Skill", skill: skill.id }, + category.skills.specific, + equalsSkillishIdGroup + ) + + return matchesGroupRequirement && matchesIdRequirement + }) + .map(([_, skill]): ResolvedSelectOption => { + const id: SkillIdentifier = { tag: "Skill", skill: skill.id } + return { + id, + skill_uses: category.skills.skill_uses?.map(use => ({ + id: use.id, + skill: { tag: "Single", single: { id } }, + translations: use.translations, + })), + skill_applications: category.skills.skill_applications?.map(use => ({ + id: use.id, + skill: { tag: "Single", single: { id } }, + translations: use.translations, + })), + prerequisites: getSkillishPrerequisites(category.skills.prerequisites, id), + ap_value: getApValueForSkillish( + category.skills.ap_value ?? apValueGen, + id, + skill.improvement_cost + ), + src: skill.src, + translations: mapObject(skill.translations, t10n => ({ + name: t10n.name, + })), + } + }) + case "Spells": + return database.spells + .filter( + ([_, spell]) => + category.spells.specific === undefined || + matchesSpecificSkillishIdList( + { tag: "Spell", spell: spell.id }, + category.spells.specific, + equalsSkillishIdGroup + ) + ) + .map(([_, spell]): ResolvedSelectOption => { + const id: SpellIdentifier = { tag: "Spell", spell: spell.id } + return { + id, + prerequisites: getSkillishPrerequisites(category.spells.prerequisites, id), + ap_value: getApValueForSkillish(apValueGen, id, spell.improvement_cost), + src: spell.src, + translations: mapObject(spell.translations, t10n => ({ + name: t10n.name, + })), + } + }) + case "Rituals": + return database.rituals + .filter( + ([_, ritual]) => + category.rituals.specific === undefined || + matchesSpecificSkillishIdList( + { tag: "Ritual", ritual: ritual.id }, + category.rituals.specific, + equalsSkillishIdGroup + ) + ) + .map(([_, ritual]): ResolvedSelectOption => { + const id: RitualIdentifier = { tag: "Ritual", ritual: ritual.id } + return { + id, + prerequisites: getSkillishPrerequisites(category.rituals.prerequisites, id), + ap_value: getApValueForSkillish(apValueGen, id, ritual.improvement_cost), + src: ritual.src, + translations: mapObject(ritual.translations, t10n => ({ + name: t10n.name, + })), + } + }) + case "LiturgicalChants": + return database.liturgicalChants + .filter( + ([_, liturgicalChant]) => + category.liturgical_chants.specific === undefined || + matchesSpecificSkillishIdList( + { tag: "LiturgicalChant", liturgical_chant: liturgicalChant.id }, + category.liturgical_chants.specific, + equalsSkillishIdGroup + ) + ) + .map(([_, liturgicalChant]): ResolvedSelectOption => { + const id: LiturgicalChantIdentifier = { + tag: "LiturgicalChant", + liturgical_chant: liturgicalChant.id, + } + return { + id, + prerequisites: getSkillishPrerequisites( + category.liturgical_chants.prerequisites, + id + ), + ap_value: getApValueForSkillish(apValueGen, id, liturgicalChant.improvement_cost), + src: liturgicalChant.src, + translations: mapObject(liturgicalChant.translations, t10n => ({ + name: t10n.name, + })), + } + }) + case "Ceremonies": + return database.ceremonies + .filter( + ([_, ceremony]) => + category.ceremonies.specific === undefined || + matchesSpecificSkillishIdList( + { tag: "Ceremony", ceremony: ceremony.id }, + category.ceremonies.specific, + equalsSkillishIdGroup + ) + ) + .map(([_, ceremony]): ResolvedSelectOption => { + const id: CeremonyIdentifier = { tag: "Ceremony", ceremony: ceremony.id } + return { + id, + prerequisites: getSkillishPrerequisites(category.ceremonies.prerequisites, id), + ap_value: getApValueForSkillish(apValueGen, id, ceremony.improvement_cost), + src: ceremony.src, + translations: mapObject(ceremony.translations, t10n => ({ + name: t10n.name, + })), + } + }) + default: + return assertExhaustive(category) + } + }) + } + + case "CombatTechniques": { + const apValueGen = selectOptionCategory.combat_techniques.ap_value + + return selectOptionCategory.combat_techniques.categories.flatMap(category => { + switch (category.tag) { + case "CloseCombatTechniques": + return database.closeCombatTechniques + .filter( + ([_, closeCombatTechnique]) => + category.close_combat_techniques.specific === undefined || + matchesSpecificSkillishIdList( + { + tag: "CloseCombatTechnique", + close_combat_technique: closeCombatTechnique.id, + }, + category.close_combat_techniques.specific, + equalsSkillishIdGroup + ) + ) + .map(([_, closeCombatTechnique]): ResolvedSelectOption => { + const id: CloseCombatTechniqueIdentifier = { + tag: "CloseCombatTechnique", + close_combat_technique: closeCombatTechnique.id, + } + return { + id, + prerequisites: getSkillishPrerequisites( + category.close_combat_techniques.prerequisites, + id + ), + ap_value: getApValueForSkillish( + apValueGen, + id, + closeCombatTechnique.improvement_cost + ), + src: closeCombatTechnique.src, + translations: mapObject(closeCombatTechnique.translations, t10n => ({ + name: t10n.name, + })), + } + }) + case "RangedCombatTechniques": + return database.rangedCombatTechniques + .filter( + ([_, rangedCombatTechnique]) => + category.ranged_combat_techniques.specific === undefined || + matchesSpecificSkillishIdList( + { + tag: "RangedCombatTechnique", + ranged_combat_technique: rangedCombatTechnique.id, + }, + category.ranged_combat_techniques.specific, + equalsSkillishIdGroup + ) + ) + .map(([_, rangedCombatTechnique]): ResolvedSelectOption => { + const id: RangedCombatTechniqueIdentifier = { + tag: "RangedCombatTechnique", + ranged_combat_technique: rangedCombatTechnique.id, + } + return { + id, + prerequisites: getSkillishPrerequisites( + category.ranged_combat_techniques.prerequisites, + id + ), + ap_value: getApValueForSkillish( + apValueGen, + id, + rangedCombatTechnique.improvement_cost + ), + src: rangedCombatTechnique.src, + translations: mapObject(rangedCombatTechnique.translations, t10n => ({ + name: t10n.name, + })), + } + }) + default: + return assertExhaustive(category) + } + }) + } + + case "TargetCategories": { + const mapToResolvedSelectOption = ( + targetCategory: TargetCategory, + specificTargetCategory?: SpecificTargetCategory + ): ResolvedSelectOption => ({ + id: { tag: "TargetCategory", target_category: targetCategory.id }, + volume: specificTargetCategory?.volume, + translations: mapObject(targetCategory.translations, t10n => ({ + name: t10n.name, + })), + }) + + if (selectOptionCategory.target_categories.list) { + return database.targetCategories + .filter(([id]) => + selectOptionCategory.target_categories.list!.some(ref => ref.id.target_category === id) + ) + .map(([id, targetCategory]) => + mapToResolvedSelectOption( + targetCategory, + selectOptionCategory.target_categories.list!.find( + ref => ref.id.target_category === id + ) + ) + ) + } + + return database.targetCategories.map(p => mapToResolvedSelectOption(p[1])) + } + + default: + return assertExhaustive(selectOptionCategory) + } +} + +const getExplicitSelectOptions = ( + explicitSelectOptions: ExplicitSelectOption[], + database: ValidResults +): ResolvedSelectOption[] => + explicitSelectOptions + .map((explicitSelectOption): ResolvedSelectOption | undefined => { + switch (explicitSelectOption.tag) { + case "General": + return { + id: { tag: "Generic", generic: explicitSelectOption.general.id }, + translations: explicitSelectOption.general.translations, + } + case "Skill": { + const skill = database.skills.find(p => p[0] === explicitSelectOption.skill.id.skill)?.[1] + if (skill === undefined) { + return undefined + } + return { + id: { tag: "Skill", skill: explicitSelectOption.skill.id.skill }, + translations: mapObject(skill.translations, t10n => ({ name: t10n.name })), + } + } + case "CombatTechnique": + switch (explicitSelectOption.combat_technique.id.tag) { + case "CloseCombatTechnique": { + const id = explicitSelectOption.combat_technique.id.close_combat_technique + const closeCombatTechnique = database.closeCombatTechniques.find( + p => p[0] === id + )?.[1] + if (closeCombatTechnique === undefined) { + return undefined + } + return { + id: { tag: "CloseCombatTechnique", close_combat_technique: id }, + translations: mapObject(closeCombatTechnique.translations, t10n => ({ + name: t10n.name, + })), + } + } + case "RangedCombatTechnique": { + const id = explicitSelectOption.combat_technique.id.ranged_combat_technique + const rangedCombatTechnique = database.rangedCombatTechniques.find( + p => p[0] === id + )?.[1] + if (rangedCombatTechnique === undefined) { + return undefined + } + return { + id: { tag: "RangedCombatTechnique", ranged_combat_technique: id }, + translations: mapObject(rangedCombatTechnique.translations, t10n => ({ + name: t10n.name, + })), + } + } + default: + return assertExhaustive(explicitSelectOption.combat_technique.id) + } + default: + return assertExhaustive(explicitSelectOption) + } + }) + .filter(isNotNullish) + +const getSelectOptions = ( + selectOptions: SelectOptions, + id: ActivatableIdentifier, + database: ValidResults +): ResolvedSelectOption[] => [ + ...(selectOptions.derived === undefined + ? [] + : getDerivedSelectOptions(selectOptions.derived, id, database)), + ...(selectOptions.explicit === undefined + ? [] + : getExplicitSelectOptions(selectOptions.explicit, database)), +] + +const getSelectOptionsForResults = ( + database: ValidResults, + idTag: ActivatableIdentifier["tag"], + results: [id: number, data: { select_options?: SelectOptions }][] +) => + results.reduce<{ + [id: number]: ResolvedSelectOption[] + }>((acc, [id, data]) => { + const options = getSelectOptions( + data.select_options ?? {}, + (() => { + switch (idTag) { + case "Advantage": + return { tag: "Advantage", advantage: id } + case "Disadvantage": + return { tag: "Disadvantage", disadvantage: id } + case "GeneralSpecialAbility": + return { tag: "GeneralSpecialAbility", general_special_ability: id } + case "FatePointSpecialAbility": + return { tag: "FatePointSpecialAbility", fate_point_special_ability: id } + case "CombatSpecialAbility": + return { tag: "CombatSpecialAbility", combat_special_ability: id } + case "MagicalSpecialAbility": + return { tag: "MagicalSpecialAbility", magical_special_ability: id } + case "StaffEnchantment": + return { tag: "StaffEnchantment", staff_enchantment: id } + case "FamiliarSpecialAbility": + return { tag: "FamiliarSpecialAbility", familiar_special_ability: id } + case "KarmaSpecialAbility": + return { tag: "KarmaSpecialAbility", karma_special_ability: id } + case "ProtectiveWardingCircleSpecialAbility": + return { + tag: "ProtectiveWardingCircleSpecialAbility", + protective_warding_circle_special_ability: id, + } + case "CombatStyleSpecialAbility": + return { tag: "CombatStyleSpecialAbility", combat_style_special_ability: id } + case "AdvancedCombatSpecialAbility": + return { tag: "AdvancedCombatSpecialAbility", advanced_combat_special_ability: id } + case "CommandSpecialAbility": + return { tag: "CommandSpecialAbility", command_special_ability: id } + case "MagicStyleSpecialAbility": + return { tag: "MagicStyleSpecialAbility", magic_style_special_ability: id } + case "AdvancedMagicalSpecialAbility": + return { tag: "AdvancedMagicalSpecialAbility", advanced_magical_special_ability: id } + case "SpellSwordEnchantment": + return { tag: "SpellSwordEnchantment", spell_sword_enchantment: id } + case "DaggerRitual": + return { tag: "DaggerRitual", dagger_ritual: id } + case "InstrumentEnchantment": + return { tag: "InstrumentEnchantment", instrument_enchantment: id } + case "AttireEnchantment": + return { tag: "AttireEnchantment", attire_enchantment: id } + case "OrbEnchantment": + return { tag: "OrbEnchantment", orb_enchantment: id } + case "WandEnchantment": + return { tag: "WandEnchantment", wand_enchantment: id } + case "BrawlingSpecialAbility": + return { tag: "BrawlingSpecialAbility", brawling_special_ability: id } + case "AncestorGlyph": + return { tag: "AncestorGlyph", ancestor_glyph: id } + case "CeremonialItemSpecialAbility": + return { tag: "CeremonialItemSpecialAbility", ceremonial_item_special_ability: id } + case "Sermon": + return { tag: "Sermon", sermon: id } + case "LiturgicalStyleSpecialAbility": + return { tag: "LiturgicalStyleSpecialAbility", liturgical_style_special_ability: id } + case "AdvancedKarmaSpecialAbility": + return { tag: "AdvancedKarmaSpecialAbility", advanced_karma_special_ability: id } + case "Vision": + return { tag: "Vision", vision: id } + case "MagicalTradition": + return { tag: "MagicalTradition", magical_tradition: id } + case "BlessedTradition": + return { tag: "BlessedTradition", blessed_tradition: id } + case "PactGift": + return { tag: "PactGift", pact_gift: id } + case "SikaryanDrainSpecialAbility": + return { tag: "SikaryanDrainSpecialAbility", sikaryan_drain_special_ability: id } + case "VampiricGift": + return { tag: "VampiricGift", vampiric_gift: id } + case "LycantropicGift": + return { tag: "LycantropicGift", lycantropic_gift: id } + case "SkillStyleSpecialAbility": + return { tag: "SkillStyleSpecialAbility", skill_style_special_ability: id } + case "AdvancedSkillSpecialAbility": + return { tag: "AdvancedSkillSpecialAbility", advanced_skill_special_ability: id } + case "ArcaneOrbEnchantment": + return { tag: "ArcaneOrbEnchantment", arcane_orb_enchantment: id } + case "CauldronEnchantment": + return { tag: "CauldronEnchantment", cauldron_enchantment: id } + case "FoolsHatEnchantment": + return { tag: "FoolsHatEnchantment", fools_hat_enchantment: id } + case "ToyEnchantment": + return { tag: "ToyEnchantment", toy_enchantment: id } + case "BowlEnchantment": + return { tag: "BowlEnchantment", bowl_enchantment: id } + case "FatePointSexSpecialAbility": + return { tag: "FatePointSexSpecialAbility", fate_point_sex_special_ability: id } + case "SexSpecialAbility": + return { tag: "SexSpecialAbility", sex_special_ability: id } + case "WeaponEnchantment": + return { tag: "WeaponEnchantment", weapon_enchantment: id } + case "SickleRitual": + return { tag: "SickleRitual", sickle_ritual: id } + case "RingEnchantment": + return { tag: "RingEnchantment", ring_enchantment: id } + case "ChronicleEnchantment": + return { tag: "ChronicleEnchantment", chronicle_enchantment: id } + case "Krallenkettenzauber": + return { tag: "Krallenkettenzauber", krallenkettenzauber: id } + case "Trinkhornzauber": + return { tag: "Trinkhornzauber", trinkhornzauber: id } + default: + return assertExhaustive(idTag) + } + })(), + database + ) + if (options.length > 0) { + acc[id] = options + } + return acc + }, {}) + +export type ActivatableSelectOptionsCacheKeys = { + [K in keyof TypeMap]: TypeMap[K] extends { select_options?: SelectOptions } ? K : never +}[keyof TypeMap] + +export type ActivatableSelectOptionsCache = { + [K in ActivatableSelectOptionsCacheKeys]: { + [id: number]: ResolvedSelectOption[] + } +} + +// prettier-ignore +export const config: CacheConfig = { + builder(database) { + return { + advancedCombatSpecialAbilities: getSelectOptionsForResults(database, "AdvancedCombatSpecialAbility", database.advancedCombatSpecialAbilities), + advancedKarmaSpecialAbilities: getSelectOptionsForResults(database, "AdvancedKarmaSpecialAbility", database.advancedKarmaSpecialAbilities), + advancedMagicalSpecialAbilities: getSelectOptionsForResults(database, "AdvancedMagicalSpecialAbility", database.advancedMagicalSpecialAbilities), + advancedSkillSpecialAbilities: getSelectOptionsForResults(database, "AdvancedSkillSpecialAbility", database.advancedSkillSpecialAbilities), + advantages: getSelectOptionsForResults(database, "Advantage", database.advantages), + ancestorGlyphs: getSelectOptionsForResults(database, "AncestorGlyph", database.ancestorGlyphs), + arcaneOrbEnchantments: getSelectOptionsForResults(database, "ArcaneOrbEnchantment", database.arcaneOrbEnchantments), + attireEnchantments: getSelectOptionsForResults(database, "AttireEnchantment", database.attireEnchantments), + blessedTraditions: getSelectOptionsForResults(database, "BlessedTradition", database.blessedTraditions), + bowlEnchantments: getSelectOptionsForResults(database, "BowlEnchantment", database.bowlEnchantments), + brawlingSpecialAbilities: getSelectOptionsForResults(database, "BrawlingSpecialAbility", database.brawlingSpecialAbilities), + cauldronEnchantments: getSelectOptionsForResults(database, "CauldronEnchantment", database.cauldronEnchantments), + ceremonialItemSpecialAbilities: getSelectOptionsForResults(database, "CeremonialItemSpecialAbility", database.ceremonialItemSpecialAbilities), + chronicleEnchantments: getSelectOptionsForResults(database, "ChronicleEnchantment", database.chronicleEnchantments), + combatSpecialAbilities: getSelectOptionsForResults(database, "CombatSpecialAbility", database.combatSpecialAbilities), + combatStyleSpecialAbilities: getSelectOptionsForResults(database, "CombatStyleSpecialAbility", database.combatStyleSpecialAbilities), + commandSpecialAbilities: getSelectOptionsForResults(database, "CommandSpecialAbility", database.commandSpecialAbilities), + daggerRituals: getSelectOptionsForResults(database, "DaggerRitual", database.daggerRituals), + disadvantages: getSelectOptionsForResults(database, "Disadvantage", database.disadvantages), + familiarSpecialAbilities: getSelectOptionsForResults(database, "FamiliarSpecialAbility", database.familiarSpecialAbilities), + fatePointSexSpecialAbilities: getSelectOptionsForResults(database, "FatePointSexSpecialAbility", database.fatePointSexSpecialAbilities), + fatePointSpecialAbilities: getSelectOptionsForResults(database, "FatePointSpecialAbility", database.fatePointSpecialAbilities), + foolsHatEnchantments: getSelectOptionsForResults(database, "FoolsHatEnchantment", database.foolsHatEnchantments), + generalSpecialAbilities: getSelectOptionsForResults(database, "GeneralSpecialAbility", database.generalSpecialAbilities), + instrumentEnchantments: getSelectOptionsForResults(database, "InstrumentEnchantment", database.instrumentEnchantments), + karmaSpecialAbilities: getSelectOptionsForResults(database, "KarmaSpecialAbility", database.karmaSpecialAbilities), + krallenkettenzauber: getSelectOptionsForResults(database, "Krallenkettenzauber", database.krallenkettenzauber), + liturgicalStyleSpecialAbilities: getSelectOptionsForResults(database, "LiturgicalStyleSpecialAbility", database.liturgicalStyleSpecialAbilities), + lycantropicGifts: getSelectOptionsForResults(database, "LycantropicGift", database.lycantropicGifts), + magicalSpecialAbilities: getSelectOptionsForResults(database, "MagicalSpecialAbility", database.magicalSpecialAbilities), + magicalTraditions: getSelectOptionsForResults(database, "MagicalTradition", database.magicalTraditions), + magicStyleSpecialAbilities: getSelectOptionsForResults(database, "MagicStyleSpecialAbility", database.magicStyleSpecialAbilities), + orbEnchantments: getSelectOptionsForResults(database, "OrbEnchantment", database.orbEnchantments), + pactGifts: getSelectOptionsForResults(database, "PactGift", database.pactGifts), + protectiveWardingCircleSpecialAbilities: getSelectOptionsForResults(database, "ProtectiveWardingCircleSpecialAbility", database.protectiveWardingCircleSpecialAbilities), + ringEnchantments: getSelectOptionsForResults(database, "RingEnchantment", database.ringEnchantments), + sermons: getSelectOptionsForResults(database, "Sermon", database.sermons), + sexSpecialAbilities: getSelectOptionsForResults(database, "SexSpecialAbility", database.sexSpecialAbilities), + sickleRituals: getSelectOptionsForResults(database, "SickleRitual", database.sickleRituals), + sikaryanDrainSpecialAbilities: getSelectOptionsForResults(database, "SikaryanDrainSpecialAbility", database.sikaryanDrainSpecialAbilities), + staffEnchantments: getSelectOptionsForResults(database, "StaffEnchantment", database.staffEnchantments), + skillStyleSpecialAbilities: getSelectOptionsForResults(database, "SkillStyleSpecialAbility", database.skillStyleSpecialAbilities), + spellSwordEnchantments: getSelectOptionsForResults(database, "SpellSwordEnchantment", database.spellSwordEnchantments), + toyEnchantments: getSelectOptionsForResults(database, "ToyEnchantment", database.toyEnchantments), + trinkhornzauber: getSelectOptionsForResults(database, "Trinkhornzauber", database.trinkhornzauber), + vampiricGifts: getSelectOptionsForResults(database, "VampiricGift", database.vampiricGifts), + visions: getSelectOptionsForResults(database, "Vision", database.visions), + wandEnchantments: getSelectOptionsForResults(database, "WandEnchantment", database.wandEnchantments), + weaponEnchantments: getSelectOptionsForResults(database, "WeaponEnchantment", database.weaponEnchantments), + } + }, +} diff --git a/src/cache/newApplicationsAndUses.ts b/src/cache/newApplicationsAndUses.ts index 818da3b2..dbc2b254 100644 --- a/src/cache/newApplicationsAndUses.ts +++ b/src/cache/newApplicationsAndUses.ts @@ -1,6 +1,7 @@ import type { CacheConfig } from "../cacheConfig.js" import type { SkillApplication, SkillUse } from "../types/_Activatable.js" import type { ActivatableIdentifier } from "../types/_IdentifierGroup.js" +import { ActivatableSelectOptionsCache } from "./activatableSelectOptions.js" export type NewApplication = { source: ActivatableIdentifier @@ -18,8 +19,11 @@ export type NewApplicationsAndUsesCache = { uses: Record } -export const config: CacheConfig = { - builder(database) { +export const config: CacheConfig< + NewApplicationsAndUsesCache, + [generatedSelectOptions: ActivatableSelectOptionsCache] +> = { + builder(database, generatedSelectOptions) { type WithApplicationsAndUses = { skill_applications?: SkillApplication[] skill_uses?: SkillUse[] @@ -97,6 +101,71 @@ export const config: CacheConfig = { }) }) + // prettier-ignore + const generatedEntries: [ + { [id: number]: WithApplicationsAndUses[] }, + (numericId: number) => ActivatableIdentifier, + ][] = [ + [generatedSelectOptions.advancedCombatSpecialAbilities, id => ({ tag: "AdvancedCombatSpecialAbility", advanced_combat_special_ability: id })], + [generatedSelectOptions.advancedKarmaSpecialAbilities, id => ({ tag: "AdvancedKarmaSpecialAbility", advanced_karma_special_ability: id })], + [generatedSelectOptions.advancedMagicalSpecialAbilities, id => ({ tag: "AdvancedMagicalSpecialAbility", advanced_magical_special_ability: id })], + [generatedSelectOptions.advancedSkillSpecialAbilities, id => ({ tag: "AdvancedSkillSpecialAbility", advanced_skill_special_ability: id })], + [generatedSelectOptions.advantages, id => ({ tag: "Advantage", advantage: id })], + [generatedSelectOptions.ancestorGlyphs, id => ({ tag: "AncestorGlyph", ancestor_glyph: id })], + [generatedSelectOptions.arcaneOrbEnchantments, id => ({ tag: "ArcaneOrbEnchantment", arcane_orb_enchantment: id })], + [generatedSelectOptions.attireEnchantments, id => ({ tag: "AttireEnchantment", attire_enchantment: id })], + [generatedSelectOptions.blessedTraditions, id => ({ tag: "BlessedTradition", blessed_tradition: id })], + [generatedSelectOptions.bowlEnchantments, id => ({ tag: "BowlEnchantment", bowl_enchantment: id })], + [generatedSelectOptions.brawlingSpecialAbilities, id => ({ tag: "BrawlingSpecialAbility", brawling_special_ability: id })], + [generatedSelectOptions.cauldronEnchantments, id => ({ tag: "CauldronEnchantment", cauldron_enchantment: id })], + [generatedSelectOptions.ceremonialItemSpecialAbilities, id => ({ tag: "CeremonialItemSpecialAbility", ceremonial_item_special_ability: id })], + [generatedSelectOptions.chronicleEnchantments, id => ({ tag: "ChronicleEnchantment", chronicle_enchantment: id })], + [generatedSelectOptions.combatSpecialAbilities, id => ({ tag: "CombatSpecialAbility", combat_special_ability: id })], + [generatedSelectOptions.combatStyleSpecialAbilities, id => ({ tag: "CombatStyleSpecialAbility", combat_style_special_ability: id })], + [generatedSelectOptions.commandSpecialAbilities, id => ({ tag: "CommandSpecialAbility", command_special_ability: id })], + [generatedSelectOptions.daggerRituals, id => ({ tag: "DaggerRitual", dagger_ritual: id })], + [generatedSelectOptions.disadvantages, id => ({ tag: "Disadvantage", disadvantage: id })], + [generatedSelectOptions.familiarSpecialAbilities, id => ({ tag: "FamiliarSpecialAbility", familiar_special_ability: id })], + [generatedSelectOptions.fatePointSexSpecialAbilities, id => ({ tag: "FatePointSexSpecialAbility", fate_point_sex_special_ability: id })], + [generatedSelectOptions.fatePointSpecialAbilities, id => ({ tag: "FatePointSpecialAbility", fate_point_special_ability: id })], + [generatedSelectOptions.foolsHatEnchantments, id => ({ tag: "FoolsHatEnchantment", fools_hat_enchantment: id })], + [generatedSelectOptions.generalSpecialAbilities, id => ({ tag: "GeneralSpecialAbility", general_special_ability: id })], + [generatedSelectOptions.instrumentEnchantments, id => ({ tag: "InstrumentEnchantment", instrument_enchantment: id })], + [generatedSelectOptions.karmaSpecialAbilities, id => ({ tag: "KarmaSpecialAbility", karma_special_ability: id })], + [generatedSelectOptions.krallenkettenzauber, id => ({ tag: "Krallenkettenzauber", krallenkettenzauber: id })], + [generatedSelectOptions.liturgicalStyleSpecialAbilities, id => ({ tag: "LiturgicalStyleSpecialAbility", liturgical_style_special_ability: id })], + [generatedSelectOptions.lycantropicGifts, id => ({ tag: "LycantropicGift", lycantropic_gift: id })], + [generatedSelectOptions.magicalSpecialAbilities, id => ({ tag: "MagicalSpecialAbility", magical_special_ability: id })], + [generatedSelectOptions.magicalTraditions, id => ({ tag: "MagicalTradition", magical_tradition: id })], + [generatedSelectOptions.magicStyleSpecialAbilities, id => ({ tag: "MagicStyleSpecialAbility", magic_style_special_ability: id })], + [generatedSelectOptions.orbEnchantments, id => ({ tag: "OrbEnchantment", orb_enchantment: id })], + [generatedSelectOptions.pactGifts, id => ({ tag: "PactGift", pact_gift: id })], + [generatedSelectOptions.protectiveWardingCircleSpecialAbilities, id => ({ tag: "ProtectiveWardingCircleSpecialAbility", protective_warding_circle_special_ability: id })], + [generatedSelectOptions.ringEnchantments, id => ({ tag: "RingEnchantment", ring_enchantment: id })], + [generatedSelectOptions.sermons, id => ({ tag: "Sermon", sermon: id })], + [generatedSelectOptions.sexSpecialAbilities, id => ({ tag: "SexSpecialAbility", sex_special_ability: id })], + [generatedSelectOptions.sickleRituals, id => ({ tag: "SickleRitual", sickle_ritual: id })], + [generatedSelectOptions.sikaryanDrainSpecialAbilities, id => ({ tag: "SikaryanDrainSpecialAbility", sikaryan_drain_special_ability: id })], + [generatedSelectOptions.staffEnchantments, id => ({ tag: "StaffEnchantment", staff_enchantment: id })], + [generatedSelectOptions.skillStyleSpecialAbilities, id => ({ tag: "SkillStyleSpecialAbility", skill_style_special_ability: id })], + [generatedSelectOptions.spellSwordEnchantments, id => ({ tag: "SpellSwordEnchantment", spell_sword_enchantment: id })], + [generatedSelectOptions.toyEnchantments, id => ({ tag: "ToyEnchantment", toy_enchantment: id })], + [generatedSelectOptions.trinkhornzauber, id => ({ tag: "Trinkhornzauber", trinkhornzauber: id })], + [generatedSelectOptions.vampiricGifts, id => ({ tag: "VampiricGift", vampiric_gift: id })], + [generatedSelectOptions.visions, id => ({ tag: "Vision", vision: id })], + [generatedSelectOptions.wandEnchantments, id => ({ tag: "WandEnchantment", wand_enchantment: id })], + [generatedSelectOptions.weaponEnchantments, id => ({ tag: "WeaponEnchantment", weapon_enchantment: id })], + ] + + generatedEntries.forEach(([ids, createId]) => + Object.entries(ids).forEach(([stringId, selectOptions]) => { + const id = createId(Number.parseInt(stringId)) + selectOptions.forEach(selectOption => { + addNewApplicationsAndUses(selectOption, id) + }) + }) + ) + return cache }, } diff --git a/src/cacheConfig.ts b/src/cacheConfig.ts index 7c68b7d1..2514286a 100644 --- a/src/cacheConfig.ts +++ b/src/cacheConfig.ts @@ -1,5 +1,5 @@ import { ValidResults } from "./main.js" -export type CacheConfig = { - builder: (data: ValidResults) => T +export type CacheConfig = { + builder: (data: ValidResults, ...deps: D) => T } diff --git a/src/config/cache.ts b/src/config/cache.ts index fd20f80a..1f8e8fe1 100644 --- a/src/config/cache.ts +++ b/src/config/cache.ts @@ -1,12 +1,15 @@ +import * as ActivatableSelectOptions from "../cache/activatableSelectOptions.js" import * as AncestorBloodAdvantages from "../cache/ancestorBloodAdvantages.js" import * as NewApplicationsAndUses from "../cache/newApplicationsAndUses.js" export type CacheMap = { + activatableSelectOptions: ActivatableSelectOptions.ActivatableSelectOptionsCache ancestorBloodAdvantages: AncestorBloodAdvantages.AncestorBloodAdvantagesCache newApplicationsAndUses: NewApplicationsAndUses.NewApplicationsAndUsesCache } export const cacheMap = { + activatableSelectOptions: ActivatableSelectOptions.config, ancestorBloodAdvantages: AncestorBloodAdvantages.config, newApplicationsAndUses: NewApplicationsAndUses.config, } diff --git a/src/helpers/nullable.test.ts b/src/helpers/nullable.test.ts new file mode 100644 index 00000000..7cfb1116 --- /dev/null +++ b/src/helpers/nullable.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { + isNotNullish, + isNullish, + mapNullable, + mapNullableDefault, + nullableToArray, +} from "./nullable.js" + +describe("isNullish", () => { + it("returns if a value is nullish", () => { + assert.equal(isNullish(null), true) + assert.equal(isNullish(undefined), true) + assert.equal(isNullish(false), false) + assert.equal(isNullish(0), false) + assert.equal(isNullish(""), false) + }) +}) + +describe("isNotNullish", () => { + it("returns if a value is not nullish", () => { + assert.equal(isNotNullish(null), false) + assert.equal(isNotNullish(undefined), false) + assert.equal(isNotNullish(false), true) + assert.equal(isNotNullish(0), true) + assert.equal(isNotNullish(""), true) + }) +}) + +describe("mapNullable", () => { + it("maps a value if it is not nullish", () => { + assert.equal( + mapNullable(2, x => x * 2), + 4 + ) + }) + + it("returns the original value if it is nullish", () => { + assert.equal( + mapNullable(undefined, x => x * 2), + undefined + ) + }) +}) + +describe("mapNullableDefault", () => { + it("maps a value if it is not nullish", () => { + assert.equal( + mapNullableDefault(2, x => x * 2, 0), + 4 + ) + }) + + it("returns a default if the value is nullish", () => { + assert.equal( + mapNullableDefault(undefined, x => x * 2, 0), + 0 + ) + }) +}) + +describe("nullableToArray", () => { + it("wraps a non-nullish value into an array", () => { + assert.deepEqual(nullableToArray(2), [2]) + }) + + it("returns an empty array if the value is null or undefined", () => { + assert.deepEqual(nullableToArray(undefined), []) + assert.deepEqual(nullableToArray(null), []) + }) +}) diff --git a/src/helpers/nullable.ts b/src/helpers/nullable.ts new file mode 100644 index 00000000..6d537ab8 --- /dev/null +++ b/src/helpers/nullable.ts @@ -0,0 +1,47 @@ +/** + * Extracts `null` and `undefined` from a type. + */ +export type Nullish = T extends null | undefined ? T : never + +/** + * Checks if a value is `null` or `undefined`. + */ +export const isNullish = (value: T): value is Exclude> => + value === null || value === undefined + +/** + * Checks if a value is not `null` or `undefined`. + */ +export const isNotNullish = (value: T): value is NonNullable => !isNullish(value) + +/** + * Maps a value to another value if it is not `null` or `undefined`. + */ +export const mapNullable = (value: T, map: (value: NonNullable) => U): U | Nullish => + isNotNullish(value) ? map(value) : (value as Nullish) + +/** + * Maps a value to another value if it is not `null` or `undefined`, otherwise + * returns a default value. + */ +export const mapNullableDefault = ( + value: T, + map: (value: NonNullable) => U, + defaultValue: U, +): U => (isNotNullish(value) ? map(value) : defaultValue) + +/** + * Returns an array, containing the value if it is not `null` or `undefined`. + * + * This can be useful in combination with the spread operator or + * `Array.prototype.flatMap`. + * @example + * nullableToArray(2) // [2] + * nullableToArray(undefined) // [] + * + * [...nullableToArray(2)] // [2] + * [1, ...nullableToArray(2)] // [1, 2] + * [1, ...nullableToArray(undefined)] // [1] + */ +export const nullableToArray = (value: T): NonNullable[] => + isNotNullish(value) ? [value] : [] diff --git a/src/helpers/object.test.ts b/src/helpers/object.test.ts new file mode 100644 index 00000000..c740e6dd --- /dev/null +++ b/src/helpers/object.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { mapObject } from "./object.js" + +describe("mapObject", () => { + it("maps all own properties of an object to a new object", () => { + const object = { a: 1, b: 2, c: 3 } + const result = mapObject(object, (value, key) => value + key) + assert.deepEqual(result, { a: "1a", b: "2b", c: "3c" }) + }) + + it("omits properties for which the mapping function returns undefined", () => { + const object = { a: 1, b: 2, c: 3 } + const result = mapObject(object, (value, key) => (key === "b" ? undefined : value + key)) + assert.deepEqual(result, { a: "1a", c: "3c" }) + }) +}) diff --git a/src/helpers/object.ts b/src/helpers/object.ts new file mode 100644 index 00000000..a32080f7 --- /dev/null +++ b/src/helpers/object.ts @@ -0,0 +1,21 @@ +/** + * Maps all own properties of an object to a new object. Returning `undefined` + * from the mapping function will omit the property from the result. + */ +export const mapObject = ( + object: T, + map: (value: T[keyof T], key: keyof T) => U | undefined +): { [key in keyof T]: Exclude } => { + const result: { [key in keyof T]: Exclude } = {} as never + + for (const key in object) { + if (Object.hasOwn(object, key)) { + const newValue = map(object[key], key) + if (newValue !== undefined) { + result[key] = newValue as Exclude + } + } + } + + return result +} diff --git a/src/helpers/typeSafety.test.ts b/src/helpers/typeSafety.test.ts new file mode 100644 index 00000000..58919d1f --- /dev/null +++ b/src/helpers/typeSafety.test.ts @@ -0,0 +1,13 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { assertExhaustive } from "./typeSafety.js" + +describe("assertExhaustive", () => { + it("should throw an error with the message 'The switch is not exhaustive.'", () => { + assert.throws( + // @ts-expect-error The function should never receive a value. + () => assertExhaustive(""), + err => err instanceof Error && err.message === "The switch is not exhaustive." + ) + }) +}) diff --git a/src/helpers/typeSafety.ts b/src/helpers/typeSafety.ts index c2abc1c0..c11e02a9 100644 --- a/src/helpers/typeSafety.ts +++ b/src/helpers/typeSafety.ts @@ -1,8 +1,16 @@ /** * This function is used to make sure that the `switch` is exhaustive. Place it * in the `default` case of the `switch`. - * @param x The value that is used in the `switch`. + * @param _x - The value that is used in the `switch`. + * @example + * const aorb = (x: "a" | "b") => { + * switch (x) { + * case "a": return 1 + * case "b": return 2 + * default: return assertExhaustive(x) + * } + * } */ -export function assertExhaustive(x: never): never { - throw new Error("The switch is not exhaustive."); +export function assertExhaustive(_x: never): never { + throw new Error("The switch is not exhaustive.") } diff --git a/src/main.ts b/src/main.ts index 075f9479..466e6bbc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,6 @@ import type { Options as AjvOptions } from "ajv" import { mkdir, readFile, writeFile } from "node:fs/promises" import { dirname } from "node:path" -import { CacheConfig } from "./cacheConfig.js" import { CacheMap, cacheMap } from "./config/cache.js" import { TypeId, TypeMap } from "./config/types.js" import "./helpers/array.js" @@ -10,7 +9,12 @@ import { Ok, Result, error, isError, isOk, ok } from "./helpers/result.js" import { IntegrityError } from "./validation/builders/integrity.js" import { FileNameError } from "./validation/builders/naming.js" import { SchemaError } from "./validation/builders/schema.js" -import { TypeIdPair, TypeValidationResult, TypeValidationResultsByType, getRawValidationResults } from "./validation/raw.js" +import { + TypeIdPair, + TypeValidationResult, + TypeValidationResultsByType, + getRawValidationResults, +} from "./validation/raw.js" /** * Options for validating data files. @@ -61,41 +65,40 @@ type StrictResults = Result> const rawResultMapToResult = (rawResultMap: TypeValidationResultsByType): StrictResults => rawResultMap.reduce( (result: StrictResults, [typeName, typeResults]) => - typeResults.reduce( - (outerResult, [filePath, fileResult]): StrictResults => { - if (isOk(outerResult) && isOk(fileResult)) { - return ok({ - ...outerResult.value, - [typeName]: [...(outerResult.value[typeName] ?? []), fileResult.value] - }) - } - else if (isOk(outerResult) && isError(fileResult)) { - return error({ - [filePath]: fileResult.error - }) - } - else if (isError(outerResult) && isError(fileResult)) { - return error({ - ...outerResult.error, - [filePath]: fileResult.error - }) - } - else { - return outerResult - } - }, - result - ), + typeResults.reduce((outerResult, [filePath, fileResult]): StrictResults => { + if (isOk(outerResult) && isOk(fileResult)) { + return ok({ + ...outerResult.value, + [typeName]: [...(outerResult.value[typeName] ?? []), fileResult.value], + }) + } else if (isOk(outerResult) && isError(fileResult)) { + return error({ + [filePath]: fileResult.error, + }) + } else if (isError(outerResult) && isError(fileResult)) { + return error({ + ...outerResult.error, + [filePath]: fileResult.error, + }) + } else { + return outerResult + } + }, result), ok({}) as StrictResults ) const filterResultMapByValidData = (rawResultMap: TypeValidationResultsByType): ValidResults => rawResultMap - .map(mapSecond((typeResults): [id: TypeId, data: TypeMap[keyof TypeMap]][] => - typeResults - .filter((typeResult): typeResult is [filePath: string, result: Ok>] => Result.isOk(typeResult[1])) - .map(fileResult => fileResult[1].value) - )) + .map( + mapSecond((typeResults): [id: TypeId, data: TypeMap[keyof TypeMap]][] => + typeResults + .filter( + (typeResult): typeResult is [filePath: string, result: Ok>] => + Result.isOk(typeResult[1]) + ) + .map(fileResult => fileResult[1].value) + ) + ) .objectFromEntries() as ValidResults /** @@ -166,13 +169,33 @@ export type CacheOptions = { * `getAllValidData`. * @param options Configuration options for building the cache. */ -export const buildCache = async (cachePaths: CachePaths, validResults: ValidResults, options: CacheOptions = {}): Promise => { +export const buildCache = async ( + cachePaths: CachePaths, + validResults: ValidResults, + options: CacheOptions = {} +): Promise => { const { pretty = false } = options + + const activatableSelectOptionsCache = cacheMap.activatableSelectOptions.builder(validResults) + const ancestorBloodAdvantagesCache = cacheMap.ancestorBloodAdvantages.builder(validResults) + const newApplicationsAndUsesCache = cacheMap.newApplicationsAndUses.builder( + validResults, + activatableSelectOptionsCache + ) + + const cacheData: CacheMap = { + activatableSelectOptions: activatableSelectOptionsCache, + ancestorBloodAdvantages: ancestorBloodAdvantagesCache, + newApplicationsAndUses: newApplicationsAndUsesCache, + } + for (const [cacheName, cachePath] of Object.entries(cachePaths)) { - const cacheConfig: CacheConfig = cacheMap[cacheName as keyof CacheMap] - const cacheData = cacheConfig.builder(validResults) await mkdir(dirname(cachePath), { recursive: true }) - await writeFile(cachePath, JSON.stringify(cacheData, null, pretty ? 2 : undefined), "utf-8") + await writeFile( + cachePath, + JSON.stringify(cacheData[cacheName as keyof CachePaths], null, pretty ? 2 : undefined), + "utf-8" + ) } } @@ -184,7 +207,6 @@ export const buildCache = async (cachePaths: CachePaths, validResults: ValidResu export const getCache = async (cachePaths: CachePaths): Promise => { const cache: Partial = {} for (const [cacheName, cachePath] of Object.entries(cachePaths)) { - const cacheConfig: CacheConfig = cacheMap[cacheName as keyof CacheMap] const cacheData = JSON.parse(await readFile(cachePath, "utf-8")) cache[cacheName as keyof CacheMap] = cacheData }