From e14bb816c02b7f66601cba1528de06269cceead7 Mon Sep 17 00:00:00 2001 From: hzy <28915578+hzy@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:17:44 +0800 Subject: [PATCH] Split Soldoros doll into standalone mod package --- README.md | 6 +- apps/pvf-mod-cli/src/index.test.ts | 1 + docs/mod-pipeline-architecture.md | 2 + .../src/constants.ts | 3 +- mods/2_3_choro_partset_skill_up/src/data.ts | 25 +- .../src/index.test.ts | 137 ++++++++++- mods/2_3_choro_partset_skill_up/src/index.ts | 1 - mods/2_3_choro_partset_skill_up/src/mod.ts | 69 ++---- .../src/transform.ts | 29 +-- mods/pipelines.ts | 4 + mods/registry.ts | 2 + mods/soldoros_doll/package.json | 23 ++ mods/soldoros_doll/src/index.test.ts | 119 ++++++++++ mods/soldoros_doll/src/index.ts | 219 ++++++++++++++++++ mods/soldoros_doll/tsconfig.json | 11 + pnpm-lock.yaml | 25 ++ 16 files changed, 587 insertions(+), 89 deletions(-) create mode 100644 mods/soldoros_doll/package.json create mode 100644 mods/soldoros_doll/src/index.test.ts create mode 100644 mods/soldoros_doll/src/index.ts create mode 100644 mods/soldoros_doll/tsconfig.json diff --git a/README.md b/README.md index 4e28523..3f86fa3 100644 --- a/README.md +++ b/README.md @@ -39,12 +39,13 @@ The repository now has a workspace-based mod pipeline system: ### Built-in mods - `example_wild_strawberry_hp_up`: changes Wild Strawberry HP recovery from `60` to `600` -- `2_3_choro_partset_skill_up`: generates the merged Choro support equipment overlays +- `soldoros_doll`: prepares the Soldoros doll APC and updates `aicharacter.lst` +- `2_3_choro_partset_skill_up`: generates the merged Choro support equipment overlays and consumes the prerequisite doll APC ### Built-in pipelines - `wild-strawberry-only`: runs only the Wild Strawberry example mod -- `demo`: runs `example_wild_strawberry_hp_up -> 2_3_choro_partset_skill_up` +- `demo`: runs `example_wild_strawberry_hp_up -> soldoros_doll -> 2_3_choro_partset_skill_up` `demo` is the current default pipeline. @@ -116,6 +117,7 @@ If you want to test a temporary sequence without editing `mods/pipelines.ts`, pa pnpm --filter pvf-mod-cli start build \ --pipeline adhoc-preview \ --mod example_wild_strawberry_hp_up \ + --mod soldoros_doll \ --mod 2_3_choro_partset_skill_up ``` diff --git a/apps/pvf-mod-cli/src/index.test.ts b/apps/pvf-mod-cli/src/index.test.ts index fe3e07a..732ea4a 100644 --- a/apps/pvf-mod-cli/src/index.test.ts +++ b/apps/pvf-mod-cli/src/index.test.ts @@ -13,6 +13,7 @@ test("runCli list shows registered pipelines and mods", async () => { assert.match(output, /wild-strawberry-only/u); assert.match(output, /demo/u); assert.match(output, /example_wild_strawberry_hp_up/u); + assert.match(output, /soldoros_doll/u); assert.match(output, /2_3_choro_partset_skill_up/u); }); diff --git a/docs/mod-pipeline-architecture.md b/docs/mod-pipeline-architecture.md index 7542bcb..24041ea 100644 --- a/docs/mod-pipeline-architecture.md +++ b/docs/mod-pipeline-architecture.md @@ -291,6 +291,7 @@ pipeline 层会在每个 mod 执行前后抓取 overlay 快照,计算该 mod 当前内建 mod 包括: - `example_wild_strawberry_hp_up` +- `soldoros_doll` - `2_3_choro_partset_skill_up` 当前内建 pipeline 包括: @@ -352,6 +353,7 @@ CLI 支持重复传 `--mod` 来临时组装 pipeline。 pnpm --filter pvf-mod-cli start build \ --pipeline adhoc-preview \ --mod example_wild_strawberry_hp_up \ + --mod soldoros_doll \ --mod 2_3_choro_partset_skill_up ``` diff --git a/mods/2_3_choro_partset_skill_up/src/constants.ts b/mods/2_3_choro_partset_skill_up/src/constants.ts index f9e8c0a..019bc57 100644 --- a/mods/2_3_choro_partset_skill_up/src/constants.ts +++ b/mods/2_3_choro_partset_skill_up/src/constants.ts @@ -12,8 +12,7 @@ export const TARGET_PIECE_COUNTS = new Set([3, 6, 9]); export const EXPLAIN_HEADING = "获得以下套装的套装效果:"; export const GENERATED_SUPPORT_NAME_PREFIX = "诸界融核臂章"; export const GENERATED_SUPPORT_ID_START = 440453; -export const SUPPORT_SUMMON_SOURCE_NAME = "\u5251\u5723\u7d22\u5fb7\u7f57\u65af"; +export const SUPPORT_SUMMON_DOLL_NAME = "\u7d22\u5fb7\u7f57\u65af"; export const SUPPORT_SUMMON_COOLDOWN = 900_000; export const SUPPORT_SUMMON_EXPLAIN = "↑↓+[宠物技能指令]输入时,可以召唤出剑圣索德罗斯协助自身战斗,剑圣索德罗斯存在15分钟。"; -export const SUPPORT_SUMMON_ATTACK_DAMAGE_RATE = "1.0"; diff --git a/mods/2_3_choro_partset_skill_up/src/data.ts b/mods/2_3_choro_partset_skill_up/src/data.ts index 58072c9..ba81ca5 100644 --- a/mods/2_3_choro_partset_skill_up/src/data.ts +++ b/mods/2_3_choro_partset_skill_up/src/data.ts @@ -475,7 +475,26 @@ export async function loadSupportPathsByClass( export async function findAiCharacterByName( session: PvfModSession, targetName: string, + options: { + pathIncludes?: string; + } = {}, ): Promise { + const found = await tryFindAiCharacterByName(session, targetName, options); + + if (found) { + return found; + } + + throw new Error(`Unable to find APC id for ${targetName}.`); +} + +export async function tryFindAiCharacterByName( + session: PvfModSession, + targetName: string, + options: { + pathIncludes?: string; + } = {}, +): Promise { const pathById = await loadListedPathById( session, AI_CHARACTER_LIST_PATH, @@ -488,6 +507,10 @@ export async function findAiCharacterByName( (left, right) => left[0] - right[0], ) ) { + if (options.pathIncludes && !aicPath.includes(options.pathIncludes)) { + continue; + } + const document = await readEquDocument(session, aicPath, session.textProfile); const minimumInfoName = getFirstSectionString(document.children, "minimum info")?.trim(); @@ -499,7 +522,7 @@ export async function findAiCharacterByName( } } - throw new Error(`Unable to find APC id for ${targetName}.`); + return undefined; } export { findNextAvailableListedPathId }; diff --git a/mods/2_3_choro_partset_skill_up/src/index.test.ts b/mods/2_3_choro_partset_skill_up/src/index.test.ts index 78551f5..ddfa07e 100644 --- a/mods/2_3_choro_partset_skill_up/src/index.test.ts +++ b/mods/2_3_choro_partset_skill_up/src/index.test.ts @@ -6,12 +6,31 @@ import test from "node:test"; import { parseEquDocument } from "@pvf/equ-ast"; import { PvfArchive } from "@pvf/pvf-core"; -import { applyPvfPipeline, buildPvfPipeline, createPvfModRegistry } from "@pvf/pvf-mod"; +import { + applyPvfPipeline, + buildPvfPipeline, + createPvfModRegistry, + runPvfMods, + updateListedPathDocument, +} from "@pvf/pvf-mod"; +import type { PvfMod } from "@pvf/pvf-mod"; -import { CHORO_PARTSET_SKILL_UP_MOD_ID, choroPartsetSkillUpModDefinition } from "./index.ts"; +import { AI_CHARACTER_LIST_PATH } from "./constants.ts"; +import { + CHORO_PARTSET_SKILL_UP_MOD_ID, + choroPartsetSkillUpModDefinition, + createChoroPartsetSkillUpMod, +} from "./index.ts"; import type { ChoroPartsetSkillUpModSummary } from "./index.ts"; +import { + SOLDOROS_DOLL_MOD_ID, + buildSupportSummonDollDocument, + soldorosDollModDefinition, +} from "../../soldoros_doll/src/index.ts"; +import type { SoldorosDollModSummary } from "../../soldoros_doll/src/index.ts"; const FIXTURE_ARCHIVE_PATH = new URL("../../../fixtures/Script.pvf", import.meta.url).pathname; +const SOURCE_SUMMON_APC_PATH = "aicharacter/_jojochan/swordman/soldoros/soldoros.aic"; const TARGET_SWORDMAN_SUPPORT_PATH = "equipment/character/common/support/support_3choro65.equ"; const TARGET_SWORDMAN_OUTPUT_PATH = "equipment/character/common/support/support_440453.equ"; const TARGET_EXORCIST_SUPPORT_PATH = "equipment/character/common/support/support_3choro83.equ"; @@ -19,26 +38,38 @@ const TARGET_EXORCIST_OUTPUT_PATH = "equipment/character/common/support/support_ const TARGET_AVENGER_SUPPORT_PATH = "equipment/character/common/support/support_3choro84.equ"; const TARGET_AVENGER_OUTPUT_PATH = "equipment/character/common/support/support_440472.equ"; const EQUIPMENT_LIST_PATH = "equipment/equipment.lst"; -const AI_CHARACTER_LIST_PATH = "aicharacter/aicharacter.lst"; const TARGET_SUMMON_APC_PATH = "aicharacter/_jojochan/swordman/soldoros/soldoros_doll.aic"; const TARGET_SUMMON_APC_ID = 1520; +const CUSTOM_SUMMON_APC_PATH = "aicharacter/_jojochan/swordman/soldoros/soldoros_custom_doll.aic"; +const CUSTOM_SUMMON_APC_ID = 990_001; const EXPECTED_FILE_COUNT = 24; const EXPECTED_OVERLAY_COUNT = EXPECTED_FILE_COUNT + 3; async function buildChoroPipeline() { const result = await buildPvfPipeline({ archivePath: FIXTURE_ARCHIVE_PATH, - registry: createPvfModRegistry([choroPartsetSkillUpModDefinition]), + registry: createPvfModRegistry([ + soldorosDollModDefinition, + choroPartsetSkillUpModDefinition, + ]), pipeline: { id: "choro-only", mods: [ + { + id: SOLDOROS_DOLL_MOD_ID, + }, { id: CHORO_PARTSET_SKILL_UP_MOD_ID, }, ], }, }); - const summary = result.mods[0]?.result as ChoroPartsetSkillUpModSummary | undefined; + const soldorosSummary = result.mods[0]?.result as SoldorosDollModSummary | undefined; + const summary = result.mods[1]?.result as ChoroPartsetSkillUpModSummary | undefined; + + if (!soldorosSummary) { + throw new Error("Missing Soldoros prerequisite summary."); + } if (!summary) { throw new Error("Missing Choro pipeline summary."); @@ -46,13 +77,95 @@ async function buildChoroPipeline() { return { result, + soldorosSummary, summary, }; } +test("choro support mod omits summon command when the doll prerequisite is missing", async () => { + const result = await buildPvfPipeline({ + archivePath: FIXTURE_ARCHIVE_PATH, + registry: createPvfModRegistry([choroPartsetSkillUpModDefinition]), + pipeline: { + id: "choro-without-prerequisite", + mods: [ + { + id: CHORO_PARTSET_SKILL_UP_MOD_ID, + }, + ], + }, + }); + const summary = result.mods[0]?.result as ChoroPartsetSkillUpModSummary | undefined; + + if (!summary) { + throw new Error("Missing Choro summary without prerequisite."); + } + + const swordmanOverlay = result.overlays.find( + (overlay) => overlay.path === TARGET_SWORDMAN_OUTPUT_PATH, + ); + + assert.equal(summary.files.length, EXPECTED_FILE_COUNT); + assert.equal(summary.skipped.length, 0); + assert.ok(swordmanOverlay); + assert.ok(result.overlays.some((overlay) => overlay.path === EQUIPMENT_LIST_PATH)); + assert.ok(!result.overlays.some((overlay) => overlay.path === AI_CHARACTER_LIST_PATH)); + assert.ok(!result.overlays.some((overlay) => overlay.path === TARGET_SUMMON_APC_PATH)); + assert.match(String(swordmanOverlay.content), /\[explain\]/u); + assert.match(String(swordmanOverlay.content), /获得以下套装的套装效果:/u); + assert.doesNotMatch( + String(swordmanOverlay.content), + /剑圣索德罗斯协助自身战斗,剑圣索德罗斯存在15分钟。/u, + ); + assert.doesNotMatch(String(swordmanOverlay.content), /\[command\]/u); + assert.doesNotMatch(String(swordmanOverlay.content), /\[summon apc\]/u); +}); + +test("choro support mod discovers the summon APC id from previous mod overlays", async () => { + const prerequisiteMod: PvfMod = { + id: "inject-custom-soldoros-doll", + async apply(session) { + const aiCharacterListDocument = await session.readScriptDocument(AI_CHARACTER_LIST_PATH); + const sourceSummonDocument = await session.readScriptDocument(SOURCE_SUMMON_APC_PATH); + + session.writeScriptDocument( + CUSTOM_SUMMON_APC_PATH, + buildSupportSummonDollDocument(sourceSummonDocument), + ); + session.writeScriptDocument( + AI_CHARACTER_LIST_PATH, + updateListedPathDocument( + aiCharacterListDocument, + "aicharacter", + [ + { + id: CUSTOM_SUMMON_APC_ID, + path: CUSTOM_SUMMON_APC_PATH, + }, + ], + ), + ); + }, + }; + const result = await runPvfMods({ + archivePath: FIXTURE_ARCHIVE_PATH, + mods: [prerequisiteMod, createChoroPartsetSkillUpMod()], + }); + const swordmanOverlay = result.overlays.find( + (overlay) => overlay.path === TARGET_SWORDMAN_OUTPUT_PATH, + ); + + assert.ok(swordmanOverlay); + assert.match( + String(swordmanOverlay.content), + new RegExp(`\\[summon apc\\]\\r?\\n${CUSTOM_SUMMON_APC_ID}\\t99\\t1`, "u"), + ); +}); + test("choro mod builds generated support overlays through the pipeline", async () => { - const { result, summary } = await buildChoroPipeline(); + const { result, soldorosSummary, summary } = await buildChoroPipeline(); + assert.equal(soldorosSummary.dollAicId, TARGET_SUMMON_APC_ID); assert.equal(summary.files.length, EXPECTED_FILE_COUNT); assert.equal(result.overlays.length, EXPECTED_OVERLAY_COUNT); assert.equal(summary.skipped.length, 0); @@ -104,7 +217,7 @@ test("choro mod builds generated support overlays through the pipeline", async ( ); assert.ok(summonApcOverlay); - assert.match(String(summonApcOverlay.content), /剑圣索德罗斯/u); + assert.match(String(summonApcOverlay.content), /\[minimum info\][\s\S]*索德罗斯/u); assert.match(String(summonApcOverlay.content), /\[attack damage rate\]\r?\n1\.0/u); const swordmanDocument = parseEquDocument(String(swordmanOverlay.content)); @@ -128,10 +241,16 @@ test("choro mod applies cleanly through the pipeline", async () => { const result = await applyPvfPipeline({ archivePath: FIXTURE_ARCHIVE_PATH, outputPath, - registry: createPvfModRegistry([choroPartsetSkillUpModDefinition]), + registry: createPvfModRegistry([ + soldorosDollModDefinition, + choroPartsetSkillUpModDefinition, + ]), pipeline: { id: "choro-only", mods: [ + { + id: SOLDOROS_DOLL_MOD_ID, + }, { id: CHORO_PARTSET_SKILL_UP_MOD_ID, }, @@ -179,7 +298,7 @@ test("choro mod applies cleanly through the pipeline", async () => { content, new RegExp(`\\[summon apc\\]\\r?\\n${TARGET_SUMMON_APC_ID}\\t99\\t1`, "u"), ); - assert.match(summonApcText, /剑圣索德罗斯/u); + assert.match(summonApcText, /\[minimum info\][\s\S]*索德罗斯/u); assert.match(summonApcText, /\[attack damage rate\]\r?\n1\.0/u); assert.match( aiCharacterListText, diff --git a/mods/2_3_choro_partset_skill_up/src/index.ts b/mods/2_3_choro_partset_skill_up/src/index.ts index 8fa4f84..e942222 100644 --- a/mods/2_3_choro_partset_skill_up/src/index.ts +++ b/mods/2_3_choro_partset_skill_up/src/index.ts @@ -9,7 +9,6 @@ import type { } from "./types.ts"; export const CHORO_PARTSET_SKILL_UP_MOD_ID = "2_3_choro_partset_skill_up"; - export const choroPartsetSkillUpModDefinition: PvfRegisteredMod< undefined, ChoroPartsetSkillUpModSummary diff --git a/mods/2_3_choro_partset_skill_up/src/mod.ts b/mods/2_3_choro_partset_skill_up/src/mod.ts index aefd431..b20c7cf 100644 --- a/mods/2_3_choro_partset_skill_up/src/mod.ts +++ b/mods/2_3_choro_partset_skill_up/src/mod.ts @@ -1,35 +1,30 @@ import { compareArchivePaths, getFirstSection, - loadListedPathById, replaceTopLevelSection, updateListedPathDocument, } from "@pvf/pvf-mod"; import type { PvfMod, PvfModSession } from "@pvf/pvf-mod"; import { - AI_CHARACTER_LIST_PATH, EQUIPMENT_LIST_PATH, GENERATED_SUPPORT_ID_START, - SUPPORT_SUMMON_SOURCE_NAME, + SUPPORT_SUMMON_DOLL_NAME, SUPPORT_TEMPLATE_PATH, } from "./constants.ts"; import { - findAiCharacterByName, - findNextAvailableListedPathId, loadClassPartsets, loadEquipmentPathById, loadPartsetNameByPath, loadPartsetPathByIndex, loadSkillEntryBlocksByPartset, loadSupportPathsByClass, + tryFindAiCharacterByName, } from "./data.ts"; import { buildExplainText, buildGeneratedSupportName, buildGeneratedSupportPath, - buildSupportSummonDollPath, - buildSupportSummonOverlayDocument, buildSupportSummonSections, createSingleStringSection, dedupeSkillEntryBlocks, @@ -73,52 +68,20 @@ export function createChoroPartsetSkillUpMod(): PvfMod compareArchivePaths(left[1]?.[0] ?? "", right[1]?.[0] ?? "") @@ -152,7 +115,9 @@ export function createChoroPartsetSkillUpMod(): PvfMod, + options: { + includeSupportSummonExplain?: boolean; + } = {}, ): string { const seen = new Set(); - const lines = [SUPPORT_SUMMON_EXPLAIN, EXPLAIN_HEADING]; + const lines = options.includeSupportSummonExplain === false + ? [EXPLAIN_HEADING] + : [SUPPORT_SUMMON_EXPLAIN, EXPLAIN_HEADING]; for (const partsetPath of sourcePartsets) { const partsetName = partsetNameByPath.get(partsetPath)?.trim(); @@ -168,26 +171,6 @@ export function buildGeneratedSupportPath(equipmentId: number): string { return `equipment/character/common/support/support_${equipmentId}.equ`; } -export function buildSupportSummonDollPath(archivePath: string): string { - if (!archivePath.endsWith(".aic")) { - throw new Error(`Expected .aic path, received ${archivePath}.`); - } - - return archivePath.replace(/\.aic$/u, "_doll.aic"); -} - -export function buildSupportSummonOverlayDocument( - sourceDocument: EquDocument, -): EquDocument { - return replaceTopLevelSection( - sourceDocument, - createSingleFloatLiteralSection( - "attack damage rate", - SUPPORT_SUMMON_ATTACK_DAMAGE_RATE, - ), - ); -} - export function sortBySupportPath( files: readonly T[], ): T[] { diff --git a/mods/pipelines.ts b/mods/pipelines.ts index fd137d6..a828a62 100644 --- a/mods/pipelines.ts +++ b/mods/pipelines.ts @@ -2,6 +2,7 @@ import type { PvfPipelineConfig } from "@pvf/pvf-mod"; import { CHORO_PARTSET_SKILL_UP_MOD_ID } from "./2_3_choro_partset_skill_up/src/index.ts"; import { EXAMPLE_WILD_STRAWBERRY_HP_UP_MOD_ID } from "./example_wild_strawberry_hp_up/src/index.ts"; +import { SOLDOROS_DOLL_MOD_ID } from "./soldoros_doll/src/index.ts"; export const pipelineDefinitions = [ { @@ -21,6 +22,9 @@ export const pipelineDefinitions = [ { id: EXAMPLE_WILD_STRAWBERRY_HP_UP_MOD_ID, }, + { + id: SOLDOROS_DOLL_MOD_ID, + }, { id: CHORO_PARTSET_SKILL_UP_MOD_ID, }, diff --git a/mods/registry.ts b/mods/registry.ts index 916d1f9..e766c26 100644 --- a/mods/registry.ts +++ b/mods/registry.ts @@ -2,9 +2,11 @@ import { createPvfModRegistry } from "@pvf/pvf-mod"; import { choroPartsetSkillUpModDefinition } from "./2_3_choro_partset_skill_up/src/index.ts"; import { exampleWildStrawberryHpUpModDefinition } from "./example_wild_strawberry_hp_up/src/index.ts"; +import { soldorosDollModDefinition } from "./soldoros_doll/src/index.ts"; export const modDefinitions = [ exampleWildStrawberryHpUpModDefinition, + soldorosDollModDefinition, choroPartsetSkillUpModDefinition, ] as const; diff --git a/mods/soldoros_doll/package.json b/mods/soldoros_doll/package.json new file mode 100644 index 0000000..b66fcfb --- /dev/null +++ b/mods/soldoros_doll/package.json @@ -0,0 +1,23 @@ +{ + "name": "@pvf-mods/soldoros_doll", + "private": true, + "type": "module", + "scripts": { + "check": "tsc --noEmit -p tsconfig.json", + "test": "node --test --test-concurrency=1 src/**/*.test.ts" + }, + "dependencies": { + "@pvf/equ-ast": "workspace:*", + "@pvf/pvf-core": "workspace:*", + "@pvf/pvf-mod": "workspace:*" + }, + "devDependencies": { + "@tsconfig/node24": "^24.0.0", + "@tsconfig/strictest": "^2.0.5", + "@types/node": "^24.0.0", + "typescript": "^6.0.2" + }, + "engines": { + "node": ">=24" + } +} diff --git a/mods/soldoros_doll/src/index.test.ts b/mods/soldoros_doll/src/index.test.ts new file mode 100644 index 0000000..f758e0f --- /dev/null +++ b/mods/soldoros_doll/src/index.test.ts @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { PvfArchive } from "@pvf/pvf-core"; +import { applyPvfPipeline, buildPvfPipeline, createPvfModRegistry } from "@pvf/pvf-mod"; + +import { + AI_CHARACTER_LIST_PATH, + SOLDOROS_DOLL_MOD_ID, + soldorosDollModDefinition, +} from "./index.ts"; +import type { SoldorosDollModSummary } from "./index.ts"; + +const FIXTURE_ARCHIVE_PATH = new URL("../../../fixtures/Script.pvf", import.meta.url).pathname; +const SOURCE_SUMMON_APC_PATH = "aicharacter/_jojochan/swordman/soldoros/soldoros.aic"; +const TARGET_SUMMON_APC_PATH = "aicharacter/_jojochan/swordman/soldoros/soldoros_doll.aic"; +const TARGET_SUMMON_APC_ID = 1520; + +async function buildSoldorosPipeline() { + const result = await buildPvfPipeline({ + archivePath: FIXTURE_ARCHIVE_PATH, + registry: createPvfModRegistry([soldorosDollModDefinition]), + pipeline: { + id: "soldoros-only", + mods: [ + { + id: SOLDOROS_DOLL_MOD_ID, + }, + ], + }, + }); + const summary = result.mods[0]?.result as SoldorosDollModSummary | undefined; + + if (!summary) { + throw new Error("Missing Soldoros doll pipeline summary."); + } + + return { + result, + summary, + }; +} + +test("soldoros mod creates a doll APC overlay and list entry", async () => { + const { result, summary } = await buildSoldorosPipeline(); + const summonApcOverlay = result.overlays.find( + (overlay) => overlay.path === TARGET_SUMMON_APC_PATH, + ); + const aiCharacterListOverlay = result.overlays.find( + (overlay) => overlay.path === AI_CHARACTER_LIST_PATH, + ); + + assert.equal(summary.sourceAicId, 1516); + assert.equal(summary.sourcePath, SOURCE_SUMMON_APC_PATH); + assert.equal(summary.dollAicId, TARGET_SUMMON_APC_ID); + assert.equal(summary.dollPath, TARGET_SUMMON_APC_PATH); + assert.equal(summary.created, true); + assert.equal(result.overlays.length, 2); + assert.ok(summonApcOverlay); + assert.ok(aiCharacterListOverlay); + assert.match(String(summonApcOverlay.content), /\[minimum info\][\s\S]*索德罗斯/u); + assert.match(String(summonApcOverlay.content), /\[attack damage rate\]\r?\n1\.0/u); + assert.match( + String(aiCharacterListOverlay.content), + /1520\t`_jojochan\/swordman\/soldoros\/soldoros_doll\.aic`/u, + ); +}); + +test("soldoros mod applies cleanly through the pipeline", async () => { + const workDir = await mkdtemp(join(tmpdir(), "pvf-soldoros-pipeline-")); + const outputPath = join(workDir, "Script.mod.pvf"); + + try { + const result = await applyPvfPipeline({ + archivePath: FIXTURE_ARCHIVE_PATH, + outputPath, + registry: createPvfModRegistry([soldorosDollModDefinition]), + pipeline: { + id: "soldoros-only", + mods: [ + { + id: SOLDOROS_DOLL_MOD_ID, + }, + ], + }, + }); + + assert.ok(result.updatedPaths.includes(AI_CHARACTER_LIST_PATH)); + assert.ok(result.addedPaths.includes(TARGET_SUMMON_APC_PATH)); + + const archive = new PvfArchive("Script.mod.pvf", outputPath); + + try { + await archive.ensureLoaded(); + const summonApcText = await archive.readRenderedFile( + TARGET_SUMMON_APC_PATH, + "simplified", + ); + const aiCharacterListText = await archive.readRenderedFile( + AI_CHARACTER_LIST_PATH, + "simplified", + ); + + assert.match(summonApcText, /\[minimum info\][\s\S]*索德罗斯/u); + assert.match(summonApcText, /\[attack damage rate\]\r?\n1\.0/u); + assert.match( + aiCharacterListText, + /1520\t`_jojochan\/swordman\/soldoros\/soldoros_doll\.aic`/u, + ); + } finally { + await archive.close(); + } + } finally { + await rm(workDir, { recursive: true, force: true }); + } +}); diff --git a/mods/soldoros_doll/src/index.ts b/mods/soldoros_doll/src/index.ts new file mode 100644 index 0000000..fa7e39c --- /dev/null +++ b/mods/soldoros_doll/src/index.ts @@ -0,0 +1,219 @@ +import type { EquDocument, EquSectionNode, EquStatementNode } from "@pvf/equ-ast"; +import { + createSingleFloatLiteralSection, + findNextAvailableListedPathId, + getFirstSection, + getFirstSectionString, + isStatement, + loadListedPathById, + readEquDocument, + replaceTopLevelSection, + updateListedPathDocument, +} from "@pvf/pvf-mod"; +import type { ListedPathEntry, PvfMod, PvfModSession, PvfRegisteredMod } from "@pvf/pvf-mod"; + +export const SOLDOROS_DOLL_MOD_ID = "soldoros_doll"; +export const AI_CHARACTER_LIST_PATH = "aicharacter/aicharacter.lst"; +export const SUPPORT_SUMMON_SOURCE_NAME = "\u5251\u5723\u7d22\u5fb7\u7f57\u65af"; +export const SUPPORT_SUMMON_DOLL_NAME = "\u7d22\u5fb7\u7f57\u65af"; +export const SUPPORT_SUMMON_ATTACK_DAMAGE_RATE = "1.0"; + +export interface SoldorosDollModSummary { + sourceAicId: number; + sourcePath: string; + dollAicId: number; + dollPath: string; + created: boolean; +} + +export function buildSupportSummonDollPath(archivePath: string): string { + if (!archivePath.endsWith(".aic")) { + throw new Error(`Expected .aic path, received ${archivePath}.`); + } + + return archivePath.replace(/\.aic$/u, "_doll.aic"); +} + +function replaceMinimumInfoName( + document: EquDocument, + nextName: string, +): EquDocument { + const minimumInfoSection = getFirstSection(document.children, "minimum info"); + + if (!minimumInfoSection) { + throw new Error("Missing [minimum info] section in summon APC source document."); + } + + const statementIndex = minimumInfoSection.children.findIndex((child) => isStatement(child)); + const statement = minimumInfoSection.children[statementIndex]; + + if (!statement || !isStatement(statement)) { + throw new Error("Missing [minimum info] statement in summon APC source document."); + } + + const nameTokenIndex = statement.tokens.findIndex( + (token) => token.kind === "string" || token.kind === "link", + ); + + if (nameTokenIndex === -1) { + throw new Error("Missing summon APC name token in [minimum info]."); + } + + const nextStatement: EquStatementNode = { + ...statement, + tokens: statement.tokens.map((token, index) => + index === nameTokenIndex && (token.kind === "string" || token.kind === "link") + ? { ...token, value: nextName } + : token + ), + }; + const nextMinimumInfoSection: EquSectionNode = { + ...minimumInfoSection, + children: minimumInfoSection.children.map((child, index) => + index === statementIndex ? nextStatement : child + ), + }; + + return replaceTopLevelSection(document, nextMinimumInfoSection); +} + +export function buildSupportSummonDollDocument( + sourceDocument: EquDocument, +): EquDocument { + return replaceTopLevelSection( + replaceMinimumInfoName(sourceDocument, SUPPORT_SUMMON_DOLL_NAME), + createSingleFloatLiteralSection( + "attack damage rate", + SUPPORT_SUMMON_ATTACK_DAMAGE_RATE, + ), + ); +} + +export async function tryFindAiCharacterByName( + session: PvfModSession, + targetName: string, + options: { + pathIncludes?: string; + } = {}, +): Promise { + const pathById = await loadListedPathById( + session, + AI_CHARACTER_LIST_PATH, + "aicharacter", + session.textProfile, + ); + + for ( + const [aicId, aicPath] of [...pathById.entries()].sort( + (left, right) => left[0] - right[0], + ) + ) { + if (options.pathIncludes && !aicPath.includes(options.pathIncludes)) { + continue; + } + + const document = await readEquDocument(session, aicPath, session.textProfile); + const minimumInfoName = getFirstSectionString(document.children, "minimum info")?.trim(); + + if (minimumInfoName === targetName) { + return { + id: aicId, + path: aicPath, + }; + } + } + + return undefined; +} + +async function findAiCharacterByName( + session: PvfModSession, + targetName: string, +): Promise { + const found = await tryFindAiCharacterByName(session, targetName); + + if (found) { + return found; + } + + throw new Error(`Unable to find APC id for ${targetName}.`); +} + +export function createSoldorosDollMod(): PvfMod { + return { + id: SOLDOROS_DOLL_MOD_ID, + async apply(session: PvfModSession): Promise { + const sourceApc = await findAiCharacterByName(session, SUPPORT_SUMMON_SOURCE_NAME); + const existingDoll = await tryFindAiCharacterByName(session, SUPPORT_SUMMON_DOLL_NAME, { + pathIncludes: "_doll", + }); + + if (existingDoll) { + const existingDollDocument = await session.readScriptDocument(existingDoll.path); + + session.writeScriptDocument( + existingDoll.path, + buildSupportSummonDollDocument(existingDollDocument), + ); + + return { + sourceAicId: sourceApc.id, + sourcePath: sourceApc.path, + dollAicId: existingDoll.id, + dollPath: existingDoll.path, + created: false, + }; + } + + const aiCharacterListPathById = await loadListedPathById( + session, + AI_CHARACTER_LIST_PATH, + "aicharacter", + session.textProfile, + ); + const aiCharacterListDocument = await session.readScriptDocument( + AI_CHARACTER_LIST_PATH, + ); + const sourceApcDocument = await session.readScriptDocument(sourceApc.path); + const dollAicId = findNextAvailableListedPathId(aiCharacterListPathById, sourceApc.id); + const dollPath = buildSupportSummonDollPath(sourceApc.path); + + session.writeScriptDocument( + dollPath, + buildSupportSummonDollDocument(sourceApcDocument), + ); + session.writeScriptDocument( + AI_CHARACTER_LIST_PATH, + updateListedPathDocument( + aiCharacterListDocument, + "aicharacter", + [ + { + id: dollAicId, + path: dollPath, + }, + ], + ), + ); + + return { + sourceAicId: sourceApc.id, + sourcePath: sourceApc.path, + dollAicId, + dollPath, + created: true, + }; + }, + }; +} + +export const soldorosDollModDefinition: PvfRegisteredMod< + undefined, + SoldorosDollModSummary +> = { + id: SOLDOROS_DOLL_MOD_ID, + description: "Ensures a Soldoros doll APC entry exists before support equipment generation.", + create() { + return createSoldorosDollMod(); + }, +}; diff --git a/mods/soldoros_doll/tsconfig.json b/mods/soldoros_doll/tsconfig.json new file mode 100644 index 0000000..430cd5f --- /dev/null +++ b/mods/soldoros_doll/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": ["@tsconfig/strictest/tsconfig.json", "@tsconfig/node24/tsconfig.json"], + "compilerOptions": { + "allowImportingTsExtensions": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 252cca0..ab69e56 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,6 +164,31 @@ importers: specifier: ^6.0.2 version: 6.0.2 + mods/soldoros_doll: + dependencies: + '@pvf/equ-ast': + specifier: workspace:* + version: link:../../packages/equ-ast + '@pvf/pvf-core': + specifier: workspace:* + version: link:../../packages/pvf-core + '@pvf/pvf-mod': + specifier: workspace:* + version: link:../../packages/pvf-mod + devDependencies: + '@tsconfig/node24': + specifier: ^24.0.0 + version: 24.0.4 + '@tsconfig/strictest': + specifier: ^2.0.5 + version: 2.0.8 + '@types/node': + specifier: ^24.0.0 + version: 24.12.2 + typescript: + specifier: ^6.0.2 + version: 6.0.2 + packages/equ-ast: devDependencies: '@tsconfig/node24':