feat: add signature officer tactics

This commit is contained in:
2026-07-13 07:36:39 +09:00
parent 26a4d78185
commit cd1abbe815
11 changed files with 535 additions and 44 deletions

View File

@@ -0,0 +1,240 @@
import { createServer } from 'vite';
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true, hmr: false },
appType: 'custom'
});
try {
const {
getUnitSignatureStrategy,
getUnitStrategies,
getUnitStrategyCoverage,
strategyCoverageDefinitions,
unitStrategyIds,
usableCatalog
} = await server.ssrLoadModule('/src/game/data/battleUsables.ts');
const signatures = [
{
ownerId: 'liu-bei',
usableId: 'benevolentCommand',
name: '인덕의 호령',
target: 'ally',
effect: 'focus',
range: 3,
power: 0,
attackBonus: 2,
hitBonus: 10,
duration: 2,
accuracyBonus: 0,
criticalBonus: 4,
statusEffect: null,
statusDuration: 0,
statusPower: 0,
coverageIds: ['buff'],
expectedStrategies: ['aid', 'benevolentCommand']
},
{
ownerId: 'guan-yu',
usableId: 'azureDragonStrike',
name: '청룡일섬',
target: 'enemy',
effect: 'damage',
range: 1,
power: 13,
accuracyBonus: 6,
criticalBonus: 10,
statusEffect: null,
statusDuration: 0,
statusPower: 0,
coverageIds: ['offense'],
expectedStrategies: ['azureDragonStrike']
},
{
ownerId: 'zhang-fei',
usableId: 'changbanRoar',
name: '장판교 대갈',
target: 'enemy',
effect: 'damage',
range: 2,
power: 8,
accuracyBonus: 12,
criticalBonus: 0,
statusEffect: 'confusion',
statusDuration: 1,
statusPower: 1,
coverageIds: ['offense', 'control'],
expectedStrategies: ['changbanRoar']
},
{
ownerId: 'zhao-yun',
usableId: 'singleRiderRescue',
name: '단기구원',
target: 'ally',
effect: 'heal',
range: 3,
power: 12,
accuracyBonus: 0,
criticalBonus: 0,
statusEffect: null,
statusDuration: 0,
statusPower: 0,
coverageIds: ['healing'],
expectedStrategies: ['singleRiderRescue']
},
{
ownerId: 'zhuge-liang',
usableId: 'eastWindFire',
name: '동남풍 화계',
target: 'enemy',
effect: 'damage',
range: 3,
power: 11,
accuracyBonus: 6,
criticalBonus: 0,
statusEffect: 'burn',
statusDuration: 3,
statusPower: 3,
coverageIds: ['offense'],
expectedStrategies: ['aid', 'encourage', 'eastWindFire']
},
{
ownerId: 'huang-zhong',
usableId: 'hundredPacePierce',
name: '백보천양',
target: 'enemy',
effect: 'damage',
range: 4,
power: 11,
accuracyBonus: 18,
criticalBonus: 12,
statusEffect: null,
statusDuration: 0,
statusPower: 0,
coverageIds: ['offense'],
expectedStrategies: ['hundredPacePierce']
},
{
ownerId: 'ma-chao',
usableId: 'westernCavalryCharge',
name: '서량철기',
target: 'enemy',
effect: 'damage',
range: 2,
power: 11,
accuracyBonus: 8,
criticalBonus: 6,
statusEffect: 'confusion',
statusDuration: 1,
statusPower: 1,
coverageIds: ['offense', 'control'],
expectedStrategies: ['westernCavalryCharge', 'encourage']
},
{
ownerId: 'jiang-wei',
usableId: 'qilinStratagem',
name: '기린지계',
target: 'enemy',
effect: 'damage',
range: 3,
power: 10,
accuracyBonus: 10,
criticalBonus: 2,
statusEffect: 'confusion',
statusDuration: 2,
statusPower: 1,
coverageIds: ['offense', 'control'],
expectedStrategies: ['aid', 'encourage', 'qilinStratagem']
}
];
assert(
JSON.stringify(strategyCoverageDefinitions.map(({ id, label }) => ({ id, label }))) === JSON.stringify([
{ id: 'healing', label: '회복' },
{ id: 'buff', label: '강화' },
{ id: 'offense', label: '공격' },
{ id: 'control', label: '제어' }
]),
`Unexpected strategy coverage contract: ${JSON.stringify(strategyCoverageDefinitions)}`
);
const signatureNames = new Set();
signatures.forEach((expected) => {
const usable = usableCatalog[expected.usableId];
assert(usable, `Missing signature strategy ${expected.usableId}.`);
assert(usable.id === expected.usableId, `Catalog key/id mismatch for ${expected.usableId}: ${usable.id}`);
assert(usable.command === 'strategy', `Signature strategy must use the strategy command: ${expected.usableId}`);
assert(usable.signatureOwnerId === expected.ownerId, `Unexpected signature owner for ${expected.usableId}: ${usable.signatureOwnerId}`);
assert(usable.name === expected.name, `Unexpected signature name for ${expected.usableId}: ${usable.name}`);
assert(usable.target === expected.target && usable.effect === expected.effect, `Unexpected target/effect for ${expected.usableId}.`);
assert(usable.range === expected.range && usable.power === expected.power, `Unexpected range/power for ${expected.usableId}.`);
assert((usable.attackBonus ?? 0) === (expected.attackBonus ?? 0), `Unexpected attack bonus for ${expected.usableId}.`);
assert((usable.hitBonus ?? 0) === (expected.hitBonus ?? 0), `Unexpected hit bonus for ${expected.usableId}.`);
assert((usable.duration ?? 0) === (expected.duration ?? 0), `Unexpected effect duration for ${expected.usableId}.`);
assert((usable.accuracyBonus ?? 0) === expected.accuracyBonus, `Unexpected accuracy bonus for ${expected.usableId}.`);
assert((usable.criticalBonus ?? 0) === expected.criticalBonus, `Unexpected critical bonus for ${expected.usableId}.`);
assert((usable.statusEffect ?? null) === expected.statusEffect, `Unexpected status for ${expected.usableId}.`);
assert((usable.statusDuration ?? 0) === expected.statusDuration, `Unexpected status duration for ${expected.usableId}.`);
assert((usable.statusPower ?? 0) === expected.statusPower, `Unexpected status power for ${expected.usableId}.`);
assert(JSON.stringify(usable.coverageIds ?? []) === JSON.stringify(expected.coverageIds), `Unexpected coverage for ${expected.usableId}.`);
assert(typeof usable.description === 'string' && usable.description.trim().length >= 20, `Signature strategy needs a meaningful description: ${expected.usableId}`);
assert(!signatureNames.has(usable.name), `Duplicate signature strategy name: ${usable.name}`);
signatureNames.add(usable.name);
assert(
JSON.stringify(unitStrategyIds[expected.ownerId]) === JSON.stringify(expected.expectedStrategies),
`Unexpected strategy loadout for ${expected.ownerId}: ${JSON.stringify(unitStrategyIds[expected.ownerId])}`
);
assert(
getUnitSignatureStrategy(expected.ownerId)?.id === expected.usableId,
`Signature lookup did not resolve ${expected.ownerId} -> ${expected.usableId}.`
);
assert(
getUnitStrategies([expected.ownerId]).filter((candidate) => candidate.id === expected.usableId).length === 1,
`Signature strategy must appear exactly once for ${expected.ownerId}.`
);
Object.entries(unitStrategyIds)
.filter(([unitId]) => unitId !== expected.ownerId)
.forEach(([unitId, strategyIds]) => {
assert(!strategyIds.includes(expected.usableId), `${expected.usableId} leaked into ${unitId}'s strategy loadout.`);
});
});
const assignedStrategyIds = Object.values(unitStrategyIds).flat();
assignedStrategyIds.forEach((strategyId) => {
assert(usableCatalog[strategyId]?.command === 'strategy', `Assigned strategy does not resolve to a strategy usable: ${strategyId}`);
});
assert(
Object.entries(usableCatalog)
.filter(([, usable]) => usable.signatureOwnerId)
.every(([usableId, usable]) => signatures.some((entry) => entry.usableId === usableId && entry.ownerId === usable.signatureOwnerId)),
'Every signature strategy must be covered by the eight-officer contract.'
);
const brotherCoverage = getUnitStrategyCoverage(['liu-bei', 'guan-yu', 'zhang-fei', 'liu-bei']);
assert(
brotherCoverage.count === 4 && brotherCoverage.total === 4 && brotherCoverage.missing.length === 0,
`The founding trio must retain full strategy coverage: ${JSON.stringify(brotherCoverage)}`
);
assert(
getUnitStrategyCoverage(['guan-yu', 'huang-zhong']).count === 1,
'Duplicate offense signatures must not inflate coverage.'
);
assert(
JSON.stringify(getUnitStrategyCoverage(['zhang-fei']).covered.map((definition) => definition.id)) === JSON.stringify(['offense', 'control']),
`Zhang Fei's signature must expose offense and control coverage: ${JSON.stringify(getUnitStrategyCoverage(['zhang-fei']))}`
);
console.log('Verified eight owner-exclusive signature strategies, their combat metadata, loadouts, and strategy coverage.');
} finally {
await server.close();
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}

