From cb66deef9d4dbb4ac6ed200aee192c5125e5daf2 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 12 Jul 2026 12:28:51 +0900 Subject: [PATCH] feat: surface officer strategies in sortie planning --- scripts/verify-release-candidate.mjs | 32 +++- scripts/verify-sortie-synergy.mjs | 56 ++++++- src/game/data/battleUsables.ts | 218 +++++++++++++++++++++++++++ src/game/scenes/BattleScene.ts | 150 +----------------- src/game/scenes/CampScene.ts | 101 ++++++++++--- 5 files changed, 395 insertions(+), 162 deletions(-) create mode 100644 src/game/data/battleUsables.ts diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 294b7a3..45b8b18 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -3862,6 +3862,26 @@ try { midFormationPortraitState?.sortiePortraitRoster?.length === 23 && missingMidFormationPortraits.length === 0, `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('전법 ') + ) ?? []; + assert( + midPortraitsMissingStrategyCopy.length === 0 && + sameJsonValue(midFormationPortraitState?.sortiePortraitRoster?.find((unit) => unit.id === 'guan-yu')?.strategyNames, ['화계']) && + sameJsonValue(midFormationPortraitState?.sortiePortraitRoster?.find((unit) => unit.id === 'zhang-fei')?.strategyNames, ['고함']), + `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('전법 '), + `Expected the selected formation and focused officer to expose complete strategy coverage: ${JSON.stringify({ + coverage: midFormationPortraitState?.sortieStrategyCoverage, + focused: midFormationPortraitState?.sortieFocusedUnit + })}` + ); const midCoreSelectorToggle = midFormationPortraitState?.sortiePursuitPreview; assert( @@ -4042,15 +4062,25 @@ try { swapPreview?.comparison?.lostRoles?.includes('flank') && swapPreview?.comparison?.activeBondDelta === 0 && swapPreview?.comparison?.gainedBonds?.length > 0 && - swapPreview?.comparison?.lostBonds?.length > 0, + swapPreview?.comparison?.lostBonds?.length > 0 && + swapPreview?.strategyCoverage?.current?.count === 4 && + swapPreview?.strategyCoverage?.projected?.count === 4 && + swapPreview?.strategyCoverage?.current?.total === 4 && + swapPreview?.strategyCoverage?.projected?.total === 4 && + swapPreview?.strategyCoverage?.delta === 0 && + swapPreview?.strategyCoverage?.trend === 'even' && + swapPreview?.strategyCoverage?.gained?.length === 0 && + swapPreview?.strategyCoverage?.lost?.length === 0, `Expected a meaningful non-destructive Ma Dai to Guan Yu swap preview: ${JSON.stringify(swapPreview)}` ); assert( midFormationSwapPreviewProbe.panel.title === '가상 교체 · 마대 → 관우' && midFormationSwapPreviewProbe.panel.headline?.includes('OUT 마대') && midFormationSwapPreviewProbe.panel.headline?.includes('IN 관우') && + midFormationSwapPreviewProbe.panel.impact?.includes('전법 4/4→4/4') && midFormationSwapPreviewProbe.panel.impact?.includes('추격') && midFormationSwapPreviewProbe.panel.impact?.includes('공백 돌파') && + midFormationSwapPreviewProbe.panel.detail?.includes('전법 유지') && midFormationSwapPreviewProbe.panel.detail?.includes('추격 해제') && midFormationSwapPreviewProbe.panel.detail?.includes('8%') && midFormationSwapPreviewProbe.panel.detail?.includes('실제 편성 미변경'), diff --git a/scripts/verify-sortie-synergy.mjs b/scripts/verify-sortie-synergy.mjs index 68d32f8..073f107 100644 --- a/scripts/verify-sortie-synergy.mjs +++ b/scripts/verify-sortie-synergy.mjs @@ -25,6 +25,60 @@ try { sortieOrderRewardClaimId, summarizeSortieOrderHistory } = await server.ssrLoadModule('/src/game/data/sortieOrders.ts'); + const { + compareUnitStrategyCoverage, + getUnitStrategyCoverage, + getUnitStrategyNames, + strategyCoverageDefinitions, + unitStrategyIds, + usableCatalog + } = await server.ssrLoadModule('/src/game/data/battleUsables.ts'); + + assert( + 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: '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(['응급', '격려']), + `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']); + assert( + brotherStrategyCoverage.count === 4 && + brotherStrategyCoverage.total === 4 && + 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']); + 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)}` + ); + const evenStrategyTrade = 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)}` + ); + const unknownStrategyIds = Object.entries(unitStrategyIds) + .flatMap(([unitId, strategyIds]) => strategyIds.map((strategyId) => ({ unitId, strategyId }))) + .filter(({ strategyId }) => usableCatalog[strategyId]?.command !== 'strategy'); + assert(unknownStrategyIds.length === 0, `Expected every officer strategy id to resolve to a battle strategy: ${JSON.stringify(unknownStrategyIds)}`); + assert( + ['bean', 'salve', 'wine'].every((itemId) => usableCatalog[itemId]?.command === 'item'), + 'Expected the shared usable catalog to retain all three battle items.' + ); const expectedRoleEffectSummaries = { front: '일반 공격 피해 -8%(최소 1) · 반격 +20%', @@ -433,7 +487,7 @@ try { ); assert(JSON.stringify(orderPerformance) === frozenOrderPerformance, 'Expected sortie-order evaluation not to mutate performance input.'); - console.log('Verified generic sortie role effects, synergy previews, bond thresholds, role coverage, first-pursuit trinity, and sortie-order evaluation/reward/history boundaries.'); + console.log('Verified officer strategy coverage, generic sortie role effects, synergy previews, bond thresholds, role coverage, first-pursuit trinity, and sortie-order evaluation/reward/history boundaries.'); } finally { await server.close(); } diff --git a/src/game/data/battleUsables.ts b/src/game/data/battleUsables.ts new file mode 100644 index 0000000..1f5e658 --- /dev/null +++ b/src/game/data/battleUsables.ts @@ -0,0 +1,218 @@ +export type UsableCommand = 'strategy' | 'item'; + +export type UsableEffect = 'damage' | 'heal' | 'focus'; + +export type UsableTarget = 'enemy' | 'ally' | 'self'; + +export type BattleStatusKind = 'burn' | 'confusion'; + +export type BattleUsable = { + id: string; + command: UsableCommand; + name: string; + target: UsableTarget; + effect: UsableEffect; + range: number; + power: number; + accuracyBonus?: number; + criticalBonus?: number; + attackBonus?: number; + hitBonus?: number; + duration?: number; + statusEffect?: BattleStatusKind; + statusDuration?: number; + statusPower?: number; + description: string; +}; + +export const usableCatalog: Record = { + aid: { + id: 'aid', + command: 'strategy', + name: '응급', + target: 'ally', + effect: 'heal', + range: 2, + power: 10, + description: '사거리 2칸의 작은 책략 회복. 위험한 아군을 멀리서 살린다.' + }, + encourage: { + id: 'encourage', + command: 'strategy', + name: '격려', + target: 'ally', + effect: 'focus', + range: 2, + power: 0, + attackBonus: 2, + hitBonus: 10, + criticalBonus: 4, + duration: 2, + description: '아군의 공격과 명중을 안정적으로 끌어올린다.' + }, + fireTactic: { + id: 'fireTactic', + command: 'strategy', + name: '화계', + target: 'enemy', + effect: 'damage', + range: 2, + power: 10, + accuracyBonus: 4, + criticalBonus: 0, + statusEffect: 'burn', + statusDuration: 2, + statusPower: 3, + description: '적 한 부대에 책략 피해와 짧은 화상 지속 피해를 준다.' + }, + roar: { + id: 'roar', + command: 'strategy', + name: '고함', + target: 'enemy', + effect: 'damage', + range: 1, + power: 7, + accuracyBonus: 10, + criticalBonus: 0, + statusEffect: 'confusion', + statusDuration: 2, + statusPower: 1, + description: '인접한 적을 위압해 다음 행동의 공격, 명중, 치명을 낮춘다.' + }, + bean: { + id: 'bean', + command: 'item', + name: '콩', + target: 'ally', + effect: 'heal', + range: 1, + power: 12, + description: '인접 아군을 빠르게 소량 회복하는 기본 보급품.' + }, + salve: { + id: 'salve', + command: 'item', + name: '상처약', + target: 'ally', + effect: 'heal', + range: 1, + power: 22, + description: '인접 아군의 큰 피해를 되돌리는 고가 회복품.' + }, + wine: { + id: 'wine', + command: 'item', + name: '술', + target: 'self', + effect: 'focus', + range: 0, + power: 0, + attackBonus: 3, + hitBonus: 4, + criticalBonus: 12, + duration: 2, + description: '사용자 자신을 짧게 몰아붙이는 공격·치명 강화 도구.' + } +}; + +export const unitStrategyIds: Record = { + 'liu-bei': ['aid', 'encourage'], + 'guan-yu': ['fireTactic'], + 'zhang-fei': ['roar'], + 'jian-yong': ['aid', 'encourage', 'fireTactic'], + 'mi-zhu': ['aid', 'encourage'], + 'sun-qian': ['aid', 'encourage', 'fireTactic'], + 'zhao-yun': ['encourage'], + 'zhuge-liang': ['aid', 'encourage', 'fireTactic'], + 'ma-liang': ['aid', 'encourage', 'fireTactic'], + 'yi-ji': ['aid', 'encourage'], + 'gong-zhi': ['encourage'], + 'huang-zhong': ['encourage'], + 'wei-yan': ['roar'], + 'pang-tong': ['aid', 'encourage', 'fireTactic'], + 'fa-zheng': ['aid', 'encourage', 'fireTactic'], + 'wu-yi': ['encourage'], + 'yan-yan': ['encourage'], + 'li-yan': ['aid', 'encourage'], + 'huang-quan': ['aid', 'encourage'], + 'ma-chao': ['roar', 'encourage'], + 'ma-dai': ['encourage'], + 'wang-ping': ['aid', 'encourage'], + 'jiang-wei': ['aid', 'encourage', 'fireTactic'] +}; + +export const strategyCoverageDefinitions = [ + { id: 'healing', label: '회복', usableId: 'aid' }, + { id: 'buff', label: '강화', usableId: 'encourage' }, + { id: 'fire', label: '화공', usableId: 'fireTactic' }, + { id: 'control', label: '제어', usableId: 'roar' } +] as const; + +export type StrategyCoverageDefinition = (typeof strategyCoverageDefinitions)[number]; + +export type UnitStrategyCoverage = { + total: number; + count: number; + covered: StrategyCoverageDefinition[]; + missing: StrategyCoverageDefinition[]; +}; + +export type UnitStrategyCoverageComparison = { + current: UnitStrategyCoverage; + projected: UnitStrategyCoverage; + gained: StrategyCoverageDefinition[]; + lost: StrategyCoverageDefinition[]; + delta: number; + trend: 'gain' | 'loss' | 'even'; +}; + +function uniqueUnitStrategyIds(unitIds: readonly string[]) { + const strategyIds = new Set(); + unitIds.forEach((unitId) => { + (unitStrategyIds[unitId] ?? []).forEach((strategyId) => strategyIds.add(strategyId)); + }); + return strategyIds; +} + +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)); + + return { + total: strategyCoverageDefinitions.length, + count: covered.length, + covered: [...covered], + missing: [...missing] + }; +} + +export function getUnitStrategyNames(unitIds: readonly string[]) { + return [...uniqueUnitStrategyIds(unitIds)] + .map((strategyId) => usableCatalog[strategyId]) + .filter((usable): usable is BattleUsable => Boolean(usable)) + .map((usable) => usable.name); +} + +export function compareUnitStrategyCoverage( + currentUnitIds: readonly string[], + projectedUnitIds: readonly string[] +): UnitStrategyCoverageComparison { + const current = getUnitStrategyCoverage(currentUnitIds); + const projected = getUnitStrategyCoverage(projectedUnitIds); + const currentIds = new Set(current.covered.map((definition) => definition.id)); + const projectedIds = new Set(projected.covered.map((definition) => definition.id)); + const gained = projected.covered.filter((definition) => !currentIds.has(definition.id)); + const lost = current.covered.filter((definition) => !projectedIds.has(definition.id)); + const delta = projected.count - current.count; + + return { + current, + projected, + gained, + lost, + delta, + trend: delta > 0 ? 'gain' : delta < 0 ? 'loss' : 'even' + }; +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 6db7d3e..3f4946b 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -25,6 +25,13 @@ import { loadBattleUiIcons, type BattleUiIconKey } from '../data/battleUiIcons'; +import { + usableCatalog, + unitStrategyIds, + type BattleStatusKind, + type BattleUsable, + type UsableCommand +} from '../data/battleUsables'; import { equipmentExpToNext, equipmentSlotLabels, @@ -1185,11 +1192,8 @@ type UnitActionPose = 'attack' | 'strategy' | 'item' | 'hurt' | 'celebrate'; const levelUpCelebrationStepOffsets = [0, -7, -2, -9, 0, -6, -3, -8]; type BattlePhase = 'deployment' | 'idle' | 'moving' | 'command' | 'targeting' | 'animating' | 'resolved'; -type BattleCommand = 'attack' | 'strategy' | 'item' | 'wait'; +type BattleCommand = 'attack' | UsableCommand | 'wait'; type DamageCommand = Exclude; -type UsableCommand = Extract; -type UsableEffect = 'damage' | 'heal' | 'focus'; -type UsableTarget = 'enemy' | 'ally' | 'self'; type SpecialDamageMotionKind = 'item' | 'arcane' | 'fire' | 'roar'; type RosterTab = 'ally' | 'enemy'; type ActiveFaction = 'ally' | 'enemy'; @@ -1614,25 +1618,6 @@ type CombatResult = CombatPreview & { counter?: CombatResult; }; -type BattleUsable = { - id: string; - command: UsableCommand; - name: string; - target: UsableTarget; - effect: UsableEffect; - range: number; - power: number; - accuracyBonus?: number; - criticalBonus?: number; - attackBonus?: number; - hitBonus?: number; - duration?: number; - statusEffect?: BattleStatusKind; - statusDuration?: number; - statusPower?: number; - description: string; -}; - type SupportPreview = { usable: BattleUsable; user: UnitData; @@ -1803,8 +1788,6 @@ type TacticalInitiativeCombatEffect = { labels: string[]; }; -type BattleStatusKind = 'burn' | 'confusion'; - type BattleStatusState = { unitId: string; kind: BattleStatusKind; @@ -1879,123 +1862,6 @@ let battleSaveStorageKey = `heros-web:battle:${battleScenario.id}`; let initialBattleUnits = battleUnits.map(cloneUnitData); let initialBattleBonds = battleBonds.map(cloneBattleBond); -const usableCatalog: Record = { - aid: { - id: 'aid', - command: 'strategy', - name: '응급', - target: 'ally', - effect: 'heal', - range: 2, - power: 10, - description: '사거리 2칸의 작은 책략 회복. 위험한 아군을 멀리서 살린다.' - }, - encourage: { - id: 'encourage', - command: 'strategy', - name: '격려', - target: 'ally', - effect: 'focus', - range: 2, - power: 0, - attackBonus: 2, - hitBonus: 10, - criticalBonus: 4, - duration: 2, - description: '아군의 공격과 명중을 안정적으로 끌어올린다.' - }, - fireTactic: { - id: 'fireTactic', - command: 'strategy', - name: '화계', - target: 'enemy', - effect: 'damage', - range: 2, - power: 10, - accuracyBonus: 4, - criticalBonus: 0, - statusEffect: 'burn', - statusDuration: 2, - statusPower: 3, - description: '적 한 부대에 책략 피해와 짧은 화상 지속 피해를 준다.' - }, - roar: { - id: 'roar', - command: 'strategy', - name: '고함', - target: 'enemy', - effect: 'damage', - range: 1, - power: 7, - accuracyBonus: 10, - criticalBonus: 0, - statusEffect: 'confusion', - statusDuration: 2, - statusPower: 1, - description: '인접한 적을 위압해 다음 행동의 공격, 명중, 치명을 낮춘다.' - }, - bean: { - id: 'bean', - command: 'item', - name: '콩', - target: 'ally', - effect: 'heal', - range: 1, - power: 12, - description: '인접 아군을 빠르게 소량 회복하는 기본 보급품.' - }, - salve: { - id: 'salve', - command: 'item', - name: '상처약', - target: 'ally', - effect: 'heal', - range: 1, - power: 22, - description: '인접 아군의 큰 피해를 되돌리는 고가 회복품.' - }, - wine: { - id: 'wine', - command: 'item', - name: '술', - target: 'self', - effect: 'focus', - range: 0, - power: 0, - attackBonus: 3, - hitBonus: 4, - criticalBonus: 12, - duration: 2, - description: '사용자 자신을 짧게 몰아붙이는 공격·치명 강화 도구.' - } -}; - -const unitStrategyIds: Record = { - 'liu-bei': ['aid', 'encourage'], - 'guan-yu': ['fireTactic'], - 'zhang-fei': ['roar'], - 'jian-yong': ['aid', 'encourage', 'fireTactic'], - 'mi-zhu': ['aid', 'encourage'], - 'sun-qian': ['aid', 'encourage', 'fireTactic'], - 'zhao-yun': ['encourage'], - 'zhuge-liang': ['aid', 'encourage', 'fireTactic'], - 'ma-liang': ['aid', 'encourage', 'fireTactic'], - 'yi-ji': ['aid', 'encourage'], - 'gong-zhi': ['encourage'], - 'huang-zhong': ['encourage'], - 'wei-yan': ['roar'], - 'pang-tong': ['aid', 'encourage', 'fireTactic'], - 'fa-zheng': ['aid', 'encourage', 'fireTactic'], - 'wu-yi': ['encourage'], - 'yan-yan': ['encourage'], - 'li-yan': ['aid', 'encourage'], - 'huang-quan': ['aid', 'encourage'], - 'ma-chao': ['roar', 'encourage'], - 'ma-dai': ['encourage'], - 'wang-ping': ['aid', 'encourage'], - 'jiang-wei': ['aid', 'encourage', 'fireTactic'] -}; - const initialItemStocks: Record> = { 'liu-bei': { bean: 2, salve: 1 }, 'guan-yu': { bean: 1 }, diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index fa5eb56..2bf4ff7 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -12,6 +12,7 @@ import { type EquipmentSlot, type ItemDefinition } from '../data/battleItems'; +import { compareUnitStrategyCoverage, 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'; @@ -294,6 +295,7 @@ type SortieUnitTacticalSummary = { equipmentLine: string; bondLine: string; terrainLine: string; + strategyLine: string; }; type SortieFocusedUnitSummary = SortieUnitTacticalSummary & { @@ -13268,6 +13270,14 @@ export class CampScene extends Phaser.Scene { this.textStyle(10, availability.available ? previewColor : '#68727e', selected || Boolean(recommendation)) ) ).setDepth(depth + 2); + this.trackSortie( + this.add.text( + cardX + 92, + cardY + 70, + this.compactText(this.sortieStrategyLine(unit.id), 20), + this.textStyle(8, availability.available ? '#9fc8e8' : '#68727e', true) + ) + ).setDepth(depth + 2); const canToggle = availability.available && !(selected && required); this.renderFirstSortieInlineButton( @@ -13733,15 +13743,22 @@ export class CampScene extends Phaser.Scene { const showMetricTransition = !isUndoAction && Boolean(preview?.projected); const comparison = isUndoAction ? undefined : preview?.comparison; const pursuitChanges = this.changedSortiePursuitPairs(current, projected); + const strategyCoverageComparison = compareUnitStrategyCoverage(current.selectedUnitIds, projected.selectedUnitIds); + const currentStrategyCoverage = strategyCoverageComparison.current; + const projectedStrategyCoverage = strategyCoverageComparison.projected; + const gainedStrategies = strategyCoverageComparison.gained; + const lostStrategies = strategyCoverageComparison.lost; + const strategyCoverageDelta = strategyCoverageComparison.delta; this.setCampaignSortieCoreSelectorVisible(false); const isHoverPreview = preview?.source === 'hover-add' || preview?.source === 'hover-swap' || preview?.source === 'pinned-swap' || preview?.source === 'hover-preset' || preview?.source === 'pinned-preset'; const lostRole = Boolean(comparison?.lostRoles.length); + const lostStrategyCoverage = strategyCoverageDelta < 0; const completeFormation = projected.coveredRoleCount === coreSortieSynergyRoles.length; view.background.setStrokeStyle( - lostRole ? 2 : 1, - lostRole ? palette.red : isHoverPreview ? palette.gold : completeFormation ? palette.green : palette.gold, - lostRole ? 0.78 : isHoverPreview ? 0.72 : 0.58 + lostRole || lostStrategyCoverage ? 2 : 1, + lostRole || lostStrategyCoverage ? palette.red : isHoverPreview ? palette.gold : completeFormation ? palette.green : palette.gold, + lostRole || lostStrategyCoverage ? 0.78 : isHoverPreview ? 0.72 : 0.58 ); if (this.sortieCoreResonanceSelectorOpen) { @@ -13786,14 +13803,18 @@ export class CampScene extends Phaser.Scene { return; } view.title.setText('편성 변화'); - view.summary.setText(`추격 후보 ${pursuitChanges.currentPairs.length}조 · 역할 ${current.coveredRoleCount}/3 · 지형 ${current.terrainGrade}`); + view.summary.setText(`전법 ${currentStrategyCoverage.count}/${currentStrategyCoverage.total} · 역할 ${current.coveredRoleCount}/3 · 추격 ${pursuitChanges.currentPairs.length}조`); view.headline.setText('출전 무장을 클릭해 교체 기준으로 고정하십시오.').setColor('#9fb0bf'); view.metrics.forEach((metric) => { metric.background.setFillStyle(0x111922, 0.9).setStrokeStyle(1, 0x53606c, 0.34); metric.value.setText('-').setColor('#77818c'); }); - view.impact.setText('후보 초상에 마우스를 올리면 역할·추격 후보 변화가 표시됩니다.').setColor('#d4dce6'); - view.detail.setText('미리보기는 실제 출전 명단과 보급을 변경하지 않습니다.').setColor('#9fb0bf'); + view.impact + .setText(`전법 ${currentStrategyCoverage.covered.map((definition) => definition.label).join('·') || '없음'} · 후보 초상에 마우스를 올려 변화를 비교하세요.`) + .setColor(currentStrategyCoverage.count === currentStrategyCoverage.total ? '#a8ffd0' : '#d4dce6'); + view.detail + .setText(currentStrategyCoverage.missing.length > 0 ? `빈 전법 ${currentStrategyCoverage.missing.map((definition) => definition.label).join('·')} · 미리보기는 실제 편성을 변경하지 않습니다.` : '전법 역할이 모두 갖춰졌습니다. 미리보기는 실제 편성을 변경하지 않습니다.') + .setColor(currentStrategyCoverage.missing.length > 0 ? '#ffdf7b' : '#9fb0bf'); return; } @@ -13813,7 +13834,7 @@ export class CampScene extends Phaser.Scene { view.title.setText(this.compactText(title, 18)); view.summary.setText( this.compactText( - `추격 후보 ${pursuitChanges.currentPairs.length}→${pursuitChanges.projectedPairs.length}조 · 역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3 · 지형 ${projected.terrainGrade}`, + `전법 ${currentStrategyCoverage.count}/${currentStrategyCoverage.total}→${projectedStrategyCoverage.count}/${projectedStrategyCoverage.total} · 역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3 · 추격 ${pursuitChanges.currentPairs.length}→${pursuitChanges.projectedPairs.length}조`, 26 ) ); @@ -13856,7 +13877,7 @@ export class CampScene extends Phaser.Scene { if (action?.kind === 'undo-preset' && this.sortiePresetUndoState) { view.impact - .setText(`현재 역할 ${current.coveredRoleCount}/3 · 추격 후보 ${pursuitChanges.currentPairs.length}조 · 지형 ${current.terrainGrade}`) + .setText(`현재 전법 ${currentStrategyCoverage.count}/${currentStrategyCoverage.total} · 역할 ${current.coveredRoleCount}/3 · 추격 ${pursuitChanges.currentPairs.length}조 · 지형 ${current.terrainGrade}`) .setColor('#a8ffd0'); view.detail .setText('유지 가능한 보급만 보존했습니다. 직전 적용은 한 번 되돌릴 수 있습니다.') @@ -13866,7 +13887,7 @@ export class CampScene extends Phaser.Scene { if (isUndoAction && this.sortieSwapUndoState) { view.impact - .setText(`현재 역할 ${current.coveredRoleCount}/3 · 추격 후보 ${pursuitChanges.currentPairs.length}조 · 지형 ${current.terrainGrade}`) + .setText(`현재 전법 ${currentStrategyCoverage.count}/${currentStrategyCoverage.total} · 역할 ${current.coveredRoleCount}/3 · 추격 ${pursuitChanges.currentPairs.length}조 · 지형 ${current.terrainGrade}`) .setColor('#a8ffd0'); view.detail .setText('장비·보급은 자동 이전하지 않았습니다. 직전 교체는 한 번 되돌릴 수 있습니다.') @@ -13893,14 +13914,15 @@ export class CampScene extends Phaser.Scene { const roleChange = roleChanges.join(' · ') || '역할 유지'; const pursuitDelta = pursuitChanges.projectedPairs.length - pursuitChanges.currentPairs.length; const signedPursuitDelta = pursuitDelta > 0 ? `+${pursuitDelta}` : `${pursuitDelta}`; + const strategyCoverageChange = `전법 ${currentStrategyCoverage.count}/${currentStrategyCoverage.total}→${projectedStrategyCoverage.count}/${projectedStrategyCoverage.total}`; view.impact .setText( this.compactText( - `역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3 · 추격 후보 ${pursuitChanges.currentPairs.length}→${pursuitChanges.projectedPairs.length}조 (${signedPursuitDelta}) · ${roleChange}`, + `${strategyCoverageChange} · 역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3 · 추격 ${pursuitChanges.currentPairs.length}→${pursuitChanges.projectedPairs.length}조 (${signedPursuitDelta}) · ${roleChange}`, 68 ) ) - .setColor(lostRoleLabels.length > 0 || pursuitDelta < 0 ? '#ff9d7d' : gainedRoleLabels.length > 0 || pursuitDelta > 0 ? '#a8ffd0' : '#d4dce6'); + .setColor(lostRoleLabels.length > 0 || strategyCoverageDelta < 0 || pursuitDelta < 0 ? '#ff9d7d' : gainedRoleLabels.length > 0 || strategyCoverageDelta > 0 || pursuitDelta > 0 ? '#a8ffd0' : '#d4dce6'); const gainedPursuits = pursuitChanges.gainedPairs.map((pair) => `추격 개방 ${this.sortiePursuitPairLabel(pair)}`); const lostPursuits = pursuitChanges.lostPairs.map((pair) => `추격 해제 ${this.sortiePursuitPairLabel(pair)}`); @@ -13910,10 +13932,14 @@ export class CampScene extends Phaser.Scene { .join(' · ') || '추격 변화 없음'; const terrainDelta = projected.terrainScore - current.terrainScore; const signedTerrainDelta = terrainDelta > 0 ? `+${terrainDelta}` : `${terrainDelta}`; + const strategyChanges = [ + gainedStrategies.length > 0 ? `전법 보강 ${gainedStrategies.map((definition) => definition.label).join('·')}` : '', + lostStrategies.length > 0 ? `전법 공백 ${lostStrategies.map((definition) => definition.label).join('·')}` : '' + ].filter(Boolean).join(' · ') || '전법 유지'; view.detail .setText( this.compactText( - `${pursuitLine} · 지형 ${current.terrainGrade} ${current.terrainScore}→${projected.terrainGrade} ${projected.terrainScore} (${signedTerrainDelta}) · ${roleChange} · 실제 편성 미변경`, + `${strategyChanges} · ${pursuitLine} · 지형 ${current.terrainGrade} ${current.terrainScore}→${projected.terrainGrade} ${projected.terrainScore} (${signedTerrainDelta}) · ${roleChange} · 실제 편성 미변경`, 82 ) ) @@ -14115,6 +14141,7 @@ export class CampScene extends Phaser.Scene { }) ).setDepth(depth + 2); this.trackSortie(this.add.text(cardX + 14, cardY + 278, this.compactText(this.sortieBondLine(unit), 26), this.textStyle(10, selected ? '#a8ffd0' : '#77818c', true))).setDepth(depth + 2); + this.trackSortie(this.add.text(cardX + 14, cardY + 294, this.compactText(this.sortieStrategyLine(unit.id), 24), this.textStyle(10, selected ? '#9fc8e8' : '#77818c', true))).setDepth(depth + 2); this.renderFirstSortieInlineButton( required ? '필수 출전' : selected ? '출전 확정' : '대기', @@ -14807,6 +14834,7 @@ export class CampScene extends Phaser.Scene { const scenario = this.nextSortieScenario(); const hasBattle = Boolean(scenario); const pursuit = this.sortiePursuitPairs(); + const strategyCoverage = this.sortieStrategyCoverage(); const canUsePresetBook = this.usesStagedSortiePrep() && this.sortiePrepStep === 'formation' && !this.isFirstSortiePrepSlice(); const canUseSortieOrder = this.usesStagedSortiePrep() && this.sortiePrepStep === 'formation' && hasBattle; const canRecommend = this.canApplyRecommendedSortiePlan(scenario) && this.sortieFormationPanelMode === 'roster'; @@ -14847,8 +14875,8 @@ export class CampScene extends Phaser.Scene { x + 18, y + 38, hasBattle - ? this.compactText(`군령 ${selectedOrder?.label ?? '미선택'} · ${plan.selectedCount}/${plan.maxCount}명 · 추격 후보 ${pursuit.activePairs.length}조 · 지형 ${plan.terrainGrade} · ${this.sortieRecommendedClassLine()}`, 54) - : `${plan.selectedCount}/${plan.maxCount}명 · 군영 의정 · ${this.sortieRecommendedClassLine()}`, + ? this.compactText(`군령 ${selectedOrder?.label ?? '미선택'} · ${plan.selectedCount}/${plan.maxCount}명 · 전법 ${strategyCoverage.count}/${strategyCoverage.total} · 추격 ${pursuit.activePairs.length}조 · 지형 ${plan.terrainGrade} · ${this.sortieRecommendedClassLine()}`, 54) + : `${plan.selectedCount}/${plan.maxCount}명 · 전법 ${strategyCoverage.count}/${strategyCoverage.total} · 군영 의정 · ${this.sortieRecommendedClassLine()}`, this.textStyle(11, '#d8b15f', true) ) ).setDepth(depth + 1); @@ -15026,7 +15054,12 @@ export class CampScene extends Phaser.Scene { this.renderSortieStatChip('move', '이', unit.move, x + 252, statY, chipWidth, depth + 1); this.trackSortie(this.add.text(x + 18, y + 137, `지형 ${terrainGrade} ${terrainScore} · ${this.sortieTerrainLine(unit)}`, this.textStyle(11, '#d8b15f', true))).setDepth(depth + 1); - this.trackSortie(this.add.text(x + 18, y + 157, this.compactText(synergyPreview.headline, 38), this.textStyle(11, synergyColor, true))).setDepth(depth + 1); + this.trackSortie( + this.add.text(x + 18, y + 157, this.compactText(this.sortieStrategyLine(unit.id), 18), this.textStyle(10, '#9fc8e8', true)) + ).setDepth(depth + 1); + this.trackSortie( + this.add.text(x + 154, y + 157, this.compactText(synergyPreview.headline, 25), this.textStyle(10, synergyColor, true)) + ).setDepth(depth + 1); equipmentSlots.forEach((slot, index) => { const rowY = y + 174 + index * 20; @@ -15238,10 +15271,20 @@ export class CampScene extends Phaser.Scene { statLine: `무 ${unit.stats.might} 지 ${unit.stats.intelligence} 통 ${unit.stats.leadership} 민 ${unit.stats.agility} 운 ${unit.stats.luck}`, equipmentLine: this.sortieEquipmentLine(unit), bondLine: this.sortieBondLine(unit), - terrainLine: this.sortieTerrainLine(unit) + terrainLine: this.sortieTerrainLine(unit), + strategyLine: this.sortieStrategyLine(unit.id) }; } + private sortieStrategyLine(unitId: string) { + const names = getUnitStrategyNames([unitId]); + return names.length > 0 ? `전법 ${names.join('·')}` : '전법 없음'; + } + + private sortieStrategyCoverage(unitIds = this.selectedSortieUnitIds) { + return getUnitStrategyCoverage(unitIds); + } + private sortieEquipmentLine(unit: UnitData) { return equipmentSlots .map((slot) => { @@ -16615,6 +16658,7 @@ export class CampScene extends Phaser.Scene { equipmentLine: summary.equipmentLine, bondLine: summary.bondLine, terrainLine: summary.terrainLine, + strategyLine: summary.strategyLine, equipment: equipmentSlots.map((slot) => { const state = unit.equipment[slot]; const item = getItem(state.itemId); @@ -20874,6 +20918,13 @@ export class CampScene extends Phaser.Scene { const portraitRosterMaxScroll = Math.max(0, (portraitRosterPageCount - 1) * sortiePortraitRosterPageSize); const sortieComparisonPreview = this.sortieFormationComparisonPreview(); const sortieComparisonAction = this.sortieComparisonAction(sortieComparisonPreview); + const sortieStrategyCoverage = this.sortieStrategyCoverage(); + const sortieComparisonStrategyCoverage = sortieComparisonPreview + ? compareUnitStrategyCoverage( + sortieComparisonPreview.current.selectedUnitIds, + sortieComparisonPreview.projected?.selectedUnitIds ?? sortieComparisonPreview.current.selectedUnitIds + ) + : null; const sortiePursuit = this.sortiePursuitPairs(); const sortieCoreResonanceCandidates = this.sortieCoreResonanceCandidates(); const sortieCoreResonanceSelection = this.campaign?.sortieResonanceSelection; @@ -21246,6 +21297,8 @@ export class CampScene extends Phaser.Scene { available: availability.available, availabilityLabel: availability.label, availabilityReason: availability.reason, + strategyNames: getUnitStrategyNames([unit.id]), + strategyLine: this.sortieStrategyLine(unit.id), portraitTextureKey: unitPortraitTextureKey ?? null, portraitReady: Boolean(unitPortraitTextureKey && this.textures.exists(unitPortraitTextureKey)) }; @@ -21312,7 +21365,12 @@ export class CampScene extends Phaser.Scene { }; }) }, - sortieComparisonPreview: sortieComparisonPreview ?? null, + sortieComparisonPreview: sortieComparisonPreview + ? { + ...sortieComparisonPreview, + strategyCoverage: sortieComparisonStrategyCoverage + } + : null, sortieComparisonAction: sortieComparisonAction ? { ...sortieComparisonAction, enabled: true } : null, sortieSwapUndo: this.sortieSwapUndoState ? { @@ -21328,6 +21386,12 @@ export class CampScene extends Phaser.Scene { } : null, sortieFocusedUnit: this.sortieFocusedUnitSummary(), + sortieStrategyCoverage: { + total: sortieStrategyCoverage.total, + count: sortieStrategyCoverage.count, + covered: sortieStrategyCoverage.covered.map((definition) => ({ ...definition })), + missing: sortieStrategyCoverage.missing.map((definition) => ({ ...definition })) + }, sortiePlanFeedback: this.sortiePlanFeedback, sortieChecklist, sortieChecklistSummary: this.sortieChecklistSummary(sortieChecklist), @@ -21369,7 +21433,8 @@ export class CampScene extends Phaser.Scene { statLine: summary.statLine, equipmentLine: summary.equipmentLine, bondLine: summary.bondLine, - terrainLine: summary.terrainLine + terrainLine: summary.terrainLine, + strategyLine: summary.strategyLine }; }), equipmentInventory: this.equipmentInventoryEntries().map((entry) => ({