feat: add signature officer tactics
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
"verify:campaign-data": "node scripts/verify-campaign-flow-data.mjs",
|
||||
"verify:campaign-save": "node scripts/verify-campaign-save-normalization.mjs",
|
||||
"verify:battle-save": "node scripts/verify-battle-save-normalization.mjs",
|
||||
"verify:battle-usables": "node scripts/verify-battle-usables.mjs",
|
||||
"verify:sortie-synergy": "node scripts/verify-sortie-synergy.mjs",
|
||||
"verify:sortie-recommendations": "node scripts/verify-sortie-recommendations.mjs",
|
||||
"verify:camp-rewards": "node scripts/verify-camp-reward-data.mjs",
|
||||
|
||||
240
scripts/verify-battle-usables.mjs
Normal file
240
scripts/verify-battle-usables.mjs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })))
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -6,6 +6,8 @@ export type UsableTarget = 'enemy' | 'ally' | 'self';
|
||||
|
||||
export type BattleStatusKind = 'burn' | 'confusion';
|
||||
|
||||
export type StrategyCoverageId = 'healing' | 'buff' | 'offense' | 'control';
|
||||
|
||||
export type BattleUsable = {
|
||||
id: string;
|
||||
command: UsableCommand;
|
||||
@@ -22,6 +24,8 @@ export type BattleUsable = {
|
||||
statusEffect?: BattleStatusKind;
|
||||
statusDuration?: number;
|
||||
statusPower?: number;
|
||||
coverageIds?: readonly StrategyCoverageId[];
|
||||
signatureOwnerId?: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
@@ -34,6 +38,7 @@ export const usableCatalog: Record<string, BattleUsable> = {
|
||||
effect: 'heal',
|
||||
range: 2,
|
||||
power: 10,
|
||||
coverageIds: ['healing'],
|
||||
description: '사거리 2칸의 작은 책략 회복. 위험한 아군을 멀리서 살린다.'
|
||||
},
|
||||
encourage: {
|
||||
@@ -48,6 +53,7 @@ export const usableCatalog: Record<string, BattleUsable> = {
|
||||
hitBonus: 10,
|
||||
criticalBonus: 4,
|
||||
duration: 2,
|
||||
coverageIds: ['buff'],
|
||||
description: '아군의 공격과 명중을 안정적으로 끌어올린다.'
|
||||
},
|
||||
fireTactic: {
|
||||
@@ -63,6 +69,7 @@ export const usableCatalog: Record<string, BattleUsable> = {
|
||||
statusEffect: 'burn',
|
||||
statusDuration: 2,
|
||||
statusPower: 3,
|
||||
coverageIds: ['offense'],
|
||||
description: '적 한 부대에 책략 피해와 짧은 화상 지속 피해를 준다.'
|
||||
},
|
||||
roar: {
|
||||
@@ -78,8 +85,133 @@ export const usableCatalog: Record<string, BattleUsable> = {
|
||||
statusEffect: 'confusion',
|
||||
statusDuration: 2,
|
||||
statusPower: 1,
|
||||
coverageIds: ['offense', 'control'],
|
||||
description: '인접한 적을 위압해 다음 행동의 공격, 명중, 치명을 낮춘다.'
|
||||
},
|
||||
benevolentCommand: {
|
||||
id: 'benevolentCommand',
|
||||
command: 'strategy',
|
||||
name: '인덕의 호령',
|
||||
target: 'ally',
|
||||
effect: 'focus',
|
||||
range: 3,
|
||||
power: 0,
|
||||
attackBonus: 2,
|
||||
hitBonus: 10,
|
||||
criticalBonus: 4,
|
||||
duration: 2,
|
||||
coverageIds: ['buff'],
|
||||
signatureOwnerId: 'liu-bei',
|
||||
description: '유비의 인덕으로 멀리 있는 아군까지 다독여 2턴 동안 공격과 명중을 안정시킨다.'
|
||||
},
|
||||
azureDragonStrike: {
|
||||
id: 'azureDragonStrike',
|
||||
command: 'strategy',
|
||||
name: '청룡일섬',
|
||||
target: 'enemy',
|
||||
effect: 'damage',
|
||||
range: 1,
|
||||
power: 13,
|
||||
accuracyBonus: 6,
|
||||
criticalBonus: 10,
|
||||
coverageIds: ['offense'],
|
||||
signatureOwnerId: 'guan-yu',
|
||||
description: '관우가 청룡도를 크게 휘둘러 인접한 적에게 높은 치명 피해를 노린다.'
|
||||
},
|
||||
changbanRoar: {
|
||||
id: 'changbanRoar',
|
||||
command: 'strategy',
|
||||
name: '장판교 대갈',
|
||||
target: 'enemy',
|
||||
effect: 'damage',
|
||||
range: 2,
|
||||
power: 8,
|
||||
accuracyBonus: 12,
|
||||
criticalBonus: 0,
|
||||
statusEffect: 'confusion',
|
||||
statusDuration: 1,
|
||||
statusPower: 1,
|
||||
coverageIds: ['offense', 'control'],
|
||||
signatureOwnerId: 'zhang-fei',
|
||||
description: '장비의 우렁찬 일갈이 두 칸 안의 적을 압도해 피해와 혼란을 남긴다.'
|
||||
},
|
||||
singleRiderRescue: {
|
||||
id: 'singleRiderRescue',
|
||||
command: 'strategy',
|
||||
name: '단기구원',
|
||||
target: 'ally',
|
||||
effect: 'heal',
|
||||
range: 3,
|
||||
power: 12,
|
||||
coverageIds: ['healing'],
|
||||
signatureOwnerId: 'zhao-yun',
|
||||
description: '조운이 전장을 가로질러 세 칸 안의 위급한 아군을 빠르게 구원한다.'
|
||||
},
|
||||
eastWindFire: {
|
||||
id: 'eastWindFire',
|
||||
command: 'strategy',
|
||||
name: '동남풍 화계',
|
||||
target: 'enemy',
|
||||
effect: 'damage',
|
||||
range: 3,
|
||||
power: 11,
|
||||
accuracyBonus: 6,
|
||||
criticalBonus: 0,
|
||||
statusEffect: 'burn',
|
||||
statusDuration: 3,
|
||||
statusPower: 3,
|
||||
coverageIds: ['offense'],
|
||||
signatureOwnerId: 'zhuge-liang',
|
||||
description: '제갈량이 바람의 흐름을 읽어 먼 적에게 화계를 펼치고 화상을 오래 유지한다.'
|
||||
},
|
||||
hundredPacePierce: {
|
||||
id: 'hundredPacePierce',
|
||||
command: 'strategy',
|
||||
name: '백보천양',
|
||||
target: 'enemy',
|
||||
effect: 'damage',
|
||||
range: 4,
|
||||
power: 11,
|
||||
accuracyBonus: 18,
|
||||
criticalBonus: 12,
|
||||
coverageIds: ['offense'],
|
||||
signatureOwnerId: 'huang-zhong',
|
||||
description: '황충이 네 칸 밖까지 꿰뚫는 정밀 사격으로 높은 명중과 치명을 노린다.'
|
||||
},
|
||||
westernCavalryCharge: {
|
||||
id: 'westernCavalryCharge',
|
||||
command: 'strategy',
|
||||
name: '서량철기',
|
||||
target: 'enemy',
|
||||
effect: 'damage',
|
||||
range: 2,
|
||||
power: 11,
|
||||
accuracyBonus: 8,
|
||||
criticalBonus: 6,
|
||||
statusEffect: 'confusion',
|
||||
statusDuration: 1,
|
||||
statusPower: 1,
|
||||
coverageIds: ['offense', 'control'],
|
||||
signatureOwnerId: 'ma-chao',
|
||||
description: '마초가 서량 기병의 기세로 두 칸 안의 적진을 흔들어 피해와 혼란을 준다.'
|
||||
},
|
||||
qilinStratagem: {
|
||||
id: 'qilinStratagem',
|
||||
command: 'strategy',
|
||||
name: '기린지계',
|
||||
target: 'enemy',
|
||||
effect: 'damage',
|
||||
range: 3,
|
||||
power: 10,
|
||||
accuracyBonus: 10,
|
||||
criticalBonus: 2,
|
||||
statusEffect: 'confusion',
|
||||
statusDuration: 2,
|
||||
statusPower: 1,
|
||||
coverageIds: ['offense', 'control'],
|
||||
signatureOwnerId: 'jiang-wei',
|
||||
description: '강유가 세 칸 안의 적을 연속 계책에 빠뜨려 피해를 주고 혼란을 이어 간다.'
|
||||
},
|
||||
bean: {
|
||||
id: 'bean',
|
||||
command: 'item',
|
||||
@@ -117,18 +249,18 @@ export const usableCatalog: Record<string, BattleUsable> = {
|
||||
};
|
||||
|
||||
export const unitStrategyIds: Record<string, string[]> = {
|
||||
'liu-bei': ['aid', 'encourage'],
|
||||
'guan-yu': ['fireTactic'],
|
||||
'zhang-fei': ['roar'],
|
||||
'liu-bei': ['aid', 'benevolentCommand'],
|
||||
'guan-yu': ['azureDragonStrike'],
|
||||
'zhang-fei': ['changbanRoar'],
|
||||
'jian-yong': ['aid', 'encourage', 'fireTactic'],
|
||||
'mi-zhu': ['aid', 'encourage'],
|
||||
'sun-qian': ['aid', 'encourage', 'fireTactic'],
|
||||
'zhao-yun': ['encourage'],
|
||||
'zhuge-liang': ['aid', 'encourage', 'fireTactic'],
|
||||
'zhao-yun': ['singleRiderRescue'],
|
||||
'zhuge-liang': ['aid', 'encourage', 'eastWindFire'],
|
||||
'ma-liang': ['aid', 'encourage', 'fireTactic'],
|
||||
'yi-ji': ['aid', 'encourage'],
|
||||
'gong-zhi': ['encourage'],
|
||||
'huang-zhong': ['encourage'],
|
||||
'huang-zhong': ['hundredPacePierce'],
|
||||
'wei-yan': ['roar'],
|
||||
'pang-tong': ['aid', 'encourage', 'fireTactic'],
|
||||
'fa-zheng': ['aid', 'encourage', 'fireTactic'],
|
||||
@@ -136,16 +268,16 @@ export const unitStrategyIds: Record<string, string[]> = {
|
||||
'yan-yan': ['encourage'],
|
||||
'li-yan': ['aid', 'encourage'],
|
||||
'huang-quan': ['aid', 'encourage'],
|
||||
'ma-chao': ['roar', 'encourage'],
|
||||
'ma-chao': ['westernCavalryCharge', 'encourage'],
|
||||
'ma-dai': ['encourage'],
|
||||
'wang-ping': ['aid', 'encourage'],
|
||||
'jiang-wei': ['aid', 'encourage', 'fireTactic']
|
||||
'jiang-wei': ['aid', 'encourage', 'qilinStratagem']
|
||||
};
|
||||
|
||||
export const strategyCoverageDefinitions = [
|
||||
{ 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' }
|
||||
] as const;
|
||||
|
||||
@@ -177,8 +309,11 @@ function uniqueUnitStrategyIds(unitIds: readonly string[]) {
|
||||
|
||||
export function getUnitStrategyCoverage(unitIds: readonly string[]): UnitStrategyCoverage {
|
||||
const strategyIds = uniqueUnitStrategyIds(unitIds);
|
||||
const covered = strategyCoverageDefinitions.filter((definition) => strategyIds.has(definition.usableId));
|
||||
const missing = strategyCoverageDefinitions.filter((definition) => !strategyIds.has(definition.usableId));
|
||||
const coverageIds = new Set(
|
||||
[...strategyIds].flatMap((strategyId) => usableCatalog[strategyId]?.coverageIds ?? [])
|
||||
);
|
||||
const covered = strategyCoverageDefinitions.filter((definition) => coverageIds.has(definition.id));
|
||||
const missing = strategyCoverageDefinitions.filter((definition) => !coverageIds.has(definition.id));
|
||||
|
||||
return {
|
||||
total: strategyCoverageDefinitions.length,
|
||||
@@ -189,10 +324,17 @@ export function getUnitStrategyCoverage(unitIds: readonly string[]): UnitStrateg
|
||||
}
|
||||
|
||||
export function getUnitStrategyNames(unitIds: readonly string[]) {
|
||||
return getUnitStrategies(unitIds).map((usable) => usable.name);
|
||||
}
|
||||
|
||||
export function getUnitStrategies(unitIds: readonly string[]) {
|
||||
return [...uniqueUnitStrategyIds(unitIds)]
|
||||
.map((strategyId) => usableCatalog[strategyId])
|
||||
.filter((usable): usable is BattleUsable => Boolean(usable))
|
||||
.map((usable) => usable.name);
|
||||
.filter((usable): usable is BattleUsable => Boolean(usable));
|
||||
}
|
||||
|
||||
export function getUnitSignatureStrategy(unitId: string) {
|
||||
return getUnitStrategies([unitId]).find((usable) => usable.signatureOwnerId === unitId);
|
||||
}
|
||||
|
||||
export function compareUnitStrategyCoverage(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getUnitStrategyCoverage, getUnitStrategyNames } from './battleUsables';
|
||||
import { getUnitSignatureStrategy, getUnitStrategyCoverage, getUnitStrategyNames } from './battleUsables';
|
||||
import type { UnitClassKey } from './battleRules';
|
||||
import type { UnitData } from './scenario';
|
||||
import type { SortieFormationAssignments, SortieFormationRole } from './sortieDeployment';
|
||||
@@ -471,6 +471,7 @@ function createPlan(
|
||||
const unitReasons = Object.fromEntries(selectedUnitIds.map((unitId) => {
|
||||
const candidate = candidateById.get(unitId);
|
||||
const authoredReason = recommendedById.get(unitId)?.reason;
|
||||
const signature = getUnitSignatureStrategy(unitId);
|
||||
const reason = required.has(unitId)
|
||||
? '이번 전투의 필수 출전 무장입니다.'
|
||||
: authoredReason
|
||||
@@ -480,7 +481,7 @@ function createPlan(
|
||||
: planId === 'resonance'
|
||||
? '공명 관계와 추격 연결 가능성을 높입니다.'
|
||||
: `지형 적성 ${candidate?.terrainScore ?? 100}과 전법 범위를 보강합니다.`;
|
||||
return [unitId, reason];
|
||||
return [unitId, `${reason}${signature ? ` · 고유 ${signature.name}` : ''}`];
|
||||
}));
|
||||
return {
|
||||
id: planId,
|
||||
|
||||
@@ -8794,7 +8794,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private usableChannelLabel(usable: BattleUsable) {
|
||||
return usable.command === 'item' ? '도구' : '책략';
|
||||
return usable.command === 'item' ? '도구' : usable.signatureOwnerId ? '고유 전법' : '책략';
|
||||
}
|
||||
|
||||
private usableTargetIcon(usable: BattleUsable): BattleUiIconKey {
|
||||
@@ -8849,7 +8849,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
powerLabel: `${usable.duration ?? 1}턴 강화`
|
||||
};
|
||||
}
|
||||
if (id.includes('fire')) {
|
||||
if (usable.statusEffect === 'burn' || id.includes('fire')) {
|
||||
return {
|
||||
icon: 'fire' as BattleUiIconKey,
|
||||
powerIcon: 'fire' as BattleUiIconKey,
|
||||
@@ -8858,7 +8858,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
powerLabel: `화염 ${usable.power}`
|
||||
};
|
||||
}
|
||||
if (id.includes('roar') || id.includes('shout')) {
|
||||
if (usable.statusEffect === 'confusion' || id.includes('roar') || id.includes('shout')) {
|
||||
return {
|
||||
icon: 'confusion' as BattleUiIconKey,
|
||||
powerIcon: 'confusion' as BattleUiIconKey,
|
||||
@@ -8898,7 +8898,11 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private usableStockLabel(user: UnitData, usable: BattleUsable) {
|
||||
return usable.command === 'item' ? `보유 ${this.itemStock(user.id, usable.id)}` : '책략';
|
||||
return usable.command === 'item'
|
||||
? `보유 ${this.itemStock(user.id, usable.id)}`
|
||||
: usable.signatureOwnerId
|
||||
? '고유 전법'
|
||||
: '책략';
|
||||
}
|
||||
|
||||
private usableDetailText(user: UnitData, usable: BattleUsable) {
|
||||
@@ -9312,7 +9316,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
if (command === 'strategy') {
|
||||
return (unitStrategyIds[unit.id] ?? [])
|
||||
.map((id) => usableCatalog[id])
|
||||
.filter((usable): usable is BattleUsable => Boolean(usable));
|
||||
.filter((usable): usable is BattleUsable => Boolean(usable) && (!usable.signatureOwnerId || usable.signatureOwnerId === unit.id));
|
||||
}
|
||||
|
||||
const stocks = this.itemStocks.get(unit.id);
|
||||
@@ -15573,6 +15577,10 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.hideMapMenu();
|
||||
soundDirector.playSelect();
|
||||
|
||||
if (action === 'threat' || action === 'roster' || action === 'bond' || action === 'situation') {
|
||||
this.hideBattleEventBanner();
|
||||
}
|
||||
|
||||
if (action === 'endTurn') {
|
||||
this.showTurnEndPrompt(undefined, {
|
||||
title: '턴 종료 확인',
|
||||
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
type EquipmentSlot,
|
||||
type ItemDefinition
|
||||
} from '../data/battleItems';
|
||||
import { compareUnitStrategyCoverage, getUnitStrategyCoverage, getUnitStrategyNames } from '../data/battleUsables';
|
||||
import {
|
||||
compareUnitStrategyCoverage,
|
||||
getUnitSignatureStrategy,
|
||||
getUnitStrategyCoverage,
|
||||
getUnitStrategyNames
|
||||
} from '../data/battleUsables';
|
||||
import { getUnitClass, terrainRules, type TerrainType, type UnitClassKey } from '../data/battleRules';
|
||||
import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition, type BattleScenarioId } from '../data/battles';
|
||||
import { getSortieFlow } from '../data/campaignFlow';
|
||||
@@ -15693,6 +15698,28 @@ export class CampScene extends Phaser.Scene {
|
||||
|
||||
private sortieStrategyLine(unitId: string) {
|
||||
const names = getUnitStrategyNames([unitId]);
|
||||
const signature = getUnitSignatureStrategy(unitId);
|
||||
if (signature) {
|
||||
const effectParts = signature.effect === 'heal'
|
||||
? [`회복${signature.power}`]
|
||||
: signature.effect === 'focus'
|
||||
? [
|
||||
'강화',
|
||||
...(signature.attackBonus ? [`공+${signature.attackBonus}`] : []),
|
||||
...(signature.hitBonus ? [`명+${signature.hitBonus}`] : []),
|
||||
...(signature.duration ? [`${signature.duration}턴`] : [])
|
||||
]
|
||||
: [
|
||||
`피해${signature.power}`,
|
||||
...(signature.accuracyBonus ? [`명+${signature.accuracyBonus}`] : []),
|
||||
...(signature.criticalBonus ? [`치+${signature.criticalBonus}`] : []),
|
||||
...(signature.statusEffect
|
||||
? [`${signature.statusEffect === 'burn' ? '화상' : '혼란'}${signature.statusDuration ?? 1}턴`]
|
||||
: [])
|
||||
];
|
||||
const otherNames = names.filter((name) => name !== signature.name);
|
||||
return `고유 ${signature.name} · ${effectParts.join(' ')} · 사거리${signature.range}${otherNames.length > 0 ? ` · ${otherNames.join('·')}` : ''}`;
|
||||
}
|
||||
return names.length > 0 ? `전법 ${names.join('·')}` : '전법 없음';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user