View File

@@ -183,6 +183,34 @@ async function clickBattleSaveSlot(page, slot) {
await page.mouse.click(point.x, point.y);
}
async function clickCampRosterUnit(page, unitId) {
const point = await page.evaluate((requestedUnitId) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const state = window.__HEROS_DEBUG__?.camp();
const row = state?.campRoster?.rowBounds?.find((entry) => entry.unitId === requestedUnitId);
const canvas = document.querySelector('canvas');
const bounds = canvas?.getBoundingClientRect();
if (!scene || !row || !canvas || !bounds) {
return null;
}
return {
x: bounds.left + (row.x + row.width / 2) * bounds.width / scene.scale.width,
y: bounds.top + (row.y + row.height / 2) * bounds.height / scene.scale.height
};
}, unitId);
if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) {
throw new Error(`Expected a visible camp roster row for ${unitId}.`);
}
await page.mouse.click(point.x, point.y);
await page.waitForFunction(
(requestedUnitId) => window.__HEROS_DEBUG__?.camp()?.selectedUnitId === requestedUnitId,
unitId,
{ timeout: 30000 }
);
}
async function moveBattlePointerToRightEdge(page) {
const point = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
@@ -2160,7 +2188,7 @@ try {
await page.mouse.click(576, 38);
await page.waitForTimeout(160);
await page.mouse.click(180, 315);
await clickCampRosterUnit(page, 'jian-yong');
await page.waitForTimeout(160);
const bowangRosterState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
if (

View File

@@ -608,7 +608,7 @@ try {
const hiddenReadyStates = [
['mismatched target intent', firstBattleBondChainReadyUi.mismatch, 'attack', firstBattleBondChainReadyUi.attackerId, null],
['strategy action', firstBattleBondChainReadyUi.strategy, 'strategy', firstBattleBondChainReadyUi.partnerId, 'fireTactic'],
['strategy action', firstBattleBondChainReadyUi.strategy, 'strategy', firstBattleBondChainReadyUi.partnerId, 'azureDragonStrike'],
['lethal base attack', firstBattleBondChainReadyUi.lethal, 'attack', firstBattleBondChainReadyUi.attackerId, null],
['out-of-support-range partner', firstBattleBondChainReadyUi.far, 'attack', firstBattleBondChainReadyUi.attackerId, null]
];
@@ -3889,20 +3889,22 @@ try {
`Expected all 23 campaign officers to use loaded lightweight portrait cards: ${JSON.stringify(missingMidFormationPortraits)}`
);
const midPortraitsMissingStrategyCopy = midFormationPortraitState?.sortiePortraitRoster?.filter(
(unit) => !Array.isArray(unit.strategyNames) || unit.strategyNames.length === 0 || !unit.strategyLine?.startsWith('전법 ')
(unit) => !Array.isArray(unit.strategyNames) || unit.strategyNames.length === 0 || !unit.strategyLine?.trim()
) ?? [];
assert(
midPortraitsMissingStrategyCopy.length === 0 &&
sameJsonValue(midFormationPortraitState?.sortiePortraitRoster?.find((unit) => unit.id === 'guan-yu')?.strategyNames, ['화계']) &&
sameJsonValue(midFormationPortraitState?.sortiePortraitRoster?.find((unit) => unit.id === 'zhang-fei')?.strategyNames, ['고함']),
sameJsonValue(midFormationPortraitState?.sortiePortraitRoster?.find((unit) => unit.id === 'guan-yu')?.strategyNames, ['청룡일섬']) &&
midFormationPortraitState?.sortiePortraitRoster?.find((unit) => unit.id === 'guan-yu')?.strategyLine?.startsWith('고유 청룡일섬') &&
sameJsonValue(midFormationPortraitState?.sortiePortraitRoster?.find((unit) => unit.id === 'zhang-fei')?.strategyNames, ['장판교 대갈']) &&
midFormationPortraitState?.sortiePortraitRoster?.find((unit) => unit.id === 'zhang-fei')?.strategyLine?.startsWith('고유 장판교 대갈'),
`Expected every sortie portrait card to expose its battle strategies: ${JSON.stringify(midPortraitsMissingStrategyCopy)}`
);
assert(
midFormationPortraitState?.sortieStrategyCoverage?.count === 4 &&
midFormationPortraitState.sortieStrategyCoverage.total === 4 &&
midFormationPortraitState.sortieStrategyCoverage.missing.length === 0 &&
sameJsonValue(midFormationPortraitState.sortieStrategyCoverage.covered.map((definition) => definition.label), ['회복', '강화', '공', '제어']) &&
midFormationPortraitState?.sortieFocusedUnit?.strategyLine?.startsWith('전법 '),
sameJsonValue(midFormationPortraitState.sortieStrategyCoverage.covered.map((definition) => definition.label), ['회복', '강화', '공', '제어']) &&
Boolean(midFormationPortraitState?.sortieFocusedUnit?.strategyLine?.trim()),
`Expected the selected formation and focused officer to expose complete strategy coverage: ${JSON.stringify({
coverage: midFormationPortraitState?.sortieStrategyCoverage,
focused: midFormationPortraitState?.sortieFocusedUnit

View File

@@ -15,6 +15,12 @@ try {
} = 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');
const { getUnitStrategyCoverage, usableCatalog } = await server.ssrLoadModule('/src/game/data/battleUsables.ts');
const signatureStrategyByOwner = new Map(
Object.values(usableCatalog)
.filter((usable) => usable.signatureOwnerId)
.map((usable) => [usable.signatureOwnerId, usable.name])
);
assert(
JSON.stringify(sortieRecommendationPlanIds) === JSON.stringify(['order', 'resonance', 'terrain-strategy']),
@@ -58,6 +64,8 @@ try {
orderId,
context,
formationRoles,
getUnitStrategyCoverage,
signatureStrategyByOwner,
expectedCount: scenario.sortie.sortieLimit
});
evaluatedPlanCount += 1;
@@ -155,7 +163,16 @@ function terrainScoreForUnit(unit, scenario, terrainRules, unitClasses) {
: 100;
}
function validateApplicablePlan({ plan, battleId, orderId, context, formationRoles, expectedCount }) {
function validateApplicablePlan({
plan,
battleId,
orderId,
context,
formationRoles,
getUnitStrategyCoverage,
signatureStrategyByOwner,
expectedCount
}) {
const candidateById = new Map(
context.candidates
.filter((candidate) => candidate.available !== false && candidate.unit.faction === 'ally' && candidate.unit.hp > 0)
@@ -205,6 +222,30 @@ function validateApplicablePlan({ plan, battleId, orderId, context, formationRol
Object.values(plan.unitReasons).every(isNonEmptyText),
`${battleId}/${orderId}/${plan.id}: expected one reason for every selected unit: ${JSON.stringify(plan.unitReasons)}`
);
if (signatureStrategyByOwner) {
plan.selectedUnitIds.forEach((unitId) => {
const signatureName = signatureStrategyByOwner.get(unitId);
if (signatureName) {
assert(
plan.unitReasons[unitId].includes(`고유 ${signatureName}`),
`${battleId}/${orderId}/${plan.id}: signature strategy must appear in ${unitId}'s recommendation reason: ${plan.unitReasons[unitId]}`
);
}
});
}
if (getUnitStrategyCoverage) {
const expectedStrategyCoverage = getUnitStrategyCoverage(plan.selectedUnitIds);
assert(
plan.metrics.strategyCoverageCount === expectedStrategyCoverage.count &&
plan.metrics.strategyCoverageTotal === expectedStrategyCoverage.total &&
JSON.stringify(plan.metrics.coveredStrategyLabels) === JSON.stringify(expectedStrategyCoverage.covered.map((definition) => definition.label)) &&
JSON.stringify(plan.metrics.missingStrategyLabels) === JSON.stringify(expectedStrategyCoverage.missing.map((definition) => definition.label)),
`${battleId}/${orderId}/${plan.id}: recommendation metrics must match tag-based strategy coverage: ${JSON.stringify({
actual: plan.metrics,
expected: expectedStrategyCoverage
})}`
);
}
validateMetrics(plan.metrics, orderId, `${battleId}/${orderId}/${plan.id}`);
}

