import { createServer } from 'vite'; const server = await createServer({ logLevel: 'error', server: { middlewareMode: true }, appType: 'custom' }); try { const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); const { campaignRecruitUnits } = await server.ssrLoadModule('/src/game/data/scenario.ts'); const { unitClasses, terrainRules } = await server.ssrLoadModule('/src/game/data/battleRules.ts'); const { itemCatalog, equipmentSlots } = await server.ssrLoadModule('/src/game/data/battleItems.ts'); const { sortieFormationRoles } = await server.ssrLoadModule('/src/game/data/sortieDeployment.ts'); const scenarioEntries = Object.entries(battleScenarios); const scenarioIds = new Set(scenarioEntries.map(([id]) => id)); const classKeys = new Set(Object.keys(unitClasses)); const terrainKeys = new Set(Object.keys(terrainRules)); const itemKeys = new Set(Object.keys(itemCatalog)); const itemNames = new Set(Object.values(itemCatalog).map((item) => item.name)); const formationRoles = new Set(sortieFormationRoles); const tacticalGuideEventKeys = new Set([ 'leader-wavering', 'ally-danger', 'village-approach', 'village-secured', 'vanguard-broken', 'cavalry-approach', 'enemy-posture' ]); const knownRewardUnitIds = new Set([ ...scenarioEntries.flatMap(([, scenario]) => scenario.units.map((unit) => unit.id)), ...(campaignRecruitUnits ?? []).map((unit) => unit.id) ]); const errors = []; scenarioEntries.forEach(([scenarioId, scenario]) => { const context = scenarioId; if (scenario.id !== scenarioId) { errors.push(`${context}: scenario id does not match battleScenarios key`); } assertNonEmptyString(errors, scenario.title, `${context}: title`); assertNonEmptyString(errors, scenario.victoryConditionLabel, `${context}: victory condition label`); assertNonEmptyString(errors, scenario.defeatConditionLabel, `${context}: defeat condition label`); validateOpeningObjectiveLines(errors, scenario, context); validateMap(errors, scenario, terrainKeys, context); validateUnits(errors, scenario, classKeys, itemKeys, equipmentSlots, context); validateObjectives(errors, scenario, context); validateTacticalGuide(errors, scenario, tacticalGuideEventKeys, context); validateSortie(errors, scenario, classKeys, formationRoles, terrainRules, context); validateRewards(errors, scenario, scenarioIds, itemNames, knownRewardUnitIds, context); }); if (errors.length) { console.error(`Battle scenario data verification failed with ${errors.length} issue(s):`); errors.slice(0, 80).forEach((error) => console.error(`- ${error}`)); if (errors.length > 80) { console.error(`- ...and ${errors.length - 80} more`); } process.exitCode = 1; } else { const unitCount = scenarioEntries.reduce((total, [, scenario]) => total + scenario.units.length, 0); const rewardCount = scenarioEntries.reduce( (total, [, scenario]) => total + scenario.itemRewards.length + campaignRewardLabelCount(scenario.campaignReward), 0 ); const tacticalGuideCount = scenarioEntries.reduce((total, [, scenario]) => total + (scenario.tacticalGuide ? 1 : 0), 0); console.log( `Verified ${scenarioEntries.length} battle scenarios, ${unitCount} unit placements, maps, opening briefs, objectives, sortie data, ${tacticalGuideCount} tactical guides, equipment, and ${rewardCount} reward labels.` ); } } finally { await server.close(); } function validateMap(errors, scenario, terrainKeys, context) { const { map } = scenario; if (!map || !Number.isInteger(map.width) || !Number.isInteger(map.height) || map.width < 1 || map.height < 1) { errors.push(`${context}: invalid map dimensions`); return; } if (!Array.isArray(map.terrain) || map.terrain.length !== map.height) { errors.push(`${context}: terrain row count does not match map height`); return; } map.terrain.forEach((row, y) => { if (!Array.isArray(row) || row.length !== map.width) { errors.push(`${context}: terrain row ${y} does not match map width`); return; } row.forEach((terrain, x) => { if (!terrainKeys.has(terrain)) { errors.push(`${context}: unknown terrain "${terrain}" at ${x},${y}`); } }); }); } function validateUnits(errors, scenario, classKeys, itemKeys, equipmentSlots, context) { const unitIds = new Set(); const occupiedTiles = new Map(); const allies = []; const enemies = []; scenario.units.forEach((unit) => { const unitContext = `${context}/${unit.id}`; if (!unit.id) { errors.push(`${context}: unit without id`); return; } if (unitIds.has(unit.id)) { errors.push(`${context}: duplicate unit id "${unit.id}"`); } unitIds.add(unit.id); if (unit.faction === 'ally') { allies.push(unit); } else if (unit.faction === 'enemy') { enemies.push(unit); } else { errors.push(`${unitContext}: invalid faction "${unit.faction}"`); } if (!classKeys.has(unit.classKey)) { errors.push(`${unitContext}: unknown classKey "${unit.classKey}"`); } validateCharacterProgress(errors, unit, unitContext); if (!Number.isInteger(unit.hp) || !Number.isInteger(unit.maxHp) || unit.hp < 1 || unit.maxHp < 1 || unit.hp > unit.maxHp) { errors.push(`${unitContext}: invalid hp ${unit.hp}/${unit.maxHp}`); } if (!Number.isInteger(unit.move) || unit.move < 1) { errors.push(`${unitContext}: invalid move ${unit.move}`); } if (!isInBounds(unit, scenario.map)) { errors.push(`${unitContext}: position ${unit.x},${unit.y} is outside ${scenario.map.width}x${scenario.map.height}`); } else { const tileKey = `${unit.x},${unit.y}`; const existingUnitId = occupiedTiles.get(tileKey); if (existingUnitId) { errors.push(`${context}: units "${existingUnitId}" and "${unit.id}" share tile ${tileKey}`); } occupiedTiles.set(tileKey, unit.id); } equipmentSlots.forEach((slot) => { const equipment = unit.equipment?.[slot]; if (!equipment) { errors.push(`${unitContext}: missing ${slot} equipment`); return; } if (!itemKeys.has(equipment.itemId)) { errors.push(`${unitContext}: unknown ${slot} item "${equipment.itemId}"`); } if (!Number.isInteger(equipment.level) || equipment.level < 1 || equipment.level > 9) { errors.push(`${unitContext}: invalid ${slot} level ${equipment.level}`); } if (!Number.isInteger(equipment.exp) || equipment.exp < 0) { errors.push(`${unitContext}: invalid ${slot} exp ${equipment.exp}`); } }); }); if (!allies.length) { errors.push(`${context}: scenario has no allied units`); } if (!enemies.length) { errors.push(`${context}: scenario has no enemy units`); } } function validateObjectives(errors, scenario, context) { const unitIds = new Set(scenario.units.map((unit) => unit.id)); const objectiveIds = new Set(); if (!scenario.leaderUnitId || !unitIds.has(scenario.leaderUnitId)) { errors.push(`${context}: leaderUnitId "${scenario.leaderUnitId}" is missing from units`); } scenario.defeatConditions.forEach((condition) => { if (!unitIds.has(condition.unitId)) { errors.push(`${context}: defeat condition references missing unit "${condition.unitId}"`); } }); scenario.objectives.forEach((objective) => { if (!objective.id || objectiveIds.has(objective.id)) { errors.push(`${context}: duplicate or missing objective id "${objective.id}"`); } objectiveIds.add(objective.id); assertNonEmptyString(errors, objective.label, `${context}/${objective.id}: objective label`); if (!Number.isFinite(objective.rewardGold) || objective.rewardGold < 0) { errors.push(`${context}/${objective.id}: invalid rewardGold ${objective.rewardGold}`); } if (objective.unitId && !unitIds.has(objective.unitId)) { errors.push(`${context}/${objective.id}: references missing unit "${objective.unitId}"`); } if (objective.targetTile && !isInBounds(objective.targetTile, scenario.map)) { errors.push(`${context}/${objective.id}: target tile ${objective.targetTile.x},${objective.targetTile.y} is outside map`); } }); } function validateOpeningObjectiveLines(errors, scenario, context) { if (!Array.isArray(scenario.openingObjectiveLines) || scenario.openingObjectiveLines.length === 0) { errors.push(`${context}: openingObjectiveLines must include at least one line`); return; } scenario.openingObjectiveLines.forEach((line, index) => { assertNonEmptyString(errors, line, `${context}: openingObjectiveLines[${index}]`); }); } function validateTacticalGuide(errors, scenario, eventKeys, context) { const guide = scenario.tacticalGuide; if (!guide) { errors.push(`${context}: tacticalGuide is required`); return; } assertNonEmptyString(errors, guide.summary, `${context}: tacticalGuide summary`); assertNonEmptyString(errors, guide.route, `${context}: tacticalGuide route`); assertNonEmptyString(errors, guide.focus, `${context}: tacticalGuide focus`); if (!Array.isArray(guide.roles) || guide.roles.length === 0) { errors.push(`${context}: tacticalGuide roles must include at least one role`); } else { guide.roles.forEach((role, index) => { assertNonEmptyString(errors, role, `${context}: tacticalGuide roles[${index}]`); }); } if (guide.events === undefined) { return; } if (!guide.events || typeof guide.events !== 'object' || Array.isArray(guide.events)) { errors.push(`${context}: tacticalGuide events must be an object`); return; } Object.entries(guide.events).forEach(([eventKey, event]) => { if (!eventKeys.has(eventKey)) { errors.push(`${context}: tacticalGuide event "${eventKey}" is not supported`); } if (!event || typeof event !== 'object' || Array.isArray(event)) { errors.push(`${context}: tacticalGuide event "${eventKey}" must be an object`); return; } assertNonEmptyString(errors, event.title, `${context}: tacticalGuide event "${eventKey}" title`); if (!Array.isArray(event.lines) || event.lines.length === 0) { errors.push(`${context}: tacticalGuide event "${eventKey}" must include at least one line`); return; } event.lines.forEach((line, index) => { assertNonEmptyString(errors, line, `${context}: tacticalGuide event "${eventKey}" lines[${index}]`); }); }); } function validateCharacterProgress(errors, unit, context) { if (!Number.isInteger(unit.level) || unit.level < 1) { errors.push(`${context}: invalid level ${unit.level}`); } if (!Number.isInteger(unit.exp) || unit.exp < 0) { errors.push(`${context}: invalid exp ${unit.exp}`); } else if (Number.isInteger(unit.level) && unit.level < 99 && unit.exp >= 100) { errors.push(`${context}: exp ${unit.exp} should be below 100 before max level`); } else if (Number.isInteger(unit.level) && unit.level >= 99 && unit.exp > 100) { errors.push(`${context}: max level exp ${unit.exp} should be at most 100`); } } function validateSortie(errors, scenario, classKeys, formationRoles, terrainRules, context) { const sortie = scenario.sortie; if (!sortie) { return; } const unitIds = new Set(scenario.units.map((unit) => unit.id)); const allyUnitIds = new Set(scenario.units.filter((unit) => unit.faction === 'ally').map((unit) => unit.id)); const enemyTiles = new Set(scenario.units.filter((unit) => unit.faction === 'enemy').map((unit) => `${unit.x},${unit.y}`)); if (!Number.isInteger(sortie.sortieLimit) || sortie.sortieLimit < 1) { errors.push(`${context}: invalid sortie limit ${sortie.sortieLimit}`); } if ((sortie.requiredUnits?.length ?? 0) > sortie.sortieLimit) { errors.push(`${context}: requiredUnits count exceeds sortieLimit`); } if ((sortie.recommendedUnits?.length ?? 0) > sortie.sortieLimit) { errors.push(`${context}: recommendedUnits count exceeds sortieLimit`); } const requiredUnits = sortie.requiredUnits ?? []; const excludedUnits = sortie.excludedUnits ?? []; const requiredUnitIds = new Set(); const excludedUnitIds = new Set(); requiredUnits.forEach((unitId) => { if (requiredUnitIds.has(unitId)) { errors.push(`${context}: duplicate required unit "${unitId}"`); } requiredUnitIds.add(unitId); }); scenario.defeatConditions.forEach((condition) => { if (allyUnitIds.has(condition.unitId) && !requiredUnitIds.has(condition.unitId)) { errors.push(`${context}: allied defeat-condition unit "${condition.unitId}" must be required for sortie`); } }); excludedUnits.forEach((unitId) => { if (excludedUnitIds.has(unitId)) { errors.push(`${context}: duplicate excluded unit "${unitId}"`); } excludedUnitIds.add(unitId); if (requiredUnitIds.has(unitId)) { errors.push(`${context}: unit "${unitId}" cannot be both required and excluded`); } }); [...requiredUnits, ...excludedUnits].forEach((unitId) => { if (!unitIds.has(unitId)) { errors.push(`${context}: sortie references missing unit "${unitId}"`); } else if (!allyUnitIds.has(unitId)) { errors.push(`${context}: sortie references non-allied unit "${unitId}"`); } }); const recommendedUnitIds = new Set(); const recommendedRoleByUnitId = new Map(); sortie.recommendedUnits.forEach((recommendation) => { if (recommendedUnitIds.has(recommendation.unitId)) { errors.push(`${context}: duplicate recommended unit "${recommendation.unitId}"`); } recommendedUnitIds.add(recommendation.unitId); if (!unitIds.has(recommendation.unitId)) { errors.push(`${context}: recommended unit "${recommendation.unitId}" is missing from units`); } else if (!allyUnitIds.has(recommendation.unitId)) { errors.push(`${context}: recommended unit "${recommendation.unitId}" is not allied`); } if (excludedUnitIds.has(recommendation.unitId)) { errors.push(`${context}: excluded unit "${recommendation.unitId}" cannot also be recommended`); } if (recommendation.role && !formationRoles.has(recommendation.role)) { errors.push(`${context}/${recommendation.unitId}: invalid recommendation role "${recommendation.role}"`); } if (recommendation.role) { recommendedRoleByUnitId.set(recommendation.unitId, recommendation.role); } assertNonEmptyString(errors, recommendation.reason, `${context}/${recommendation.unitId}: recommendation reason`); }); sortie.recommendedClasses.forEach((recommendation) => { assertNonEmptyString(errors, recommendation.label, `${context}: recommended class label`); assertNonEmptyString(errors, recommendation.reason, `${context}/${recommendation.label}: recommended class reason`); const recommendedClassKeys = Array.isArray(recommendation.classKeys) ? recommendation.classKeys : []; if (!Array.isArray(recommendation.classKeys) || recommendation.classKeys.length === 0) { errors.push(`${context}/${recommendation.label}: recommended class must include at least one classKey`); } const availableRecommendedUnits = scenario.units.filter( (unit) => unit.faction === 'ally' && !excludedUnitIds.has(unit.id) && recommendedClassKeys.includes(unit.classKey) ); if (availableRecommendedUnits.length === 0) { errors.push(`${context}/${recommendation.label}: recommended class has no available allied unit`); } recommendedClassKeys.forEach((classKey) => { if (!classKeys.has(classKey)) { errors.push(`${context}: recommended class "${recommendation.label}" references unknown classKey "${classKey}"`); } }); }); if (sortie.deploymentSlots.length !== sortie.sortieLimit) { errors.push( `${context}: deploymentSlots count ${sortie.deploymentSlots.length} does not match sortieLimit ${sortie.sortieLimit}` ); } const deploymentTiles = new Set(); const deploymentUnitIds = new Set(); sortie.deploymentSlots.forEach((slot) => { if (!isInBounds(slot, scenario.map)) { errors.push(`${context}: deployment slot ${slot.x},${slot.y} is outside map`); return; } if (slot.role && !formationRoles.has(slot.role)) { errors.push(`${context}: deployment slot ${slot.x},${slot.y} has invalid role "${slot.role}"`); } const tileKey = `${slot.x},${slot.y}`; const terrain = scenario.map.terrain[slot.y][slot.x]; if (terrainRules[terrain]?.passable === false) { errors.push(`${context}: deployment slot ${tileKey} is on impassable terrain "${terrain}"`); } if (enemyTiles.has(tileKey)) { errors.push(`${context}: deployment slot ${tileKey} overlaps an enemy unit`); } if (deploymentTiles.has(tileKey)) { errors.push(`${context}: duplicate deployment slot ${tileKey}`); } deploymentTiles.add(tileKey); if (slot.unitId && !unitIds.has(slot.unitId)) { errors.push(`${context}: deployment slot references missing unit "${slot.unitId}"`); } else if (slot.unitId && !allyUnitIds.has(slot.unitId)) { errors.push(`${context}: deployment slot references non-allied unit "${slot.unitId}"`); } if (slot.unitId && excludedUnitIds.has(slot.unitId)) { errors.push(`${context}: excluded unit "${slot.unitId}" cannot have a deployment slot`); } if (slot.unitId && !requiredUnitIds.has(slot.unitId) && !recommendedUnitIds.has(slot.unitId)) { errors.push(`${context}: deployment slot unit "${slot.unitId}" must be required or recommended`); } const recommendedRole = slot.unitId ? recommendedRoleByUnitId.get(slot.unitId) : undefined; if (slot.role && recommendedRole && slot.role !== recommendedRole) { errors.push( `${context}: deployment slot unit "${slot.unitId}" role "${slot.role}" does not match recommendation role "${recommendedRole}"` ); } if (slot.unitId) { if (deploymentUnitIds.has(slot.unitId)) { errors.push(`${context}: duplicate deployment unit slot "${slot.unitId}"`); } deploymentUnitIds.add(slot.unitId); } }); requiredUnits.forEach((unitId) => { if (!deploymentUnitIds.has(unitId)) { errors.push(`${context}: required unit "${unitId}" must have an explicit deployment slot`); } }); recommendedUnitIds.forEach((unitId) => { if (!deploymentUnitIds.has(unitId)) { errors.push(`${context}: recommended unit "${unitId}" must have an explicit deployment slot`); } }); } function validateRewards(errors, scenario, scenarioIds, itemNames, knownRewardUnitIds, context) { validateRewardLabels(errors, scenario.itemRewards, `${context}: itemRewards`); const campaignReward = scenario.campaignReward; if (campaignReward) { validateRewardLabels(errors, campaignReward.supplies ?? [], `${context}: campaignReward.supplies`); validateRewardLabels(errors, campaignReward.equipment ?? [], `${context}: campaignReward.equipment`); validateRewardLabels(errors, campaignReward.reputation ?? [], `${context}: campaignReward.reputation`, { allowSignedAmount: true }); validateRecruitRewards(errors, campaignReward.recruits ?? [], knownRewardUnitIds, context); validateEquipmentRewardLabels(errors, campaignReward.equipment ?? [], itemNames, context); if (campaignReward.unlockLabel !== undefined) { assertNonEmptyString(errors, campaignReward.unlockLabel, `${context}: campaignReward unlockLabel`); } if (campaignReward.note !== undefined) { assertNonEmptyString(errors, campaignReward.note, `${context}: campaignReward note`); } } const unlockBattleId = campaignReward?.unlockBattleId; if (unlockBattleId && !scenarioIds.has(unlockBattleId)) { errors.push(`${context}: campaignReward unlockBattleId "${unlockBattleId}" is not a known battle`); } } function validateRewardLabels(errors, rewards, context, options = {}) { if (!Array.isArray(rewards)) { errors.push(`${context} must be an array`); return; } const labels = new Set(); rewards.forEach((reward, index) => { assertNonEmptyString(errors, reward, `${context}[${index}]`); if (typeof reward !== 'string') { return; } const parsed = parseRewardLabel(reward); if (!parsed.label) { errors.push(`${context}[${index}] has an empty parsed label`); } if (!Number.isInteger(parsed.amount) || parsed.amount < 1) { if (!options.allowSignedAmount || !Number.isInteger(parsed.amount) || parsed.amount === 0) { errors.push(`${context}[${index}] has invalid amount ${parsed.amount}`); } } if (labels.has(parsed.label)) { errors.push(`${context}: duplicate reward label "${parsed.label}"`); } labels.add(parsed.label); }); } function validateRecruitRewards(errors, recruits, knownRewardUnitIds, context) { const recruitIds = new Set(); recruits.forEach((unitId, index) => { assertNonEmptyString(errors, unitId, `${context}: campaignReward.recruits[${index}]`); if (typeof unitId !== 'string') { return; } if (!knownRewardUnitIds.has(unitId)) { errors.push(`${context}: campaignReward recruit "${unitId}" is not a known battle or campaign recruit unit`); } if (recruitIds.has(unitId)) { errors.push(`${context}: duplicate campaignReward recruit "${unitId}"`); } recruitIds.add(unitId); }); } function validateEquipmentRewardLabels(errors, equipmentRewards, itemNames, context) { equipmentRewards.forEach((reward) => { const { label } = parseRewardLabel(reward); if (itemNames.has(label) || looksLikeCampaignRecord(label)) { return; } errors.push(`${context}: campaignReward equipment "${reward}" is neither an equipment catalog item nor a campaign record/map label`); }); } function parseRewardLabel(reward) { const normalized = String(reward).replace(/\s+/g, ' ').trim(); const match = normalized.match(/^(.*?)(?:\s*[+xX]?\s*(\d+))$/); if (!match) { return { label: normalized, amount: 1 }; } return { label: match[1].trim() || normalized, amount: Number(match[2] ?? 1) }; } function looksLikeCampaignRecord(label) { return /기록|문서|장부|지도|표식|서찰|명단|명부|초안|부적|군령패|통행문서|방화선|말뚝|항복문|물길|병법서|봉화도|외곽도|수위표|성문도|정찰|진군|퇴각|회유|서약|고시문|길잡이|소문|풍문|논의|준비|명성|공적|민심|신뢰|명분|예우|공훈/.test(label); } function campaignRewardLabelCount(campaignReward) { if (!campaignReward) { return 0; } return ( (campaignReward.supplies?.length ?? 0) + (campaignReward.equipment?.length ?? 0) + (campaignReward.reputation?.length ?? 0) + (campaignReward.recruits?.length ?? 0) + (campaignReward.unlockBattleId ? 1 : 0) ); } function isInBounds(point, map) { return ( Number.isInteger(point.x) && Number.isInteger(point.y) && point.x >= 0 && point.y >= 0 && point.x < map.width && point.y < map.height ); } function assertNonEmptyString(errors, value, context) { if (typeof value !== 'string' || value.trim().length === 0) { errors.push(`${context} must be a non-empty string`); } }