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 { unitBaseSheetAssetInfo } = await server.ssrLoadModule('/src/game/data/unitAssets.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, unitBaseSheetAssetInfo, 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, unitBaseSheetAssetInfo, 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 (!unitBaseSheetAssetInfo(actor?.textureKey)) { errors.push(`${actorContext}: unknown unit texture "${actor?.textureKey}"`); } }); } if (!unitBaseSheetAssetInfo('unit-liu-bei')) { errors.push(`${context}: player texture "unit-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})`); } }