feat: enrich campaign reward and roster visuals

This commit is contained in:
2026-07-19 12:16:30 +09:00
parent 7c6a8174d1
commit f83296db7b
11 changed files with 1164 additions and 205 deletions

View File

@@ -7,6 +7,8 @@ const validMarkerSides = new Set(['ally', 'enemy', 'objective']);
const validRewardTones = new Set(['reward', 'next', 'bond']);
const validDirections = new Set(['south', 'east', 'north', 'west']);
const portraitlessActorIds = new Set(['rebel-leader']);
const maxAdjacentBackgroundDuplicateRate = 0.02;
const maxAdjacentBackgroundRun = 3;
const server = await createServer({
logLevel: 'error',
@@ -17,8 +19,12 @@ const server = await createServer({
try {
const scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts');
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
const { storyBackgroundAssets, storyBackgroundKeyForPage, storyBackgroundKeysFor } =
await server.ssrLoadModule('/src/game/data/storyAssets.ts');
const {
storyBackgroundAssets,
storyBackgroundKeysFor,
storyBackgroundKeysForPages,
storyBackgroundPageHasFixedAssignment
} = await server.ssrLoadModule('/src/game/data/storyAssets.ts');
const { portraitAssetEntriesForKey, portraitKeyForSpeaker } = await server.ssrLoadModule('/src/game/data/portraitAssets.ts');
const { storyCutsceneActorProfileFor } = await server.ssrLoadModule('/src/game/data/storyCutscenes.ts');
const { hasUnitSheetAsset } = await server.ssrLoadModule('/src/game/data/unitAssets.ts');
@@ -31,6 +37,12 @@ try {
const selectedBackgroundUrlsByBase = new Map();
const usedPortraitKeys = new Set();
const usedCutsceneActorIds = new Set();
const adjacencyMetrics = {
pairs: 0,
duplicatePairs: 0,
avoidableDuplicatePairs: 0,
maxRun: 0
};
let pageCount = 0;
let cutsceneCount = 0;
@@ -40,6 +52,23 @@ try {
return;
}
const selectedKeys = storyBackgroundKeysForPages(pages);
const repeatedSelection = storyBackgroundKeysForPages(pages);
if (selectedKeys.some((key, index) => key !== repeatedSelection[index])) {
errors.push(`${source}: story background sequence selection must be deterministic`);
}
validateStoryBackgroundSequence({
errors,
source,
pages,
selectedKeys,
storyBackgroundAssets,
storyBackgroundKeysFor,
storyBackgroundPageHasFixedAssignment,
metrics: adjacencyMetrics
});
pages.forEach((page, index) => {
pageCount += 1;
uniquePageIds.add(page.id);
@@ -48,13 +77,13 @@ try {
source,
index,
page,
selectedKey: selectedKeys[index],
usedBackgroundKeys,
selectedBackgroundKeys,
selectedBackgroundUrlsByBase,
usedPortraitKeys,
usedCutsceneActorIds,
storyBackgroundAssets,
storyBackgroundKeyForPage,
storyBackgroundKeysFor,
portraitAssetEntriesForKey,
portraitKeyForSpeaker,
@@ -69,6 +98,7 @@ try {
validateStoryBackgroundVariantCoverage(errors, selectedBackgroundKeys);
validateRepeatedStoryBackgroundDistribution(errors, selectedBackgroundUrlsByBase);
validateStoryBackgroundAdjacencyMetrics(errors, adjacencyMetrics);
if (errors.length) {
console.error(`Story asset data verification failed with ${errors.length} issue(s):`);
@@ -78,11 +108,16 @@ try {
}
process.exitCode = 1;
} else {
const adjacentDuplicateRate = adjacencyMetrics.pairs > 0
? adjacencyMetrics.duplicatePairs / adjacencyMetrics.pairs
: 0;
console.log(
`Verified ${pageCount} story page references (${uniquePageIds.size} unique pages), ` +
`${cutsceneCount} cutscenes, ${usedBackgroundKeys.size} background candidates, ` +
`${selectedBackgroundKeys.size} selected backgrounds, ` +
`${usedPortraitKeys.size} portrait keys, and ${usedCutsceneActorIds.size} cutscene actor profiles.`
`${usedPortraitKeys.size} portrait keys, and ${usedCutsceneActorIds.size} cutscene actor profiles; ` +
`${adjacencyMetrics.duplicatePairs}/${adjacencyMetrics.pairs} adjacent background repeats ` +
`(${(adjacentDuplicateRate * 100).toFixed(1)}%), max run ${adjacencyMetrics.maxRun}.`
);
}
} finally {
@@ -146,11 +181,11 @@ function validateBackground(context, pageContext) {
const {
errors,
page,
selectedKey,
usedBackgroundKeys,
selectedBackgroundKeys,
selectedBackgroundUrlsByBase,
storyBackgroundAssets,
storyBackgroundKeyForPage,
storyBackgroundKeysFor
} = context;
if (!page.background) {
@@ -164,11 +199,6 @@ function validateBackground(context, pageContext) {
}
keys.forEach((key) => usedBackgroundKeys.add(key));
const selectedKey = storyBackgroundKeyForPage(page.background, page.id);
const repeatedSelection = storyBackgroundKeyForPage(page.background, page.id);
if (selectedKey !== repeatedSelection) {
errors.push(`${pageContext}: story background selection must be deterministic`);
}
if (!keys.includes(selectedKey)) {
errors.push(`${pageContext}: selected background "${selectedKey}" is not a candidate for "${page.background}"`);
return;
@@ -192,6 +222,98 @@ function validateBackground(context, pageContext) {
selectedBackgroundUrlsByBase.set(page.background, backgroundGroup);
}
function validateStoryBackgroundSequence(context) {
const {
errors,
source,
pages,
selectedKeys,
storyBackgroundAssets,
storyBackgroundKeysFor,
storyBackgroundPageHasFixedAssignment,
metrics
} = context;
let previousUrl;
let previousPage;
let previousKey;
let currentRun = 0;
pages.forEach((page, index) => {
const selectedKey = selectedKeys[index];
const selectedUrl = storyBackgroundAssets[selectedKey] ?? selectedKey;
if (index > 0) {
metrics.pairs += 1;
}
if (previousUrl !== undefined && selectedUrl === previousUrl) {
metrics.duplicatePairs += 1;
currentRun += 1;
const previousUrls = selectableStoryBackgroundUrls(
previousPage,
previousKey,
storyBackgroundAssets,
storyBackgroundKeysFor,
storyBackgroundPageHasFixedAssignment
);
const currentUrls = selectableStoryBackgroundUrls(
page,
selectedKey,
storyBackgroundAssets,
storyBackgroundKeysFor,
storyBackgroundPageHasFixedAssignment
);
const hasDistinctPair = [...previousUrls].some((leftUrl) =>
[...currentUrls].some((rightUrl) => leftUrl !== rightUrl)
);
if (hasDistinctPair) {
metrics.avoidableDuplicatePairs += 1;
errors.push(`${source}[${index}]/${page.id}: avoidable adjacent story background repeat`);
}
} else {
currentRun = 1;
}
metrics.maxRun = Math.max(metrics.maxRun, currentRun);
previousUrl = selectedUrl;
previousPage = page;
previousKey = selectedKey;
});
}
function selectableStoryBackgroundUrls(
page,
selectedKey,
storyBackgroundAssets,
storyBackgroundKeysFor,
storyBackgroundPageHasFixedAssignment
) {
const selectedUrl = storyBackgroundAssets[selectedKey] ?? selectedKey;
if (!page || storyBackgroundPageHasFixedAssignment(page.id)) {
return new Set([selectedUrl]);
}
return new Set(
storyBackgroundKeysFor(page.background)
.map((key) => storyBackgroundAssets[key] ?? key)
);
}
function validateStoryBackgroundAdjacencyMetrics(errors, metrics) {
const duplicateRate = metrics.pairs > 0 ? metrics.duplicatePairs / metrics.pairs : 0;
if (metrics.avoidableDuplicatePairs > 0) {
errors.push(`${metrics.avoidableDuplicatePairs} avoidable adjacent story background repeat(s) remain`);
}
if (duplicateRate > maxAdjacentBackgroundDuplicateRate) {
errors.push(
`adjacent story background duplicate rate ${(duplicateRate * 100).toFixed(1)}% exceeds ` +
`${(maxAdjacentBackgroundDuplicateRate * 100).toFixed(1)}%`
);
}
if (metrics.maxRun > maxAdjacentBackgroundRun) {
errors.push(`story background max run ${metrics.maxRun} exceeds ${maxAdjacentBackgroundRun}`);
}
}
function validateStoryBackgroundVariantCoverage(errors, selectedBackgroundKeys) {
const firstVariantNumber = 33;
const lastVariantNumber = 134;