From e49cc0d90c0de43729ebf5f6c4d817deb1323397 Mon Sep 17 00:00:00 2001 From: Lukas Obermann Date: Fri, 4 Oct 2024 13:37:04 +0200 Subject: [PATCH] feat: add library entries for close and ranged combattechniques, cantrips, spells, rituals, blessings, liturgical chants and ceremonies --- package-lock.json | 28 +- package.json | 2 +- src/database/contents | 2 +- .../inlineLibrary/InlineLibraryRouter.tsx | 24 +- .../entities/InlineLibraryBlessing.tsx | 25 + .../entities/InlineLibraryCantrip.tsx | 31 ++ .../entities/InlineLibraryCeremony.tsx | 35 ++ .../InlineLibraryCloseCombatTechnique.tsx | 25 + .../entities/InlineLibraryLiturgicalChant.tsx | 35 ++ .../InlineLibraryRangedCombatTechnique.tsx | 25 + .../entities/InlineLibraryRitual.tsx | 35 ++ .../entities/InlineLibrarySpell.tsx | 35 ++ .../liturgicalChants/LiturgicalChants.tsx | 4 +- .../activatableSkill/castingTime.ts | 167 +++++++ .../activatableSkill/checkResultBased.test.ts | 25 + .../activatableSkill/checkResultBased.ts | 42 ++ .../libraryEntry/activatableSkill/cost.ts | 456 ++++++++++++++++++ .../libraryEntry/activatableSkill/duration.ts | 222 +++++++++ .../libraryEntry/activatableSkill/effect.ts | 59 +++ .../libraryEntry/activatableSkill/entity.ts | 11 + .../activatableSkill/isMaximum.ts | 21 + .../activatableSkill/modifiableParameter.ts | 8 + .../activatableSkill/nonModifiable.ts | 83 ++++ .../libraryEntry/activatableSkill/parensIf.ts | 13 + .../libraryEntry/activatableSkill/range.ts | 221 +++++++++ .../libraryEntry/activatableSkill/speed.ts | 33 ++ .../activatableSkill/targetCategory.ts | 55 +++ .../libraryEntry/activatableSkill/units.ts | 123 +++++ .../domain/libraryEntry/responsiveText.ts | 89 ++++ src/shared/domain/libraryEntry/unknown.ts | 4 + src/shared/domain/rated/activatableSkill.ts | 128 +++++ src/shared/domain/rated/combatTechnique.ts | 74 +++ src/shared/domain/rated/liturgicalChant.ts | 370 +++++++++++++- src/shared/domain/rated/spell.ts | 444 ++++++++++++++++- src/shared/utils/array.test.ts | 13 + src/shared/utils/array.ts | 17 + src/shared/utils/translate.ts | 33 +- 37 files changed, 2981 insertions(+), 36 deletions(-) create mode 100644 src/main_window/inlineLibrary/entities/InlineLibraryBlessing.tsx create mode 100644 src/main_window/inlineLibrary/entities/InlineLibraryCantrip.tsx create mode 100644 src/main_window/inlineLibrary/entities/InlineLibraryCeremony.tsx create mode 100644 src/main_window/inlineLibrary/entities/InlineLibraryCloseCombatTechnique.tsx create mode 100644 src/main_window/inlineLibrary/entities/InlineLibraryLiturgicalChant.tsx create mode 100644 src/main_window/inlineLibrary/entities/InlineLibraryRangedCombatTechnique.tsx create mode 100644 src/main_window/inlineLibrary/entities/InlineLibraryRitual.tsx create mode 100644 src/main_window/inlineLibrary/entities/InlineLibrarySpell.tsx create mode 100644 src/shared/domain/libraryEntry/activatableSkill/castingTime.ts create mode 100644 src/shared/domain/libraryEntry/activatableSkill/checkResultBased.test.ts create mode 100644 src/shared/domain/libraryEntry/activatableSkill/checkResultBased.ts create mode 100644 src/shared/domain/libraryEntry/activatableSkill/cost.ts create mode 100644 src/shared/domain/libraryEntry/activatableSkill/duration.ts create mode 100644 src/shared/domain/libraryEntry/activatableSkill/effect.ts create mode 100644 src/shared/domain/libraryEntry/activatableSkill/entity.ts create mode 100644 src/shared/domain/libraryEntry/activatableSkill/isMaximum.ts create mode 100644 src/shared/domain/libraryEntry/activatableSkill/modifiableParameter.ts create mode 100644 src/shared/domain/libraryEntry/activatableSkill/nonModifiable.ts create mode 100644 src/shared/domain/libraryEntry/activatableSkill/parensIf.ts create mode 100644 src/shared/domain/libraryEntry/activatableSkill/range.ts create mode 100644 src/shared/domain/libraryEntry/activatableSkill/speed.ts create mode 100644 src/shared/domain/libraryEntry/activatableSkill/targetCategory.ts create mode 100644 src/shared/domain/libraryEntry/activatableSkill/units.ts create mode 100644 src/shared/domain/libraryEntry/responsiveText.ts create mode 100644 src/shared/domain/libraryEntry/unknown.ts create mode 100644 src/shared/domain/rated/activatableSkill.ts diff --git a/package-lock.json b/package-lock.json index baffdcf7d..6ae048a07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "moment": "^2.30.1", "mousetrap": "1.6.5", "optolith-character-schema": "^0.0.3", - "optolith-database-schema": "^0.16.8", + "optolith-database-schema": "^0.17.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", @@ -10641,9 +10641,10 @@ "integrity": "sha512-65wA26OjrFjvtCMaAieLZl2TMjYBx63E0nWRrZyWAzT0gjZ/FI8G8X7ouEF2EuTWYM2YYQMblq8/fK+PhGtWug==" }, "node_modules/optolith-database-schema": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/optolith-database-schema/-/optolith-database-schema-0.16.8.tgz", - "integrity": "sha512-1sKJQHtmxYFmlg/fempwdFPsz4bEOfaTpbPV7kl/bIfFaouHgx/ghnb8i/B5EGS7XtnMpj7f/Tonn2W6gIkCsA==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/optolith-database-schema/-/optolith-database-schema-0.17.0.tgz", + "integrity": "sha512-pvrJXVvivBbJ8CeFlcCoOYZOCiUwvjkJs77rfLB7coRFrnhI0PdVSGuG1v/XPCDOW3g5RWcKDPKmqSxvBDIIvg==", + "license": "MPL-2.0", "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^2.1.1", @@ -15067,9 +15068,10 @@ "dev": true }, "node_modules/yaml": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.0.tgz", - "integrity": "sha512-j9iR8g+/t0lArF4V6NE/QCfT+CO7iLqrXAHZbJdo+LfjqP1vR8Fg5bSiaq6Q2lOD1AUEVrEVIgABvBFYojJVYQ==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", + "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", + "license": "ISC", "bin": { "yaml": "bin.mjs" }, @@ -22851,9 +22853,9 @@ "integrity": "sha512-65wA26OjrFjvtCMaAieLZl2TMjYBx63E0nWRrZyWAzT0gjZ/FI8G8X7ouEF2EuTWYM2YYQMblq8/fK+PhGtWug==" }, "optolith-database-schema": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/optolith-database-schema/-/optolith-database-schema-0.16.8.tgz", - "integrity": "sha512-1sKJQHtmxYFmlg/fempwdFPsz4bEOfaTpbPV7kl/bIfFaouHgx/ghnb8i/B5EGS7XtnMpj7f/Tonn2W6gIkCsA==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/optolith-database-schema/-/optolith-database-schema-0.17.0.tgz", + "integrity": "sha512-pvrJXVvivBbJ8CeFlcCoOYZOCiUwvjkJs77rfLB7coRFrnhI0PdVSGuG1v/XPCDOW3g5RWcKDPKmqSxvBDIIvg==", "requires": { "ajv": "^8.12.0", "ajv-formats": "^2.1.1", @@ -26011,9 +26013,9 @@ "dev": true }, "yaml": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.0.tgz", - "integrity": "sha512-j9iR8g+/t0lArF4V6NE/QCfT+CO7iLqrXAHZbJdo+LfjqP1vR8Fg5bSiaq6Q2lOD1AUEVrEVIgABvBFYojJVYQ==" + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", + "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==" }, "yargs": { "version": "17.6.2", diff --git a/package.json b/package.json index 4b359759f..a6815d2c6 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "moment": "^2.30.1", "mousetrap": "1.6.5", "optolith-character-schema": "^0.0.3", - "optolith-database-schema": "^0.16.8", + "optolith-database-schema": "^0.17.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", diff --git a/src/database/contents b/src/database/contents index d17be0b48..c754ba252 160000 --- a/src/database/contents +++ b/src/database/contents @@ -1 +1 @@ -Subproject commit d17be0b48beea37ee8007fea0d2c8102b403079e +Subproject commit c754ba2523b4f466f275c55280f6bd18bc299e51 diff --git a/src/main_window/inlineLibrary/InlineLibraryRouter.tsx b/src/main_window/inlineLibrary/InlineLibraryRouter.tsx index fcbfb75b7..a00160df9 100644 --- a/src/main_window/inlineLibrary/InlineLibraryRouter.tsx +++ b/src/main_window/inlineLibrary/InlineLibraryRouter.tsx @@ -3,10 +3,18 @@ import { assertExhaustive } from "../../shared/utils/typeSafety.ts" import { DisplayableMainIdentifier } from "../slices/inlineWikiSlice.ts" import "./InlineLibrary.scss" import { InlineLibraryPlaceholder } from "./InlineLibraryPlaceholder.tsx" +import { InlineLibraryBlessing } from "./entities/InlineLibraryBlessing.tsx" +import { InlineLibraryCantrip } from "./entities/InlineLibraryCantrip.tsx" +import { InlineLibraryCeremony } from "./entities/InlineLibraryCeremony.tsx" +import { InlineLibraryCloseCombatTechnique } from "./entities/InlineLibraryCloseCombatTechnique.tsx" import { InlineLibraryExperienceLevel } from "./entities/InlineLibraryExperienceLevel.tsx" import { InlineLibraryFocusRule } from "./entities/InlineLibraryFocusRule.tsx" +import { InlineLibraryLiturgicalChant } from "./entities/InlineLibraryLiturgicalChant.tsx" import { InlineLibraryOptionalRule } from "./entities/InlineLibraryOptionalRule.tsx" +import { InlineLibraryRangedCombatTechnique } from "./entities/InlineLibraryRangedCombatTechnique.tsx" +import { InlineLibraryRitual } from "./entities/InlineLibraryRitual.tsx" import { InlineLibrarySkill } from "./entities/InlineLibrarySkill.tsx" +import { InlineLibrarySpell } from "./entities/InlineLibrarySpell.tsx" type Props = { id: DisplayableMainIdentifier @@ -48,7 +56,7 @@ export const InlineLibraryRouter: FC = ({ id }) => { case "BlessedTradition": return case "Blessing": - return + return case "Book": return case "BowlEnchantment": @@ -56,7 +64,7 @@ export const InlineLibraryRouter: FC = ({ id }) => { case "BrawlingSpecialAbility": return case "Cantrip": - return + return case "CauldronEnchantment": return case "CeremonialItem": @@ -64,11 +72,11 @@ export const InlineLibraryRouter: FC = ({ id }) => { case "CeremonialItemSpecialAbility": return case "Ceremony": - return + return case "ChronicleEnchantment": return case "CloseCombatTechnique": - return + return case "Clothes": return case "CombatSpecialAbility": @@ -134,7 +142,7 @@ export const InlineLibraryRouter: FC = ({ id }) => { case "Liebesspielzeug": return case "LiturgicalChant": - return + return case "LiturgicalStyleSpecialAbility": return case "LuxuryGood": @@ -178,11 +186,11 @@ export const InlineLibraryRouter: FC = ({ id }) => { case "Race": return case "RangedCombatTechnique": - return + return case "RingEnchantment": return case "Ritual": - return + return case "RopeOrChain": return case "Sermon": @@ -198,7 +206,7 @@ export const InlineLibraryRouter: FC = ({ id }) => { case "SkillStyleSpecialAbility": return case "Spell": - return + return case "SpellSwordEnchantment": return case "StaffEnchantment": diff --git a/src/main_window/inlineLibrary/entities/InlineLibraryBlessing.tsx b/src/main_window/inlineLibrary/entities/InlineLibraryBlessing.tsx new file mode 100644 index 000000000..3765975ad --- /dev/null +++ b/src/main_window/inlineLibrary/entities/InlineLibraryBlessing.tsx @@ -0,0 +1,25 @@ +import { FC } from "react" +import { LibraryEntry } from "../../../shared/components/libraryEntry/LibraryEntry.tsx" +import { getBlessingLibraryEntry } from "../../../shared/domain/rated/liturgicalChant.ts" +import { useAppSelector } from "../../hooks/redux.ts" +import { SelectGetById } from "../../selectors/basicCapabilitySelectors.ts" + +type Props = { + id: number +} + +/** + * Displays all information about a blessing. + */ +export const InlineLibraryBlessing: FC = ({ id }) => { + const getTargetCategoryById = useAppSelector(SelectGetById.Static.TargetCategory) + const entry = useAppSelector(SelectGetById.Static.Blessing)(id) + + return ( + + ) +} diff --git a/src/main_window/inlineLibrary/entities/InlineLibraryCantrip.tsx b/src/main_window/inlineLibrary/entities/InlineLibraryCantrip.tsx new file mode 100644 index 000000000..0e1ad7bf9 --- /dev/null +++ b/src/main_window/inlineLibrary/entities/InlineLibraryCantrip.tsx @@ -0,0 +1,31 @@ +import { FC } from "react" +import { LibraryEntry } from "../../../shared/components/libraryEntry/LibraryEntry.tsx" +import { getCantripLibraryEntry } from "../../../shared/domain/rated/spell.ts" +import { useAppSelector } from "../../hooks/redux.ts" +import { SelectGetById } from "../../selectors/basicCapabilitySelectors.ts" + +type Props = { + id: number +} + +/** + * Displays all information about a cantrip. + */ +export const InlineLibraryCantrip: FC = ({ id }) => { + const getCurriculumById = useAppSelector(SelectGetById.Static.Curriculum) + const getTargetCategoryById = useAppSelector(SelectGetById.Static.TargetCategory) + const getPropertyById = useAppSelector(SelectGetById.Static.Property) + const getMagicalTraditionById = useAppSelector(SelectGetById.Static.MagicalTradition) + const entry = useAppSelector(SelectGetById.Static.Cantrip)(id) + + return ( + + ) +} diff --git a/src/main_window/inlineLibrary/entities/InlineLibraryCeremony.tsx b/src/main_window/inlineLibrary/entities/InlineLibraryCeremony.tsx new file mode 100644 index 000000000..082af23a8 --- /dev/null +++ b/src/main_window/inlineLibrary/entities/InlineLibraryCeremony.tsx @@ -0,0 +1,35 @@ +import { FC } from "react" +import { LibraryEntry } from "../../../shared/components/libraryEntry/LibraryEntry.tsx" +import { getCeremonyLibraryEntry } from "../../../shared/domain/rated/liturgicalChant.ts" +import { useAppSelector } from "../../hooks/redux.ts" +import { SelectGetById } from "../../selectors/basicCapabilitySelectors.ts" + +type Props = { + id: number +} + +/** + * Displays all information about a ceremony. + */ +export const InlineLibraryCeremony: FC = ({ id }) => { + const getAttributeById = useAppSelector(SelectGetById.Static.Attribute) + const getDerivedCharacteristicById = useAppSelector(SelectGetById.Static.DerivedCharacteristic) + const getSkillModificationLevelById = useAppSelector(SelectGetById.Static.SkillModificationLevel) + const getTargetCategoryById = useAppSelector(SelectGetById.Static.TargetCategory) + const getBlessedTraditionById = useAppSelector(SelectGetById.Static.BlessedTradition) + const getAspectById = useAppSelector(SelectGetById.Static.Aspect) + const entry = useAppSelector(SelectGetById.Static.Ceremony)(id) + + return ( + + ) +} diff --git a/src/main_window/inlineLibrary/entities/InlineLibraryCloseCombatTechnique.tsx b/src/main_window/inlineLibrary/entities/InlineLibraryCloseCombatTechnique.tsx new file mode 100644 index 000000000..e97b6be41 --- /dev/null +++ b/src/main_window/inlineLibrary/entities/InlineLibraryCloseCombatTechnique.tsx @@ -0,0 +1,25 @@ +import { FC } from "react" +import { LibraryEntry } from "../../../shared/components/libraryEntry/LibraryEntry.tsx" +import { getCloseCombatTechniqueLibraryEntry } from "../../../shared/domain/rated/combatTechnique.ts" +import { useAppSelector } from "../../hooks/redux.ts" +import { SelectGetById } from "../../selectors/basicCapabilitySelectors.ts" + +type Props = { + id: number +} + +/** + * Displays all information about a close combat technique. + */ +export const InlineLibraryCloseCombatTechnique: FC = ({ id }) => { + const getAttributeById = useAppSelector(SelectGetById.Static.Attribute) + const entry = useAppSelector(SelectGetById.Static.CloseCombatTechnique)(id) + + return ( + + ) +} diff --git a/src/main_window/inlineLibrary/entities/InlineLibraryLiturgicalChant.tsx b/src/main_window/inlineLibrary/entities/InlineLibraryLiturgicalChant.tsx new file mode 100644 index 000000000..84e654491 --- /dev/null +++ b/src/main_window/inlineLibrary/entities/InlineLibraryLiturgicalChant.tsx @@ -0,0 +1,35 @@ +import { FC } from "react" +import { LibraryEntry } from "../../../shared/components/libraryEntry/LibraryEntry.tsx" +import { getLiturgicalChantLibraryEntry } from "../../../shared/domain/rated/liturgicalChant.ts" +import { useAppSelector } from "../../hooks/redux.ts" +import { SelectGetById } from "../../selectors/basicCapabilitySelectors.ts" + +type Props = { + id: number +} + +/** + * Displays all information about a liturgical chant. + */ +export const InlineLibraryLiturgicalChant: FC = ({ id }) => { + const getAttributeById = useAppSelector(SelectGetById.Static.Attribute) + const getDerivedCharacteristicById = useAppSelector(SelectGetById.Static.DerivedCharacteristic) + const getSkillModificationLevelById = useAppSelector(SelectGetById.Static.SkillModificationLevel) + const getTargetCategoryById = useAppSelector(SelectGetById.Static.TargetCategory) + const getBlessedTraditionById = useAppSelector(SelectGetById.Static.BlessedTradition) + const getAspectById = useAppSelector(SelectGetById.Static.Aspect) + const entry = useAppSelector(SelectGetById.Static.LiturgicalChant)(id) + + return ( + + ) +} diff --git a/src/main_window/inlineLibrary/entities/InlineLibraryRangedCombatTechnique.tsx b/src/main_window/inlineLibrary/entities/InlineLibraryRangedCombatTechnique.tsx new file mode 100644 index 000000000..5db69a37c --- /dev/null +++ b/src/main_window/inlineLibrary/entities/InlineLibraryRangedCombatTechnique.tsx @@ -0,0 +1,25 @@ +import { FC } from "react" +import { LibraryEntry } from "../../../shared/components/libraryEntry/LibraryEntry.tsx" +import { getRangedCombatTechniqueLibraryEntry } from "../../../shared/domain/rated/combatTechnique.ts" +import { useAppSelector } from "../../hooks/redux.ts" +import { SelectGetById } from "../../selectors/basicCapabilitySelectors.ts" + +type Props = { + id: number +} + +/** + * Displays all information about a ranged combat technique. + */ +export const InlineLibraryRangedCombatTechnique: FC = ({ id }) => { + const getAttributeById = useAppSelector(SelectGetById.Static.Attribute) + const entry = useAppSelector(SelectGetById.Static.RangedCombatTechnique)(id) + + return ( + + ) +} diff --git a/src/main_window/inlineLibrary/entities/InlineLibraryRitual.tsx b/src/main_window/inlineLibrary/entities/InlineLibraryRitual.tsx new file mode 100644 index 000000000..bc6f61b50 --- /dev/null +++ b/src/main_window/inlineLibrary/entities/InlineLibraryRitual.tsx @@ -0,0 +1,35 @@ +import { FC } from "react" +import { LibraryEntry } from "../../../shared/components/libraryEntry/LibraryEntry.tsx" +import { getRitualLibraryEntry } from "../../../shared/domain/rated/spell.ts" +import { useAppSelector } from "../../hooks/redux.ts" +import { SelectGetById } from "../../selectors/basicCapabilitySelectors.ts" + +type Props = { + id: number +} + +/** + * Displays all information about a ritual. + */ +export const InlineLibraryRitual: FC = ({ id }) => { + const getAttributeById = useAppSelector(SelectGetById.Static.Attribute) + const getDerivedCharacteristicById = useAppSelector(SelectGetById.Static.DerivedCharacteristic) + const getSkillModificationLevelById = useAppSelector(SelectGetById.Static.SkillModificationLevel) + const getTargetCategoryById = useAppSelector(SelectGetById.Static.TargetCategory) + const getPropertyById = useAppSelector(SelectGetById.Static.Property) + const getMagicalTraditionById = useAppSelector(SelectGetById.Static.MagicalTradition) + const entry = useAppSelector(SelectGetById.Static.Ritual)(id) + + return ( + + ) +} diff --git a/src/main_window/inlineLibrary/entities/InlineLibrarySpell.tsx b/src/main_window/inlineLibrary/entities/InlineLibrarySpell.tsx new file mode 100644 index 000000000..baff13a32 --- /dev/null +++ b/src/main_window/inlineLibrary/entities/InlineLibrarySpell.tsx @@ -0,0 +1,35 @@ +import { FC } from "react" +import { LibraryEntry } from "../../../shared/components/libraryEntry/LibraryEntry.tsx" +import { getSpellLibraryEntry } from "../../../shared/domain/rated/spell.ts" +import { useAppSelector } from "../../hooks/redux.ts" +import { SelectGetById } from "../../selectors/basicCapabilitySelectors.ts" + +type Props = { + id: number +} + +/** + * Displays all information about a spell. + */ +export const InlineLibrarySpell: FC = ({ id }) => { + const getAttributeById = useAppSelector(SelectGetById.Static.Attribute) + const getDerivedCharacteristicById = useAppSelector(SelectGetById.Static.DerivedCharacteristic) + const getSkillModificationLevelById = useAppSelector(SelectGetById.Static.SkillModificationLevel) + const getTargetCategoryById = useAppSelector(SelectGetById.Static.TargetCategory) + const getPropertyById = useAppSelector(SelectGetById.Static.Property) + const getMagicalTraditionById = useAppSelector(SelectGetById.Static.MagicalTradition) + const entry = useAppSelector(SelectGetById.Static.Spell)(id) + + return ( + + ) +} diff --git a/src/main_window/routes/characters/character/liturgicalChants/LiturgicalChants.tsx b/src/main_window/routes/characters/character/liturgicalChants/LiturgicalChants.tsx index 15bf6b586..9e3f51b2c 100644 --- a/src/main_window/routes/characters/character/liturgicalChants/LiturgicalChants.tsx +++ b/src/main_window/routes/characters/character/liturgicalChants/LiturgicalChants.tsx @@ -136,7 +136,7 @@ export const LiturgicalChants: FC = () => { {translate("Name")} - {translate("liturgicalchants.header.traditions")} + {translate("Traditions")} {sortOrder === LiturgiesSortOrder.Group ? ` / ${translate("Group")}` : null} {translate("Check")} @@ -227,7 +227,7 @@ export const LiturgicalChants: FC = () => { {translate("Name")} - {translate("liturgicalchants.header.traditions")} + {translate("Traditions")} {sortOrder === LiturgiesSortOrder.Group ? ` / ${translate("Group")}` : null} diff --git a/src/shared/domain/libraryEntry/activatableSkill/castingTime.ts b/src/shared/domain/libraryEntry/activatableSkill/castingTime.ts new file mode 100644 index 000000000..38f75d6f8 --- /dev/null +++ b/src/shared/domain/libraryEntry/activatableSkill/castingTime.ts @@ -0,0 +1,167 @@ +import { + CastingTime, + CastingTimeDuringLovemaking, + FastCastingTime, + FastSkillNonModifiableCastingTime, + ModifiableCastingTime, + SlowCastingTime, + SlowSkillNonModifiableCastingTime, +} from "optolith-database-schema/types/_ActivatableSkillCastingTime" +import { isNotNullish, mapNullable } from "../../../utils/nullable.ts" +import { Translate } from "../../../utils/translate.ts" +import { assertExhaustive } from "../../../utils/typeSafety.ts" +import { GetById } from "../../getTypes.ts" +import { ResponsiveTextSize } from "../responsiveText.ts" +import { MISSING_VALUE } from "../unknown.ts" +import { Entity } from "./entity.ts" +import { ModifiableParameter } from "./modifiableParameter.ts" +import { getTextForNonModifiableSuffix } from "./nonModifiable.ts" +import { Speed } from "./speed.ts" +import { formatTimeSpan } from "./units.ts" + +const getTextForModifiableCastingTime = ( + deps: { + getSkillModificationLevelById: GetById.Static.SkillModificationLevel + translate: Translate + }, + value: ModifiableCastingTime, + env: { + speed: Speed + responsiveText: ResponsiveTextSize + }, +): string => + mapNullable( + deps.getSkillModificationLevelById(value.initial_modification_level), + ({ fast: { casting_time: fastTime }, slow: { casting_time: slowTime } }) => { + switch (env.speed) { + case Speed.Fast: + return formatTimeSpan(deps.translate, env.responsiveText, "Actions", fastTime) + case Speed.Slow: + return formatTimeSpan(deps.translate, env.responsiveText, slowTime.unit, slowTime.value) + default: + return assertExhaustive(env.speed) + } + }, + ) ?? MISSING_VALUE + +const getTextForFastSkillNonModifiableCastingTime = ( + deps: { + translate: Translate + }, + value: FastSkillNonModifiableCastingTime, + env: { + entity: Entity + responsiveText: ResponsiveTextSize + }, +): string => + formatTimeSpan(deps.translate, env.responsiveText, "Actions", value.actions) + + getTextForNonModifiableSuffix( + deps.translate, + env.entity, + ModifiableParameter.CastingTime, + env.responsiveText, + ) + +const getTextForSlowSkillNonModifiableCastingTime = ( + deps: { + translate: Translate + }, + value: SlowSkillNonModifiableCastingTime, + env: { + entity: Entity + responsiveText: ResponsiveTextSize + }, +): string => + formatTimeSpan(deps.translate, env.responsiveText, value.unit, value.value) + + getTextForNonModifiableSuffix( + deps.translate, + env.entity, + ModifiableParameter.CastingTime, + env.responsiveText, + ) + +const getTextForCastingTime = ( + deps: { + getSkillModificationLevelById: GetById.Static.SkillModificationLevel + translate: Translate + }, + value: CastingTime, + env: { + speed: Speed + responsiveText: ResponsiveTextSize + }, + getTextForNonModifiableCastingTime: (value: NonModifiable) => string, +): string => { + switch (value.tag) { + case "Modifiable": + return getTextForModifiableCastingTime(deps, value.modifiable, env) + case "NonModifiable": + return getTextForNonModifiableCastingTime(value.non_modifiable) + default: + return assertExhaustive(value) + } +} + +const getTextForCastingTimeDuringLovemaking = ( + deps: { + translate: Translate + }, + value: CastingTimeDuringLovemaking, + env: { + responsiveText: ResponsiveTextSize + }, +): string => formatTimeSpan(deps.translate, env.responsiveText, value.unit, value.value) + +/** + * Get the text for the casting time of a fast activatable skill. + */ +export const getTextForFastCastingTime = ( + deps: { + getSkillModificationLevelById: GetById.Static.SkillModificationLevel + translate: Translate + }, + value: FastCastingTime, + env: { + entity: Entity + responsiveText: ResponsiveTextSize + }, +): string => + [ + mapNullable(value.default, def => + getTextForCastingTime(deps, def, { ...env, speed: Speed.Fast }, nonModifiableValue => + getTextForFastSkillNonModifiableCastingTime(deps, nonModifiableValue, env), + ), + ), + mapNullable(value.during_lovemaking, duringLovemaking => + getTextForCastingTimeDuringLovemaking(deps, duringLovemaking, env), + ), + ] + .filter(isNotNullish) + .join(" / ") + +/** + * Get the text for the casting time of a slow activatable skill. + */ +export const getTextForSlowCastingTime = ( + deps: { + getSkillModificationLevelById: GetById.Static.SkillModificationLevel + translate: Translate + }, + value: SlowCastingTime, + env: { + entity: Entity + responsiveText: ResponsiveTextSize + }, +): string => + [ + mapNullable(value.default, def => + getTextForCastingTime(deps, def, { ...env, speed: Speed.Slow }, nonModifiableValue => + getTextForSlowSkillNonModifiableCastingTime(deps, nonModifiableValue, env), + ), + ), + mapNullable(value.during_lovemaking, duringLovemaking => + getTextForCastingTimeDuringLovemaking(deps, duringLovemaking, env), + ), + ] + .filter(isNotNullish) + .join(" / ") diff --git a/src/shared/domain/libraryEntry/activatableSkill/checkResultBased.test.ts b/src/shared/domain/libraryEntry/activatableSkill/checkResultBased.test.ts new file mode 100644 index 000000000..62c607d74 --- /dev/null +++ b/src/shared/domain/libraryEntry/activatableSkill/checkResultBased.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { translateMock } from "../../../utils/translate.ts" +import { getTextForCheckResultBased } from "./checkResultBased.ts" + +describe("getTextForCheckResultBased", () => { + it("should return the value text for a check-result-based parameter of an activatable skill", () => { + assert.equal(getTextForCheckResultBased({ base: "QualityLevels" }, translateMock), "QL") + assert.equal(getTextForCheckResultBased({ base: "SkillPoints" }, translateMock), "SP") + assert.equal( + getTextForCheckResultBased( + { base: "QualityLevels", modifier: { arithmetic: "Divide", value: 2 } }, + translateMock, + ), + "QL / 2", + ) + assert.equal( + getTextForCheckResultBased( + { base: "SkillPoints", modifier: { arithmetic: "Multiply", value: 3 } }, + translateMock, + ), + "SP × 3", + ) + }) +}) diff --git a/src/shared/domain/libraryEntry/activatableSkill/checkResultBased.ts b/src/shared/domain/libraryEntry/activatableSkill/checkResultBased.ts new file mode 100644 index 000000000..e1f190cf6 --- /dev/null +++ b/src/shared/domain/libraryEntry/activatableSkill/checkResultBased.ts @@ -0,0 +1,42 @@ +import { + CheckResultArithmetic, + CheckResultBased, + CheckResultValue, +} from "optolith-database-schema/types/_ActivatableSkillCheckResultBased" +import { mapNullableDefault } from "../../../utils/nullable.ts" +import { Translate } from "../../../utils/translate.ts" +import { assertExhaustive } from "../../../utils/typeSafety.ts" + +const getCheckResultBaseValue = (baseValue: CheckResultValue, translate: Translate) => { + switch (baseValue) { + case "QualityLevels": + return translate("QL") + case "SkillPoints": + return translate("SP") + default: + return assertExhaustive(baseValue) + } +} + +const getArithmeticSymbol = (arithmetic: CheckResultArithmetic) => { + switch (arithmetic) { + case "Divide": + return ` / ` + case "Multiply": + return ` × ` + default: + return assertExhaustive(arithmetic) + } +} + +/** + * Returns the value text for a check-result-based parameter of an activatable + * skill. + */ +export const getTextForCheckResultBased = (value: CheckResultBased, translate: Translate): string => + getCheckResultBaseValue(value.base, translate) + + mapNullableDefault( + value.modifier, + modifier => getArithmeticSymbol(modifier.arithmetic) + modifier.value, + "", + ) diff --git a/src/shared/domain/libraryEntry/activatableSkill/cost.ts b/src/shared/domain/libraryEntry/activatableSkill/cost.ts new file mode 100644 index 000000000..a5649aabe --- /dev/null +++ b/src/shared/domain/libraryEntry/activatableSkill/cost.ts @@ -0,0 +1,456 @@ +import { + CostMap, + IndefiniteOneTimeCost, + ModifiableOneTimeCost, + MultipleOneTimeCosts, + NonModifiableOneTimeCost, + NonModifiableOneTimeCostPerCountable, + OneTimeCost, + SingleOneTimeCost, + SustainedCost, +} from "optolith-database-schema/types/_ActivatableSkillCost" +import { mapNullable, mapNullableDefault } from "../../../utils/nullable.ts" +import { Translate, TranslateMap } from "../../../utils/translate.ts" +import { assertExhaustive } from "../../../utils/typeSafety.ts" +import { GetById } from "../../getTypes.ts" +import { + getResponsiveText, + getResponsiveTextOptional, + replaceTextIfRequested, + responsive, + ResponsiveTextSize, +} from "../responsiveText.ts" +import { MISSING_VALUE } from "../unknown.ts" +import { Entity } from "./entity.ts" +import { ModifiableParameter } from "./modifiableParameter.ts" +import { getTextForNonModifiableSuffix } from "./nonModifiable.ts" +import { getModifiableBySpeed, Speed } from "./speed.ts" +import { formatCost, formatTimeSpan } from "./units.ts" + +const getTextForModifiableOneTimeCost = ( + deps: { + getSkillModificationLevelById: GetById.Static.SkillModificationLevel + translate: Translate + translateMap: TranslateMap + }, + value: ModifiableOneTimeCost, + env: { + speed: Speed + entity: Entity + responsiveText: ResponsiveTextSize + }, +): string => + mapNullable( + deps.getSkillModificationLevelById(value.initial_modification_level), + modificationLevel => { + const cost = getModifiableBySpeed( + modificationLevel, + env.speed, + config => config.cost, + config => config.cost, + ) + + return replaceTextIfRequested( + value.translations, + formatCost(deps.translate, env.entity, cost), + deps.translateMap, + env.responsiveText, + ) + }, + ) ?? MISSING_VALUE + +const getMinimumText = ( + isMinimum: boolean | undefined, + translate: Translate, + responsiveText: ResponsiveTextSize, +) => + isMinimum !== true + ? "" + : responsive( + responsiveText, + () => translate("at least "), + () => translate("min. "), + ) + +const getTextForNonModifiableOneTimeCostPerCountable = ( + deps: { + translate: Translate + translateMap: TranslateMap + formatCost: (x: number | string) => string + }, + value: NonModifiableOneTimeCostPerCountable | undefined, + env: { + responsiveText: ResponsiveTextSize + }, +) => + mapNullable(value, perCountable => { + const countableText = responsive( + env.responsiveText, + entity => deps.translate(" per {0}", entity), + entity => deps.translate("/{0}", entity), + getResponsiveText( + deps.translateMap(perCountable.translations)?.countable, + env.responsiveText, + ), + ) + + const minimumTotalText = + mapNullable(perCountable.minimum_total, minimumTotal => + deps.translate(", minimum of {0}", deps.formatCost(minimumTotal)), + ) ?? "" + + return countableText + minimumTotalText + }) ?? "" + +const getTextForPermanentValue = ( + value: number | undefined, + responsiveText: ResponsiveTextSize, + translate: Translate, +) => + value === undefined + ? "" + : responsive( + responsiveText, + perm => translate(", {0} of which are permanent", perm), + perm => translate(" ({0} perm.)", perm), + value, + ) + +const getTextForNonModifiableOneTimeCost = ( + deps: { translate: Translate; translateMap: TranslateMap }, + value: NonModifiableOneTimeCost, + env: { + responsiveText: ResponsiveTextSize + entity: Entity + }, +): string => { + const isMinimum = getMinimumText(value.is_minimum, deps.translate, env.responsiveText) + const formatCostP = formatCost.bind(this, deps.translate, env.entity) + const per = getTextForNonModifiableOneTimeCostPerCountable( + { ...deps, formatCost: formatCostP }, + value.per, + env, + ) + const permanent = getTextForPermanentValue( + value.permanent_value, + env.responsiveText, + deps.translate, + ) + const translation = deps.translateMap(value.translations) + const note = mapNullableDefault( + translation === undefined || translation.note === undefined + ? undefined + : getResponsiveTextOptional(translation.note, env.responsiveText), + noteIfPresent => ` (${noteIfPresent})`, + "", + ) + + const cannotModify = getTextForNonModifiableSuffix( + deps.translate, + env.entity, + ModifiableParameter.Cost, + env.responsiveText, + ) + + return isMinimum + formatCostP(value.value) + per + permanent + note + cannotModify +} + +const getTextForIndefiniteOneTimeCost = ( + deps: { translate: Translate; translateMap: TranslateMap }, + value: IndefiniteOneTimeCost, + env: { + responsiveText: ResponsiveTextSize + entity: Entity + }, +): string => + (getResponsiveText(deps.translateMap(value.translations)?.description, env.responsiveText) ?? + "") + + getTextForNonModifiableSuffix( + deps.translate, + env.entity, + ModifiableParameter.Cost, + env.responsiveText, + ) + +const getTextForSingleOneTimeCost = ( + deps: { + getSkillModificationLevelById: GetById.Static.SkillModificationLevel + translate: Translate + translateMap: TranslateMap + }, + value: SingleOneTimeCost, + env: { + speed: Speed + responsiveText: ResponsiveTextSize + entity: Entity + }, +): string => { + switch (value.tag) { + case "Modifiable": + return getTextForModifiableOneTimeCost(deps, value.modifiable, env) + case "NonModifiable": + return getTextForNonModifiableOneTimeCost(deps, value.non_modifiable, env) + case "Indefinite": + return getTextForIndefiniteOneTimeCost(deps, value.indefinite, env) + default: + return assertExhaustive(value) + } +} + +const getTextForMultipleOneTimeCosts = ( + deps: { + getSkillModificationLevelById: GetById.Static.SkillModificationLevel + translate: Translate + translateMap: TranslateMap + }, + value: MultipleOneTimeCosts, + type: "conjunction" | "disjunction", + env: { + speed: Speed + responsiveText: ResponsiveTextSize + entity: Entity + }, +): string => { + const modifiable = !value.every(part => part.tag === "Modifiable") + ? getTextForNonModifiableSuffix( + deps.translate, + env.entity, + ModifiableParameter.Cost, + env.responsiveText, + ) + : "" + + return ( + value + .map(part => getTextForSingleOneTimeCost(deps, part, env)) + .join( + (() => { + switch (type) { + case "conjunction": + return responsive( + env.responsiveText, + () => deps.translate(" and "), + () => deps.translate(" + "), + ) + case "disjunction": + return responsive( + env.responsiveText, + () => deps.translate(" or "), + () => deps.translate(" / "), + ) + default: + return assertExhaustive(type) + } + })(), + ) + modifiable + ) +} + +const getTextForCostMap = ( + deps: { + translate: Translate + translateMap: TranslateMap + }, + value: CostMap, + env: { + responsiveText: ResponsiveTextSize + entity: Entity + }, +): string => { + const translation = deps.translateMap(value.translations) + + if (value.translations !== undefined && translation === undefined) { + return MISSING_VALUE + } + + if (translation?.replacement !== undefined) { + return translation.replacement + } + + const costs = value.options.map(option => option.value).join("/") + const labels = value.options + .map(option => deps.translateMap(option.translations)?.label ?? MISSING_VALUE) + .join("/") + const permanentCosts = value.options.every(option => option.permanent_value !== undefined) + ? value.options.map(option => option.permanent_value!).join("/") + : undefined + + const formatCostP = formatCost.bind(this, deps.translate, env.entity) + const notModifiable = getTextForNonModifiableSuffix( + deps.translate, + env.entity, + ModifiableParameter.Cost, + env.responsiveText, + ) + + return ( + formatCostP(costs) + + deps.translate(" for ") + + mapNullableDefault(translation?.list_prepend, listPrepend => `${listPrepend} `, "") + + labels + + (translation?.list_append ?? "") + + (permanentCosts !== undefined + ? deps.translate(", {0} of which are permanent", formatCostP(permanentCosts)) + : "") + + notModifiable + ) +} + +/** + * Returns the text for the cost of a one-time activatable skill. + */ +export const getTextForOneTimeCost = ( + deps: { + getSkillModificationLevelById: GetById.Static.SkillModificationLevel + translate: Translate + translateMap: TranslateMap + }, + value: OneTimeCost, + env: { + speed: Speed + responsiveText: ResponsiveTextSize + entity: Entity + }, +): string => { + switch (value.tag) { + case "Single": + return getTextForSingleOneTimeCost(deps, value.single, env) + case "Conjunction": + return getTextForMultipleOneTimeCosts(deps, value.conjunction, "conjunction", env) + case "Disjunction": + return getTextForMultipleOneTimeCosts(deps, value.disjunction, "disjunction", env) + case "Map": + return getTextForCostMap(deps, value.map, env) + default: + return assertExhaustive(value) + } +} + +/** + * Returns the text for the cost of a sustained activatable skill. + */ +export const getTextForSustainedCost = ( + deps: { + getSkillModificationLevelById: GetById.Static.SkillModificationLevel + translate: Translate + translateMap: TranslateMap + }, + value: SustainedCost, + env: { + speed: Speed + responsiveText: ResponsiveTextSize + entity: Entity + }, +): string => { + switch (value.tag) { + case "Modifiable": { + const modificationLevel = deps.getSkillModificationLevelById( + value.modifiable.initial_modification_level, + ) + + if (modificationLevel === undefined) { + return MISSING_VALUE + } + + const cost = (() => { + switch (env.speed) { + case Speed.Fast: + return modificationLevel.fast.cost + case Speed.Slow: + return modificationLevel.slow.cost + default: + return assertExhaustive(env.speed) + } + })() + + const formatCostP = formatCost.bind(this, deps.translate, env.entity) + const interval = formatTimeSpan( + deps.translate, + env.responsiveText, + value.modifiable.interval.unit, + value.modifiable.interval.value, + ) + + return responsive( + env.responsiveText, + () => + `${formatCostP(cost) + deps.translate(" (casting)")} + ${ + formatCostP(cost / 2) + deps.translate(" per {0}", interval) + }`, + () => `${formatCostP(cost)} + ${formatCostP(cost / 2) + deps.translate("/{0}", interval)}`, + ) + } + case "NonModifiable": { + const isMinimum = getMinimumText( + value.non_modifiable.is_minimum, + deps.translate, + env.responsiveText, + ) + const cost = value.non_modifiable.value + const formatCostP = formatCost.bind(this, deps.translate, env.entity) + + const per = (() => { + if (value.non_modifiable.per === undefined) { + return { countable: "", minimumTotal: "" } + } + + const countable = responsive( + env.responsiveText, + entity => deps.translate(" per {0}", entity), + entity => deps.translate("/{0}", entity), + getResponsiveText( + deps.translateMap(value.non_modifiable.per.translations)?.countable, + env.responsiveText, + ), + ) + + const minimumTotal = + value.non_modifiable.per.minimum_total !== undefined + ? deps.translate( + ", minimum of {0}", + formatCostP(value.non_modifiable.per.minimum_total), + ) + : "" + + return { countable, minimumTotal } + })() + + const interval = formatTimeSpan( + deps.translate, + env.responsiveText, + value.non_modifiable.interval.unit, + value.non_modifiable.interval.value, + ) + + return ( + isMinimum + + responsive( + env.responsiveText, + () => + `${formatCostP(cost) + deps.translate(" (casting)")} + ${ + (value.non_modifiable.is_minimum === true + ? deps.translate("half of the activation cost") + : formatCostP(cost / 2)) + + per.countable + + deps.translate(" per {0}", interval) + }`, + () => + `${formatCostP(cost)} + ${ + (value.non_modifiable.is_minimum === true ? "50%" : formatCostP(cost / 2)) + + per.countable + + deps.translate("/{0}", interval) + }`, + ) + + per.minimumTotal + + getTextForNonModifiableSuffix( + deps.translate, + env.entity, + ModifiableParameter.Cost, + env.responsiveText, + ) + ) + } + default: + return assertExhaustive(value) + } +} diff --git a/src/shared/domain/libraryEntry/activatableSkill/duration.ts b/src/shared/domain/libraryEntry/activatableSkill/duration.ts new file mode 100644 index 000000000..bee892175 --- /dev/null +++ b/src/shared/domain/libraryEntry/activatableSkill/duration.ts @@ -0,0 +1,222 @@ +import { + CheckResultBasedDuration, + DurationForOneTime, + DurationForSustained, + FixedDuration, + Immediate, + PermanentDuration, +} from "optolith-database-schema/types/_ActivatableSkillDuration" +import { BlessingDuration } from "optolith-database-schema/types/Blessing" +import { CantripDuration } from "optolith-database-schema/types/Cantrip" +import { mapNullableDefault } from "../../../utils/nullable.ts" +import { Translate, TranslateMap } from "../../../utils/translate.ts" +import { assertExhaustive } from "../../../utils/typeSafety.ts" +import { + ResponsiveTextSize, + getResponsiveText, + replaceTextIfRequested, + responsive, +} from "../responsiveText.ts" +import { getTextForCheckResultBased } from "./checkResultBased.ts" +import { getTextForIsMaximum } from "./isMaximum.ts" +import { formatTimeSpan } from "./units.ts" + +const getTextForImmediateDuration = ( + deps: { + translate: Translate + translateMap: TranslateMap + }, + value: Immediate, + env: { + responsiveText: ResponsiveTextSize + }, +): string => { + const text = + deps.translate("Immediate") + + mapNullableDefault( + value.maximum, + max => { + const maxText = formatTimeSpan(deps.translate, env.responsiveText, max.unit, max.value) + + return responsive( + env.responsiveText, + () => deps.translate(" (no more than {0})", maxText), + () => deps.translate(" (max. {0})", maxText), + ) + }, + "", + ) + + return replaceTextIfRequested(value.translations, text, deps.translateMap, env.responsiveText) +} + +const getTextForPermanentDuration = ( + deps: { + translate: Translate + translateMap: TranslateMap + }, + value: PermanentDuration, + env: { + responsiveText: ResponsiveTextSize + }, +): string => { + const translation = deps.translateMap(value.translations) + const text = deps.translate("Permanent") + + if (translation?.replacement !== undefined) { + return getResponsiveText(translation.replacement, env.responsiveText).replace("$1", text) + } else { + return text + } +} + +const getTextForFixedDuration = ( + deps: { + translate: Translate + translateMap: TranslateMap + }, + value: FixedDuration, + env: { + responsiveText: ResponsiveTextSize + }, +): string => { + const isMaximum = getTextForIsMaximum(value.is_maximum, deps.translate, env.responsiveText) + const unitValue = formatTimeSpan(deps.translate, env.responsiveText, value.unit, value.value) + const text = isMaximum + unitValue + const translation = deps.translateMap(value.translations) + + if (translation?.replacement !== undefined) { + return getResponsiveText(translation.replacement, env.responsiveText).replace("$1", text) + } else { + return text + } +} + +const getTextForCheckResultBasedDuration = ( + deps: { + translate: Translate + translateMap: TranslateMap + }, + value: CheckResultBasedDuration, + env: { + responsiveText: ResponsiveTextSize + }, +): string => { + const isMaximum = getTextForIsMaximum(value.is_maximum, deps.translate, env.responsiveText) + + return formatTimeSpan( + deps.translate, + env.responsiveText, + value.unit, + isMaximum + getTextForCheckResultBased(value, deps.translate), + ) +} + +/** + * Returns the text for the duration of a one-time activatable skill. + */ +export const getTextForDurationForOneTime = ( + deps: { + translate: Translate + translateMap: TranslateMap + }, + value: DurationForOneTime, + env: { + responsiveText: ResponsiveTextSize + }, +): string => { + switch (value.tag) { + case "Immediate": + return getTextForImmediateDuration(deps, value.immediate, env) + case "Permanent": + return getTextForPermanentDuration(deps, value.permanent, env) + case "Fixed": + return getTextForFixedDuration(deps, value.fixed, env) + case "CheckResultBased": + return getTextForCheckResultBasedDuration(deps, value.check_result_based, env) + case "Indefinite": + return getResponsiveText( + deps.translateMap(value.indefinite.translations)?.description, + env.responsiveText, + ) + default: + return assertExhaustive(value) + } +} + +/** + * Returns the text for the duration of a sustained activatable skill. + */ +export const getTextForDurationForSustained = ( + deps: { translate: Translate }, + value: DurationForSustained | undefined, + env: { + responsiveText: ResponsiveTextSize + }, +): string => + value === undefined + ? responsive( + env.responsiveText, + () => deps.translate("Sustained"), + () => deps.translate("(S)"), + ) + : responsive( + env.responsiveText, + () => deps.translate("no more than "), + () => deps.translate("max. "), + ) + + formatTimeSpan(deps.translate, env.responsiveText, value.maximum.unit, value.maximum.value) + +/** + * Returns the text for the duration of a cantrip. + */ +export const getTextForCantripDuration = ( + deps: { translate: Translate; translateMap: TranslateMap }, + value: CantripDuration, + env: { + responsiveText: ResponsiveTextSize + }, +): string => { + switch (value.tag) { + case "Immediate": + return getTextForImmediateDuration(deps, value.immediate, env) + case "Fixed": + return getTextForFixedDuration(deps, value.fixed, env) + case "Indefinite": + return getResponsiveText( + deps.translateMap(value.indefinite.translations)?.description, + env.responsiveText, + ) + case "DuringLovemaking": { + const { value: lovemakingValue, unit: lovemakingUnit } = value.during_lovemaking + return formatTimeSpan(deps.translate, env.responsiveText, lovemakingUnit, lovemakingValue) + } + default: + return assertExhaustive(value) + } +} + +/** + * Returns the text for the duration of a blessing. + */ +export const getTextForBlessingDuration = ( + deps: { translate: Translate; translateMap: TranslateMap }, + value: BlessingDuration, + env: { + responsiveText: ResponsiveTextSize + }, +): string => { + switch (value.tag) { + case "Immediate": + return getTextForImmediateDuration(deps, value.immediate, env) + case "Fixed": + return getTextForFixedDuration(deps, value.fixed, env) + case "Indefinite": + return getResponsiveText( + deps.translateMap(value.indefinite.translations)?.description, + env.responsiveText, + ) + default: + return assertExhaustive(value) + } +} diff --git a/src/shared/domain/libraryEntry/activatableSkill/effect.ts b/src/shared/domain/libraryEntry/activatableSkill/effect.ts new file mode 100644 index 000000000..be57bd4f5 --- /dev/null +++ b/src/shared/domain/libraryEntry/activatableSkill/effect.ts @@ -0,0 +1,59 @@ +import { Effect } from "optolith-database-schema/types/_ActivatableSkillEffect" +import { filterNonNullable } from "../../../utils/array.ts" +import { mapNullable } from "../../../utils/nullable.ts" +import { Translate } from "../../../utils/translate.ts" +import { assertExhaustive } from "../../../utils/typeSafety.ts" +import { LibraryEntryContent } from "../../libraryEntry.ts" + +const getContentPartsForQualityLevels = ( + source: { + text_before: string + quality_levels: string[] + text_after?: string + }, + getQualityLevelString: (index: number) => string | number, + translate: Translate, +): LibraryEntryContent[] => + filterNonNullable([ + { + label: translate("Effect"), + value: source.text_before, + }, + ...source.quality_levels.map((text, index) => ({ + value: text, + label: translate("QL {0}", getQualityLevelString(index)), + })), + mapNullable(source.text_after, textAfter => ({ + value: textAfter, + className: "effect-after", + })), + ]) + +/** + * Gets the text for the effect of an activatable skill. + */ +export const getTextForEffect = (effect: Effect, translate: Translate): LibraryEntryContent[] => { + switch (effect.tag) { + case "Plain": + return [ + { + label: translate("Effect"), + value: effect.plain.text, + }, + ] + case "ForEachQualityLevel": + return getContentPartsForQualityLevels( + effect.for_each_quality_level, + index => index + 1, + translate, + ) + case "ForEachTwoQualityLevels": + return getContentPartsForQualityLevels( + effect.for_each_two_quality_levels, + index => `${index * 2 + 1}–${index * 2 + 2}`, + translate, + ) + default: + return assertExhaustive(effect) + } +} diff --git a/src/shared/domain/libraryEntry/activatableSkill/entity.ts b/src/shared/domain/libraryEntry/activatableSkill/entity.ts new file mode 100644 index 000000000..ad3a0499a --- /dev/null +++ b/src/shared/domain/libraryEntry/activatableSkill/entity.ts @@ -0,0 +1,11 @@ +/** + * The entity type of an activatable skill. + */ +export enum Entity { + Cantrip, + Spell, + Ritual, + Blessing, + LiturgicalChant, + Ceremony, +} diff --git a/src/shared/domain/libraryEntry/activatableSkill/isMaximum.ts b/src/shared/domain/libraryEntry/activatableSkill/isMaximum.ts new file mode 100644 index 000000000..6eb8731ff --- /dev/null +++ b/src/shared/domain/libraryEntry/activatableSkill/isMaximum.ts @@ -0,0 +1,21 @@ +import { Translate } from "../../../utils/translate.ts" +import { ResponsiveTextSize, responsive } from "../responsiveText.ts" + +/** + * Returns the text to prepend for the `is_maximum` property. + */ +export const getTextForIsMaximum = ( + is_maximum: boolean | undefined, + translate: Translate, + responsiveText: ResponsiveTextSize, +): string => { + if (is_maximum !== true) { + return "" + } + + return responsive( + responsiveText, + () => translate("no more than "), + () => translate("max. "), + ) +} diff --git a/src/shared/domain/libraryEntry/activatableSkill/modifiableParameter.ts b/src/shared/domain/libraryEntry/activatableSkill/modifiableParameter.ts new file mode 100644 index 000000000..1245a79c1 --- /dev/null +++ b/src/shared/domain/libraryEntry/activatableSkill/modifiableParameter.ts @@ -0,0 +1,8 @@ +/** + * A parameter that is designed to be modifiable. + */ +export enum ModifiableParameter { + CastingTime, + Cost, + Range, +} diff --git a/src/shared/domain/libraryEntry/activatableSkill/nonModifiable.ts b/src/shared/domain/libraryEntry/activatableSkill/nonModifiable.ts new file mode 100644 index 000000000..9284cdcb1 --- /dev/null +++ b/src/shared/domain/libraryEntry/activatableSkill/nonModifiable.ts @@ -0,0 +1,83 @@ +import { Translate } from "../../../utils/translate.ts" +import { assertExhaustive } from "../../../utils/typeSafety.ts" +import { ResponsiveTextSize } from "../responsiveText.ts" +import { Entity } from "./entity.ts" +import { ModifiableParameter } from "./modifiableParameter.ts" + +/** + * Returns the suffix for the text of a non-modifiable parameter that indicates + * that the parameter cannot be modified. + */ +export const getTextForNonModifiableSuffix = ( + translate: Translate, + entity: Entity, + param: ModifiableParameter, + responsiveText: ResponsiveTextSize, +): string => { + if (responsiveText === ResponsiveTextSize.Compressed) { + switch (entity) { + case Entity.Spell: + case Entity.Ritual: + case Entity.LiturgicalChant: + case Entity.Ceremony: + return translate(" (cannot modify)") + case Entity.Cantrip: + case Entity.Blessing: + return "" + default: + return assertExhaustive(entity) + } + } + + switch (entity) { + case Entity.Spell: + switch (param) { + case ModifiableParameter.CastingTime: + return translate(" (you cannot use a modification on this spell’s casting time)") + case ModifiableParameter.Cost: + return translate(" (you cannot use a modification on this spell’s cost)") + case ModifiableParameter.Range: + return translate(" (you cannot use a modification on this spell’s range)") + default: + return assertExhaustive(param) + } + case Entity.Ritual: + switch (param) { + case ModifiableParameter.CastingTime: + return translate(" (you cannot use a modification on this ritual’s ritual time)") + case ModifiableParameter.Cost: + return translate(" (you cannot use a modification on this ritual’s cost)") + case ModifiableParameter.Range: + return translate(" (you cannot use a modification on this ritual’s range)") + default: + return assertExhaustive(param) + } + case Entity.LiturgicalChant: + switch (param) { + case ModifiableParameter.CastingTime: + return translate(" (you cannot use a modification on this chant’s liturgical time)") + case ModifiableParameter.Cost: + return translate(" (you cannot use a modification on this chant’s cost)") + case ModifiableParameter.Range: + return translate(" (you cannot use a modification on this chant’s range)") + default: + return assertExhaustive(param) + } + case Entity.Ceremony: + switch (param) { + case ModifiableParameter.CastingTime: + return translate(" (you cannot use a modification on this ceremony’s ceremonial time)") + case ModifiableParameter.Cost: + return translate(" (you cannot use a modification on this ceremony’s cost)") + case ModifiableParameter.Range: + return translate(" (you cannot use a modification on this ceremony’s range)") + default: + return assertExhaustive(param) + } + case Entity.Cantrip: + case Entity.Blessing: + return "" + default: + return assertExhaustive(entity) + } +} diff --git a/src/shared/domain/libraryEntry/activatableSkill/parensIf.ts b/src/shared/domain/libraryEntry/activatableSkill/parensIf.ts new file mode 100644 index 000000000..67f02a719 --- /dev/null +++ b/src/shared/domain/libraryEntry/activatableSkill/parensIf.ts @@ -0,0 +1,13 @@ +/** + * Wraps a string in parentheses with a leading space if it is not empty or + * `undefined`. + */ +export const parensIf = (text: string | undefined): string => + text === undefined || text === "" ? "" : ` (${text})` + +/** + * Appends a string in parentheses with a leading space if it is not empty or + * `undefined`. + */ +export const appendInParens = (text: string, append: string | undefined): string => + append === undefined || append === "" ? text : `${text} (${append})` diff --git a/src/shared/domain/libraryEntry/activatableSkill/range.ts b/src/shared/domain/libraryEntry/activatableSkill/range.ts new file mode 100644 index 000000000..d97db0ff9 --- /dev/null +++ b/src/shared/domain/libraryEntry/activatableSkill/range.ts @@ -0,0 +1,221 @@ +import { Range, RangeUnit } from "optolith-database-schema/types/_ActivatableSkillRange" +import { BlessingRange } from "optolith-database-schema/types/Blessing" +import { CantripRange } from "optolith-database-schema/types/Cantrip" +import { Translate, TranslateMap } from "../../../utils/translate.ts" +import { assertExhaustive } from "../../../utils/typeSafety.ts" +import { GetById } from "../../getTypes.ts" +import { + getResponsiveText, + getResponsiveTextOptional, + responsive, + ResponsiveTextSize, +} from "../responsiveText.ts" +import { MISSING_VALUE } from "../unknown.ts" +import { getTextForCheckResultBased } from "./checkResultBased.ts" +import { Entity } from "./entity.ts" +import { getTextForIsMaximum } from "./isMaximum.ts" +import { ModifiableParameter } from "./modifiableParameter.ts" +import { getTextForNonModifiableSuffix } from "./nonModifiable.ts" +import { Speed } from "./speed.ts" + +const toRangeUnit = ( + unit: RangeUnit, + value: number | string, + translate: Translate, + responsiveText: ResponsiveTextSize, +) => { + switch (unit) { + case "Steps": + return responsive( + responsiveText, + () => translate("{0} yards", value), + () => translate("{0} yd", value), + ) + case "Miles": + return responsive( + responsiveText, + () => translate("{0} miles", value), + () => translate("{0} mi.", value), + ) + default: + return assertExhaustive(unit) + } +} + +/** + * Returns the text for the range of an activatable skill. + */ +export const getTextForActivatableSkillRange = ( + deps: { + getSkillModificationLevelById: GetById.Static.SkillModificationLevel + translate: Translate + translateMap: TranslateMap + }, + value: Range, + env: { + speed: Speed + responsiveText: ResponsiveTextSize + entity: Entity + }, +): string => { + const translation = deps.translateMap(value.translations) + + if (value.translations !== undefined && translation === undefined) { + return MISSING_VALUE + } + + const rangeValue = (() => { + switch (value.value.tag) { + case "Modifiable": { + const modificationLevel = deps.getSkillModificationLevelById( + value.value.modifiable.initial_modification_level, + ) + + if (modificationLevel === undefined) { + return MISSING_VALUE + } + + const range = (() => { + switch (env.speed) { + case Speed.Fast: + return modificationLevel.fast.range + case Speed.Slow: + return modificationLevel.slow.range + default: + return assertExhaustive(env.speed) + } + })() + + if (range === 1) { + return deps.translate("Touch") + } + + return toRangeUnit("Steps", range, deps.translate, env.responsiveText) + } + case "Sight": + return deps.translate("Sight") + case "Self": + return deps.translate("Self") + case "Global": + return deps.translate("Global") + case "Touch": + return ( + deps.translate("Touch") + + getTextForNonModifiableSuffix( + deps.translate, + env.entity, + ModifiableParameter.Range, + env.responsiveText, + ) + ) + case "Fixed": { + return ( + toRangeUnit( + value.value.fixed.unit, + value.value.fixed.value, + deps.translate, + env.responsiveText, + ) + + getTextForNonModifiableSuffix( + deps.translate, + env.entity, + ModifiableParameter.Range, + env.responsiveText, + ) + ) + } + case "CheckResultBased": { + const isMaximum = getTextForIsMaximum( + value.value.check_result_based.is_maximum, + deps.translate, + env.responsiveText, + ) + + const isRadius = + value.value.check_result_based.is_radius === true ? ` ${deps.translate("Radius")}` : "" + + return ( + isMaximum + + toRangeUnit( + value.value.check_result_based.unit, + getTextForCheckResultBased(value.value.check_result_based, deps.translate), + deps.translate, + env.responsiveText, + ) + + isRadius + + getTextForNonModifiableSuffix( + deps.translate, + env.entity, + ModifiableParameter.Range, + env.responsiveText, + ) + ) + } + default: + return assertExhaustive(value.value) + } + })() + + const withReplacement = + translation?.replacement !== undefined + ? getResponsiveText(translation.replacement, env.responsiveText).replace("$1", rangeValue) + : rangeValue + + const withNote = (() => { + if (translation?.note === undefined) { + return withReplacement + } + + const note = getResponsiveTextOptional(translation.note, env.responsiveText) + + if (note === undefined) { + return withReplacement + } + + return `${withReplacement} (${note})` + })() + + return withNote +} + +/** + * Returns the text for the range of a cantrip. + */ +export const getTextForCantripRange = ( + deps: { translate: Translate }, + value: CantripRange, + env: { responsiveText: ResponsiveTextSize }, +): string => { + switch (value.tag) { + case "Self": + return deps.translate("Self") + case "Touch": + return deps.translate("Touch") + case "Fixed": { + return toRangeUnit(value.fixed.unit, value.fixed.value, deps.translate, env.responsiveText) + } + default: + return assertExhaustive(value) + } +} + +/** + * Returns the text for the range of a blessing. + */ +export const getTextForBlessingRange = ( + deps: { translate: Translate }, + value: BlessingRange, + env: { responsiveText: ResponsiveTextSize }, +): string => { + switch (value.tag) { + case "Self": + return deps.translate("Self") + case "Touch": + return deps.translate("Touch") + case "Fixed": { + return toRangeUnit(value.fixed.unit, value.fixed.value, deps.translate, env.responsiveText) + } + default: + return assertExhaustive(value) + } +} diff --git a/src/shared/domain/libraryEntry/activatableSkill/speed.ts b/src/shared/domain/libraryEntry/activatableSkill/speed.ts new file mode 100644 index 000000000..3dfacd75c --- /dev/null +++ b/src/shared/domain/libraryEntry/activatableSkill/speed.ts @@ -0,0 +1,33 @@ +import { + FastSkillModificationLevelConfig, + SkillModificationLevel, + SlowSkillModificationLevelConfig, +} from "optolith-database-schema/types/SkillModificationLevel" +import { assertExhaustive } from "../../../utils/typeSafety.ts" + +/** + * The speed of an activatable skill. + */ +export enum Speed { + Fast, + Slow, +} + +/** + * Returns a common value for a skill modification level depending on the speed. + */ +export const getModifiableBySpeed = ( + level: SkillModificationLevel, + speed: Speed, + fast: (config: FastSkillModificationLevelConfig) => T, + slow: (config: SlowSkillModificationLevelConfig) => T, +): T => { + switch (speed) { + case Speed.Fast: + return fast(level.fast) + case Speed.Slow: + return slow(level.slow) + default: + return assertExhaustive(speed) + } +} diff --git a/src/shared/domain/libraryEntry/activatableSkill/targetCategory.ts b/src/shared/domain/libraryEntry/activatableSkill/targetCategory.ts new file mode 100644 index 000000000..965877468 --- /dev/null +++ b/src/shared/domain/libraryEntry/activatableSkill/targetCategory.ts @@ -0,0 +1,55 @@ +import { TargetCategory } from "optolith-database-schema/types/_ActivatableSkillTargetCategory" +import { mapNullable } from "../../../utils/nullable.ts" +import { Translate, TranslateMap } from "../../../utils/translate.ts" +import { assertExhaustive } from "../../../utils/typeSafety.ts" +import { GetById } from "../../getTypes.ts" +import { LibraryEntryContent } from "../../libraryEntry.ts" +import { MISSING_VALUE } from "../unknown.ts" +import { appendInParens } from "./parensIf.ts" + +/** + * Get the text for the target category. + */ +export const getTextForTargetCategory = ( + deps: { + translate: Translate + translateMap: TranslateMap + getTargetCategoryById: GetById.Static.TargetCategory + }, + values: TargetCategory, +): LibraryEntryContent => ({ + label: deps.translate("Target Category"), + value: + values.length === 0 + ? deps.translate("all") + : values + .map(({ id, translations }) => { + const mainName = (() => { + switch (id.tag) { + case "Self": + return deps.translate("Self") + case "Zone": + return deps.translate("Zone") + case "LiturgicalChantsAndCeremonies": + return deps.translate("Liturgical Chants and Ceremonies") + case "Cantrips": + return deps.translate("Cantrips") + case "Predefined": { + const numericId = id.predefined.id.target_category + const specificTargetCategory = deps.getTargetCategoryById(numericId) + return ( + mapNullable( + deps.translateMap(specificTargetCategory?.translations), + translation => translation.name, + ) ?? MISSING_VALUE + ) + } + default: + return assertExhaustive(id) + } + })() + + return appendInParens(mainName, deps.translateMap(translations)?.note) + }) + .join(", "), +}) diff --git a/src/shared/domain/libraryEntry/activatableSkill/units.ts b/src/shared/domain/libraryEntry/activatableSkill/units.ts new file mode 100644 index 000000000..801867c5d --- /dev/null +++ b/src/shared/domain/libraryEntry/activatableSkill/units.ts @@ -0,0 +1,123 @@ +import { Translate } from "../../../utils/translate.ts" +import { assertExhaustive } from "../../../utils/typeSafety.ts" +import { responsive, ResponsiveTextSize } from "../responsiveText.ts" +import { Entity } from "./entity.ts" + +type TimeSpanUnit = + | "Seconds" + | "Minutes" + | "Hours" + | "Days" + | "Weeks" + | "Months" + | "Years" + | "Centuries" + | "Actions" + | "CombatRounds" + | "SeductionActions" + | "Rounds" + +/** + * Returns the text for a time span unit. + */ +export const formatTimeSpan = ( + translate: Translate, + responsiveTextSize: ResponsiveTextSize, + unit: TimeSpanUnit, + value: number | string, +): string => { + switch (unit) { + case "Seconds": + return responsive( + responsiveTextSize, + () => translate("{0} seconds", value), + () => translate("{0} s", value), + ) + case "Minutes": + return responsive( + responsiveTextSize, + () => translate("{0} minutes", value), + () => translate("{0} min", value), + ) + case "Hours": + return responsive( + responsiveTextSize, + () => translate("{0} hours", value), + () => translate("{0} h", value), + ) + case "Days": + return responsive( + responsiveTextSize, + () => translate("{0} days", value), + () => translate("{0} d", value), + ) + case "Weeks": + return responsive( + responsiveTextSize, + () => translate("{0} weeks", value), + () => translate("{0} wks.", value), + ) + case "Months": + return responsive( + responsiveTextSize, + () => translate("{0} months", value), + () => translate("{0} mos.", value), + ) + case "Years": + return responsive( + responsiveTextSize, + () => translate("{0} years", value), + () => translate("{0} yrs.", value), + ) + case "Centuries": + return responsive( + responsiveTextSize, + () => translate("{0} centuries", value), + () => translate("{0} cent.", value), + ) + case "Actions": + return responsive( + responsiveTextSize, + () => translate("{0} actions", value), + () => translate("{0} act", value), + ) + case "CombatRounds": + return responsive( + responsiveTextSize, + () => translate("{0} combat rounds", value), + () => translate("{0} CR", value), + ) + case "SeductionActions": + return responsive( + responsiveTextSize, + () => translate("{0} seduction actions", value), + () => translate("{0} SA", value), + ) + case "Rounds": + return responsive( + responsiveTextSize, + () => translate("{0} rounds", value), + () => translate("{0} rnds", value), + ) + default: + return assertExhaustive(unit) + } +} + +/** + * Returns the text for a cost unit that is based on the entity type. + */ +export const formatCost = (translate: Translate, entity: Entity, value: number | string) => { + switch (entity) { + case Entity.Cantrip: + case Entity.Spell: + case Entity.Ritual: + return translate("{0} AE", value) + case Entity.Blessing: + case Entity.LiturgicalChant: + case Entity.Ceremony: + return translate("{0} KP", value) + default: + return assertExhaustive(entity) + } +} diff --git a/src/shared/domain/libraryEntry/responsiveText.ts b/src/shared/domain/libraryEntry/responsiveText.ts new file mode 100644 index 000000000..9f649a914 --- /dev/null +++ b/src/shared/domain/libraryEntry/responsiveText.ts @@ -0,0 +1,89 @@ +import { LocaleMap } from "optolith-database-schema/types/_LocaleMap" +import { + ResponsiveText, + ResponsiveTextOptional, + ResponsiveTextReplace, +} from "optolith-database-schema/types/_ResponsiveText" +import { mapNullable } from "../../utils/nullable.ts" +import { TranslateMap } from "../../utils/translate.ts" +import { assertExhaustive } from "../../utils/typeSafety.ts" +import { MISSING_VALUE } from "./unknown.ts" + +/** + * Whether the entry is displayed in a normal or compressed setting. Normal/full + * usually means a full library entry display, whether compressed usually means + * the character sheet. + */ +export enum ResponsiveTextSize { + Compressed, + Full, +} + +/** + * Executes one of two functions depending on the responsive text size. + */ +export const responsive = ( + size: ResponsiveTextSize, + full: (...args: A) => T, + compressed: (...args: A) => T, + ...args: A +): T => { + switch (size) { + case ResponsiveTextSize.Compressed: + return compressed(...args) + case ResponsiveTextSize.Full: + return full(...args) + default: + return assertExhaustive(size) + } +} + +/** + * Returns the responsive text for a given size. + */ +export const getResponsiveText = ( + value: ResponsiveText | undefined, + size: ResponsiveTextSize, +): string => { + if (value === undefined) { + return MISSING_VALUE + } + + return responsive( + size, + () => value.full, + () => value.compressed, + ) +} + +/** + * Returns the responsive text for a given size if it is defined. + */ +export const getResponsiveTextOptional = ( + value: ResponsiveTextOptional | undefined, + size: ResponsiveTextSize, +): string | undefined => { + if (value === undefined) { + return MISSING_VALUE + } + + return responsive( + size, + () => value.full, + () => value.compressed, + ) +} + +/** + * Replaces a text with a given value if a replacement is requested, otherwise + * just return the . + */ +export const replaceTextIfRequested = ( + translation: LocaleMap<{ replacement?: ResponsiveTextReplace }> | undefined, + valueToReplace: string, + translateMap: TranslateMap, + responsiveText: ResponsiveTextSize, +) => + mapNullable(translateMap(translation)?.replacement, replacement => + getResponsiveText(replacement, responsiveText).replace("$1", valueToReplace), + ) ?? valueToReplace diff --git a/src/shared/domain/libraryEntry/unknown.ts b/src/shared/domain/libraryEntry/unknown.ts new file mode 100644 index 000000000..f5274f212 --- /dev/null +++ b/src/shared/domain/libraryEntry/unknown.ts @@ -0,0 +1,4 @@ +/** + * String to display when a translation that should be present is missing. + */ +export const MISSING_VALUE = "???" diff --git a/src/shared/domain/rated/activatableSkill.ts b/src/shared/domain/rated/activatableSkill.ts new file mode 100644 index 000000000..327298332 --- /dev/null +++ b/src/shared/domain/rated/activatableSkill.ts @@ -0,0 +1,128 @@ +import { + FastOneTimePerformanceParameters, + FastSustainedPerformanceParameters, + SlowOneTimePerformanceParameters, + SlowSustainedPerformanceParameters, +} from "optolith-database-schema/types/_ActivatableSkill" +import { Translate, TranslateMap } from "../../utils/translate.ts" +import { GetById } from "../getTypes.ts" +import { + getTextForFastCastingTime, + getTextForSlowCastingTime, +} from "../libraryEntry/activatableSkill/castingTime.ts" +import { + getTextForOneTimeCost, + getTextForSustainedCost, +} from "../libraryEntry/activatableSkill/cost.ts" +import { + getTextForDurationForOneTime, + getTextForDurationForSustained, +} from "../libraryEntry/activatableSkill/duration.ts" +import { Entity } from "../libraryEntry/activatableSkill/entity.ts" +import { getTextForActivatableSkillRange } from "../libraryEntry/activatableSkill/range.ts" +import { Speed } from "../libraryEntry/activatableSkill/speed.ts" +import { ResponsiveTextSize } from "../libraryEntry/responsiveText.ts" + +/** + * Get the texts for all fast one-time performance parameters. + */ +export const getTextForFastOneTimePerformanceParameters = ( + deps: { + getSkillModificationLevelById: GetById.Static.SkillModificationLevel + translate: Translate + translateMap: TranslateMap + }, + value: FastOneTimePerformanceParameters, + env: { + entity: Entity + responsiveText: ResponsiveTextSize + }, +): { + castingTime: string + cost: string + range: string + duration: string +} => ({ + castingTime: getTextForFastCastingTime(deps, value.casting_time, env), + cost: getTextForOneTimeCost(deps, value.cost, { speed: Speed.Fast, ...env }), + range: getTextForActivatableSkillRange(deps, value.range, { speed: Speed.Fast, ...env }), + duration: getTextForDurationForOneTime(deps, value.duration, env), +}) + +/** + * Get the texts for all fast sustained performance parameters. + */ +export const getTextForFastSustainedPerformanceParameters = ( + deps: { + getSkillModificationLevelById: GetById.Static.SkillModificationLevel + translate: Translate + translateMap: TranslateMap + }, + value: FastSustainedPerformanceParameters, + env: { + entity: Entity + responsiveText: ResponsiveTextSize + }, +): { + castingTime: string + cost: string + range: string + duration: string +} => ({ + castingTime: getTextForFastCastingTime(deps, value.casting_time, env), + cost: getTextForSustainedCost(deps, value.cost, { speed: Speed.Fast, ...env }), + range: getTextForActivatableSkillRange(deps, value.range, { speed: Speed.Fast, ...env }), + duration: getTextForDurationForSustained(deps, value.duration, env), +}) + +/** + * Get the texts for all slow one-time performance parameters. + */ +export const getTextForSlowOneTimePerformanceParameters = ( + deps: { + getSkillModificationLevelById: GetById.Static.SkillModificationLevel + translate: Translate + translateMap: TranslateMap + }, + value: SlowOneTimePerformanceParameters, + env: { + entity: Entity + responsiveText: ResponsiveTextSize + }, +): { + castingTime: string + cost: string + range: string + duration: string +} => ({ + castingTime: getTextForSlowCastingTime(deps, value.casting_time, env), + cost: getTextForOneTimeCost(deps, value.cost, { speed: Speed.Slow, ...env }), + range: getTextForActivatableSkillRange(deps, value.range, { speed: Speed.Slow, ...env }), + duration: getTextForDurationForOneTime(deps, value.duration, env), +}) + +/** + * Get the texts for all slow sustained performance parameters. + */ +export const getTextForSlowSustainedPerformanceParameters = ( + deps: { + getSkillModificationLevelById: GetById.Static.SkillModificationLevel + translate: Translate + translateMap: TranslateMap + }, + value: SlowSustainedPerformanceParameters, + env: { + entity: Entity + responsiveText: ResponsiveTextSize + }, +): { + castingTime: string + cost: string + range: string + duration: string +} => ({ + castingTime: getTextForSlowCastingTime(deps, value.casting_time, env), + cost: getTextForSustainedCost(deps, value.cost, { speed: Speed.Slow, ...env }), + range: getTextForActivatableSkillRange(deps, value.range, { speed: Speed.Slow, ...env }), + duration: getTextForDurationForSustained(deps, value.duration, env), +}) diff --git a/src/shared/domain/rated/combatTechnique.ts b/src/shared/domain/rated/combatTechnique.ts index fe8e2bb0b..6b1aa0b34 100644 --- a/src/shared/domain/rated/combatTechnique.ts +++ b/src/shared/domain/rated/combatTechnique.ts @@ -2,8 +2,12 @@ import { CloseCombatTechnique } from "optolith-database-schema/types/CombatTechn import { RangedCombatTechnique } from "optolith-database-schema/types/CombatTechnique_Ranged" import { CombatTechniqueIdentifier } from "optolith-database-schema/types/_IdentifierGroup" import { AttributeReference } from "optolith-database-schema/types/_SimpleReferences" +import { mapNullable } from "../../utils/nullable.ts" import { Activatable, countOptions } from "../activatable/activatableEntry.ts" +import { createImprovementCost } from "../adventurePoints/improvementCost.ts" +import { GetById } from "../getTypes.ts" import { AttributeIdentifier } from "../identifier.ts" +import { createLibraryEntryCreator } from "../libraryEntry.ts" import { getAttributeValue } from "./attribute.ts" import { Rated } from "./ratedEntry.ts" @@ -153,3 +157,73 @@ export const getHighestRequiredAttributeForCombatTechnique = ( value: dynamicCombatTechnique.value - 2 - exceptionalCombatTechniqueBonus, } } + +/** + * Get a JSON representation of the rules text for a close combat technique. + */ +export const getCloseCombatTechniqueLibraryEntry = createLibraryEntryCreator< + CloseCombatTechnique, + { + getAttributeById: GetById.Static.Attribute + } +>((entry, { getAttributeById }) => ({ translate, translateMap }) => { + const translation = translateMap(entry.translations) + + if (translation === undefined) { + return undefined + } + + return { + title: translation.name, + className: "combat-technique close-combat-technique", + content: [ + mapNullable(translation.special, value => ({ + label: translate("Special"), + value, + })), + { + label: translate("Primary Attribute"), + value: entry.primary_attribute + .map(attr => translateMap(getAttributeById(attr.id.attribute)?.translations)?.name) + .join("/"), + }, + createImprovementCost(translate, entry.improvement_cost), + ], + src: entry.src, + } +}) + +/** + * Get a JSON representation of the rules text for a ranged combat technique. + */ +export const getRangedCombatTechniqueLibraryEntry = createLibraryEntryCreator< + RangedCombatTechnique, + { + getAttributeById: GetById.Static.Attribute + } +>((entry, { getAttributeById }) => ({ translate, translateMap }) => { + const translation = translateMap(entry.translations) + + if (translation === undefined) { + return undefined + } + + return { + title: translation.name, + className: "combat-technique ranged-combat-technique", + content: [ + mapNullable(translation.special, value => ({ + label: translate("Special"), + value, + })), + { + label: translate("Primary Attribute"), + value: entry.primary_attribute + .map(attr => translateMap(getAttributeById(attr.id.attribute)?.translations)?.name) + .join("/"), + }, + createImprovementCost(translate, entry.improvement_cost), + ], + src: entry.src, + } +}) diff --git a/src/shared/domain/rated/liturgicalChant.ts b/src/shared/domain/rated/liturgicalChant.ts index 54347092a..3983ebf54 100644 --- a/src/shared/domain/rated/liturgicalChant.ts +++ b/src/shared/domain/rated/liturgicalChant.ts @@ -1,20 +1,41 @@ import { Aspect } from "optolith-database-schema/types/Aspect" +import { Blessing } from "optolith-database-schema/types/Blessing" +import { Ceremony } from "optolith-database-schema/types/Ceremony" import { ExperienceLevel } from "optolith-database-schema/types/ExperienceLevel" +import { LiturgicalChant } from "optolith-database-schema/types/LiturgicalChant" import { SkillTradition } from "optolith-database-schema/types/_Blessed" import { LiturgyIdentifier } from "optolith-database-schema/types/_IdentifierGroup" import { ImprovementCost } from "optolith-database-schema/types/_ImprovementCost" +import { AspectReference } from "optolith-database-schema/types/_SimpleReferences" import { SkillCheck } from "optolith-database-schema/types/_SkillCheck" import { BlessedTradition } from "optolith-database-schema/types/specialAbility/BlessedTradition" import { count, countByMany } from "../../utils/array.ts" import { Compare, compareAt, compareNullish, numAsc, reduceCompare } from "../../utils/compare.ts" import { isNotNullish } from "../../utils/nullable.ts" -import { TranslateMap } from "../../utils/translate.ts" +import { Translate, TranslateMap } from "../../utils/translate.ts" import { assertExhaustive } from "../../utils/typeSafety.ts" import { Activatable, countOptions } from "../activatable/activatableEntry.ts" -import { compareImprovementCost, fromRaw } from "../adventurePoints/improvementCost.ts" -import { All } from "../getTypes.ts" +import { + compareImprovementCost, + createImprovementCost, + fromRaw, +} from "../adventurePoints/improvementCost.ts" +import { All, GetById } from "../getTypes.ts" import { AspectIdentifier, createIdentifierObject } from "../identifier.ts" +import { createLibraryEntryCreator, LibraryEntryContent } from "../libraryEntry.ts" +import { getTextForBlessingDuration } from "../libraryEntry/activatableSkill/duration.ts" +import { getTextForEffect } from "../libraryEntry/activatableSkill/effect.ts" +import { Entity } from "../libraryEntry/activatableSkill/entity.ts" +import { getTextForBlessingRange } from "../libraryEntry/activatableSkill/range.ts" +import { getTextForTargetCategory } from "../libraryEntry/activatableSkill/targetCategory.ts" +import { ResponsiveTextSize } from "../libraryEntry/responsiveText.ts" import { LiturgiesSortOrder } from "../sortOrders.ts" +import { + getTextForFastOneTimePerformanceParameters, + getTextForFastSustainedPerformanceParameters, + getTextForSlowOneTimePerformanceParameters, + getTextForSlowSustainedPerformanceParameters, +} from "./activatableSkill.ts" import { DisplayedActiveLiturgy } from "./liturgicalChantActive.ts" import { DisplayedInactiveLiturgy } from "./liturgicalChantInactive.ts" import { @@ -26,6 +47,7 @@ import { isRatedActive, isRatedWithEnhancementsActive, } from "./ratedEntry.ts" +import { getTextForCheck } from "./skillCheck.ts" /** * Returns the value for a dynamic liturgical chant entry that might not exist @@ -533,3 +555,345 @@ export const filterAndSortDisplayed = `${aspects_str} / ${gr_str}`)) // ) + +const getTextForTraditions = ( + deps: { + translate: Translate + translateMap: TranslateMap + localeCompare: Compare + getBlessedTraditionById: GetById.Static.BlessedTradition + getAspectById: GetById.Static.Aspect + }, + values: SkillTradition[], +): LibraryEntryContent => { + const getAspectName = (ref: AspectReference) => + deps.translateMap(deps.getAspectById(ref.id.aspect)?.translations)?.name + + const text = values + .map(trad => { + switch (trad.tag) { + case "GeneralAspect": + return getAspectName(trad.general_aspect) + case "Tradition": { + const traditionTranslation = deps.translateMap( + deps.getBlessedTraditionById(trad.tradition.tradition.id.blessed_tradition) + ?.translations, + ) + const name = traditionTranslation?.name_compressed ?? traditionTranslation?.name + + if (name === undefined) { + return undefined + } + + const aspects = + trad.tradition.aspects + ?.map(getAspectName) + .filter(isNotNullish) + .sort(deps.localeCompare) ?? [] + + if (aspects.length === 0) { + return name + } + + return `${name} (${aspects.join(" and ")})` + } + default: + return assertExhaustive(trad) + } + }) + .filter(isNotNullish) + .join(", ") + + return { + label: deps.translate("Traditions"), + value: text, + } +} + +/** + * Get a JSON representation of the rules text for a blessing. + */ +export const getBlessingLibraryEntry = createLibraryEntryCreator< + Blessing, + { + getTargetCategoryById: GetById.Static.TargetCategory + } +>((entry, { getTargetCategoryById }) => ({ translate, translateMap }) => { + const translation = translateMap(entry.translations) + + if (translation === undefined) { + return undefined + } + + const range = getTextForBlessingRange({ translate }, entry.parameters.range, { + responsiveText: ResponsiveTextSize.Full, + }) + + const duration = getTextForBlessingDuration( + { translate, translateMap }, + entry.parameters.duration, + { + responsiveText: ResponsiveTextSize.Full, + }, + ) + + return { + title: translation.name, + className: "blessing", + content: [ + { + label: translate("Effect"), + value: translation.effect, + }, + { + label: translate("Range"), + value: range !== translation.range ? `***${range}*** (${translation.range})` : range, + }, + { + label: translate("Duration"), + value: + duration !== translation.duration + ? `***${duration}*** (${translation.duration})` + : duration, + }, + getTextForTargetCategory({ translate, translateMap, getTargetCategoryById }, entry.target), + ], + src: entry.src, + } +}) + +/** + * Get a JSON representation of the rules text for a liturgical chant. + */ +export const getLiturgicalChantLibraryEntry = createLibraryEntryCreator< + LiturgicalChant, + { + getAttributeById: GetById.Static.Attribute + getDerivedCharacteristicById: GetById.Static.DerivedCharacteristic + getSkillModificationLevelById: GetById.Static.SkillModificationLevel + getTargetCategoryById: GetById.Static.TargetCategory + getBlessedTraditionById: GetById.Static.BlessedTradition + getAspectById: GetById.Static.Aspect + } +>( + ( + entry, + { + getAttributeById, + getDerivedCharacteristicById, + getSkillModificationLevelById, + getTargetCategoryById, + getBlessedTraditionById, + getAspectById, + }, + ) => + ({ translate, translateMap, localeCompare }) => { + const translation = translateMap(entry.translations) + + if (translation === undefined) { + return undefined + } + + const { castingTime, cost, range, duration } = (() => { + switch (entry.parameters.tag) { + case "OneTime": + return getTextForFastOneTimePerformanceParameters( + { + getSkillModificationLevelById, + translate, + translateMap, + }, + entry.parameters.one_time, + { + entity: Entity.LiturgicalChant, + responsiveText: ResponsiveTextSize.Full, + }, + ) + + case "Sustained": + return getTextForFastSustainedPerformanceParameters( + { + getSkillModificationLevelById, + translate, + translateMap, + }, + entry.parameters.sustained, + { + entity: Entity.LiturgicalChant, + responsiveText: ResponsiveTextSize.Full, + }, + ) + + default: + return assertExhaustive(entry.parameters) + } + })() + + return { + title: translation.name, + className: "liturgical-chant", + content: [ + getTextForCheck({ translate, translateMap, getAttributeById }, entry.check, { + value: entry.check_penalty, + responsiveText: ResponsiveTextSize.Full, + getDerivedCharacteristicById, + }), + ...getTextForEffect(translation.effect, translate), + { + label: translate("Liturgical Time"), + value: + castingTime !== translation.casting_time.full + ? `***${castingTime}*** (${translation.casting_time.full})` + : castingTime, + }, + { + label: translate("KP Cost"), + value: + cost !== translation.cost.full ? `***${cost}*** (${translation.cost.full})` : cost, + }, + { + label: translate("Range"), + value: + range !== translation.range.full + ? `***${range}*** (${translation.range.full})` + : range, + }, + { + label: translate("Duration"), + value: + duration !== translation.duration.full + ? `***${duration}*** (${translation.duration.full})` + : duration, + }, + getTextForTargetCategory( + { translate, translateMap, getTargetCategoryById }, + entry.target, + ), + getTextForTraditions( + { translate, translateMap, localeCompare, getBlessedTraditionById, getAspectById }, + entry.traditions, + ), + createImprovementCost(translate, entry.improvement_cost), + ], + src: entry.src, + } + }, +) + +/** + * Get a JSON representation of the rules text for a ceremony. + */ +export const getCeremonyLibraryEntry = createLibraryEntryCreator< + Ceremony, + { + getAttributeById: GetById.Static.Attribute + getDerivedCharacteristicById: GetById.Static.DerivedCharacteristic + getSkillModificationLevelById: GetById.Static.SkillModificationLevel + getTargetCategoryById: GetById.Static.TargetCategory + getBlessedTraditionById: GetById.Static.BlessedTradition + getAspectById: GetById.Static.Aspect + } +>( + ( + entry, + { + getAttributeById, + getDerivedCharacteristicById, + getSkillModificationLevelById, + getTargetCategoryById, + getBlessedTraditionById, + getAspectById, + }, + ) => + ({ translate, translateMap, localeCompare }) => { + const translation = translateMap(entry.translations) + + if (translation === undefined) { + return undefined + } + + const { castingTime, cost, range, duration } = (() => { + switch (entry.parameters.tag) { + case "OneTime": + return getTextForSlowOneTimePerformanceParameters( + { + getSkillModificationLevelById, + translate, + translateMap, + }, + entry.parameters.one_time, + { + entity: Entity.Ceremony, + responsiveText: ResponsiveTextSize.Full, + }, + ) + + case "Sustained": + return getTextForSlowSustainedPerformanceParameters( + { + getSkillModificationLevelById, + translate, + translateMap, + }, + entry.parameters.sustained, + { + entity: Entity.Ceremony, + responsiveText: ResponsiveTextSize.Full, + }, + ) + + default: + return assertExhaustive(entry.parameters) + } + })() + + return { + title: translation.name, + className: "ceremony", + content: [ + getTextForCheck({ translate, translateMap, getAttributeById }, entry.check, { + value: entry.check_penalty, + responsiveText: ResponsiveTextSize.Full, + getDerivedCharacteristicById, + }), + ...getTextForEffect(translation.effect, translate), + { + label: translate("Ceremonial Time"), + value: + castingTime !== translation.casting_time.full + ? `***${castingTime}*** (${translation.casting_time.full})` + : castingTime, + }, + { + label: translate("KP Cost"), + value: + cost !== translation.cost.full ? `***${cost}*** (${translation.cost.full})` : cost, + }, + { + label: translate("Range"), + value: + range !== translation.range.full + ? `***${range}*** (${translation.range.full})` + : range, + }, + { + label: translate("Duration"), + value: + duration !== translation.duration.full + ? `***${duration}*** (${translation.duration.full})` + : duration, + }, + getTextForTargetCategory( + { translate, translateMap, getTargetCategoryById }, + entry.target, + ), + getTextForTraditions( + { translate, translateMap, localeCompare, getBlessedTraditionById, getAspectById }, + entry.traditions, + ), + createImprovementCost(translate, entry.improvement_cost), + ], + src: entry.src, + } + }, +) diff --git a/src/shared/domain/rated/spell.ts b/src/shared/domain/rated/spell.ts index 22be9f229..8e2177fe5 100644 --- a/src/shared/domain/rated/spell.ts +++ b/src/shared/domain/rated/spell.ts @@ -9,18 +9,23 @@ import { SpellworkIdentifier, } from "optolith-database-schema/types/_IdentifierGroup" import { ImprovementCost as RawImprovementCost } from "optolith-database-schema/types/_ImprovementCost" +import { + MagicalTraditionReference, + PropertyReference, +} from "optolith-database-schema/types/_SimpleReferences" import { SkillCheck } from "optolith-database-schema/types/_SkillCheck" import { Traditions } from "optolith-database-schema/types/_Spellwork" import { count, countBy, sum } from "../../utils/array.ts" import { Compare, compareAt, compareNullish, numAsc, reduceCompare } from "../../utils/compare.ts" -import { isNotNullish, mapNullableDefault } from "../../utils/nullable.ts" -import { TranslateMap } from "../../utils/translate.ts" +import { isNotNullish, mapNullable, mapNullableDefault } from "../../utils/nullable.ts" +import { Translate, TranslateMap } from "../../utils/translate.ts" import { assertExhaustive } from "../../utils/typeSafety.ts" import { Activatable, countOptions } from "../activatable/activatableEntry.ts" import { CombinedActiveMagicalTradition } from "../activatable/magicalTradition.ts" import { ImprovementCost, compareImprovementCost, + createImprovementCost, fromRaw, } from "../adventurePoints/improvementCost.ts" import { All, GetById } from "../getTypes.ts" @@ -29,7 +34,21 @@ import { createIdentifierObject, getCreateIdentifierObject, } from "../identifier.ts" +import { LibraryEntryContent, createLibraryEntryCreator } from "../libraryEntry.ts" +import { getTextForCantripDuration } from "../libraryEntry/activatableSkill/duration.ts" +import { getTextForEffect } from "../libraryEntry/activatableSkill/effect.ts" +import { Entity } from "../libraryEntry/activatableSkill/entity.ts" +import { parensIf } from "../libraryEntry/activatableSkill/parensIf.ts" +import { getTextForCantripRange } from "../libraryEntry/activatableSkill/range.ts" +import { getTextForTargetCategory } from "../libraryEntry/activatableSkill/targetCategory.ts" +import { ResponsiveTextSize } from "../libraryEntry/responsiveText.ts" import { SpellsSortOrder } from "../sortOrders.ts" +import { + getTextForFastOneTimePerformanceParameters, + getTextForFastSustainedPerformanceParameters, + getTextForSlowOneTimePerformanceParameters, + getTextForSlowSustainedPerformanceParameters, +} from "./activatableSkill.ts" import { cursesImprovementCost, dominationRitualsImprovementCost, @@ -45,6 +64,7 @@ import { isRatedActive, isRatedWithEnhancementsActive, } from "./ratedEntry.ts" +import { getTextForCheck } from "./skillCheck.ts" import { DisplayedActiveSpellwork } from "./spellActive.ts" import { DisplayedInactiveSpellwork } from "./spellInactive.ts" @@ -700,3 +720,423 @@ export const filterAndSortDisplayed = < })(), ) } + +const getTextForProperty = ( + deps: { + translate: Translate + translateMap: TranslateMap + getPropertyById: GetById.Static.Property + }, + value: PropertyReference, +): LibraryEntryContent => { + const text = (() => { + const staticEntry = deps.getPropertyById(value.id.property) + const staticEntryTranslation = deps.translateMap(staticEntry?.translations) + + if (staticEntryTranslation === undefined) { + return "" + } + + return staticEntryTranslation.name + })() + + return { + label: deps.translate("Property"), + value: text, + } +} + +const getTextForTraditions = ( + deps: { + translate: Translate + translateMap: TranslateMap + localeCompare: Compare + getMagicalTraditionById: GetById.Static.MagicalTradition + }, + value: Traditions, +): LibraryEntryContent => { + const text = (() => { + switch (value.tag) { + case "General": + return deps.translate("General") + case "Specific": + return value.specific + .map(trad => + deps.translateMap(deps.getMagicalTraditionById(trad.magical_tradition)?.translations), + ) + .filter(isNotNullish) + .map(trad => trad.name_for_arcane_spellworks ?? trad.name) + .sort(deps.localeCompare) + .join(", ") + default: + return assertExhaustive(value) + } + })() + + return { + label: deps.translate("Traditions"), + value: text, + } +} + +const getTraditionNameForArcaneSpellworksById = ( + ref: MagicalTraditionReference, + getMagicalTraditionById: GetById.Static.MagicalTradition, + translateMap: TranslateMap, +) => { + const translation = translateMap(getMagicalTraditionById(ref.id.magical_tradition)?.translations) + return translation?.name_for_arcane_spellworks ?? translation?.name +} + +/** + * Get a JSON representation of the rules text for a cantrip. + */ +export const getCantripLibraryEntry = createLibraryEntryCreator< + Cantrip, + { + getTargetCategoryById: GetById.Static.TargetCategory + getPropertyById: GetById.Static.Property + getMagicalTraditionById: GetById.Static.MagicalTradition + getCurriculumById: GetById.Static.Curriculum + } +>( + (entry, { getTargetCategoryById, getPropertyById, getMagicalTraditionById, getCurriculumById }) => + ({ translate, translateMap, localeCompare }) => { + const translation = translateMap(entry.translations) + + if (translation === undefined) { + return undefined + } + + const range = getTextForCantripRange({ translate }, entry.parameters.range, { + responsiveText: ResponsiveTextSize.Full, + }) + + const duration = getTextForCantripDuration( + { translate, translateMap }, + entry.parameters.duration, + { + responsiveText: ResponsiveTextSize.Full, + }, + ) + + return { + title: translation.name, + className: "cantrip", + content: [ + { + label: translate("Effect"), + value: translation.effect, + }, + { + label: translate("Range"), + value: range !== translation.range ? `***${range}*** (${translation.range})` : range, + }, + { + label: translate("Duration"), + value: + duration !== translation.duration + ? `***${duration}*** (${translation.duration})` + : duration, + }, + getTextForTargetCategory( + { translate, translateMap, getTargetCategoryById }, + entry.target, + ), + getTextForProperty({ translate, translateMap, getPropertyById }, entry.property), + mapNullable(entry.note, note => ({ + label: translate("Note"), + value: (() => { + switch (note.tag) { + case "Common": + return note.common.list + .map(academyOrTradition => { + switch (academyOrTradition.tag) { + case "Academy": + return translateMap( + getCurriculumById(academyOrTradition.academy.id.curriculum) + ?.translations, + )?.name + case "Tradition": { + return mapNullable( + getTraditionNameForArcaneSpellworksById( + academyOrTradition.tradition, + getMagicalTraditionById, + translateMap, + ), + name => + name + + parensIf( + translateMap(academyOrTradition.tradition.translations)?.note, + ), + ) + } + default: + return assertExhaustive(academyOrTradition) + } + }) + .filter(isNotNullish) + .sort(localeCompare) + .join(", ") + + case "Exclusive": + return note.exclusive.traditions + .map(tradition => + getTraditionNameForArcaneSpellworksById( + tradition, + getMagicalTraditionById, + translateMap, + ), + ) + .filter(isNotNullish) + .sort(localeCompare) + .join(", ") + + default: + return assertExhaustive(note) + } + })(), + })), + ], + src: entry.src, + } + }, +) + +/** + * Get a JSON representation of the rules text for a skill. + */ +export const getSpellLibraryEntry = createLibraryEntryCreator< + Spell, + { + getAttributeById: GetById.Static.Attribute + getDerivedCharacteristicById: GetById.Static.DerivedCharacteristic + getSkillModificationLevelById: GetById.Static.SkillModificationLevel + getTargetCategoryById: GetById.Static.TargetCategory + getPropertyById: GetById.Static.Property + getMagicalTraditionById: GetById.Static.MagicalTradition + } +>( + ( + entry, + { + getAttributeById, + getDerivedCharacteristicById, + getSkillModificationLevelById, + getTargetCategoryById, + getPropertyById, + getMagicalTraditionById, + }, + ) => + ({ translate, translateMap, localeCompare }) => { + const translation = translateMap(entry.translations) + + if (translation === undefined) { + return undefined + } + + const { castingTime, cost, range, duration } = (() => { + switch (entry.parameters.tag) { + case "OneTime": + return getTextForFastOneTimePerformanceParameters( + { + getSkillModificationLevelById, + translate, + translateMap, + }, + entry.parameters.one_time, + { + entity: Entity.Spell, + responsiveText: ResponsiveTextSize.Full, + }, + ) + + case "Sustained": + return getTextForFastSustainedPerformanceParameters( + { + getSkillModificationLevelById, + translate, + translateMap, + }, + entry.parameters.sustained, + { + entity: Entity.Spell, + responsiveText: ResponsiveTextSize.Full, + }, + ) + + default: + return assertExhaustive(entry.parameters) + } + })() + + return { + title: translation.name, + className: "spell", + content: [ + getTextForCheck({ translate, translateMap, getAttributeById }, entry.check, { + value: entry.check_penalty, + responsiveText: ResponsiveTextSize.Full, + getDerivedCharacteristicById, + }), + ...getTextForEffect(translation.effect, translate), + { + label: translate("Casting Time"), + value: + castingTime !== translation.casting_time.full + ? `***${castingTime}*** (${translation.casting_time.full})` + : castingTime, + }, + { + label: translate("AE Cost"), + value: + cost !== translation.cost.full ? `***${cost}*** (${translation.cost.full})` : cost, + }, + { + label: translate("Range"), + value: + range !== translation.range.full + ? `***${range}*** (${translation.range.full})` + : range, + }, + { + label: translate("Duration"), + value: + duration !== translation.duration.full + ? `***${duration}*** (${translation.duration.full})` + : duration, + }, + getTextForTargetCategory( + { translate, translateMap, getTargetCategoryById }, + entry.target, + ), + getTextForProperty({ translate, translateMap, getPropertyById }, entry.property), + getTextForTraditions( + { translate, translateMap, localeCompare, getMagicalTraditionById }, + entry.traditions, + ), + createImprovementCost(translate, entry.improvement_cost), + ], + src: entry.src, + } + }, +) + +/** + * Get a JSON representation of the rules text for a ritual. + */ +export const getRitualLibraryEntry = createLibraryEntryCreator< + Ritual, + { + getAttributeById: GetById.Static.Attribute + getDerivedCharacteristicById: GetById.Static.DerivedCharacteristic + getSkillModificationLevelById: GetById.Static.SkillModificationLevel + getTargetCategoryById: GetById.Static.TargetCategory + getPropertyById: GetById.Static.Property + getMagicalTraditionById: GetById.Static.MagicalTradition + } +>( + ( + entry, + { + getAttributeById, + getDerivedCharacteristicById, + getSkillModificationLevelById, + getTargetCategoryById, + getPropertyById, + getMagicalTraditionById, + }, + ) => + ({ translate, translateMap, localeCompare }) => { + const translation = translateMap(entry.translations) + + if (translation === undefined) { + return undefined + } + + const { castingTime, cost, range, duration } = (() => { + switch (entry.parameters.tag) { + case "OneTime": + return getTextForSlowOneTimePerformanceParameters( + { + getSkillModificationLevelById, + translate, + translateMap, + }, + entry.parameters.one_time, + { + entity: Entity.Ritual, + responsiveText: ResponsiveTextSize.Full, + }, + ) + + case "Sustained": + return getTextForSlowSustainedPerformanceParameters( + { + getSkillModificationLevelById, + translate, + translateMap, + }, + entry.parameters.sustained, + { + entity: Entity.Ritual, + responsiveText: ResponsiveTextSize.Full, + }, + ) + + default: + return assertExhaustive(entry.parameters) + } + })() + + return { + title: translation.name, + className: "ritual", + content: [ + getTextForCheck({ translate, translateMap, getAttributeById }, entry.check, { + value: entry.check_penalty, + responsiveText: ResponsiveTextSize.Full, + getDerivedCharacteristicById, + }), + ...getTextForEffect(translation.effect, translate), + { + label: translate("Ritual Time"), + value: + castingTime !== translation.casting_time.full + ? `***${castingTime}*** (${translation.casting_time.full})` + : castingTime, + }, + { + label: translate("AE Cost"), + value: + cost !== translation.cost.full ? `***${cost}*** (${translation.cost.full})` : cost, + }, + { + label: translate("Range"), + value: + range !== translation.range.full + ? `***${range}*** (${translation.range.full})` + : range, + }, + { + label: translate("Duration"), + value: + duration !== translation.duration.full + ? `***${duration}*** (${translation.duration.full})` + : duration, + }, + getTextForTargetCategory( + { translate, translateMap, getTargetCategoryById }, + entry.target, + ), + getTextForProperty({ translate, translateMap, getPropertyById }, entry.property), + getTextForTraditions( + { translate, translateMap, localeCompare, getMagicalTraditionById }, + entry.traditions, + ), + createImprovementCost(translate, entry.improvement_cost), + ], + src: entry.src, + } + }, +) diff --git a/src/shared/utils/array.test.ts b/src/shared/utils/array.test.ts index e456364b6..83926fa70 100644 --- a/src/shared/utils/array.test.ts +++ b/src/shared/utils/array.test.ts @@ -7,6 +7,7 @@ import { countByMany, ensureNonEmpty, filterNonNullable, + groupBy, partition, range, rangeSafe, @@ -245,3 +246,15 @@ describe("someCount", () => { assert.equal(result, false) }) }) + +describe("groupBy", () => { + it("should return an empty array for an empty input", () => { + const result = groupBy([], (a, b) => a === b) + assert.deepEqual(result, []) + }) + + it("should group adjacent elements that are equal", () => { + const result = groupBy([1, 1, 2, 2, 2, 3, 4, 4, 4, 4], (a, b) => a === b) + assert.deepEqual(result, [[1, 1], [2, 2, 2], [3], [4, 4, 4, 4]]) + }) +}) diff --git a/src/shared/utils/array.ts b/src/shared/utils/array.ts index b4c0b10f2..54863258c 100644 --- a/src/shared/utils/array.ts +++ b/src/shared/utils/array.ts @@ -1,3 +1,4 @@ +import { Equality } from "./compare.ts" import { isNotNullish } from "./nullable.ts" /** @@ -183,3 +184,19 @@ export const removeIndex = (arr: T[], index: number): T[] => [ ...arr.slice(0, index), ...arr.slice(index + 1), ] + +/** + * Returns a new array, where adjacent elements that are considered equal by the + * given equality function are grouped together. Calling the `flat` method on + * the result will return the original array. + */ +export const groupBy = (arr: T[], equal: Equality): T[][] => + arr.reduce((acc, value) => { + const lastGroup = acc[acc.length - 1] + if (lastGroup?.[0] !== undefined && equal(lastGroup[0], value)) { + lastGroup.push(value) + } else { + acc.push([value]) + } + return acc + }, []) diff --git a/src/shared/utils/translate.ts b/src/shared/utils/translate.ts index 53c98a167..3fcf3307a 100644 --- a/src/shared/utils/translate.ts +++ b/src/shared/utils/translate.ts @@ -18,12 +18,7 @@ const matchSystemLocaleToSupported = (available: string[], system: string) => { /** * Translates a given key into a string, optionally with parameters. */ -export type Translate = ( - key: K, - ...options: UI[K] extends PluralizationCategories - ? [count: number, ...params: (string | number)[]] - : [...params: (string | number)[]] -) => string +export type Translate = (key: K, ...params: (string | number)[]) => string const isPluralizationCategories = ( value: string | PluralizationCategories | VaryBySystem, @@ -40,6 +35,24 @@ const pluralType: { "You are missing {0} Adventure Points to do this.": "cardinal", "since the {0}. printing": "ordinal", "removed in {0}. printing": "ordinal", + "{0} actions": "cardinal", + "{0} hours": "cardinal", + "{0} minutes": "cardinal", + "{0} rounds": "cardinal", + "{0} seduction actions": "cardinal", + ", {0} of which are permanent": "cardinal", + "{0} centuries": "cardinal", + "{0} combat rounds": "cardinal", + "{0} days": "cardinal", + "{0} months": "cardinal", + "{0} mos.": "cardinal", + "{0} seconds": "cardinal", + "{0} weeks": "cardinal", + "{0} wks.": "cardinal", + "{0} years": "cardinal", + "{0} yrs.": "cardinal", + "{0} miles": "cardinal", + "{0} yards": "cardinal", } /** @@ -96,6 +109,14 @@ export const createTranslate = ( return translate } +/** + * A mocked translate function. + */ +export const translateMock: Translate = ( + key: K, + ...options: (string | number)[] +) => insertParams(key, options) + /** * Selects a value from a locale dictionary based on the selected locale. */