feat: expression-based activatable skill parameters

This commit is contained in:
Lukas Obermann
2026-06-14 00:14:55 +02:00
parent aca535421e
commit f2749ff383
8 changed files with 103 additions and 106 deletions
+4 -4
View File
@@ -18,7 +18,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@optolith/database-schema": "^0.47.4",
"@optolith/database-schema": "^0.49.0",
"@types/node": "^25.9.1",
"commit-and-tag-version": "^12.7.3",
"eslint-config-prettier": "^10.1.8",
@@ -842,9 +842,9 @@
"license": "MPL-2.0"
},
"node_modules/@optolith/database-schema": {
"version": "0.47.4",
"resolved": "https://registry.npmjs.org/@optolith/database-schema/-/database-schema-0.47.4.tgz",
"integrity": "sha512-FaKuLaOWD7P6CldlMjcTF/K+VWX82xUH86O4Ys8cFV3AyF7GPQEUyfLHrMprtcta/qWYqPqLUSOm1U3lzcEdAQ==",
"version": "0.49.0",
"resolved": "https://registry.npmjs.org/@optolith/database-schema/-/database-schema-0.49.0.tgz",
"integrity": "sha512-1YqZnPRCTStzb+RzMyR56dqNlniMuBRTt0gWjJc/C/j7VAFxZ8qVXR5FXki8dKXPcNNK6fiu4hlJ34Fmrgp5Vw==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
+1 -1
View File
@@ -42,7 +42,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@optolith/database-schema": "^0.47.4",
"@optolith/database-schema": "^0.49.0",
"@types/node": "^25.9.1",
"commit-and-tag-version": "^12.7.3",
"eslint-config-prettier": "^10.1.8",
+9 -4
View File
@@ -48,14 +48,19 @@ export const groupFormatter: UnaryFormatter = value => `(${printOperand(value)})
/**
* Render a math operation as a string, using the provided function to render the values.
*/
export const renderMathOperation = <T>(
export const renderMathOperation = <T, R extends string | number>(
operation: MathOperation<T>,
renderValue: (value: T) => string,
): string => {
renderValue: (value: T) => R,
renderDirectValue?: (value: T) => R,
): R | string => {
if (operation.kind === "Value" && renderDirectValue) {
return renderDirectValue(operation.Value)
}
const renderWithParenthesis = (
op: MathOperation<T>,
addParenthesisTo: MathOperation<T>["kind"][] = [],
): string => {
): R | string => {
const rendered = renderMathOperation(op, renderValue)
return addParenthesisTo.includes(op.kind) ? groupFormatter(rendered) : rendered
}
@@ -1,25 +1,16 @@
import { Reader } from "@elyukai/utils/reader"
import type {
CheckResultArithmetic,
CheckResultBasedModifier,
CheckResultValue,
ExpressionBasedParameterValue,
} from "@optolith/database-schema/gen"
import { mapNullableDefault } from "@optolith/helpers/nullable"
import { assertExhaustive } from "@optolith/helpers/typeSafety"
import { divisionFormatter, multiplicationFormatter } from "../../mathOperation.js"
import { translateR, type StdReader } from "../../reader.js"
const renderCheckResultBaseValue = (baseValue: CheckResultValue) => {
switch (baseValue.kind) {
case "QualityLevels":
return translateR("QL")
case "SkillPoints":
return translateR("SP")
case "SkillRating":
return translateR("SR")
default:
return assertExhaustive(baseValue)
}
}
import {
divisionFormatter,
multiplicationFormatter,
renderMathOperation,
} from "../../mathOperation.js"
import { type StdEnv, type StdReader } from "../../reader.js"
const getArithmeticFormatter = (arithmetic: CheckResultArithmetic) => {
switch (arithmetic.kind) {
@@ -32,18 +23,6 @@ const getArithmeticFormatter = (arithmetic: CheckResultArithmetic) => {
}
}
interface CheckResultBased {
/**
* The base value that is derived from the check result.
*/
base: CheckResultValue
/**
* If defined, it modifies the base value.
*/
modifier?: CheckResultBasedModifier
}
/**
* Appends the modifier of a check-result-based parameter of an activatable skill to the base value, using the appropriate arithmetic formatter.
*/
@@ -53,10 +32,25 @@ export const appendCheckResultModifier = (left: string, modifier: CheckResultBas
}
/**
* Returns the value text for a check-result-based parameter of an activatable
* skill.
* Renders a value that is based on the result of the skill check, which can
* either be a simple value or a math operation.
*/
export const renderCheckResultBasedValue = (value: CheckResultBased): StdReader<string, "t"> =>
renderCheckResultBaseValue(value.base).map(base =>
mapNullableDefault(value.modifier, modifier => appendCheckResultModifier(base, modifier), base),
export const renderExpressionBasedParameterValue = (
value: ExpressionBasedParameterValue,
): StdReader<string | number, "t"> =>
Reader.asks(({ translate }: StdEnv<"t">) =>
renderMathOperation(value, expressionValue => {
switch (expressionValue.kind) {
case "Constant":
return expressionValue.Constant
case "QualityLevels":
return translate("QL")
case "SkillPoints":
return translate("SP")
case "SkillRating":
return translate("SR")
default:
return assertExhaustive(expressionValue)
}
}),
)
@@ -1,9 +1,8 @@
import { Reader } from "@elyukai/utils/reader"
import type {
CastingTimeDuringLovemaking,
CheckResultBasedDuration,
DurationForSustained,
FixedDuration,
ExpressionBasedDuration,
Immediate,
MusicDuration,
PermanentDuration,
@@ -11,6 +10,7 @@ import type {
} from "@optolith/database-schema/gen"
import { mapNullable } from "@optolith/helpers/nullable"
import { assertExhaustive } from "@optolith/helpers/typeSafety"
import { Case } from "../../../../helpers/enums.js"
import type { LocaleMap } from "../../../../helpers/translate.js"
import {
responsiveTextR,
@@ -22,7 +22,7 @@ import {
import { replaceTextIfNeeded } from "../../responsiveText.js"
import { formatCombinedTimeSpanR, formatTimeSpanR } from "../../units/timeSpan.js"
import { MISSING_VALUE } from "../../unknown.js"
import { renderCheckResultBasedValue } from "./checkResultBased.js"
import { renderExpressionBasedParameterValue } from "./checkResultBased.js"
import { wrapAsMaximum, wrapIfMaximum } from "./isMinimumMaximum.js"
import { appendInParensIfNotEmpty } from "./parensIf.js"
@@ -41,21 +41,17 @@ const renderImmediateDuration = (value?: Immediate): StdReader<string, "t" | "tm
const renderPermanentDuration = (value: PermanentDuration): StdReader<string, "t" | "tm" | "rts"> =>
translateR("Permanent").thenW(text => replaceTextIfNeeded(value.translations, text))
const renderFixedDuration = (value: FixedDuration): StdReader<string, "t" | "tm" | "rts"> =>
formatCombinedTimeSpanR(value)
/**
* Returns the text for a duration that is based on an expression.
*/
export const renderExpressionBasedDuration = (
value: ExpressionBasedDuration,
): StdReader<string, "t" | "tm" | "rts"> =>
renderExpressionBasedParameterValue(value.value)
.thenW(expressionValue => formatTimeSpanR(value.unit, expressionValue))
.then(text => wrapIfMaximum(value.is_maximum, text).map(wrapped => [wrapped, text] as const))
.thenW(([wrapped, text]) => replaceTextIfNeeded(value.translations, wrapped, text))
/**
* Renders a duration that is based on the result of the skill check.
*/
export const renderCheckResultBasedDuration = (
value: CheckResultBasedDuration,
): StdReader<string, "t" | "tm" | "rts"> =>
renderCheckResultBasedValue(value)
.thenW(text => formatTimeSpanR(value.unit, text))
.then(text => wrapIfMaximum(value.is_maximum, text))
const renderIndefiniteDuration = (value: {
maximum?: OneTimeDuration
translations: LocaleMap<{
@@ -100,11 +96,11 @@ type OneTimeDuration =
}
| {
kind: "Fixed"
Fixed: FixedDuration
Fixed: Omit<ExpressionBasedDuration, "value"> & { value: number }
}
| {
kind: "CheckResultBased"
CheckResultBased: CheckResultBasedDuration
kind: "Expression"
Expression: ExpressionBasedDuration
}
| {
kind: "Indefinite"
@@ -133,9 +129,12 @@ export const renderOneTimeDuration = (
case "Permanent":
return renderPermanentDuration(value.Permanent)
case "Fixed":
return renderFixedDuration(value.Fixed)
case "CheckResultBased":
return renderCheckResultBasedDuration(value.CheckResultBased)
return renderExpressionBasedDuration({
...value.Fixed,
value: Case("Value", Case("Constant", value.Fixed.value)),
})
case "Expression":
return renderExpressionBasedDuration(value.Expression)
case "Indefinite":
return renderIndefiniteDuration(value.Indefinite)
case "DuringLovemaking":
+18 -24
View File
@@ -1,7 +1,6 @@
import { Reader } from "@elyukai/utils/reader"
import type {
CheckResultBasedRange,
FixedRange,
ExpressionBasedRange,
ModifiableRange,
Range,
RangeValue,
@@ -9,6 +8,7 @@ import type {
} from "@optolith/database-schema/gen"
import { mapNullable } from "@optolith/helpers/nullable"
import { assertExhaustive } from "@optolith/helpers/typeSafety"
import { Case } from "../../../../helpers/enums.js"
import {
getInstanceByIdFnR,
modifiableBySpeedOptionalR,
@@ -19,9 +19,9 @@ import {
type StdReader,
} from "../../reader.js"
import { appendNoteIfNeeded, replaceTextIfNeeded } from "../../responsiveText.js"
import { formatCombinedLengthR, formatLengthR } from "../../units/length.js"
import { formatLengthR } from "../../units/length.js"
import { MISSING_VALUE } from "../../unknown.js"
import { renderCheckResultBasedValue } from "./checkResultBased.js"
import { renderExpressionBasedParameterValue } from "./checkResultBased.js"
import { wrapIfMaximum } from "./isMinimumMaximum.js"
import { appendNonModifiableSuffix, ModifiableParameter } from "./nonModifiableSuffix.js"
@@ -65,14 +65,9 @@ const renderModifiableRange = (value: ModifiableRange) =>
.then(text => wrapIfRadius(value.is_radius, text))
.then(text => wrapIfMaximum(value.is_maximum, text))
const renderFixedRange = (value: FixedRange) =>
formatCombinedLengthR(value)
.then(text => wrapIfRadius(value.is_radius, text))
.then(text => wrapIfMaximum(value.is_maximum, text))
const getCheckResultBasedRangeTranslation = (value: CheckResultBasedRange) =>
renderCheckResultBasedValue(value)
.thenW(text => formatLengthR(value.unit, text))
const renderExpressionBasedRange = (value: ExpressionBasedRange) =>
renderExpressionBasedParameterValue(value.value)
.thenW(expressionValue => formatLengthR(value.unit, expressionValue))
.then(text => wrapIfRadius(value.is_radius, text))
.then(text => wrapIfMaximum(value.is_maximum, text))
@@ -95,11 +90,11 @@ export const renderNonModifiableRange = (
}
| {
kind: "Fixed"
Fixed: FixedRange
Fixed: Omit<ExpressionBasedRange, "value"> & { value: number }
}
| {
kind: "CheckResultBased"
CheckResultBased: CheckResultBasedRange
kind: "Expression"
Expression: ExpressionBasedRange
},
shouldAppendNonModifiableSuffix: boolean,
): StdReader<string, "t" | "tm" | "rts" | "nms"> => {
@@ -116,13 +111,13 @@ export const renderNonModifiableRange = (
return translateR("Global")
case "Touch":
return translateR("Touch").thenW(appendNonModifiableSuffixIfNeeded)
case "Fixed": {
return renderFixedRange(value.Fixed).thenW(appendNonModifiableSuffixIfNeeded)
}
case "CheckResultBased":
return getCheckResultBasedRangeTranslation(value.CheckResultBased).then(
appendNonModifiableSuffixIfNeeded,
)
case "Fixed":
return renderExpressionBasedRange({
...value.Fixed,
value: Case("Value", Case("Constant", value.Fixed.value)),
}).thenW(appendNonModifiableSuffixIfNeeded)
case "Expression":
return renderExpressionBasedRange(value.Expression).thenW(appendNonModifiableSuffixIfNeeded)
default:
return assertExhaustive(value)
}
@@ -142,8 +137,7 @@ export const renderRangeValue = (
case "Self":
case "Global":
case "Touch":
case "Fixed":
case "CheckResultBased":
case "Expression":
return renderNonModifiableRange(value, shouldAppendNonModifiableSuffix)
default:
return assertExhaustive(value)
+3 -3
View File
@@ -53,7 +53,7 @@ import {
renderNonModifiableOneTimeCost,
} from "./partial/rated/activatable/cost.js"
import {
renderCheckResultBasedDuration,
renderExpressionBasedDuration,
renderMusicDuration,
renderOneTimeDuration,
renderSustainedDuration,
@@ -1503,8 +1503,8 @@ const renderMagicalRuneCraftingTime = (craftingTime: MagicalRuneCraftingTime) =>
)
const renderMagicalRuneDuration = (duration: MagicalRuneDuration) =>
renderCheckResultBasedDuration(duration.fast).map2(
renderCheckResultBasedDuration(duration.slow),
renderExpressionBasedDuration(duration.fast).map2(
renderExpressionBasedDuration(duration.slow),
(fast, slow) => `${slow} / ${fast}`,
)
@@ -1,35 +1,40 @@
import type { ExpressionBasedParameterValue } from "@optolith/database-schema/gen"
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { renderCheckResultBasedValue } from "../../../../../src/entities/partial/rated/activatable/checkResultBased.js"
import { renderExpressionBasedParameterValue } from "../../../../../src/entities/partial/rated/activatable/checkResultBased.js"
import { Case } from "../../../../../src/helpers/enums.js"
import { translateMock } from "../../../../helpers/translate.js"
describe("getTextForCheckResultBased", () => {
it("should return the value text for a check-result-based parameter of an activatable skill", () => {
assert.equal(
renderCheckResultBasedValue({
base: Case("QualityLevels"),
}).run({ translate: translateMock }),
renderExpressionBasedParameterValue(Case("Value", Case("QualityLevels"))).run({
translate: translateMock,
}),
"QL",
)
assert.equal(
renderCheckResultBasedValue({
base: Case("SkillPoints"),
}).run({ translate: translateMock }),
renderExpressionBasedParameterValue(Case("Value", Case("SkillPoints"))).run({
translate: translateMock,
}),
"SP",
)
assert.equal(
renderCheckResultBasedValue({
base: Case("QualityLevels"),
modifier: { arithmetic: Case("Divide"), value: 2 },
}).run({ translate: translateMock }),
renderExpressionBasedParameterValue(
Case("Division", [
Case("Value", Case("QualityLevels")),
Case("Value", Case("Constant", 2)),
] as [ExpressionBasedParameterValue, ExpressionBasedParameterValue]),
).run({ translate: translateMock }),
"QL/2",
)
assert.equal(
renderCheckResultBasedValue({
base: Case("SkillPoints"),
modifier: { arithmetic: Case("Multiply"), value: 3 },
}).run({ translate: translateMock }),
renderExpressionBasedParameterValue(
Case("Multiplication", [
Case("Value", Case("SkillPoints")),
Case("Value", Case("Constant", 3)),
] as [ExpressionBasedParameterValue, ExpressionBasedParameterValue]),
).run({ translate: translateMock }),
"SP × 3",
)
})