diff --git a/package.json b/package.json index 7b71d41..1c2c6dd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/verify-battle-usables.mjs b/scripts/verify-battle-usables.mjs new file mode 100644 index 0000000..4a6e712 --- /dev/null +++ b/scripts/verify-battle-usables.mjs @@ -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); + } +} diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index d9caf30..443242a 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -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 ( diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 5a99cf6..da7759b 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -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 diff --git a/scripts/verify-sortie-recommendations.mjs b/scripts/verify-sortie-recommendations.mjs index 89a81fe..2baea32 100644 --- a/scripts/verify-sortie-recommendations.mjs +++ b/scripts/verify-sortie-recommendations.mjs @@ -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}`); } diff --git a/scripts/verify-sortie-synergy.mjs b/scripts/verify-sortie-synergy.mjs index 073f107..bcd6763 100644 --- a/scripts/verify-sortie-synergy.mjs +++ b/scripts/verify-sortie-synergy.mjs @@ -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 }))) diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index b282af6..8ac2f44 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -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', diff --git a/src/game/data/battleUsables.ts b/src/game/data/battleUsables.ts index 1f5e658..ee9a0ec 100644 --- a/src/game/data/battleUsables.ts +++ b/src/game/data/battleUsables.ts @@ -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 = { effect: 'heal', range: 2, power: 10, + coverageIds: ['healing'], description: '사거리 2칸의 작은 책략 회복. 위험한 아군을 멀리서 살린다.' }, encourage: { @@ -48,6 +53,7 @@ export const usableCatalog: Record = { hitBonus: 10, criticalBonus: 4, duration: 2, + coverageIds: ['buff'], description: '아군의 공격과 명중을 안정적으로 끌어올린다.' }, fireTactic: { @@ -63,6 +69,7 @@ export const usableCatalog: Record = { statusEffect: 'burn', statusDuration: 2, statusPower: 3, + coverageIds: ['offense'], description: '적 한 부대에 책략 피해와 짧은 화상 지속 피해를 준다.' }, roar: { @@ -78,8 +85,133 @@ export const usableCatalog: Record = { 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 = { }; export const unitStrategyIds: Record = { - '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 = { '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( diff --git a/src/game/data/sortieRecommendations.ts b/src/game/data/sortieRecommendations.ts index 6bfab51..0f93b96 100644 --- a/src/game/data/sortieRecommendations.ts +++ b/src/game/data/sortieRecommendations.ts @@ -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, diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 02420fc..e484d2a 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -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: '턴 종료 확인', diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index d2a238f..802f4ae 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -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('·')}` : '전법 없음'; }