473 lines
18 KiB
JavaScript
473 lines
18 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');
|
|
const {
|
|
cityStayExplorationProfiles,
|
|
getCityStayExplorationProfile
|
|
} = await server.ssrLoadModule('/src/game/data/cityStayExploration.ts');
|
|
const { explorationCharacterAssetInfo } = await server.ssrLoadModule(
|
|
'/src/game/data/explorationCharacterAssets.ts'
|
|
);
|
|
const { musicTracks, ambienceTracks } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
|
|
|
|
validateCityStayCollection(cityStayDefinitions, battleScenarios, itemCatalog);
|
|
validateLookupHelpers(
|
|
cityStayDefinitions,
|
|
getCityStayDefinition,
|
|
findCityStayAfterBattle,
|
|
findCityStayBeforeBattle
|
|
);
|
|
validateExplorationProfiles(
|
|
cityStayDefinitions,
|
|
cityStayExplorationProfiles,
|
|
getCityStayExplorationProfile,
|
|
explorationCharacterAssetInfo,
|
|
musicTracks,
|
|
ambienceTracks
|
|
);
|
|
|
|
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, direct-exploration profiles, NPC sprites, soundscapes, 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 validateExplorationProfiles(
|
|
definitions,
|
|
profiles,
|
|
getProfile,
|
|
explorationCharacterAssetInfo,
|
|
musicTracks,
|
|
ambienceTracks
|
|
) {
|
|
if (!profiles || typeof profiles !== 'object') {
|
|
errors.push('cityStayExplorationProfiles must be an object');
|
|
return;
|
|
}
|
|
|
|
const actualIds = Object.keys(profiles);
|
|
if (JSON.stringify(actualIds) !== JSON.stringify(expectedCityStayIds)) {
|
|
errors.push(
|
|
`exploration profile ids/order must be ${expectedCityStayIds.join(', ')} (found ${actualIds.join(', ')})`
|
|
);
|
|
}
|
|
|
|
const knownDirections = new Set(['north', 'east', 'south', 'west']);
|
|
const knownTerrains = new Set([
|
|
'earth',
|
|
'stone',
|
|
'wet',
|
|
'wood',
|
|
'plain',
|
|
'road',
|
|
'forest',
|
|
'hill',
|
|
'village',
|
|
'fort',
|
|
'camp',
|
|
'river',
|
|
'cliff',
|
|
'bridge',
|
|
'water',
|
|
'marsh',
|
|
'swamp'
|
|
]);
|
|
const seenActorIds = new Set();
|
|
const seenBuildingIds = new Set();
|
|
|
|
definitions.forEach((definition) => {
|
|
const profile = profiles[definition.id];
|
|
const context = `cityStayExplorationProfiles.${definition.id}`;
|
|
if (!profile || typeof profile !== 'object') {
|
|
errors.push(`${context}: profile is missing`);
|
|
return;
|
|
}
|
|
if (profile.id !== definition.id) {
|
|
errors.push(`${context}: id "${profile.id}" does not match city stay "${definition.id}"`);
|
|
}
|
|
if (getProfile(definition.id) !== profile) {
|
|
errors.push(`${context}: getCityStayExplorationProfile did not return the canonical profile`);
|
|
}
|
|
|
|
['heading', 'subtitle', 'landmarkLabel', 'musicKey', 'ambienceKey'].forEach((field) => {
|
|
assertNonEmptyString(profile[field], `${context}.${field}`);
|
|
});
|
|
['groundColor', 'grassColor', 'roadColor', 'roadHighlightColor', 'accentColor'].forEach((field) => {
|
|
if (!Number.isInteger(profile[field]) || profile[field] < 0 || profile[field] > 0xffffff) {
|
|
errors.push(`${context}.${field} must be a 24-bit color`);
|
|
}
|
|
});
|
|
if (!musicTracks[profile.musicKey]) {
|
|
errors.push(`${context}: unknown musicKey "${profile.musicKey}"`);
|
|
}
|
|
if (!ambienceTracks[profile.ambienceKey]) {
|
|
errors.push(`${context}: unknown ambienceKey "${profile.ambienceKey}"`);
|
|
}
|
|
if (!knownTerrains.has(profile.movementTerrain)) {
|
|
errors.push(`${context}: unsupported movementTerrain "${profile.movementTerrain}"`);
|
|
}
|
|
|
|
validateExplorationPoint(profile.playerStart, `${context}.playerStart`, {
|
|
minX: 67,
|
|
maxX: 1427,
|
|
minY: 133,
|
|
maxY: 897
|
|
});
|
|
if (!knownDirections.has(profile.playerStart?.direction)) {
|
|
errors.push(`${context}.playerStart.direction must be north/east/south/west`);
|
|
}
|
|
|
|
if (profile.exit?.id !== 'camp-exit') {
|
|
errors.push(`${context}.exit.id must be "camp-exit"`);
|
|
}
|
|
assertNonEmptyString(profile.exit?.name, `${context}.exit.name`);
|
|
validateExplorationPoint(profile.exit, `${context}.exit`, {
|
|
minX: 42,
|
|
maxX: 1452,
|
|
minY: 108,
|
|
maxY: 922
|
|
});
|
|
const startToExit = distanceBetween(profile.playerStart, profile.exit);
|
|
if (startToExit <= 164) {
|
|
errors.push(
|
|
`${context}: player must start outside the exit prompt radius (distance ${Math.round(startToExit)})`
|
|
);
|
|
}
|
|
|
|
if (!Array.isArray(profile.actors) || profile.actors.length !== 3) {
|
|
errors.push(`${context}.actors must contain exactly 3 entries`);
|
|
} else {
|
|
const kinds = profile.actors.map((actor) => actor.kind).sort();
|
|
if (JSON.stringify(kinds) !== JSON.stringify(['dialogue', 'information', 'market'])) {
|
|
errors.push(`${context}.actors must contain one information, market, and dialogue actor`);
|
|
}
|
|
profile.actors.forEach((actor, actorIndex) => {
|
|
const actorContext = `${context}.actors[${actorIndex}]`;
|
|
assertUniqueNonEmptyString(actor?.id, seenActorIds, `${actorContext}.id`);
|
|
assertNonEmptyString(actor?.name, `${actorContext}.name`);
|
|
assertNonEmptyString(actor?.role, `${actorContext}.role`);
|
|
assertNonEmptyString(actor?.textureKey, `${actorContext}.textureKey`);
|
|
validateExplorationPoint(actor, actorContext, {
|
|
minX: 42,
|
|
maxX: 1452,
|
|
minY: 108,
|
|
maxY: 922
|
|
});
|
|
if (!knownDirections.has(actor?.direction)) {
|
|
errors.push(`${actorContext}.direction must be north/east/south/west`);
|
|
}
|
|
if (!explorationCharacterAssetInfo(actor?.textureKey)) {
|
|
errors.push(`${actorContext}: unknown exploration character texture "${actor?.textureKey}"`);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (!explorationCharacterAssetInfo('exploration-liu-bei')) {
|
|
errors.push(`${context}: player texture "exploration-liu-bei" is missing`);
|
|
}
|
|
if (!Array.isArray(profile.buildings) || profile.buildings.length !== 3) {
|
|
errors.push(`${context}.buildings must contain exactly 3 entries`);
|
|
} else {
|
|
profile.buildings.forEach((building, buildingIndex) => {
|
|
const buildingContext = `${context}.buildings[${buildingIndex}]`;
|
|
assertUniqueNonEmptyString(building?.id, seenBuildingIds, `${buildingContext}.id`);
|
|
assertNonEmptyString(building?.label, `${buildingContext}.label`);
|
|
['x', 'y', 'width', 'height'].forEach((field) => {
|
|
if (!Number.isFinite(building?.[field])) {
|
|
errors.push(`${buildingContext}.${field} must be a finite number`);
|
|
}
|
|
});
|
|
if (
|
|
building?.x < 0 ||
|
|
building?.y < 92 ||
|
|
building?.width <= 0 ||
|
|
building?.height <= 0 ||
|
|
building?.x + building?.width > 1495 ||
|
|
building?.y + building?.height > 958
|
|
) {
|
|
errors.push(`${buildingContext} must fit inside the city map`);
|
|
}
|
|
['wallColor', 'roofColor'].forEach((field) => {
|
|
if (!Number.isInteger(building?.[field]) || building[field] < 0 || building[field] > 0xffffff) {
|
|
errors.push(`${buildingContext}.${field} must be a 24-bit color`);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
const points = [
|
|
profile.playerStart,
|
|
profile.exit,
|
|
...(Array.isArray(profile.actors) ? profile.actors : [])
|
|
];
|
|
for (let left = 0; left < points.length; left += 1) {
|
|
for (let right = left + 1; right < points.length; right += 1) {
|
|
if (distanceBetween(points[left], points[right]) < 46) {
|
|
errors.push(`${context}: exploration points ${left} and ${right} overlap`);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function validateExplorationPoint(point, context, bounds) {
|
|
if (!point || typeof point !== 'object') {
|
|
errors.push(`${context} is missing`);
|
|
return;
|
|
}
|
|
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) {
|
|
errors.push(`${context} must use finite x/y coordinates`);
|
|
return;
|
|
}
|
|
if (
|
|
point.x < bounds.minX ||
|
|
point.x > bounds.maxX ||
|
|
point.y < bounds.minY ||
|
|
point.y > bounds.maxY
|
|
) {
|
|
errors.push(`${context} (${point.x}, ${point.y}) lies outside the playable map`);
|
|
}
|
|
}
|
|
|
|
function distanceBetween(left, right) {
|
|
if (!left || !right || !Number.isFinite(left.x) || !Number.isFinite(left.y) || !Number.isFinite(right.x) || !Number.isFinite(right.y)) {
|
|
return Number.POSITIVE_INFINITY;
|
|
}
|
|
return Math.hypot(left.x - right.x, left.y - right.y);
|
|
}
|
|
|
|
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})`);
|
|
}
|
|
}
|