diff --git a/package-lock.json b/package-lock.json index d668d8e..aec2096 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@optolith/database-schema": "^0.52.1", + "@optolith/database-schema": "^0.53.0", "@types/node": "^25.9.1", "commit-and-tag-version": "^12.7.3", "eslint-config-prettier": "^10.1.8", @@ -83,9 +83,9 @@ } }, "node_modules/@elyukai/utils": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@elyukai/utils/-/utils-0.3.4.tgz", - "integrity": "sha512-zqBF3Nz7Pd5e35E2mZuImeAvCSRlAneIWb/gZKtaa9yX1oPTW/4uVKrK5k7QE9pR7xMPo7FTUs2jZ5ZDaZ75PA==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@elyukai/utils/-/utils-0.3.6.tgz", + "integrity": "sha512-SFij3T0baqFu+G5NGjlHPVpLtKEeVRKg59jiMr7wQwAYM7LLpgoArBLeM/UL98FM0ntmvaRDDEBCfyMcCnsxdA==", "license": "MPL-2.0" }, "node_modules/@es-joy/jsdoccomment": { @@ -842,13 +842,13 @@ "license": "MPL-2.0" }, "node_modules/@optolith/database-schema": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@optolith/database-schema/-/database-schema-0.52.1.tgz", - "integrity": "sha512-XOSbSfJIGYo7+ud+RKziJPsnf7QsS43Zbgf3yv9FPIUNCgq6n/TlK1va4V9mLXvhayxTEdoEr4YTNkl/04VruQ==", + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@optolith/database-schema/-/database-schema-0.53.0.tgz", + "integrity": "sha512-T41UV2G0l30iIMWBvw8pABNHcWkrR5iE88MvRYm/4GfXwacSkzOAiuXtqn/gVj9Wq3HIbI1cMizgU2y1QtB+dw==", "dev": true, "license": "MPL-2.0", "dependencies": { - "@elyukai/utils": "^0.3.4", + "@elyukai/utils": "^0.3.6", "tsondb": "^0.20.3", "yaml": "^2.9.0" } diff --git a/package.json b/package.json index 5be7f78..6b9439e 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@optolith/database-schema": "^0.52.1", + "@optolith/database-schema": "^0.53.0", "@types/node": "^25.9.1", "commit-and-tag-version": "^12.7.3", "eslint-config-prettier": "^10.1.8", diff --git a/src/entities/spell.ts b/src/entities/spell.ts index 88845d5..fb65ea3 100644 --- a/src/entities/spell.ts +++ b/src/entities/spell.ts @@ -11,6 +11,11 @@ import { type AnimistPowerPerformanceParameters, type ArcaneBardTraditionReference, type ArcaneDancerTraditionReference, + type BannzeichenCost, + type BannzeichenCraftingTime, + type BannzeichenDuration, + type BannzeichenImprovementCost, + type BannzeichenOption, type FamiliarsTrickPerformanceParameters, type FamiliarsTrickProperty, type MagicalRuneCost, @@ -22,6 +27,7 @@ import { type OldParameterBySpeed, type Property_ID, type RatedIdentifier, + type SingleBannzeichenCost, type SpellworkTraditions, type Tribe_ID, } from "@optolith/database-schema/gen" @@ -51,6 +57,8 @@ import { renderMagicalActionCost, renderModifiableOneTimeCost, renderNonModifiableOneTimeCost, + renderNonModifiableSustainedCost, + renderOneTimeCostMap, } from "./partial/rated/activatable/cost.js" import { renderExpressionBasedDuration, @@ -67,7 +75,7 @@ import { } from "./partial/rated/activatable/index.js" import { ModifiableParameter } from "./partial/rated/activatable/nonModifiableSuffix.js" import { parensIf } from "./partial/rated/activatable/parensIf.js" -import { renderNonModifiableRange } from "./partial/rated/activatable/range.js" +import { renderNonModifiableRange, renderRange } from "./partial/rated/activatable/range.js" import { Speed } from "./partial/rated/activatable/speed.js" import { renderTargetCategory } from "./partial/rated/activatable/targetCategory.js" import { @@ -1336,6 +1344,110 @@ export const getJesterTrickEntityDescription = createEntityDescriptionCreator< } }) +/** + * Get a JSON representation of the rules text for a goblin ritual. + */ +export const getGoblinRitualEntityDescription = createEntityDescriptionCreator< + "GoblinRitual", + { + getInstanceById: GetInstanceById< + | "Publication" + | "Attribute" + | "SkillModificationLevel" + | "TargetCategory" + | "Property" + | "MagicalTradition" + | "DerivedCharacteristic" + > + idMap: IdMap + } +>(({ getInstanceById, idMap }, locale, { content: entry }) => { + const { translate, translateMap } = locale + const translation = translateMap(entry.translations) + + if (translation === undefined) { + return undefined + } + + const env = { + translate, + translateMap, + getInstanceById, + localeJoin: locale.join, + speed: Speed.Slow, + energyUnit: "ArcaneEnergy", + responsiveTextSize: ResponsiveTextSize.Full, + } satisfies Partial + + const { castingTime, cost, range, duration } = (() => { + switch (entry.parameters.kind) { + case "OneTime": { + const parameters = entry.parameters.OneTime + const { value, unit } = parameters.casting_time + const renderedCastingTime = + unit.kind === "Actions" + ? renderFastSkillNonModifiableCastingTime({ actions: value }).run(env) + : renderSlowSkillNonModifiableCastingTime({ value, unit }).run(env) + + return { + castingTime: renderedCastingTime, + cost: renderNonModifiableOneTimeCost(parameters.cost, false).run(env), + range: renderRange(parameters.range).run(env), + duration: renderOneTimeDuration(parameters.duration).run(env), + } + } + case "Sustained": { + const parameters = entry.parameters.Sustained + const { value, unit } = parameters.casting_time + const renderedCastingTime = + unit.kind === "Actions" + ? renderFastSkillNonModifiableCastingTime({ actions: value }).run(env) + : renderSlowSkillNonModifiableCastingTime({ value, unit }).run(env) + + return { + castingTime: renderedCastingTime, + cost: renderNonModifiableSustainedCost(parameters.cost).run(env), + range: renderRange(parameters.range).run(env), + duration: renderSustainedDuration(undefined).run(env), + } + } + default: + return assertExhaustive(entry.parameters) + } + })() + + return { + title: translation.name, + className: "goblin-ritual", + body: [ + { + type: "definitionList", + items: [ + renderSkillCheckWithPenalty(entry.check, entry.check_penalty, idMap).run(env), + renderEffect(translation.effect).run(env), + combineGeneratedTextWithStaticTranslation( + translate("Ritual Time"), + castingTime, + translation.casting_time, + ), + combineGeneratedTextWithStaticTranslation(translate("AE Cost"), cost, translation.cost), + combineGeneratedTextWithStaticTranslation(translate("Range"), range, translation.range), + combineGeneratedTextWithStaticTranslation( + translate("Duration"), + duration, + translation.duration, + ), + renderTargetCategory(entry.target).run(env), + renderProperty(entry.property).run(env), + renderImprovementCost(entry.improvement_cost).run(env), + ], + }, + ], + errata: translation.errata, + references: entry.src, + } +}) + /** * Get a JSON representation of the rules text for a Zibilja ritual. */ @@ -1423,9 +1535,12 @@ export const getZibiljaRitualEntityDescription = createEntityDescriptionCreator< } }) -const deriveValueGroupsFromMagicalRuneOptions = ( - options: Lazy, - grouper: (option: MagicalRuneOption) => T, +const deriveValueGroupsFromNamedOptions = < + T, + O extends { translations?: Record }, +>( + options: Lazy, + grouper: (option: O) => T, comparator: Compare, printValue: (value: T) => string, ): StdReader => @@ -1447,32 +1562,128 @@ const deriveValueGroupsFromMagicalRuneOptions = ( ), ).then(formattedOptions => localeJoinR(formattedOptions, "disjunction")) +const deriveValueGroupsFromMagicalRuneOptions = ( + options: Lazy, + grouper: (option: MagicalRuneOption) => T, + comparator: Compare, + printValue: (value: T) => string, +): StdReader => + deriveValueGroupsFromNamedOptions(options, grouper, comparator, printValue) + +const renderSingleEnergyCost = (cost: Pick) => + formatEnergyR(cost.value).thenW(text => appendNoteIfNeeded(cost.translations, text)) + +const renderOptionDerivedEnergyCost = < + O extends { + translations?: Record + cost?: { value: number } + }, +>( + options: Lazy, + grouper: (option: O) => number | undefined, +): StdReader => + deriveValueGroupsFromNamedOptions( + options, + grouper, + compareNullish(numAsc), + num => num?.toFixed() ?? MISSING_VALUE, + ).thenW(formatEnergyR) + const renderMagicalRuneCost = (options: Lazy, cost: MagicalRuneCost) => { switch (cost.kind) { case "Single": - return formatEnergyR(cost.Single.value).thenW(text => - appendNoteIfNeeded(cost.Single.translations, text), + return renderSingleEnergyCost(cost.Single) + case "Disjunction": + return Reader.sequence(cost.Disjunction.list.map(renderSingleEnergyCost)).thenW(text => + localeJoinR(text, "disjunction"), ) + case "DerivedFromOption": + return renderOptionDerivedEnergyCost(options, option => option.cost?.value) + default: + return assertExhaustive(cost) + } +} + +const renderBannzeichenCost = (options: Lazy, cost: BannzeichenCost) => { + switch (cost.kind) { + case "Single": + return renderSingleEnergyCost(cost.Single) case "Disjunction": - return Reader.sequence( - cost.Disjunction.list.map(costItem => - formatEnergyR(costItem.value).thenW(text => - appendNoteIfNeeded(costItem.translations, text), - ), - ), - ).thenW(text => localeJoinR(text, "disjunction")) + return Reader.sequence(cost.Disjunction.list.map(renderSingleEnergyCost)).thenW(text => + localeJoinR(text, "disjunction"), + ) + case "Map": + return renderOneTimeCostMap(cost.Map) case "DerivedFromOption": - return deriveValueGroupsFromMagicalRuneOptions( - options, - option => option.cost?.value, - compareNullish(numAsc), - num => num?.toFixed() ?? MISSING_VALUE, - ).thenW(formatEnergyR) + return renderOptionDerivedEnergyCost(options, option => option.cost?.value) default: return assertExhaustive(cost) } } +const renderBannzeichenCraftingTimePart = ( + craftingTime: BannzeichenCraftingTime, + unit: TimeSpanUnit, +) => + formatTimeSpanR(unit, craftingTime.value).thenW(text => { + if (craftingTime.per === undefined) { + return Reader.of(text) + } + + const { translations } = craftingTime.per + + return translateMapR(translations).thenW(translation => { + if (translation === undefined) { + return Reader.of(text) + } + + return responsiveTextR(translation.countable).thenW(countable => + responsiveTranslateR("{$cost} per {$countable}", "{$cost}/{$countable}", { + cost: text, + countable, + }), + ) + }) + }) + +const renderBannzeichenCraftingTime = ( + craftingTime: BannzeichenCraftingTime, +): StdReader => + renderBannzeichenCraftingTimePart(craftingTime, "Actions").map2( + renderBannzeichenCraftingTimePart(craftingTime, "Days"), + (fast, slow) => `${slow} / ${fast}`, + ) + +const renderSlowFastCheckResultBasedDuration = ( + duration: BannzeichenDuration | MagicalRuneDuration, +) => + renderExpressionBasedDuration(duration.fast).map2( + renderExpressionBasedDuration(duration.slow), + (fast, slow) => `${slow} / ${fast}`, + ) + +const renderBannzeichenImprovementCost = ( + options: Lazy, + improvementCost: BannzeichenImprovementCost, +): StdReader => { + switch (improvementCost.kind) { + case "Constant": + return renderImprovementCost(improvementCost.Constant) + case "DerivedFromOption": + return deriveValueGroupsFromNamedOptions( + options, + option => + option.improvement_cost === undefined + ? undefined + : renderImprovementCostValue(option.improvement_cost), + compareNullish((a, b) => a.localeCompare(b)), + selectedImprovementCost => selectedImprovementCost ?? MISSING_VALUE, + ).then(value => translateR("Improvement Cost").map(label => ({ label, value }))) + default: + return assertExhaustive(improvementCost) + } +} + const renderSplitMagicalRuneParameterTranslation = ( parameter: OldParameterBySpeed, ): StdReader => @@ -1487,6 +1698,73 @@ const renderSplitMagicalRuneParameterTranslation = ( (fast, slow) => `${slow} / ${fast}`, ) +/** + * Get a JSON representation of the rules text for a Bannzeichen. + */ +export const getBannzeichenEntityDescription = createEntityDescriptionCreator< + "Bannzeichen", + { + getInstanceById: GetInstanceById< + "Publication" | "Attribute" | "Property" | "DerivedCharacteristic" + > + getChildInstancesForInstanceId: GetAllChildInstancesForParent<"BannzeichenOption"> + } +>(({ getInstanceById, getChildInstancesForInstanceId }, locale, { id, content: entry }) => { + const { translate, translateMap } = locale + const translation = translateMap(entry.translations) + + if (translation === undefined) { + return undefined + } + + const env = { + translate, + translateMap, + getInstanceById, + localeCompare: locale.compare, + localeJoin: locale.join, + energyUnit: "ArcaneEnergy", + responsiveTextSize: ResponsiveTextSize.Full, + } satisfies Partial + + const options = Lazy.of(() => + getChildInstancesForInstanceId("BannzeichenOption", id).map(item => item.content), + ) + + const cost = renderBannzeichenCost(options, entry.parameters.cost).run(env) + const craftingTime = renderBannzeichenCraftingTime(entry.parameters.crafting_time).run(env) + const duration = renderSlowFastCheckResultBasedDuration(entry.parameters.duration).run(env) + + return { + title: (translation.name_in_library ?? translation.name) + parensIf(translation.native_name), + className: "bannzeichen", + body: [ + { + type: "definitionList", + items: [ + renderSkillCheck(entry.check).run(env), + renderEffect(translation.effect).run(env), + combineGeneratedTextWithStaticTranslation(translate("AE Cost"), cost, translation.cost), + combineGeneratedTextWithStaticTranslation( + translate("Crafting Time (slow / fast)"), + craftingTime, + translation.crafting_time, + ), + combineGeneratedTextWithStaticTranslation( + translate("Duration (slow / fast)"), + duration, + translation.duration, + ), + renderProperty(entry.property).run(env), + renderBannzeichenImprovementCost(options, entry.improvement_cost).run(env), + ], + }, + ], + errata: translation.errata, + references: entry.src, + } +}) + const renderMagicalRuneCraftingTimePart = ( craftingTime: MagicalRuneCraftingTime, unit: TimeSpanUnit, @@ -1519,10 +1797,7 @@ const renderMagicalRuneCraftingTime = (craftingTime: MagicalRuneCraftingTime) => ) const renderMagicalRuneDuration = (duration: MagicalRuneDuration) => - renderExpressionBasedDuration(duration.fast).map2( - renderExpressionBasedDuration(duration.slow), - (fast, slow) => `${slow} / ${fast}`, - ) + renderSlowFastCheckResultBasedDuration(duration) const renderMagicalRuneImprovementCost = ( options: Lazy, @@ -1540,9 +1815,7 @@ const renderMagicalRuneImprovementCost = ( : renderImprovementCostValue(option.improvement_cost), compareNullish((a, b) => a.localeCompare(b)), selectedImprovementCost => selectedImprovementCost ?? MISSING_VALUE, - ) - .thenW(formatEnergyR) - .then(value => translateR("Improvement Cost").map(label => ({ label, value }))) + ).then(value => translateR("Improvement Cost").map(label => ({ label, value }))) default: return assertExhaustive(improvementCost) } diff --git a/src/index.ts b/src/index.ts index 688a529..b346526 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,12 +48,14 @@ import { getSexPracticeEntityDescription } from "./entities/sexPractice.js" import { getSkillEntityDescription } from "./entities/skill.js" import { getAnimistPowerEntityDescription, + getBannzeichenEntityDescription, getCantripEntityDescription, getCurseEntityDescription, getDominationRitualEntityDescription, getElvenMagicalSongEntityDescription, getFamiliarsTrickEntityDescription, getGeodeRitualEntityDescription, + getGoblinRitualEntityDescription, getJesterTrickEntityDescription, getMagicalDanceEntityDescription, getMagicalMelodyEntityDescription, @@ -282,6 +284,7 @@ const registeredEntityDescriptionCreators = { DominationRitual: getDominationRitualEntityDescription, ElvenMagicalSong: getElvenMagicalSongEntityDescription, GeodeRitual: getGeodeRitualEntityDescription, + GoblinRitual: getGoblinRitualEntityDescription, JesterTrick: getJesterTrickEntityDescription, MagicalDance: getMagicalDanceEntityDescription, MagicalMelody: getMagicalMelodyEntityDescription, @@ -302,6 +305,7 @@ const registeredEntityDescriptionCreators = { AncestorGlyph: getActivatableEntityDescription, ArcaneOrbEnchantment: getActivatableEntityDescription, AttireEnchantment: getActivatableEntityDescription, + Bannzeichen: getBannzeichenEntityDescription, Beutelzauber: getActivatableEntityDescription, BlessedTradition: getActivatableEntityDescription, BowlEnchantment: getActivatableEntityDescription,