Files
heros_web/scripts/verify-city-stay-data.mjs

253 lines
9.7 KiB
JavaScript

import { createServer } from 'vite';
const expectedCityStayIds = ['xuzhou', 'xinye', 'chengdu'];
const errors = [];
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true },
appType: 'custom'
});
try {
const {
cityStayDefinitions,
findCityStayAfterBattle,
findCityStayBeforeBattle,
getCityStayDefinition
} = await server.ssrLoadModule('/src/game/data/cityStays.ts');
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
const { itemCatalog } = await server.ssrLoadModule('/src/game/data/battleItems.ts');
validateCityStayCollection(cityStayDefinitions, battleScenarios, itemCatalog);
validateLookupHelpers(
cityStayDefinitions,
getCityStayDefinition,
findCityStayAfterBattle,
findCityStayBeforeBattle
);
if (errors.length > 0) {
throw new Error(`City stay data verification failed:\n${errors.map((error) => `- ${error}`).join('\n')}`);
}
console.log(
`Verified ${cityStayDefinitions.length} city stays, ${cityStayDefinitions.length * 3} common equipment offers, battle links, information visits, resonance dialogues, and typed lookup helpers.`
);
} finally {
await server.close();
}
function validateCityStayCollection(definitions, battleScenarios, itemCatalog) {
if (!Array.isArray(definitions) || definitions.length !== 3) {
errors.push(`cityStayDefinitions must contain exactly 3 entries (found ${definitions?.length ?? 'invalid'})`);
return;
}
const actualIds = definitions.map((definition) => definition.id);
if (JSON.stringify(actualIds) !== JSON.stringify(expectedCityStayIds)) {
errors.push(`city stay ids/order must be ${expectedCityStayIds.join(', ')} (found ${actualIds.join(', ')})`);
}
const battleOrder = Object.keys(battleScenarios);
const seenCityIds = new Set();
const seenVisitIds = new Set();
const seenDialogueIds = new Set();
const seenChoiceIds = new Set();
const seenOfferIds = new Set();
definitions.forEach((definition, index) => {
const context = `cityStayDefinitions[${index}]/${definition?.id ?? 'unknown'}`;
assertUniqueNonEmptyString(definition?.id, seenCityIds, `${context}: id`);
validateCityMeta(definition?.city, context);
validateBattleLink(definition, context, battleScenarios, battleOrder);
validateInformationVisit(definition?.information, context, seenVisitIds);
validateDialogue(definition?.dialogue, context, definition, battleScenarios, seenDialogueIds, seenChoiceIds);
validateEquipmentOffers(definition?.equipmentOffers, context, itemCatalog, seenOfferIds);
});
}
function validateCityMeta(city, context) {
if (!city || typeof city !== 'object') {
errors.push(`${context}: city metadata is missing`);
return;
}
['name', 'region', 'title', 'description'].forEach((field) => {
assertNonEmptyString(city[field], `${context}: city.${field}`);
});
}
function validateBattleLink(definition, context, battleScenarios, battleOrder) {
const afterScenario = battleScenarios[definition.afterBattleId];
const nextScenario = battleScenarios[definition.nextBattleId];
if (!afterScenario) {
errors.push(`${context}: unknown afterBattleId "${definition.afterBattleId}"`);
}
if (!nextScenario) {
errors.push(`${context}: unknown nextBattleId "${definition.nextBattleId}"`);
}
if (!afterScenario || !nextScenario) {
return;
}
const afterIndex = battleOrder.indexOf(definition.afterBattleId);
const nextIndex = battleOrder.indexOf(definition.nextBattleId);
if (afterIndex < 0 || nextIndex !== afterIndex + 1) {
errors.push(
`${context}: next battle must immediately follow after battle (${definition.afterBattleId} -> ${definition.nextBattleId})`
);
}
if (afterScenario.campaignReward?.unlockBattleId !== definition.nextBattleId) {
errors.push(
`${context}: after battle unlock "${afterScenario.campaignReward?.unlockBattleId ?? 'none'}" does not match nextBattleId`
);
}
}
function validateInformationVisit(information, context, seenVisitIds) {
if (!information || typeof information !== 'object') {
errors.push(`${context}: information visit is missing`);
return;
}
assertUniqueNonEmptyString(information.id, seenVisitIds, `${context}: information.id`);
['title', 'location', 'description', 'itemReward'].forEach((field) => {
assertNonEmptyString(information[field], `${context}: information.${field}`);
});
if (!Array.isArray(information.briefingLines) || information.briefingLines.length < 1 || information.briefingLines.length > 2) {
errors.push(`${context}: information.briefingLines must contain 1 or 2 lines`);
} else {
information.briefingLines.forEach((line, lineIndex) => {
assertNonEmptyString(line, `${context}: information.briefingLines[${lineIndex}]`);
});
}
if (!/^(.*?)\s+([1-9]\d*)$/.test(information.itemReward ?? '')) {
errors.push(`${context}: information.itemReward must end with a positive amount`);
}
}
function validateDialogue(dialogue, context, definition, battleScenarios, seenDialogueIds, seenChoiceIds) {
if (!dialogue || typeof dialogue !== 'object') {
errors.push(`${context}: resonance dialogue is missing`);
return;
}
assertUniqueNonEmptyString(dialogue.id, seenDialogueIds, `${context}: dialogue.id`);
assertNonEmptyString(dialogue.title, `${context}: dialogue.title`);
assertNonEmptyString(dialogue.bondId, `${context}: dialogue.bondId`);
assertPositiveInteger(dialogue.rewardExp, `${context}: dialogue.rewardExp`);
if (
!Array.isArray(dialogue.unitIds) ||
dialogue.unitIds.length !== 2 ||
!dialogue.unitIds.every((unitId) => typeof unitId === 'string' && unitId.trim().length > 0) ||
dialogue.unitIds[0] === dialogue.unitIds[1]
) {
errors.push(`${context}: dialogue.unitIds must contain two distinct unit ids`);
}
if (!Array.isArray(dialogue.lines) || dialogue.lines.length === 0) {
errors.push(`${context}: dialogue.lines must not be empty`);
} else {
dialogue.lines.forEach((line, lineIndex) => {
assertNonEmptyString(line, `${context}: dialogue.lines[${lineIndex}]`);
});
}
if (!Array.isArray(dialogue.choices) || dialogue.choices.length !== 2) {
errors.push(`${context}: dialogue.choices must contain exactly 2 entries`);
} else {
dialogue.choices.forEach((choice, choiceIndex) => {
const choiceContext = `${context}: dialogue.choices[${choiceIndex}]`;
assertUniqueNonEmptyString(choice.id, seenChoiceIds, `${choiceContext}.id`);
assertNonEmptyString(choice.label, `${choiceContext}.label`);
assertNonEmptyString(choice.response, `${choiceContext}.response`);
assertPositiveInteger(choice.rewardExp, `${choiceContext}.rewardExp`);
});
}
const nextScenario = battleScenarios[definition.nextBattleId];
const bond = nextScenario?.bonds?.find((candidate) => candidate.id === dialogue.bondId);
if (!bond) {
errors.push(`${context}: dialogue bond "${dialogue.bondId}" is not available in the next battle`);
} else if (
!Array.isArray(dialogue.unitIds) ||
dialogue.unitIds.length !== 2 ||
!dialogue.unitIds.every((unitId) => bond.unitIds.includes(unitId))
) {
errors.push(
`${context}: dialogue unit ids ${JSON.stringify(dialogue.unitIds)} do not match bond ${JSON.stringify(bond.unitIds)}`
);
}
}
function validateEquipmentOffers(offers, context, itemCatalog, seenOfferIds) {
if (!Array.isArray(offers) || offers.length !== 3) {
errors.push(`${context}: equipmentOffers must contain exactly 3 entries`);
return;
}
const cityItemIds = new Set();
offers.forEach((offer, offerIndex) => {
const offerContext = `${context}: equipmentOffers[${offerIndex}]`;
assertUniqueNonEmptyString(offer?.id, seenOfferIds, `${offerContext}.id`);
assertNonEmptyString(offer?.itemId, `${offerContext}.itemId`);
assertPositiveInteger(offer?.price, `${offerContext}.price`);
if (cityItemIds.has(offer?.itemId)) {
errors.push(`${offerContext}: duplicate city item "${offer?.itemId}"`);
}
cityItemIds.add(offer?.itemId);
const item = itemCatalog[offer?.itemId];
if (!item) {
errors.push(`${offerContext}: unknown equipment item "${offer?.itemId}"`);
return;
}
if (item.rank !== 'common') {
errors.push(`${offerContext}: item "${offer.itemId}" must have common rank (found ${item.rank})`);
}
if (offer.slot !== item.slot) {
errors.push(`${offerContext}: slot "${offer.slot}" does not match catalog slot "${item.slot}"`);
}
});
}
function validateLookupHelpers(
definitions,
getCityStayDefinition,
findCityStayAfterBattle,
findCityStayBeforeBattle
) {
definitions.forEach((definition) => {
if (getCityStayDefinition(definition.id) !== definition) {
errors.push(`getCityStayDefinition("${definition.id}") did not return the canonical definition`);
}
if (findCityStayAfterBattle(definition.afterBattleId) !== definition) {
errors.push(`findCityStayAfterBattle("${definition.afterBattleId}") did not return ${definition.id}`);
}
if (findCityStayBeforeBattle(definition.nextBattleId) !== definition) {
errors.push(`findCityStayBeforeBattle("${definition.nextBattleId}") did not return ${definition.id}`);
}
});
}
function assertUniqueNonEmptyString(value, seen, context) {
assertNonEmptyString(value, context);
if (typeof value !== 'string' || value.trim().length === 0) {
return;
}
if (seen.has(value)) {
errors.push(`${context}: duplicate value "${value}"`);
}
seen.add(value);
}
function assertNonEmptyString(value, context) {
if (typeof value !== 'string' || value.trim().length === 0) {
errors.push(`${context} must be a non-empty string`);
}
}
function assertPositiveInteger(value, context) {
if (!Number.isInteger(value) || value <= 0) {
errors.push(`${context} must be a positive integer (found ${value})`);
}
}