feat: add tactical sortie recommendations
This commit is contained in:
439
scripts/verify-sortie-recommendations.mjs
Normal file
439
scripts/verify-sortie-recommendations.mjs
Normal file
@@ -0,0 +1,439 @@
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const server = await createServer({
|
||||
logLevel: 'error',
|
||||
server: { middlewareMode: true, hmr: false },
|
||||
appType: 'custom'
|
||||
});
|
||||
|
||||
try {
|
||||
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
|
||||
const { terrainRules, unitClasses } = await server.ssrLoadModule('/src/game/data/battleRules.ts');
|
||||
const {
|
||||
buildSortieRecommendationPlans,
|
||||
sortieRecommendationPlanIds
|
||||
} = await server.ssrLoadModule('/src/game/data/sortieRecommendations.ts');
|
||||
const { sortieFormationRoles } = await server.ssrLoadModule('/src/game/data/sortieDeployment.ts');
|
||||
const { sortieOrderIds } = await server.ssrLoadModule('/src/game/data/sortieOrders.ts');
|
||||
|
||||
assert(
|
||||
JSON.stringify(sortieRecommendationPlanIds) === JSON.stringify(['order', 'resonance', 'terrain-strategy']),
|
||||
`Expected the three recommendation profiles to retain their stable order: ${JSON.stringify(sortieRecommendationPlanIds)}`
|
||||
);
|
||||
|
||||
const scenarioEntries = Object.entries(battleScenarios);
|
||||
const formationRoles = new Set(sortieFormationRoles);
|
||||
const differentiatedProfileBattleIds = new Set();
|
||||
const differentiatedOrderBattleIds = new Set();
|
||||
let evaluatedPlanCount = 0;
|
||||
|
||||
scenarioEntries.forEach(([battleId, scenario], battleIndex) => {
|
||||
assert(scenario.sortie, `${battleId}: expected sortie data before recommendation verification`);
|
||||
const profileSignaturesByOrder = new Map();
|
||||
const orderPlanSignatures = new Set();
|
||||
|
||||
sortieOrderIds.forEach((orderId) => {
|
||||
const context = buildScenarioContext(scenario, orderId, terrainRules, unitClasses);
|
||||
const frozenInput = JSON.stringify(context);
|
||||
const plans = buildSortieRecommendationPlans(context);
|
||||
const repeated = buildSortieRecommendationPlans(context);
|
||||
|
||||
assert(
|
||||
JSON.stringify(plans) === JSON.stringify(repeated),
|
||||
`${battleId}/${orderId}: repeated recommendation calculation must be deterministic`
|
||||
);
|
||||
assert(
|
||||
JSON.stringify(context) === frozenInput,
|
||||
`${battleId}/${orderId}: recommendation calculation must not mutate its context`
|
||||
);
|
||||
assert(
|
||||
JSON.stringify(plans.map((plan) => plan.id)) === JSON.stringify(sortieRecommendationPlanIds),
|
||||
`${battleId}/${orderId}: expected one plan for every stable recommendation id: ${JSON.stringify(plans)}`
|
||||
);
|
||||
|
||||
plans.forEach((plan) => {
|
||||
validateApplicablePlan({
|
||||
plan,
|
||||
battleId,
|
||||
orderId,
|
||||
context,
|
||||
formationRoles,
|
||||
expectedCount: scenario.sortie.sortieLimit
|
||||
});
|
||||
evaluatedPlanCount += 1;
|
||||
});
|
||||
|
||||
profileSignaturesByOrder.set(orderId, new Set(plans.map(planProjectionSignature)));
|
||||
orderPlanSignatures.add(planProjectionSignature(plans.find((plan) => plan.id === 'order')));
|
||||
});
|
||||
|
||||
if ([...profileSignaturesByOrder.values()].some((signatures) => signatures.size > 1)) {
|
||||
differentiatedProfileBattleIds.add(battleId);
|
||||
}
|
||||
if (orderPlanSignatures.size > 1) {
|
||||
differentiatedOrderBattleIds.add(battleId);
|
||||
}
|
||||
|
||||
const alliedCount = scenario.units.filter((unit) => unit.faction === 'ally').length;
|
||||
if (alliedCount <= scenario.sortie.sortieLimit) {
|
||||
assert(
|
||||
battleIndex < 15,
|
||||
`${battleId}: a no-choice roster unexpectedly appeared after the established first 15 battles`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
assert(
|
||||
differentiatedProfileBattleIds.size > 0,
|
||||
'Expected at least one campaign battle to produce materially different profile projections.'
|
||||
);
|
||||
assert(
|
||||
differentiatedOrderBattleIds.size > 0,
|
||||
'Expected elite, mobile, and siege orders to change at least one order-profile projection.'
|
||||
);
|
||||
|
||||
const fixtureScenario = battleScenarios['sixtieth-battle-wudu-yinping'];
|
||||
assert(fixtureScenario?.sortie, 'Expected the Wudu/Yinping battle to remain available as the broad-roster fixture.');
|
||||
const fixtureContext = buildScenarioContext(fixtureScenario, 'mobile', terrainRules, unitClasses);
|
||||
|
||||
verifyRosterIndexDeterminism(fixtureContext, buildSortieRecommendationPlans);
|
||||
verifyStrongBondPairSeed(fixtureContext, buildSortieRecommendationPlans);
|
||||
verifyUnavailableOptionalCandidates(fixtureContext, buildSortieRecommendationPlans, formationRoles);
|
||||
verifyUnavailableRequiredCandidate(fixtureContext, buildSortieRecommendationPlans);
|
||||
verifyRequiredExcludedConflict(fixtureContext, buildSortieRecommendationPlans);
|
||||
verifyRequiredOverflow(fixtureContext, buildSortieRecommendationPlans);
|
||||
verifyDuplicateAndNonAllyCandidates(fixtureContext, buildSortieRecommendationPlans, formationRoles);
|
||||
verifyMissingOrderFallback(fixtureContext, buildSortieRecommendationPlans, formationRoles);
|
||||
|
||||
console.log(
|
||||
`Verified ${evaluatedPlanCount} recommendation plans across ${scenarioEntries.length} battles and ${sortieOrderIds.length} orders; ` +
|
||||
`${differentiatedProfileBattleIds.size} battles vary by profile and ${differentiatedOrderBattleIds.size} vary by selected order, with synthetic availability, required, exclusion, duplicate, and fallback cases.`
|
||||
);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
function buildScenarioContext(scenario, orderId, terrainRules, unitClasses) {
|
||||
const sortie = scenario.sortie;
|
||||
const excludedUnitIds = sortie.excludedUnits ?? [];
|
||||
const excluded = new Set(excludedUnitIds);
|
||||
const recommendationById = new Map(sortie.recommendedUnits.map((entry) => [entry.unitId, entry]));
|
||||
const candidates = scenario.units
|
||||
.filter((unit) => unit.faction === 'ally')
|
||||
.map((unit, rosterIndex) => ({
|
||||
unit,
|
||||
available: unit.hp > 0 && !excluded.has(unit.id),
|
||||
terrainScore: terrainScoreForUnit(unit, scenario, terrainRules, unitClasses),
|
||||
preferredRole: recommendationById.get(unit.id)?.role,
|
||||
rosterIndex
|
||||
}));
|
||||
|
||||
return {
|
||||
battleId: scenario.id,
|
||||
maxUnits: sortie.sortieLimit,
|
||||
orderId,
|
||||
candidates,
|
||||
requiredUnitIds: sortie.requiredUnits,
|
||||
excludedUnitIds,
|
||||
recommendedUnits: sortie.recommendedUnits,
|
||||
recommendedClasses: sortie.recommendedClasses,
|
||||
bonds: scenario.bonds
|
||||
};
|
||||
}
|
||||
|
||||
function terrainScoreForUnit(unit, scenario, terrainRules, unitClasses) {
|
||||
const counts = new Map();
|
||||
scenario.map.terrain.flat().forEach((terrain) => {
|
||||
if (terrainRules[terrain]?.passable !== false) {
|
||||
counts.set(terrain, (counts.get(terrain) ?? 0) + 1);
|
||||
}
|
||||
});
|
||||
const total = [...counts.values()].reduce((sum, count) => sum + count, 0);
|
||||
const ratings = unitClasses[unit.classKey]?.terrainRatings ?? {};
|
||||
return total > 0
|
||||
? Math.round([...counts.entries()].reduce((sum, [terrain, count]) => sum + (ratings[terrain] ?? 0) * count, 0) / total)
|
||||
: 100;
|
||||
}
|
||||
|
||||
function validateApplicablePlan({ plan, battleId, orderId, context, formationRoles, expectedCount }) {
|
||||
const candidateById = new Map(
|
||||
context.candidates
|
||||
.filter((candidate) => candidate.available !== false && candidate.unit.faction === 'ally' && candidate.unit.hp > 0)
|
||||
.filter((candidate) => !(context.excludedUnitIds ?? []).includes(candidate.unit.id))
|
||||
.map((candidate) => [candidate.unit.id, candidate])
|
||||
);
|
||||
const selected = new Set(plan.selectedUnitIds);
|
||||
const assignmentIds = Object.keys(plan.formationAssignments).sort();
|
||||
const selectedSorted = [...selected].sort();
|
||||
|
||||
assert(plan.applicable === true, `${battleId}/${orderId}/${plan.id}: expected an applicable plan: ${JSON.stringify(plan)}`);
|
||||
assert(!plan.blockedReason, `${battleId}/${orderId}/${plan.id}: applicable plan must not have a blocked reason`);
|
||||
assert(
|
||||
plan.selectedUnitIds.length === expectedCount && selected.size === expectedCount,
|
||||
`${battleId}/${orderId}/${plan.id}: expected ${expectedCount} unique selected units: ${JSON.stringify(plan.selectedUnitIds)}`
|
||||
);
|
||||
assert(
|
||||
plan.selectedUnitIds.every((unitId) => candidateById.has(unitId)),
|
||||
`${battleId}/${orderId}/${plan.id}: selected an unavailable, excluded, injured, non-allied, or unknown unit: ${JSON.stringify(plan.selectedUnitIds)}`
|
||||
);
|
||||
assert(
|
||||
(context.requiredUnitIds ?? []).every((unitId) => selected.has(unitId)),
|
||||
`${battleId}/${orderId}/${plan.id}: omitted a required unit: ${JSON.stringify({ required: context.requiredUnitIds, selected: plan.selectedUnitIds })}`
|
||||
);
|
||||
assert(
|
||||
JSON.stringify(assignmentIds) === JSON.stringify(selectedSorted),
|
||||
`${battleId}/${orderId}/${plan.id}: formation assignments must match selected ids exactly: ${JSON.stringify(plan.formationAssignments)}`
|
||||
);
|
||||
assert(
|
||||
Object.values(plan.formationAssignments).every((role) => formationRoles.has(role)),
|
||||
`${battleId}/${orderId}/${plan.id}: formation contains an invalid role: ${JSON.stringify(plan.formationAssignments)}`
|
||||
);
|
||||
assert(
|
||||
Number.isInteger(plan.score) && plan.score >= 0 && plan.score <= 100,
|
||||
`${battleId}/${orderId}/${plan.id}: score must be an integer from 0 to 100: ${plan.score}`
|
||||
);
|
||||
assertNonEmptyText(plan.label, `${battleId}/${orderId}/${plan.id}: label`);
|
||||
assertNonEmptyText(plan.title, `${battleId}/${orderId}/${plan.id}: title`);
|
||||
assertNonEmptyText(plan.summary, `${battleId}/${orderId}/${plan.id}: summary`);
|
||||
assertNonEmptyText(plan.tradeoff, `${battleId}/${orderId}/${plan.id}: tradeoff`);
|
||||
assert(
|
||||
Array.isArray(plan.highlights) && plan.highlights.length >= 2 && plan.highlights.every(isNonEmptyText),
|
||||
`${battleId}/${orderId}/${plan.id}: expected at least two non-empty highlights: ${JSON.stringify(plan.highlights)}`
|
||||
);
|
||||
assert(
|
||||
JSON.stringify(Object.keys(plan.unitReasons).sort()) === JSON.stringify(selectedSorted) &&
|
||||
Object.values(plan.unitReasons).every(isNonEmptyText),
|
||||
`${battleId}/${orderId}/${plan.id}: expected one reason for every selected unit: ${JSON.stringify(plan.unitReasons)}`
|
||||
);
|
||||
validateMetrics(plan.metrics, orderId, `${battleId}/${orderId}/${plan.id}`);
|
||||
}
|
||||
|
||||
function validateMetrics(metrics, orderId, context) {
|
||||
assert(metrics.orderId === orderId, `${context}: metrics must retain selected order ${orderId}: ${JSON.stringify(metrics)}`);
|
||||
assert(inRange(metrics.orderFit, 0, 100), `${context}: orderFit must be within 0..100: ${metrics.orderFit}`);
|
||||
assert(Number.isInteger(metrics.activeBondCount) && metrics.activeBondCount >= 0, `${context}: invalid active bond count`);
|
||||
assert(Number.isInteger(metrics.pursuitPairCount) && metrics.pursuitPairCount >= 0, `${context}: invalid pursuit pair count`);
|
||||
assert(Number.isFinite(metrics.terrainScore) && metrics.terrainScore > 0, `${context}: invalid terrain score ${metrics.terrainScore}`);
|
||||
assertNonEmptyText(metrics.terrainGrade, `${context}: terrain grade`);
|
||||
assert(
|
||||
Number.isInteger(metrics.strategyCoverageCount) &&
|
||||
Number.isInteger(metrics.strategyCoverageTotal) &&
|
||||
metrics.strategyCoverageCount >= 0 &&
|
||||
metrics.strategyCoverageCount <= metrics.strategyCoverageTotal,
|
||||
`${context}: invalid strategy coverage: ${JSON.stringify(metrics)}`
|
||||
);
|
||||
assert(
|
||||
metrics.coveredStrategyLabels.length === metrics.strategyCoverageCount &&
|
||||
metrics.coveredStrategyLabels.length + metrics.missingStrategyLabels.length === metrics.strategyCoverageTotal,
|
||||
`${context}: strategy labels must match coverage counts: ${JSON.stringify(metrics)}`
|
||||
);
|
||||
assert(Number.isInteger(metrics.roleCoverage) && inRange(metrics.roleCoverage, 0, 3), `${context}: invalid role coverage`);
|
||||
assert(inRange(metrics.recommendedClassCoverage, 0, 100), `${context}: invalid recommended-class coverage`);
|
||||
}
|
||||
|
||||
function verifyUnavailableOptionalCandidates(base, buildPlans, formationRoles) {
|
||||
const required = new Set(base.requiredUnitIds);
|
||||
const optionalIds = base.candidates.filter((candidate) => !required.has(candidate.unit.id)).slice(0, 2).map((candidate) => candidate.unit.id);
|
||||
const [injuredId, excludedId] = optionalIds;
|
||||
assert(injuredId && excludedId, 'Expected two optional units for the availability fixture.');
|
||||
const context = {
|
||||
...base,
|
||||
candidates: base.candidates.map((candidate) => candidate.unit.id === injuredId
|
||||
? { ...candidate, unit: { ...candidate.unit, hp: 0 }, available: false, blockedReason: '부상' }
|
||||
: candidate),
|
||||
excludedUnitIds: [...(base.excludedUnitIds ?? []), excludedId]
|
||||
};
|
||||
buildPlans(context).forEach((plan) => {
|
||||
validateApplicablePlan({
|
||||
plan,
|
||||
battleId: 'fixture-optional-unavailable',
|
||||
orderId: base.orderId,
|
||||
context,
|
||||
formationRoles,
|
||||
expectedCount: base.maxUnits
|
||||
});
|
||||
assert(
|
||||
!plan.selectedUnitIds.includes(injuredId) && !plan.selectedUnitIds.includes(excludedId),
|
||||
`Optional injured/excluded units must be omitted: ${JSON.stringify(plan.selectedUnitIds)}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function verifyRosterIndexDeterminism(base, buildPlans) {
|
||||
const forward = buildPlans(base);
|
||||
const reversedCandidates = {
|
||||
...base,
|
||||
candidates: [...base.candidates].reverse()
|
||||
};
|
||||
const reversed = buildPlans(reversedCandidates);
|
||||
assert(
|
||||
JSON.stringify(reversed) === JSON.stringify(forward),
|
||||
`Explicit rosterIndex values must make recommendations independent of candidate array order: ${JSON.stringify({
|
||||
forward: forward.map(planProjectionSignature),
|
||||
reversed: reversed.map(planProjectionSignature)
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
function verifyStrongBondPairSeed(base, buildPlans) {
|
||||
const source = base.candidates[0];
|
||||
const candidate = (unitId, rosterIndex, terrainScore) => ({
|
||||
...source,
|
||||
unit: {
|
||||
...source.unit,
|
||||
id: unitId,
|
||||
name: unitId,
|
||||
hp: source.unit.maxHp
|
||||
},
|
||||
terrainScore,
|
||||
rosterIndex
|
||||
});
|
||||
const bondUnitIds = ['synthetic-bond-a', 'synthetic-bond-b'];
|
||||
const terrainUnitIds = ['synthetic-terrain-c', 'synthetic-terrain-d'];
|
||||
const context = {
|
||||
battleId: 'fixture-strong-bond-pair-seed',
|
||||
maxUnits: 2,
|
||||
orderId: 'elite',
|
||||
candidates: [
|
||||
candidate(bondUnitIds[0], 0, 100),
|
||||
candidate(bondUnitIds[1], 1, 100),
|
||||
candidate(terrainUnitIds[0], 2, 106),
|
||||
candidate(terrainUnitIds[1], 3, 106)
|
||||
],
|
||||
requiredUnitIds: [],
|
||||
excludedUnitIds: [],
|
||||
recommendedUnits: [],
|
||||
recommendedClasses: [],
|
||||
bonds: [
|
||||
{
|
||||
id: 'synthetic-level-100-bond',
|
||||
unitIds: bondUnitIds,
|
||||
title: '강한 합성 공명',
|
||||
level: 100,
|
||||
exp: 0
|
||||
}
|
||||
]
|
||||
};
|
||||
const plans = buildPlans(context);
|
||||
const resonance = plans.find((plan) => plan.id === 'resonance');
|
||||
const terrain = plans.find((plan) => plan.id === 'terrain-strategy');
|
||||
assert(
|
||||
resonance?.applicable === true &&
|
||||
resonance.metrics.activeBondCount === 1 &&
|
||||
bondUnitIds.every((unitId) => resonance.selectedUnitIds.includes(unitId)),
|
||||
`The resonance profile must seed both ends of a strong bond instead of getting trapped by one-member greedy additions: ${JSON.stringify(resonance)}`
|
||||
);
|
||||
assert(
|
||||
terrain?.applicable === true && terrainUnitIds.every((unitId) => terrain.selectedUnitIds.includes(unitId)),
|
||||
`The control profile must still prefer the strictly stronger unbonded terrain pair: ${JSON.stringify(terrain)}`
|
||||
);
|
||||
}
|
||||
|
||||
function verifyUnavailableRequiredCandidate(base, buildPlans) {
|
||||
const requiredId = base.requiredUnitIds[0];
|
||||
const context = {
|
||||
...base,
|
||||
candidates: base.candidates.map((candidate) => candidate.unit.id === requiredId
|
||||
? { ...candidate, unit: { ...candidate.unit, hp: 0 }, available: false, blockedReason: '부상' }
|
||||
: candidate)
|
||||
};
|
||||
validateBlockedPlans(buildPlans(context), requiredId, 'injured required unit');
|
||||
}
|
||||
|
||||
function verifyRequiredExcludedConflict(base, buildPlans) {
|
||||
const requiredId = base.requiredUnitIds[0];
|
||||
const context = { ...base, excludedUnitIds: [...(base.excludedUnitIds ?? []), requiredId] };
|
||||
validateBlockedPlans(buildPlans(context), requiredId, 'required/excluded conflict');
|
||||
}
|
||||
|
||||
function verifyRequiredOverflow(base, buildPlans) {
|
||||
const requiredUnitIds = base.candidates.slice(0, 3).map((candidate) => candidate.unit.id);
|
||||
const context = { ...base, maxUnits: 2, requiredUnitIds };
|
||||
validateBlockedPlans(buildPlans(context), '출전 한도 2명', 'required overflow');
|
||||
}
|
||||
|
||||
function verifyDuplicateAndNonAllyCandidates(base, buildPlans, formationRoles) {
|
||||
const duplicate = base.candidates[1];
|
||||
const enemySource = base.candidates[2];
|
||||
const enemyId = 'synthetic-enemy-candidate';
|
||||
const context = {
|
||||
...base,
|
||||
candidates: [
|
||||
duplicate,
|
||||
...base.candidates,
|
||||
{ ...enemySource, unit: { ...enemySource.unit, id: enemyId, faction: 'enemy' }, rosterIndex: 999 }
|
||||
]
|
||||
};
|
||||
buildPlans(context).forEach((plan) => {
|
||||
validateApplicablePlan({
|
||||
plan,
|
||||
battleId: 'fixture-duplicate-non-ally',
|
||||
orderId: base.orderId,
|
||||
context,
|
||||
formationRoles,
|
||||
expectedCount: base.maxUnits
|
||||
});
|
||||
assert(!plan.selectedUnitIds.includes(enemyId), `Non-allied candidate must be omitted: ${JSON.stringify(plan.selectedUnitIds)}`);
|
||||
});
|
||||
}
|
||||
|
||||
function verifyMissingOrderFallback(base, buildPlans, formationRoles) {
|
||||
const context = { ...base };
|
||||
delete context.orderId;
|
||||
const plans = buildPlans(context);
|
||||
plans.forEach((plan) => {
|
||||
validateApplicablePlan({
|
||||
plan,
|
||||
battleId: 'fixture-order-fallback',
|
||||
orderId: undefined,
|
||||
context,
|
||||
formationRoles,
|
||||
expectedCount: base.maxUnits
|
||||
});
|
||||
});
|
||||
const orderPlan = plans.find((plan) => plan.id === 'order');
|
||||
assert(
|
||||
orderPlan.tradeoff.includes('군령 미선택') && orderPlan.highlights.some((line) => line.includes('미선택')),
|
||||
`Order fallback must explain its balanced calculation: ${JSON.stringify(orderPlan)}`
|
||||
);
|
||||
}
|
||||
|
||||
function validateBlockedPlans(plans, expectedReasonFragment, label) {
|
||||
assert(
|
||||
JSON.stringify(plans.map((plan) => plan.id)) === JSON.stringify(['order', 'resonance', 'terrain-strategy']),
|
||||
`${label}: blocked result must retain all three stable cards: ${JSON.stringify(plans)}`
|
||||
);
|
||||
plans.forEach((plan) => {
|
||||
assert(
|
||||
plan.applicable === false &&
|
||||
plan.selectedUnitIds.length === 0 &&
|
||||
Object.keys(plan.formationAssignments).length === 0 &&
|
||||
plan.score === 0 &&
|
||||
plan.blockedReason?.includes(expectedReasonFragment) &&
|
||||
plan.summary === plan.blockedReason,
|
||||
`${label}/${plan.id}: expected a safe blocked plan: ${JSON.stringify(plan)}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function planProjectionSignature(plan) {
|
||||
return JSON.stringify({ selectedUnitIds: plan.selectedUnitIds, formationAssignments: plan.formationAssignments });
|
||||
}
|
||||
|
||||
function inRange(value, minimum, maximum) {
|
||||
return Number.isFinite(value) && value >= minimum && value <= maximum;
|
||||
}
|
||||
|
||||
function isNonEmptyText(value) {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function assertNonEmptyText(value, context) {
|
||||
assert(isNonEmptyText(value), `${context} must be a non-empty string: ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ const checks = [
|
||||
'scripts/verify-campaign-save-normalization.mjs',
|
||||
'scripts/verify-battle-save-normalization.mjs',
|
||||
'scripts/verify-sortie-synergy.mjs',
|
||||
'scripts/verify-sortie-recommendations.mjs',
|
||||
'scripts/verify-battle-scenario-data.mjs',
|
||||
'scripts/verify-camp-reward-data.mjs',
|
||||
'scripts/verify-camp-skin-data.mjs',
|
||||
|
||||
Reference in New Issue
Block a user