View File

@@ -38,13 +38,13 @@ try {
JSON.stringify(strategyCoverageDefinitions.map(({ id, label, usableId }) => ({ id, label, usableId }))) === JSON.stringify([
{ id: 'healing', label: '회복', usableId: 'aid' },
{ id: 'buff', label: '강화', usableId: 'encourage' },
{ id: 'fire', label: '공', usableId: 'fireTactic' },
{ id: 'offense', label: '공', usableId: 'fireTactic' },
{ id: 'control', label: '제어', usableId: 'roar' }
]),
`Expected the four sortie strategy coverage roles to remain stable: ${JSON.stringify(strategyCoverageDefinitions)}`
);
assert(
JSON.stringify(getUnitStrategyNames(['liu-bei'])) === JSON.stringify(['응급', '격려']),
JSON.stringify(getUnitStrategyNames(['liu-bei'])) === JSON.stringify(['응급', '인덕의 호령']),
`Expected Liu Bei's strategy names to be visible before sortie: ${JSON.stringify(getUnitStrategyNames(['liu-bei']))}`
);
const brotherStrategyCoverage = getUnitStrategyCoverage(['liu-bei', 'guan-yu', 'zhang-fei', 'liu-bei']);
@@ -54,22 +54,22 @@ try {
brotherStrategyCoverage.missing.length === 0,
`Expected the three brothers to cover healing, buff, attack, and control: ${JSON.stringify(brotherStrategyCoverage)}`
);
const noFireStrategyCoverage = getUnitStrategyCoverage(['liu-bei', 'zhang-fei', 'unknown-unit']);
const noOffenseStrategyCoverage = getUnitStrategyCoverage(['liu-bei', 'unknown-unit']);
assert(
noFireStrategyCoverage.count === 3 &&
noFireStrategyCoverage.missing.length === 1 &&
noFireStrategyCoverage.missing[0]?.id === 'fire',
`Expected removing Guan Yu to expose the missing fire-tactic role: ${JSON.stringify(noFireStrategyCoverage)}`
noOffenseStrategyCoverage.count === 2 &&
noOffenseStrategyCoverage.missing.length === 2 &&
noOffenseStrategyCoverage.missing.some((definition) => definition.id === 'offense') &&
noOffenseStrategyCoverage.missing.some((definition) => definition.id === 'control'),
`Expected Liu Bei alone to leave offense and control uncovered: ${JSON.stringify(noOffenseStrategyCoverage)}`
);
const evenStrategyTrade = compareUnitStrategyCoverage(['liu-bei', 'guan-yu'], ['liu-bei', 'zhang-fei']);
const controlStrategyGain = compareUnitStrategyCoverage(['liu-bei', 'guan-yu'], ['liu-bei', 'zhang-fei']);
assert(
evenStrategyTrade.delta === 0 &&
evenStrategyTrade.trend === 'even' &&
evenStrategyTrade.gained.length === 1 &&
evenStrategyTrade.gained[0]?.id === 'control' &&
evenStrategyTrade.lost.length === 1 &&
evenStrategyTrade.lost[0]?.id === 'fire',
`Expected exchanging fire tactics for control at equal coverage to remain a neutral trade: ${JSON.stringify(evenStrategyTrade)}`
controlStrategyGain.delta === 1 &&
controlStrategyGain.trend === 'gain' &&
controlStrategyGain.gained.length === 1 &&
controlStrategyGain.gained[0]?.id === 'control' &&
controlStrategyGain.lost.length === 0,
`Expected Zhang Fei's signature control to add coverage while preserving offense: ${JSON.stringify(controlStrategyGain)}`
);
const unknownStrategyIds = Object.entries(unitStrategyIds)
.flatMap(([unitId, strategyIds]) => strategyIds.map((strategyId) => ({ unitId, strategyId })))

View File

@@ -4,6 +4,7 @@ const checks = [
'scripts/verify-campaign-flow-data.mjs',
'scripts/verify-campaign-save-normalization.mjs',
'scripts/verify-battle-save-normalization.mjs',
'scripts/verify-battle-usables.mjs',
'scripts/verify-sortie-synergy.mjs',
'scripts/verify-sortie-recommendations.mjs',
'scripts/verify-battle-scenario-data.mjs',