diff --git a/package.json b/package.json index 706a294..8e03db6 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "verify:city-equipment-preparation": "node scripts/verify-city-equipment-preparation.mjs", "verify:city-stays:browser": "node scripts/verify-city-stay-browser.mjs", "verify:xuzhou-equipment-link:browser": "node scripts/verify-xuzhou-equipment-link-browser.mjs", + "verify:xuzhou-stay-payoff:browser": "node scripts/verify-xuzhou-stay-battle-payoff-browser.mjs", "verify:prologue-village": "node scripts/verify-prologue-village-data.mjs", "verify:first-battle-camp-followup": "node scripts/verify-first-battle-camp-followup.mjs", "verify:first-battle-camaraderie": "node scripts/verify-first-battle-camaraderie-memory.mjs", @@ -79,7 +80,7 @@ "verify:interaction-ux": "node scripts/verify-interaction-ux.mjs", "verify:save-flow": "node scripts/verify-save-retry-flow.mjs", "verify:release": "node scripts/verify-release-candidate.mjs", - "verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:city-stays:browser && pnpm run verify:xuzhou-equipment-link:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief:browser && pnpm run verify:third-battle-return:browser && pnpm run verify:third-camp-preparation:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance", + "verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:city-stays:browser && pnpm run verify:xuzhou-equipment-link:browser && pnpm run verify:xuzhou-stay-payoff:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief:browser && pnpm run verify:third-battle-return:browser && pnpm run verify:third-camp-preparation:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance", "verify:1.0": "pnpm run verify:local-release && pnpm run verify:flow && pnpm run qa:campaign-complete && pnpm run qa:battle-maps:webgl", "verify:public-deploy": "node scripts/verify-public-deploy.mjs", "qa:representative": "node scripts/qa-representative-battles.mjs", diff --git a/scripts/verify-battle-save-normalization.mjs b/scripts/verify-battle-save-normalization.mjs index af835f0..d925ecd 100644 --- a/scripts/verify-battle-save-normalization.mjs +++ b/scripts/verify-battle-save-normalization.mjs @@ -19,6 +19,16 @@ try { const { cityEquipmentPreparationSelectionRecordId } = await server.ssrLoadModule('/src/game/data/cityEquipmentPreparation.ts'); + const { + xuzhouStayBattlePayoffRecordId, + xuzhouStayBattlePayoffSourceBattleId, + xuzhouStayBattlePayoffTargetBattleId, + xuzhouStayId, + xuzhouStayInformationVisitId, + xuzhouStayResonanceDialogueId, + xuzhouStayShareStorehouseDutyChoiceId, + xuzhouStayTrustMiZhuLedgerChoiceId + } = await server.ssrLoadModule('/src/game/data/xuzhouStayBattlePayoff.ts'); const options = { expectedBattleId: 'first-battle-zhuo-commandery', @@ -630,6 +640,280 @@ try { 'Expected legacy eighth-battle saves without city-equipment contribution progress to remain valid.' ); + const eighthBattlePayoffOptions = { + ...eighthBattleOptions, + validUnitIds: new Set([ + ...eighthBattleOptions.validUnitIds, + 'mi-zhu' + ]), + validAllyUnitIds: new Set([ + ...eighthBattleOptions.validAllyUnitIds, + 'mi-zhu' + ]) + }; + const miZhuBattleSaveUnit = { + ...eighthBattleUnits.find((unit) => unit.id === 'guan-yu'), + id: 'mi-zhu', + hp: 28, + maxHp: 28, + attack: 7, + stats: { + might: 40, + intelligence: 74, + leadership: 70, + agility: 58, + luck: 82 + }, + x: 3, + y: 2, + equipment: createEquipmentSet() + }; + const eighthBattlePayoffBaseState = { + ...eighthBattleBaseState, + units: [ + ...eighthBattleUnits.map((unit) => ({ + ...unit, + stats: unit.stats ? { ...unit.stats } : undefined, + equipment: { + weapon: { ...unit.equipment.weapon }, + armor: { ...unit.equipment.armor }, + accessory: { ...unit.equipment.accessory } + } + })), + miZhuBattleSaveUnit + ] + }; + const xuzhouStayPayoffIdentity = { + version: 1, + recordId: xuzhouStayBattlePayoffRecordId, + cityStayId: xuzhouStayId, + sourceBattleId: xuzhouStayBattlePayoffSourceBattleId, + targetBattleId: xuzhouStayBattlePayoffTargetBattleId, + informationVisitId: xuzhouStayInformationVisitId, + dialogueId: xuzhouStayResonanceDialogueId + }; + const validInformationOnlyPayoff = { + ...xuzhouStayPayoffIdentity, + informationCompleted: true, + informationCounterplays: 2, + miZhuDeployed: true, + supportAttempts: 0, + supportUses: 0, + bonusHealing: 0, + bonusBuffTurns: 0 + }; + const validSharedDutyPayoff = { + ...validInformationOnlyPayoff, + choiceId: xuzhouStayShareStorehouseDutyChoiceId, + supportAttempts: 2, + supportUses: 1, + bonusHealing: 4 + }; + const validEntrustedLedgerPayoff = { + ...validInformationOnlyPayoff, + choiceId: xuzhouStayTrustMiZhuLedgerChoiceId, + supportAttempts: 1, + supportUses: 1, + bonusBuffTurns: 1 + }; + + [ + ['information-only', validInformationOnlyPayoff], + ['shared-duty choice', validSharedDutyPayoff], + ['entrusted-ledger choice', validEntrustedLedgerPayoff] + ].forEach(([label, xuzhouStayBattlePayoff]) => { + const candidate = { + ...eighthBattlePayoffBaseState, + xuzhouStayBattlePayoff + }; + const parsed = parseBattleSaveState( + JSON.stringify(candidate), + eighthBattlePayoffOptions + ); + const parsedPayoff = parsed?.xuzhouStayBattlePayoff; + assert( + isValidBattleSaveState(candidate, eighthBattlePayoffOptions) && + parsedPayoff && + Object.keys(parsedPayoff).length === + Object.keys(xuzhouStayBattlePayoff).length && + Object.entries(xuzhouStayBattlePayoff).every( + ([key, value]) => parsedPayoff[key] === value + ) && + parsedPayoff !== xuzhouStayBattlePayoff, + `Expected canonical ${label} Xuzhou payoff progress to round-trip as a deep-cloned battle-save field: ${JSON.stringify({ + strict: isValidBattleSaveState( + candidate, + eighthBattlePayoffOptions + ), + expected: xuzhouStayBattlePayoff, + actual: parsedPayoff + })}` + ); + }); + + assert( + isValidBattleSaveState( + eighthBattlePayoffBaseState, + eighthBattlePayoffOptions + ) && + parseBattleSaveState( + JSON.stringify(eighthBattlePayoffBaseState), + eighthBattlePayoffOptions + )?.xuzhouStayBattlePayoff === undefined, + 'Expected legacy eighth-battle saves without Xuzhou stay payoff progress to remain valid.' + ); + + const normalizedGhostMiZhuPayoff = normalizeBattleSaveState( + { + ...eighthBattleBaseState, + xuzhouStayBattlePayoff: { + ...validSharedDutyPayoff, + informationCounterplays: 1, + miZhuDeployed: true, + supportAttempts: 8, + supportUses: 1, + bonusHealing: 4 + } + }, + eighthBattleOptions + )?.xuzhouStayBattlePayoff; + assert( + normalizedGhostMiZhuPayoff?.miZhuDeployed === false && + normalizedGhostMiZhuPayoff.supportAttempts === 0 && + normalizedGhostMiZhuPayoff.supportUses === 0 && + normalizedGhostMiZhuPayoff.bonusHealing === 0 && + normalizedGhostMiZhuPayoff.bonusBuffTurns === 0, + `Expected a claimed Mi Zhu deployment absent from the saved roster to clear all support counters: ${JSON.stringify(normalizedGhostMiZhuPayoff)}` + ); + + const normalizedSharedDutyCrossEffect = + normalizeBattleSaveState( + { + ...eighthBattlePayoffBaseState, + xuzhouStayBattlePayoff: { + ...validSharedDutyPayoff, + bonusBuffTurns: 999 + } + }, + eighthBattlePayoffOptions + )?.xuzhouStayBattlePayoff; + const normalizedEntrustedLedgerCrossEffect = + normalizeBattleSaveState( + { + ...eighthBattlePayoffBaseState, + xuzhouStayBattlePayoff: { + ...validEntrustedLedgerPayoff, + bonusHealing: 999 + } + }, + eighthBattlePayoffOptions + )?.xuzhouStayBattlePayoff; + assert( + normalizedSharedDutyCrossEffect?.bonusHealing === 4 && + normalizedSharedDutyCrossEffect.bonusBuffTurns === 0 && + normalizedEntrustedLedgerCrossEffect?.bonusHealing === 0 && + normalizedEntrustedLedgerCrossEffect.bonusBuffTurns === 1, + 'Expected each Xuzhou resonance choice to remove the other choice effect during normalization.' + ); + + const normalizedOversizedXuzhouPayoff = + normalizeBattleSaveState( + { + ...eighthBattlePayoffBaseState, + xuzhouStayBattlePayoff: { + ...validSharedDutyPayoff, + informationCounterplays: Number.MAX_SAFE_INTEGER, + supportAttempts: Number.MAX_SAFE_INTEGER, + supportUses: Number.MAX_SAFE_INTEGER, + bonusHealing: Number.MAX_SAFE_INTEGER, + bonusBuffTurns: Number.MAX_SAFE_INTEGER + } + }, + eighthBattlePayoffOptions + )?.xuzhouStayBattlePayoff; + assert( + normalizedOversizedXuzhouPayoff?.informationCounterplays === + 2 && + normalizedOversizedXuzhouPayoff.supportAttempts === + 999999 && + normalizedOversizedXuzhouPayoff.supportUses === 1 && + normalizedOversizedXuzhouPayoff.bonusHealing === 4 && + normalizedOversizedXuzhouPayoff.bonusBuffTurns === 0, + `Expected oversized Xuzhou payoff counters to clamp to canonical limits: ${JSON.stringify(normalizedOversizedXuzhouPayoff)}` + ); + + const forgedXuzhouPayoffCases = [ + { + label: 'target battle identity', + payoff: { + ...validSharedDutyPayoff, + targetBattleId: 'forged-target-battle' + } + }, + { + label: 'source battle identity', + payoff: { + ...validSharedDutyPayoff, + sourceBattleId: 'forged-source-battle' + } + }, + { + label: 'information identity', + payoff: { + ...validSharedDutyPayoff, + informationVisitId: 'forged-information' + } + }, + { + label: 'dialogue identity', + payoff: { + ...validSharedDutyPayoff, + dialogueId: 'forged-dialogue' + } + }, + { + label: 'resonance choice', + payoff: { + ...validSharedDutyPayoff, + choiceId: 'forged-choice' + } + } + ]; + forgedXuzhouPayoffCases.forEach(({ label, payoff }) => { + const candidate = { + ...eighthBattlePayoffBaseState, + xuzhouStayBattlePayoff: payoff + }; + const normalized = normalizeBattleSaveState( + candidate, + eighthBattlePayoffOptions + ); + assert( + normalized?.xuzhouStayBattlePayoff === undefined && + !isValidBattleSaveState( + candidate, + eighthBattlePayoffOptions + ), + `Expected forged Xuzhou ${label} progress to be removed and rejected by strict validation.` + ); + }); + + const payoffAttachedToWrongBattle = { + ...validState, + xuzhouStayBattlePayoff: validSharedDutyPayoff + }; + assert( + normalizeBattleSaveState( + payoffAttachedToWrongBattle, + options + )?.xuzhouStayBattlePayoff === undefined && + !isValidBattleSaveState( + payoffAttachedToWrongBattle, + options + ), + 'Expected Xuzhou payoff progress attached to a non-eighth battle save to be removed and rejected by strict validation.' + ); + const validSortieStatsState = { ...validState, battleStats: { diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs index 5d80494..559ba08 100644 --- a/scripts/verify-campaign-save-normalization.mjs +++ b/scripts/verify-campaign-save-normalization.mjs @@ -58,6 +58,16 @@ try { const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); const { campaignRecruitUnits } = await server.ssrLoadModule('/src/game/data/scenario.ts'); const { normalizeSortieFormationAssignments } = await server.ssrLoadModule('/src/game/data/sortieDeployment.ts'); + const { + xuzhouStayBattlePayoffRecordId, + xuzhouStayBattlePayoffSourceBattleId, + xuzhouStayBattlePayoffTargetBattleId, + xuzhouStayId, + xuzhouStayInformationVisitId, + xuzhouStayResonanceDialogueId, + xuzhouStayShareStorehouseDutyChoiceId, + xuzhouStayTrustMiZhuLedgerChoiceId + } = await server.ssrLoadModule('/src/game/data/xuzhouStayBattlePayoff.ts'); const sortieStatsFixture = (unitId, overrides = {}) => ({ unitId, hpBefore: 30, @@ -2601,11 +2611,268 @@ try { `Expected explicitly loaded corrupted slot timestamp to be normalized: ${JSON.stringify(corruptedSlot)}` ); + const eighthScenario = + battleScenarios[xuzhouStayBattlePayoffTargetBattleId]; + const eighthAlliedUnits = eighthScenario.units.filter( + (unit) => unit.faction === 'ally' + ); + const xuzhouStayPayoffIdentity = { + version: 1, + recordId: xuzhouStayBattlePayoffRecordId, + cityStayId: xuzhouStayId, + sourceBattleId: xuzhouStayBattlePayoffSourceBattleId, + targetBattleId: xuzhouStayBattlePayoffTargetBattleId, + informationVisitId: xuzhouStayInformationVisitId, + dialogueId: xuzhouStayResonanceDialogueId + }; + const eighthReportBase = { + battleId: eighthScenario.id, + battleTitle: eighthScenario.title, + outcome: 'victory', + turnNumber: 8, + rewardGold: 0, + defeatedEnemies: 8, + totalEnemies: 8, + objectives: [], + units: eighthAlliedUnits, + bonds: eighthScenario.bonds.map((bond) => ({ + ...bond, + battleExp: 0 + })), + itemRewards: [], + campaignRewards: { + supplies: [], + equipment: [], + reputation: [], + recruits: [], + unlocks: [] + }, + completedCampDialogues: [], + completedCampVisits: [], + createdAt: '2026-07-27T08:00:00.000Z' + }; + const seedXuzhouStayMemory = (choiceId) => { + resetCampaignState(); + setFirstBattleReport(seventhBattleReport); + const sourceState = getCampaignState(); + sourceState.completedCampVisits = [ + ...new Set([ + ...sourceState.completedCampVisits, + xuzhouStayInformationVisitId + ]) + ]; + if (choiceId) { + sourceState.completedCampDialogues = [ + ...new Set([ + ...sourceState.completedCampDialogues, + xuzhouStayResonanceDialogueId + ]) + ]; + sourceState.campDialogueChoiceIds = { + ...sourceState.campDialogueChoiceIds, + [xuzhouStayResonanceDialogueId]: choiceId + }; + } else { + sourceState.completedCampDialogues = + sourceState.completedCampDialogues.filter( + (dialogueId) => + dialogueId !== xuzhouStayResonanceDialogueId + ); + delete sourceState.campDialogueChoiceIds[ + xuzhouStayResonanceDialogueId + ]; + } + setCampaignState(sourceState); + }; + const campaignPayoffFixtures = [ + { + label: 'information-only', + choiceId: undefined, + snapshot: { + ...xuzhouStayPayoffIdentity, + informationCompleted: true, + informationCounterplays: 2, + miZhuDeployed: true, + supportAttempts: 0, + supportUses: 0, + bonusHealing: 0, + bonusBuffTurns: 0 + } + }, + { + label: 'shared-duty choice', + choiceId: xuzhouStayShareStorehouseDutyChoiceId, + snapshot: { + ...xuzhouStayPayoffIdentity, + informationCompleted: true, + choiceId: xuzhouStayShareStorehouseDutyChoiceId, + informationCounterplays: 2, + miZhuDeployed: true, + supportAttempts: 2, + supportUses: 1, + bonusHealing: 4, + bonusBuffTurns: 0 + } + }, + { + label: 'entrusted-ledger choice', + choiceId: xuzhouStayTrustMiZhuLedgerChoiceId, + snapshot: { + ...xuzhouStayPayoffIdentity, + informationCompleted: true, + choiceId: xuzhouStayTrustMiZhuLedgerChoiceId, + informationCounterplays: 1, + miZhuDeployed: true, + supportAttempts: 1, + supportUses: 1, + bonusHealing: 0, + bonusBuffTurns: 1 + } + } + ]; + + seedXuzhouStayMemory( + xuzhouStayShareStorehouseDutyChoiceId + ); + setFirstBattleReport({ ...eighthReportBase }); + const legacyEighthCampaignState = loadCampaignState(); + assert( + legacyEighthCampaignState.firstBattleReport + ?.xuzhouStayBattlePayoff === undefined && + legacyEighthCampaignState.battleHistory[ + eighthScenario.id + ]?.xuzhouStayBattlePayoff === undefined, + 'Expected legacy eighth-battle campaign reports without a Xuzhou payoff field to remain valid.' + ); + + for (const fixture of campaignPayoffFixtures) { + seedXuzhouStayMemory(fixture.choiceId); + const inputSnapshot = { ...fixture.snapshot }; + const normalizedReport = setFirstBattleReport({ + ...eighthReportBase, + xuzhouStayBattlePayoff: inputSnapshot + }); + const recordedCampaign = loadCampaignState(); + const reportSnapshot = + recordedCampaign.firstBattleReport + ?.xuzhouStayBattlePayoff; + const historySnapshot = + recordedCampaign.battleHistory[eighthScenario.id] + ?.xuzhouStayBattlePayoff; + assert( + flatRecordsEqual( + normalizedReport.xuzhouStayBattlePayoff, + fixture.snapshot + ) && + flatRecordsEqual(reportSnapshot, fixture.snapshot) && + flatRecordsEqual(historySnapshot, fixture.snapshot) && + reportSnapshot !== historySnapshot, + `Expected canonical ${fixture.label} payoff to copy from the eighth-battle report into battle history: ${JSON.stringify({ + normalizedReport: + normalizedReport.xuzhouStayBattlePayoff, + reportSnapshot, + historySnapshot + })}` + ); + + inputSnapshot.informationCounterplays = 0; + inputSnapshot.supportAttempts = 0; + inputSnapshot.supportUses = 0; + inputSnapshot.bonusHealing = 0; + inputSnapshot.bonusBuffTurns = 0; + const detachedCampaign = getCampaignState(); + assert( + flatRecordsEqual( + detachedCampaign.battleHistory[eighthScenario.id] + ?.xuzhouStayBattlePayoff, + fixture.snapshot + ), + `Expected later mutations of the report input not to alter the stored ${fixture.label} settlement snapshot.` + ); + + if ( + fixture.choiceId === + xuzhouStayShareStorehouseDutyChoiceId + ) { + const changedCurrentState = getCampaignState(); + changedCurrentState.campDialogueChoiceIds = { + ...changedCurrentState.campDialogueChoiceIds, + [xuzhouStayResonanceDialogueId]: + xuzhouStayTrustMiZhuLedgerChoiceId + }; + changedCurrentState.roster = + changedCurrentState.roster.map((unit) => + unit.id === 'mi-zhu' + ? { + ...unit, + hp: 1, + equipment: { + weapon: { + itemId: 'training-sword', + level: 1, + exp: 0 + }, + armor: { + itemId: 'cloth-armor', + level: 1, + exp: 0 + }, + accessory: { + itemId: 'grain-pouch', + level: 1, + exp: 0 + } + } + } + : unit + ); + changedCurrentState.selectedSortieUnitIds = + changedCurrentState.selectedSortieUnitIds.filter( + (unitId) => unitId !== 'mi-zhu' + ); + setCampaignState(changedCurrentState); + const historicalCampaign = loadCampaignState(); + assert( + flatRecordsEqual( + historicalCampaign.firstBattleReport + ?.xuzhouStayBattlePayoff, + fixture.snapshot + ) && + flatRecordsEqual( + historicalCampaign.battleHistory[ + eighthScenario.id + ]?.xuzhouStayBattlePayoff, + fixture.snapshot + ), + `Expected completed Xuzhou payoff history to remain a report/settlement snapshot instead of being recomputed from later resonance or roster state: ${JSON.stringify({ + report: + historicalCampaign.firstBattleReport + ?.xuzhouStayBattlePayoff, + history: + historicalCampaign.battleHistory[ + eighthScenario.id + ]?.xuzhouStayBattlePayoff + })}` + ); + } + } + console.log('Verified campaign save normalization, reward resettlement, camp reward idempotence, malformed-field/roster-equipment/bond-growth/bond-roster/report-reference/sortie-roster/sortie-preset/sortie-performance/sortie-review/sortie-order/latest-history/detail recovery, sortie performance/review/order legacy/deep-clone/retry/reward-idempotence safety, timestamp-safe history/slot recovery, unknown-step/report/history recovery, unsupported-version rejection, and slotted fallback.'); } finally { await server.close(); } +function flatRecordsEqual(left, right) { + if (!left || !right) { + return left === right; + } + const leftEntries = Object.entries(left); + return ( + leftEntries.length === Object.keys(right).length && + leftEntries.every(([key, value]) => right[key] === value) + ); +} + function assert(condition, message) { if (!condition) { throw new Error(message); diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index 3eaed56..7b14dd3 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -23,6 +23,7 @@ const checks = [ 'scripts/verify-camp-reward-data.mjs', 'scripts/verify-city-stay-data.mjs', 'scripts/verify-city-equipment-preparation.mjs', + 'scripts/verify-xuzhou-stay-battle-payoff.mjs', 'scripts/verify-prologue-village-data.mjs', 'scripts/verify-first-battle-camp-followup.mjs', 'scripts/verify-first-battle-camaraderie-memory.mjs', diff --git a/scripts/verify-xuzhou-equipment-link-browser.mjs b/scripts/verify-xuzhou-equipment-link-browser.mjs index 039444e..19c772c 100644 --- a/scripts/verify-xuzhou-equipment-link-browser.mjs +++ b/scripts/verify-xuzhou-equipment-link-browser.mjs @@ -1936,10 +1936,10 @@ function assertBattleResultContribution( assert( result.visibleResultTexts.some((text) => text.includes( - battle.cityEquipmentPreparation.resultText + battle.cityEquipmentPreparation.compactResultText ) ), - `${renderer}: visible result subtitle must contain the persisted contribution feedback: ${JSON.stringify( + `${renderer}: visible result subtitle must contain the compact persisted contribution feedback: ${JSON.stringify( result.visibleResultTexts )}` ); diff --git a/scripts/verify-xuzhou-stay-battle-payoff-browser.mjs b/scripts/verify-xuzhou-stay-battle-payoff-browser.mjs new file mode 100644 index 0000000..f76f140 --- /dev/null +++ b/scripts/verify-xuzhou-stay-battle-payoff-browser.mjs @@ -0,0 +1,1281 @@ +import assert from 'node:assert/strict'; +import { mkdirSync } from 'node:fs'; +import { chromium } from 'playwright'; +import { createServer } from 'vite'; +import { + desktopBrowserContextOptions, + desktopBrowserDeviceScaleFactor, + desktopBrowserViewport +} from './desktop-browser-viewport.mjs'; + +const sourceBattleId = 'seventh-battle-xuzhou-rescue'; +const targetBattleId = 'eighth-battle-xiaopei-supply-road'; +const informationVisitId = 'city-xuzhou-xiaopei-ledger'; +const dialogueId = 'city-xuzhou-liu-mi-supply-trust'; +const aidChoiceId = 'city-xuzhou-share-storehouse-duty'; +const encourageChoiceId = 'city-xuzhou-trust-mi-zhu-ledger'; +const miZhuUnitId = 'mi-zhu'; +const trackedEnemyUnitIds = [ + 'xiaopei-guard-a', + 'xiaopei-guard-b' +]; +const equipmentOfferId = 'city-xuzhou-iron-spear'; + +const rendererFixtures = { + canvas: { renderer: 'canvas', expectedRendererType: 1 }, + webgl: { renderer: 'webgl', expectedRendererType: 2 } +}; +const requestedRenderer = + cliOption('renderer') ?? + process.env.VERIFY_XUZHOU_STAY_RENDERER ?? + 'both'; +assert( + ['canvas', 'webgl', 'both'].includes(requestedRenderer), + `Unknown renderer "${requestedRenderer}". Use canvas, webgl, or both.` +); +const renderers = + requestedRenderer === 'both' + ? ['canvas', 'webgl'] + : [requestedRenderer]; + +const server = await createServer({ + logLevel: 'error', + server: { host: '127.0.0.1', port: 0, hmr: false }, + appType: 'spa' +}); +let browser; + +try { + mkdirSync('dist', { recursive: true }); + await server.listen(); + const address = server.httpServer?.address(); + assert( + address && typeof address !== 'string', + 'Expected a local Vite verification server.' + ); + const baseUrl = `http://127.0.0.1:${address.port}/heros_web/`; + browser = await chromium.launch({ + headless: process.env.VERIFY_XUZHOU_STAY_HEADLESS !== '0' + }); + + for (const renderer of renderers) { + await verifyRenderer(browser, baseUrl, rendererFixtures[renderer]); + } + + console.log( + `Xuzhou stay battle payoff browser verification passed for ${renderers.join( + ' + ' + )} at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} CSS pixels, ` + + `100% zoom, DPR ${desktopBrowserDeviceScaleFactor}: two exact deployment intent previews, ` + + 'aid +4 and encourage +1-turn single-use branches, Mi Zhu absence, no-information baseline, ' + + 'Mi Zhu defeat expiry, guard counterplay credit, equipment-safe header/chip layout, ' + + 'battle-save restoration, and campaign result/history persistence.' + ); +} finally { + await browser?.close(); + await server.close(); +} + +async function verifyRenderer( + browserInstance, + baseUrl, + rendererFixture +) { + const context = await browserInstance.newContext(desktopBrowserContextOptions); + const page = await context.newPage(); + page.setDefaultTimeout(30000); + const pageErrors = []; + const consoleErrors = []; + page.on('pageerror', (error) => + pageErrors.push(error.stack ?? error.message) + ); + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); + } + }); + + try { + const url = new URL(baseUrl); + url.searchParams.set('debug', '1'); + url.searchParams.set('renderer', rendererFixture.renderer); + await page.goto(url.toString(), { + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await page.evaluate(() => window.localStorage.clear()); + await page.reload({ + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForDebugApi(page); + await assertFhdViewport(page, rendererFixture); + + await verifyAidBranch(page, rendererFixture.renderer); + await verifyEncourageBranch(page, rendererFixture.renderer); + await verifyMiZhuDefeatedBranch( + page, + rendererFixture.renderer + ); + await verifyMiZhuMissingBranch(page, rendererFixture.renderer); + await verifyNoInformationBaseline(page, rendererFixture.renderer); + + await assertFhdViewport(page, rendererFixture); + assert.deepEqual( + pageErrors, + [], + `${rendererFixture.renderer}: expected no browser page errors: ${JSON.stringify( + pageErrors.slice(-8) + )}` + ); + assert.deepEqual( + consoleErrors.filter( + (message) => + !message.includes( + 'The AudioContext was not allowed to start' + ) + ), + [], + `${rendererFixture.renderer}: expected no browser console errors: ${JSON.stringify( + consoleErrors.slice(-8) + )}` + ); + } finally { + await context.close(); + } +} + +async function verifyAidBranch(page, renderer) { + const seed = await seedCampaign(page, { + informationCompleted: true, + choiceId: aidChoiceId, + includeMiZhu: true, + includeEquipment: true + }); + assert.equal(seed.saved, true, seed.reason); + assert.equal(seed.choiceId, aidChoiceId); + assert.equal(seed.equipmentOfferId, equipmentOfferId); + + let battle = await openBattle(page, { + active: true, + choiceId: aidChoiceId, + miZhuDeployed: true + }); + assertDeploymentInformation(battle, `${renderer} aid`); + assert.equal( + battle.xuzhouStayBattlePayoff.effect?.bonusHealing, + 4 + ); + assert( + battle.battleHud.deploymentPanel.subtitleText.includes( + '적 의도 2대' + ), + `${renderer}: aid deployment subtitle must retain the two revealed enemy intents.` + ); + assert( + battle.battleHud.deploymentPanel.subtitleText.includes( + '응급 +4' + ), + `${renderer}: aid deployment subtitle must state the +4 payoff.` + ); + assert( + battle.battleHud.deploymentPanel.subtitleText.includes( + '철창' + ), + `${renderer}: the existing Xuzhou equipment must remain visible beside the stay payoff.` + ); + assert( + battle.battleHud.deploymentPanel.subtitleText + .split('\n') + .some((line) => line.startsWith('선택 ')), + `${renderer}: the deployment header must preserve the selected strategy/order line.` + ); + assertDeploymentHeaderLayout( + battle.battleHud.deploymentPanel, + `${renderer} aid + equipment` + ); + await capture( + page, + screenshotPath(renderer, 'aid-deployment-equipment') + ); + + battle = await startBattleWithRealClick(page); + assert.equal( + battle.xuzhouStayBattlePayoff.chip.visible, + true + ); + assert.equal( + battle.cityEquipmentPreparation.chip.visible, + true + ); + const activeChips = [ + { + id: 'stay-payoff', + bounds: battle.xuzhouStayBattlePayoff.chip.bounds + }, + { + id: 'city-equipment', + bounds: battle.cityEquipmentPreparation.chip.bounds + } + ]; + assertNoOverlappingBounds( + activeChips, + `${renderer}: active Xuzhou HUD chips` + ); + const headerRegions = Object.entries({ + title: battle.battleHud.header.titleBounds, + turn: battle.battleHud.header.turnBounds, + speed: battle.battleHud.header.speedBounds, + objective: battle.battleHud.header.objectiveBounds, + defeat: battle.battleHud.header.defeatBounds, + 'sortie-order': battle.battleHud.header.sortieOrderBounds + }).filter(([, bounds]) => bounds); + activeChips.forEach((chip) => { + headerRegions.forEach(([id, bounds]) => + assertNoOverlappingBounds( + [chip, { id, bounds }], + `${renderer}: ${chip.id} must stay below the battle header` + ) + ); + if (battle.battleHud.quickTabs.visible) { + assertNoOverlappingBounds( + [ + chip, + { + id: 'quick-tabs', + bounds: battle.battleHud.quickTabs.bounds + } + ], + `${renderer}: ${chip.id} must reserve space above quick tabs` + ); + } + }); + + const roundTrip = await page.evaluate( + ({ trackedId, untrackedId, expectedBattleId }) => { + const scene = + window.__HEROS_DEBUG__?.scene('BattleScene'); + if ( + !scene || + typeof scene.debugResolveXuzhouStaySupport !== + 'function' || + typeof scene.debugRecordXuzhouStayCounterplay !== + 'function' || + typeof scene.debugRecordXuzhouStayGuardCounterplay !== + 'function' || + typeof scene.createBattleSaveState !== 'function' || + typeof scene.applyBattleSaveState !== 'function' + ) { + throw new Error( + 'BattleScene Xuzhou support/counterplay/save hooks are unavailable.' + ); + } + + const before = structuredClone( + window.__HEROS_DEBUG__.battle() + .xuzhouStayBattlePayoff + ); + const first = scene.debugResolveXuzhouStaySupport('aid'); + const beforeTracked = + first.runtime.informationCounterplays; + const guard = + scene.debugRecordXuzhouStayGuardCounterplay( + trackedId + ); + const tracked = guard.runtime; + const beforeUntracked = tracked.informationCounterplays; + const untracked = + scene.debugRecordXuzhouStayCounterplay(untrackedId); + const fullSave = scene.createBattleSaveState(); + if ( + fullSave.battleId !== expectedBattleId || + !fullSave.xuzhouStayBattlePayoff + ) { + throw new Error( + 'Battle save did not include the Xuzhou stay payoff.' + ); + } + const saveSnapshot = structuredClone( + fullSave.xuzhouStayBattlePayoff + ); + const detached = + fullSave.xuzhouStayBattlePayoff !== tracked; + + scene.xuzhouStayBattlePayoffRuntime.supportAttempts = 0; + scene.xuzhouStayBattlePayoffRuntime.supportUses = 0; + scene.xuzhouStayBattlePayoffRuntime.bonusHealing = 0; + scene.xuzhouStayBattlePayoffRuntime.informationCounterplays = 0; + scene.renderTacticalInitiativeChip(); + const reset = structuredClone( + window.__HEROS_DEBUG__.battle() + .xuzhouStayBattlePayoff + ); + + scene.applyBattleSaveState(structuredClone(fullSave)); + scene.renderTacticalInitiativeChip(); + const restored = structuredClone( + window.__HEROS_DEBUG__.battle() + .xuzhouStayBattlePayoff + ); + const second = + scene.debugResolveXuzhouStaySupport('aid'); + scene.renderTacticalInitiativeChip(); + const finalState = structuredClone( + window.__HEROS_DEBUG__.battle() + .xuzhouStayBattlePayoff + ); + + return { + before, + first, + trackedDelta: + tracked.informationCounterplays - beforeTracked, + untrackedDelta: + untracked.informationCounterplays - beforeUntracked, + saveSnapshot, + detached, + reset, + restored, + second, + finalState + }; + }, + { + trackedId: trackedEnemyUnitIds[0], + untrackedId: 'xiaopei-raider-a', + expectedBattleId: targetBattleId + } + ); + assert.equal( + roundTrip.first.cityStayBonusHealing, + 4, + `${renderer}: the first aid must gain exactly +4 healing.` + ); + assert.equal(roundTrip.first.cityStayBonusBuffTurns, 0); + assert.equal(roundTrip.trackedDelta, 1); + assert.equal(roundTrip.untrackedDelta, 0); + assert.equal(roundTrip.saveSnapshot.supportUses, 1); + assert.equal(roundTrip.saveSnapshot.bonusHealing, 4); + assert.equal( + roundTrip.saveSnapshot.informationCounterplays, + 1 + ); + assert.equal(roundTrip.detached, true); + assert.equal(roundTrip.reset.runtime.supportUses, 0); + assert.equal(roundTrip.reset.runtime.bonusHealing, 0); + assert.equal( + roundTrip.reset.runtime.informationCounterplays, + 0 + ); + assert.deepEqual( + roundTrip.restored.runtime, + roundTrip.saveSnapshot, + `${renderer}: create/apply must restore the exact support-used state.` + ); + assert.equal( + roundTrip.second.cityStayBonusHealing, + 0, + `${renderer}: the second aid must not receive the one-use bonus.` + ); + assert.equal(roundTrip.finalState.runtime.supportUses, 1); + assert.equal(roundTrip.finalState.chip.visible, true); + assertBoundsInsideViewport( + roundTrip.finalState.chip.bounds, + `${renderer}: restored aid payoff chip` + ); + await capture(page, screenshotPath(renderer, 'aid-used-restored')); + + const result = await forceVictoryAndReadPayoff(page); + assert.equal(result.battle.battleOutcome, 'victory'); + assert.equal(result.battle.resultVisible, true); + assert.equal( + result.battle.xuzhouStayBattlePayoff.result.supportUses, + 1 + ); + assert.equal( + result.battle.xuzhouStayBattlePayoff.result.bonusHealing, + 4 + ); + assert.equal( + result.battle.xuzhouStayBattlePayoff.result + .informationCounterplays, + 1 + ); + assert( + result.battle.xuzhouStayBattlePayoff.compactResultText.includes( + '장부 파훼 1' + ) + ); + assert( + result.battle.xuzhouStayBattlePayoff.compactResultText.includes( + '응급 +4' + ) + ); + assert.deepEqual( + result.campaign.reportPayoff, + result.battle.xuzhouStayBattlePayoff.result + ); + assert.deepEqual( + result.campaign.historyPayoff, + result.battle.xuzhouStayBattlePayoff.result + ); + assert( + result.visibleResultTexts.some( + (text) => + text.includes('장부 파훼 1') && + text.includes('응급 +4') && + text.includes('철창') + ), + `${renderer}: result subtitle must compactly retain both stay and equipment payoffs: ${JSON.stringify( + result.visibleResultTexts + )}` + ); + await capture(page, screenshotPath(renderer, 'aid-result')); +} + +async function verifyEncourageBranch(page, renderer) { + const seed = await seedCampaign(page, { + informationCompleted: true, + choiceId: encourageChoiceId, + includeMiZhu: true, + includeEquipment: false + }); + assert.equal(seed.saved, true, seed.reason); + + let battle = await openBattle(page, { + active: true, + choiceId: encourageChoiceId, + miZhuDeployed: true + }); + assertDeploymentInformation(battle, `${renderer} encourage`); + assert( + battle.battleHud.deploymentPanel.subtitleText.includes( + '격려 +1턴' + ) + ); + battle = await startBattleWithRealClick(page); + + const support = await page.evaluate(() => { + const scene = + window.__HEROS_DEBUG__?.scene('BattleScene'); + const first = + scene.debugResolveXuzhouStaySupport('encourage'); + const second = + scene.debugResolveXuzhouStaySupport('encourage'); + scene.renderTacticalInitiativeChip(); + return { + first, + second, + payoff: structuredClone( + window.__HEROS_DEBUG__.battle() + .xuzhouStayBattlePayoff + ), + chipTexts: (scene.tacticalInitiativeChipObjects ?? []) + .filter( + (object) => + object?.type === 'Text' && + object.active && + object.visible + ) + .map((object) => object.text) + }; + }); + assert.equal(support.first.cityStayBonusHealing, 0); + assert.equal(support.first.cityStayBonusBuffTurns, 1); + assert.equal( + support.second.cityStayBonusBuffTurns, + 0, + `${renderer}: the second encourage must not receive the one-use duration bonus.` + ); + assert.equal(support.payoff.runtime.supportUses, 1); + assert.equal(support.payoff.runtime.bonusBuffTurns, 1); + assert( + support.chipTexts.some((text) => + text.includes('격려 +1턴') + ) + ); + await capture(page, screenshotPath(renderer, 'encourage-used')); +} + +async function verifyMiZhuDefeatedBranch(page, renderer) { + const seed = await seedCampaign(page, { + informationCompleted: true, + choiceId: aidChoiceId, + includeMiZhu: true, + includeEquipment: false + }); + assert.equal(seed.saved, true, seed.reason); + + let battle = await openBattle(page, { + active: true, + choiceId: aidChoiceId, + miZhuDeployed: true + }); + assertDeploymentInformation( + battle, + `${renderer} Mi Zhu defeated` + ); + battle = await startBattleWithRealClick(page); + const defeated = await page.evaluate(() => { + const scene = + window.__HEROS_DEBUG__?.scene('BattleScene'); + if ( + !scene || + typeof scene.debugSetXuzhouStayMiZhuHp !== + 'function' + ) { + throw new Error( + 'BattleScene Mi Zhu defeat debug hook is unavailable.' + ); + } + const payoff = scene.debugSetXuzhouStayMiZhuHp(0); + return { + payoff: structuredClone(payoff), + support: + scene.debugResolveXuzhouStaySupport('aid') ?? null, + chipTexts: (scene.tacticalInitiativeChipObjects ?? []) + .filter( + (object) => + object?.type === 'Text' && + object.active && + object.visible + ) + .map((object) => object.text) + }; + }); + assert.equal(defeated.payoff.effectActive, false); + assert.equal(defeated.payoff.runtime.supportUses, 0); + assert.equal(defeated.support, null); + assert( + defeated.chipTexts.some((text) => + text.includes('미축 퇴각') + ), + `${renderer}: an unused support payoff must visibly expire when Mi Zhu is defeated.` + ); + assert( + defeated.payoff.resultText.includes('미축 퇴각'), + `${renderer}: result feedback must preserve the defeat reason.` + ); + await capture( + page, + screenshotPath(renderer, 'mi-zhu-defeated-active') + ); +} + +async function verifyMiZhuMissingBranch(page, renderer) { + const seed = await seedCampaign(page, { + informationCompleted: true, + choiceId: aidChoiceId, + includeMiZhu: false, + includeEquipment: false + }); + assert.equal(seed.saved, true, seed.reason); + + let battle = await openBattle(page, { + active: true, + choiceId: aidChoiceId, + miZhuDeployed: false + }); + assertDeploymentInformation(battle, `${renderer} Mi Zhu missing`); + assert.equal( + battle.xuzhouStayBattlePayoff.effectActive, + false + ); + assert.equal( + battle.xuzhouStayBattlePayoff.mechanicsProbe.aid, + null + ); + const deploymentTexts = await sceneTextValues( + page, + 'sidePanelObjects' + ); + assert( + deploymentTexts.some((text) => + text.includes('미축 미출전') + ), + `${renderer}: deployment notice must explicitly state that Mi Zhu is absent.` + ); + await capture( + page, + screenshotPath(renderer, 'mi-zhu-missing-deployment') + ); + + battle = await startBattleWithRealClick(page); + const missing = await page.evaluate(() => { + const scene = + window.__HEROS_DEBUG__?.scene('BattleScene'); + return { + support: + scene.debugResolveXuzhouStaySupport('aid') ?? null, + payoff: structuredClone( + window.__HEROS_DEBUG__.battle() + .xuzhouStayBattlePayoff + ), + chipTexts: (scene.tacticalInitiativeChipObjects ?? []) + .filter( + (object) => + object?.type === 'Text' && + object.active && + object.visible + ) + .map((object) => object.text) + }; + }); + assert.equal(missing.support, null); + assert.equal(missing.payoff.runtime.supportUses, 0); + assert.equal(missing.payoff.runtime.bonusHealing, 0); + assert.equal(missing.payoff.chip.visible, true); + assert( + missing.chipTexts.some((text) => + text.includes('미축 미출전') + ), + `${renderer}: active payoff chip must preserve the no-sortie explanation.` + ); + await capture( + page, + screenshotPath(renderer, 'mi-zhu-missing-active') + ); +} + +async function verifyNoInformationBaseline(page, renderer) { + const seed = await seedCampaign(page, { + informationCompleted: false, + choiceId: undefined, + includeMiZhu: true, + includeEquipment: false + }); + assert.equal(seed.saved, true, seed.reason); + + const battle = await openBattle(page, { + active: false, + choiceId: null, + miZhuDeployed: false + }); + assert.equal(battle.phase, 'deployment'); + assert.equal(battle.xuzhouStayBattlePayoff.active, false); + assert.deepEqual( + battle.enemyIntentPreviews, + [], + `${renderer}: the no-information deployment baseline must reveal no enemy intents.` + ); + assert.equal(battle.enemyIntentVisualCount, 0); + assert( + !battle.battleHud.deploymentPanel.subtitleText.includes( + '관청 장부' + ) + ); + assertDeploymentHeaderLayout( + battle.battleHud.deploymentPanel, + `${renderer} no-information baseline` + ); + await capture( + page, + screenshotPath(renderer, 'baseline-no-information') + ); +} + +async function seedCampaign(page, options) { + return page.evaluate( + async ({ + sourceId, + targetId, + visitId, + requestedDialogueId, + selectedChoiceId, + requestedMiZhuId, + requestedOfferId, + informationCompleted, + includeMiZhu, + includeEquipment + }) => { + const [campaignModule, scenarioModule] = + await Promise.all([ + import('/heros_web/src/game/state/campaignState.ts'), + import('/heros_web/src/game/data/scenario.ts') + ]); + const current = campaignModule.getCampaignState(); + const roster = scenarioModule.eighthBattleUnits + .filter((unit) => unit.faction === 'ally') + .filter( + (unit) => + includeMiZhu || unit.id !== requestedMiZhuId + ) + .map((unit) => structuredClone(unit)); + const miZhu = roster.find( + (unit) => unit.id === requestedMiZhuId + ); + if (includeMiZhu && !miZhu) { + return { + saved: false, + reason: 'The eighth-battle Mi Zhu template is missing.' + }; + } + if (includeEquipment && miZhu) { + miZhu.equipment.weapon = { + itemId: 'iron-spear', + level: 1, + exp: 6 + }; + } + + const now = new Date().toISOString(); + const selectedSortieUnitIds = roster + .map((unit) => unit.id) + .filter((unitId) => + [ + 'liu-bei', + 'guan-yu', + 'zhang-fei', + 'jian-yong', + requestedMiZhuId + ].includes(unitId) + ); + const next = { + ...current, + step: 'seventh-camp', + latestBattleId: sourceId, + roster, + bonds: [], + battleHistory: { + [sourceId]: { + battleId: sourceId, + battleTitle: '서주 구원전', + outcome: 'victory', + turnNumber: 12, + rewardGold: 0, + itemRewards: [], + objectives: [], + units: structuredClone(roster), + bonds: [], + completedAt: now + } + }, + completedCampVisits: informationCompleted + ? [visitId] + : [], + completedCampDialogues: selectedChoiceId + ? [requestedDialogueId] + : [], + campDialogueChoiceIds: selectedChoiceId + ? { + [requestedDialogueId]: selectedChoiceId + } + : {}, + campVisitChoiceIds: {}, + selectedSortieUnitIds, + sortieFormationAssignments: { + 'liu-bei': 'support', + 'guan-yu': 'front', + 'zhang-fei': 'flank', + 'jian-yong': 'support', + ...(includeMiZhu + ? { [requestedMiZhuId]: 'reserve' } + : {}) + }, + sortieItemAssignments: {}, + sortieOrderSelection: { + battleId: targetId, + orderId: 'elite' + }, + inventory: {}, + cityEquipmentPurchaseCounts: + includeEquipment && includeMiZhu + ? { [requestedOfferId]: 1 } + : {} + }; + delete next.firstBattleReport; + delete next.sortieResonanceSelection; + delete next.sortieRecommendationSelection; + delete next.pendingAftermathBattleId; + delete next.cityEquipmentEquipIntent; + if (includeEquipment && includeMiZhu) { + next.cityEquipmentPreparation = { + version: 1, + selectionRecordId: 'city-equipment-preparation', + cityStayId: 'xuzhou', + sourceBattleId: sourceId, + targetBattleId: targetId, + offerId: requestedOfferId, + itemId: 'iron-spear', + slot: 'weapon', + price: 620, + purchaseNumber: 1, + unitId: requestedMiZhuId, + previousItemId: 'training-sword' + }; + } else { + delete next.cityEquipmentPreparation; + } + + const saved = campaignModule.setCampaignState(next); + return { + saved: true, + choiceId: + saved.campDialogueChoiceIds?.[ + requestedDialogueId + ] ?? null, + informationCompleted: + saved.completedCampVisits.includes(visitId), + selectedSortieUnitIds: [ + ...saved.selectedSortieUnitIds + ], + equipmentOfferId: + saved.cityEquipmentPreparation?.offerId ?? null + }; + }, + { + sourceId: sourceBattleId, + targetId: targetBattleId, + visitId: informationVisitId, + requestedDialogueId: dialogueId, + selectedChoiceId: options.choiceId, + requestedMiZhuId: miZhuUnitId, + requestedOfferId: equipmentOfferId, + informationCompleted: options.informationCompleted, + includeMiZhu: options.includeMiZhu, + includeEquipment: options.includeEquipment + } + ); +} + +async function openBattle(page, expected) { + await page.evaluate(async (battleId) => { + await window.__HEROS_DEBUG__.goToBattle(battleId); + }, targetBattleId); + await page.waitForFunction( + ({ battleId, expectedState }) => { + const battle = window.__HEROS_DEBUG__?.battle?.(); + const payoff = battle?.xuzhouStayBattlePayoff; + return ( + window.__HEROS_DEBUG__?.activeScenes?.().includes( + 'BattleScene' + ) && + battle?.battleId === battleId && + battle?.phase === 'deployment' && + battle?.battleHud?.deploymentPanel?.startButtonBounds && + payoff?.active === expectedState.active && + payoff?.choiceId === expectedState.choiceId && + (expectedState.active === false || + payoff?.runtime?.miZhuDeployed === + expectedState.miZhuDeployed) + ); + }, + { battleId: targetBattleId, expectedState: expected }, + { timeout: 90000 } + ); + return readBattle(page); +} + +function assertDeploymentInformation(battle, label) { + assert.equal(battle.battleId, targetBattleId); + assert.equal(battle.phase, 'deployment'); + assert.equal( + battle.xuzhouStayBattlePayoff.active, + true, + `${label}: payoff memory must be active.` + ); + assert.equal( + battle.xuzhouStayBattlePayoff.informationCompleted, + true + ); + assert.deepEqual( + [...battle.xuzhouStayBattlePayoff.trackedEnemyUnitIds].sort(), + [...trackedEnemyUnitIds].sort() + ); + const previewIds = battle.enemyIntentPreviews + .map((preview) => preview.enemyId) + .sort(); + assert.deepEqual( + previewIds, + [...trackedEnemyUnitIds].sort(), + `${label}: deployment must reveal exactly the two warehouse-raider intents.` + ); + assert.equal(battle.enemyIntentPreviews.length, 2); +} + +async function startBattleWithRealClick(page) { + await page.waitForFunction( + () => { + const battle = window.__HEROS_DEBUG__?.battle?.(); + return ( + battle?.phase === 'deployment' && + ['ready', 'degraded'].includes( + battle?.combatAssets?.status + ) && + battle?.battleHud?.deploymentPanel + ?.combatAssetsReady === true + ); + }, + undefined, + { timeout: 90000 } + ); + const battle = await readBattle(page); + await clickSceneBounds( + page, + 'BattleScene', + battle.battleHud.deploymentPanel.startButtonBounds + ); + await page.waitForFunction( + () => { + const state = window.__HEROS_DEBUG__?.battle?.(); + return ( + state?.phase === 'idle' && + state?.battleHud?.deploymentPanel === null && + state?.xuzhouStayBattlePayoff?.chip?.visible === true + ); + }, + undefined, + { timeout: 90000 } + ); + return readBattle(page); +} + +async function forceVictoryAndReadPayoff(page) { + await page.evaluate(() => { + window.__HEROS_DEBUG__.forceBattleOutcome('victory'); + }); + await page.waitForFunction( + (battleId) => { + const battle = window.__HEROS_DEBUG__?.battle?.(); + return ( + battle?.battleId === battleId && + battle?.battleOutcome === 'victory' && + battle?.resultVisible === true && + battle?.resultSettlement?.status === 'complete' && + battle?.xuzhouStayBattlePayoff?.result + ?.supportUses === 1 + ); + }, + targetBattleId, + { timeout: 90000 } + ); + return page.evaluate(async (battleId) => { + const battle = window.__HEROS_DEBUG__.battle(); + const scene = + window.__HEROS_DEBUG__?.scene('BattleScene'); + const campaignModule = await import( + '/heros_web/src/game/state/campaignState.ts' + ); + const campaign = campaignModule.loadCampaignState(); + const visibleResultTexts = (scene?.resultObjects ?? []) + .filter( + (object) => + object?.type === 'Text' && + object.active && + object.visible && + typeof object.text === 'string' + ) + .map((object) => object.text); + return { + battle, + visibleResultTexts, + campaign: { + reportBattleId: + campaign.firstBattleReport?.battleId ?? null, + reportPayoff: campaign.firstBattleReport + ?.xuzhouStayBattlePayoff + ? structuredClone( + campaign.firstBattleReport + .xuzhouStayBattlePayoff + ) + : null, + historyBattleId: + campaign.battleHistory?.[battleId]?.battleId ?? + null, + historyPayoff: campaign.battleHistory?.[battleId] + ?.xuzhouStayBattlePayoff + ? structuredClone( + campaign.battleHistory[battleId] + .xuzhouStayBattlePayoff + ) + : null + } + }; + }, targetBattleId); +} + +function assertDeploymentHeaderLayout(panel, label) { + assert(panel, `${label}: deployment panel is required.`); + assertBoundsInsideViewport( + panel.headerBounds, + `${label}: header` + ); + assertBoundsInside( + panel.statusBounds, + panel.headerBounds, + `${label}: status` + ); + if (panel.preparationSummaryBounds) { + assertBoundsInside( + panel.preparationSummaryBounds, + panel.headerBounds, + `${label}: preparation summary` + ); + assertNoOverlappingBounds( + [ + { id: 'status', bounds: panel.statusBounds }, + { + id: 'preparation-summary', + bounds: panel.preparationSummaryBounds + } + ], + `${label}: header text regions` + ); + } + assertBoundsInsideViewport( + panel.noticeBounds, + `${label}: notice` + ); + assertBoundsInside( + panel.noticeTextBounds, + panel.noticeBounds, + `${label}: notice text` + ); + assertNoOverlappingBounds( + [ + { id: 'header', bounds: panel.headerBounds }, + { id: 'notice', bounds: panel.noticeBounds }, + ...panel.roleBounds.map((bounds, index) => ({ + id: `role-${index + 1}`, + bounds + })), + { id: 'restore', bounds: panel.restoreButtonBounds }, + { id: 'start', bounds: panel.startButtonBounds } + ], + `${label}: primary deployment regions` + ); +} + +async function sceneTextValues(page, collectionName) { + return page.evaluate((requestedCollectionName) => { + const scene = + window.__HEROS_DEBUG__?.scene('BattleScene'); + return (scene?.[requestedCollectionName] ?? []) + .filter( + (object) => + object?.type === 'Text' && + object.active && + object.visible && + typeof object.text === 'string' + ) + .map((object) => object.text); + }, collectionName); +} + +async function readBattle(page) { + return page.evaluate( + () => window.__HEROS_DEBUG__?.battle?.() + ); +} + +async function waitForDebugApi(page) { + await page.waitForFunction( + () => + document.querySelector('canvas') !== null && + window.__HEROS_GAME__ !== undefined && + window.__HEROS_DEBUG__ !== undefined, + undefined, + { timeout: 90000 } + ); +} + +async function assertFhdViewport(page, rendererFixture) { + const viewport = await page.evaluate(() => { + const canvas = document.querySelector('canvas'); + const bounds = canvas?.getBoundingClientRect(); + return { + width: window.innerWidth, + height: window.innerHeight, + dpr: window.devicePixelRatio, + visualScale: window.visualViewport?.scale ?? 1, + rendererType: window.__HEROS_GAME__?.renderer?.type, + canvas: canvas + ? { + width: canvas.width, + height: canvas.height, + clientWidth: canvas.clientWidth, + clientHeight: canvas.clientHeight, + bounds: bounds + ? { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height + } + : null + } + : null + }; + }); + assert.equal(viewport.width, desktopBrowserViewport.width); + assert.equal(viewport.height, desktopBrowserViewport.height); + assert.equal(viewport.dpr, desktopBrowserDeviceScaleFactor); + assert.equal(viewport.visualScale, 1); + assert.equal( + viewport.rendererType, + rendererFixture.expectedRendererType + ); + assert.equal(viewport.canvas?.width, desktopBrowserViewport.width); + assert.equal(viewport.canvas?.height, desktopBrowserViewport.height); + assert.equal( + viewport.canvas?.clientWidth, + desktopBrowserViewport.width + ); + assert.equal( + viewport.canvas?.clientHeight, + desktopBrowserViewport.height + ); + assert.deepEqual(viewport.canvas?.bounds, { + x: 0, + y: 0, + width: desktopBrowserViewport.width, + height: desktopBrowserViewport.height + }); +} + +async function clickSceneBounds(page, sceneKey, bounds) { + assertFiniteBounds(bounds, `${sceneKey} click bounds`); + const point = await page.evaluate( + ({ requestedSceneKey, requestedBounds }) => { + const scene = + window.__HEROS_DEBUG__?.scene(requestedSceneKey); + const canvas = document.querySelector('canvas'); + const canvasBounds = canvas?.getBoundingClientRect(); + if (!scene || !canvasBounds) { + return null; + } + return { + x: + canvasBounds.left + + (requestedBounds.x + requestedBounds.width / 2) * + canvasBounds.width / + scene.scale.width, + y: + canvasBounds.top + + (requestedBounds.y + requestedBounds.height / 2) * + canvasBounds.height / + scene.scale.height + }; + }, + { + requestedSceneKey: sceneKey, + requestedBounds: bounds + } + ); + assert( + point && Number.isFinite(point.x) && Number.isFinite(point.y) + ); + await page.mouse.click(point.x, point.y); +} + +function assertFiniteBounds(bounds, label) { + assert(bounds, `${label}: bounds are required.`); + for (const key of ['x', 'y', 'width', 'height']) { + assert( + Number.isFinite(bounds[key]), + `${label}: ${key} must be finite.` + ); + } + assert(bounds.width > 0, `${label}: width must be positive.`); + assert(bounds.height > 0, `${label}: height must be positive.`); +} + +function assertBoundsInsideViewport(bounds, label) { + assertBoundsInside( + bounds, + { + x: 0, + y: 0, + width: desktopBrowserViewport.width, + height: desktopBrowserViewport.height + }, + label + ); +} + +function assertBoundsInside(bounds, container, label) { + assertFiniteBounds(bounds, label); + assertFiniteBounds(container, `${label} container`); + const epsilon = 0.01; + assert( + bounds.x >= container.x - epsilon && + bounds.y >= container.y - epsilon && + bounds.x + bounds.width <= + container.x + container.width + epsilon && + bounds.y + bounds.height <= + container.y + container.height + epsilon, + `${label}: expected bounds inside container, received ${JSON.stringify( + { bounds, container } + )}` + ); +} + +function assertNoOverlappingBounds(entries, label) { + for ( + let leftIndex = 0; + leftIndex < entries.length; + leftIndex += 1 + ) { + for ( + let rightIndex = leftIndex + 1; + rightIndex < entries.length; + rightIndex += 1 + ) { + const left = entries[leftIndex]; + const right = entries[rightIndex]; + assertFiniteBounds(left.bounds, `${label} ${left.id}`); + assertFiniteBounds(right.bounds, `${label} ${right.id}`); + const overlapWidth = + Math.min( + left.bounds.x + left.bounds.width, + right.bounds.x + right.bounds.width + ) - Math.max(left.bounds.x, right.bounds.x); + const overlapHeight = + Math.min( + left.bounds.y + left.bounds.height, + right.bounds.y + right.bounds.height + ) - Math.max(left.bounds.y, right.bounds.y); + assert( + overlapWidth <= 0 || overlapHeight <= 0, + `${label} "${left.id}" and "${right.id}" overlap: ${JSON.stringify( + { left, right } + )}` + ); + } + } +} + +async function capture(page, path) { + await page.waitForTimeout(200); + const loopSlept = await page.evaluate(() => { + const loop = window.__HEROS_GAME__?.loop; + if (!loop || typeof loop.sleep !== 'function') { + return false; + } + loop.sleep(); + return true; + }); + await page.waitForTimeout(40); + try { + await page.screenshot({ path, fullPage: true }); + } finally { + if (loopSlept) { + await page.evaluate(() => + window.__HEROS_GAME__?.loop.wake() + ); + await page.waitForTimeout(40); + } + } +} + +function screenshotPath(renderer, stage) { + return `dist/verification-xuzhou-stay-payoff-${renderer}-${stage}.png`; +} + +function cliOption(name) { + const prefix = `--${name}=`; + const value = process.argv.find((argument) => + argument.startsWith(prefix) + ); + return value?.slice(prefix.length); +} diff --git a/scripts/verify-xuzhou-stay-battle-payoff.mjs b/scripts/verify-xuzhou-stay-battle-payoff.mjs new file mode 100644 index 0000000..d4d34bc --- /dev/null +++ b/scripts/verify-xuzhou-stay-battle-payoff.mjs @@ -0,0 +1,575 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'vite'; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true, hmr: false }, + appType: 'custom' +}); + +try { + const payoff = await server.ssrLoadModule( + '/src/game/data/xuzhouStayBattlePayoff.ts' + ); + const cityStays = await server.ssrLoadModule( + '/src/game/data/cityStays.ts' + ); + const battleUsables = await server.ssrLoadModule( + '/src/game/data/battleUsables.ts' + ); + const scenario = await server.ssrLoadModule( + '/src/game/data/scenario.ts' + ); + + verifyCanonicalDefinition({ + payoff, + cityStays, + battleUsables, + scenario + }); + const memories = verifyCampaignResolution(payoff); + verifyRuntimeAndSnapshotNormalization(payoff, memories); + + console.log( + 'Xuzhou stay battle payoff verification passed (canonical city provenance, source-victory precedence, independent information/dialogue resolution, target-battle isolation, first-three-turn aid/encourage effects, counterplay bounds, Mi Zhu deployment neutralization, choice-exclusive counters, snapshot cloning, and forged-state rejection).' + ); +} finally { + await server.close(); +} + +function verifyCanonicalDefinition({ + payoff, + cityStays, + battleUsables, + scenario +}) { + const city = cityStays.getCityStayDefinition('xuzhou'); + const definition = payoff.xuzhouStayBattlePayoffDefinition; + assert.equal(definition.version, 1); + assert.equal(definition.recordId, 'xuzhou-stay-battle-payoff'); + assert.equal(definition.cityStayId, city.id); + assert.equal(definition.sourceBattleId, city.afterBattleId); + assert.equal(definition.targetBattleId, city.nextBattleId); + assert.equal(definition.information.visitId, city.information.id); + assert.equal(definition.dialogue.dialogueId, city.dialogue.id); + assert.deepEqual( + Object.keys(definition.dialogue.choices), + city.dialogue.choices.map((choice) => choice.id), + 'Both canonical Xuzhou dialogue choices must have a battle payoff.' + ); + assert.deepEqual(payoff.xuzhouStayBattlePayoffChoiceIds, [ + 'city-xuzhou-share-storehouse-duty', + 'city-xuzhou-trust-mi-zhu-ledger' + ]); + + assert.deepEqual(definition.information.effect, { + kind: 'deployment-intent-preview', + enemyUnitIds: ['xiaopei-guard-a', 'xiaopei-guard-b'], + previewCount: 2 + }); + const eighthBattleEnemyIds = new Set( + scenario.eighthBattleUnits + .filter((unit) => unit.faction === 'enemy') + .map((unit) => unit.id) + ); + definition.information.effect.enemyUnitIds.forEach((unitId) => { + assert( + eighthBattleEnemyIds.has(unitId), + `Information preview target is missing from battle 8: ${unitId}` + ); + }); + + const aid = payoff.getXuzhouStayResonanceEffect( + 'city-xuzhou-share-storehouse-duty' + ); + assert.deepEqual(aid, { + kind: 'mi-zhu-first-aid-healing', + actorUnitId: 'mi-zhu', + usableId: 'aid', + activeThroughTurn: 3, + bonusHealing: 4, + maxBattleUses: 1 + }); + const encourage = payoff.getXuzhouStayResonanceEffect( + 'city-xuzhou-trust-mi-zhu-ledger' + ); + assert.deepEqual(encourage, { + kind: 'mi-zhu-first-encourage-duration', + actorUnitId: 'mi-zhu', + usableId: 'encourage', + activeThroughTurn: 3, + bonusBuffTurns: 1, + maxBattleUses: 1 + }); + assert.equal( + payoff.getXuzhouStayResonanceEffect('forged-choice'), + undefined + ); + assert( + battleUsables.unitStrategyIds['mi-zhu'].includes(aid.usableId), + 'Mi Zhu must know aid before his aid payoff can be applied.' + ); + assert( + battleUsables.unitStrategyIds['mi-zhu'].includes( + encourage.usableId + ), + 'Mi Zhu must know encourage before his encourage payoff can be applied.' + ); + assert.equal( + battleUsables.usableCatalog.encourage.duration + encourage.bonusBuffTurns, + 3, + 'The entrusted ledger choice should extend encourage from two to three turns.' + ); + + assert.equal(payoff.isXuzhouStayBattlePayoffActiveTurn(1), true); + assert.equal(payoff.isXuzhouStayBattlePayoffActiveTurn(3), true); + assert.equal(payoff.isXuzhouStayBattlePayoffActiveTurn(0), false); + assert.equal(payoff.isXuzhouStayBattlePayoffActiveTurn(4), false); + assert.equal(payoff.isXuzhouStayBattlePayoffActiveTurn(2.5), false); +} + +function verifyCampaignResolution(payoff) { + const sourceBattleId = + payoff.xuzhouStayBattlePayoffSourceBattleId; + const targetBattleId = + payoff.xuzhouStayBattlePayoffTargetBattleId; + const victory = { + battleId: sourceBattleId, + outcome: 'victory' + }; + const baseCampaign = { + battleHistory: { + [sourceBattleId]: victory + }, + firstBattleReport: null, + completedCampVisits: [], + completedCampDialogues: [], + campDialogueChoiceIds: {} + }; + + assert.equal( + payoff.resolveXuzhouStayBattlePayoff(baseCampaign), + undefined, + 'A victory without either city activity must not create a payoff.' + ); + assert.equal( + payoff.resolveXuzhouStayBattlePayoff( + { + ...baseCampaign, + completedCampVisits: [payoff.xuzhouStayInformationVisitId] + }, + 'ninth-battle-xuzhou-gate-night-raid' + ), + undefined, + 'Xuzhou preparation must not leak beyond battle 8.' + ); + + const informationOnly = + payoff.resolveXuzhouStayBattlePayoff({ + ...baseCampaign, + completedCampVisits: [ + 'unrelated-visit', + payoff.xuzhouStayInformationVisitId + ] + }); + assert(informationOnly); + assert.equal(informationOnly.recordSource, 'battle-history'); + assert.equal(informationOnly.informationCompleted, true); + assert.equal(informationOnly.choiceId, undefined); + assert.deepEqual(informationOnly.informationEffect, { + kind: 'deployment-intent-preview', + enemyUnitIds: ['xiaopei-guard-a', 'xiaopei-guard-b'], + previewCount: 2 + }); + assert.notEqual( + informationOnly.informationEffect.enemyUnitIds, + payoff.xuzhouStayInformationEnemyUnitIds, + 'Resolved memories must not expose the canonical tuple by reference.' + ); + + const aidOnly = payoff.resolveXuzhouStayBattlePayoff({ + ...baseCampaign, + completedCampDialogues: [ + payoff.xuzhouStayResonanceDialogueId + ], + campDialogueChoiceIds: { + [payoff.xuzhouStayResonanceDialogueId]: + payoff.xuzhouStayShareStorehouseDutyChoiceId + } + }); + assert(aidOnly); + assert.equal(aidOnly.informationCompleted, false); + assert.equal( + aidOnly.choiceId, + payoff.xuzhouStayShareStorehouseDutyChoiceId + ); + assert.equal( + aidOnly.resonanceEffect.kind, + 'mi-zhu-first-aid-healing' + ); + + const bothWithEncourage = + payoff.resolveXuzhouStayBattlePayoff({ + ...baseCampaign, + completedCampVisits: [ + payoff.xuzhouStayInformationVisitId + ], + completedCampDialogues: [ + payoff.xuzhouStayResonanceDialogueId + ], + campDialogueChoiceIds: { + [payoff.xuzhouStayResonanceDialogueId]: + payoff.xuzhouStayTrustMiZhuLedgerChoiceId + } + }); + assert(bothWithEncourage); + assert.equal(bothWithEncourage.informationCompleted, true); + assert.equal( + bothWithEncourage.choiceId, + payoff.xuzhouStayTrustMiZhuLedgerChoiceId + ); + assert.equal( + bothWithEncourage.resonanceEffect.kind, + 'mi-zhu-first-encourage-duration' + ); + + assert.equal( + payoff.resolveXuzhouStayBattlePayoff({ + ...baseCampaign, + completedCampDialogues: [], + campDialogueChoiceIds: { + [payoff.xuzhouStayResonanceDialogueId]: + payoff.xuzhouStayShareStorehouseDutyChoiceId + } + }), + undefined, + 'A remembered choice without completed dialogue is not evidence.' + ); + assert.equal( + payoff.resolveXuzhouStayBattlePayoff({ + ...baseCampaign, + completedCampDialogues: [ + payoff.xuzhouStayResonanceDialogueId + ], + campDialogueChoiceIds: { + [payoff.xuzhouStayResonanceDialogueId]: 'forged-choice' + } + }), + undefined, + 'A completed dialogue with a forged choice must stay neutral.' + ); + const informationSurvivesForgedDialogue = + payoff.resolveXuzhouStayBattlePayoff({ + ...baseCampaign, + completedCampVisits: [ + payoff.xuzhouStayInformationVisitId + ], + completedCampDialogues: [ + payoff.xuzhouStayResonanceDialogueId + ], + campDialogueChoiceIds: { + [payoff.xuzhouStayResonanceDialogueId]: 'forged-choice' + } + }); + assert(informationSurvivesForgedDialogue); + assert.equal( + informationSurvivesForgedDialogue.informationCompleted, + true + ); + assert.equal( + informationSurvivesForgedDialogue.choiceId, + undefined, + 'Invalid dialogue data must not erase independently earned information.' + ); + + const legacyInformation = + payoff.resolveXuzhouStayBattlePayoff({ + ...baseCampaign, + battleHistory: {}, + firstBattleReport: victory, + completedCampVisits: [ + payoff.xuzhouStayInformationVisitId + ] + }); + assert(legacyInformation); + assert.equal(legacyInformation.recordSource, 'legacy-report'); + + assert.equal( + payoff.resolveXuzhouStayBattlePayoff({ + ...baseCampaign, + battleHistory: { + [sourceBattleId]: { + battleId: sourceBattleId, + outcome: 'defeat' + } + }, + firstBattleReport: victory, + completedCampVisits: [ + payoff.xuzhouStayInformationVisitId + ] + }), + undefined, + 'An explicit invalid history entry must shadow a stale legacy report.' + ); + assert.equal( + payoff.resolveXuzhouStayBattlePayoff({ + ...baseCampaign, + battleHistory: {}, + firstBattleReport: { + battleId: targetBattleId, + outcome: 'victory' + }, + completedCampVisits: [ + payoff.xuzhouStayInformationVisitId + ] + }), + undefined, + 'A victory from another battle must not authorize this payoff.' + ); + + return { + informationOnly, + aidOnly, + bothWithEncourage + }; +} + +function verifyRuntimeAndSnapshotNormalization(payoff, memories) { + const aidRuntime = + payoff.createXuzhouStayBattlePayoffRuntime( + memories.aidOnly, + { + miZhuDeployed: true, + expectedRosterUnitIds: ['liu-bei', 'mi-zhu'] + } + ); + assert.deepEqual(aidRuntime, { + version: 1, + recordId: 'xuzhou-stay-battle-payoff', + cityStayId: 'xuzhou', + sourceBattleId: 'seventh-battle-xuzhou-rescue', + targetBattleId: 'eighth-battle-xiaopei-supply-road', + informationVisitId: 'city-xuzhou-xiaopei-ledger', + dialogueId: 'city-xuzhou-liu-mi-supply-trust', + informationCompleted: false, + choiceId: 'city-xuzhou-share-storehouse-duty', + informationCounterplays: 0, + miZhuDeployed: true, + supportAttempts: 0, + supportUses: 0, + bonusHealing: 0, + bonusBuffTurns: 0 + }); + + const saturatedAid = + payoff.normalizeXuzhouStayBattlePayoffRuntime( + { + ...aidRuntime, + informationCounterplays: 99, + supportAttempts: 4.9, + supportUses: 8, + bonusHealing: 99, + bonusBuffTurns: 99 + }, + { + expectedBattleId: + payoff.xuzhouStayBattlePayoffTargetBattleId, + expectedRosterUnitIds: new Set(['mi-zhu']), + expectedMemory: memories.aidOnly + } + ); + assert.deepEqual(saturatedAid, { + ...aidRuntime, + supportAttempts: 4, + supportUses: 1, + bonusHealing: 4 + }); + const zeroBenefitAid = + payoff.normalizeXuzhouStayBattlePayoffRuntime({ + ...aidRuntime, + supportAttempts: 1, + supportUses: 1, + bonusHealing: 0 + }); + assert.equal( + zeroBenefitAid.supportUses, + 0, + 'Aid cannot be consumed unless it provided at least one point of real bonus healing.' + ); + assert.equal(zeroBenefitAid.bonusHealing, 0); + + const informationRuntime = + payoff.createXuzhouStayBattlePayoffRuntime( + memories.informationOnly, + { + miZhuDeployed: false, + expectedRosterUnitIds: ['liu-bei'] + } + ); + const boundedInformation = + payoff.normalizeXuzhouStayBattlePayoffRuntime({ + ...informationRuntime, + informationCounterplays: 500, + supportAttempts: 3, + supportUses: 1, + bonusHealing: 4, + bonusBuffTurns: 1 + }); + assert.equal(boundedInformation.informationCounterplays, 2); + assert.equal(boundedInformation.supportAttempts, 0); + assert.equal(boundedInformation.supportUses, 0); + assert.equal(boundedInformation.bonusHealing, 0); + assert.equal(boundedInformation.bonusBuffTurns, 0); + + const neutralizedGhost = + payoff.normalizeXuzhouStayBattlePayoffRuntime( + { + ...saturatedAid, + informationCompleted: true, + informationCounterplays: 2, + miZhuDeployed: true + }, + { + expectedRosterUnitIds: ['liu-bei'] + } + ); + assert.equal(neutralizedGhost.miZhuDeployed, false); + assert.equal(neutralizedGhost.supportAttempts, 0); + assert.equal(neutralizedGhost.supportUses, 0); + assert.equal(neutralizedGhost.bonusHealing, 0); + assert.equal(neutralizedGhost.bonusBuffTurns, 0); + assert.equal( + neutralizedGhost.informationCounterplays, + 2, + 'Mi Zhu deployment must not erase the independent information payoff.' + ); + + const rosterRepairsDeployment = + payoff.normalizeXuzhouStayBattlePayoffRuntime( + { + ...aidRuntime, + miZhuDeployed: false, + supportAttempts: 2, + supportUses: 1, + bonusHealing: 3 + }, + { + expectedRosterUnitIds: ['mi-zhu'] + } + ); + assert.equal(rosterRepairsDeployment.miZhuDeployed, true); + assert.equal(rosterRepairsDeployment.supportAttempts, 2); + assert.equal(rosterRepairsDeployment.supportUses, 1); + assert.equal(rosterRepairsDeployment.bonusHealing, 3); + + const encourageRuntime = + payoff.createXuzhouStayBattlePayoffRuntime( + memories.bothWithEncourage, + { + miZhuDeployed: true, + expectedRosterUnitIds: ['mi-zhu'] + } + ); + const appliedEncourage = + payoff.normalizeXuzhouStayBattlePayoffRuntime( + { + ...encourageRuntime, + informationCounterplays: 2, + supportAttempts: 3, + supportUses: 7, + bonusHealing: 50, + bonusBuffTurns: 50 + }, + { + expectedMemory: memories.bothWithEncourage + } + ); + assert.equal(appliedEncourage.informationCounterplays, 2); + assert.equal(appliedEncourage.supportAttempts, 3); + assert.equal(appliedEncourage.supportUses, 1); + assert.equal(appliedEncourage.bonusHealing, 0); + assert.equal(appliedEncourage.bonusBuffTurns, 1); + + const noEncourageUse = + payoff.normalizeXuzhouStayBattlePayoffRuntime({ + ...encourageRuntime, + supportAttempts: 1, + supportUses: 0, + bonusBuffTurns: 99 + }); + assert.equal(noEncourageUse.supportAttempts, 1); + assert.equal(noEncourageUse.supportUses, 0); + assert.equal(noEncourageUse.bonusBuffTurns, 0); + + const snapshot = + payoff.createXuzhouStayBattlePayoffSnapshot( + appliedEncourage, + { + expectedBattleId: + payoff.xuzhouStayBattlePayoffTargetBattleId, + expectedRosterUnitIds: ['mi-zhu'] + } + ); + assert.deepEqual(snapshot, appliedEncourage); + assert.notEqual(snapshot, appliedEncourage); + const snapshotClone = + payoff.cloneXuzhouStayBattlePayoffSnapshot(snapshot); + const runtimeClone = + payoff.cloneXuzhouStayBattlePayoffRuntime(appliedEncourage); + assert.deepEqual(snapshotClone, snapshot); + assert.deepEqual(runtimeClone, appliedEncourage); + assert.notEqual(snapshotClone, snapshot); + assert.notEqual(runtimeClone, appliedEncourage); + + assert.equal( + payoff.normalizeXuzhouStayBattlePayoffSnapshot(snapshot, { + expectedBattleId: 'ninth-battle-xuzhou-gate-night-raid' + }), + undefined, + 'A containing report from another battle must reject the snapshot.' + ); + assert.equal( + payoff.normalizeXuzhouStayBattlePayoffSnapshot( + { + ...snapshot, + targetBattleId: 'forged-battle' + } + ), + undefined + ); + assert.equal( + payoff.normalizeXuzhouStayBattlePayoffSnapshot( + { + ...snapshot, + choiceId: 'forged-choice' + } + ), + undefined + ); + assert.equal( + payoff.normalizeXuzhouStayBattlePayoffSnapshot( + { + ...snapshot, + informationCompleted: false + }, + { + expectedMemory: memories.bothWithEncourage + } + ), + undefined, + 'Loaded runtime evidence must match the campaign-derived memory.' + ); + assert.equal( + payoff.normalizeXuzhouStayBattlePayoffSnapshot( + { + ...snapshot, + choiceId: payoff.xuzhouStayShareStorehouseDutyChoiceId + }, + { + expectedMemory: memories.bothWithEncourage + } + ), + undefined, + 'A save cannot exchange one completed resonance choice for another.' + ); +} diff --git a/src/game/data/cityStays.ts b/src/game/data/cityStays.ts index 030b9f7..53ef260 100644 --- a/src/game/data/cityStays.ts +++ b/src/game/data/cityStays.ts @@ -24,6 +24,7 @@ export type CityResonanceDialogueChoice = { label: string; response: string; rewardExp: number; + battleEffect?: string; }; export type CityResonanceDialogue = { @@ -70,8 +71,8 @@ export const cityStayDefinitions: readonly CityStayDefinition[] = [ location: '서주 관청', description: '관청의 창고 장부와 상인들의 증언을 맞추어 소패로 향하는 보급로의 약한 지점을 찾습니다.', briefingLines: [ - '소패의 창고는 서쪽 길목과 마을 어귀를 통해 보급을 받습니다.', - '습격대보다 먼저 마을과 창고 주변을 안정시키면 고순의 압박을 견디기 쉬워집니다.' + '창고 보급은 서쪽 길목으로 들어오며, 장부의 빈틈을 노린 여포군 기병은 동쪽 길에서 창고를 우회 압박합니다.', + '마을 어귀와 창고 습격대의 움직임을 배치 전에 확인해 먼저 대응하십시오.' ], itemReward: '소패 보급로 장부 1' }, @@ -90,13 +91,15 @@ export const cityStayDefinitions: readonly CityStayDefinition[] = [ id: 'city-xuzhou-share-storehouse-duty', label: '창고와 백성의 몫을 함께 지킨다', response: '미축은 군량과 구휼 장부를 나누어 적고, 어느 쪽도 희생시키지 않겠다고 답했습니다.', - rewardExp: 10 + rewardExp: 12, + battleEffect: '8차 전투 3턴까지 미축의 첫 ‘응급’ 실제 회복량 +4' }, { id: 'city-xuzhou-trust-mi-zhu-ledger', label: '보급 판단을 미축에게 맡긴다', response: '미축은 유비의 신뢰에 고개를 숙이고 소패까지 이어지는 상단과 창고를 직접 정리했습니다.', - rewardExp: 12 + rewardExp: 12, + battleEffect: '8차 전투 3턴까지 미축의 첫 ‘격려’ 지속 +1턴' } ] }, diff --git a/src/game/data/xuzhouStayBattlePayoff.ts b/src/game/data/xuzhouStayBattlePayoff.ts new file mode 100644 index 0000000..efc3ced --- /dev/null +++ b/src/game/data/xuzhouStayBattlePayoff.ts @@ -0,0 +1,511 @@ +export const xuzhouStayBattlePayoffVersion = 1 as const; +export const xuzhouStayBattlePayoffRecordId = + 'xuzhou-stay-battle-payoff' as const; +export const xuzhouStayId = 'xuzhou' as const; +export const xuzhouStayBattlePayoffSourceBattleId = + 'seventh-battle-xuzhou-rescue' as const; +export const xuzhouStayBattlePayoffTargetBattleId = + 'eighth-battle-xiaopei-supply-road' as const; +export const xuzhouStayInformationVisitId = + 'city-xuzhou-xiaopei-ledger' as const; +export const xuzhouStayResonanceDialogueId = + 'city-xuzhou-liu-mi-supply-trust' as const; +export const xuzhouStayShareStorehouseDutyChoiceId = + 'city-xuzhou-share-storehouse-duty' as const; +export const xuzhouStayTrustMiZhuLedgerChoiceId = + 'city-xuzhou-trust-mi-zhu-ledger' as const; +export const xuzhouStayMiZhuUnitId = 'mi-zhu' as const; +export const xuzhouStayBattlePayoffActiveThroughTurn = 3 as const; +export const xuzhouStayInformationEnemyUnitIds = [ + 'xiaopei-guard-a', + 'xiaopei-guard-b' +] as const; + +export const xuzhouStayBattlePayoffChoiceIds = [ + xuzhouStayShareStorehouseDutyChoiceId, + xuzhouStayTrustMiZhuLedgerChoiceId +] as const; + +export type XuzhouStayBattlePayoffChoiceId = + (typeof xuzhouStayBattlePayoffChoiceIds)[number]; + +export type XuzhouStayInformationEffect = { + kind: 'deployment-intent-preview'; + enemyUnitIds: readonly string[]; + previewCount: 2; +}; + +export type XuzhouStayAidEffect = { + kind: 'mi-zhu-first-aid-healing'; + actorUnitId: typeof xuzhouStayMiZhuUnitId; + usableId: 'aid'; + activeThroughTurn: typeof xuzhouStayBattlePayoffActiveThroughTurn; + bonusHealing: 4; + maxBattleUses: 1; +}; + +export type XuzhouStayEncourageEffect = { + kind: 'mi-zhu-first-encourage-duration'; + actorUnitId: typeof xuzhouStayMiZhuUnitId; + usableId: 'encourage'; + activeThroughTurn: typeof xuzhouStayBattlePayoffActiveThroughTurn; + bonusBuffTurns: 1; + maxBattleUses: 1; +}; + +export type XuzhouStayResonanceEffect = + | XuzhouStayAidEffect + | XuzhouStayEncourageEffect; + +export const xuzhouStayBattlePayoffDefinition = { + version: xuzhouStayBattlePayoffVersion, + recordId: xuzhouStayBattlePayoffRecordId, + cityStayId: xuzhouStayId, + sourceBattleId: xuzhouStayBattlePayoffSourceBattleId, + targetBattleId: xuzhouStayBattlePayoffTargetBattleId, + information: { + visitId: xuzhouStayInformationVisitId, + effect: { + kind: 'deployment-intent-preview', + enemyUnitIds: xuzhouStayInformationEnemyUnitIds, + previewCount: 2 + } satisfies XuzhouStayInformationEffect + }, + dialogue: { + dialogueId: xuzhouStayResonanceDialogueId, + choices: { + [xuzhouStayShareStorehouseDutyChoiceId]: { + kind: 'mi-zhu-first-aid-healing', + actorUnitId: xuzhouStayMiZhuUnitId, + usableId: 'aid', + activeThroughTurn: + xuzhouStayBattlePayoffActiveThroughTurn, + bonusHealing: 4, + maxBattleUses: 1 + } satisfies XuzhouStayAidEffect, + [xuzhouStayTrustMiZhuLedgerChoiceId]: { + kind: 'mi-zhu-first-encourage-duration', + actorUnitId: xuzhouStayMiZhuUnitId, + usableId: 'encourage', + activeThroughTurn: + xuzhouStayBattlePayoffActiveThroughTurn, + bonusBuffTurns: 1, + maxBattleUses: 1 + } satisfies XuzhouStayEncourageEffect + } + } +} as const; + +export type XuzhouStayBattleRecord = { + battleId?: unknown; + outcome?: unknown; +}; + +export type XuzhouStayBattlePayoffCampaignState = { + battleHistory?: Readonly< + Record + >; + firstBattleReport?: XuzhouStayBattleRecord | null; + completedCampVisits?: readonly unknown[]; + completedCampDialogues?: readonly unknown[]; + campDialogueChoiceIds?: Readonly> | null; +}; + +export type XuzhouStayBattlePayoffRecordSource = + | 'battle-history' + | 'legacy-report'; + +export type ResolvedXuzhouStayBattlePayoff = { + recordId: typeof xuzhouStayBattlePayoffRecordId; + cityStayId: typeof xuzhouStayId; + sourceBattleId: typeof xuzhouStayBattlePayoffSourceBattleId; + targetBattleId: typeof xuzhouStayBattlePayoffTargetBattleId; + informationVisitId: typeof xuzhouStayInformationVisitId; + dialogueId: typeof xuzhouStayResonanceDialogueId; + recordSource: XuzhouStayBattlePayoffRecordSource; + informationCompleted: boolean; + informationEffect?: XuzhouStayInformationEffect; + choiceId?: XuzhouStayBattlePayoffChoiceId; + resonanceEffect?: XuzhouStayResonanceEffect; +}; + +export type XuzhouStayBattlePayoffSnapshot = { + version: typeof xuzhouStayBattlePayoffVersion; + recordId: typeof xuzhouStayBattlePayoffRecordId; + cityStayId: typeof xuzhouStayId; + sourceBattleId: typeof xuzhouStayBattlePayoffSourceBattleId; + targetBattleId: typeof xuzhouStayBattlePayoffTargetBattleId; + informationVisitId: typeof xuzhouStayInformationVisitId; + dialogueId: typeof xuzhouStayResonanceDialogueId; + informationCompleted: boolean; + choiceId?: XuzhouStayBattlePayoffChoiceId; + informationCounterplays: number; + miZhuDeployed: boolean; + supportAttempts: number; + supportUses: number; + bonusHealing: number; + bonusBuffTurns: number; +}; + +export type XuzhouStayBattlePayoffRuntime = + XuzhouStayBattlePayoffSnapshot; +export type XuzhouStayBattlePayoffRuntimeState = + XuzhouStayBattlePayoffRuntime; + +export type XuzhouStayBattlePayoffNormalizationOptions = { + expectedBattleId?: unknown; + expectedRosterUnitIds?: readonly unknown[] | ReadonlySet; + expectedMemory?: ResolvedXuzhouStayBattlePayoff | null; +}; + +export function isXuzhouStayBattlePayoffChoiceId( + value: unknown +): value is XuzhouStayBattlePayoffChoiceId { + return ( + typeof value === 'string' && + (xuzhouStayBattlePayoffChoiceIds as readonly string[]).includes( + value + ) + ); +} + +export function getXuzhouStayResonanceEffect( + choiceId: unknown +): XuzhouStayResonanceEffect | undefined { + if (!isXuzhouStayBattlePayoffChoiceId(choiceId)) { + return undefined; + } + return { + ...xuzhouStayBattlePayoffDefinition.dialogue.choices[choiceId] + }; +} + +export function isXuzhouStayBattlePayoffActiveTurn( + turnNumber: unknown +) { + return ( + typeof turnNumber === 'number' && + Number.isInteger(turnNumber) && + turnNumber >= 1 && + turnNumber <= xuzhouStayBattlePayoffActiveThroughTurn + ); +} + +export function resolveXuzhouStayBattlePayoff( + campaign?: XuzhouStayBattlePayoffCampaignState | null, + battleId: unknown = xuzhouStayBattlePayoffTargetBattleId +): ResolvedXuzhouStayBattlePayoff | undefined { + if (battleId !== xuzhouStayBattlePayoffTargetBattleId) { + return undefined; + } + const recordSource = selectSourceVictoryRecord(campaign); + if (!recordSource) { + return undefined; + } + + const informationCompleted = arrayValue( + campaign?.completedCampVisits + ).includes(xuzhouStayInformationVisitId); + const dialogueCompleted = arrayValue( + campaign?.completedCampDialogues + ).includes(xuzhouStayResonanceDialogueId); + const rawChoiceId = isRecord(campaign?.campDialogueChoiceIds) + ? campaign.campDialogueChoiceIds[ + xuzhouStayResonanceDialogueId + ] + : undefined; + const choiceId = + dialogueCompleted && + isXuzhouStayBattlePayoffChoiceId(rawChoiceId) + ? rawChoiceId + : undefined; + + if (!informationCompleted && !choiceId) { + return undefined; + } + + return { + recordId: xuzhouStayBattlePayoffRecordId, + cityStayId: xuzhouStayId, + sourceBattleId: xuzhouStayBattlePayoffSourceBattleId, + targetBattleId: xuzhouStayBattlePayoffTargetBattleId, + informationVisitId: xuzhouStayInformationVisitId, + dialogueId: xuzhouStayResonanceDialogueId, + recordSource, + informationCompleted, + ...(informationCompleted + ? { + informationEffect: { + ...xuzhouStayBattlePayoffDefinition.information.effect, + enemyUnitIds: [ + ...xuzhouStayInformationEnemyUnitIds + ] + } + } + : {}), + ...(choiceId + ? { + choiceId, + resonanceEffect: + getXuzhouStayResonanceEffect(choiceId)! + } + : {}) + }; +} + +export function createXuzhouStayBattlePayoffRuntime( + memory: ResolvedXuzhouStayBattlePayoff, + options: { + miZhuDeployed: unknown; + expectedRosterUnitIds?: readonly unknown[] | ReadonlySet; + } +): XuzhouStayBattlePayoffRuntime | undefined { + return normalizeXuzhouStayBattlePayoffRuntime( + { + version: xuzhouStayBattlePayoffVersion, + recordId: memory.recordId, + cityStayId: memory.cityStayId, + sourceBattleId: memory.sourceBattleId, + targetBattleId: memory.targetBattleId, + informationVisitId: memory.informationVisitId, + dialogueId: memory.dialogueId, + informationCompleted: memory.informationCompleted, + ...(memory.choiceId ? { choiceId: memory.choiceId } : {}), + informationCounterplays: 0, + miZhuDeployed: options.miZhuDeployed, + supportAttempts: 0, + supportUses: 0, + bonusHealing: 0, + bonusBuffTurns: 0 + }, + { + expectedBattleId: memory.targetBattleId, + expectedRosterUnitIds: options.expectedRosterUnitIds, + expectedMemory: memory + } + ); +} + +export function normalizeXuzhouStayBattlePayoffRuntime( + value: unknown, + options: XuzhouStayBattlePayoffNormalizationOptions = {} +): XuzhouStayBattlePayoffRuntime | undefined { + return normalizePayoffState(value, options); +} + +export function cloneXuzhouStayBattlePayoffRuntime( + runtime: XuzhouStayBattlePayoffRuntime +): XuzhouStayBattlePayoffRuntime { + return { ...runtime }; +} + +export function createXuzhouStayBattlePayoffSnapshot( + runtime: unknown, + options: XuzhouStayBattlePayoffNormalizationOptions = {} +): XuzhouStayBattlePayoffSnapshot | undefined { + return normalizeXuzhouStayBattlePayoffSnapshot(runtime, options); +} + +export function normalizeXuzhouStayBattlePayoffSnapshot( + value: unknown, + options: XuzhouStayBattlePayoffNormalizationOptions = {} +): XuzhouStayBattlePayoffSnapshot | undefined { + return normalizePayoffState(value, options); +} + +export function cloneXuzhouStayBattlePayoffSnapshot( + snapshot: XuzhouStayBattlePayoffSnapshot +): XuzhouStayBattlePayoffSnapshot { + return { ...snapshot }; +} + +function normalizePayoffState( + value: unknown, + options: XuzhouStayBattlePayoffNormalizationOptions +): XuzhouStayBattlePayoffSnapshot | undefined { + if ( + !isRecord(value) || + value.version !== xuzhouStayBattlePayoffVersion || + value.recordId !== xuzhouStayBattlePayoffRecordId || + value.cityStayId !== xuzhouStayId || + value.sourceBattleId !== + xuzhouStayBattlePayoffSourceBattleId || + value.targetBattleId !== + xuzhouStayBattlePayoffTargetBattleId || + value.informationVisitId !== + xuzhouStayInformationVisitId || + value.dialogueId !== xuzhouStayResonanceDialogueId || + typeof value.informationCompleted !== 'boolean' || + typeof value.miZhuDeployed !== 'boolean' || + ( + options.expectedBattleId !== undefined && + options.expectedBattleId !== + xuzhouStayBattlePayoffTargetBattleId + ) + ) { + return undefined; + } + + const choiceId = + value.choiceId === undefined + ? undefined + : isXuzhouStayBattlePayoffChoiceId(value.choiceId) + ? value.choiceId + : undefined; + if (value.choiceId !== undefined && !choiceId) { + return undefined; + } + + if (options.expectedMemory === null) { + return undefined; + } + const expectedMemory = options.expectedMemory; + if ( + expectedMemory && + ( + expectedMemory.recordId !== xuzhouStayBattlePayoffRecordId || + expectedMemory.sourceBattleId !== + xuzhouStayBattlePayoffSourceBattleId || + expectedMemory.targetBattleId !== + xuzhouStayBattlePayoffTargetBattleId || + expectedMemory.informationCompleted !== + value.informationCompleted || + expectedMemory.choiceId !== choiceId + ) + ) { + return undefined; + } + + const rosterDeployment = expectedMiZhuDeployment( + options.expectedRosterUnitIds + ); + const miZhuDeployed = + rosterDeployment ?? value.miZhuDeployed; + const informationCounterplays = value.informationCompleted + ? cappedNonNegativeInteger( + value.informationCounterplays, + xuzhouStayInformationEnemyUnitIds.length + ) + : 0; + const supportEnabled = Boolean(choiceId && miZhuDeployed); + const supportAttempts = supportEnabled + ? cappedNonNegativeInteger( + value.supportAttempts, + maxXuzhouStayBattlePayoffCounter + ) + : 0; + const requestedSupportUses = supportEnabled + ? Math.min( + cappedNonNegativeInteger(value.supportUses, 1), + supportAttempts + ) + : 0; + + const bonusHealing = + choiceId === xuzhouStayShareStorehouseDutyChoiceId && + requestedSupportUses > 0 + ? cappedNonNegativeInteger( + value.bonusHealing, + xuzhouStayBattlePayoffDefinition.dialogue.choices[ + xuzhouStayShareStorehouseDutyChoiceId + ].bonusHealing + ) + : 0; + const supportUses = + choiceId === xuzhouStayShareStorehouseDutyChoiceId + ? bonusHealing > 0 + ? requestedSupportUses + : 0 + : requestedSupportUses; + const bonusBuffTurns = + choiceId === xuzhouStayTrustMiZhuLedgerChoiceId && + supportUses > 0 + ? xuzhouStayBattlePayoffDefinition.dialogue.choices[ + xuzhouStayTrustMiZhuLedgerChoiceId + ].bonusBuffTurns + : 0; + + return { + version: xuzhouStayBattlePayoffVersion, + recordId: xuzhouStayBattlePayoffRecordId, + cityStayId: xuzhouStayId, + sourceBattleId: xuzhouStayBattlePayoffSourceBattleId, + targetBattleId: xuzhouStayBattlePayoffTargetBattleId, + informationVisitId: xuzhouStayInformationVisitId, + dialogueId: xuzhouStayResonanceDialogueId, + informationCompleted: value.informationCompleted, + ...(choiceId ? { choiceId } : {}), + informationCounterplays, + miZhuDeployed, + supportAttempts, + supportUses, + bonusHealing, + bonusBuffTurns + }; +} + +function selectSourceVictoryRecord( + campaign?: XuzhouStayBattlePayoffCampaignState | null +): XuzhouStayBattlePayoffRecordSource | undefined { + const history = campaign?.battleHistory; + if ( + history && + Object.prototype.hasOwnProperty.call( + history, + xuzhouStayBattlePayoffSourceBattleId + ) + ) { + return isSourceVictoryRecord( + history[xuzhouStayBattlePayoffSourceBattleId] + ) + ? 'battle-history' + : undefined; + } + return isSourceVictoryRecord(campaign?.firstBattleReport) + ? 'legacy-report' + : undefined; +} + +function isSourceVictoryRecord( + value?: XuzhouStayBattleRecord | null +) { + return Boolean( + value && + value.battleId === + xuzhouStayBattlePayoffSourceBattleId && + value.outcome === 'victory' + ); +} + +function expectedMiZhuDeployment( + value?: readonly unknown[] | ReadonlySet +) { + if (value === undefined) { + return undefined; + } + if (value instanceof Set) { + return value.has(xuzhouStayMiZhuUnitId); + } + if (!Array.isArray(value)) { + return false; + } + return value.some((unitId) => unitId === xuzhouStayMiZhuUnitId); +} + +function cappedNonNegativeInteger(value: unknown, max: number) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) { + return 0; + } + return Math.min(max, Math.floor(numeric)); +} + +function arrayValue(value: unknown): readonly unknown[] { + return Array.isArray(value) ? value : []; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +const maxXuzhouStayBattlePayoffCounter = 999999; diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index cab66ab..73c8cbd 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -37,6 +37,20 @@ import { type CityEquipmentContributionSnapshot, type ResolvedCityEquipmentPreparation } from '../data/cityEquipmentPreparation'; +import { + cloneXuzhouStayBattlePayoffRuntime, + createXuzhouStayBattlePayoffRuntime, + getXuzhouStayResonanceEffect, + isXuzhouStayBattlePayoffActiveTurn, + normalizeXuzhouStayBattlePayoffRuntime, + resolveXuzhouStayBattlePayoff, + xuzhouStayBattlePayoffActiveThroughTurn, + xuzhouStayMiZhuUnitId, + xuzhouStayShareStorehouseDutyChoiceId, + xuzhouStayTrustMiZhuLedgerChoiceId, + type ResolvedXuzhouStayBattlePayoff, + type XuzhouStayBattlePayoffRuntime +} from '../data/xuzhouStayBattlePayoff'; import { prologueMilitiaCampPreparationPayoffs } from '../data/prologueMilitiaCamp'; import { enemyIntentOpeningGuide, @@ -199,6 +213,7 @@ import { type BattleSaveTacticalCommandRole, type BattleSaveTacticalCommandState, type BattleSaveThirdCampPreparationState, + type BattleSaveXuzhouStayBattlePayoffState, type BattleSaveUnitStats } from '../state/battleSaveState'; import { @@ -2103,6 +2118,8 @@ type SupportPreview = { target: UnitData; healAmount: number; sortieHealBonus: number; + cityStayBonusHealing: number; + cityStayBonusBuffTurns: number; projectedHp: number; attackBonus: number; hitBonus: number; @@ -2164,6 +2181,8 @@ type SupportResult = { previousTargetHp: number; healAmount: number; sortieHealBonus: number; + cityStayBonusHealing: number; + cityStayBonusBuffTurns: number; buff?: BattleBuffState; characterGrowth: CharacterGrowthResult; equipmentGrowth: EquipmentGrowthResult; @@ -3964,6 +3983,10 @@ export class BattleScene extends Phaser.Scene { preventedDamage: 0 }; private cityEquipmentPreparationChipBounds?: HudBounds; + private xuzhouStayBattlePayoff?: ResolvedXuzhouStayBattlePayoff; + private xuzhouStayBattlePayoffRuntime?: XuzhouStayBattlePayoffRuntime; + private xuzhouStayBattlePayoffChipBounds?: HudBounds; + private tacticalInitiativeChipBounds?: HudBounds; private thirdCampPreparationRuntime: ThirdCampPreparationRuntimeState = { informationConsumed: false, equipmentConsumed: false, @@ -4105,6 +4128,11 @@ export class BattleScene extends Phaser.Scene { ); this.resetCityEquipmentContributionRuntime(); this.cityEquipmentPreparationChipBounds = undefined; + this.xuzhouStayBattlePayoff = + resolveXuzhouStayBattlePayoff(campaign, battleScenario.id); + this.xuzhouStayBattlePayoffRuntime = undefined; + this.xuzhouStayBattlePayoffChipBounds = undefined; + this.tacticalInitiativeChipBounds = undefined; this.resetThirdCampPreparationRuntime(); this.thirdCampPreparationChipBounds = undefined; this.prologueVolunteerReassured = campaign.completedTutorialIds.includes( @@ -4179,6 +4207,7 @@ export class BattleScene extends Phaser.Scene { const campaign = getCampaignState(); this.resetBattleData(campaign); this.syncCityEquipmentPreparationDeployment(); + this.syncXuzhouStayBattlePayoffDeployment(); if ( campaign.step === 'new' || campaign.step === 'prologue' || @@ -6303,40 +6332,54 @@ export class BattleScene extends Phaser.Scene { private renderTacticalInitiativeChip() { this.tacticalInitiativeChipObjects.forEach((object) => object.destroy()); this.tacticalInitiativeChipObjects = []; + this.tacticalInitiativeChipBounds = undefined; this.thirdCampPreparationChipBounds = undefined; this.cityEquipmentPreparationChipBounds = undefined; + this.xuzhouStayBattlePayoffChipBounds = undefined; if ( - this.cityEquipmentPreparation && - this.phase !== 'deployment' && - this.phase !== 'resolved' && - !this.battleOutcome - ) { - this.renderCityEquipmentPreparationChip(); - return; - } - if ( - this.thirdCampPreparationMemory && - this.phase !== 'deployment' && - this.phase !== 'resolved' && - !this.battleOutcome - ) { - this.renderThirdCampPreparationChip(); - return; - } - if ( - !this.isFirstPursuitRoleEffectBattle() || this.phase === 'deployment' || this.phase === 'resolved' || this.battleOutcome ) { return; } + if ( + this.xuzhouStayBattlePayoffRuntime && + this.cityEquipmentPreparation + ) { + this.renderXuzhouStayBattlePayoffChip(0, 'left'); + this.renderCityEquipmentPreparationChip(0, 'right'); + return; + } + let row = 0; + if (this.xuzhouStayBattlePayoffRuntime) { + this.renderXuzhouStayBattlePayoffChip(row); + row += 1; + } + if ( + this.cityEquipmentPreparation + ) { + this.renderCityEquipmentPreparationChip(row); + return; + } + if ( + this.thirdCampPreparationMemory + ) { + this.renderThirdCampPreparationChip(row); + return; + } + if (row > 0) { + return; + } + if (!this.isFirstPursuitRoleEffectBattle()) { + return; + } - const { panelX, panelY, panelWidth } = this.layout; + const { panelX, panelWidth } = this.layout; const width = this.battleUiLength(82); const height = this.battleUiLength(30); const left = panelX + panelWidth - width - this.battleUiLength(24); - const top = panelY + this.battleUiLength(26); + const top = this.preparationChipTop(); const points = this.tacticalInitiativePoints(); const ready = this.tacticalInitiativeReady(); const used = Boolean(this.tacticalCommand); @@ -6379,6 +6422,13 @@ export class BattleScene extends Phaser.Scene { }); text.setOrigin(0.5); text.setDepth(17); + const bounds = button.getBounds(); + this.tacticalInitiativeChipBounds = { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height + }; if (canOpen) { const open = () => { @@ -6396,7 +6446,7 @@ export class BattleScene extends Phaser.Scene { this.tacticalInitiativeChipObjects.push(button, text); } - private renderThirdCampPreparationChip() { + private renderThirdCampPreparationChip(row = 0) { const presentation = this.thirdCampPreparationPresentation('turn'); const label = @@ -6404,12 +6454,12 @@ export class BattleScene extends Phaser.Scene { if (!presentation || !label) { return; } - const { panelX, panelY, panelWidth } = this.layout; + const { panelX, panelWidth } = this.layout; const width = this.battleUiLength(240); const height = this.battleUiLength(30); const left = panelX + panelWidth - width - this.battleUiLength(24); - const top = panelY + this.battleUiLength(26); + const top = this.preparationChipTop(row); const applied = this.thirdCampPreparationRuntime.usageCount > 0; const active = presentation.status === 'active'; @@ -6461,19 +6511,16 @@ export class BattleScene extends Phaser.Scene { this.tacticalInitiativeChipObjects.push(background, text); } - private renderCityEquipmentPreparationChip() { + private renderCityEquipmentPreparationChip( + row = 0, + split?: 'left' | 'right' + ) { if (!this.cityEquipmentPreparation) { return; } - const { panelX, panelY, panelWidth } = this.layout; - const width = Math.min( - panelWidth - this.battleUiLength(48), - this.battleUiLength(310) - ); + const { left, width, top } = + this.preparationStatusChipPlacement(row, split); const height = this.battleUiLength(30); - const left = - panelX + panelWidth - width - this.battleUiLength(24); - const top = panelY + this.battleUiLength(26); const runtime = this.cityEquipmentContributionRuntime; const contributed = runtime.offensiveActions > 0 || runtime.defensiveHits > 0; @@ -6500,11 +6547,13 @@ export class BattleScene extends Phaser.Scene { const text = this.add.text( left + width / 2, top + height / 2 - this.battleUiLength(1), - this.cityEquipmentPreparationDisplayLine(), + split + ? `${this.cityEquipmentPreparation.unit.name}·${this.cityEquipmentPreparation.item.name}·${this.cityEquipmentPreparationStatDeltaText().replaceAll(' ', '')}${runtime.deployed ? '' : '·미반영'}` + : this.cityEquipmentPreparationDisplayLine(), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: this.battleUiFontSize(10), + fontSize: this.battleUiFontSize(split ? 9 : 10), color: !runtime.deployed ? '#9fb0bf' : contributed @@ -6525,6 +6574,126 @@ export class BattleScene extends Phaser.Scene { this.tacticalInitiativeChipObjects.push(background, text); } + private renderXuzhouStayBattlePayoffChip( + row = 0, + split?: 'left' | 'right' + ) { + const runtime = this.xuzhouStayBattlePayoffRuntime; + const memory = this.xuzhouStayBattlePayoff; + if (!runtime || !memory) { + return; + } + const { left, width, top } = + this.preparationStatusChipPlacement(row, split); + const height = this.battleUiLength(30); + const miZhuAvailable = this.xuzhouStayMiZhuAvailable(); + const active = + Boolean(memory.choiceId) && + miZhuAvailable && + runtime.supportUses === 0 && + isXuzhouStayBattlePayoffActiveTurn(this.turnNumber); + const contributed = + runtime.informationCounterplays > 0 || + runtime.supportUses > 0; + const tone = contributed + ? palette.green + : active || runtime.informationCompleted + ? palette.gold + : 0x647485; + const background = this.add.rectangle( + left, + top, + width, + height, + active ? 0x2d2619 : 0x17232e, + 0.98 + ); + background.setOrigin(0); + background.setDepth(16); + background.setStrokeStyle( + this.battleUiLength(1), + tone, + active || contributed ? 0.92 : 0.62 + ); + const parts = [ + runtime.informationCompleted + ? runtime.informationCounterplays > 0 + ? split + ? `파훼${runtime.informationCounterplays}` + : `장부 파훼 ${runtime.informationCounterplays}` + : split + ? '의도2' + : '장부 의도 2' + : '', + memory.choiceId && !runtime.miZhuDeployed + ? split + ? '미축 없음' + : '미축 미출전' + : memory.choiceId && + runtime.supportUses === 0 && + !miZhuAvailable + ? split + ? '미축 퇴각' + : '미축 퇴각 · 지원 종료' + : memory.choiceId === + xuzhouStayShareStorehouseDutyChoiceId + ? runtime.supportUses > 0 + ? split + ? `응급+${runtime.bonusHealing} 완료` + : `응급 +${runtime.bonusHealing} 사용 완료` + : active + ? split + ? '응급+4 대기' + : '응급 +4 대기' + : split + ? '응급 종료' + : '응급 강화 종료' + : memory.choiceId === + xuzhouStayTrustMiZhuLedgerChoiceId + ? runtime.supportUses > 0 + ? split + ? `격려+${runtime.bonusBuffTurns}턴 완료` + : `격려 +${runtime.bonusBuffTurns}턴 사용 완료` + : active + ? split + ? '격려+1턴 대기' + : '격려 +1턴 대기' + : split + ? '격려 종료' + : '격려 강화 종료' + : '' + ].filter(Boolean); + const label = split + ? `서주·${parts.join('·')}` + : `서주 · ${parts.join(' · ')}`; + const text = this.add.text( + left + width / 2, + top + height / 2 - this.battleUiLength(1), + label, + { + fontFamily: + '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: this.battleUiFontSize(split ? 9 : 10), + color: contributed + ? '#a8ffd0' + : active + ? '#fff2b8' + : '#9fb0bf', + fontStyle: '700' + } + ); + text.setOrigin(0.5); + text.setDepth(17); + const bounds = background.getBounds(); + this.xuzhouStayBattlePayoffChipBounds = { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height + }; + this.tacticalInitiativeChipObjects.push(background, text); + } + private thirdCampPreparationChipLabel( presentation = this.thirdCampPreparationPresentation('turn') @@ -8867,6 +9036,25 @@ export class BattleScene extends Phaser.Scene { if (this.firstPursuitScoutMemory) { return `${this.firstPursuitScoutMemory.campSummaryLine}. 간옹이 표시한 적의 첫 움직임을 보며 전열을 조정하세요.`; } + if (this.xuzhouStayBattlePayoff) { + const information = this.xuzhouStayBattlePayoff + .informationCompleted + ? '관청 장부로 창고 습격병 2개 부대의 첫 의도를 표시합니다.' + : ''; + const resonance = + this.xuzhouStayBattlePayoff.choiceId === + xuzhouStayShareStorehouseDutyChoiceId + ? this.xuzhouStayBattlePayoffRuntime?.miZhuDeployed + ? '미축은 3턴 안 첫 응급의 실제 회복을 4 늘립니다.' + : '미축 미출전으로 공동 구휼 방침은 반영되지 않습니다.' + : this.xuzhouStayBattlePayoff.choiceId === + xuzhouStayTrustMiZhuLedgerChoiceId + ? this.xuzhouStayBattlePayoffRuntime?.miZhuDeployed + ? '미축은 3턴 안 첫 격려를 1턴 연장합니다.' + : '미축 미출전으로 장부 위임 방침은 반영되지 않습니다.' + : ''; + return [information, resonance].filter(Boolean).join(' '); + } if (this.cityEquipmentPreparation) { return this.cityEquipmentContributionRuntime.deployed ? `${this.cityEquipmentPreparationDisplayLine()} 효과는 기존 장비 능력치에 이미 반영되어 있습니다.` @@ -8912,6 +9100,17 @@ export class BattleScene extends Phaser.Scene { })(); return `${this.firstPursuitScoutMemory.campSummaryLine}\n${strategySummary}`; } + if (this.xuzhouStayBattlePayoff) { + const strategySummary = this.launchSortieRecommendation + ? `선택 전략 · ${this.launchSortieRecommendation.label}` + : (() => { + const order = sortieOrderDefinition( + this.launchSortieOrderId + ); + return `선택 군령 · ${order.label}`; + })(); + return `${this.xuzhouStayDeploymentPreparationLine()}\n${strategySummary}`; + } if (this.cityEquipmentPreparation) { const strategySummary = this.launchSortieRecommendation ? `선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}` @@ -9388,7 +9587,10 @@ export class BattleScene extends Phaser.Scene { const preparationSummaryVisible = (this.firstBattlePreparation.eligible && this.firstBattlePreparationCompletedCount() > 0) || - Boolean(this.cityEquipmentPreparation); + Boolean( + this.cityEquipmentPreparation || + this.xuzhouStayBattlePayoff + ); const deploymentSubtitleText = this.deploymentSubtitle(); const subtitleText = this.trackSideObject(this.add.text(left + this.battleUiLength(12), top + this.battleUiLength(32), deploymentSubtitleText, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', @@ -10001,6 +10203,9 @@ export class BattleScene extends Phaser.Scene { this.thirdCampPreparationMemory?.effect.kind === 'marked-vanguard-intel' && !this.thirdCampPreparationRuntime.informationConsumed + ) || + ( + this.xuzhouStayBattlePayoff?.informationCompleted === true ) ) && !this.battleOutcome @@ -10014,7 +10219,7 @@ export class BattleScene extends Phaser.Scene { }); } - private trackedDeploymentIntentEnemyId() { + private trackedDeploymentIntentEnemyIds() { if (this.phase !== 'deployment') { return undefined; } @@ -10027,19 +10232,31 @@ export class BattleScene extends Phaser.Scene { ) { return undefined; } + if ( + this.xuzhouStayBattlePayoff?.informationEffect + ?.enemyUnitIds + ) { + return new Set( + this.xuzhouStayBattlePayoff.informationEffect.enemyUnitIds + ); + } if ( this.thirdCampPreparationMemory?.effect.kind === 'marked-vanguard-intel' && !this.thirdCampPreparationRuntime.informationConsumed ) { - return this.thirdCampPreparationMemory.effect.trackedEnemyUnitId; + return new Set([ + this.thirdCampPreparationMemory.effect.trackedEnemyUnitId + ]); } if ( this.secondBattleReliefMemory?.effect.kind === 'ferry-messenger-intel' && this.secondBattleReliefMemory.effect.revealTrackedRoute === true ) { - return this.secondBattleReliefMemory.effect.trackedEnemyUnitId; + return new Set([ + this.secondBattleReliefMemory.effect.trackedEnemyUnitId + ]); } return undefined; } @@ -10048,8 +10265,8 @@ export class BattleScene extends Phaser.Scene { if (!this.enemyIntentForecastEnabled()) { return false; } - const trackedEnemyId = this.trackedDeploymentIntentEnemyId(); - return !trackedEnemyId || trackedEnemyId === unitId; + const trackedEnemyIds = this.trackedDeploymentIntentEnemyIds(); + return !trackedEnemyIds || trackedEnemyIds.has(unitId); } private enemyIntentCounterplayEnabled() { @@ -10127,9 +10344,10 @@ export class BattleScene extends Phaser.Scene { return; } - const trackedEnemyId = this.trackedDeploymentIntentEnemyId(); + const trackedEnemyIds = this.trackedDeploymentIntentEnemyIds(); const plans = this.currentEnemyActionPlans().filter( - (plan) => !trackedEnemyId || plan.enemy.id === trackedEnemyId + (plan) => + !trackedEnemyIds || trackedEnemyIds.has(plan.enemy.id) ); plans.forEach((plan) => this.enemyIntentForecasts.set(plan.enemy.id, plan)); @@ -10438,6 +10656,9 @@ export class BattleScene extends Phaser.Scene { return undefined; } + this.recordXuzhouStayInformationCounterplays( + events.map((event) => event.enemyId) + ); const wasInitiativeReady = this.tacticalInitiativeReady(); const stats = this.statsFor(actor.id); const defeats = events.filter((event) => event.kind === 'defeat').length; @@ -10482,6 +10703,9 @@ export class BattleScene extends Phaser.Scene { ) { return false; } + this.recordXuzhouStayInformationCounterplays([ + attacker.id + ]); const wasInitiativeReady = this.tacticalInitiativeReady(); this.statsFor(defender.id).intentGuardSuccesses += 1; this.intentCounterplayEvents = [ @@ -11048,6 +11272,19 @@ export class BattleScene extends Phaser.Scene { const target = this.supportTargets(user, usable)[0]; const amount = target ? this.supportHealAmount(user, target, usable) : usable.power; const projectedHp = target ? Math.min(target.maxHp, target.hp + amount) : undefined; + const cityStayBonus = target + ? Math.max( + 0, + amount - + this.supportHealAmount( + user, + target, + usable, + false, + true + ) + ) + : 0; return { available, status, @@ -11055,7 +11292,9 @@ export class BattleScene extends Phaser.Scene { valueLabel: '회복', value: `+${amount}`, detail: target ? `${target.name} HP ${target.hp} → ${projectedHp}` : this.usableUnavailableReason(user, usable), - secondary: target ? `기본 ${usable.power} + 능력/장비 보정` : usable.description, + secondary: target + ? `기본 ${usable.power} + 능력/장비 보정${cityStayBonus > 0 ? ` · 서주 방침 +${cityStayBonus}` : ''}` + : usable.description, chips: [ { icon: this.usablePowerIcon(usable), label: '회복량', value: `+${amount}`, tone: 0x59d18c }, { icon: 'success', label: '적용', value: available ? '100%' : '-', tone: available ? 0x59d18c : 0x647485 }, @@ -11063,17 +11302,23 @@ export class BattleScene extends Phaser.Scene { ] }; } + const cityStayBonusBuffTurns = + this.xuzhouStayBonusBuffTurns(user, usable); + const duration = + (usable.duration ?? 1) + cityStayBonusBuffTurns; return { available, status, targetCount, valueLabel: '강화', - value: `${usable.duration ?? 1}턴`, + value: `${duration}턴`, detail: `공격 +${usable.attackBonus ?? 0} · 명중 +${usable.hitBonus ?? 0} · 치명 +${usable.criticalBonus ?? 0}`, - secondary: available ? `${this.usableTargetLabel(usable)} ${targetCount}명에게 ${usable.duration ?? 1}턴 적용` : this.usableUnavailableReason(user, usable), + secondary: available + ? `${this.usableTargetLabel(usable)} ${targetCount}명에게 ${duration}턴 적용${cityStayBonusBuffTurns > 0 ? ' · 서주 방침 +1턴' : ''}` + : this.usableUnavailableReason(user, usable), chips: [ { icon: 'focus', label: '강화', value: this.usableBuffText(usable), tone: palette.gold }, - { icon: 'leadership', label: '지속', value: `${usable.duration ?? 1}턴`, tone: 0x83d6ff }, + { icon: 'leadership', label: '지속', value: `${duration}턴`, tone: 0x83d6ff }, { icon: this.usableTargetIcon(usable), label: '대상', value: available ? `${targetCount}명` : '없음', tone: accent } ] }; @@ -12721,17 +12966,32 @@ export class BattleScene extends Phaser.Scene { private supportPreview(user: UnitData, target: UnitData, usable: BattleUsable): SupportPreview { const healAmount = usable.effect === 'heal' ? this.supportHealAmount(user, target, usable) : 0; const baselineHealAmount = usable.effect === 'heal' ? this.supportHealAmount(user, target, usable, true) : 0; + const cityStayBaselineHealAmount = + usable.effect === 'heal' + ? this.supportHealAmount(user, target, usable, false, true) + : 0; + const cityStayBonusHealing = Math.max( + 0, + healAmount - cityStayBaselineHealAmount + ); + const cityStayBonusBuffTurns = + usable.effect === 'focus' + ? this.xuzhouStayBonusBuffTurns(user, usable) + : 0; return { usable, user, target, healAmount, sortieHealBonus: Math.max(0, healAmount - baselineHealAmount), + cityStayBonusHealing, + cityStayBonusBuffTurns, projectedHp: Math.min(target.maxHp, target.hp + healAmount), attackBonus: usable.attackBonus ?? 0, hitBonus: usable.hitBonus ?? 0, criticalBonus: usable.criticalBonus ?? 0, - duration: usable.duration ?? 1 + duration: + (usable.duration ?? 1) + cityStayBonusBuffTurns }; } @@ -13857,7 +14117,10 @@ export class BattleScene extends Phaser.Scene { this.hideTacticalCommandCutIn(); this.tacticalInitiativeChipObjects.forEach((object) => object.destroy()); this.tacticalInitiativeChipObjects = []; + this.tacticalInitiativeChipBounds = undefined; this.thirdCampPreparationChipBounds = undefined; + this.cityEquipmentPreparationChipBounds = undefined; + this.xuzhouStayBattlePayoffChipBounds = undefined; this.hideBattleAlert(); this.clearBattleEvents(); this.refreshUnitLegibilityStyles(); @@ -13935,7 +14198,12 @@ export class BattleScene extends Phaser.Scene { subtitle.setOrigin(0.5, 0); subtitle.setDepth(depth + 2); - const metricTop = top + 112; + const metricTop = Math.ceil( + Math.max( + top + 112, + subtitle.y + subtitle.height + 8 + ) + ); const metricWidth = 226; const metricGap = 16; this.renderResultMetric('소요 턴', `${this.turnNumber}턴`, left + 44, metricTop, metricWidth, depth + 2); @@ -14560,6 +14828,8 @@ export class BattleScene extends Phaser.Scene { sortieCooperation: this.dominantSortieCooperationSnapshot(), cityEquipmentContribution: this.cityEquipmentContributionSnapshot(), + xuzhouStayBattlePayoff: + this.xuzhouStayBattlePayoffSnapshot(), completedCampDialogues: [], completedCampVisits: [], createdAt: new Date().toISOString() @@ -14579,10 +14849,23 @@ export class BattleScene extends Phaser.Scene { : `${battleScenario.title} 승리. 획득 보상과 장수 성장을 정산합니다.`; const preparation = this.thirdCampPreparationPresentation('result'); - const cityEquipmentContribution = - this.cityEquipmentContributionResultText(); - if (cityEquipmentContribution) { - return `${base}\n${cityEquipmentContribution}`; + const xuzhouResult = + this.xuzhouStayBattlePayoffCompactResultText(); + const equipmentResult = + this.cityEquipmentContributionCompactResultText(); + if (xuzhouResult) { + return `${base}\n서주 준비 · ${[ + xuzhouResult, + equipmentResult + ] + .filter((line): line is string => Boolean(line)) + .join(' · ')}`; + } + if (equipmentResult) { + const cityName = + this.cityEquipmentPreparation?.cityStay.city.name ?? + '도시'; + return `${base}\n${cityName} 장비 준비 · ${equipmentResult}`; } return preparation ? `${base}\n군영 준비 결과 · ${this.thirdCampPreparationResultText(preparation)}` @@ -17329,6 +17612,18 @@ export class BattleScene extends Phaser.Scene { const healAmount = usable.effect === 'heal' ? this.supportHealAmount(user, target, usable) : 0; const baselineHealAmount = usable.effect === 'heal' ? this.supportHealAmount(user, target, usable, true) : 0; const sortieHealBonus = Math.max(0, healAmount - baselineHealAmount); + const cityStayBaselineHealAmount = + usable.effect === 'heal' + ? this.supportHealAmount(user, target, usable, false, true) + : 0; + const cityStayBonusHealing = Math.max( + 0, + healAmount - cityStayBaselineHealAmount + ); + const cityStayBonusBuffTurns = + usable.effect === 'focus' + ? this.xuzhouStayBonusBuffTurns(user, usable) + : 0; let buff: BattleBuffState | undefined; if (healAmount > 0) { @@ -17342,7 +17637,8 @@ export class BattleScene extends Phaser.Scene { buff = { unitId: target.id, label: usable.name, - turns: usable.duration ?? 1, + turns: + (usable.duration ?? 1) + cityStayBonusBuffTurns, attackBonus: usable.attackBonus ?? 0, hitBonus: usable.hitBonus ?? 0, criticalBonus: usable.criticalBonus ?? 0 @@ -17354,6 +17650,12 @@ export class BattleScene extends Phaser.Scene { const equipmentGrowth = this.awardEquipmentExp(user, 'accessory', this.supportEquipmentExpGain(user, usable)); const characterGrowth = this.awardCharacterExp(user, usable.effect === 'heal' ? 8 : 6); this.recordSupportStats(user, healAmount, sortieHealBonus, usable); + this.recordXuzhouStaySupportUse( + user, + usable, + cityStayBonusHealing, + cityStayBonusBuffTurns + ); return { usable, @@ -17362,13 +17664,21 @@ export class BattleScene extends Phaser.Scene { previousTargetHp, healAmount, sortieHealBonus, + cityStayBonusHealing, + cityStayBonusBuffTurns, buff, characterGrowth, equipmentGrowth }; } - private supportHealAmount(user: UnitData, target: UnitData, usable: BattleUsable, ignoreSortieEffects = false) { + private supportHealAmount( + user: UnitData, + target: UnitData, + usable: BattleUsable, + ignoreSortieEffects = false, + ignoreCityStayEffect = false + ) { const bonus = usable.command === 'strategy' ? Math.floor(user.stats.intelligence / 12) : Math.floor(user.stats.luck / 14); const equipmentBonus = getItem(user.equipment.weapon.itemId).id === 'white-feather-fan' && usable.command === 'strategy' @@ -17379,7 +17689,15 @@ export class BattleScene extends Phaser.Scene { const flatAmount = usable.power + bonus + equipmentBonus; const percentFloor = Math.ceil(target.maxHp * this.supportHealFloorRate(usable)); const sortieBonus = this.sortieSupportHealBonus(user, usable, ignoreSortieEffects); - return Math.min(target.maxHp - target.hp, Math.max(1, flatAmount, percentFloor) + sortieBonus); + const cityStayBonus = ignoreCityStayEffect + ? 0 + : this.xuzhouStayBonusHealing(user, usable); + return Math.min( + target.maxHp - target.hp, + Math.max(1, flatAmount, percentFloor) + + sortieBonus + + cityStayBonus + ); } private supportHealFloorRate(usable: BattleUsable) { @@ -17776,16 +18094,22 @@ export class BattleScene extends Phaser.Scene { ? `결과: 회복 +${result.healAmount} · HP ${result.previousTargetHp}→${result.target.hp}` : `결과: 강화 ${result.buff?.turns ?? result.usable.duration ?? 1}턴 · ${this.supportBuffCompactText(result.buff)}`; const sortieContribution = result.sortieHealBonus > 0 ? `\n편성 기여: 후원 회복 +${result.sortieHealBonus}` : ''; - return `${this.supportResultSummaryLine(result)}\n${supportEffect}${sortieContribution}\n${this.formatCharacterGrowth(result.characterGrowth)}\n${this.formatEquipmentGrowth(result.equipmentGrowth)}`; + const cityStayContribution = + result.cityStayBonusHealing > 0 + ? `\n서주 방침: 공동 구휼 회복 +${result.cityStayBonusHealing}` + : result.cityStayBonusBuffTurns > 0 + ? `\n서주 방침: 장부 위임 지속 +${result.cityStayBonusBuffTurns}턴` + : ''; + return `${this.supportResultSummaryLine(result)}\n${supportEffect}${sortieContribution}${cityStayContribution}\n${this.formatCharacterGrowth(result.characterGrowth)}\n${this.formatEquipmentGrowth(result.equipmentGrowth)}`; } private supportResultSummaryLine(result: SupportResult) { if (result.usable.effect === 'heal') { - return `${result.user.name} → ${result.target.name} | ${result.usable.name} | 회복 +${result.healAmount}${result.sortieHealBonus > 0 ? ` · 후원 +${result.sortieHealBonus}` : ''} · HP ${result.previousTargetHp}→${result.target.hp}`; + return `${result.user.name} → ${result.target.name} | ${result.usable.name} | 회복 +${result.healAmount}${result.sortieHealBonus > 0 ? ` · 후원 +${result.sortieHealBonus}` : ''}${result.cityStayBonusHealing > 0 ? ` · 공동 구휼 +${result.cityStayBonusHealing}` : ''} · HP ${result.previousTargetHp}→${result.target.hp}`; } - return `${result.user.name} → ${result.target.name} | ${result.usable.name} | 강화 ${result.buff?.turns ?? result.usable.duration ?? 1}턴 · ${this.supportBuffCompactText(result.buff)}`; + return `${result.user.name} → ${result.target.name} | ${result.usable.name} | 강화 ${result.buff?.turns ?? result.usable.duration ?? 1}턴${result.cityStayBonusBuffTurns > 0 ? ` · 장부 위임 +${result.cityStayBonusBuffTurns}턴` : ''} · ${this.supportBuffCompactText(result.buff)}`; } private supportBuffCompactText(buff?: BattleBuffState) { @@ -18678,6 +19002,319 @@ export class BattleScene extends Phaser.Scene { return `${base} · ${parts.join(' / ')}`; } + private cityEquipmentContributionCompactResultText() { + const preparation = this.cityEquipmentPreparation; + if (!preparation) { + return undefined; + } + const runtime = this.cityEquipmentContributionRuntime; + if (!runtime.deployed) { + return `${preparation.item.name} 미반영`; + } + const parts = [ + runtime.bonusDamage > 0 + ? `추가 피해 +${runtime.bonusDamage}` + : '', + runtime.preventedDamage > 0 + ? `피해 ${runtime.preventedDamage} 감소` + : '', + runtime.bonusDamage <= 0 && + runtime.preventedDamage <= 0 + ? '직접 기여 없음' + : '' + ].filter(Boolean); + return `${preparation.item.name} ${parts.join('/')}`; + } + + private syncXuzhouStayBattlePayoffDeployment() { + const memory = this.xuzhouStayBattlePayoff; + if (!memory) { + this.xuzhouStayBattlePayoffRuntime = undefined; + return; + } + const deployedAllyIds = battleUnits + .filter((unit) => unit.faction === 'ally') + .map((unit) => unit.id); + this.xuzhouStayBattlePayoffRuntime = + createXuzhouStayBattlePayoffRuntime(memory, { + miZhuDeployed: deployedAllyIds.includes( + xuzhouStayMiZhuUnitId + ), + expectedRosterUnitIds: deployedAllyIds + }); + } + + private xuzhouStayBattlePayoffSnapshot() { + const runtime = this.xuzhouStayBattlePayoffRuntime; + return runtime + ? cloneXuzhouStayBattlePayoffRuntime(runtime) + : undefined; + } + + private xuzhouStayBattlePayoffSaveState(): + BattleSaveXuzhouStayBattlePayoffState | undefined { + return this.xuzhouStayBattlePayoffSnapshot(); + } + + private restoreXuzhouStayBattlePayoffRuntime( + state?: BattleSaveXuzhouStayBattlePayoffState + ) { + this.syncXuzhouStayBattlePayoffDeployment(); + const memory = this.xuzhouStayBattlePayoff; + const deployedAllyIds = battleUnits + .filter((unit) => unit.faction === 'ally') + .map((unit) => unit.id); + if (!memory || !state) { + return; + } + const restored = normalizeXuzhouStayBattlePayoffRuntime( + state, + { + expectedBattleId: battleScenario.id, + expectedRosterUnitIds: deployedAllyIds, + expectedMemory: memory + } + ); + if (restored) { + this.xuzhouStayBattlePayoffRuntime = restored; + } + } + + private xuzhouStayChoiceCompactLabel() { + const choiceId = this.xuzhouStayBattlePayoff?.choiceId; + if (choiceId === xuzhouStayShareStorehouseDutyChoiceId) { + return '공동 구휼'; + } + if (choiceId === xuzhouStayTrustMiZhuLedgerChoiceId) { + return '미축 장부 위임'; + } + return undefined; + } + + private xuzhouStayDeploymentSummary() { + const memory = this.xuzhouStayBattlePayoff; + if (!memory) { + return undefined; + } + const parts = [ + memory.informationCompleted ? '적 의도 2대' : '', + memory.choiceId === xuzhouStayShareStorehouseDutyChoiceId + ? '응급 +4' + : memory.choiceId === + xuzhouStayTrustMiZhuLedgerChoiceId + ? '격려 +1턴' + : '' + ].filter(Boolean); + return parts.length > 0 + ? `서주 · ${parts.join(' · ')}` + : undefined; + } + + private xuzhouStayDeploymentPreparationLine() { + const staySummary = this.xuzhouStayDeploymentSummary(); + if (!staySummary) { + return undefined; + } + const equipment = this.cityEquipmentPreparation; + if (!equipment) { + return staySummary; + } + const equipmentState = + this.cityEquipmentContributionRuntime.deployed + ? '' + : ' 미반영'; + return `${staySummary} · ${equipment.item.name}/${equipment.unit.name} ${this.cityEquipmentPreparationStatDeltaText()}${equipmentState}`; + } + + private xuzhouStayMiZhuAvailable() { + const runtime = this.xuzhouStayBattlePayoffRuntime; + return Boolean( + runtime?.miZhuDeployed && + battleUnits.some( + (unit) => + unit.id === xuzhouStayMiZhuUnitId && + unit.faction === 'ally' && + unit.hp > 0 + ) + ); + } + + private xuzhouStaySupportEffect( + user: UnitData, + usable: BattleUsable + ) { + const memory = this.xuzhouStayBattlePayoff; + const runtime = this.xuzhouStayBattlePayoffRuntime; + const effect = getXuzhouStayResonanceEffect( + memory?.choiceId + ); + if ( + !memory || + !runtime || + !effect || + !runtime.miZhuDeployed || + runtime.supportUses >= effect.maxBattleUses || + !isXuzhouStayBattlePayoffActiveTurn(this.turnNumber) || + user.id !== effect.actorUnitId || + user.faction !== 'ally' || + user.hp <= 0 || + usable.id !== effect.usableId + ) { + return undefined; + } + return effect; + } + + private xuzhouStayBonusHealing( + user: UnitData, + usable: BattleUsable + ) { + const effect = this.xuzhouStaySupportEffect(user, usable); + return effect?.kind === 'mi-zhu-first-aid-healing' + ? effect.bonusHealing + : 0; + } + + private xuzhouStayBonusBuffTurns( + user: UnitData, + usable: BattleUsable + ) { + const effect = this.xuzhouStaySupportEffect(user, usable); + return effect?.kind === + 'mi-zhu-first-encourage-duration' + ? effect.bonusBuffTurns + : 0; + } + + private recordXuzhouStaySupportUse( + user: UnitData, + usable: BattleUsable, + bonusHealing: number, + bonusBuffTurns: number + ) { + const runtime = this.xuzhouStayBattlePayoffRuntime; + const effect = this.xuzhouStaySupportEffect(user, usable); + if (!runtime || !effect) { + return; + } + runtime.supportAttempts = Math.min( + 999999, + runtime.supportAttempts + 1 + ); + if (bonusHealing <= 0 && bonusBuffTurns <= 0) { + this.renderTacticalInitiativeChip(); + return; + } + runtime.supportUses = 1; + runtime.bonusHealing = Math.min( + effect.kind === 'mi-zhu-first-aid-healing' + ? effect.bonusHealing + : 0, + Math.max(0, bonusHealing) + ); + runtime.bonusBuffTurns = + effect.kind === 'mi-zhu-first-encourage-duration' + ? effect.bonusBuffTurns + : 0; + this.renderTacticalInitiativeChip(); + } + + private recordXuzhouStayInformationCounterplays( + enemyUnitIds: readonly string[] + ) { + const memory = this.xuzhouStayBattlePayoff; + const runtime = this.xuzhouStayBattlePayoffRuntime; + const trackedEnemyIds = new Set( + memory?.informationEffect?.enemyUnitIds ?? [] + ); + if ( + !runtime || + !runtime.informationCompleted || + trackedEnemyIds.size === 0 + ) { + return; + } + const count = new Set( + enemyUnitIds.filter((unitId) => + trackedEnemyIds.has(unitId) + ) + ).size; + if (count <= 0) { + return; + } + runtime.informationCounterplays = Math.min( + trackedEnemyIds.size, + runtime.informationCounterplays + count + ); + this.renderTacticalInitiativeChip(); + } + + private xuzhouStayBattlePayoffResultText() { + const memory = this.xuzhouStayBattlePayoff; + const runtime = this.xuzhouStayBattlePayoffRuntime; + if (!memory || !runtime) { + return undefined; + } + const miZhuAvailable = this.xuzhouStayMiZhuAvailable(); + const parts = [ + runtime.informationCompleted + ? `장부 의도 파훼 ${runtime.informationCounterplays}회` + : '', + memory.choiceId && !runtime.miZhuDeployed + ? '미축 미출전·방침 미반영' + : memory.choiceId && + runtime.supportUses === 0 && + !miZhuAvailable + ? '미축 퇴각·방침 미반영' + : memory.choiceId === + xuzhouStayShareStorehouseDutyChoiceId + ? runtime.supportUses > 0 + ? `공동 구휼·응급 회복 +${runtime.bonusHealing}` + : '공동 구휼·응급 강화 미사용' + : memory.choiceId === + xuzhouStayTrustMiZhuLedgerChoiceId + ? runtime.supportUses > 0 + ? `장부 위임·격려 +${runtime.bonusBuffTurns}턴` + : '장부 위임·격려 강화 미사용' + : '' + ].filter(Boolean); + return parts.length > 0 + ? `서주 체류 · ${parts.join(' · ')}` + : undefined; + } + + private xuzhouStayBattlePayoffCompactResultText() { + const memory = this.xuzhouStayBattlePayoff; + const runtime = this.xuzhouStayBattlePayoffRuntime; + if (!memory || !runtime) { + return undefined; + } + const miZhuAvailable = this.xuzhouStayMiZhuAvailable(); + const parts = [ + runtime.informationCompleted + ? `장부 파훼 ${runtime.informationCounterplays}` + : '', + memory.choiceId && !runtime.miZhuDeployed + ? '미축 미출전' + : memory.choiceId && + runtime.supportUses === 0 && + !miZhuAvailable + ? '미축 퇴각' + : memory.choiceId === + xuzhouStayShareStorehouseDutyChoiceId + ? runtime.supportUses > 0 + ? `응급 +${runtime.bonusHealing}` + : '응급 미사용' + : memory.choiceId === + xuzhouStayTrustMiZhuLedgerChoiceId + ? runtime.supportUses > 0 + ? `격려 +${runtime.bonusBuffTurns}턴` + : '격려 미사용' + : '' + ].filter(Boolean); + return parts.join(' · ') || undefined; + } + private resetThirdCampPreparationRuntime() { this.thirdCampPreparationRuntime = { informationConsumed: false, @@ -20356,6 +20993,8 @@ export class BattleScene extends Phaser.Scene { thirdCampPreparation: this.thirdCampPreparationSaveState(), cityEquipmentContribution: this.cityEquipmentContributionSaveState(), + xuzhouStayBattlePayoff: + this.xuzhouStayBattlePayoffSaveState(), savedAt: new Date().toISOString(), turnNumber: this.turnNumber, activeFaction: this.activeFaction, @@ -20510,6 +21149,11 @@ export class BattleScene extends Phaser.Scene { battleId: battleScenario.id }); const campaign = getCampaignState(); + this.xuzhouStayBattlePayoff = + resolveXuzhouStayBattlePayoff( + campaign, + battleScenario.id + ); this.cityEquipmentPreparation = resolveCityEquipmentPreparation( campaign.cityEquipmentPreparation, { @@ -20521,6 +21165,9 @@ export class BattleScene extends Phaser.Scene { this.restoreCityEquipmentContributionRuntime( state.cityEquipmentContribution ); + this.restoreXuzhouStayBattlePayoffRuntime( + state.xuzhouStayBattlePayoff + ); this.restoreThirdCampPreparationRuntime( state.thirdCampPreparation ); @@ -21555,7 +22202,14 @@ export class BattleScene extends Phaser.Scene { private supportResultImportance(result: SupportResult): CombatPresentationImportance { return { - signature: Boolean(result.usable.signatureOwnerId && result.usable.signatureOwnerId === result.user.id), + signature: Boolean( + ( + result.usable.signatureOwnerId && + result.usable.signatureOwnerId === result.user.id + ) || + result.cityStayBonusHealing > 0 || + result.cityStayBonusBuffTurns > 0 + ), growth: result.characterGrowth.leveled || result.equipmentGrowth.leveled }; } @@ -22637,7 +23291,24 @@ export class BattleScene extends Phaser.Scene { const badgeY = 64; this.addCombatOutcomeChip(container, 86, badgeY, this.usableIcon(result.usable), this.usableEffectLabel(result.usable), tone, 78); this.addCombatOutcomeChip(container, 170, badgeY, 'success', '적용', 0x59d18c, 62); - this.addCombatOutcomeChip(container, 238, badgeY, this.usableTargetIcon(result.usable), `${this.usableTargetLabel(result.usable)} ${result.usable.range}칸`, palette.blue, 92); + if ( + result.cityStayBonusHealing > 0 || + result.cityStayBonusBuffTurns > 0 + ) { + this.addCombatOutcomeChip( + container, + 238, + badgeY, + 'leadership', + result.cityStayBonusHealing > 0 + ? `공동 +${result.cityStayBonusHealing}` + : `위임 +${result.cityStayBonusBuffTurns}턴`, + palette.gold, + 112 + ); + } else { + this.addCombatOutcomeChip(container, 238, badgeY, this.usableTargetIcon(result.usable), `${this.usableTargetLabel(result.usable)} ${result.usable.range}칸`, palette.blue, 92); + } this.tweens.add({ targets: container, alpha: 1, scale: 1, y: y - 4, duration: 160, ease: 'Sine.easeOut' }); return container; @@ -22731,8 +23402,8 @@ export class BattleScene extends Phaser.Scene { const resultLabel = result.usable.effect === 'heal' - ? `회복 +${result.healAmount}${result.sortieHealBonus > 0 ? ` · 편성 +${result.sortieHealBonus}` : ''}` - : `강화 ${result.buff?.turns ?? result.usable.duration ?? 1}턴`; + ? `회복 +${result.healAmount}${result.sortieHealBonus > 0 ? ` · 편성 +${result.sortieHealBonus}` : ''}${result.cityStayBonusHealing > 0 ? ` · 공동 +${result.cityStayBonusHealing}` : ''}` + : `강화 ${result.buff?.turns ?? result.usable.duration ?? 1}턴${result.cityStayBonusBuffTurns > 0 ? ' · 위임 +1턴' : ''}`; const resultText = this.trackCombatObject(this.add.text(x + width - 14, y + 8, resultLabel, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '15px', @@ -22744,7 +23415,24 @@ export class BattleScene extends Phaser.Scene { this.renderCombatRibbonBadge(x + 46, y + 38, this.usableIcon(result.usable), this.usableEffectLabel(result.usable), depth + 1, 68); this.renderCombatRibbonBadge(x + 120, y + 38, 'success', '적용', depth + 1, 58); - this.renderCombatRibbonBadge(x + 184, y + 38, this.usableTargetIcon(result.usable), `${this.usableTargetLabel(result.usable)} ${result.usable.range}칸`, depth + 1, 78); + this.renderCombatRibbonBadge( + x + 184, + y + 38, + result.cityStayBonusHealing > 0 || + result.cityStayBonusBuffTurns > 0 + ? 'leadership' + : this.usableTargetIcon(result.usable), + result.cityStayBonusHealing > 0 + ? `공동 +${result.cityStayBonusHealing}` + : result.cityStayBonusBuffTurns > 0 + ? `위임 +${result.cityStayBonusBuffTurns}턴` + : `${this.usableTargetLabel(result.usable)} ${result.usable.range}칸`, + depth + 1, + result.cityStayBonusHealing > 0 || + result.cityStayBonusBuffTurns > 0 + ? 92 + : 78 + ); } private renderSupportActionRibbon(result: SupportResult, x: number, y: number, width: number, depth: number) { @@ -24466,17 +25154,31 @@ export class BattleScene extends Phaser.Scene { } private showSortieSupportCombatEffect(result: SupportResult, x: number, y: number, depth: number) { - if (result.sortieHealBonus <= 0) { + const cityStayLabel = + result.cityStayBonusHealing > 0 + ? `서주 공동 구휼 +${result.cityStayBonusHealing}` + : result.cityStayBonusBuffTurns > 0 + ? `서주 장부 위임 +${result.cityStayBonusBuffTurns}턴` + : undefined; + if (result.sortieHealBonus <= 0 && !cityStayLabel) { return; } + const tone = cityStayLabel ? palette.gold : 0x83d6ff; const outer = this.trackCombatObject(this.add.circle(x, y, 44)); - outer.setStrokeStyle(4, 0x83d6ff, 0.9); + outer.setStrokeStyle(4, tone, 0.9); outer.setDepth(depth); - const inner = this.trackCombatObject(this.add.circle(x, y, 25, 0x83d6ff, 0.18)); + const inner = this.trackCombatObject(this.add.circle(x, y, 25, tone, 0.18)); inner.setDepth(depth - 1); - const spark = this.trackCombatObject(this.add.star(x, y, 6, 7, 22, 0xd9f1ff, 0.84)); + const spark = this.trackCombatObject(this.add.star(x, y, 6, 7, 22, cityStayLabel ? 0xffefb0 : 0xd9f1ff, 0.84)); spark.setDepth(depth + 1); - void this.showSortieCombatCue('support', `후원 회복 +${result.sortieHealBonus}`, x, y, depth + 2); + void this.showSortieCombatCue( + 'support', + cityStayLabel ?? + `후원 회복 +${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' }); @@ -26746,23 +27448,35 @@ export class BattleScene extends Phaser.Scene { [ `회복 +${result.healAmount}`, `HP ${result.previousTargetHp}→${result.target.hp}`, - result.sortieHealBonus > 0 ? `후원 편성 +${result.sortieHealBonus}` : '' + result.sortieHealBonus > 0 ? `후원 편성 +${result.sortieHealBonus}` : '', + result.cityStayBonusHealing > 0 + ? `서주 공동 구휼 +${result.cityStayBonusHealing}` + : '' ], '#a8ffd0', '#071623', 18, - result.sortieHealBonus > 0 ? 36 : 28 + result.sortieHealBonus > 0 || + result.cityStayBonusHealing > 0 + ? 42 + : 28 ); return; } this.showMapResultPopup( result.target, - [`강화 ${result.buff?.turns ?? result.usable.duration ?? 1}턴`, this.supportBuffCompactText(result.buff)], + [ + `강화 ${result.buff?.turns ?? result.usable.duration ?? 1}턴`, + this.supportBuffCompactText(result.buff), + result.cityStayBonusBuffTurns > 0 + ? `서주 장부 위임 +${result.cityStayBonusBuffTurns}턴` + : '' + ], '#ffdf7b', '#071623', 18, - 28 + result.cityStayBonusBuffTurns > 0 ? 38 : 28 ); } @@ -27279,7 +27993,7 @@ export class BattleScene extends Phaser.Scene { return { icon: 'success', tone: 0x9fb0bf }; } - private sideHeaderBottom() { + private sideHeaderBaseBottom() { const fallbackBottom = this.layout.panelY + this.battleUiLength(144); const objectiveBottom = this.objectiveTrackerText?.visible ? this.objectiveTrackerText.y + this.objectiveTrackerText.height @@ -27293,6 +28007,65 @@ export class BattleScene extends Phaser.Scene { return Math.max(fallbackBottom, objectiveBottom, objectiveSubBottom, sortieOrderBottom); } + private preparationChipTop(row = 0) { + return ( + this.sideHeaderBaseBottom() + + this.battleUiLength(3 + row * 34) + ); + } + + private preparationStatusChipPlacement( + row = 0, + split?: 'left' | 'right' + ) { + const { panelX, panelWidth } = this.layout; + const totalWidth = Math.min( + panelWidth - this.battleUiLength(48), + this.battleUiLength(310) + ); + const fullLeft = + panelX + + panelWidth - + totalWidth - + this.battleUiLength(24); + if (!split) { + return { + left: fullLeft, + width: totalWidth, + top: this.preparationChipTop(row) + }; + } + const gap = this.battleUiLength(4); + const width = (totalWidth - gap) / 2; + return { + left: + fullLeft + + (split === 'right' ? width + gap : 0), + width, + top: this.preparationChipTop(row) + }; + } + + private sideHeaderBottom() { + const preparationChipBottom = [ + this.tacticalInitiativeChipBounds, + this.thirdCampPreparationChipBounds, + this.cityEquipmentPreparationChipBounds, + this.xuzhouStayBattlePayoffChipBounds + ].reduce( + (bottom, bounds) => + Math.max( + bottom, + bounds ? bounds.y + bounds.height : 0 + ), + 0 + ); + return Math.max( + this.sideHeaderBaseBottom(), + preparationChipBottom + ); + } + private sideQuickTabTop() { return this.sideHeaderBottom() + this.battleUiLength(3); } @@ -30310,6 +31083,7 @@ export class BattleScene extends Phaser.Scene { }, result: null, resultText: null, + compactResultText: null, mechanicsProbe: null, chip: { visible: false, @@ -30337,6 +31111,8 @@ export class BattleScene extends Phaser.Scene { result: contribution ? { ...contribution } : null, resultText: this.cityEquipmentContributionResultText() ?? null, + compactResultText: + this.cityEquipmentContributionCompactResultText() ?? null, mechanicsProbe: this.cityEquipmentPreparationMechanicsProbe(), chip: { @@ -30353,6 +31129,102 @@ export class BattleScene extends Phaser.Scene { }; } + private xuzhouStayBattlePayoffDebugState() { + const memory = this.xuzhouStayBattlePayoff; + const runtime = this.xuzhouStayBattlePayoffRuntime; + const miZhu = battleUnits.find( + (unit) => + unit.id === xuzhouStayMiZhuUnitId && + unit.faction === 'ally' + ); + const aidTarget = battleUnits.find( + (unit) => + unit.faction === 'ally' && + unit.id !== xuzhouStayMiZhuUnitId && + unit.hp > 0 && + unit.hp < unit.maxHp + ); + const aid = usableCatalog.aid; + const encourage = usableCatalog.encourage; + const aidPrepared = + miZhu && aidTarget && aid + ? this.supportHealAmount(miZhu, aidTarget, aid) + : null; + const aidBaseline = + miZhu && aidTarget && aid + ? this.supportHealAmount( + miZhu, + aidTarget, + aid, + false, + true + ) + : null; + return { + active: Boolean(memory), + sourceBattleId: + memory?.sourceBattleId ?? null, + targetBattleId: + memory?.targetBattleId ?? null, + informationCompleted: + memory?.informationCompleted ?? false, + trackedEnemyUnitIds: + memory?.informationEffect + ? [...memory.informationEffect.enemyUnitIds] + : [], + choiceId: memory?.choiceId ?? null, + effect: memory?.resonanceEffect + ? { ...memory.resonanceEffect } + : null, + activeThroughTurn: + xuzhouStayBattlePayoffActiveThroughTurn, + effectActive: + Boolean( + memory?.choiceId && + this.xuzhouStayMiZhuAvailable() && + runtime?.supportUses === 0 && + isXuzhouStayBattlePayoffActiveTurn(this.turnNumber) + ), + runtime: runtime ? { ...runtime } : null, + result: this.xuzhouStayBattlePayoffSnapshot() ?? null, + resultText: + this.xuzhouStayBattlePayoffResultText() ?? null, + compactResultText: + this.xuzhouStayBattlePayoffCompactResultText() ?? null, + mechanicsProbe: { + aid: + aidPrepared !== null && aidBaseline !== null + ? { + prepared: aidPrepared, + baseline: aidBaseline, + bonus: Math.max(0, aidPrepared - aidBaseline) + } + : null, + encourage: miZhu && encourage + ? { + prepared: + (encourage.duration ?? 1) + + this.xuzhouStayBonusBuffTurns( + miZhu, + encourage + ), + baseline: encourage.duration ?? 1, + bonus: this.xuzhouStayBonusBuffTurns( + miZhu, + encourage + ) + } + : null + }, + chip: { + visible: Boolean(this.xuzhouStayBattlePayoffChipBounds), + bounds: this.xuzhouStayBattlePayoffChipBounds + ? { ...this.xuzhouStayBattlePayoffChipBounds } + : null + } + }; + } + private thirdCampPreparationMemoryDebugState() { const memory = this.thirdCampPreparationMemory; if (!memory) { @@ -30762,6 +31634,8 @@ export class BattleScene extends Phaser.Scene { this.thirdCampPreparationMemoryDebugState(), cityEquipmentPreparation: this.cityEquipmentPreparationDebugState(), + xuzhouStayBattlePayoff: + this.xuzhouStayBattlePayoffDebugState(), firstBattleNarrativeMemory: { volunteerPromise: this.firstBattleVolunteerPromiseDebugState() }, @@ -31177,6 +32051,135 @@ export class BattleScene extends Phaser.Scene { return this.debugToolsEnabled() ? battleUnits : undefined; } + debugResolveXuzhouStaySupport( + usableId: 'aid' | 'encourage', + targetId = 'liu-bei' + ) { + if ( + !this.debugToolsEnabled() || + this.phase === 'deployment' || + this.phase === 'resolved' || + this.battleOutcome + ) { + return undefined; + } + const user = battleUnits.find( + (unit) => + unit.id === xuzhouStayMiZhuUnitId && + unit.faction === 'ally' && + unit.hp > 0 + ); + const target = battleUnits.find( + (unit) => + unit.id === targetId && + unit.faction === 'ally' && + unit.hp > 0 + ); + const usable = usableCatalog[usableId]; + if (!user || !target || !usable) { + return undefined; + } + if (usable.effect === 'heal') { + target.hp = Math.max(1, target.maxHp - 30); + } + const result = this.resolveSupportAction( + user, + target, + usable + ); + return { + usableId, + targetId, + healAmount: result.healAmount, + buffTurns: result.buff?.turns ?? 0, + cityStayBonusHealing: result.cityStayBonusHealing, + cityStayBonusBuffTurns: result.cityStayBonusBuffTurns, + runtime: this.xuzhouStayBattlePayoffSnapshot() ?? null + }; + } + + debugRecordXuzhouStayCounterplay(enemyUnitId: string) { + if (!this.debugToolsEnabled()) { + return undefined; + } + this.recordXuzhouStayInformationCounterplays([ + enemyUnitId + ]); + return this.xuzhouStayBattlePayoffSnapshot() ?? null; + } + + debugRecordXuzhouStayGuardCounterplay( + enemyUnitId: string, + defenderUnitId = 'liu-bei', + preventedDamage = 2 + ) { + if (!this.debugToolsEnabled()) { + return undefined; + } + const attacker = battleUnits.find( + (unit) => + unit.id === enemyUnitId && + unit.faction === 'enemy' && + unit.hp > 0 + ); + const defender = battleUnits.find( + (unit) => + unit.id === defenderUnitId && + unit.faction === 'ally' && + unit.hp > 0 + ); + if (!attacker || !defender) { + return undefined; + } + const initiativeReadied = + this.recordIntentGuardCounterplay( + attacker, + defender, + false, + Math.max(1, Math.floor(preventedDamage)) + ); + return { + initiativeReadied, + runtime: this.xuzhouStayBattlePayoffSnapshot() ?? null + }; + } + + debugSetXuzhouStayMiZhuHp(hitPoints: number) { + if (!this.debugToolsEnabled()) { + return undefined; + } + const miZhu = battleUnits.find( + (unit) => + unit.id === xuzhouStayMiZhuUnitId && + unit.faction === 'ally' + ); + if (!miZhu) { + return undefined; + } + miZhu.hp = Phaser.Math.Clamp( + Math.floor(hitPoints), + 0, + miZhu.maxHp + ); + if (miZhu.hp <= 0) { + this.applyDefeatedStyle(miZhu); + } else { + this.syncUnitMotion(miZhu); + } + this.updateMiniMap(); + this.renderTacticalInitiativeChip(); + return this.xuzhouStayBattlePayoffDebugState(); + } + + debugSetBattleTurn(turnNumber: number) { + if (!this.debugToolsEnabled()) { + return false; + } + this.turnNumber = Math.max(1, Math.floor(turnNumber)); + this.renderTacticalInitiativeChip(); + return true; + } + debugBattleSimulationState() { if (!this.debugToolsEnabled()) { return undefined; diff --git a/src/game/scenes/CityStayScene.ts b/src/game/scenes/CityStayScene.ts index 3410c83..e7b866b 100644 --- a/src/game/scenes/CityStayScene.ts +++ b/src/game/scenes/CityStayScene.ts @@ -381,6 +381,7 @@ export class CityStayScene extends Phaser.Scene { id: choice.id, label: choice.label, rewardExp: this.cityStay.dialogue.rewardExp + choice.rewardExp, + battleEffect: choice.battleEffect ?? null, interactive: Boolean(view?.interactive), bounds: view ? this.boundsSnapshot(view.button.getBounds()) : null }; @@ -1289,7 +1290,7 @@ export class CityStayScene extends Phaser.Scene { color: '#f1d691', fontStyle: 'bold' }); - const hint = this.add.text(150, 697, '유비의 답을 선택하세요. 선택은 저장되며 공명 경험치가 달라집니다.', { + const hint = this.add.text(150, 697, '유비의 답을 선택하세요. 선택은 저장되며 공명과 다음 전투의 지원 방식이 달라집니다.', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '19px', color: '#aeb8c4' @@ -1318,7 +1319,7 @@ export class CityStayScene extends Phaser.Scene { }); const reward = this.add.text( x + 76, - y + 98, + y + 126, `공명 +${dialogue.rewardExp + choice.rewardExp}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', @@ -1327,10 +1328,21 @@ export class CityStayScene extends Phaser.Scene { fontStyle: 'bold' } ); + const battleEffect = this.add.text( + x + 76, + y + 82, + choice.battleEffect ?? '선택한 방침은 이후 대화와 공명에 반영됩니다.', + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#9fd7ef', + wordWrap: { width: 670, useAdvancedWrap: true } + } + ); button.on('pointerover', () => button.setFillStyle(0x2a3c4e, 0.99)); button.on('pointerout', () => button.setFillStyle(0x1d2936, 0.98)); button.on('pointerdown', () => this.chooseDialogue(choice)); - objects.push(button, number, label, reward); + objects.push(button, number, label, battleEffect, reward); return { choiceId: choice.id, button, interactive: true }; }); const cancel = this.add.text(1740, 940, 'ESC · 나중에 결정', { diff --git a/src/game/state/battleSaveState.ts b/src/game/state/battleSaveState.ts index 2be15c6..9c9717b 100644 --- a/src/game/state/battleSaveState.ts +++ b/src/game/state/battleSaveState.ts @@ -21,6 +21,10 @@ import { normalizeCityEquipmentContributionSnapshot, type CityEquipmentContributionSnapshot } from '../data/cityEquipmentPreparation'; +import { + normalizeXuzhouStayBattlePayoffRuntime, + type XuzhouStayBattlePayoffRuntime +} from '../data/xuzhouStayBattlePayoff'; export type BattleSaveFaction = 'ally' | 'enemy'; export type BattleSaveRosterTab = 'ally' | 'enemy'; @@ -118,6 +122,8 @@ export type BattleSaveThirdCampPreparationState = { export type BattleSaveCityEquipmentContributionState = CityEquipmentContributionSnapshot; +export type BattleSaveXuzhouStayBattlePayoffState = + XuzhouStayBattlePayoffRuntime; export type BattleSaveEventPriority = 'critical' | 'high' | 'normal' | 'low'; @@ -141,6 +147,7 @@ export type BattleSaveState = { cooperationStatsByBond?: Record; thirdCampPreparation?: BattleSaveThirdCampPreparationState; cityEquipmentContribution?: BattleSaveCityEquipmentContributionState; + xuzhouStayBattlePayoff?: BattleSaveXuzhouStayBattlePayoffState; savedAt: string; turnNumber: number; activeFaction: BattleSaveFaction; @@ -239,6 +246,7 @@ export function normalizeBattleSaveState( normalizeCooperationSaveFields(cloned, options); normalizeThirdCampPreparationSaveField(cloned); normalizeCityEquipmentContributionSaveField(cloned, options); + normalizeXuzhouStayBattlePayoffSaveField(cloned, options); const recommendation = normalizeCampaignSortieRecommendationSnapshot(cloned.sortieRecommendation, { expectedBattleId: typeof cloned.battleId === 'string' ? cloned.battleId : options.expectedBattleId, allowedUnitIds: options.validAllyUnitIds @@ -296,6 +304,10 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida return false; } + if (!isOptionalXuzhouStayBattlePayoffState(state.xuzhouStayBattlePayoff, state, options)) { + return false; + } + const attackIntents = state.attackIntents; const units = state.units; @@ -803,6 +815,100 @@ function canonicalCityEquipmentContributionSaveState( }; } +function normalizeXuzhouStayBattlePayoffSaveField( + state: Record, + options: BattleSaveValidationOptions +) { + const normalized = canonicalXuzhouStayBattlePayoffSaveState( + state.xuzhouStayBattlePayoff, + state, + options + ); + if (normalized) { + state.xuzhouStayBattlePayoff = normalized; + } else { + delete state.xuzhouStayBattlePayoff; + } +} + +function isOptionalXuzhouStayBattlePayoffState( + value: unknown, + state: Record, + options: BattleSaveValidationOptions +) { + if (value === undefined) { + return true; + } + const normalized = canonicalXuzhouStayBattlePayoffSaveState( + value, + state, + options + ); + if (!normalized || !isRecord(value)) { + return false; + } + const fields: Array< + keyof BattleSaveXuzhouStayBattlePayoffState + > = [ + 'version', + 'recordId', + 'cityStayId', + 'sourceBattleId', + 'targetBattleId', + 'informationVisitId', + 'dialogueId', + 'informationCompleted', + 'informationCounterplays', + 'miZhuDeployed', + 'supportAttempts', + 'supportUses', + 'bonusHealing', + 'bonusBuffTurns' + ]; + if (normalized.choiceId) { + fields.push('choiceId'); + } + return ( + Object.keys(value).length === fields.length && + fields.every((field) => value[field] === normalized[field]) + ); +} + +function canonicalXuzhouStayBattlePayoffSaveState( + value: unknown, + state: Record, + options: BattleSaveValidationOptions +): BattleSaveXuzhouStayBattlePayoffState | undefined { + const battleId = + typeof state.battleId === 'string' ? state.battleId : undefined; + if (!battleId || !Array.isArray(state.units)) { + return undefined; + } + const deployedUnitIds = state.units + .filter(isRecord) + .map((unit) => unit.id) + .filter( + (unitId): unitId is string => + typeof unitId === 'string' && + (!options.validAllyUnitIds || + options.validAllyUnitIds.has(unitId)) + ); + const normalized = normalizeXuzhouStayBattlePayoffRuntime( + value, + { + expectedBattleId: battleId, + expectedRosterUnitIds: deployedUnitIds + } + ); + if ( + !normalized || + (!normalized.informationCompleted && !normalized.choiceId) + ) { + return undefined; + } + return normalized; +} + function isOptionalCoreResonanceState( state: Record, options: BattleSaveValidationOptions diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 9c4efcf..a78f187 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -13,6 +13,12 @@ import { type CityEquipmentPreparationSnapshot, type CityEquipmentPurchaseCounts } from '../data/cityEquipmentPreparation'; +import { + cloneXuzhouStayBattlePayoffSnapshot, + normalizeXuzhouStayBattlePayoffSnapshot, + resolveXuzhouStayBattlePayoff, + type XuzhouStayBattlePayoffSnapshot +} from '../data/xuzhouStayBattlePayoff'; import { findCityStayAfterBattle, type CityStayId } from '../data/cityStays'; import { isThirdCampPreparationPriorityId, @@ -190,6 +196,7 @@ export type FirstBattleReport = { sortieRecommendation?: CampaignSortieRecommendationSnapshot; sortieCooperation?: CampaignSortieCooperationSnapshot; cityEquipmentContribution?: CityEquipmentContributionSnapshot; + xuzhouStayBattlePayoff?: XuzhouStayBattlePayoffSnapshot; completedCampDialogues: string[]; completedCampVisits: string[]; createdAt: string; @@ -475,6 +482,7 @@ export type CampaignBattleSettlement = { sortieRecommendation?: CampaignSortieRecommendationSnapshot; sortieCooperation?: CampaignSortieCooperationSnapshot; cityEquipmentContribution?: CityEquipmentContributionSnapshot; + xuzhouStayBattlePayoff?: XuzhouStayBattlePayoffSnapshot; completedAt: string; }; @@ -1027,6 +1035,24 @@ export function setFirstBattleReport(report: FirstBattleReport) { } else { delete reportClone.cityEquipmentContribution; } + const xuzhouStayBattlePayoff = + normalizeXuzhouStayBattlePayoffSnapshot( + reportClone.xuzhouStayBattlePayoff, + { + expectedBattleId: battleId, + expectedRosterUnitIds: reportClone.units + .filter((unit) => unit.faction === 'ally') + .map((unit) => unit.id), + expectedMemory: + resolveXuzhouStayBattlePayoff(state, battleId) ?? null + } + ); + if (xuzhouStayBattlePayoff) { + reportClone.xuzhouStayBattlePayoff = + xuzhouStayBattlePayoff; + } else { + delete reportClone.xuzhouStayBattlePayoff; + } const previousSettlement = state.battleHistory[battleId]; const completedCampDialogues = mergeCampCompletionIds( state.completedCampDialogues, @@ -1899,6 +1925,7 @@ function normalizeCampaignState(state: Partial): CampaignState { normalizeStoredCityEquipmentContributionsForContainers( normalized ); + normalizeStoredXuzhouStayBattlePayoffsForContainers(normalized); const recordedVictoryBattleIds = new Set( Object.entries(normalized.battleHistory) .filter(([, settlement]) => settlement.outcome === 'victory') @@ -2628,6 +2655,44 @@ function normalizeStoredCityEquipmentContributionsForContainers( }); } +function normalizeStoredXuzhouStayBattlePayoffsForContainers( + state: CampaignState +) { + if (state.firstBattleReport) { + const payoff = normalizeXuzhouStayBattlePayoffSnapshot( + state.firstBattleReport.xuzhouStayBattlePayoff, + { + expectedBattleId: state.firstBattleReport.battleId, + expectedRosterUnitIds: state.firstBattleReport.units + .filter((unit) => unit.faction === 'ally') + .map((unit) => unit.id) + } + ); + if (payoff) { + state.firstBattleReport.xuzhouStayBattlePayoff = payoff; + } else { + delete state.firstBattleReport.xuzhouStayBattlePayoff; + } + } + + Object.values(state.battleHistory).forEach((settlement) => { + const payoff = normalizeXuzhouStayBattlePayoffSnapshot( + settlement.xuzhouStayBattlePayoff, + { + expectedBattleId: settlement.battleId, + expectedRosterUnitIds: settlement.units.map( + (unit) => unit.unitId + ) + } + ); + if (payoff) { + settlement.xuzhouStayBattlePayoff = payoff; + } else { + delete settlement.xuzhouStayBattlePayoff; + } + }); +} + function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rosterUnitIds: Set): FirstBattleReport { const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(report.sortiePerformance, rosterUnitIds); const sortiePerformanceUnchanged = sortiePerformanceSnapshotsShareRoster(report.sortiePerformance, sortiePerformance); @@ -2793,6 +2858,14 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle battleId, campaignSettlementContributionRoster(units) ); + const xuzhouStayBattlePayoff = + normalizeXuzhouStayBattlePayoffSnapshot( + settlement.xuzhouStayBattlePayoff, + { + expectedBattleId: battleId, + expectedRosterUnitIds: units.map((unit) => unit.unitId) + } + ); return { battleId, @@ -2814,6 +2887,9 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle ...(cityEquipmentContribution ? { cityEquipmentContribution } : {}), + ...(xuzhouStayBattlePayoff + ? { xuzhouStayBattlePayoff } + : {}), completedAt }; } @@ -3171,6 +3247,16 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi battleId, units ); + const xuzhouStayBattlePayoff = + normalizeXuzhouStayBattlePayoffSnapshot( + report.xuzhouStayBattlePayoff, + { + expectedBattleId: battleId, + expectedRosterUnitIds: units + .filter((unit) => unit.faction === 'ally') + .map((unit) => unit.id) + } + ); return { battleId, @@ -3197,6 +3283,9 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi ...(cityEquipmentContribution ? { cityEquipmentContribution } : {}), + ...(xuzhouStayBattlePayoff + ? { xuzhouStayBattlePayoff } + : {}), completedCampDialogues: uniqueCampHistoryIds(report.completedCampDialogues), completedCampVisits: uniqueCampHistoryIds(report.completedCampVisits), createdAt: normalizeCampaignTimestamp(report.createdAt) ?? new Date().toISOString() @@ -3472,6 +3561,14 @@ function createBattleSettlement(report: FirstBattleReport, reserveTraining: Camp ) } : {}), + ...(report.xuzhouStayBattlePayoff + ? { + xuzhouStayBattlePayoff: + cloneXuzhouStayBattlePayoffSnapshot( + report.xuzhouStayBattlePayoff + ) + } + : {}), completedAt: report.createdAt }; } @@ -3641,6 +3738,12 @@ function cloneReport(report: FirstBattleReport): FirstBattleReport { cloned.cityEquipmentContribution ); } + if (cloned.xuzhouStayBattlePayoff) { + cloned.xuzhouStayBattlePayoff = + cloneXuzhouStayBattlePayoffSnapshot( + cloned.xuzhouStayBattlePayoff + ); + } return cloned; }