diff --git a/scripts/qa-representative-battles.mjs b/scripts/qa-representative-battles.mjs index 99817fd..f984f30 100644 --- a/scripts/qa-representative-battles.mjs +++ b/scripts/qa-representative-battles.mjs @@ -1277,6 +1277,7 @@ async function setupBattle(page, battle) { { timeout: 90000 } ); await startDeploymentIfNeeded(page, battle.id); + await verifySortieDoctrine(page, battle); await normalizeRepresentativeBattleUnits(page, battle); } @@ -1414,6 +1415,7 @@ async function setupCumulativeBattle(page, battle, firstBattle) { { timeout: 90000 } ); await startDeploymentIfNeeded(page, battle.id); + await verifySortieDoctrine(page, battle); await normalizeRepresentativeBattleUnits(page, battle); return preparation; } @@ -1520,6 +1522,48 @@ async function startDeploymentIfNeeded(page, battleId) { ); } +async function verifySortieDoctrine(page, battle) { + const state = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const doctrine = state?.sortieDoctrine; + if (!doctrine?.active || doctrine.battleId !== battle.id || doctrine.openingBannerShown !== true) { + throw new Error(`Sortie doctrine did not activate for ${battle.id}: ${JSON.stringify(doctrine)}`); + } + + const expectedFirstPursuit = battle.id === 'second-battle-yellow-turban-pursuit'; + if ( + state.firstSortieRoleEffects?.active !== expectedFirstPursuit || + state.tacticalInitiative?.active !== expectedFirstPursuit + ) { + throw new Error( + `First-pursuit-only mechanics leaked for ${battle.id}: ${JSON.stringify({ + firstSortieRoleEffects: state.firstSortieRoleEffects, + tacticalInitiative: state.tacticalInitiative + })}` + ); + } + + const roleGroups = new Map((doctrine.roles ?? []).map((group) => [group.role, new Set(group.unitIds ?? [])])); + const coreRoles = new Set(['front', 'flank', 'support']); + const sealByRole = { front: '전', flank: '돌', support: '후' }; + const deployedAllies = (state.units ?? []).filter((unit) => unit.faction === 'ally'); + for (const unit of deployedAllies) { + if (coreRoles.has(unit.formationRole)) { + if ( + !unit.sortieRoleEffect || + !unit.roleSealPresent || + unit.roleSealText !== sealByRole[unit.formationRole] || + !roleGroups.get(unit.formationRole)?.has(unit.id) + ) { + throw new Error(`Missing doctrine effect or seal for ${battle.id}/${unit.id}: ${JSON.stringify(unit)}`); + } + continue; + } + if (unit.formationRole === 'reserve' && (unit.sortieRoleEffect || unit.roleSealPresent || unit.roleSealText !== null)) { + throw new Error(`Reserve unit received a doctrine effect for ${battle.id}/${unit.id}: ${JSON.stringify(unit)}`); + } + } +} + async function writeCampaignSnapshot(page, snapshotPath) { const snapshot = await page.evaluate( ({ slotKey, storageKey }) => window.localStorage.getItem(slotKey) ?? window.localStorage.getItem(storageKey), diff --git a/scripts/verify-battle-scenario-data.mjs b/scripts/verify-battle-scenario-data.mjs index 8f29b85..840e2bd 100644 --- a/scripts/verify-battle-scenario-data.mjs +++ b/scripts/verify-battle-scenario-data.mjs @@ -303,6 +303,11 @@ function validateSortie(errors, scenario, classKeys, formationRoles, terrainRule } requiredUnitIds.add(unitId); }); + scenario.defeatConditions.forEach((condition) => { + if (allyUnitIds.has(condition.unitId) && !requiredUnitIds.has(condition.unitId)) { + errors.push(`${context}: allied defeat-condition unit "${condition.unitId}" must be required for sortie`); + } + }); excludedUnits.forEach((unitId) => { if (excludedUnitIds.has(unitId)) { errors.push(`${context}: duplicate excluded unit "${unitId}"`); diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index d6f67ce..a8ef6f0 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -75,9 +75,9 @@ try { assert(firstBattleProbe.objectiveSubText?.includes('패배 조건:'), `Expected clear defeat condition label: ${JSON.stringify(firstBattleProbe)}`); assert(firstBattleProbe.objectiveSubText?.includes('진군:'), `Expected tactical route in objective tracker: ${JSON.stringify(firstBattleProbe)}`); assert( - firstBattleProbe.sideTexts.some((text) => text.includes('작전 목표')) && + firstBattleProbe.sideTexts.some((text) => text.includes('출진 군세')) && !firstBattleProbe.sideTexts.some((text) => text.includes('...')), - `Expected opening tactical message without ASCII truncation: ${JSON.stringify(firstBattleProbe.sideTexts)}` + `Expected sortie doctrine opening message without ASCII truncation: ${JSON.stringify(firstBattleProbe.sideTexts)}` ); await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); @@ -188,7 +188,7 @@ try { await page.screenshot({ path: `${screenshotDir}/rc-second-battle-from-first-camp.png`, fullPage: true }); await assertCanvasPainted(page, 'second battle from first camp'); - const secondBattleProbe = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const secondBattleProbe = await readBattleDoctrineProbe(page); assert(secondBattleProbe?.battleId === 'second-battle-yellow-turban-pursuit', `Expected first camp sortie to enter second battle: ${JSON.stringify(secondBattleProbe)}`); assertSameMembers( secondBattleProbe?.selectedSortieUnitIds, @@ -205,6 +205,112 @@ try { firstCampDeploymentPreview, `Expected second battle ally positions to match first camp deployment preview: ${JSON.stringify(secondBattleProbe?.deployedAllyPositions)} / ${JSON.stringify(firstCampDeploymentPreview)}` ); + assertSortieDoctrine(secondBattleProbe, { requireFullCoverage: true, requireVisibleSeals: true }); + assert( + secondBattleProbe.firstSortieRoleEffects?.active === true && + secondBattleProbe.firstSortieRoleEffects?.fullCoverage === true && + secondBattleProbe.firstSortieRoleEffects?.trinityActive === true && + secondBattleProbe.firstSortieRoleEffects?.trinitySupportRange === 2, + `Expected the second battle to retain its three-role Peach Garden trinity special: ${JSON.stringify(secondBattleProbe.firstSortieRoleEffects)}` + ); + assert( + secondBattleProbe.sortieDoctrine?.roles?.every((role) => role.unitIds.length === 1) && + secondBattleProbe.sortieDoctrine?.activeBondCount === 3, + `Expected the second battle doctrine to expose one officer per role and all three active bonds: ${JSON.stringify(secondBattleProbe.sortieDoctrine)}` + ); + assert( + secondBattleProbe.openingBannerTexts?.some((text) => text.includes('세 형제 생존 중 공명 지원 거리 2칸')) && + secondBattleProbe.openingBannerTexts?.some((text) => text.includes('전술 명령')), + `Expected the second battle opening banner to retain trinity and tactical-command guidance: ${JSON.stringify(secondBattleProbe.openingBannerTexts)}` + ); + const roleSealResyncProbe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + if (!scene) { + return undefined; + } + const officerIds = ['liu-bei', 'guan-yu', 'zhang-fei']; + const campaignCurrentKey = 'heros-web:campaign-state'; + const campaignSlotKey = `${campaignCurrentKey}:slot-1`; + const battleKey = 'heros-web:battle:second-battle-yellow-turban-pursuit'; + const storageKeys = [campaignCurrentKey, campaignSlotKey, battleKey, `${battleKey}:slot-1`, 'heros-web:first-battle-state']; + const storageSnapshot = new Map(storageKeys.map((key) => [key, window.localStorage.getItem(key)])); + const captureUnits = () => { + const byId = new Map((window.__HEROS_DEBUG__?.battle()?.units ?? []).map((unit) => [unit.id, unit])); + return officerIds.map((id) => { + const unit = byId.get(id); + return { + id, + role: unit?.formationRole ?? null, + effect: unit?.sortieRoleEffect?.label ?? null, + seal: unit?.roleSealText ?? null, + sealPresent: unit?.roleSealPresent ?? false, + sealVisible: unit?.roleSealVisible ?? false + }; + }); + }; + const loadAssignments = (savedCampaignRaw, assignments) => { + const campaign = JSON.parse(savedCampaignRaw); + campaign.sortieFormationAssignments = assignments; + window.localStorage.setItem(campaignSlotKey, JSON.stringify(campaign)); + scene.loadBattleState?.(1); + }; + + scene.saveBattleState?.(1); + const savedCampaignRaw = window.localStorage.getItem(campaignSlotKey); + if (!savedCampaignRaw) { + return { error: 'campaign slot save missing' }; + } + + loadAssignments(savedCampaignRaw, { 'liu-bei': 'front', 'guan-yu': 'support', 'zhang-fei': 'reserve' }); + const changed = captureUnits(); + + loadAssignments(savedCampaignRaw, { 'liu-bei': 'reserve', 'guan-yu': 'reserve', 'zhang-fei': 'reserve' }); + scene.hideBattleEventBanner?.(); + scene.triggeredBattleEvents?.delete('opening'); + scene.showOpeningBattleEvent?.(); + const allReserve = { + doctrineActive: window.__HEROS_DEBUG__?.battle()?.sortieDoctrine?.active ?? false, + coveredRoleCount: window.__HEROS_DEBUG__?.battle()?.sortieDoctrine?.coveredRoleCount ?? -1, + bannerTexts: (scene.battleEventObjects ?? []) + .filter((object) => object?.type === 'Text') + .map((object) => object.text) + }; + scene.hideBattleEventBanner?.(); + + window.localStorage.setItem(campaignSlotKey, savedCampaignRaw); + scene.loadBattleState?.(1); + const restored = captureUnits(); + storageSnapshot.forEach((value, key) => { + if (value === null) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, value); + } + }); + return { changed, allReserve, restored }; + }); + assert( + JSON.stringify(roleSealResyncProbe?.changed) === JSON.stringify([ + { id: 'liu-bei', role: 'front', effect: '전열 방호', seal: '전', sealPresent: true, sealVisible: true }, + { id: 'guan-yu', role: 'support', effect: '후원 지휘', seal: '후', sealPresent: true, sealVisible: true }, + { id: 'zhang-fei', role: 'reserve', effect: null, seal: null, sealPresent: false, sealVisible: false } + ]), + `Expected role seals to resync when a loaded formation changes roles: ${JSON.stringify(roleSealResyncProbe)}` + ); + assert( + roleSealResyncProbe?.allReserve?.doctrineActive === true && + roleSealResyncProbe?.allReserve?.coveredRoleCount === 0 && + roleSealResyncProbe?.allReserve?.bannerTexts?.some((text) => text.startsWith('출진 군세')), + `Expected an all-reserve formation to keep the sortie-doctrine opening banner: ${JSON.stringify(roleSealResyncProbe?.allReserve)}` + ); + assert( + JSON.stringify(roleSealResyncProbe?.restored) === JSON.stringify([ + { id: 'liu-bei', role: 'support', effect: '후원 지휘', seal: '후', sealPresent: true, sealVisible: true }, + { id: 'guan-yu', role: 'front', effect: '전열 방호', seal: '전', sealPresent: true, sealVisible: true }, + { id: 'zhang-fei', role: 'flank', effect: '돌파 선봉', seal: '돌', sealPresent: true, sealVisible: true } + ]), + `Expected loaded role seals to restore the original formation cleanly: ${JSON.stringify(roleSealResyncProbe?.restored)}` + ); await seedCampaignSave(page, { battleId: 'fifty-eighth-battle-qishan-retreat', @@ -303,8 +409,26 @@ try { `Expected an under-capacity manual formation to survive scene recreation: ${JSON.stringify(restoredUnderCapacityState?.selectedSortieUnitIds)} / ${JSON.stringify(underCapacityProbe)}` ); - const midCampSelectedSortieUnitIds = restoredUnderCapacityState.selectedSortieUnitIds ?? []; - const midCampDeploymentPreview = restoredUnderCapacityState.sortieDeploymentPreview ?? []; + const reserveDoctrineSetup = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const state = window.__HEROS_DEBUG__?.camp(); + const supportUnitIds = (state?.sortieRoster ?? []) + .filter((unit) => unit.selected && unit.formationRole === 'support') + .map((unit) => unit.id); + scene.sortieFormationAssignments = { + ...scene.sortieFormationAssignments, + ...Object.fromEntries(supportUnitIds.map((unitId) => [unitId, 'reserve'])) + }; + scene.persistSortieSelection(); + return { supportUnitIds, state: window.__HEROS_DEBUG__?.camp() }; + }); + assert( + reserveDoctrineSetup.supportUnitIds.length > 0 && reserveDoctrineSetup.state?.sortiePlan?.formationCoverageComplete === false, + `Expected the mid-campaign doctrine setup to leave support uncovered with reserve officers: ${JSON.stringify(reserveDoctrineSetup)}` + ); + + const midCampSelectedSortieUnitIds = reserveDoctrineSetup.state?.selectedSortieUnitIds ?? []; + const midCampDeploymentPreview = reserveDoctrineSetup.state?.sortieDeploymentPreview ?? []; await page.mouse.click(1160, 38); await waitForSortiePrep(page); await page.mouse.click(1116, 656); @@ -312,7 +436,7 @@ try { await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-next-battle.png`, fullPage: true }); await assertCanvasPainted(page, 'mid camp next battle'); - const midNextBattleProbe = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const midNextBattleProbe = await readBattleDoctrineProbe(page); assert(midNextBattleProbe?.battleId === midCampNextBattleId, `Expected mid-campaign sortie to enter next battle: ${JSON.stringify(midNextBattleProbe)} / ${midCampNextBattleId}`); assertSameMembers( midNextBattleProbe?.selectedSortieUnitIds, @@ -329,6 +453,25 @@ try { midCampDeploymentPreview, `Expected mid-campaign next battle ally positions to match deployment preview: ${JSON.stringify(midNextBattleProbe?.deployedAllyPositions)} / ${JSON.stringify(midCampDeploymentPreview)}` ); + assertSortieDoctrine(midNextBattleProbe, { requireReserve: true }); + assert( + midNextBattleProbe.sortieDoctrine?.coveredRoleCount < 3, + `Expected the seeded mid-campaign formation to allow a missing doctrine role: ${JSON.stringify(midNextBattleProbe.sortieDoctrine)}` + ); + assert( + midNextBattleProbe.firstSortieRoleEffects?.active === false && + midNextBattleProbe.firstSortieRoleEffects?.trinityActive === false, + `Expected Peach Garden trinity to remain exclusive to the second battle: ${JSON.stringify(midNextBattleProbe.firstSortieRoleEffects)}` + ); + assert( + midNextBattleProbe.units.some((unit) => + unit.faction === 'ally' && + !['liu-bei', 'guan-yu', 'zhang-fei'].includes(unit.id) && + ['front', 'flank', 'support'].includes(unit.formationRole) && + unit.sortieRoleEffect + ), + `Expected a non-Peach-Garden officer to receive a generic sortie role effect: ${JSON.stringify(midNextBattleProbe.units)}` + ); await seedCampaignSave(page, { battleId: 'sixty-sixth-battle-wuzhang-final', @@ -636,6 +779,27 @@ async function readCampaignSave(page) { }); } +async function readBattleDoctrineProbe(page) { + return page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.battle(); + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const roleSeals = (state?.units ?? []) + .filter((unit) => unit.faction === 'ally') + .map((unit) => { + const seal = scene?.unitViews?.get(unit.id)?.roleSeal; + return { + unitId: unit.id, + text: seal?.text ?? null, + visible: seal?.visible ?? false + }; + }); + const openingBannerTexts = (scene?.battleEventObjects ?? []) + .filter((object) => object?.type === 'Text') + .map((object) => object.text); + return { ...state, roleSeals, openingBannerTexts }; + }); +} + async function seedCampaignSave(page, options) { await page.evaluate((seed) => { const storageKey = 'heros-web:campaign-state'; @@ -795,6 +959,70 @@ function assertDeploymentMatchesPreview(positions, preview, message) { ); } +function assertSortieDoctrine(probe, options = {}) { + const doctrine = probe?.sortieDoctrine; + const expectedSealByRole = { front: '전', flank: '돌', support: '후' }; + const expectedRoles = Object.keys(expectedSealByRole); + const roles = doctrine?.roles ?? []; + const roleById = new Map(roles.map((role) => [role.role, role])); + const allyUnits = (probe?.units ?? []).filter((unit) => unit.faction === 'ally'); + const sealsByUnitId = new Map((probe?.roleSeals ?? []).map((seal) => [seal.unitId, seal])); + const effectUnits = allyUnits.filter((unit) => expectedRoles.includes(unit.formationRole)); + const reserveUnits = allyUnits.filter((unit) => unit.formationRole === 'reserve'); + const coveredRoleCount = roles.filter((role) => role.unitIds.length > 0).length; + + assert(doctrine?.active === true, `Expected sortie doctrine to be active: ${JSON.stringify(probe)}`); + assert(doctrine?.openingBannerShown === true, `Expected sortie doctrine opening banner to be shown: ${JSON.stringify(doctrine)}`); + assert( + roles.length === expectedRoles.length && expectedRoles.every((role) => roleById.has(role)), + `Expected doctrine debug state to expose front, flank, and support roles: ${JSON.stringify(doctrine)}` + ); + assert( + doctrine.coveredRoleCount === coveredRoleCount, + `Expected doctrine role coverage to match non-empty role groups: ${JSON.stringify(doctrine)}` + ); + if (options.requireFullCoverage) { + assert(coveredRoleCount === 3, `Expected all three doctrine roles to be covered: ${JSON.stringify(doctrine)}`); + } + + assert(effectUnits.length > 0, `Expected at least one deployed officer with a doctrine role effect: ${JSON.stringify(allyUnits)}`); + effectUnits.forEach((unit) => { + const role = roleById.get(unit.formationRole); + const seal = sealsByUnitId.get(unit.id); + assert( + role?.unitIds.includes(unit.id) && + unit.sortieRoleEffect?.label === role.label && + unit.sortieRoleEffect?.summary === role.summary, + `Expected ${unit.id} to expose the assigned ${unit.formationRole} doctrine effect: ${JSON.stringify({ unit, role })}` + ); + assert( + seal?.text === expectedSealByRole[unit.formationRole] && unit.roleSealVisible === seal.visible, + `Expected ${unit.id} to show the ${expectedSealByRole[unit.formationRole]} role seal: ${JSON.stringify({ unit, seal })}` + ); + if (options.requireVisibleSeals) { + assert( + unit.roleSealVisible === true && seal.visible === true, + `Expected ${unit.id} role seal to be visible in the opening formation: ${JSON.stringify({ unit, seal })}` + ); + } + }); + + if (options.requireReserve) { + assert(reserveUnits.length > 0, `Expected the doctrine probe to include a reserve officer: ${JSON.stringify(allyUnits)}`); + } + reserveUnits.forEach((unit) => { + const seal = sealsByUnitId.get(unit.id); + assert( + unit.sortieRoleEffect === null && + unit.roleSealVisible === false && + seal?.visible === false && + seal?.text === null && + roles.every((role) => !role.unitIds.includes(unit.id)), + `Expected reserve officer ${unit.id} to have no doctrine effect or role seal: ${JSON.stringify({ unit, seal, doctrine })}` + ); + }); +} + function runCommand(command, args, env = {}) { const result = spawnSync(command, args, { cwd: process.cwd(), env: { ...process.env, ...env }, stdio: 'inherit' }); if (result.status !== 0) { diff --git a/scripts/verify-sortie-synergy.mjs b/scripts/verify-sortie-synergy.mjs index 503a342..871418f 100644 --- a/scripts/verify-sortie-synergy.mjs +++ b/scripts/verify-sortie-synergy.mjs @@ -10,10 +10,26 @@ try { const { compareSortieSynergy, evaluateSortieSynergy, + firstPursuitRolePreviewSummaries, firstPursuitSynergyBattleId, - sortieBondBonusForLevel + sortieBondBonusForLevel, + sortieRoleEffectSummaries } = await server.ssrLoadModule('/src/game/data/sortieSynergy.ts'); + const expectedRoleEffectSummaries = { + front: '일반 공격 피해 -8%(최소 1) · 반격 +20%', + flank: '초반 2턴 이동 +1 · 공격 피해 +10%', + support: '공격 책략 +10% · 회복 +2' + }; + assert( + JSON.stringify(sortieRoleEffectSummaries) === JSON.stringify(expectedRoleEffectSummaries), + `Expected the three generic sortie role effects to retain their combat values and copy: ${JSON.stringify(sortieRoleEffectSummaries)}` + ); + assert( + firstPursuitRolePreviewSummaries === sortieRoleEffectSummaries, + 'Expected the first-pursuit role preview export to remain a compatibility alias.' + ); + const boundaryCases = [ [19, 0, 0], [20, 3, 0], @@ -98,7 +114,7 @@ try { ); assert(JSON.stringify({ members, bonds }) === frozenInputs, 'Expected synergy evaluation not to mutate its inputs.'); - console.log('Verified sortie synergy previews, bond thresholds, role coverage, and first-pursuit trinity.'); + console.log('Verified generic sortie role effects, synergy previews, bond thresholds, role coverage, and first-pursuit trinity.'); } finally { await server.close(); } diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index d520099..cdd1a5f 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -8456,7 +8456,7 @@ export const fiftySeventhBattleScenario: BattleScenarioDefinition = { }, sortie: { sortieLimit: 7, - requiredUnits: ['zhuge-liang'], + requiredUnits: ['zhuge-liang', 'wang-ping'], recommendedUnits: [ { unitId: 'zhuge-liang', role: 'support', reason: '가정 배치 위기의 총지휘관입니다. 고지와 물길 판단을 끝까지 조율해야 합니다.' }, { unitId: 'wang-ping', role: 'front', reason: '가정의 물길과 고지 배치를 읽는 핵심 장수입니다. 왕평 생존 목표도 직접 걸려 있습니다.' }, diff --git a/src/game/data/sortieSynergy.ts b/src/game/data/sortieSynergy.ts index 78ae7b8..66bf049 100644 --- a/src/game/data/sortieSynergy.ts +++ b/src/game/data/sortieSynergy.ts @@ -3,11 +3,12 @@ import type { SortieFormationRole } from './sortieDeployment'; export const coreSortieSynergyRoles = ['front', 'flank', 'support'] as const; export const firstPursuitSynergyBattleId = 'second-battle-yellow-turban-pursuit'; export const firstPursuitBrotherUnitIds = ['liu-bei', 'guan-yu', 'zhang-fei'] as const; -export const firstPursuitRolePreviewSummaries: Partial> = { +export const sortieRoleEffectSummaries: Partial> = { front: '일반 공격 피해 -8%(최소 1) · 반격 +20%', flank: '초반 2턴 이동 +1 · 공격 피해 +10%', support: '공격 책략 +10% · 회복 +2' }; +export const firstPursuitRolePreviewSummaries = sortieRoleEffectSummaries; export type SortieSynergyMember = { id: string; diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 9a5f679..1d31b9c 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -45,9 +45,9 @@ import { import { coreSortieSynergyRoles, firstPursuitBrotherUnitIds, - firstPursuitRolePreviewSummaries, firstPursuitSynergyBattleId, - sortieBondBonusForLevel + sortieBondBonusForLevel, + sortieRoleEffectSummaries } from '../data/sortieSynergy'; import { campaignSaveSlotCount, @@ -1115,6 +1115,7 @@ type UnitView = { sprite: Phaser.GameObjects.Sprite; hitZone: Phaser.GameObjects.Zone; label: Phaser.GameObjects.Text; + roleSeal?: Phaser.GameObjects.Text; textureBase: string; direction: UnitDirection; baseX: number; @@ -1146,14 +1147,13 @@ type SaveSlotMode = 'save' | 'load'; type TurnPromptMode = 'turn-end' | 'post-move' | 'load-confirm' | 'save-overwrite-confirm'; const battleSpeedStorageKey = 'heros-web:battle-speed'; -const firstPursuitRoleEffectBattleId = firstPursuitSynergyBattleId; -const firstPursuitRoleOrder: SortieFormationRole[] = [...coreSortieSynergyRoles]; +const sortieRoleOrder: SortieFormationRole[] = [...coreSortieSynergyRoles]; const tacticalInitiativeThreshold = 3; const tacticalInitiativeFrontReduction = 50; const tacticalInitiativeFlankDamageBonus = 30; const tacticalInitiativeFlankHitBonus = 20; const tacticalInitiativeSupportHeal = 6; -const firstPursuitRoleEffects: Partial> = { front: { label: '전열 방호', - summary: firstPursuitRolePreviewSummaries.front ?? '', + summary: sortieRoleEffectSummaries.front ?? '', icon: 'leadership', tone: 0x59d18c }, flank: { label: '돌파 선봉', - summary: firstPursuitRolePreviewSummaries.flank ?? '', + summary: sortieRoleEffectSummaries.flank ?? '', icon: 'move', tone: 0xd8b15f }, support: { label: '후원 지휘', - summary: firstPursuitRolePreviewSummaries.support ?? '', + summary: sortieRoleEffectSummaries.support ?? '', icon: 'intelligence', tone: 0x83d6ff } @@ -3522,14 +3522,12 @@ export class BattleScene extends Phaser.Scene { effectiveSortieUnitIds, assignments, (unit) => this.recommendedSortieFormationRole(unit), - this.isFirstPursuitRoleEffectBattle() - ? battleScenario.sortie?.deploymentSlots.map((slot) => ({ - x: slot.x, - y: slot.y, - role: slot.role, - label: slot.label - })) - : battleScenario.sortie?.deploymentSlots + battleScenario.sortie?.deploymentSlots.map((slot) => ({ + x: slot.x, + y: slot.y, + role: slot.role, + label: slot.label + })) ); battleUnits.splice(0, battleUnits.length, ...applySortieDeploymentPlan(nextUnits, deploymentPlan)); battleBonds.splice(0, battleBonds.length, ...initialBattleBonds.map((bond) => this.applyCampaignBondProgress(bond, campaign))); @@ -3723,33 +3721,65 @@ export class BattleScene extends Phaser.Scene { } private isFirstPursuitRoleEffectBattle() { - return battleScenario.id === firstPursuitRoleEffectBattleId; + return battleScenario.id === firstPursuitSynergyBattleId; } - private firstPursuitRoleForUnit(unit: UnitData): SortieFormationRole | undefined { - if ( - !this.isFirstPursuitRoleEffectBattle() || - unit.faction !== 'ally' || - !firstPursuitBrotherUnitIds.includes(unit.id as (typeof firstPursuitBrotherUnitIds)[number]) - ) { + private sortieRoleForUnit(unit: UnitData): SortieFormationRole | undefined { + if (!battleScenario.sortie || unit.faction !== 'ally') { return undefined; } const assignments = this.effectiveSortieFormationAssignments(getCampaignState()); return assignments[unit.id] ?? this.recommendedSortieFormationRole(unit); } - private firstPursuitRoleAssignments() { + private firstPursuitRoleForUnit(unit: UnitData): SortieFormationRole | undefined { + if ( + !this.isFirstPursuitRoleEffectBattle() || + !firstPursuitBrotherUnitIds.includes(unit.id as (typeof firstPursuitBrotherUnitIds)[number]) + ) { + return undefined; + } + return this.sortieRoleForUnit(unit); + } + + private sortieRoleAssignments() { + const selectedOrder = new Map(this.effectiveSortieUnitIds(getCampaignState()).map((unitId, index) => [unitId, index])); return battleUnits - .map((unit) => { - const role = this.firstPursuitRoleForUnit(unit); - if (!role) { - return undefined; - } - const definition = firstPursuitRoleEffects[role]; - return definition ? { role, unit, definition } : undefined; + .map((unit, index) => { + const role = this.sortieRoleForUnit(unit); + const definition = role ? sortieRoleEffects[role] : undefined; + return role && definition ? { role, unit, definition, index } : undefined; }) .filter((entry): entry is NonNullable => Boolean(entry)) - .sort((left, right) => firstPursuitRoleOrder.indexOf(left.role) - firstPursuitRoleOrder.indexOf(right.role)); + .sort((left, right) => ( + sortieRoleOrder.indexOf(left.role) - sortieRoleOrder.indexOf(right.role) || + (selectedOrder.get(left.unit.id) ?? left.index) - (selectedOrder.get(right.unit.id) ?? right.index) + )); + } + + private sortieRoleGroups() { + const assignments = this.sortieRoleAssignments(); + return coreSortieSynergyRoles.map((role) => ({ + role, + definition: sortieRoleEffects[role]!, + units: assignments.filter((entry) => entry.role === role).map((entry) => entry.unit) + })); + } + + private activeSortieBonds() { + const deployedIds = new Set(battleUnits.filter((unit) => unit.faction === 'ally').map((unit) => unit.id)); + return Array.from(this.bondStates.values()) + .filter((bond) => bond.unitIds.every((unitId) => deployedIds.has(unitId))) + .sort((left, right) => right.level - left.level || left.title.localeCompare(right.title)); + } + + private firstPursuitRoleAssignments() { + if (!this.isFirstPursuitRoleEffectBattle()) { + return []; + } + return this.sortieRoleAssignments().filter(({ unit }) => ( + firstPursuitBrotherUnitIds.includes(unit.id as (typeof firstPursuitBrotherUnitIds)[number]) + )); } private firstPursuitTrinityConfigured() { @@ -3763,7 +3793,7 @@ export class BattleScene extends Phaser.Scene { return false; } const roles = new Set(brothers.map((unit) => this.firstPursuitRoleForUnit(unit))); - return firstPursuitRoleOrder.every((role) => roles.has(role)); + return sortieRoleOrder.every((role) => roles.has(role)); } private firstPursuitTrinityActive() { @@ -3772,17 +3802,22 @@ export class BattleScene extends Phaser.Scene { ); } - private firstPursuitOpeningLines() { - const lines = this.firstPursuitRoleAssignments().map(({ role, unit, definition }) => ( - `${this.deploymentRoleLabel(role)} ${unit.name} · ${definition.summary}` + private sortieOpeningLines() { + const lines = this.sortieRoleGroups().map(({ role, units, definition }) => ( + `${this.deploymentRoleLabel(role)} ${units.map((unit) => unit.name).join('·') || '담당 없음'} · ${definition.summary}` )); if (this.firstPursuitTrinityConfigured()) { lines.push('도원 공명 · 세 형제 생존 중 공명 지원 거리 2칸'); } + const strongestBond = this.activeSortieBonds()[0]; + if (strongestBond) { + const bonus = sortieBondBonusForLevel(strongestBond.level); + lines.push(`공명 ${strongestBond.title} Lv${strongestBond.level} · 피해 +${bonus.damageBonus}%${bonus.chainRate > 0 ? ` · 연계 ${bonus.chainRate}%` : ''}`); + } return lines; } - private firstPursuitRoleCombatEffect( + private sortieRoleCombatEffect( attacker: UnitData, defender: UnitData, action: DamageCommand, @@ -3790,18 +3825,18 @@ export class BattleScene extends Phaser.Scene { ignoreSortieEffects = false ) { const effect = { - role: this.firstPursuitRoleForUnit(attacker), + role: this.sortieRoleForUnit(attacker), damageBonus: 0, damageReduction: 0, hitBonus: 0, criticalBonus: 0, labels: [] as string[] }; - if (ignoreSortieEffects || !this.isFirstPursuitRoleEffectBattle()) { + if (ignoreSortieEffects) { return effect; } - const defenderRole = this.firstPursuitRoleForUnit(defender); + const defenderRole = this.sortieRoleForUnit(defender); if (action === 'attack' && defenderRole === 'front') { effect.damageReduction += 8; effect.labels.push('전열 방호 -8%'); @@ -3821,8 +3856,8 @@ export class BattleScene extends Phaser.Scene { return effect; } - private firstPursuitSupportHealBonus(user: UnitData, usable: BattleUsable, ignoreSortieEffects = false) { - return !ignoreSortieEffects && usable.effect === 'heal' && this.firstPursuitRoleForUnit(user) === 'support' ? 2 : 0; + private sortieSupportHealBonus(user: UnitData, usable: BattleUsable, ignoreSortieEffects = false) { + return !ignoreSortieEffects && usable.effect === 'heal' && this.sortieRoleForUnit(user) === 'support' ? 2 : 0; } private tacticalInitiativeCounterplayCount() { @@ -3993,20 +4028,20 @@ export class BattleScene extends Phaser.Scene { this.renderTacticalInitiativeChip(); } - private firstPursuitContributionLine() { - if (!this.isFirstPursuitRoleEffectBattle()) { + private sortieContributionLine() { + if (this.sortieRoleAssignments().length === 0) { return undefined; } - const parts = this.firstPursuitRoleAssignments().map(({ role, unit }) => { + const parts = this.sortieRoleAssignments().map(({ role, unit }) => { const stats = this.statsFor(unit.id); - const counterplay = this.intentCounterplayContributionText(stats); + const counterplay = this.isFirstPursuitRoleEffectBattle() ? `${this.intentCounterplayContributionText(stats)}·` : ''; if (role === 'front') { - return `전열 ${unit.name}: ${counterplay}·감소 ${stats.sortiePreventedDamage}·반격 추가 ${stats.sortieBonusDamage}`; + return `전열 ${unit.name}: ${counterplay}감소 ${stats.sortiePreventedDamage}·반격 추가 ${stats.sortieBonusDamage}`; } if (role === 'flank') { - return `돌파 ${unit.name}: ${counterplay}·추가 피해 ${stats.sortieBonusDamage}`; + return `돌파 ${unit.name}: ${counterplay}추가 피해 ${stats.sortieBonusDamage}`; } - return `후원 ${unit.name}: ${counterplay}·추가 피해 ${stats.sortieBonusDamage}·회복 ${stats.sortieBonusHealing}`; + return `후원 ${unit.name}: ${counterplay}추가 피해 ${stats.sortieBonusDamage}·회복 ${stats.sortieBonusHealing}`; }); if (this.firstPursuitTrinityConfigured()) { parts.push(this.firstPursuitTrinityActive() ? '삼재진: 공명 2칸 유지' : '삼재진: 퇴각으로 해제'); @@ -4014,8 +4049,8 @@ export class BattleScene extends Phaser.Scene { return `편성 기여 · ${parts.join(' · ')}`; } - private firstPursuitContributionTotals() { - return this.firstPursuitRoleAssignments().reduce( + private sortieContributionTotals() { + return this.sortieRoleAssignments().reduce( (totals, { unit }) => { const stats = this.statsFor(unit.id); totals.bonusDamage += stats.sortieBonusDamage; @@ -4048,21 +4083,21 @@ export class BattleScene extends Phaser.Scene { return `명령 ${label}(${actor}·${command.usedTurn}턴·${effect})`; } - private firstPursuitUnitContributionLine(unit: UnitData) { - const role = this.firstPursuitRoleForUnit(unit); - if (!role) { + private sortieUnitContributionLine(unit: UnitData) { + const role = this.sortieRoleForUnit(unit); + if (!role || !sortieRoleEffects[role]) { return undefined; } const stats = this.statsFor(unit.id); - const counterplay = this.intentCounterplayContributionText(stats); + const counterplay = this.isFirstPursuitRoleEffectBattle() ? ` · ${this.intentCounterplayContributionText(stats)}` : ''; const extendedBond = stats.sortieExtendedBondTriggers > 0 ? ` · 공명 ${stats.sortieExtendedBondTriggers}회` : ''; if (role === 'front') { - return `전열 · ${counterplay} · 피해 ${stats.sortiePreventedDamage} 감소 · 반격 +${stats.sortieBonusDamage}${extendedBond}`; + return `전열${counterplay} · 피해 ${stats.sortiePreventedDamage} 감소 · 반격 +${stats.sortieBonusDamage}${extendedBond}`; } if (role === 'flank') { - return `돌파 · ${counterplay} · 추가 피해 +${stats.sortieBonusDamage}${extendedBond}`; + return `돌파${counterplay} · 추가 피해 +${stats.sortieBonusDamage}${extendedBond}`; } - return `후원 · ${counterplay} · 피해 +${stats.sortieBonusDamage} · 회복 +${stats.sortieBonusHealing}${extendedBond}`; + return `후원${counterplay} · 책략 +${stats.sortieBonusDamage} · 회복 +${stats.sortieBonusHealing}${extendedBond}`; } private applyCampaignUnitProgress(unit: UnitData, campaign?: CampaignState) { @@ -4218,7 +4253,7 @@ export class BattleScene extends Phaser.Scene { label.setMask(this.mapMask); } - const view = { + const view: UnitView = { sprite, hitZone, label, @@ -4230,6 +4265,7 @@ export class BattleScene extends Phaser.Scene { baseScaleY: sprite.scaleY }; this.unitViews.set(unit.id, view); + this.syncUnitRoleSeal(unit, view); this.syncUnitMotion(unit, view); this.applyUnitLegibilityStyle(unit, view); }); @@ -4500,7 +4536,7 @@ export class BattleScene extends Phaser.Scene { const recordedActor = selected ? battleUnits.find((unit) => unit.id === this.tacticalCommand?.actorId) : undefined; const actor = recordedActor ?? assignedActor; const available = this.canUseTacticalInitiativeCommand(role); - const definition = firstPursuitRoleEffects[role]; + const definition = sortieRoleEffects[role]; const supportTarget = role === 'support' ? this.tacticalInitiativeSupportTarget() : undefined; const bg = this.add.rectangle(x, y, width, height, selected ? 0x3a2d14 : available ? 0x172b35 : 0x111820, 0.98); bg.setOrigin(0); @@ -5129,16 +5165,57 @@ export class BattleScene extends Phaser.Scene { return; } + this.syncUnitRoleSeal(unit, view); this.setUnitBasePosition(view, this.tileCenterX(unit.x), this.tileCenterY(unit.y)); view.label.setPosition(view.baseX, view.baseY + this.layout.tileSize * 0.52); + this.setUnitRoleSealPosition(view, view.baseX, view.baseY); const visible = unit.hp > 0 && this.isTileVisible(unit.x, unit.y); view.sprite.setVisible(visible); this.setUnitHitZoneEnabled(view, visible); view.label.setVisible(visible); + view.roleSeal?.setVisible(visible); this.syncUnitMotion(unit, view); this.applyUnitLegibilityStyle(unit, view); } + private syncUnitRoleSeal(unit: UnitData, view = this.unitViews.get(unit.id)) { + if (!view) { + return; + } + const role = this.sortieRoleForUnit(unit); + const effect = role ? sortieRoleEffects[role] : undefined; + if (!role || !effect) { + view.roleSeal?.destroy(); + view.roleSeal = undefined; + return; + } + + if (!view.roleSeal?.active) { + view.roleSeal = this.add.text(0, 0, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '10px', + color: this.sortieRoleSealColor(role), + fontStyle: '700', + backgroundColor: '#541d1a', + stroke: '#160908', + strokeThickness: 2, + padding: { x: 3, y: 2 } + }); + view.roleSeal.setOrigin(0.5); + view.roleSeal.setDepth(11); + if (this.mapMask) { + view.roleSeal.setMask(this.mapMask); + } + } + + view.roleSeal + .setText(this.sortieRoleSealLabel(role)) + .setColor(this.sortieRoleSealColor(role)) + .setBackgroundColor('#541d1a') + .setStroke('#160908', 2); + this.setUnitRoleSealPosition(view, view.baseX, view.baseY); + } + private refreshUnitLegibilityStyles() { battleUnits.forEach((unit) => this.applyUnitLegibilityStyle(unit)); } @@ -5165,6 +5242,7 @@ export class BattleScene extends Phaser.Scene { label.setColor(unit.faction === 'ally' ? '#e7edf7' : '#ffebe7'); label.setBackgroundColor(''); }); + view.roleSeal?.setVisible(visible).setAlpha(1); } private isTileVisible(x: number, y: number) { @@ -5739,7 +5817,7 @@ export class BattleScene extends Phaser.Scene { } private movementAllowance(unit: UnitData, ignoreSortieEffects = false) { - const sortieBonus = !ignoreSortieEffects && this.turnNumber <= 2 && this.activeFaction === 'ally' && this.firstPursuitRoleForUnit(unit) === 'flank' ? 1 : 0; + const sortieBonus = !ignoreSortieEffects && this.turnNumber <= 2 && this.activeFaction === 'ally' && this.sortieRoleForUnit(unit) === 'flank' ? 1 : 0; return unit.move + sortieBonus; } @@ -5912,11 +5990,7 @@ export class BattleScene extends Phaser.Scene { private deploymentDefaultSelectedUnit() { const allies = this.deploymentEligibleUnits(); - if (this.isFirstPursuitRoleEffectBattle()) { - return allies.find((unit) => this.deploymentRoleForUnit(unit) === 'front') ?? allies[0]; - } - const recommendedFront = battleScenario.sortie?.recommendedUnits.find((entry) => entry.role === 'front'); - return allies.find((unit) => unit.id === recommendedFront?.unitId) ?? allies.find((unit) => unit.id === 'guan-yu') ?? allies[0]; + return allies.find((unit) => this.deploymentRoleForUnit(unit) === 'front') ?? allies[0]; } private deploymentDefaultNotice() { @@ -5973,15 +6047,11 @@ export class BattleScene extends Phaser.Scene { }; battleScenario.sortie?.deploymentSlots.forEach((slot) => { - if (!this.isFirstPursuitRoleEffectBattle()) { - addSlot(slot); - return; - } const assignedUnit = initialBattleUnits.find( (unit) => unit.faction === 'ally' && deployedAllyIds.has(unit.id) && unit.x === slot.x && unit.y === slot.y ); const assignedRole = assignedUnit ? this.deploymentRoleForUnit(assignedUnit) : slot.role ?? 'reserve'; - const effect = firstPursuitRoleEffects[assignedRole]; + const effect = sortieRoleEffects[assignedRole]; addSlot( { ...slot, @@ -6079,6 +6149,32 @@ export class BattleScene extends Phaser.Scene { return '예비'; } + private sortieRoleSealLabel(role: SortieFormationRole) { + if (role === 'front') { + return '전'; + } + if (role === 'flank') { + return '돌'; + } + if (role === 'support') { + return '후'; + } + return '예'; + } + + private sortieRoleSealColor(role: SortieFormationRole) { + if (role === 'front') { + return '#a8ffd0'; + } + if (role === 'flank') { + return '#ffdf7b'; + } + if (role === 'support') { + return '#b9e8ff'; + } + return '#c8d2dd'; + } + private deploymentRoleNote(role: SortieFormationRole, unitId?: string) { const unit = unitId ? battleUnits.find((candidate) => candidate.id === unitId) : undefined; const name = unit?.name ?? '장수'; @@ -6410,7 +6506,7 @@ export class BattleScene extends Phaser.Scene { .map((unit) => { const role = this.deploymentRoleForUnit(unit); const recommendation = recommendationByUnitId.get(unit.id); - const roleEffect = this.isFirstPursuitRoleEffectBattle() ? firstPursuitRoleEffects[role] : undefined; + const roleEffect = sortieRoleEffects[role]; return { unitId: unit.id, name: unit.name, @@ -6896,7 +6992,7 @@ export class BattleScene extends Phaser.Scene { if (!plan.target) { return '예상 행동'; } - const role = this.firstPursuitRoleForUnit(plan.target); + const role = this.sortieRoleForUnit(plan.target); return role ? `${plan.target.name} · ${this.deploymentRoleLabel(role)}` : `예상 · ${plan.target.name}`; } @@ -9223,7 +9319,7 @@ export class BattleScene extends Phaser.Scene { } } if (preview.initiativeRole && preview.initiativeEffectLabels.length > 0) { - const definition = firstPursuitRoleEffects[preview.initiativeRole]; + const definition = sortieRoleEffects[preview.initiativeRole]; summaries.push({ icon: definition?.icon ?? 'leadership', label: this.tacticalInitiativeCommandLabel(preview.initiativeRole), @@ -9250,7 +9346,7 @@ export class BattleScene extends Phaser.Scene { }); } if (preview.sortieRole && preview.sortieEffectLabels.length > 0) { - const definition = firstPursuitRoleEffects[preview.sortieRole]; + const definition = sortieRoleEffects[preview.sortieRole]; summaries.push({ icon: definition?.icon ?? 'leadership', label: definition?.label ?? '편성 전술', @@ -10210,7 +10306,11 @@ export class BattleScene extends Phaser.Scene { const visibleAllies = this.resultVisibleAllies(allies, 3); const hiddenAllyCount = allies.length - visibleAllies.length; - const unitTitle = this.trackResultObject(this.add.text(left + 44, top + 354, hiddenAllyCount > 0 ? '장수 성장 · 주요 정산' : '장수 성장', { + const doctrineActive = this.sortieRoleAssignments().length > 0; + const unitTitleLabel = doctrineActive + ? `장수 성장 · 편성 기여${hiddenAllyCount > 0 ? ' 주요 정산' : ''}` + : hiddenAllyCount > 0 ? '장수 성장 · 주요 정산' : '장수 성장'; + const unitTitle = this.trackResultObject(this.add.text(left + 44, top + 354, unitTitleLabel, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '21px', color: '#f2e3bf', @@ -10218,12 +10318,16 @@ export class BattleScene extends Phaser.Scene { })); unitTitle.setDepth(depth + 2); - if (this.isFirstPursuitRoleEffectBattle()) { - const totals = this.firstPursuitContributionTotals(); + if (doctrineActive) { + const totals = this.sortieContributionTotals(); + const specialTactics = this.isFirstPursuitRoleEffectBattle() + ? `파훼 ${totals.counterplays} · ${this.tacticalInitiativeResultText()} · ` + : ''; + const hiddenSummary = hiddenAllyCount > 0 ? ` · 외 ${hiddenAllyCount}명 정산` : ''; const contributionSummary = this.trackResultObject(this.add.text( left + panelWidth - 44, top + 358, - `전술 기여 · 파훼 ${totals.counterplays} · ${this.tacticalInitiativeResultText()} · 편성 +${totals.bonusDamage}/-${totals.preventedDamage}/회복${totals.bonusHealing} · 공명 ${totals.extendedBondTriggers}회`, + `편성 기여 · ${specialTactics}추가 피해 +${totals.bonusDamage} · 방호 ${totals.preventedDamage} · 회복 +${totals.bonusHealing} · 공명 ${totals.extendedBondTriggers}회${hiddenSummary}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', @@ -10235,7 +10339,7 @@ export class BattleScene extends Phaser.Scene { contributionSummary.setDepth(depth + 2); } - if (hiddenAllyCount > 0 && !this.isFirstPursuitRoleEffectBattle()) { + if (hiddenAllyCount > 0 && !doctrineActive) { const hiddenSummary = this.trackResultObject(this.add.text(left + panelWidth - 44, top + 358, `외 ${hiddenAllyCount}명 정산 반영`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', @@ -10622,7 +10726,7 @@ export class BattleScene extends Phaser.Scene { bg.setDepth(depth); bg.setStrokeStyle(1, palette.gold, 0.56); - const title = this.trackResultObject(this.add.text(x + 14, y + 10, this.isFirstPursuitRoleEffectBattle() ? '보상 · 편성 정산' : '보상 정산', { + const title = this.trackResultObject(this.add.text(x + 14, y + 10, this.sortieRoleAssignments().length > 0 ? '보상 · 편성 정산' : '보상 정산', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '19px', color: '#f2e3bf', @@ -10803,7 +10907,11 @@ export class BattleScene extends Phaser.Scene { return allies; } return allies - .map((unit, index) => ({ unit, index, score: this.resultUnitGrowthScore(unit) })) + .map((unit, index) => ({ + unit, + index, + score: this.resultUnitGrowthScore(unit) + this.resultUnitSortieContributionScore(unit) + })) .sort((a, b) => b.score - a.score || a.index - b.index) .slice(0, maxVisible) .map((entry) => entry.unit); @@ -10825,6 +10933,16 @@ export class BattleScene extends Phaser.Scene { return (unit.level - previousLevel) * 1200 + characterGained * 4 + equipmentGained * 2 + (unit.hp > 0 ? 20 : 0) + previousExp * 0.01; } + private resultUnitSortieContributionScore(unit: UnitData) { + const stats = this.statsFor(unit.id); + return ( + stats.sortieBonusDamage * 3 + + stats.sortiePreventedDamage * 3 + + stats.sortieBonusHealing * 4 + + stats.sortieExtendedBondTriggers * 12 + ); + } + private renderResultMetric(label: string, value: string, x: number, y: number, width: number, depth: number) { const bg = this.trackResultObject(this.add.rectangle(x, y, width, 48, 0x16212d, 0.94)); bg.setOrigin(0); @@ -10851,12 +10969,29 @@ export class BattleScene extends Phaser.Scene { private renderResultUnitRow(unit: UnitData, x: number, y: number, width: number, depth: number) { const alive = unit.hp > 0; const initial = initialBattleUnits.find((candidate) => candidate.id === unit.id); + const role = this.sortieRoleForUnit(unit); + const roleEffect = role ? sortieRoleEffects[role] : undefined; const bg = this.trackResultObject(this.add.rectangle(x, y, width, 68, 0x101820, alive ? 0.94 : 0.66)); bg.setOrigin(0); bg.setDepth(depth); - bg.setStrokeStyle(1, alive ? palette.blue : 0x646464, alive ? 0.62 : 0.5); + bg.setStrokeStyle(1, alive ? roleEffect?.tone ?? palette.blue : 0x646464, alive ? 0.62 : 0.5); - const name = this.trackResultObject(this.add.text(x + 14, y + 8, `${unit.name} Lv ${unit.level}`, { + const nameLeft = roleEffect && role ? x + 46 : x + 14; + if (roleEffect && role) { + const seal = this.trackResultObject(this.add.rectangle(x + 25, y + 20, 22, 22, 0x541d1a, alive ? 0.96 : 0.58)); + seal.setDepth(depth + 1); + seal.setStrokeStyle(1, roleEffect.tone, alive ? 0.9 : 0.46); + const sealText = this.trackResultObject(this.add.text(x + 25, y + 20, this.sortieRoleSealLabel(role), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '11px', + color: alive ? '#fff0ca' : '#9a9a9a', + fontStyle: '700' + })); + sealText.setOrigin(0.5); + sealText.setDepth(depth + 2); + } + + const name = this.trackResultObject(this.add.text(nameLeft, y + 8, `${unit.name} Lv ${unit.level}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', color: alive ? '#e9f0f8' : '#9a9a9a', @@ -10879,14 +11014,14 @@ export class BattleScene extends Phaser.Scene { })); hpText.setDepth(depth + 1); - const sortieContribution = this.firstPursuitUnitContributionLine(unit); + const sortieContribution = this.sortieUnitContributionLine(unit); if (sortieContribution) { - const sortieText = this.trackResultObject(this.add.text(x + 14, y + 31, this.truncateUiText(sortieContribution, 48), { + const sortieText = this.trackResultObject(this.add.text(nameLeft, y + 31, this.truncateUiText(sortieContribution, 48), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '11px', color: '#d8b15f', fontStyle: '700', - fixedWidth: 360 + fixedWidth: 360 - (nameLeft - x - 14) })); sortieText.setDepth(depth + 1); } @@ -11467,7 +11602,7 @@ export class BattleScene extends Phaser.Scene { const defensePower = this.actionDefense(defender, action, terrainRule.defenseBonus); const bondBonus = this.activeBondCombatBonus(attacker, defender, action, isCounter); const equipmentEffect = this.combatEquipmentEffect(attacker, defender, action, usable, bondBonus); - const sortieEffect = this.firstPursuitRoleCombatEffect(attacker, defender, action, isCounter, ignoreSortieEffects); + const sortieEffect = this.sortieRoleCombatEffect(attacker, defender, action, isCounter, ignoreSortieEffects); const initiativeEffect = this.tacticalInitiativeCombatEffect( attacker, defender, @@ -11824,7 +11959,7 @@ export class BattleScene extends Phaser.Scene { : 0; const flatAmount = usable.power + bonus + equipmentBonus; const percentFloor = Math.ceil(target.maxHp * this.supportHealFloorRate(usable)); - const sortieBonus = this.firstPursuitSupportHealBonus(user, usable, ignoreSortieEffects); + const sortieBonus = this.sortieSupportHealBonus(user, usable, ignoreSortieEffects); return Math.min(target.maxHp - target.hp, Math.max(1, flatAmount, percentFloor) + sortieBonus); } @@ -12278,12 +12413,14 @@ export class BattleScene extends Phaser.Scene { private showOpeningBattleEvent() { const guide = battleScenario.tacticalGuide; const objectiveLines = guide ? [guide.summary, `진군: ${guide.route}`, guide.focus] : battleScenario.openingObjectiveLines; - if (this.isFirstPursuitRoleEffectBattle()) { - const roleLines = this.firstPursuitOpeningLines(); - const title = this.firstPursuitTrinityConfigured() ? '도원 삼재진 발동' : '편성 전술 발동'; + if (battleScenario.sortie) { + const roleLines = this.sortieOpeningLines(); + const title = this.firstPursuitTrinityConfigured() ? '출진 군세 · 도원 삼재진' : '출진 군세'; this.triggerBattleEvent('opening', title, [ ...roleLines, - `적 의도 · 검=공격 · 발=추격 · 파훼 ${tacticalInitiativeThreshold}회 → 전술 명령`, + ...(this.isFirstPursuitRoleEffectBattle() + ? [`적 의도 · 검=공격 · 발=추격 · 파훼 ${tacticalInitiativeThreshold}회 → 전술 명령`] + : []), `작전 목표 · ${objectiveLines[0]}` ]); return; @@ -12311,8 +12448,12 @@ export class BattleScene extends Phaser.Scene { private showBattleEventBanner(title: string, lines: string[]) { this.hideBattleEventBanner(); - const showExpandedSortieBanner = this.isFirstPursuitRoleEffectBattle() && (title.includes('편성') || title.includes('삼재진')); - const maxBodyLines = showExpandedSortieBanner ? 6 : 2; + if (title.startsWith('출진 군세')) { + this.showSortieDoctrineBanner(title, lines); + return; + } + + const maxBodyLines = 2; const bodyLines = lines.slice(0, maxBodyLines).map((line) => this.truncateBattleLogText(line, 42)); const width = 540; const height = Phaser.Math.Clamp(80 + bodyLines.length * 22, 108, maxBodyLines > 2 ? 214 : 126); @@ -12412,6 +12553,152 @@ export class BattleScene extends Phaser.Scene { }); } + private showSortieDoctrineBanner(title: string, lines: string[]) { + const doctrineLines = lines.filter((line) => ( + line.startsWith('도원 공명') || + line.startsWith('공명 ') || + line.startsWith('적 의도') + )); + if (doctrineLines.length === 0) { + doctrineLines.push('활성 공명 없음 · 역할 효과로 전선을 운용합니다.'); + } + const objectiveLine = lines.find((line) => line.startsWith('작전 목표')) ?? `작전 목표 · ${battleScenario.victoryConditionLabel}`; + const width = Math.min(620, this.layout.gridWidth - 32); + const height = 202 + Math.max(0, doctrineLines.length - 1) * 20; + const left = this.layout.gridX + Math.max(16, (this.layout.gridWidth - width) / 2); + const top = this.layout.gridY + 18; + const depth = 45; + const panel = this.add.rectangle(left, top, width, height, 0x0c141b, 0.97); + panel.setOrigin(0); + panel.setDepth(depth); + panel.setStrokeStyle(2, palette.gold, 0.9); + this.battleEventObjects.push(panel); + + const commandSeal = this.add.rectangle(left + 29, top + 28, 30, 30, 0x651f1b, 0.98); + commandSeal.setDepth(depth + 1); + commandSeal.setStrokeStyle(1, 0xf0c778, 0.92); + this.battleEventObjects.push(commandSeal); + const commandSealText = this.add.text(left + 29, top + 28, '令', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", serif', + fontSize: '17px', + color: '#ffe7ad', + fontStyle: '700' + }); + commandSealText.setOrigin(0.5); + commandSealText.setDepth(depth + 2); + this.battleEventObjects.push(commandSealText); + + const titleText = this.add.text(left + 52, top + 13, title, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '21px', + color: '#f4dfad', + fontStyle: '700', + stroke: '#05070a', + strokeThickness: 3 + }); + titleText.setDepth(depth + 1); + this.battleEventObjects.push(titleText); + const doctrineLabel = this.add.text(left + width - 18, top + 18, `${battleScenario.title} · 전장 교리`, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '11px', + color: '#9fb0bf', + fontStyle: '700' + }); + doctrineLabel.setOrigin(1, 0); + doctrineLabel.setDepth(depth + 1); + this.battleEventObjects.push(doctrineLabel); + + const groups = this.sortieRoleGroups(); + const cardGap = 8; + const cardLeft = left + 18; + const cardTop = top + 52; + const cardWidth = (width - 36 - cardGap * 2) / 3; + groups.forEach(({ role, definition, units }, index) => { + const x = cardLeft + index * (cardWidth + cardGap); + const filled = units.length > 0; + const card = this.add.rectangle(x, cardTop, cardWidth, 82, filled ? 0x14232a : 0x171a1d, 0.97); + card.setOrigin(0); + card.setDepth(depth + 1); + card.setStrokeStyle(1, filled ? definition.tone : 0x647485, filled ? 0.82 : 0.42); + this.battleEventObjects.push(card); + + const seal = this.add.rectangle(x + 19, cardTop + 21, 24, 24, 0x541d1a, filled ? 0.98 : 0.56); + seal.setDepth(depth + 2); + seal.setStrokeStyle(1, filled ? definition.tone : 0x647485, 0.9); + this.battleEventObjects.push(seal); + const sealText = this.add.text(x + 19, cardTop + 21, this.sortieRoleSealLabel(role), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '12px', + color: filled ? '#fff0ca' : '#8c9298', + fontStyle: '700' + }); + sealText.setOrigin(0.5); + sealText.setDepth(depth + 3); + this.battleEventObjects.push(sealText); + + const roleText = this.add.text(x + 38, cardTop + 7, definition.label, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '13px', + color: filled ? '#f2e3bf' : '#8c9298', + fontStyle: '700' + }); + roleText.setDepth(depth + 2); + this.battleEventObjects.push(roleText); + const names = units.map((unit) => unit.name); + const namesText = this.add.text(x + 38, cardTop + 27, names.length > 0 ? `${names.slice(0, 2).join('·')}${names.length > 2 ? ` 외 ${names.length - 2}` : ''}` : '담당 없음', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '11px', + color: filled ? '#d9f1ff' : '#7f8994', + fontStyle: '700', + fixedWidth: cardWidth - 48 + }); + namesText.setDepth(depth + 2); + this.battleEventObjects.push(namesText); + const summary = this.add.text(x + 10, cardTop + 49, this.truncateUiText(definition.summary, 25), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '10px', + color: filled ? '#aeb7c2' : '#68727c', + fixedWidth: cardWidth - 20, + wordWrap: { width: cardWidth - 20, useAdvancedWrap: true } + }); + summary.setDepth(depth + 2); + this.battleEventObjects.push(summary); + }); + + doctrineLines.forEach((line, index) => { + const doctrineText = this.add.text(left + 18, top + 145 + index * 20, this.truncateBattleLogText(line, 54), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '11px', + color: line.startsWith('적 의도') ? '#b9d9ec' : '#d8b15f', + fontStyle: '700', + fixedWidth: width - 36 + }); + doctrineText.setDepth(depth + 1); + this.battleEventObjects.push(doctrineText); + }); + const objectiveText = this.add.text(left + 18, top + 169 + (doctrineLines.length - 1) * 20, this.truncateBattleLogText(objectiveLine, 58), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '11px', + color: '#c8d2dd', + fixedWidth: width - 36 + }); + objectiveText.setDepth(depth + 1); + this.battleEventObjects.push(objectiveText); + + const eventObjects = [...this.battleEventObjects]; + this.tweens.add({ + targets: eventObjects, + alpha: 0, + delay: 3200, + duration: 420, + ease: 'Sine.easeIn', + onComplete: () => { + eventObjects.forEach((object) => object.destroy()); + this.battleEventObjects = this.battleEventObjects.filter((object) => !eventObjects.includes(object)); + } + }); + } + private checkObjectiveOutcomeEvents(outcome: BattleOutcome) { this.checkObjectiveFlowEvents(outcome); this.objectiveStates(outcome) @@ -13324,11 +13611,16 @@ export class BattleScene extends Phaser.Scene { return; } + this.syncUnitRoleSeal(unit, view); this.tweens.killTweensOf([view.sprite, view.label]); + if (view.roleSeal) { + this.tweens.killTweensOf(view.roleSeal); + } view.direction = direction; this.setUnitBasePosition(view, this.tileCenterX(unit.x), this.tileCenterY(unit.y)); this.resetUnitSpritePose(view); view.label.setPosition(view.baseX, view.baseY + this.layout.tileSize * 0.52); + this.setUnitRoleSealPosition(view, view.baseX, view.baseY); this.applyUnitLegibilityStyle(unit, view); } @@ -14369,7 +14661,7 @@ export class BattleScene extends Phaser.Scene { return { icon: 'leadership', label: `방호 -${result.sortiePreventedDamageAmount}`, tone: 0x59d18c }; } if (result.sortieBonusDamageAmount > 0 && result.sortieRole) { - const definition = firstPursuitRoleEffects[result.sortieRole]; + const definition = sortieRoleEffects[result.sortieRole]; return { icon: definition?.icon ?? 'leadership', label: `편성 +${result.sortieBonusDamageAmount}`, @@ -16213,7 +16505,7 @@ export class BattleScene extends Phaser.Scene { } private showSortieCombatCue(role: SortieFormationRole, label: string, x: number, y: number, depth: number) { - const definition = firstPursuitRoleEffects[role]; + const definition = sortieRoleEffects[role]; if (!definition) { return Promise.resolve(); } @@ -16231,7 +16523,15 @@ export class BattleScene extends Phaser.Scene { const badgeWidth = Math.max(116, label.length * 13 + 28); const badge = this.add.rectangle(0, -58, badgeWidth, 28, 0x0b1218, 0.94); badge.setStrokeStyle(2, definition.tone, 0.88); - const icon = this.createBattleUiIcon(-badgeWidth / 2 + 17, -58, definition.icon, 22); + const seal = this.add.rectangle(-badgeWidth / 2 + 16, -58, 21, 21, 0x541d1a, 0.98); + seal.setStrokeStyle(1, definition.tone, 0.9); + const sealText = this.add.text(-badgeWidth / 2 + 16, -58, this.sortieRoleSealLabel(role), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '10px', + color: this.sortieRoleSealColor(role), + fontStyle: '700' + }); + sealText.setOrigin(0.5); const text = this.add.text(8, -58, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '13px', @@ -16241,7 +16541,7 @@ export class BattleScene extends Phaser.Scene { strokeThickness: 3 }); text.setOrigin(0.5); - container.add([outer, inner, spark, badge, icon, text]); + container.add([outer, inner, spark, badge, seal, sealText, text]); this.tweens.add({ targets: container, alpha: 1, scale: 1, duration: 120, ease: 'Back.easeOut' }); this.tweens.add({ targets: outer, scale: 1.55, alpha: 0.08, duration: 360, ease: 'Sine.easeOut' }); @@ -16269,6 +16569,7 @@ export class BattleScene extends Phaser.Scene { inner.setDepth(depth - 1); const spark = this.trackCombatObject(this.add.star(x, y, 6, 7, 22, 0xd9f1ff, 0.84)); spark.setDepth(depth + 1); + void this.showSortieCombatCue('support', `후원 회복 +${result.sortieHealBonus}`, x, y, depth + 2); this.tweens.add({ targets: outer, scale: 1.55, alpha: 0, duration: 230, ease: 'Sine.easeOut' }); this.tweens.add({ targets: inner, scale: 1.3, alpha: 0.02, duration: 230, ease: 'Sine.easeOut' }); this.tweens.add({ targets: spark, angle: 180, scale: 1.18, alpha: 0.08, duration: 230, ease: 'Sine.easeOut' }); @@ -16474,9 +16775,13 @@ export class BattleScene extends Phaser.Scene { const movementDuration = this.movementDuration(duration, movementDistance); this.showSortieFlankMovementTrail(unit, startX, startY, targetX, targetY, movementDuration); this.tweens.killTweensOf([view.sprite, view.hitZone, view.label]); + if (view.roleSeal) { + this.tweens.killTweensOf(view.roleSeal); + } this.setUnitBasePosition(view, startX, startY); this.resetUnitSpritePose(view); view.label.setPosition(startX, startY + this.layout.tileSize * 0.52); + this.setUnitRoleSealPosition(view, startX, startY); this.playMovementSound(unit, movementDuration, movementDistance); this.playUnitWalk(view, direction); this.tweens.add({ @@ -16500,6 +16805,15 @@ export class BattleScene extends Phaser.Scene { duration: movementDuration, ease: 'Sine.easeInOut' }); + if (view.roleSeal) { + this.tweens.add({ + targets: view.roleSeal, + x: targetX - this.layout.tileSize * 0.34, + y: targetY - this.layout.tileSize * 0.39, + duration: movementDuration, + ease: 'Sine.easeInOut' + }); + } }); } @@ -17259,9 +17573,13 @@ export class BattleScene extends Phaser.Scene { this.showSortieFlankMovementTrail(unit, startX, startY, targetX, targetY, movementDuration); } this.tweens.killTweensOf([view.sprite, view.hitZone, view.label]); + if (view.roleSeal) { + this.tweens.killTweensOf(view.roleSeal); + } this.setUnitBasePosition(view, startX, startY); this.resetUnitSpritePose(view); view.label.setPosition(startX, startY + this.layout.tileSize * 0.52); + this.setUnitRoleSealPosition(view, startX, startY); this.playMovementSound(unit, movementDuration, movementDistance); this.playUnitWalk(view, direction); this.tweens.add({ @@ -17284,6 +17602,15 @@ export class BattleScene extends Phaser.Scene { duration: movementDuration, ease: 'Sine.easeInOut' }); + if (view.roleSeal) { + this.tweens.add({ + targets: view.roleSeal, + x: targetX - this.layout.tileSize * 0.34, + y: targetY - this.layout.tileSize * 0.39, + duration: movementDuration, + ease: 'Sine.easeInOut' + }); + } } private movementTileDistance(unit: UnitData, x: number, y: number) { @@ -17302,7 +17629,7 @@ export class BattleScene extends Phaser.Scene { this.phase === 'deployment' || this.activeFaction !== 'ally' || this.turnNumber > 2 || - this.firstPursuitRoleForUnit(unit) !== 'flank' || + this.sortieRoleForUnit(unit) !== 'flank' || (startX === targetX && startY === targetY) ) { return; @@ -17528,6 +17855,13 @@ export class BattleScene extends Phaser.Scene { view.hitZone.setSize(this.layout.tileSize, this.layout.tileSize); } + private setUnitRoleSealPosition(view: UnitView, x: number, y: number) { + view.roleSeal?.setPosition( + x - this.layout.tileSize * 0.34, + y - this.layout.tileSize * 0.39 + ); + } + private setUnitHitZoneEnabled(view: UnitView, enabled: boolean) { view.hitZone.setVisible(enabled); if (view.hitZone.input) { @@ -17833,6 +18167,7 @@ export class BattleScene extends Phaser.Scene { label.setColor('#b6b6b6'); label.setBackgroundColor(''); }); + view.roleSeal?.setAlpha(0).setVisible(false); } private faceUnitToward(unit: UnitData, target: UnitData) { @@ -17876,7 +18211,7 @@ export class BattleScene extends Phaser.Scene { this.showMapResultPopup(unit, [label], '#d9f1ff', '#071623', label.length > 4 ? 17 : 19, 26); this.tweens.add({ - targets: [view.sprite, view.label], + targets: view.roleSeal ? [view.sprite, view.label, view.roleSeal] : [view.sprite, view.label], x: `+=${unit.faction === 'ally' ? -8 : 8}`, duration: 90, yoyo: true, @@ -19112,8 +19447,8 @@ export class BattleScene extends Phaser.Scene { }); } - const sortieRole = this.firstPursuitRoleForUnit(unit); - const sortieEffect = sortieRole ? firstPursuitRoleEffects[sortieRole] : undefined; + const sortieRole = this.sortieRoleForUnit(unit); + const sortieEffect = sortieRole ? sortieRoleEffects[sortieRole] : undefined; if (sortieRole && sortieEffect) { const expiredFlankOpening = sortieRole === 'flank' && this.turnNumber > 2; effects.push({ @@ -19129,7 +19464,7 @@ export class BattleScene extends Phaser.Scene { ? this.tacticalCommand : undefined; if (pendingCommand) { - const definition = firstPursuitRoleEffects[pendingCommand.role]; + const definition = sortieRoleEffects[pendingCommand.role]; effects.push({ icon: definition?.icon ?? 'leadership', label: this.tacticalInitiativeCommandLabel(pendingCommand.role), @@ -19575,6 +19910,7 @@ export class BattleScene extends Phaser.Scene { selectedSortieUnitIds: effectiveSortieUnitIds, sortieFormationAssignments: { ...sortieFormationAssignments }, sortieItemAssignments, + sortieDoctrine: this.debugSortieDoctrine(), firstSortieRoleEffects: this.debugFirstPursuitRoleEffects(), firstSortieRoleMechanicsProbe: this.debugFirstPursuitRoleMechanicsProbe(), deployedAllyIds, @@ -19650,12 +19986,15 @@ export class BattleScene extends Phaser.Scene { combatPortraitKey: this.combatPortraitKey(unit) ?? null, ai: unit.faction === 'enemy' ? this.enemyBehavior(unit) : null, attackRange: this.attackRange(unit), - formationRole: this.firstPursuitRoleForUnit(unit) ?? null, + formationRole: this.sortieRoleForUnit(unit) ?? null, effectiveMove: this.movementAllowance(unit), sortieRoleEffect: (() => { - const role = this.firstPursuitRoleForUnit(unit); - return role ? firstPursuitRoleEffects[role] ?? null : null; + const role = this.sortieRoleForUnit(unit); + return role ? sortieRoleEffects[role] ?? null : null; })(), + roleSealPresent: Boolean(this.unitViews.get(unit.id)?.roleSeal), + roleSealText: this.unitViews.get(unit.id)?.roleSeal?.text ?? null, + roleSealVisible: this.unitViews.get(unit.id)?.roleSeal?.visible ?? false, level: unit.level, exp: unit.exp, hp: unit.hp, @@ -19677,8 +20016,37 @@ export class BattleScene extends Phaser.Scene { }; } + private debugSortieDoctrine() { + const groups = this.sortieRoleGroups(); + const activeBonds = this.activeSortieBonds(); + return { + active: Boolean(battleScenario.sortie), + battleId: battleScenario.id, + coveredRoleCount: groups.filter((group) => group.units.length > 0).length, + openingBannerShown: this.triggeredBattleEvents.has('opening'), + roles: groups.map(({ role, definition, units }) => ({ + role, + label: definition.label, + summary: definition.summary, + unitIds: units.map((unit) => unit.id), + unitNames: units.map((unit) => unit.name) + })), + activeBondCount: activeBonds.length, + strongestBond: activeBonds[0] + ? { + id: activeBonds[0].id, + title: activeBonds[0].title, + level: activeBonds[0].level, + ...sortieBondBonusForLevel(activeBonds[0].level) + } + : null, + contributionTotals: this.sortieContributionTotals(), + contributionLine: this.sortieContributionLine() ?? null + }; + } + debugUnitById(unitId: string) { - if (!import.meta.env.DEV) { + if (!this.debugToolsEnabled()) { return undefined; } @@ -19702,8 +20070,8 @@ export class BattleScene extends Phaser.Scene { baseMove: unit.move, effectiveMove: this.movementAllowance(unit) })), - contributionTotals: this.firstPursuitContributionTotals(), - contributionLine: this.firstPursuitContributionLine() ?? null + contributionTotals: this.sortieContributionTotals(), + contributionLine: this.sortieContributionLine() ?? null }; } @@ -19969,7 +20337,7 @@ export class BattleScene extends Phaser.Scene { ? `${this.pendingMove.unit.name}: (${this.pendingMove.fromX},${this.pendingMove.fromY}) -> (${this.pendingMove.toX},${this.pendingMove.toY})` : 'none'; const sortieProbe = this.debugFirstPursuitRoleMechanicsProbe(); - const sortieTotals = this.isFirstPursuitRoleEffectBattle() ? this.firstPursuitContributionTotals() : undefined; + const sortieTotals = this.isFirstPursuitRoleEffectBattle() ? this.sortieContributionTotals() : undefined; const counterplay = this.intentCounterplayDebugState(); const initiative = this.tacticalInitiativeDebugState(); const intentCounts = Array.from(this.enemyIntentForecasts.values()).reduce( diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index f151df5..e91ab6d 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -27,7 +27,7 @@ import { compareSortieSynergy, coreSortieSynergyRoles, evaluateSortieSynergy, - firstPursuitRolePreviewSummaries, + sortieRoleEffectSummaries, type SortieSynergyComparison, type SortieSynergySnapshot } from '../data/sortieSynergy'; @@ -1938,7 +1938,7 @@ const sortieRulesByBattleId: Partial this.sortieRecommendation(unit.id, scenario)?.role ?? this.defaultSortieFormationRole(unit), - this.nextSortieRule(scenario).deploymentSlots + this.nextSortieRule(scenario).deploymentSlots?.map((slot) => ({ + x: slot.x, + y: slot.y, + role: slot.role, + label: slot.label + })) ); }