feat: add second-victory relief exploration
This commit is contained in:
60
scripts/build-exploration-background.py
Normal file
60
scripts/build-exploration-background.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Build an opaque, desktop-sized WebP exploration background."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--input", required=True, type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
parser.add_argument("--width", type=int, default=1920)
|
||||
parser.add_argument("--height", type=int, default=1080)
|
||||
parser.add_argument("--quality", type=int, default=88)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
if args.width <= 0 or args.height <= 0:
|
||||
raise ValueError("Output dimensions must be positive.")
|
||||
if not 1 <= args.quality <= 100:
|
||||
raise ValueError("WebP quality must be between 1 and 100.")
|
||||
|
||||
output_path = args.output.resolve()
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with Image.open(args.input) as source:
|
||||
rgb_source = ImageOps.exif_transpose(source).convert("RGB")
|
||||
resized = ImageOps.fit(
|
||||
rgb_source,
|
||||
(args.width, args.height),
|
||||
method=Image.Resampling.LANCZOS,
|
||||
centering=(0.5, 0.5),
|
||||
)
|
||||
resized.save(
|
||||
output_path,
|
||||
format="WEBP",
|
||||
quality=args.quality,
|
||||
method=6,
|
||||
exact=False,
|
||||
)
|
||||
|
||||
with Image.open(output_path) as result:
|
||||
if result.size != (args.width, args.height) or result.mode != "RGB":
|
||||
raise RuntimeError(
|
||||
f"Unexpected output: {result.size[0]}x{result.size[1]} {result.mode}"
|
||||
)
|
||||
|
||||
print(
|
||||
f"Built {output_path} ({args.width}x{args.height}, "
|
||||
f"{output_path.stat().st_size} bytes)."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
101
scripts/build-riverside-day-ambience.py
Normal file
101
scripts/build-riverside-day-ambience.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Prepare the licensed daytime riverside ambience used by exploration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
|
||||
SOURCE_URL = (
|
||||
"https://pixabay.com/sound-effects/"
|
||||
"nature-stream-water-with-birds-459511/"
|
||||
)
|
||||
TARGET_DURATION_SECONDS = 32
|
||||
SOURCE_OFFSET_SECONDS = 7
|
||||
LOOP_CROSSFADE_SECONDS = 2
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--input", required=True, type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
source_path = args.input.resolve()
|
||||
output_path = args.output.resolve()
|
||||
if not source_path.is_file():
|
||||
raise FileNotFoundError(source_path)
|
||||
|
||||
ffmpeg = os.environ.get("FFMPEG_PATH") or shutil.which("ffmpeg")
|
||||
if not ffmpeg:
|
||||
raise RuntimeError("ffmpeg is required to build the ambience track.")
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
loop_end = SOURCE_OFFSET_SECONDS + TARGET_DURATION_SECONDS
|
||||
head_end = SOURCE_OFFSET_SECONDS + LOOP_CROSSFADE_SECONDS
|
||||
body_start = head_end
|
||||
tail_start = loop_end
|
||||
tail_end = loop_end + LOOP_CROSSFADE_SECONDS
|
||||
filter_graph = (
|
||||
"[0:a]asplit=3[tailSource][headSource][bodySource];"
|
||||
f"[tailSource]atrim=start={tail_start}:end={tail_end},"
|
||||
f"asetpts=PTS-STARTPTS,afade=t=out:st=0:"
|
||||
f"d={LOOP_CROSSFADE_SECONDS}[tail];"
|
||||
f"[headSource]atrim=start={SOURCE_OFFSET_SECONDS}:end={head_end},"
|
||||
f"asetpts=PTS-STARTPTS,afade=t=in:st=0:"
|
||||
f"d={LOOP_CROSSFADE_SECONDS}[head];"
|
||||
"[tail][head]amix=inputs=2:normalize=0:duration=first[seam];"
|
||||
f"[bodySource]atrim=start={body_start}:end={loop_end},"
|
||||
"asetpts=PTS-STARTPTS[body];"
|
||||
"[seam][body]concat=n=2:v=0:a=1,"
|
||||
"loudnorm=I=-24:TP=-3:LRA=7[out]"
|
||||
)
|
||||
command = [
|
||||
ffmpeg,
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-i",
|
||||
str(source_path),
|
||||
"-filter_complex",
|
||||
filter_graph,
|
||||
"-map",
|
||||
"[out]",
|
||||
"-ar",
|
||||
"44100",
|
||||
"-ac",
|
||||
"2",
|
||||
"-codec:a",
|
||||
"libmp3lame",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-metadata",
|
||||
"title=Riverside Village Day Ambience",
|
||||
"-metadata",
|
||||
"artist=loswin23",
|
||||
"-metadata",
|
||||
(
|
||||
"comment=Derived from Stream Water with Birds; "
|
||||
f"Pixabay Content License; {SOURCE_URL}"
|
||||
),
|
||||
str(output_path),
|
||||
]
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
if output_path.stat().st_size <= 0:
|
||||
raise RuntimeError(f"Empty ambience output: {output_path}")
|
||||
print(
|
||||
f"Built {output_path} ({TARGET_DURATION_SECONDS}s target, "
|
||||
f"{output_path.stat().st_size} bytes)."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -8,11 +8,15 @@ const sourceFilesToScan = [
|
||||
'src/game/audio/SoundDirector.ts',
|
||||
'src/game/scenes/BattleScene.ts',
|
||||
'src/game/scenes/CampScene.ts',
|
||||
'src/game/scenes/CampVisitExplorationScene.ts',
|
||||
'src/game/scenes/EndingScene.ts',
|
||||
'src/game/scenes/StoryScene.ts',
|
||||
'src/game/scenes/TitleScene.ts'
|
||||
];
|
||||
const requiredBattlefieldAmbienceKeys = ['battle-rain', 'battle-fire'];
|
||||
const requiredDaytimeExplorationAmbienceKeys = [
|
||||
'riverside-village-day-ambience'
|
||||
];
|
||||
const requiredTacticalCueKeys = [
|
||||
'ally-turn',
|
||||
'enemy-turn',
|
||||
@@ -68,6 +72,14 @@ const battlefieldSourceRecords = [
|
||||
source: 'https://pixabay.com/sound-effects/film-special-effects-failure-1-89170/'
|
||||
}
|
||||
];
|
||||
const riversideDaySourceRecord = {
|
||||
trackKey: 'riverside-village-day-ambience',
|
||||
projectFile:
|
||||
'src/assets/audio/ambience/riverside-village-day-ambience.mp3',
|
||||
source:
|
||||
'https://pixabay.com/sound-effects/nature-stream-water-with-birds-459511/',
|
||||
creator: 'loswin23'
|
||||
};
|
||||
const narrativeSourceRecord = {
|
||||
projectFile: 'src/assets/audio/sfx/story-page-turn.mp3',
|
||||
source: 'https://pixabay.com/sound-effects/film-special-effects-flipping-book-page-499646/',
|
||||
@@ -130,10 +142,17 @@ try {
|
||||
validateTrackMap(errors, 'ambience', ambienceTracks, 'ambience');
|
||||
validateTrackMap(errors, 'effect', effectTracks, 'sfx');
|
||||
validateRequiredTrackKeys(errors, 'battlefield ambience', ambienceTracks, requiredBattlefieldAmbienceKeys);
|
||||
validateRequiredTrackKeys(
|
||||
errors,
|
||||
'daytime exploration ambience',
|
||||
ambienceTracks,
|
||||
requiredDaytimeExplorationAmbienceKeys
|
||||
);
|
||||
validateRequiredTrackKeys(errors, 'tactical cue', effectTracks, requiredTacticalCueKeys);
|
||||
validateRequiredTrackKeys(errors, 'narrative cue', effectTracks, requiredNarrativeCueKeys);
|
||||
validateRequiredTrackKeys(errors, 'semantic feedback cue', effectTracks, requiredSemanticFeedbackCueKeys);
|
||||
validateBattlefieldSourceDocumentation(errors);
|
||||
validateRiversideDaySource(errors, ambienceTracks);
|
||||
validateNarrativeSourceDocumentation(errors);
|
||||
validateSemanticFeedbackSourceDocumentation(errors);
|
||||
validateMovementSourceDocumentation(errors);
|
||||
@@ -465,6 +484,70 @@ function linearGainDb(gain) {
|
||||
return 20 * Math.log10(Math.max(0.0001, gain));
|
||||
}
|
||||
|
||||
function validateRiversideDaySource(errors, ambienceTracks) {
|
||||
const docs = readFileSync(join('docs', 'audio-sources.md'), 'utf8');
|
||||
const requiredDetails = [
|
||||
'2026-07-27',
|
||||
riversideDaySourceRecord.projectFile,
|
||||
riversideDaySourceRecord.source,
|
||||
'Stream Water with Birds',
|
||||
riversideDaySourceRecord.creator,
|
||||
'32-second',
|
||||
'44.1 kHz stereo MP3',
|
||||
'two-second end-to-start',
|
||||
'-24.03 LUFS',
|
||||
'128 kbps'
|
||||
];
|
||||
requiredDetails.forEach((detail) => {
|
||||
if (!docs.includes(detail)) {
|
||||
errors.push(
|
||||
`docs/audio-sources.md is missing daytime riverside detail "${detail}"`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (!audioInspectionToolsAvailable(errors)) {
|
||||
return;
|
||||
}
|
||||
const url = ambienceTracks[riversideDaySourceRecord.trackKey];
|
||||
const path = url ? assetUrlToPath(url) : undefined;
|
||||
if (!path) {
|
||||
errors.push('The daytime riverside ambience must resolve to a project asset.');
|
||||
return;
|
||||
}
|
||||
const probe = probeAudio(path, errors, riversideDaySourceRecord.trackKey);
|
||||
if (!probe) {
|
||||
return;
|
||||
}
|
||||
if (probe.duration < 31.9 || probe.duration > 32.2) {
|
||||
errors.push(
|
||||
`${riversideDaySourceRecord.trackKey} must remain a 32-second loop; ` +
|
||||
`received ${probe.duration.toFixed(3)} seconds`
|
||||
);
|
||||
}
|
||||
if (probe.sampleRate !== 44_100 || probe.channels !== 2) {
|
||||
errors.push(
|
||||
`${riversideDaySourceRecord.trackKey} must be 44.1 kHz stereo; ` +
|
||||
`received ${probe.sampleRate} Hz/${probe.channels} channels`
|
||||
);
|
||||
}
|
||||
if (!probe.artist.includes(riversideDaySourceRecord.creator)) {
|
||||
errors.push(
|
||||
`${riversideDaySourceRecord.trackKey} metadata must retain creator ` +
|
||||
`"${riversideDaySourceRecord.creator}"`
|
||||
);
|
||||
}
|
||||
if (
|
||||
!probe.comment.includes('Pixabay Content License') ||
|
||||
!probe.comment.includes(riversideDaySourceRecord.source)
|
||||
) {
|
||||
errors.push(
|
||||
`${riversideDaySourceRecord.trackKey} metadata must retain its Pixabay ` +
|
||||
'license and source URL'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateBattlefieldSourceDocumentation(errors) {
|
||||
const docs = readFileSync(join('docs', 'audio-sources.md'), 'utf8');
|
||||
if (!docs.includes('2026-07-21')) {
|
||||
|
||||
@@ -16,6 +16,10 @@ try {
|
||||
const { firstPursuitScoutVisitDefinition } = await server.ssrLoadModule(
|
||||
'/src/game/data/firstPursuitScoutVisit.ts'
|
||||
);
|
||||
const { secondBattleReliefExplorationDefinition } =
|
||||
await server.ssrLoadModule(
|
||||
'/src/game/data/secondBattleReliefExploration.ts'
|
||||
);
|
||||
const scenarioData = await server.ssrLoadModule('/src/game/data/scenario.ts');
|
||||
const { isCampaignStep } = await server.ssrLoadModule('/src/game/state/campaignState.ts');
|
||||
const itemRewardArrays = collectItemRewardArrays(source);
|
||||
@@ -26,9 +30,12 @@ try {
|
||||
const knownBondsById = collectKnownBondsById(battleScenarios, scenarioData);
|
||||
const dialogueEvents = collectCampEvents(source, 'campDialogues');
|
||||
const visitEvents = collectCampEvents(source, 'campVisits').map((event) =>
|
||||
materializeCanonicalFirstPursuitVisit(
|
||||
event,
|
||||
firstPursuitScoutVisitDefinition
|
||||
materializeCanonicalSecondBattleReliefVisit(
|
||||
materializeCanonicalFirstPursuitVisit(
|
||||
event,
|
||||
firstPursuitScoutVisitDefinition
|
||||
),
|
||||
secondBattleReliefExplorationDefinition
|
||||
)
|
||||
);
|
||||
const campaignStepReferences =
|
||||
@@ -355,6 +362,32 @@ function materializeCanonicalFirstPursuitVisit(event, definition) {
|
||||
};
|
||||
}
|
||||
|
||||
function materializeCanonicalSecondBattleReliefVisit(event, definition) {
|
||||
if (!event.body.includes('secondBattleReliefExplorationDefinition')) {
|
||||
return event;
|
||||
}
|
||||
|
||||
const quote = (value) =>
|
||||
`'${String(value).replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`;
|
||||
const choiceSource = definition.choices
|
||||
.map(
|
||||
(choice) =>
|
||||
`{ id: ${quote(choice.id)}, bondId: ${quote(choice.bondId)}, ` +
|
||||
`bondExp: ${choice.bondExp}, ` +
|
||||
`itemRewards: [${choice.itemRewards.map(quote).join(', ')}] }`
|
||||
)
|
||||
.join(', ');
|
||||
const canonicalSource =
|
||||
`{ id: ${quote(definition.id)}, ` +
|
||||
'availableAfterBattleIds: [campBattleIds.second], ' +
|
||||
"availableDuringSteps: ['second-camp'], " +
|
||||
`choices: [${choiceSource}], `;
|
||||
return {
|
||||
...event,
|
||||
body: canonicalSource + event.body.slice(1)
|
||||
};
|
||||
}
|
||||
|
||||
function validateCampEventCollection(events, collectionName, campBattleIdKeys, isCampaignStep) {
|
||||
const seenIds = new Map();
|
||||
let campaignStepReferenceCount = 0;
|
||||
@@ -433,7 +466,7 @@ function validateCampBondReferences(events, collectionName, knownBondsById, opti
|
||||
return;
|
||||
}
|
||||
bondReferenceCount += bondIdEntries.length;
|
||||
if (bondIdEntries.length > 1) {
|
||||
if (bondIdEntries.length > 1 && collectionName !== 'campVisits') {
|
||||
errors.push(`${sourcePath}:${lineNumber(event.offset)} ${context} has multiple bondId properties`);
|
||||
}
|
||||
|
||||
@@ -469,10 +502,20 @@ function validateRewardBondLinks(events, collectionName, rewardPropertyName) {
|
||||
return;
|
||||
}
|
||||
rewardReferenceCount += rewardEntries.length;
|
||||
if (collectStringProperties(event.body, 'bondId').length === 0) {
|
||||
const bondIdCount = collectStringProperties(event.body, 'bondId').length;
|
||||
if (bondIdCount === 0) {
|
||||
errors.push(
|
||||
`${sourcePath}:${lineNumber(rewardEntries[0].offset)} ${context} has ${rewardPropertyName} rewards but no bondId`
|
||||
);
|
||||
} else if (
|
||||
bondIdCount > 1 &&
|
||||
bondIdCount !== rewardEntries.length
|
||||
) {
|
||||
errors.push(
|
||||
`${sourcePath}:${lineNumber(rewardEntries[0].offset)} ${context} has ` +
|
||||
`${rewardEntries.length} ${rewardPropertyName} rewards but ` +
|
||||
`${bondIdCount} per-choice bondIds`
|
||||
);
|
||||
}
|
||||
});
|
||||
return rewardReferenceCount;
|
||||
|
||||
@@ -80,7 +80,8 @@ try {
|
||||
verifyExplorationLayout(city, xuzhouCase.cityStayId);
|
||||
await captureStableScreenshot(page, 'dist/verification-city-xuzhou-map.png');
|
||||
|
||||
await verifyKeyboardMovement(page);
|
||||
const movementProbe = await verifyKeyboardMovement(page);
|
||||
await verifyPointerMovement(page, movementProbe);
|
||||
await verifyDistantInteractionIsBlocked(page);
|
||||
|
||||
const informationResult = await verifyInformationReward(
|
||||
@@ -126,7 +127,7 @@ try {
|
||||
|
||||
console.log(
|
||||
`Verified direct inter-battle city exploration at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` +
|
||||
`DPR ${desktopBrowserDeviceScaleFactor}: Camp gateway entry, real keyboard movement, distance-gated interaction, ` +
|
||||
`DPR ${desktopBrowserDeviceScaleFactor}: Camp gateway entry, real keyboard and world-pointer movement, distance-gated interaction, ` +
|
||||
'one-time information rewards, exact market purchases, saved resonance choices, reload/Continue restoration, ' +
|
||||
'optional-objective exit, next-battle briefing integration, and Xuzhou/Xinye/Chengdu map and modal layouts.'
|
||||
);
|
||||
@@ -442,6 +443,42 @@ async function verifyKeyboardMovement(page) {
|
||||
})}`
|
||||
);
|
||||
assert.equal(after.player.moving, false);
|
||||
return {
|
||||
start: { x: before.player.x, y: before.player.y },
|
||||
end: { x: after.player.x, y: after.player.y }
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyPointerMovement(page, movementProbe) {
|
||||
await clickScenePoint(page, 'CityStayScene', movementProbe.start);
|
||||
await waitForPlayerAt(page, movementProbe.start);
|
||||
const returned = await readCity(page);
|
||||
assert(
|
||||
returned.player.x - movementProbe.end.x >= 85,
|
||||
`Expected world-pointer movement to return Liu Bei right: ${JSON.stringify({
|
||||
movementProbe,
|
||||
returned: returned.player
|
||||
})}`
|
||||
);
|
||||
assert(
|
||||
Math.abs(returned.player.y - movementProbe.start.y) <= 3,
|
||||
`Expected world-pointer movement to retain Y: ${JSON.stringify({
|
||||
movementProbe,
|
||||
returned: returned.player
|
||||
})}`
|
||||
);
|
||||
|
||||
await clickScenePoint(page, 'CityStayScene', movementProbe.end);
|
||||
await waitForPlayerAt(page, movementProbe.end);
|
||||
const restored = await readCity(page);
|
||||
assert(
|
||||
Math.abs(restored.player.x - movementProbe.end.x) <= 9 &&
|
||||
Math.abs(restored.player.y - movementProbe.end.y) <= 9,
|
||||
`Expected the pointer probe to restore the distant interaction position: ${JSON.stringify({
|
||||
expected: movementProbe.end,
|
||||
restored: restored.player
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
async function verifyDistantInteractionIsBlocked(page) {
|
||||
@@ -1028,6 +1065,40 @@ async function clickSceneBounds(page, sceneKey, bounds) {
|
||||
await page.mouse.click(point.x, point.y);
|
||||
}
|
||||
|
||||
async function clickScenePoint(page, sceneKey, scenePoint) {
|
||||
const point = await page.evaluate(({ requestedSceneKey, requestedPoint }) => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene(requestedSceneKey);
|
||||
const canvas = document.querySelector('canvas');
|
||||
const canvasBounds = canvas?.getBoundingClientRect();
|
||||
if (!scene || !canvasBounds || !requestedPoint) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
x: canvasBounds.left +
|
||||
requestedPoint.x * canvasBounds.width / scene.scale.width,
|
||||
y: canvasBounds.top +
|
||||
requestedPoint.y * canvasBounds.height / scene.scale.height
|
||||
};
|
||||
}, {
|
||||
requestedSceneKey: sceneKey,
|
||||
requestedPoint: scenePoint
|
||||
});
|
||||
assert(point && Number.isFinite(point.x) && Number.isFinite(point.y));
|
||||
await page.mouse.click(point.x, point.y);
|
||||
}
|
||||
|
||||
async function waitForPlayerAt(page, target) {
|
||||
await page.waitForFunction(({ x, y }) => {
|
||||
const player = window.__HEROS_DEBUG__?.cityStay?.()?.player;
|
||||
return Boolean(
|
||||
player &&
|
||||
Math.abs(player.x - x) <= 9 &&
|
||||
Math.abs(player.y - y) <= 9 &&
|
||||
player.moving === false
|
||||
);
|
||||
}, target);
|
||||
}
|
||||
|
||||
async function captureStableScreenshot(page, path) {
|
||||
await page.waitForTimeout(260);
|
||||
const loopSlept = await page.evaluate(() => {
|
||||
|
||||
@@ -4,12 +4,13 @@ import { join } from 'node:path';
|
||||
const explorationAssetDirectory = join('src', 'assets', 'images', 'exploration');
|
||||
const expectedAssets = [
|
||||
'prologue-village.webp',
|
||||
'prologue-militia-camp.webp'
|
||||
'prologue-militia-camp.webp',
|
||||
'second-pursuit-village-ferry.webp'
|
||||
];
|
||||
const expectedWidth = 1920;
|
||||
const expectedHeight = 1080;
|
||||
const maximumAssetBytes = 2 * 1024 * 1024;
|
||||
const maximumTotalBytes = 4 * 1024 * 1024;
|
||||
const maximumTotalBytes = 6 * 1024 * 1024;
|
||||
const rasterAssetPattern = /\.(?:avif|jpe?g|png|webp)$/i;
|
||||
const errors = [];
|
||||
let totalBytes = 0;
|
||||
@@ -53,7 +54,7 @@ if (totalBytes > maximumTotalBytes) {
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(`Prologue exploration asset verification failed with ${errors.length} issue(s):`);
|
||||
console.error(`Exploration background asset verification failed with ${errors.length} issue(s):`);
|
||||
errors.forEach((error) => console.error(`- ${error}`));
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
@@ -90,7 +91,7 @@ function validateDirectoryCoverage(validationErrors) {
|
||||
if (!expectedRasterFiles.includes(fileName)) {
|
||||
validationErrors.push(
|
||||
`${explorationAssetDirectory}: unexpected raster asset "${fileName}"; ` +
|
||||
`only the two optimized runtime WebP backgrounds should be tracked here`
|
||||
`only the optimized runtime WebP backgrounds should be tracked here`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
1129
scripts/verify-second-battle-relief-exploration-browser.mjs
Normal file
1129
scripts/verify-second-battle-relief-exploration-browser.mjs
Normal file
File diff suppressed because it is too large
Load Diff
657
scripts/verify-second-battle-relief-exploration.mjs
Normal file
657
scripts/verify-second-battle-relief-exploration.mjs
Normal file
@@ -0,0 +1,657 @@
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const storage = new Map();
|
||||
let rejectStorageWrites = false;
|
||||
globalThis.window = {
|
||||
localStorage: {
|
||||
getItem(key) {
|
||||
return storage.has(key) ? storage.get(key) : null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
if (rejectStorageWrites) {
|
||||
throw new Error('Simulated campaign storage write failure.');
|
||||
}
|
||||
storage.set(key, String(value));
|
||||
},
|
||||
removeItem(key) {
|
||||
storage.delete(key);
|
||||
},
|
||||
clear() {
|
||||
storage.clear();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const server = await createServer({
|
||||
logLevel: 'error',
|
||||
server: { middlewareMode: true, hmr: false },
|
||||
appType: 'custom'
|
||||
});
|
||||
|
||||
try {
|
||||
const dataModule = await server.ssrLoadModule(
|
||||
'/src/game/data/secondBattleReliefExploration.ts'
|
||||
);
|
||||
const memoryModule = await server.ssrLoadModule(
|
||||
'/src/game/data/secondBattleReliefMemory.ts'
|
||||
);
|
||||
const actionModule = await server.ssrLoadModule(
|
||||
'/src/game/state/secondBattleReliefActions.ts'
|
||||
);
|
||||
const campaignModule = await server.ssrLoadModule(
|
||||
'/src/game/state/campaignState.ts'
|
||||
);
|
||||
const { battleScenarios } = await server.ssrLoadModule(
|
||||
'/src/game/data/battles.ts'
|
||||
);
|
||||
const { usableCatalog } = await server.ssrLoadModule(
|
||||
'/src/game/data/battleUsables.ts'
|
||||
);
|
||||
const explorationAssets = await server.ssrLoadModule(
|
||||
'/src/game/data/explorationCharacterAssets.ts'
|
||||
);
|
||||
const portraitAssets = await server.ssrLoadModule(
|
||||
'/src/game/data/portraitAssets.ts'
|
||||
);
|
||||
const firstScoutModule = await server.ssrLoadModule(
|
||||
'/src/game/data/firstPursuitScoutMemory.ts'
|
||||
);
|
||||
|
||||
verifyCanonicalDefinition(
|
||||
dataModule,
|
||||
battleScenarios,
|
||||
usableCatalog,
|
||||
explorationAssets,
|
||||
portraitAssets
|
||||
);
|
||||
verifyBattleResultAndScoutReview(
|
||||
dataModule,
|
||||
memoryModule,
|
||||
firstScoutModule
|
||||
);
|
||||
verifyTargetBattleMemories(dataModule, memoryModule);
|
||||
verifyGuardedPersistentProgress(
|
||||
dataModule,
|
||||
memoryModule,
|
||||
actionModule,
|
||||
campaignModule,
|
||||
battleScenarios
|
||||
);
|
||||
|
||||
console.log(
|
||||
'Second-battle relief exploration verification passed (canonical route data, ordered resumable objectives, first-scout result review, guarded rewards, and third-battle memory isolation).'
|
||||
);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
function verifyCanonicalDefinition(
|
||||
dataModule,
|
||||
battleScenarios,
|
||||
usableCatalog,
|
||||
explorationAssets,
|
||||
portraitAssets
|
||||
) {
|
||||
const definition =
|
||||
dataModule.secondBattleReliefExplorationDefinition;
|
||||
const source =
|
||||
battleScenarios[dataModule.secondBattleReliefSourceBattleId];
|
||||
const target =
|
||||
battleScenarios[dataModule.secondBattleReliefTargetBattleId];
|
||||
assert(
|
||||
definition.id === dataModule.secondBattleReliefVisitId &&
|
||||
definition.sourceBattleId === source.id &&
|
||||
definition.targetBattleId === target.id,
|
||||
'The relief route must stay scoped from the second victory to the third battle.'
|
||||
);
|
||||
assert(
|
||||
source.objectives.some(
|
||||
(objective) =>
|
||||
objective.id ===
|
||||
dataModule.secondBattleReliefSourceObjectiveIds.village
|
||||
) &&
|
||||
source.objectives.some(
|
||||
(objective) =>
|
||||
objective.id ===
|
||||
dataModule.secondBattleReliefSourceObjectiveIds.quick
|
||||
),
|
||||
'The relief memory must reference the authored village and quick-victory objectives.'
|
||||
);
|
||||
|
||||
const actorIds = new Set();
|
||||
definition.actors.forEach((actor) => {
|
||||
assert(!actorIds.has(actor.id), `Duplicate relief actor id ${actor.id}.`);
|
||||
actorIds.add(actor.id);
|
||||
assert(
|
||||
pointInside(actor, definition.movementBounds),
|
||||
`Actor ${actor.id} must stay inside the FHD exploration movement bounds.`
|
||||
);
|
||||
assert(
|
||||
explorationAssets.hasExplorationCharacterAsset(actor.textureKey),
|
||||
`Actor ${actor.id} must use an existing exploration character sheet.`
|
||||
);
|
||||
assert(
|
||||
typeof actor.portraitKey === 'string' &&
|
||||
portraitAssets.portraitAssetEntriesForKey(actor.portraitKey)
|
||||
.length > 0,
|
||||
`Actor ${actor.id} must use an existing face portrait in dialogue.`
|
||||
);
|
||||
assert(
|
||||
actor.dialogue.length >= 2 && actor.repeatDialogue.length >= 1,
|
||||
`Actor ${actor.id} must have first-time and repeat dialogue.`
|
||||
);
|
||||
});
|
||||
assert(
|
||||
pointInside(definition.player, definition.movementBounds) &&
|
||||
pointInside(definition.exit, definition.movementBounds),
|
||||
'The player start and camp exit must stay inside the movement bounds.'
|
||||
);
|
||||
|
||||
const objectiveIds = new Set();
|
||||
definition.objectives.forEach((objective, index) => {
|
||||
assert(
|
||||
!objectiveIds.has(objective.id),
|
||||
`Duplicate relief objective id ${objective.id}.`
|
||||
);
|
||||
assert(
|
||||
actorIds.has(objective.targetActorId),
|
||||
`Objective ${objective.id} must point to a canonical actor.`
|
||||
);
|
||||
objective.prerequisiteObjectiveIds.forEach((prerequisiteId) => {
|
||||
assert(
|
||||
objectiveIds.has(prerequisiteId),
|
||||
`Objective ${objective.id} prerequisite ${prerequisiteId} must appear earlier in the route.`
|
||||
);
|
||||
});
|
||||
assert(
|
||||
objective.id ===
|
||||
dataModule.secondBattleReliefObjectiveIds[index],
|
||||
`Objective ${objective.id} must retain the authored route order.`
|
||||
);
|
||||
objectiveIds.add(objective.id);
|
||||
});
|
||||
assert(
|
||||
JSON.stringify(
|
||||
dataModule.secondBattleReliefPrerequisiteObjectiveIds
|
||||
) ===
|
||||
JSON.stringify(
|
||||
dataModule.secondBattleReliefObjectiveIds.slice(0, -1)
|
||||
),
|
||||
'Every field interaction before the final Jian Yong choice must be a persisted prerequisite.'
|
||||
);
|
||||
|
||||
assert(
|
||||
JSON.stringify(definition.choices.map(({ id }) => id)) ===
|
||||
JSON.stringify(dataModule.secondBattleReliefChoiceIds),
|
||||
'The two relief choices must remain canonical and ordered.'
|
||||
);
|
||||
definition.choices.forEach((choice) => {
|
||||
const effect = choice.thirdBattleEffect;
|
||||
assert(
|
||||
choice.bondExp > 0 &&
|
||||
choice.itemRewards.length === 1 &&
|
||||
/^.+\s+\d+$/.test(choice.itemRewards[0]),
|
||||
`Choice ${choice.id} must provide valid bond and item feedback.`
|
||||
);
|
||||
assert(
|
||||
target.objectives.some(
|
||||
(objective) => objective.id === effect.focusObjectiveId
|
||||
),
|
||||
`Choice ${choice.id} must focus an objective in the third battle.`
|
||||
);
|
||||
assert(
|
||||
target.units.some(
|
||||
(unit) =>
|
||||
unit.id === effect.recommendedUnitId &&
|
||||
unit.faction === 'ally'
|
||||
),
|
||||
`Choice ${choice.id} must recommend an allied third-battle unit.`
|
||||
);
|
||||
if (effect.kind === 'village-supply-line') {
|
||||
assert(
|
||||
usableCatalog[effect.carriedUsableId]?.command === 'item',
|
||||
'The village supply payoff must reference a real battle item.'
|
||||
);
|
||||
} else {
|
||||
assert(
|
||||
target.units.some(
|
||||
(unit) =>
|
||||
unit.id === effect.trackedEnemyUnitId &&
|
||||
unit.faction === 'enemy'
|
||||
),
|
||||
'The ferry intelligence payoff must track the authored third-battle leader.'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function verifyBattleResultAndScoutReview(
|
||||
dataModule,
|
||||
memoryModule,
|
||||
firstScoutModule
|
||||
) {
|
||||
const cases = [
|
||||
{
|
||||
choiceId: firstScoutModule.firstPursuitScoutChoiceIds[0],
|
||||
villageAchieved: true,
|
||||
quickAchieved: true,
|
||||
expectedFocus: 'river',
|
||||
expectedOutcome: 'confirmed'
|
||||
},
|
||||
{
|
||||
choiceId: firstScoutModule.firstPursuitScoutChoiceIds[0],
|
||||
villageAchieved: true,
|
||||
quickAchieved: false,
|
||||
expectedFocus: 'river',
|
||||
expectedOutcome: 'missed'
|
||||
},
|
||||
{
|
||||
choiceId: firstScoutModule.firstPursuitScoutChoiceIds[1],
|
||||
villageAchieved: true,
|
||||
quickAchieved: false,
|
||||
expectedFocus: 'village',
|
||||
expectedOutcome: 'confirmed'
|
||||
},
|
||||
{
|
||||
choiceId: firstScoutModule.firstPursuitScoutChoiceIds[1],
|
||||
villageAchieved: false,
|
||||
quickAchieved: true,
|
||||
expectedFocus: 'village',
|
||||
expectedOutcome: 'missed'
|
||||
}
|
||||
];
|
||||
|
||||
cases.forEach((testCase) => {
|
||||
const campaign = literalCampaign(dataModule, {
|
||||
villageAchieved: testCase.villageAchieved,
|
||||
quickAchieved: testCase.quickAchieved,
|
||||
firstScoutVisitId: firstScoutModule.firstPursuitScoutVisitId,
|
||||
firstScoutChoiceId: testCase.choiceId
|
||||
});
|
||||
const context =
|
||||
memoryModule.resolveSecondBattleReliefContext(campaign);
|
||||
assert(
|
||||
context?.scoutReview.choiceId === testCase.choiceId &&
|
||||
context.scoutReview.focus === testCase.expectedFocus &&
|
||||
context.scoutReview.outcome === testCase.expectedOutcome,
|
||||
`The first scout choice must be reviewed against its matching second-battle result: ${JSON.stringify({
|
||||
testCase,
|
||||
context
|
||||
})}`
|
||||
);
|
||||
});
|
||||
|
||||
const unrecorded =
|
||||
memoryModule.resolveSecondBattleReliefContext(
|
||||
literalCampaign(dataModule, {
|
||||
villageAchieved: false,
|
||||
quickAchieved: false
|
||||
})
|
||||
);
|
||||
assert(
|
||||
unrecorded?.scoutReview.outcome === 'unrecorded',
|
||||
'Legacy saves without a first-scout marker must receive a safe unrecorded fallback.'
|
||||
);
|
||||
}
|
||||
|
||||
function verifyTargetBattleMemories(dataModule, memoryModule) {
|
||||
dataModule.secondBattleReliefChoiceIds.forEach((choiceId) => {
|
||||
const campaign = literalCampaign(dataModule, {
|
||||
villageAchieved: choiceId ===
|
||||
'restore-northern-village',
|
||||
quickAchieved: choiceId === 'trace-ferry-messenger',
|
||||
reliefChoiceId: choiceId
|
||||
});
|
||||
const memory = memoryModule.resolveSecondBattleReliefMemory({
|
||||
campaign,
|
||||
battleId: dataModule.secondBattleReliefTargetBattleId
|
||||
});
|
||||
assert(
|
||||
memory?.choiceId === choiceId &&
|
||||
memory.effect.readiness === 'reinforced' &&
|
||||
memory.effect.sourceObjectiveAchieved === true &&
|
||||
memory.openingLines.length === 3 &&
|
||||
memory.openingLines[0] === memory.campSummaryLine,
|
||||
`Choice ${choiceId} must resolve a reinforced third-battle memory.`
|
||||
);
|
||||
|
||||
const recoveredCampaign = literalCampaign(dataModule, {
|
||||
villageAchieved: false,
|
||||
quickAchieved: false,
|
||||
reliefChoiceId: choiceId
|
||||
});
|
||||
const recovered = memoryModule.resolveSecondBattleReliefMemory({
|
||||
campaign: recoveredCampaign,
|
||||
battleId: dataModule.secondBattleReliefTargetBattleId
|
||||
});
|
||||
assert(
|
||||
recovered?.effect.readiness === 'recovered' &&
|
||||
recovered.effect.sourceObjectiveAchieved === false,
|
||||
`Choice ${choiceId} must turn a missed source objective into a clearly labelled recovery, not a false success.`
|
||||
);
|
||||
assert(
|
||||
memoryModule.resolveSecondBattleReliefMemory({
|
||||
campaign,
|
||||
battleId: dataModule.secondBattleReliefSourceBattleId
|
||||
}) === undefined,
|
||||
`Choice ${choiceId} memory must not leak back into the source battle.`
|
||||
);
|
||||
});
|
||||
|
||||
const forged = literalCampaign(dataModule, {
|
||||
villageAchieved: true,
|
||||
quickAchieved: true,
|
||||
reliefChoiceId: 'forged-choice'
|
||||
});
|
||||
assert(
|
||||
memoryModule.resolveSecondBattleReliefMemory({
|
||||
campaign: forged,
|
||||
battleId: dataModule.secondBattleReliefTargetBattleId
|
||||
}) === undefined,
|
||||
'A forged relief choice must never produce a third-battle effect.'
|
||||
);
|
||||
}
|
||||
|
||||
function verifyGuardedPersistentProgress(
|
||||
dataModule,
|
||||
memoryModule,
|
||||
actionModule,
|
||||
campaignModule,
|
||||
battleScenarios
|
||||
) {
|
||||
storage.clear();
|
||||
campaignModule.resetCampaignState();
|
||||
assert(
|
||||
actionModule.recordSecondBattleReliefObjective(
|
||||
dataModule.secondBattleReliefObjectiveIds[0]
|
||||
).reason === 'invalid-campaign',
|
||||
'Relief progress must reject completion before the second victory camp.'
|
||||
);
|
||||
|
||||
prepareSecondVictory(
|
||||
campaignModule,
|
||||
battleScenarios,
|
||||
dataModule,
|
||||
{ villageAchieved: true, quickAchieved: false }
|
||||
);
|
||||
assert(
|
||||
actionModule.recordSecondBattleReliefObjective(
|
||||
dataModule.secondBattleReliefObjectiveIds[1]
|
||||
).reason === 'prerequisites-incomplete',
|
||||
'The ferry testimony must not be recordable before the village check.'
|
||||
);
|
||||
assert(
|
||||
actionModule.completeSecondBattleReliefExploration(
|
||||
dataModule.secondBattleReliefChoiceIds[0]
|
||||
).reason === 'prerequisites-incomplete',
|
||||
'The final choice must remain locked until every field objective is persisted.'
|
||||
);
|
||||
|
||||
dataModule.secondBattleReliefPrerequisiteObjectiveIds.forEach(
|
||||
(objectiveId, index) => {
|
||||
const result =
|
||||
actionModule.recordSecondBattleReliefObjective(objectiveId);
|
||||
assert(
|
||||
result.ok === true &&
|
||||
result.completedObjectiveIds.length === index + 1,
|
||||
`Objective ${objectiveId} must persist in authored order.`
|
||||
);
|
||||
}
|
||||
);
|
||||
const resumedProgress = actionModule.secondBattleReliefProgress();
|
||||
assert(
|
||||
resumedProgress.choiceReady === true &&
|
||||
JSON.stringify(resumedProgress.completedObjectiveIds) ===
|
||||
JSON.stringify(
|
||||
dataModule.secondBattleReliefPrerequisiteObjectiveIds
|
||||
),
|
||||
'Reloadable campaign markers must reconstruct all completed field objectives.'
|
||||
);
|
||||
|
||||
const choice =
|
||||
dataModule.secondBattleReliefExplorationDefinition.choices[0];
|
||||
const inventoryBefore =
|
||||
campaignModule.getCampaignState().inventory;
|
||||
const completed =
|
||||
actionModule.completeSecondBattleReliefExploration(choice.id);
|
||||
assert(
|
||||
completed.ok === true &&
|
||||
completed.memory.choiceId === choice.id &&
|
||||
completed.memory.targetBattleId ===
|
||||
dataModule.secondBattleReliefTargetBattleId &&
|
||||
completed.campaign.completedCampVisits.includes(
|
||||
dataModule.secondBattleReliefVisitId
|
||||
) &&
|
||||
completed.campaign.campVisitChoiceIds[
|
||||
dataModule.secondBattleReliefVisitId
|
||||
] === choice.id,
|
||||
'A valid final choice must atomically save the route, reward, and third-battle memory.'
|
||||
);
|
||||
assertRewardApplied(choice, inventoryBefore, completed.campaign);
|
||||
const completedProgress = actionModule.secondBattleReliefProgress();
|
||||
assert(
|
||||
completedProgress.completed === true &&
|
||||
completedProgress.nextObjectiveId === null &&
|
||||
completedProgress.choiceReady === false,
|
||||
'A completed route must not keep advertising a stale final objective or another choice.'
|
||||
);
|
||||
assert(
|
||||
actionModule.completeSecondBattleReliefExploration(choice.id)
|
||||
.reason === 'already-completed',
|
||||
'The completed route must reject duplicate rewards.'
|
||||
);
|
||||
|
||||
prepareSecondVictory(
|
||||
campaignModule,
|
||||
battleScenarios,
|
||||
dataModule,
|
||||
{ villageAchieved: false, quickAchieved: true }
|
||||
);
|
||||
dataModule.secondBattleReliefPrerequisiteObjectiveIds.forEach(
|
||||
(objectiveId) => {
|
||||
const result =
|
||||
actionModule.recordSecondBattleReliefObjective(objectiveId);
|
||||
assert(result.ok === true, `Expected progress for ${objectiveId}.`);
|
||||
}
|
||||
);
|
||||
const beforeFailedSave = progressSnapshot(
|
||||
campaignModule.getCampaignState(),
|
||||
dataModule
|
||||
);
|
||||
let failedSave;
|
||||
rejectStorageWrites = true;
|
||||
try {
|
||||
failedSave = actionModule.completeSecondBattleReliefExploration(
|
||||
dataModule.secondBattleReliefChoiceIds[1]
|
||||
);
|
||||
} finally {
|
||||
rejectStorageWrites = false;
|
||||
}
|
||||
const afterFailedSave = progressSnapshot(
|
||||
campaignModule.getCampaignState(),
|
||||
dataModule
|
||||
);
|
||||
assert(
|
||||
failedSave?.ok === false &&
|
||||
failedSave.reason === 'save-unavailable' &&
|
||||
JSON.stringify(afterFailedSave) ===
|
||||
JSON.stringify(beforeFailedSave),
|
||||
`A failed save must restore the route, rewards, and memory markers: ${JSON.stringify({
|
||||
failedSave,
|
||||
beforeFailedSave,
|
||||
afterFailedSave
|
||||
})}`
|
||||
);
|
||||
|
||||
const finalCampaign = campaignModule.getCampaignState();
|
||||
assert(
|
||||
memoryModule.resolveSecondBattleReliefMemory({
|
||||
campaign: finalCampaign,
|
||||
battleId: dataModule.secondBattleReliefTargetBattleId
|
||||
}) === undefined,
|
||||
'A failed final write must not leave a ghost third-battle memory.'
|
||||
);
|
||||
}
|
||||
|
||||
function prepareSecondVictory(
|
||||
campaignModule,
|
||||
battleScenarios,
|
||||
dataModule,
|
||||
{ villageAchieved, quickAchieved }
|
||||
) {
|
||||
storage.clear();
|
||||
campaignModule.resetCampaignState();
|
||||
const scenario =
|
||||
battleScenarios[dataModule.secondBattleReliefSourceBattleId];
|
||||
campaignModule.setFirstBattleReport({
|
||||
battleId: scenario.id,
|
||||
battleTitle: scenario.title,
|
||||
outcome: 'victory',
|
||||
turnNumber: quickAchieved ? 18 : 27,
|
||||
rewardGold: scenario.baseVictoryGold,
|
||||
defeatedEnemies: scenario.units.filter(
|
||||
(unit) => unit.faction === 'enemy'
|
||||
).length,
|
||||
totalEnemies: scenario.units.filter(
|
||||
(unit) => unit.faction === 'enemy'
|
||||
).length,
|
||||
objectives: scenario.objectives.map((objective) => {
|
||||
const achieved = objective.id ===
|
||||
dataModule.secondBattleReliefSourceObjectiveIds.village
|
||||
? villageAchieved
|
||||
: objective.id ===
|
||||
dataModule.secondBattleReliefSourceObjectiveIds.quick
|
||||
? quickAchieved
|
||||
: true;
|
||||
return {
|
||||
id: objective.id,
|
||||
label: objective.label,
|
||||
achieved,
|
||||
status: achieved ? 'done' : 'failed',
|
||||
detail: achieved ? '달성' : '미달',
|
||||
rewardGold: objective.rewardGold
|
||||
};
|
||||
}),
|
||||
units: scenario.units,
|
||||
bonds: scenario.bonds.map((bond) => ({
|
||||
...bond,
|
||||
battleExp: 0
|
||||
})),
|
||||
itemRewards: [],
|
||||
completedCampDialogues: [],
|
||||
completedCampVisits: [],
|
||||
createdAt: '2026-07-27T00:00:00.000Z'
|
||||
});
|
||||
const campaign = campaignModule.getCampaignState();
|
||||
assert(
|
||||
campaign.step === 'second-camp' &&
|
||||
campaign.latestBattleId === scenario.id,
|
||||
'The canonical second victory must route into second-camp.'
|
||||
);
|
||||
return campaign;
|
||||
}
|
||||
|
||||
function literalCampaign(
|
||||
dataModule,
|
||||
{
|
||||
villageAchieved,
|
||||
quickAchieved,
|
||||
reliefChoiceId,
|
||||
firstScoutVisitId,
|
||||
firstScoutChoiceId
|
||||
}
|
||||
) {
|
||||
const completedCampVisits = [];
|
||||
const campVisitChoiceIds = {};
|
||||
if (reliefChoiceId) {
|
||||
completedCampVisits.push(dataModule.secondBattleReliefVisitId);
|
||||
campVisitChoiceIds[dataModule.secondBattleReliefVisitId] =
|
||||
reliefChoiceId;
|
||||
}
|
||||
if (firstScoutVisitId && firstScoutChoiceId) {
|
||||
completedCampVisits.push(firstScoutVisitId);
|
||||
campVisitChoiceIds[firstScoutVisitId] = firstScoutChoiceId;
|
||||
}
|
||||
return {
|
||||
battleHistory: {
|
||||
[dataModule.secondBattleReliefSourceBattleId]: {
|
||||
battleId: dataModule.secondBattleReliefSourceBattleId,
|
||||
outcome: 'victory',
|
||||
turnNumber: quickAchieved ? 18 : 27,
|
||||
objectives: [
|
||||
{
|
||||
id: dataModule.secondBattleReliefSourceObjectiveIds.village,
|
||||
achieved: villageAchieved,
|
||||
status: villageAchieved ? 'done' : 'failed'
|
||||
},
|
||||
{
|
||||
id: dataModule.secondBattleReliefSourceObjectiveIds.quick,
|
||||
achieved: quickAchieved,
|
||||
status: quickAchieved ? 'done' : 'failed'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
completedCampVisits,
|
||||
campVisitChoiceIds
|
||||
};
|
||||
}
|
||||
|
||||
function assertRewardApplied(choice, inventoryBefore, campaign) {
|
||||
choice.itemRewards.forEach((reward) => {
|
||||
const match = /^(.+?)\s+(\d+)$/.exec(reward);
|
||||
assert(match, `Invalid reward label ${reward}.`);
|
||||
const [, label, amountText] = match;
|
||||
const amount = Number.parseInt(amountText, 10);
|
||||
assert(
|
||||
(campaign.inventory[label] ?? 0) ===
|
||||
(inventoryBefore[label] ?? 0) + amount,
|
||||
`Expected ${reward} to be granted exactly once.`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function progressSnapshot(campaign, dataModule) {
|
||||
const relevantIds = campaign.completedCampVisits.filter(
|
||||
(visitId) =>
|
||||
visitId === dataModule.secondBattleReliefVisitId ||
|
||||
visitId.startsWith(
|
||||
`${dataModule.secondBattleReliefVisitId}:objective:`
|
||||
)
|
||||
);
|
||||
return {
|
||||
completedCampVisits: relevantIds,
|
||||
reportCompletedCampVisits:
|
||||
campaign.firstBattleReport?.completedCampVisits.filter(
|
||||
(visitId) => relevantIds.includes(visitId)
|
||||
) ?? [],
|
||||
choiceId:
|
||||
campaign.campVisitChoiceIds[
|
||||
dataModule.secondBattleReliefVisitId
|
||||
] ?? null,
|
||||
inventory: { ...campaign.inventory },
|
||||
bonds: campaign.bonds.map(
|
||||
({ id, level, exp, battleExp }) => ({
|
||||
id,
|
||||
level,
|
||||
exp,
|
||||
battleExp
|
||||
})
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function pointInside(point, bounds) {
|
||||
return (
|
||||
point.x >= bounds.x &&
|
||||
point.y >= bounds.y &&
|
||||
point.x <= bounds.x + bounds.width &&
|
||||
point.y <= bounds.y + bounds.height
|
||||
);
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ const checks = [
|
||||
'scripts/verify-first-battle-camp-followup.mjs',
|
||||
'scripts/verify-first-battle-camaraderie-memory.mjs',
|
||||
'scripts/verify-first-pursuit-scout-memory.mjs',
|
||||
'scripts/verify-second-battle-relief-exploration.mjs',
|
||||
'scripts/verify-prologue-exploration-asset-data.mjs',
|
||||
'scripts/verify-exploration-character-asset-data.mjs',
|
||||
'scripts/verify-prologue-dialogue-portrait-data.mjs',
|
||||
|
||||
Reference in New Issue
Block a user