diff --git a/docs/game-design-improvement-goal.md b/docs/game-design-improvement-goal.md index 0ade48f..cee88ec 100644 --- a/docs/game-design-improvement-goal.md +++ b/docs/game-design-improvement-goal.md @@ -206,9 +206,38 @@ RPG로 다듬는다. - 전투 장면을 연속으로 재사용해도 이전 전투의 턴과 진영이 남지 않도록 매 전투를 `1턴 · 아군`에서 초기화한다. -다음 묶음은 서주 체류에서 장비를 구매한 뒤 실제 무장에게 장착하고, 그 -선택을 여덟 번째 전투의 출진 전 비교·전투 수치·결과 피드백까지 같은 -장비 기록으로 이어지게 한다. +### 완료한 10차 묶음: 서주 구입 장비의 장착과 여덟 번째 전투 회수 + +- 서주 시장의 무기·방어구·보조구를 서로 다른 아이콘으로 보여 주고, + 보유량과 해당 도시의 실제 구매 횟수를 함께 남긴다. +- 구입 뒤 `장착 비교`로 군영의 정확한 장비와 권장 무장에게 이어지되 + 자동 장착하지 않는다. 비교 취소는 장비·재고·준비 기록을 바꾸지 않는다. +- 시장에서 군영으로 넘어가는 장착 의도는 저장 슬롯에 남아 새로고침해도 + 복원되며, 대상 무장을 바꾸면 그 선택도 보존한다. 준비 확정 또는 해당 + 군영 종료 뒤에는 임시 의도를 제거한다. +- 현재 장비보다 전투 수치가 좋아지지 않는 후보는 준비 장비로 잘못 + 기록하지 않는다. 아직 전투 효과가 없는 군량 주머니는 이를 숨기지 않고 + `보유 확인`과 `추가 전투 기여 없음`으로 구분한다. +- 장비의 기존 능력치는 전투 계산에 이미 적용되므로 별도 강화 효과를 + 중복 가산하지 않는다. 같은 공격을 이전 장비로 계산한 기준값과 비교해 + 실제로 늘어난 피해나 줄어든 피해만 기여도로 집계한다. +- 출전 편성, 여덟 번째 전투 배치, 전투 HUD와 결과 보고에서 같은 도시, + 구매 기록, 무장, 장비와 교체 수치를 사용한다. 준비 무장을 빼면 + `미반영`으로 표시하고 자동으로 다시 편성하지 않는다. +- 실제 기여 횟수와 추가 피해·감소 피해를 전투 저장, 최신 보고서와 해당 + 전투 정산에 보존한다. 이후 장비를 바꿔도 과거의 정당한 기록은 지우지 + 않되, 위조된 장비·유령 무장 참조는 각 전투 당시 명단으로 걸러 낸다. +- 장비 교체 저장이 실패하면 재고와 장착 상태를 함께 되돌려 반쪽 상태가 + 남지 않게 한다. +- 전투 HUD와 결과가 강조하는 도시 장비는 `이번 출전의 대표 준비 1건`이다. + 여러 장비를 장착하면 모든 능력치는 정상 적용되지만, 시장에서 군영으로 + 마지막 연결한 준비만 성과로 강조해 결과가 장비 목록처럼 번잡해지지 + 않도록 한다. + +다음 묶음은 서주 관청에서 얻은 정보와 미축과 나눈 공명 선택을 여덟 번째 +전투의 적 의도·보급 판단·동료 행동과 결과 회고로 각각 구분해 회수한다. +장비와 마찬가지로 이미 한 준비를 중복 강화하지 않고, 플레이어가 실제로 +고른 한 가지 관점이 전투 중 눈에 보이는 판단 차이를 만들게 한다. ## 장기 개선 순서 diff --git a/package.json b/package.json index cea4a80..706a294 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,9 @@ "verify:sortie-roster-history": "node scripts/verify-sortie-roster-history.mjs", "verify:camp-rewards": "node scripts/verify-camp-reward-data.mjs", "verify:city-stays": "node scripts/verify-city-stay-data.mjs", + "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: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", @@ -77,7 +79,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: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: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 4c003e4..af835f0 100644 --- a/scripts/verify-battle-save-normalization.mjs +++ b/scripts/verify-battle-save-normalization.mjs @@ -16,6 +16,9 @@ try { thirdCampPreparationSelectionRecordId, thirdCampPreparationTargetBattleId } = await server.ssrLoadModule('/src/game/data/thirdCampPreparation.ts'); + const { + cityEquipmentPreparationSelectionRecordId + } = await server.ssrLoadModule('/src/game/data/cityEquipmentPreparation.ts'); const options = { expectedBattleId: 'first-battle-zhuo-commandery', @@ -406,6 +409,227 @@ try { parsedLegacyFourthBattleState?.thirdCampPreparation === undefined, 'Expected legacy fourth-battle saves without preparation progress to remain valid.' ); + + const eighthBattleId = 'eighth-battle-xiaopei-supply-road'; + const eighthBattleOptions = { + ...options, + expectedBattleId: eighthBattleId, + validEquipmentItemIds: new Set([ + ...options.validEquipmentItemIds, + 'iron-spear' + ]), + validEquipmentSlotItems: { + ...options.validEquipmentSlotItems, + weapon: new Set(['training-sword', 'iron-spear']) + } + }; + const eighthBattleUnits = validState.units.map((unit) => ({ + ...unit, + stats: unit.stats ? { ...unit.stats } : undefined, + equipment: { + weapon: { + ...(unit.id === 'liu-bei' + ? { itemId: 'iron-spear', level: 2, exp: 17 } + : unit.equipment.weapon) + }, + armor: { ...unit.equipment.armor }, + accessory: { ...unit.equipment.accessory } + } + })); + const validCityEquipmentContribution = { + version: 1, + selectionRecordId: cityEquipmentPreparationSelectionRecordId, + cityStayId: 'xuzhou', + sourceBattleId: 'seventh-battle-xuzhou-rescue', + targetBattleId: eighthBattleId, + offerId: 'city-xuzhou-iron-spear', + itemId: 'iron-spear', + slot: 'weapon', + price: 620, + purchaseNumber: 2, + unitId: 'liu-bei', + previousItemId: 'training-sword', + deployed: true, + offensiveActions: 3, + defensiveHits: 0, + bonusDamage: 7, + preventedDamage: 0 + }; + const eighthBattleBaseState = { + ...validState, + battleId: eighthBattleId, + campaignStep: 'eighth-battle', + units: eighthBattleUnits, + enemyUsableUseKeys: [] + }; + const validCityEquipmentState = { + ...eighthBattleBaseState, + cityEquipmentContribution: validCityEquipmentContribution + }; + const parsedCityEquipmentState = parseBattleSaveState( + JSON.stringify(validCityEquipmentState), + eighthBattleOptions + ); + assert( + isValidBattleSaveState( + validCityEquipmentState, + eighthBattleOptions + ) && + JSON.stringify( + parsedCityEquipmentState?.cityEquipmentContribution + ) === JSON.stringify(validCityEquipmentContribution) && + parsedCityEquipmentState?.cityEquipmentContribution !== + validCityEquipmentContribution, + 'Expected a deployed city-purchase contribution to round-trip as a deep-cloned battle-save field.' + ); + + const unsafeCityEquipmentState = { + ...validCityEquipmentState, + cityEquipmentContribution: { + ...validCityEquipmentContribution, + offensiveActions: 2.9, + defensiveHits: -4, + bonusDamage: Number.MAX_SAFE_INTEGER, + preventedDamage: 18 + } + }; + const normalizedUnsafeCityEquipment = parseBattleSaveState( + JSON.stringify(unsafeCityEquipmentState), + eighthBattleOptions + )?.cityEquipmentContribution; + assert( + normalizedUnsafeCityEquipment?.offensiveActions === 2 && + normalizedUnsafeCityEquipment.defensiveHits === 0 && + normalizedUnsafeCityEquipment.bonusDamage === 999999 && + normalizedUnsafeCityEquipment.preventedDamage === 0 && + !isValidBattleSaveState( + unsafeCityEquipmentState, + eighthBattleOptions + ), + `Expected unsafe city-equipment counters to normalize safely and fail strict direct validation: ${JSON.stringify(normalizedUnsafeCityEquipment)}` + ); + + const noRecordedUseCityEquipment = parseBattleSaveState( + JSON.stringify({ + ...validCityEquipmentState, + cityEquipmentContribution: { + ...validCityEquipmentContribution, + offensiveActions: 0, + bonusDamage: 77, + defensiveHits: 0, + preventedDamage: 88 + } + }), + eighthBattleOptions + )?.cityEquipmentContribution; + assert( + noRecordedUseCityEquipment?.bonusDamage === 0 && + noRecordedUseCityEquipment.preventedDamage === 0, + 'Expected contribution damage totals to clear when no matching actions or hits were recorded.' + ); + + const wrongBattleCityEquipment = { + ...validState, + cityEquipmentContribution: validCityEquipmentContribution + }; + const normalizedWrongBattleCityEquipment = + normalizeBattleSaveState( + wrongBattleCityEquipment, + options + ); + assert( + normalizedWrongBattleCityEquipment?.cityEquipmentContribution === + undefined && + !isValidBattleSaveState( + wrongBattleCityEquipment, + options + ), + 'Expected city-equipment progress attached to another battle to be discarded and rejected by direct validation.' + ); + + const mismatchedEquipmentCityState = { + ...validCityEquipmentState, + units: validState.units.map((unit) => ({ + ...unit, + equipment: { + weapon: { ...unit.equipment.weapon }, + armor: { ...unit.equipment.armor }, + accessory: { ...unit.equipment.accessory } + } + })) + }; + const normalizedMismatchedEquipment = normalizeBattleSaveState( + mismatchedEquipmentCityState, + eighthBattleOptions + ); + assert( + normalizedMismatchedEquipment?.cityEquipmentContribution === + undefined && + !isValidBattleSaveState( + mismatchedEquipmentCityState, + eighthBattleOptions + ), + 'Expected a save whose recorded unit no longer equips the purchased item to discard and reject the contribution.' + ); + + const corruptedCityEquipmentCases = [ + { + label: 'forged record id', + contribution: { + ...validCityEquipmentContribution, + selectionRecordId: 'forged-city-equipment' + } + }, + { + label: 'wrong-slot previous item', + contribution: { + ...validCityEquipmentContribution, + previousItemId: 'cloth-armor' + } + }, + { + label: 'undeployed runtime', + contribution: { + ...validCityEquipmentContribution, + deployed: false + } + }, + { + label: 'unknown prepared unit', + contribution: { + ...validCityEquipmentContribution, + unitId: 'missing-unit' + } + } + ]; + corruptedCityEquipmentCases.forEach(({ label, contribution }) => { + const candidate = { + ...eighthBattleBaseState, + cityEquipmentContribution: contribution + }; + const normalized = normalizeBattleSaveState( + candidate, + eighthBattleOptions + ); + assert( + normalized?.cityEquipmentContribution === undefined && + !isValidBattleSaveState(candidate, eighthBattleOptions), + `Expected ${label} city-equipment progress to be discarded and rejected.` + ); + }); + + assert( + isValidBattleSaveState( + eighthBattleBaseState, + eighthBattleOptions + ) && + parseBattleSaveState( + JSON.stringify(eighthBattleBaseState), + eighthBattleOptions + )?.cityEquipmentContribution === undefined, + 'Expected legacy eighth-battle saves without city-equipment contribution progress to remain valid.' + ); + const validSortieStatsState = { ...validState, battleStats: { diff --git a/scripts/verify-city-equipment-preparation.mjs b/scripts/verify-city-equipment-preparation.mjs new file mode 100644 index 0000000..c48079a --- /dev/null +++ b/scripts/verify-city-equipment-preparation.mjs @@ -0,0 +1,910 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'vite'; + +const storage = new Map(); +let rejectStorageWrites = false; +globalThis.window = { + localStorage: { + getItem(key) { + return storage.has(key) ? storage.get(key) : null; + }, + setItem(key, value) { + if (rejectStorageWrites) { + throw new Error('Simulated campaign storage write failure.'); + } + storage.set(key, String(value)); + }, + removeItem(key) { + storage.delete(key); + }, + clear() { + storage.clear(); + } + } +}; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true, hmr: false }, + appType: 'custom' +}); + +try { + const preparationModule = await server.ssrLoadModule( + '/src/game/data/cityEquipmentPreparation.ts' + ); + const campaignModule = await server.ssrLoadModule( + '/src/game/state/campaignState.ts' + ); + const actionModule = await server.ssrLoadModule( + '/src/game/state/cityStayActions.ts' + ); + const { itemInventoryLabel } = await server.ssrLoadModule( + '/src/game/data/battleItems.ts' + ); + const { xuzhouRecruitUnits } = await server.ssrLoadModule( + '/src/game/data/scenario.ts' + ); + + verifyCanonicalOfferAndCountNormalization(preparationModule); + verifyEquipIntentNormalization({ + preparationModule, + campaignModule + }); + const purchased = verifyPurchasePersistenceAndRollback({ + actionModule, + campaignModule, + itemInventoryLabel, + xuzhouRecruitUnits + }); + const prepared = verifyPreparationResolution({ + preparationModule, + campaignModule, + purchased + }); + verifyCampaignNormalizationAndInvalidation({ + preparationModule, + campaignModule, + prepared + }); + verifyStoredContributionRosterCrossValidation({ + campaignModule, + prepared + }); + verifyContributionNormalization(preparationModule, prepared.snapshot); + verifyOldSaveCompatibility(campaignModule); + + console.log( + 'City equipment preparation verification passed (canonical Xuzhou purchase provenance, pending equip intent normalization, duplicate purchase counts, storage rollback, exact equip resolution, previous-item baseline deltas, target-battle isolation, swap invalidation, historical report/settlement contribution cross-validation, later equipment-change retention, and old-save compatibility).' + ); +} finally { + await server.close(); +} + +function verifyCanonicalOfferAndCountNormalization(preparationModule) { + const canonical = preparationModule.findCanonicalCityEquipmentOffer( + 'xuzhou', + 'city-xuzhou-iron-spear' + ); + assert(canonical, 'Expected the Xuzhou iron spear offer to be canonical.'); + assert.equal(canonical.cityStay.afterBattleId, 'seventh-battle-xuzhou-rescue'); + assert.equal(canonical.cityStay.nextBattleId, 'eighth-battle-xiaopei-supply-road'); + assert.equal(canonical.offer.itemId, 'iron-spear'); + assert.equal(canonical.offer.slot, 'weapon'); + assert.equal(canonical.offer.price, 620); + assert.equal(canonical.item.attackBonus, 3); + assert.equal( + preparationModule.cityEquipmentPreparationSelectionRecordId, + 'city-equipment-preparation' + ); + assert.equal( + preparationModule.findCanonicalCityEquipmentOffer( + 'xinye', + canonical.offer.id + ), + undefined, + 'An offer must not be portable to another city.' + ); + assert.equal( + preparationModule.findCanonicalCityEquipmentOffer('xuzhou', 'unknown'), + undefined + ); + assert.deepEqual( + preparationModule.normalizeCityEquipmentPurchaseCounts({ + [canonical.offer.id]: 500, + 'city-xuzhou-lamellar-armor': 2.9, + unknown: 8, + 'city-xuzhou-grain-pouch': -4 + }), + { + [canonical.offer.id]: 99, + 'city-xuzhou-lamellar-armor': 2 + } + ); +} + +function verifyEquipIntentNormalization({ + preparationModule, + campaignModule +}) { + const offerId = 'city-xuzhou-iron-spear'; + const purchaseCounts = { [offerId]: 2 }; + const intent = + preparationModule.createCityEquipmentEquipIntent({ + cityStayId: 'xuzhou', + offerId, + purchaseNumber: 2, + purchaseCounts + }); + assert.deepEqual(intent, { + version: 1, + intentRecordId: 'city-equipment-equip-intent', + cityStayId: 'xuzhou', + sourceBattleId: 'seventh-battle-xuzhou-rescue', + targetBattleId: 'eighth-battle-xiaopei-supply-road', + offerId, + itemId: 'iron-spear', + slot: 'weapon', + price: 620, + purchaseNumber: 2 + }); + assert.equal( + preparationModule.normalizeCityEquipmentEquipIntent( + { ...intent, price: 1 }, + purchaseCounts + ), + undefined, + 'A forged equip intent must be rejected.' + ); + assert.equal( + preparationModule.normalizeCityEquipmentEquipIntent( + intent, + { [offerId]: 1 } + ), + undefined, + 'An equip intent must be backed by its exact purchase number.' + ); + const intentWithUnit = + preparationModule.normalizeCityEquipmentEquipIntent( + { ...intent, unitId: 'mi-zhu' }, + purchaseCounts, + [ + { + id: 'mi-zhu', + name: '미축', + faction: 'ally', + equipment: {} + } + ] + ); + assert.equal(intentWithUnit.unitId, 'mi-zhu'); + assert.equal( + preparationModule.normalizeCityEquipmentEquipIntent( + { ...intent, unitId: 'ghost-unit' }, + purchaseCounts, + [ + { + id: 'mi-zhu', + name: '미축', + faction: 'ally', + equipment: {} + } + ] + ), + undefined, + 'A pending target must reference a current allied unit.' + ); + + const campaign = campaignModule.createInitialCampaignState(); + campaign.step = 'seventh-camp'; + campaign.latestBattleId = + 'seventh-battle-xuzhou-rescue'; + campaign.battleHistory[ + 'seventh-battle-xuzhou-rescue' + ] = { + battleId: 'seventh-battle-xuzhou-rescue', + battleTitle: '서주 구원전', + outcome: 'victory', + turnNumber: 18, + rewardGold: 0, + itemRewards: [], + objectives: [], + units: [], + bonds: [], + completedAt: new Date().toISOString() + }; + campaign.cityEquipmentPurchaseCounts = purchaseCounts; + campaign.cityEquipmentEquipIntent = + structuredClone(intent); + const normalized = campaignModule.setCampaignState(campaign); + assert.deepEqual( + normalized.cityEquipmentEquipIntent, + intent, + 'A canonical pending equip intent must survive campaign normalization.' + ); + const forgedCampaign = campaignModule.setCampaignState({ + ...structuredClone(campaign), + cityEquipmentEquipIntent: { + ...intent, + targetBattleId: 'ninth-battle-xuzhou-gate-night-raid' + } + }); + assert.equal( + forgedCampaign.cityEquipmentEquipIntent, + undefined, + 'Campaign normalization must discard a forged equip intent.' + ); + const advancedCampaign = campaignModule.setCampaignState({ + ...structuredClone(campaign), + step: 'eighth-battle' + }); + assert.equal( + advancedCampaign.cityEquipmentEquipIntent, + undefined, + 'A pending equip intent must expire when its source camp ends.' + ); +} + +function verifyPurchasePersistenceAndRollback({ + actionModule, + campaignModule, + itemInventoryLabel, + xuzhouRecruitUnits +}) { + storage.clear(); + rejectStorageWrites = false; + campaignModule.resetCampaignState(); + + const miZhu = structuredClone( + xuzhouRecruitUnits.find((unit) => unit.id === 'mi-zhu') + ); + assert(miZhu, 'Expected the canonical Mi Zhu recruit.'); + const initial = campaignModule.createInitialCampaignState(); + initial.step = 'seventh-camp'; + initial.latestBattleId = 'seventh-battle-xuzhou-rescue'; + initial.activeCityStayId = 'xuzhou'; + initial.gold = 5000; + initial.roster = [miZhu]; + initial.battleHistory['seventh-battle-xuzhou-rescue'] = { + battleId: 'seventh-battle-xuzhou-rescue', + battleTitle: '서주 구원전', + outcome: 'victory', + turnNumber: 18, + rewardGold: 0, + itemRewards: [], + objectives: [], + units: [], + bonds: [], + completedAt: new Date().toISOString() + }; + campaignModule.setCampaignState(initial); + + assert.deepEqual( + actionModule.selectCityStayEquipmentForCamp( + 'xuzhou', + 'city-xuzhou-iron-spear' + ), + { ok: false, reason: 'not-purchased' }, + 'The camp handoff must not be created before the offer is purchased.' + ); + + const first = actionModule.purchaseCityStayEquipment( + 'xuzhou', + 'city-xuzhou-iron-spear' + ); + assert.equal(first.ok, true); + assert.equal(first.purchaseNumber, 1); + assert.equal(first.campaign.gold, 4380); + const label = itemInventoryLabel('iron-spear'); + assert.equal(first.campaign.inventory[label], 1); + assert.equal( + first.campaign.cityEquipmentPurchaseCounts[ + 'city-xuzhou-iron-spear' + ], + 1 + ); + assert.equal(first.campaign.cityEquipmentPreparation, undefined); + assertStoredPurchase(storage.get(campaignModule.campaignStorageKey), 4380, 1, 1, label); + assertStoredPurchase( + storage.get(`${campaignModule.campaignStorageKey}:slot-1`), + 4380, + 1, + 1, + label + ); + + const second = actionModule.purchaseCityStayEquipment( + 'xuzhou', + 'city-xuzhou-iron-spear' + ); + assert.equal(second.ok, true); + assert.equal(second.purchaseNumber, 2); + assert.equal(second.campaign.gold, 3760); + assert.equal(second.campaign.inventory[label], 2); + assert.equal( + second.campaign.cityEquipmentPurchaseCounts[ + 'city-xuzhou-iron-spear' + ], + 2 + ); + const selected = + actionModule.selectCityStayEquipmentForCamp( + 'xuzhou', + 'city-xuzhou-iron-spear' + ); + assert.equal(selected.ok, true); + assert.equal(selected.purchaseNumber, 2); + assert.deepEqual(selected.campaign.cityEquipmentEquipIntent, { + version: 1, + intentRecordId: 'city-equipment-equip-intent', + cityStayId: 'xuzhou', + sourceBattleId: 'seventh-battle-xuzhou-rescue', + targetBattleId: 'eighth-battle-xiaopei-supply-road', + offerId: 'city-xuzhou-iron-spear', + itemId: 'iron-spear', + slot: 'weapon', + price: 620, + purchaseNumber: 2 + }); + + const beforeFailure = campaignModule.getCampaignState(); + const storedBeforeFailure = new Map(storage); + rejectStorageWrites = true; + const failed = actionModule.purchaseCityStayEquipment( + 'xuzhou', + 'city-xuzhou-iron-spear' + ); + rejectStorageWrites = false; + assert.deepEqual(failed, { ok: false, reason: 'save-unavailable' }); + const { updatedAt: actualUpdatedAt, ...actualAfterFailure } = + campaignModule.getCampaignState(); + const { updatedAt: expectedUpdatedAt, ...expectedAfterFailure } = + beforeFailure; + assert( + typeof actualUpdatedAt === 'string' && + typeof expectedUpdatedAt === 'string', + 'Rollback snapshots must retain valid campaign timestamps.' + ); + assert.deepEqual( + actualAfterFailure, + expectedAfterFailure, + 'A failed storage write must restore the in-memory campaign snapshot.' + ); + assert.deepEqual( + new Map(storage), + storedBeforeFailure, + 'A failed purchase must not partially change persisted storage.' + ); + + const reloaded = campaignModule.loadCampaignState(1); + assert.equal(reloaded.gold, 3760); + assert.equal(reloaded.inventory[label], 2); + assert.equal( + reloaded.cityEquipmentPurchaseCounts['city-xuzhou-iron-spear'], + 2 + ); + assert.deepEqual( + reloaded.cityEquipmentEquipIntent, + selected.campaign.cityEquipmentEquipIntent, + 'The market-to-camp handoff must survive a save-slot reload.' + ); + return { campaign: reloaded, label }; +} + +function verifyPreparationResolution({ + preparationModule, + campaignModule, + purchased +}) { + const equipped = structuredClone(purchased.campaign); + const miZhu = equipped.roster.find((unit) => unit.id === 'mi-zhu'); + assert(miZhu); + miZhu.equipment.weapon = { + itemId: 'iron-spear', + level: 2, + exp: 17 + }; + const normalizedEquipped = campaignModule.setCampaignState(equipped); + const snapshot = preparationModule.createCityEquipmentPreparationSnapshot({ + cityStayId: 'xuzhou', + offerId: 'city-xuzhou-iron-spear', + purchaseNumber: 2, + unitId: 'mi-zhu', + previousItemId: 'training-sword', + roster: normalizedEquipped.roster, + purchaseCounts: normalizedEquipped.cityEquipmentPurchaseCounts + }); + assert.deepEqual(snapshot, { + version: 1, + selectionRecordId: 'city-equipment-preparation', + cityStayId: 'xuzhou', + sourceBattleId: 'seventh-battle-xuzhou-rescue', + targetBattleId: 'eighth-battle-xiaopei-supply-road', + offerId: 'city-xuzhou-iron-spear', + itemId: 'iron-spear', + slot: 'weapon', + price: 620, + purchaseNumber: 2, + unitId: 'mi-zhu', + previousItemId: 'training-sword' + }); + + const resolved = preparationModule.resolveCityEquipmentPreparation( + snapshot, + { + battleId: 'eighth-battle-xiaopei-supply-road', + roster: normalizedEquipped.roster, + purchaseCounts: normalizedEquipped.cityEquipmentPurchaseCounts + } + ); + assert(resolved); + assert.equal(resolved.equipmentLevel, 2); + assert.equal(resolved.equipmentExp, 17); + assert.equal(resolved.preparedBonuses.attack, 4); + assert.equal(resolved.previousBonuses.attack, 2); + assert.deepEqual(resolved.statDelta, { + attack: 2, + defense: 0, + strategy: 0 + }); + assert.equal( + preparationModule.resolveCityEquipmentPreparation(snapshot, { + battleId: 'ninth-battle-xuzhou-gate-night-raid', + roster: normalizedEquipped.roster, + purchaseCounts: normalizedEquipped.cityEquipmentPurchaseCounts + }), + undefined, + 'The purchase preparation must apply only to its exact target battle.' + ); + assert.equal( + preparationModule.normalizeCityEquipmentPreparationSnapshot(snapshot, { + roster: normalizedEquipped.roster, + purchaseCounts: { 'city-xuzhou-iron-spear': 1 } + }), + undefined, + 'The recorded purchase number must be backed by purchase provenance.' + ); + assert.equal( + preparationModule.createCityEquipmentPreparationSnapshot({ + cityStayId: 'xuzhou', + offerId: 'city-xuzhou-grain-pouch', + purchaseNumber: 1, + unitId: 'mi-zhu', + previousItemId: 'grain-pouch', + roster: normalizedEquipped.roster, + purchaseCounts: { 'city-xuzhou-grain-pouch': 1 } + }), + undefined, + 'A zero-value equipment swap must not become a battle preparation.' + ); + return { campaign: normalizedEquipped, snapshot, resolved }; +} + +function verifyCampaignNormalizationAndInvalidation({ + preparationModule, + campaignModule, + prepared +}) { + const withPreparation = { + ...structuredClone(prepared.campaign), + cityEquipmentPurchaseCounts: { + ...prepared.campaign.cityEquipmentPurchaseCounts, + unknown: 9, + 'city-xuzhou-lamellar-armor': 1000 + }, + cityEquipmentPreparation: structuredClone(prepared.snapshot) + }; + let normalized = campaignModule.setCampaignState(withPreparation); + assert.equal(normalized.cityEquipmentPurchaseCounts.unknown, undefined); + assert.equal( + normalized.cityEquipmentPurchaseCounts[ + 'city-xuzhou-lamellar-armor' + ], + 99 + ); + assert.deepEqual(normalized.cityEquipmentPreparation, prepared.snapshot); + + const contribution = { + ...prepared.snapshot, + deployed: true, + offensiveActions: 2, + defensiveHits: 0, + bonusDamage: 4, + preventedDamage: 0 + }; + const completedAt = new Date().toISOString(); + const report = { + battleId: prepared.snapshot.targetBattleId, + battleTitle: '소패 보급로 방어전', + outcome: 'victory', + turnNumber: 9, + rewardGold: 0, + defeatedEnemies: 0, + totalEnemies: 0, + objectives: [], + units: structuredClone(normalized.roster), + bonds: [], + itemRewards: [], + cityEquipmentContribution: contribution, + completedCampDialogues: [], + completedCampVisits: [], + createdAt: completedAt + }; + const settlement = { + battleId: report.battleId, + battleTitle: report.battleTitle, + outcome: report.outcome, + turnNumber: report.turnNumber, + rewardGold: 0, + itemRewards: [], + objectives: [], + units: normalized.roster.map((unit) => ({ + unitId: unit.id, + name: unit.name, + level: unit.level, + exp: unit.exp, + hp: unit.hp, + maxHp: unit.maxHp, + equipment: structuredClone(unit.equipment) + })), + bonds: [], + cityEquipmentContribution: contribution, + completedAt + }; + normalized = campaignModule.setCampaignState({ + ...normalized, + firstBattleReport: report, + battleHistory: { [report.battleId]: settlement } + }); + assert.deepEqual( + normalized.firstBattleReport.cityEquipmentContribution, + contribution + ); + assert.deepEqual( + normalized.battleHistory[report.battleId].cityEquipmentContribution, + contribution + ); + + const swappedOff = structuredClone(normalized); + const miZhu = swappedOff.roster.find((unit) => unit.id === 'mi-zhu'); + miZhu.equipment.weapon.itemId = 'training-sword'; + const invalidated = campaignModule.setCampaignState(swappedOff); + assert.equal( + invalidated.cityEquipmentPreparation, + undefined, + 'Swapping the purchased item off must invalidate the active preparation.' + ); + + const corrupt = preparationModule.normalizeCityEquipmentPreparationSnapshot( + { ...prepared.snapshot, price: 1 }, + { + roster: prepared.campaign.roster, + purchaseCounts: prepared.campaign.cityEquipmentPurchaseCounts + } + ); + assert.equal(corrupt, undefined); +} + +function verifyStoredContributionRosterCrossValidation({ + campaignModule, + prepared +}) { + const contribution = { + ...prepared.snapshot, + deployed: true, + offensiveActions: 3, + defensiveHits: 0, + bonusDamage: 6, + preventedDamage: 0 + }; + const completedAt = new Date().toISOString(); + const reportRoster = structuredClone(prepared.campaign.roster); + const createState = ({ + roster = structuredClone(prepared.campaign.roster), + reportUnits = structuredClone(reportRoster), + storedContribution = structuredClone(contribution) + } = {}) => { + const report = { + battleId: prepared.snapshot.targetBattleId, + battleTitle: 'Xuzhou equipment contribution audit', + outcome: 'victory', + turnNumber: 9, + rewardGold: 0, + defeatedEnemies: 0, + totalEnemies: 0, + objectives: [], + units: reportUnits, + bonds: [], + itemRewards: [], + cityEquipmentContribution: + structuredClone(storedContribution), + completedCampDialogues: [], + completedCampVisits: [], + createdAt: completedAt + }; + const settlement = { + battleId: report.battleId, + battleTitle: report.battleTitle, + outcome: report.outcome, + turnNumber: report.turnNumber, + rewardGold: 0, + itemRewards: [], + objectives: [], + units: reportUnits.map((unit) => ({ + unitId: unit.id, + name: unit.name, + level: unit.level, + exp: unit.exp, + hp: unit.hp, + maxHp: unit.maxHp, + equipment: structuredClone(unit.equipment) + })), + bonds: [], + cityEquipmentContribution: + structuredClone(storedContribution), + completedAt + }; + return { + ...structuredClone(prepared.campaign), + roster, + latestBattleId: report.battleId, + firstBattleReport: report, + battleHistory: { + ...structuredClone(prepared.campaign.battleHistory), + [report.battleId]: settlement + } + }; + }; + const assertContributionState = ( + campaign, + expected, + message + ) => { + assert.equal( + Boolean( + campaign.firstBattleReport + ?.cityEquipmentContribution + ), + expected, + `${message} (latest report)` + ); + assert.equal( + Boolean( + campaign.battleHistory[ + prepared.snapshot.targetBattleId + ]?.cityEquipmentContribution + ), + expected, + `${message} (battle settlement)` + ); + }; + + const valid = campaignModule.setCampaignState(createState()); + assertContributionState( + valid, + true, + 'A canonical contribution backed by the current equipped roster must remain' + ); + + const forged = campaignModule.setCampaignState( + createState({ + storedContribution: { + ...contribution, + price: 1 + } + }) + ); + assertContributionState( + forged, + false, + 'A forged canonical identity must be removed' + ); + + const ghost = campaignModule.setCampaignState( + createState({ + storedContribution: { + ...contribution, + unitId: 'ghost-unit' + } + }) + ); + assertContributionState( + ghost, + false, + 'A contribution for a unit absent from the current roster must be removed' + ); + + const wrongEquipmentRoster = structuredClone( + prepared.campaign.roster + ); + wrongEquipmentRoster[0].equipment.weapon.itemId = + 'training-sword'; + const changedAfterBattle = campaignModule.setCampaignState( + createState({ roster: wrongEquipmentRoster }) + ); + assertContributionState( + changedAfterBattle, + true, + 'Later equipment changes must not erase a canonical historical contribution' + ); + + const wrongHistoricalEquipment = structuredClone( + reportRoster + ); + wrongHistoricalEquipment[0].equipment.weapon.itemId = + 'training-sword'; + const wrongHistoricalRecord = + campaignModule.setCampaignState( + createState({ + reportUnits: wrongHistoricalEquipment + }) + ); + assertContributionState( + wrongHistoricalRecord, + false, + 'A deployed contribution must be removed when its own historical unit snapshot has the wrong item' + ); + + const filteredRosterUnit = structuredClone( + prepared.campaign.roster[0] + ); + filteredRosterUnit.id = 'jian-yong'; + filteredRosterUnit.name = 'Jian Yong'; + const filtered = campaignModule.setCampaignState( + createState({ roster: [filteredRosterUnit] }) + ); + assertContributionState( + filtered, + false, + 'Roster-reference filtering must remove a contribution whose prepared unit was filtered out' + ); + assert.equal( + filtered.firstBattleReport.units.some( + (unit) => unit.id === prepared.snapshot.unitId + ), + false, + 'The stale report unit should be removed by the same current-roster filter pass.' + ); + + const undeployed = campaignModule.setCampaignState( + createState({ + reportUnits: [], + storedContribution: { + ...contribution, + deployed: false, + offensiveActions: 7, + defensiveHits: 8, + bonusDamage: 90, + preventedDamage: 80 + } + }) + ); + assertContributionState( + undeployed, + true, + 'An undeployed result may remain without a historical unit snapshot' + ); + assert.deepEqual( + { + offensiveActions: + undeployed.firstBattleReport + .cityEquipmentContribution.offensiveActions, + defensiveHits: + undeployed.firstBattleReport + .cityEquipmentContribution.defensiveHits, + bonusDamage: + undeployed.firstBattleReport + .cityEquipmentContribution.bonusDamage, + preventedDamage: + undeployed.firstBattleReport + .cityEquipmentContribution.preventedDamage + }, + { + offensiveActions: 0, + defensiveHits: 0, + bonusDamage: 0, + preventedDamage: 0 + }, + 'Undeployed historical counters must normalize to zero.' + ); + + const legacy = campaignModule.setCampaignState({ + ...createState(), + firstBattleReport: { + ...createState().firstBattleReport, + cityEquipmentContribution: undefined + }, + battleHistory: Object.fromEntries( + Object.entries(createState().battleHistory).map( + ([battleId, settlement]) => [ + battleId, + { + ...settlement, + cityEquipmentContribution: undefined + } + ] + ) + ) + }); + assertContributionState( + legacy, + false, + 'Legacy reports without contribution metadata must remain valid' + ); +} + +function verifyContributionNormalization(preparationModule, snapshot) { + const normalized = + preparationModule.normalizeCityEquipmentContributionSnapshot( + { + ...snapshot, + deployed: true, + offensiveActions: 2.9, + defensiveHits: -4, + bonusDamage: 9999999, + preventedDamage: 8.8 + }, + snapshot.targetBattleId + ); + assert.deepEqual(normalized, { + ...snapshot, + deployed: true, + offensiveActions: 2, + defensiveHits: 0, + bonusDamage: 999999, + preventedDamage: 0 + }); + const impossibleOffense = + preparationModule.normalizeCityEquipmentContributionSnapshot({ + ...normalized, + offensiveActions: 0, + bonusDamage: 25 + }); + assert.equal( + impossibleOffense.bonusDamage, + 0, + 'Bonus damage without an offensive action must be discarded.' + ); + assert.equal( + preparationModule.normalizeCityEquipmentContributionSnapshot( + normalized, + 'ninth-battle-xuzhou-gate-night-raid' + ), + undefined + ); + const undeployed = + preparationModule.normalizeCityEquipmentContributionSnapshot({ + ...normalized, + deployed: false, + offensiveActions: 7, + bonusDamage: 90 + }); + assert.equal(undeployed.offensiveActions, 0); + assert.equal(undeployed.bonusDamage, 0); + const clone = + preparationModule.cloneCityEquipmentContributionSnapshot(normalized); + clone.bonusDamage = 0; + assert.equal(normalized.bonusDamage, 999999); +} + +function verifyOldSaveCompatibility(campaignModule) { + const oldSave = campaignModule.createInitialCampaignState(); + delete oldSave.cityEquipmentPurchaseCounts; + delete oldSave.cityEquipmentEquipIntent; + delete oldSave.cityEquipmentPreparation; + const normalized = campaignModule.setCampaignState(oldSave); + assert.deepEqual(normalized.cityEquipmentPurchaseCounts, {}); + assert.equal(normalized.cityEquipmentEquipIntent, undefined); + assert.equal(normalized.cityEquipmentPreparation, undefined); +} + +function assertStoredPurchase(raw, gold, amount, purchaseCount, label) { + assert.equal(typeof raw, 'string'); + const parsed = JSON.parse(raw); + assert.equal(parsed.gold, gold); + assert.equal(parsed.inventory[label], amount); + assert.equal( + parsed.cityEquipmentPurchaseCounts['city-xuzhou-iron-spear'], + purchaseCount + ); +} diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index 0fef1ae..3eaed56 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -22,6 +22,7 @@ const checks = [ 'scripts/verify-narrative-continuity.mjs', 'scripts/verify-camp-reward-data.mjs', 'scripts/verify-city-stay-data.mjs', + 'scripts/verify-city-equipment-preparation.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 new file mode 100644 index 0000000..039444e --- /dev/null +++ b/scripts/verify-xuzhou-equipment-link-browser.mjs @@ -0,0 +1,2350 @@ +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 cityStayId = 'xuzhou'; +const sourceBattleId = 'seventh-battle-xuzhou-rescue'; +const targetBattleId = 'eighth-battle-xiaopei-supply-road'; +const offerId = 'city-xuzhou-iron-spear'; +const itemId = 'iron-spear'; +const itemName = '철창'; +const previousItemId = 'training-sword'; +const previousItemName = '연습검'; +const preparedUnitId = 'mi-zhu'; +const preparedUnitName = '미축'; +const seedGold = 5000; +const offerPrice = 620; +const expectedGold = seedGold - offerPrice; +const expectedActiveLine = + '서주 구입 장비 · 미축 · 철창 · 공격 +2'; +const expectedMissingLine = + '서주 구입 장비 · 미축 미출전 · 미반영'; + +const rendererFixtures = { + canvas: { renderer: 'canvas', expectedRendererType: 1 }, + webgl: { renderer: 'webgl', expectedRendererType: 2 } +}; +const requestedRenderer = + cliOption('renderer') ?? + process.env.VERIFY_XUZHOU_EQUIPMENT_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_EQUIPMENT_HEADLESS !== '0' + }); + + for (const renderer of renderers) { + await verifyRenderer(browser, baseUrl, rendererFixtures[renderer]); + } + + console.log( + `Xuzhou purchase-to-battle equipment verification passed for ${renderers.join( + ' + ' + )} at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} CSS pixels, ` + + `DPR ${desktopBrowserDeviceScaleFactor}: real market purchase, reload-compatible equip CTA, ` + + 'reload-persisted Mi Zhu focus, comparison/cancel/atomic storage-failure rollback/confirm invariants, ' + + 'exact persisted preparation record, active/missing sortie header states, ' + + 'real deployment confirmation, one deterministic combat contribution, battle-save restoration, and campaign result 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 openCamp(page); + const seed = await seedXuzhouCampaign(page); + assert.equal(seed.saved, true, seed.reason ?? 'Xuzhou seed failed.'); + assert.equal(seed.step, 'seventh-camp'); + assert.equal(seed.gold, seedGold); + assert.equal(seed.activeCityStayId, cityStayId); + assert.deepEqual(seed.selectedSortieUnitIds, [ + 'liu-bei', + 'guan-yu', + 'zhang-fei', + 'jian-yong', + preparedUnitId + ]); + + await openXuzhouCity(page); + await openMarketWithRealInteraction(page); + let city = await readCity(page); + assertMarketBaseline(city, rendererFixture.renderer); + await capture( + page, + screenshotPath(rendererFixture.renderer, 'market') + ); + + const offerBefore = findOffer(city, offerId); + await clickSceneBounds( + page, + 'CityStayScene', + offerBefore.buttonBounds + ); + await page.waitForFunction( + ({ expectedOfferId, gold }) => { + const state = window.__HEROS_DEBUG__?.cityStay?.(); + const offer = state?.shop?.offers?.find( + (candidate) => candidate.id === expectedOfferId + ); + return ( + state?.shop?.open === true && + state?.shop?.gold === gold && + offer?.owned === 1 && + offer?.purchaseCount === 1 && + offer?.equipInteractive === true + ); + }, + { expectedOfferId: offerId, gold: expectedGold } + ); + city = await readCity(page); + assertMarketReceipt(city, rendererFixture.renderer); + assertCampaignAfterPurchase( + await readSceneCampaign(page, 'CityStayScene') + ); + await capture( + page, + screenshotPath(rendererFixture.renderer, 'receipt') + ); + + await reloadAndContinueCity(page); + await openMarketWithRealInteraction(page); + city = await readCity(page); + assertMarketReceipt(city, `${rendererFixture.renderer} reload`); + const restoredOffer = findOffer(city, offerId); + await page.waitForTimeout(380); + const ctaHitProbe = await armCityEquipmentCtaProbe( + page, + restoredOffer.equipCtaBounds + ); + assert.equal( + ctaHitProbe.equipButtonHit, + true, + `${rendererFixture.renderer}: the real CTA center must hit its rectangle: ${JSON.stringify( + ctaHitProbe + )}` + ); + await clickSceneBounds( + page, + 'CityStayScene', + restoredOffer.equipCtaBounds + ); + await page.waitForTimeout(320); + const ctaClickProbe = await page.evaluate(() => ({ + events: [ + ...(window.__XUZHOU_EQUIPMENT_CTA_EVENTS__ ?? []) + ], + activeScenes: + window.__HEROS_DEBUG__?.activeScenes?.() ?? [], + navigationPending: + window.__HEROS_DEBUG__?.cityStay?.() + ?.navigationPending ?? null + })); + assert( + ctaClickProbe.events.some( + (event) => event.kind === 'equip-button' + ) || + ctaClickProbe.activeScenes.includes('CampScene') || + ctaClickProbe.navigationPending === true, + `${rendererFixture.renderer}: the real CTA center click was not delivered: ${JSON.stringify( + { ctaHitProbe, ctaClickProbe, pageErrors, consoleErrors } + )}` + ); + let camp; + try { + camp = await waitForEquipmentContext(page); + } catch (error) { + const diagnostic = await page.evaluate(() => ({ + activeScenes: + window.__HEROS_DEBUG__?.activeScenes?.() ?? [], + city: window.__HEROS_DEBUG__?.cityStay?.(), + camp: window.__HEROS_DEBUG__?.camp?.() + })); + throw new Error( + `${error.message}\nEquipment routing diagnostic: ${JSON.stringify( + diagnostic + )}`, + { cause: error } + ); + } + assertEquipmentContext(camp, rendererFixture.renderer); + await capture( + page, + screenshotPath(rendererFixture.renderer, 'camp') + ); + await reloadAndContinueEquipmentContext(page); + camp = await waitForEquipmentContext(page); + assertEquipmentContext( + camp, + `${rendererFixture.renderer} intent reload` + ); + await capture( + page, + screenshotPath( + rendererFixture.renderer, + 'camp-context-reloaded' + ) + ); + + const focusedRow = focusedEquipmentRow(camp); + await clickSceneBounds( + page, + 'CampScene', + focusedRow.actionButtonBounds + ); + camp = await waitForEquipmentComparison(page); + assertEquipmentComparison(camp, rendererFixture.renderer); + await capture( + page, + screenshotPath(rendererFixture.renderer, 'comparison') + ); + + const beforeCancel = relevantEquipmentState( + await readSceneCampaign(page, 'CampScene') + ); + await clickSceneBounds( + page, + 'CampScene', + camp.equipmentSwapComparison.cancelButtonBounds + ); + await page.waitForFunction( + () => + window.__HEROS_DEBUG__?.camp?.()?.equipmentSwapComparison === + null + ); + camp = await readCamp(page); + assert.equal( + camp.cityEquipmentContext.preparationRecord, + null, + `${rendererFixture.renderer}: cancel must not create a preparation record.` + ); + assert.deepEqual( + relevantEquipmentState( + await readSceneCampaign(page, 'CampScene') + ), + beforeCancel, + `${rendererFixture.renderer}: cancel must preserve inventory, equipment level/exp, purchase count, and preparation.` + ); + + await clickSceneBounds( + page, + 'CampScene', + focusedEquipmentRow(camp).actionButtonBounds + ); + camp = await waitForEquipmentComparison(page); + const beforeStorageFailure = relevantEquipmentState( + await readSceneCampaign(page, 'CampScene') + ); + await injectOneCampaignStorageFailure(page); + let storageFailureTriggered = false; + try { + await clickSceneBounds( + page, + 'CampScene', + camp.equipmentSwapComparison.confirmButtonBounds + ); + await page.waitForFunction( + () => + window.__XUZHOU_STORAGE_FAILURE_TRIGGERED__ === + true && + window.__HEROS_DEBUG__?.camp?.() + ?.equipmentSwapComparison === null + ); + } finally { + storageFailureTriggered = + await restoreCampaignStorageWrites(page); + } + assert.equal( + storageFailureTriggered, + true, + `${rendererFixture.renderer}: the injected campaign write failure must be exercised.` + ); + camp = await readCamp(page); + assert.deepEqual( + relevantEquipmentState( + await readSceneCampaign(page, 'CampScene') + ), + beforeStorageFailure, + `${rendererFixture.renderer}: failed confirmation must roll back inventory, equipment progress, purchase intent, and preparation atomically.` + ); + assert.equal( + camp.cityEquipmentContext.preparationRecord, + null, + `${rendererFixture.renderer}: failed confirmation must not leave a preparation record.` + ); + assert.equal( + focusedEquipmentRow(camp).canEquip, + true, + `${rendererFixture.renderer}: the purchased spear must remain equip-ready after rollback.` + ); + await capture( + page, + screenshotPath( + rendererFixture.renderer, + 'storage-rollback' + ) + ); + + await clickSceneBounds( + page, + 'CampScene', + focusedEquipmentRow(camp).actionButtonBounds + ); + camp = await waitForEquipmentComparison(page); + await clickSceneBounds( + page, + 'CampScene', + camp.equipmentSwapComparison.confirmButtonBounds + ); + await page.waitForFunction( + ({ unitId, nextItemId }) => { + const state = window.__HEROS_DEBUG__?.camp?.(); + const unit = state?.campaign?.roster?.find( + (candidate) => candidate.id === unitId + ); + return ( + state?.equipmentSwapComparison === null && + unit?.equipment?.weapon?.itemId === nextItemId && + state?.campaign?.cityEquipmentPreparation?.offerId === + 'city-xuzhou-iron-spear' + ); + }, + { unitId: preparedUnitId, nextItemId: itemId } + ); + camp = await readCamp(page); + assertConfirmedPreparation(camp, rendererFixture.renderer); + await capture( + page, + screenshotPath(rendererFixture.renderer, 'equipped') + ); + + await reloadAndContinueCamp(page); + camp = await readCamp(page); + assertConfirmedPreparation(camp, `${rendererFixture.renderer} reload`); + await openSortieWithRealClick(page); + camp = await waitForSortiePreparation(page, 'briefing'); + assertSortiePreparationActive(camp, rendererFixture.renderer); + await capture( + page, + screenshotPath(rendererFixture.renderer, 'sortie-active') + ); + + await clickSceneBounds( + page, + 'CampScene', + camp.sortiePrimaryAction.bounds + ); + camp = await waitForSortiePreparation(page, 'formation'); + const miZhuToggle = sortieToggleBounds(camp, preparedUnitId); + assertBoundsInsideViewport( + miZhuToggle, + `${rendererFixture.renderer}: Mi Zhu sortie toggle` + ); + await clickSceneBounds(page, 'CampScene', miZhuToggle); + await page.waitForFunction( + ({ unitId }) => { + const state = window.__HEROS_DEBUG__?.camp?.(); + return ( + state?.sortiePrepStep === 'formation' && + !state?.selectedSortieUnitIds?.includes(unitId) && + state?.sortieCityEquipmentPreparation?.status === + 'unit-not-selected' + ); + }, + { unitId: preparedUnitId } + ); + camp = await readCamp(page); + assertSortiePreparationMissing(camp, rendererFixture.renderer); + await capture( + page, + screenshotPath(rendererFixture.renderer, 'sortie-missing') + ); + + await clickSceneBounds( + page, + 'CampScene', + sortieToggleBounds(camp, preparedUnitId) + ); + await page.waitForFunction( + ({ unitId }) => { + const state = window.__HEROS_DEBUG__?.camp?.(); + return ( + state?.sortiePrepStep === 'formation' && + state?.selectedSortieUnitIds?.includes(unitId) && + state?.sortieCityEquipmentPreparation?.status === + 'active' + ); + }, + { unitId: preparedUnitId } + ); + camp = await waitForSortiePreparation(page, 'formation'); + assertSortiePreparationActive( + camp, + `${rendererFixture.renderer} reselected` + ); + assert.equal( + camp.selectedSortieUnitIds.includes(preparedUnitId), + true, + `${rendererFixture.renderer}: Mi Zhu must be restored to the sortie before entering battle.` + ); + + let battle = await openPreparedBattle(page); + assertBattleDeploymentPreparation( + battle, + rendererFixture.renderer + ); + await capture( + page, + screenshotPath( + rendererFixture.renderer, + 'battle-deployment' + ) + ); + + battle = await startBattleWithRealClick(page, battle); + assertBattleHudPreparation(battle, rendererFixture.renderer); + await capture( + page, + screenshotPath(rendererFixture.renderer, 'battle-active') + ); + + const contributionRoundTrip = + await exerciseBattleContributionAndSave(page); + assertBattleContributionRoundTrip( + contributionRoundTrip, + rendererFixture.renderer + ); + battle = await readBattle(page); + assertBattleHudPreparation( + battle, + `${rendererFixture.renderer} contributed` + ); + await capture( + page, + screenshotPath( + rendererFixture.renderer, + 'battle-contributed' + ) + ); + + const result = await forceVictoryAndReadContribution(page); + assertBattleResultContribution( + result, + contributionRoundTrip.restored.result, + rendererFixture.renderer + ); + await capture( + page, + screenshotPath(rendererFixture.renderer, 'battle-result') + ); + + 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 openCamp(page) { + await page.evaluate(async () => { + await window.__HEROS_DEBUG__.goToCamp(); + }); + await page.waitForFunction( + () => { + const camp = window.__HEROS_DEBUG__?.camp?.(); + return ( + window.__HEROS_DEBUG__?.activeScenes?.().includes('CampScene') && + camp?.scene === 'CampScene' && + camp?.campaign + ); + }, + undefined, + { timeout: 15000 } + ); +} + +async function seedXuzhouCampaign(page) { + return page.evaluate( + ({ + cityId, + sourceId, + targetId, + gold, + miZhuId + }) => { + const scene = + window.__HEROS_GAME__?.scene.getScene('CampScene'); + if ( + !scene?.campaign || + !scene.report || + typeof scene.persistSortieSelection !== 'function' + ) { + return { + saved: false, + reason: + 'CampScene campaign/report/persistSortieSelection is unavailable.' + }; + } + + const templates = [ + ...scene.campaign.roster, + ...(scene.report.units ?? []) + ]; + const fallback = templates[0]; + if (!fallback) { + return { + saved: false, + reason: 'No UnitData template is available.' + }; + } + const unit = (spec) => { + const template = + templates.find((candidate) => candidate.id === spec.id) ?? + fallback; + return { + ...structuredClone(template), + ...spec, + faction: 'ally', + stats: { ...spec.stats }, + equipment: structuredClone(spec.equipment) + }; + }; + const roster = [ + unit({ + id: 'liu-bei', + name: '유비', + className: '군주', + classKey: 'lord', + level: 5, + exp: 10, + hp: 34, + maxHp: 34, + attack: 9, + move: 5, + stats: { + might: 72, + intelligence: 82, + leadership: 91, + agility: 76, + luck: 88 + }, + equipment: { + weapon: { + itemId: 'twin-oath-blades', + level: 1, + exp: 8 + }, + armor: { + itemId: 'oath-robe', + level: 1, + exp: 6 + }, + accessory: { + itemId: 'benevolence-seal', + level: 1, + exp: 0 + } + }, + x: 2, + y: 17 + }), + unit({ + id: 'guan-yu', + name: '관우', + className: '중장보병', + classKey: 'infantry', + level: 5, + exp: 12, + hp: 40, + maxHp: 40, + attack: 12, + move: 4, + stats: { + might: 96, + intelligence: 78, + leadership: 90, + agility: 74, + luck: 72 + }, + equipment: { + weapon: { + itemId: 'green-dragon-blade', + level: 1, + exp: 9 + }, + armor: { + itemId: 'lamellar-armor', + level: 1, + exp: 7 + }, + accessory: { + itemId: 'bravery-token', + level: 1, + exp: 0 + } + }, + x: 3, + y: 17 + }), + unit({ + id: 'zhang-fei', + name: '장비', + className: '창병', + classKey: 'spearman', + level: 5, + exp: 11, + hp: 39, + maxHp: 39, + attack: 12, + move: 5, + stats: { + might: 98, + intelligence: 34, + leadership: 82, + agility: 82, + luck: 68 + }, + equipment: { + weapon: { + itemId: 'serpent-spear', + level: 1, + exp: 10 + }, + armor: { + itemId: 'lamellar-armor', + level: 1, + exp: 8 + }, + accessory: { + itemId: 'bravery-token', + level: 1, + exp: 0 + } + }, + x: 4, + y: 18 + }), + unit({ + id: 'jian-yong', + name: '간옹', + className: '책사', + classKey: 'strategist', + level: 4, + exp: 7, + hp: 27, + maxHp: 27, + attack: 7, + move: 4, + stats: { + might: 42, + intelligence: 80, + leadership: 66, + agility: 64, + luck: 76 + }, + equipment: { + weapon: { + itemId: 'training-sword', + level: 1, + exp: 4 + }, + armor: { + itemId: 'cloth-armor', + level: 1, + exp: 4 + }, + accessory: { + itemId: 'war-manual', + level: 1, + exp: 0 + } + }, + x: 3, + y: 18 + }), + unit({ + id: miZhuId, + name: '미축', + className: '군량관', + classKey: 'quartermaster', + level: 4, + exp: 8, + hp: 28, + maxHp: 28, + attack: 7, + move: 4, + stats: { + might: 40, + intelligence: 74, + leadership: 70, + agility: 58, + luck: 82 + }, + equipment: { + weapon: { + itemId: 'training-sword', + level: 1, + exp: 6 + }, + armor: { + itemId: 'cloth-armor', + level: 1, + exp: 6 + }, + accessory: { + itemId: 'grain-pouch', + level: 1, + exp: 0 + } + }, + x: 1, + y: 18 + }) + ]; + const selectedSortieUnitIds = roster.map( + (candidate) => candidate.id + ); + const formationAssignments = { + 'liu-bei': 'support', + 'guan-yu': 'front', + 'zhang-fei': 'flank', + 'jian-yong': 'support', + [miZhuId]: 'reserve' + }; + const now = new Date().toISOString(); + const report = { + ...structuredClone(scene.report), + battleId: sourceId, + battleTitle: '서주 구원전', + outcome: 'victory', + turnNumber: 18, + rewardGold: 0, + itemRewards: [], + objectives: [], + units: structuredClone(roster), + bonds: [], + completedCampDialogues: [], + completedCampVisits: [], + createdAt: now + }; + delete report.campaignRewards; + + scene.campaign.step = 'seventh-camp'; + scene.campaign.gold = gold; + scene.campaign.inventory = {}; + scene.campaign.cityEquipmentPurchaseCounts = {}; + delete scene.campaign.cityEquipmentPreparation; + scene.campaign.latestBattleId = sourceId; + scene.campaign.roster = roster; + scene.campaign.bonds = []; + scene.campaign.firstBattleReport = report; + scene.campaign.battleHistory = { + [sourceId]: { + battleId: sourceId, + battleTitle: report.battleTitle, + outcome: 'victory', + turnNumber: report.turnNumber, + rewardGold: 0, + itemRewards: [], + objectives: [], + units: [], + bonds: [], + completedAt: now + } + }; + scene.campaign.completedCampDialogues = []; + scene.campaign.completedCampVisits = []; + scene.campaign.campDialogueChoiceIds = {}; + scene.campaign.campVisitChoiceIds = {}; + scene.campaign.acknowledgedVictoryRewardBattleIds = [ + sourceId + ]; + scene.campaign.acknowledgedVictoryRewardCategories = {}; + scene.campaign.dismissedVictoryRewardNoticeBattleIds = [ + sourceId + ]; + delete scene.campaign.pendingAftermathBattleId; + scene.campaign.activeCityStayId = cityId; + scene.campaign.selectedSortieUnitIds = + selectedSortieUnitIds; + scene.campaign.sortieFormationAssignments = + formationAssignments; + scene.campaign.sortieItemAssignments = {}; + scene.campaign.sortieOrderSelection = { + battleId: targetId, + orderId: 'elite' + }; + delete scene.campaign.sortieResonanceSelection; + scene.report = report; + scene.selectedSortieUnitIds = selectedSortieUnitIds; + scene.sortieFormationAssignments = formationAssignments; + scene.sortieItemAssignments = {}; + scene.persistSortieSelection(); + + return { + saved: true, + step: scene.campaign.step, + gold: scene.campaign.gold, + activeCityStayId: scene.campaign.activeCityStayId, + selectedSortieUnitIds: [ + ...scene.campaign.selectedSortieUnitIds + ] + }; + }, + { + cityId: cityStayId, + sourceId: sourceBattleId, + targetId: targetBattleId, + gold: seedGold, + miZhuId: preparedUnitId + } + ); +} + +async function openXuzhouCity(page) { + await page.evaluate(async (cityId) => { + await window.__HEROS_DEBUG__.goToCityStay(cityId); + }, cityStayId); + await waitForCity(page); +} + +async function waitForCity(page) { + await page.waitForFunction( + (expectedCityId) => { + const city = window.__HEROS_DEBUG__?.cityStay?.(); + return ( + window.__HEROS_DEBUG__?.activeScenes?.().includes( + 'CityStayScene' + ) && + city?.scene === 'CityStayScene' && + city?.cityStayId === expectedCityId && + city?.ready === true + ); + }, + cityStayId, + { timeout: 90000 } + ); +} + +async function openMarketWithRealInteraction(page) { + const teleported = await page.evaluate(() => { + const scene = + window.__HEROS_GAME__?.scene.getScene('CityStayScene'); + return scene?.debugTeleportTo?.('market') ?? false; + }); + assert.equal( + teleported, + true, + 'Expected to place the player within the market interaction radius.' + ); + await page.waitForTimeout(520); + await page.keyboard.press('KeyE'); + await page.waitForFunction( + () => + window.__HEROS_DEBUG__?.cityStay?.()?.shop?.open === true + ); +} + +function assertMarketBaseline(city, renderer) { + assert.equal(city.shop.open, true); + assert.equal(city.shop.gold, seedGold); + assert.equal(city.shop.offers.length, 3); + assertBoundsInsideViewport( + city.shop.panelBounds, + `${renderer}: market panel` + ); + assert.equal( + new Set(city.shop.offers.map((offer) => offer.iconKind)).size, + 3, + `${renderer}: weapon, armor, and accessory must use distinct icon kinds.` + ); + city.shop.offers.forEach((offer) => { + assertFiniteBounds( + offer.itemIconBounds, + `${renderer}: ${offer.id} item icon` + ); + assertBoundsInside( + offer.itemIconBounds, + city.shop.panelBounds, + `${renderer}: ${offer.id} item icon` + ); + assertBoundsInside( + offer.buttonBounds, + city.shop.panelBounds, + `${renderer}: ${offer.id} purchase button` + ); + }); + assertNoOverlappingBounds( + city.shop.offers.map((offer) => ({ + id: offer.id, + bounds: offer.itemIconBounds + })), + `${renderer}: market item icons` + ); + const ironSpear = findOffer(city, offerId); + assert.equal(ironSpear.itemId, itemId); + assert.equal(ironSpear.itemName, itemName); + assert.equal(ironSpear.price, offerPrice); + assert.equal(ironSpear.owned, 0); + assert.equal(ironSpear.purchaseCount, 0); + assert.equal(ironSpear.canBuy, true); + assert.equal(ironSpear.interactive, true); + assert.equal(ironSpear.equipCtaBounds, null); + assert.equal(ironSpear.equipInteractive, false); + + const grainPouch = findOffer( + city, + 'city-xuzhou-grain-pouch' + ); + assert.equal( + grainPouch.bonusText, + '보급품 슬롯 · 전투 효과 준비 중' + ); +} + +function assertMarketReceipt(city, renderer) { + assert.equal(city.shop.gold, expectedGold); + const offer = findOffer(city, offerId); + assert.equal(offer.owned, 1); + assert.equal(offer.purchaseCount, 1); + assert.equal(offer.interactive, true); + assert.equal(offer.equipInteractive, true); + assertFiniteBounds( + offer.equipCtaBounds, + `${renderer}: equip comparison CTA` + ); + assertBoundsInside( + offer.equipCtaBounds, + city.shop.panelBounds, + `${renderer}: equip comparison CTA` + ); + assertNoOverlappingBounds( + [ + { id: 'purchase', bounds: offer.buttonBounds }, + { id: 'equip-comparison', bounds: offer.equipCtaBounds } + ], + `${renderer}: purchased-row CTAs` + ); +} + +function assertCampaignAfterPurchase(campaign) { + assert.equal(campaign.gold, expectedGold); + assert.equal(campaign.inventory[itemName], 1); + assert.equal( + campaign.cityEquipmentPurchaseCounts[offerId], + 1 + ); + assert.equal(campaign.cityEquipmentPreparation, undefined); + const miZhu = campaign.roster.find( + (unit) => unit.id === preparedUnitId + ); + assert.equal(miZhu.equipment.weapon.itemId, previousItemId); + assert.equal(miZhu.equipment.weapon.level, 1); + assert.equal(miZhu.equipment.weapon.exp, 6); +} + +async function reloadAndContinueCity(page) { + await page.reload({ + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForDebugApi(page); + await page.waitForFunction( + () => + window.__HEROS_DEBUG__?.activeScenes?.().includes( + 'TitleScene' + ), + undefined, + { timeout: 90000 } + ); + await page.waitForTimeout(380); + await page.keyboard.press('Enter'); + await waitForCity(page); +} + +async function waitForEquipmentContext(page) { + await page.waitForFunction( + ({ expectedOfferId, expectedUnitId, expectedItemId }) => { + const camp = window.__HEROS_DEBUG__?.camp?.(); + return ( + window.__HEROS_DEBUG__?.activeScenes?.().includes( + 'CampScene' + ) && + camp?.activeTab === 'equipment' && + camp?.secondaryPanels?.equipment?.visible === true && + camp?.cityEquipmentContext?.offerId === expectedOfferId && + camp?.cityEquipmentContext?.unitId === expectedUnitId && + camp?.cityEquipmentContext?.itemId === expectedItemId && + camp?.cityEquipmentContext?.focusedActionButtonBounds + ); + }, + { + expectedOfferId: offerId, + expectedUnitId: preparedUnitId, + expectedItemId: itemId + }, + { timeout: 90000 } + ); + return readCamp(page); +} + +function assertEquipmentContext(camp, renderer) { + const context = camp.cityEquipmentContext; + assert(context, `${renderer}: city equipment context is required.`); + assert.deepEqual(camp.campaign.cityEquipmentEquipIntent, { + version: 1, + intentRecordId: 'city-equipment-equip-intent', + cityStayId, + sourceBattleId, + targetBattleId, + offerId, + itemId, + slot: 'weapon', + price: offerPrice, + purchaseNumber: 1 + }); + assert.equal(context.selectionRecordId, 'city-equipment-preparation'); + assert.equal(context.cityStayId, cityStayId); + assert.equal(context.cityName, '서주'); + assert.equal(context.offerId, offerId); + assert.equal(context.itemId, itemId); + assert.equal(context.itemName, itemName); + assert.equal(context.slot, 'weapon'); + assert.equal(context.unitId, preparedUnitId); + assert.equal(context.unitName, preparedUnitName); + assert.equal(context.purchaseCount, 1); + assert.equal(context.activeTab, 'equipment'); + assert.equal(context.selectedUnitId, preparedUnitId); + assert.equal(context.currentItemId, previousItemId); + assert.equal(context.preparationRecord, null); + assert.equal( + camp.secondaryPanels.equipment.page, + context.inventoryPage + ); + assert( + camp.secondaryPanels.equipment.visibleItemIds.includes(itemId), + `${renderer}: the purchased spear must be visible on the selected inventory page.` + ); + assertBoundsInsideViewport( + camp.secondaryPanels.equipment.bounds, + `${renderer}: equipment panel` + ); + assertBoundsInside( + context.bannerBounds, + camp.secondaryPanels.equipment.bounds, + `${renderer}: purchase context banner` + ); + assertBoundsInside( + context.focusedRowBounds, + camp.secondaryPanels.equipment.bounds, + `${renderer}: focused purchase row` + ); + assertBoundsInside( + context.focusedActionButtonBounds, + context.focusedRowBounds, + `${renderer}: focused purchase action` + ); + assertNoOverlappingBounds( + [ + { id: 'context-banner', bounds: context.bannerBounds }, + { id: 'focused-row', bounds: context.focusedRowBounds } + ], + `${renderer}: equipment context layout` + ); + const focused = focusedEquipmentRow(camp); + assert.equal(focused.itemId, itemId); + assert.equal(focused.focusedCityPurchase, true); + assert.equal(focused.canEquip, true); + assert.equal(focused.interactive, true); +} + +function focusedEquipmentRow(camp) { + const row = camp.secondaryPanels.equipment.rows.find( + (candidate) => candidate.focusedCityPurchase + ); + assert(row, 'Expected a focused purchased equipment row.'); + return row; +} + +async function waitForEquipmentComparison(page) { + await page.waitForFunction( + () => { + const comparison = + window.__HEROS_DEBUG__?.camp?.()?.equipmentSwapComparison; + return ( + comparison?.visible === true && + comparison?.confirmInteractive === true && + comparison?.cancelInteractive === true + ); + }, + undefined, + { timeout: 30000 } + ); + return readCamp(page); +} + +function assertEquipmentComparison(camp, renderer) { + const comparison = camp.equipmentSwapComparison; + assert(comparison, `${renderer}: equipment comparison is required.`); + assert.equal(comparison.unitId, preparedUnitId); + assert.equal(comparison.unitName, preparedUnitName); + assert.equal(comparison.slot, 'weapon'); + assert.equal(comparison.currentItemId, previousItemId); + assert.equal(comparison.currentItemName, previousItemName); + assert.equal(comparison.candidateItemId, itemId); + assert.equal(comparison.candidateItemName, itemName); + assert( + comparison.deltaText.includes('공격+2'), + `${renderer}: expected an exact +2 attack comparison, received "${comparison.deltaText}".` + ); + assertBoundsInsideViewport( + comparison.panelBounds, + `${renderer}: equipment comparison panel` + ); + assertBoundsInside( + comparison.cancelButtonBounds, + comparison.panelBounds, + `${renderer}: comparison cancel` + ); + assertBoundsInside( + comparison.confirmButtonBounds, + comparison.panelBounds, + `${renderer}: comparison confirm` + ); + assertNoOverlappingBounds( + [ + { id: 'cancel', bounds: comparison.cancelButtonBounds }, + { id: 'confirm', bounds: comparison.confirmButtonBounds } + ], + `${renderer}: comparison actions` + ); +} + +function relevantEquipmentState(campaign) { + const unit = campaign.roster.find( + (candidate) => candidate.id === preparedUnitId + ); + return { + gold: campaign.gold, + inventory: structuredClone(campaign.inventory), + purchaseCount: + campaign.cityEquipmentPurchaseCounts?.[offerId] ?? 0, + preparation: campaign.cityEquipmentPreparation + ? structuredClone(campaign.cityEquipmentPreparation) + : null, + equipIntent: campaign.cityEquipmentEquipIntent + ? structuredClone(campaign.cityEquipmentEquipIntent) + : null, + weapon: structuredClone(unit.equipment.weapon), + unitLevel: unit.level, + unitExp: unit.exp + }; +} + +async function injectOneCampaignStorageFailure(page) { + await page.evaluate(() => { + if (window.__XUZHOU_ORIGINAL_STORAGE_SET_ITEM__) { + throw new Error( + 'A campaign storage failure injection is already active.' + ); + } + const original = Storage.prototype.setItem; + window.__XUZHOU_ORIGINAL_STORAGE_SET_ITEM__ = original; + window.__XUZHOU_STORAGE_FAILURE_TRIGGERED__ = false; + Storage.prototype.setItem = function (key, value) { + if ( + window.__XUZHOU_STORAGE_FAILURE_TRIGGERED__ !== true && + String(key).startsWith('heros-web:campaign-state') + ) { + window.__XUZHOU_STORAGE_FAILURE_TRIGGERED__ = true; + throw new DOMException( + 'Injected Xuzhou equipment save failure.', + 'QuotaExceededError' + ); + } + return original.call(this, key, value); + }; + }); +} + +async function restoreCampaignStorageWrites(page) { + return page.evaluate(() => { + const triggered = + window.__XUZHOU_STORAGE_FAILURE_TRIGGERED__ === true; + const original = + window.__XUZHOU_ORIGINAL_STORAGE_SET_ITEM__; + if (original) { + Storage.prototype.setItem = original; + } + delete window.__XUZHOU_ORIGINAL_STORAGE_SET_ITEM__; + delete window.__XUZHOU_STORAGE_FAILURE_TRIGGERED__; + return triggered; + }); +} + +function assertConfirmedPreparation(camp, renderer) { + const campaign = camp.campaign; + assert(campaign, `${renderer}: Camp campaign debug is required.`); + assert.equal( + campaign.cityEquipmentEquipIntent, + undefined, + `${renderer}: confirming the equipment swap must consume the reload intent.` + ); + const miZhu = campaign.roster.find( + (unit) => unit.id === preparedUnitId + ); + assert(miZhu, `${renderer}: Mi Zhu must remain in the roster.`); + assert.deepEqual(miZhu.equipment.weapon, { + itemId, + level: 1, + exp: 6 + }); + assert.equal(campaign.inventory[itemName] ?? 0, 0); + assert.equal(campaign.inventory[previousItemName], 1); + assert.equal(campaign.cityEquipmentPurchaseCounts[offerId], 1); + assert.deepEqual(campaign.cityEquipmentPreparation, { + version: 1, + selectionRecordId: 'city-equipment-preparation', + cityStayId, + sourceBattleId, + targetBattleId, + offerId, + itemId, + slot: 'weapon', + price: offerPrice, + purchaseNumber: 1, + unitId: preparedUnitId, + previousItemId + }); + if (camp.cityEquipmentContext) { + assert.deepEqual( + camp.cityEquipmentContext.preparationRecord, + campaign.cityEquipmentPreparation + ); + assert.equal(camp.cityEquipmentContext.currentItemId, itemId); + } +} + +async function reloadAndContinueCamp(page) { + await page.reload({ + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForDebugApi(page); + await page.waitForFunction( + () => + window.__HEROS_DEBUG__?.activeScenes?.().includes( + 'TitleScene' + ), + undefined, + { timeout: 90000 } + ); + await page.waitForTimeout(380); + await page.keyboard.press('Enter'); + await page.waitForFunction( + () => { + const camp = window.__HEROS_DEBUG__?.camp?.(); + return ( + window.__HEROS_DEBUG__?.activeScenes?.().includes( + 'CampScene' + ) && + camp?.scene === 'CampScene' && + camp?.campaign?.cityEquipmentPreparation?.offerId === + 'city-xuzhou-iron-spear' + ); + }, + undefined, + { timeout: 90000 } + ); +} + +async function reloadAndContinueEquipmentContext(page) { + await page.reload({ + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForDebugApi(page); + await page.waitForFunction( + () => + window.__HEROS_DEBUG__?.activeScenes?.().includes( + 'TitleScene' + ), + undefined, + { timeout: 90000 } + ); + await page.waitForTimeout(380); + await page.keyboard.press('Enter'); + await waitForEquipmentContext(page); +} + +async function openSortieWithRealClick(page) { + await page.waitForFunction( + () => { + const command = + window.__HEROS_DEBUG__?.camp?.()?.sortieCommand; + return command?.interactive === true && command?.bounds; + }, + undefined, + { timeout: 30000 } + ); + const camp = await readCamp(page); + assert.equal( + camp.sortieCommand?.interactive, + true, + 'The Camp sortie command must be interactive.' + ); + assertBoundsInsideViewport( + camp.sortieCommand?.bounds, + 'Camp sortie command' + ); + await clickSceneBounds( + page, + 'CampScene', + camp.sortieCommand.bounds + ); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp?.()?.sortieVisible === true + ); +} + +async function waitForSortiePreparation(page, step) { + await page.waitForFunction( + (expectedStep) => { + const camp = window.__HEROS_DEBUG__?.camp?.(); + return ( + camp?.sortieVisible === true && + camp?.sortiePrepStep === expectedStep && + camp?.sortiePrimaryAction?.interactive === true && + camp?.sortieCityEquipmentPreparation?.visible === true + ); + }, + step, + { timeout: 30000 } + ); + return readCamp(page); +} + +function assertSortiePreparationActive(camp, renderer) { + const status = camp.sortieCityEquipmentPreparation; + assert(status, `${renderer}: sortie preparation status is required.`); + assert.equal(camp.nextSortieBattleId, targetBattleId); + assert.equal(status.selectionRecordId, 'city-equipment-preparation'); + assert.equal(status.sourceBattleId, sourceBattleId); + assert.equal(status.targetBattleId, targetBattleId); + assert.equal(status.offerId, offerId); + assert.equal(status.itemId, itemId); + assert.equal(status.itemName, itemName); + assert.equal(status.unitId, preparedUnitId); + assert.equal(status.unitName, preparedUnitName); + assert.deepEqual(status.statDelta, { + attack: 2, + defense: 0, + strategy: 0 + }); + assert.equal(status.unitSelected, true); + assert.equal(status.equipmentValid, true); + assert.equal(status.active, true); + assert.equal(status.status, 'active'); + assert.equal(status.line, expectedActiveLine); + assert.equal(status.displayedText, expectedActiveLine); + assertBoundsInsideViewport( + status.bounds, + `${renderer}: active sortie preparation line` + ); +} + +function sortieToggleBounds(camp, unitId) { + const index = + camp.sortiePortraitRosterView.visibleUnitIds.indexOf(unitId); + assert( + index >= 0, + `Expected ${unitId} on the visible sortie roster page: ${JSON.stringify( + camp.sortiePortraitRosterView.visibleUnitIds + )}` + ); + const card = camp.sortiePortraitRosterView.cardBounds[index]; + assert(card?.toggleButtonBounds, `Expected ${unitId} toggle bounds.`); + return card.toggleButtonBounds; +} + +function assertSortiePreparationMissing(camp, renderer) { + const status = camp.sortieCityEquipmentPreparation; + assert(status, `${renderer}: missing sortie status is required.`); + assert.equal(status.unitSelected, false); + assert.equal(status.equipmentValid, true); + assert.equal(status.active, false); + assert.equal(status.status, 'unit-not-selected'); + assert.equal(status.line, expectedMissingLine); + assert.equal(status.displayedText, expectedMissingLine); + assert.equal( + camp.selectedSortieUnitIds.includes(preparedUnitId), + false + ); + assertBoundsInsideViewport( + status.bounds, + `${renderer}: missing sortie preparation line` + ); +} + +async function openPreparedBattle(page) { + await page.evaluate(async (battleId) => { + await window.__HEROS_DEBUG__.goToBattle(battleId); + }, targetBattleId); + await page.waitForFunction( + ({ battleId, unitId, expectedOfferId }) => { + const battle = window.__HEROS_DEBUG__?.battle?.(); + return ( + window.__HEROS_DEBUG__?.activeScenes?.().includes( + 'BattleScene' + ) && + battle?.battleId === battleId && + battle?.phase === 'deployment' && + battle?.deployedAllyIds?.includes(unitId) && + battle?.cityEquipmentPreparation?.active === true && + battle?.cityEquipmentPreparation?.snapshot?.offerId === + expectedOfferId && + battle?.cityEquipmentPreparation?.mechanicsProbe?.offensive && + battle?.battleHud?.deploymentPanel?.startButtonBounds + ); + }, + { + battleId: targetBattleId, + unitId: preparedUnitId, + expectedOfferId: offerId + }, + { timeout: 90000 } + ); + return readBattle(page); +} + +function assertBattleDeploymentPreparation(battle, renderer) { + assert.equal(battle.battleId, targetBattleId); + assert.equal(battle.phase, 'deployment'); + assert( + battle.selectedSortieUnitIds.includes(preparedUnitId), + `${renderer}: Mi Zhu must remain in the effective sortie selection.` + ); + assert( + battle.deployedAllyIds.includes(preparedUnitId), + `${renderer}: Mi Zhu must be instantiated as an allied battle unit.` + ); + + const preparation = battle.cityEquipmentPreparation; + assert(preparation?.active, `${renderer}: battle preparation is missing.`); + assert.equal(preparation.deployed, true); + assertPreparationIdentity( + preparation.snapshot, + `${renderer}: deployment snapshot` + ); + assert.equal(preparation.unitName, preparedUnitName); + assert.equal(preparation.itemName, itemName); + assert.equal(preparation.previousItemName, previousItemName); + assert.deepEqual(preparation.preparedBonuses, { + attack: 3, + defense: 1, + strategy: 0 + }); + assert.deepEqual(preparation.previousBonuses, { + attack: 1, + defense: 1, + strategy: 0 + }); + assert.deepEqual(preparation.statDelta, { + attack: 2, + defense: 0, + strategy: 0 + }); + assert.deepEqual(preparation.runtime, { + deployed: true, + offensiveActions: 0, + defensiveHits: 0, + bonusDamage: 0, + preventedDamage: 0 + }); + assert.equal(preparation.chip.visible, false); + assert.equal(preparation.chip.bounds, null); + + const probe = preparation.mechanicsProbe; + assert(probe, `${renderer}: mechanics probe is required.`); + assert.equal(probe.selectionRecordId, 'city-equipment-preparation'); + assert.deepEqual(probe.preparedBonuses, preparation.preparedBonuses); + assert.deepEqual(probe.baselineBonuses, preparation.previousBonuses); + assert.equal(probe.offensive?.attackerId, preparedUnitId); + assert.equal( + probe.offensive.preparedDamage - + probe.offensive.baselineDamage, + 2, + `${renderer}: iron spear must add exactly two real attack damage over the training sword baseline.` + ); + assert.equal(probe.offensive.bonusDamage, 2); + + const panel = battle.battleHud?.deploymentPanel; + assert(panel, `${renderer}: deployment panel is required.`); + assert( + panel.subtitleText.includes(expectedActiveLine), + `${renderer}: deployment subtitle must surface the exact Xuzhou equipment preparation: "${panel.subtitleText}".` + ); + assertBoundsInsideViewport( + panel.headerBounds, + `${renderer}: deployment header` + ); + assertBoundsInside( + panel.statusBounds, + panel.headerBounds, + `${renderer}: deployment status` + ); + assertBoundsInside( + panel.preparationSummaryBounds, + panel.headerBounds, + `${renderer}: deployment equipment summary` + ); + assertBoundsInsideViewport( + panel.noticeBounds, + `${renderer}: deployment notice` + ); + assertBoundsInside( + panel.noticeTextBounds, + panel.noticeBounds, + `${renderer}: deployment notice text` + ); + assertBoundsInsideViewport( + panel.restoreButtonBounds, + `${renderer}: deployment restore action` + ); + assertBoundsInsideViewport( + panel.startButtonBounds, + `${renderer}: deployment start action` + ); + panel.roleBounds.forEach((bounds, index) => + assertBoundsInsideViewport( + bounds, + `${renderer}: deployment role ${index + 1}` + ) + ); + 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 } + ], + `${renderer}: deployment primary regions` + ); +} + +async function startBattleWithRealClick(page, initialBattle) { + 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 readyBattle = await readBattle(page); + assert.deepEqual( + readyBattle.cityEquipmentPreparation.snapshot, + initialBattle.cityEquipmentPreparation.snapshot, + 'The preparation identity must not change while combat assets load.' + ); + await clickSceneBounds( + page, + 'BattleScene', + readyBattle.battleHud.deploymentPanel.startButtonBounds + ); + await page.waitForFunction( + () => { + const battle = window.__HEROS_DEBUG__?.battle?.(); + return ( + battle?.phase === 'idle' && + battle?.battleHud?.deploymentPanel === null && + battle?.cityEquipmentPreparation?.chip?.visible === true + ); + }, + undefined, + { timeout: 90000 } + ); + return readBattle(page); +} + +function assertBattleHudPreparation(battle, renderer) { + const preparation = battle.cityEquipmentPreparation; + assert(preparation?.active, `${renderer}: active battle preparation is required.`); + assert.equal(preparation.deployed, true); + assertPreparationIdentity( + preparation.snapshot, + `${renderer}: active battle snapshot` + ); + assert.equal(preparation.chip.visible, true); + assert.equal(preparation.chip.text, expectedActiveLine); + assertBoundsInsideViewport( + preparation.chip.bounds, + `${renderer}: active battle preparation chip` + ); + assert.equal( + battle.battleHud?.deploymentPanel, + null, + `${renderer}: deployment panel must be gone after its real start action.` + ); +} + +async function exerciseBattleContributionAndSave(page) { + return page.evaluate( + ({ + unitId, + expectedBattleId + }) => { + const scene = + window.__HEROS_GAME__?.scene.getScene('BattleScene'); + if ( + !scene || + typeof scene.resolveAttack !== 'function' || + typeof scene.createBattleSaveState !== 'function' || + typeof scene.applyBattleSaveState !== 'function' || + typeof scene.resetCityEquipmentContributionRuntime !== + 'function' + ) { + throw new Error( + 'BattleScene equipment contribution/save hooks are unavailable.' + ); + } + const before = + window.__HEROS_DEBUG__.battle() + .cityEquipmentPreparation; + const enemyId = + before?.mechanicsProbe?.offensive?.defenderId; + const attacker = scene.debugUnitById(unitId); + const defender = scene.debugUnitById(enemyId); + if (!attacker || !defender) { + throw new Error( + `Prepared combat pair is unavailable: ${unitId} -> ${enemyId}.` + ); + } + + const url = new URL(window.location.href); + url.searchParams.set('debugForceHit', '1'); + window.history.replaceState( + window.history.state, + '', + `${url.pathname}${url.search}${url.hash}` + ); + const originalRollPercent = scene.rollPercent; + let attack; + try { + scene.rollPercent = () => false; + attack = scene.resolveAttack(attacker, defender); + } finally { + scene.rollPercent = originalRollPercent; + } + scene.renderTacticalInitiativeChip(); + const after = + window.__HEROS_DEBUG__.battle() + .cityEquipmentPreparation; + const fullState = scene.createBattleSaveState(); + if ( + fullState.battleId !== expectedBattleId || + !fullState.cityEquipmentContribution + ) { + throw new Error( + 'Battle save is missing the deployed Xuzhou equipment contribution.' + ); + } + const saveSnapshot = structuredClone( + fullState.cityEquipmentContribution + ); + const detached = + fullState.cityEquipmentContribution !== after.result; + + scene.resetCityEquipmentContributionRuntime(); + scene.renderTacticalInitiativeChip(); + const reset = + window.__HEROS_DEBUG__.battle() + .cityEquipmentPreparation; + scene.applyBattleSaveState(structuredClone(fullState)); + scene.renderTacticalInitiativeChip(); + const restored = + window.__HEROS_DEBUG__.battle() + .cityEquipmentPreparation; + + return { + before, + attack: { + attackerId: attack.attacker.id, + defenderId: attack.defender.id, + hit: attack.hit, + critical: attack.critical, + damage: attack.damage, + previousDefenderHp: attack.previousDefenderHp + }, + after, + saveSnapshot, + reset, + restored, + detached + }; + }, + { + unitId: preparedUnitId, + expectedBattleId: targetBattleId + } + ); +} + +function assertBattleContributionRoundTrip(roundTrip, renderer) { + assert.equal(roundTrip.attack.attackerId, preparedUnitId); + assert.equal(roundTrip.attack.hit, true); + assert.equal(roundTrip.attack.critical, false); + assert(roundTrip.attack.damage > 0); + assert( + roundTrip.attack.damage <= roundTrip.attack.previousDefenderHp + ); + assert.deepEqual(roundTrip.before.runtime, { + deployed: true, + offensiveActions: 0, + defensiveHits: 0, + bonusDamage: 0, + preventedDamage: 0 + }); + assert.deepEqual(roundTrip.after.runtime, { + deployed: true, + offensiveActions: 1, + defensiveHits: 0, + bonusDamage: 2, + preventedDamage: 0 + }); + assertContributionSnapshot( + roundTrip.after.result, + `${renderer}: live contribution` + ); + assertContributionSnapshot( + roundTrip.saveSnapshot, + `${renderer}: battle save contribution` + ); + assert.equal( + roundTrip.detached, + true, + `${renderer}: battle save contribution must be detached from the debug result snapshot.` + ); + assert.deepEqual(roundTrip.reset.runtime, { + deployed: false, + offensiveActions: 0, + defensiveHits: 0, + bonusDamage: 0, + preventedDamage: 0 + }); + assert.deepEqual( + roundTrip.restored.runtime, + roundTrip.after.runtime, + `${renderer}: create/apply must restore the exact contribution counters.` + ); + assert.deepEqual( + roundTrip.restored.result, + roundTrip.saveSnapshot, + `${renderer}: create/apply must restore the exact saved contribution snapshot.` + ); + assert.equal(roundTrip.restored.chip.visible, true); + assertBoundsInsideViewport( + roundTrip.restored.chip.bounds, + `${renderer}: restored contribution chip` + ); +} + +async function forceVictoryAndReadContribution(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?.cityEquipmentPreparation?.result + ?.offensiveActions === 1 + ); + }, + targetBattleId, + { timeout: 90000 } + ); + return page.evaluate(async (battleId) => { + const battle = window.__HEROS_DEBUG__.battle(); + const scene = + window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const visibleResultTexts = (scene?.resultObjects ?? []) + .filter( + (object) => + object?.type === 'Text' && + object.active && + object.visible && + typeof object.text === 'string' + ) + .map((object) => object.text); + const campaignModule = await import( + '/heros_web/src/game/state/campaignState.ts' + ); + const campaign = campaignModule.loadCampaignState(); + return { + battle, + visibleResultTexts, + campaign: { + reportBattleId: + campaign.firstBattleReport?.battleId ?? null, + reportContribution: campaign.firstBattleReport + ?.cityEquipmentContribution + ? structuredClone( + campaign.firstBattleReport + .cityEquipmentContribution + ) + : null, + settlementBattleId: + campaign.battleHistory?.[battleId]?.battleId ?? + null, + settlementContribution: campaign.battleHistory?.[ + battleId + ]?.cityEquipmentContribution + ? structuredClone( + campaign.battleHistory[battleId] + .cityEquipmentContribution + ) + : null + } + }; + }, targetBattleId); +} + +function assertBattleResultContribution( + result, + expectedContribution, + renderer +) { + const battle = result.battle; + assert.equal(battle.battleOutcome, 'victory'); + assert.equal(battle.resultVisible, true); + assert.equal(battle.resultSettlement.status, 'complete'); + assertContributionSnapshot( + battle.cityEquipmentPreparation.result, + `${renderer}: result contribution` + ); + assert.deepEqual( + battle.cityEquipmentPreparation.result, + expectedContribution, + `${renderer}: result must preserve the restored live contribution.` + ); + assert( + battle.cityEquipmentPreparation.resultText.includes( + '공격 사용 1회' + ), + `${renderer}: result feedback must report one actual Mi Zhu attack: "${battle.cityEquipmentPreparation.resultText}".` + ); + assert( + battle.cityEquipmentPreparation.resultText.includes( + '추가 피해 +2' + ), + `${renderer}: result feedback must report the exact +2 damage contribution.` + ); + assert( + result.visibleResultTexts.some((text) => + text.includes( + battle.cityEquipmentPreparation.resultText + ) + ), + `${renderer}: visible result subtitle must contain the persisted contribution feedback: ${JSON.stringify( + result.visibleResultTexts + )}` + ); + battle.resultActions.forEach((action) => { + assertBoundsInsideViewport( + action.bounds, + `${renderer}: result action ${action.kind}` + ); + }); + assertNoOverlappingBounds( + battle.resultActions.map((action) => ({ + id: action.kind, + bounds: action.bounds + })), + `${renderer}: result actions` + ); + + assert.equal( + result.campaign.reportBattleId, + targetBattleId + ); + assert.equal( + result.campaign.settlementBattleId, + targetBattleId + ); + assertContributionSnapshot( + result.campaign.reportContribution, + `${renderer}: campaign report contribution` + ); + assertContributionSnapshot( + result.campaign.settlementContribution, + `${renderer}: campaign settlement contribution` + ); + assert.deepEqual( + result.campaign.reportContribution, + expectedContribution, + `${renderer}: campaign report must persist the exact battle contribution.` + ); + assert.deepEqual( + result.campaign.settlementContribution, + expectedContribution, + `${renderer}: campaign settlement must persist the exact battle contribution.` + ); +} + +function assertPreparationIdentity(snapshot, label) { + assert(snapshot, `${label}: snapshot is required.`); + assert.equal(snapshot.version, 1); + assert.equal( + snapshot.selectionRecordId, + 'city-equipment-preparation' + ); + assert.equal(snapshot.cityStayId, cityStayId); + assert.equal(snapshot.sourceBattleId, sourceBattleId); + assert.equal(snapshot.targetBattleId, targetBattleId); + assert.equal(snapshot.offerId, offerId); + assert.equal(snapshot.itemId, itemId); + assert.equal(snapshot.slot, 'weapon'); + assert.equal(snapshot.price, offerPrice); + assert.equal(snapshot.purchaseNumber, 1); + assert.equal(snapshot.unitId, preparedUnitId); + assert.equal(snapshot.previousItemId, previousItemId); +} + +function assertContributionSnapshot(snapshot, label) { + assertPreparationIdentity(snapshot, label); + assert.equal(snapshot.deployed, true); + assert.equal(snapshot.offensiveActions, 1); + assert.equal(snapshot.defensiveHits, 0); + assert.equal(snapshot.bonusDamage, 2); + assert.equal(snapshot.preventedDamage, 0); +} + +async function readCity(page) { + return page.evaluate( + () => window.__HEROS_DEBUG__?.cityStay?.() + ); +} + +async function readCamp(page) { + return page.evaluate(() => window.__HEROS_DEBUG__?.camp?.()); +} + +async function readBattle(page) { + return page.evaluate(() => window.__HEROS_DEBUG__?.battle?.()); +} + +async function readSceneCampaign(page, sceneKey) { + return page.evaluate((key) => { + const scene = window.__HEROS_DEBUG__?.scene(key); + if (!scene?.campaign) { + throw new Error(`${key} campaign is unavailable.`); + } + return structuredClone(scene.campaign); + }, sceneKey); +} + +function findOffer(city, requestedOfferId) { + const offer = city.shop.offers.find( + (candidate) => candidate.id === requestedOfferId + ); + assert(offer, `Expected market offer ${requestedOfferId}.`); + return offer; +} + +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 armCityEquipmentCtaProbe(page, bounds) { + assertFiniteBounds(bounds, 'City equipment CTA probe bounds'); + const point = await page.evaluate((requestedBounds) => { + const scene = + window.__HEROS_GAME__?.scene.getScene('CityStayScene'); + 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 + }; + }, bounds); + assert( + point && Number.isFinite(point.x) && Number.isFinite(point.y), + 'Unable to map the City equipment CTA probe point.' + ); + await page.mouse.move(point.x, point.y); + await page.waitForTimeout(80); + return page.evaluate(({ expectedOfferId, expectedPoint }) => { + const scene = + window.__HEROS_GAME__?.scene.getScene('CityStayScene'); + const view = scene?.shopOfferViews?.find( + (candidate) => candidate.offerId === expectedOfferId + ); + const equipButton = view?.equipButton; + const pointer = scene?.input?.activePointer; + const hits = + scene && pointer && + typeof scene.input.hitTestPointer === 'function' + ? scene.input.hitTestPointer(pointer) + : []; + window.__XUZHOU_EQUIPMENT_CTA_EVENTS__ = []; + if (scene && equipButton) { + equipButton.on('pointerdown', () => { + window.__XUZHOU_EQUIPMENT_CTA_EVENTS__.push({ + kind: 'equip-button' + }); + }); + scene.input.on( + 'gameobjectdown', + (_pointer, gameObject) => { + if (gameObject === equipButton) { + window.__XUZHOU_EQUIPMENT_CTA_EVENTS__.push({ + kind: 'gameobjectdown' + }); + } + } + ); + } + return { + point: expectedPoint, + pointer: pointer + ? { + x: pointer.x, + y: pointer.y, + worldX: pointer.worldX, + worldY: pointer.worldY + } + : null, + equipButtonReady: Boolean( + equipButton?.active && + equipButton?.visible && + equipButton?.input?.enabled + ), + equipButtonHit: hits.includes(equipButton), + hits: hits.map((object) => ({ + type: object.type ?? null, + depth: object.depth ?? null, + text: + typeof object.text === 'string' + ? object.text + : null, + equipButton: object === equipButton + })) + }; + }, { + expectedOfferId: offerId, + expectedPoint: point + }); +} + +async function clickSceneBounds(page, sceneKey, bounds) { + assertFiniteBounds(bounds, `${sceneKey} click bounds`); + const point = await page.evaluate( + ({ requestedSceneKey, requestedBounds }) => { + const scene = + window.__HEROS_GAME__?.scene.getScene(requestedSceneKey); + const canvas = document.querySelector('canvas'); + const canvasBounds = canvas?.getBoundingClientRect(); + if (!scene || !canvasBounds || !requestedBounds) { + 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(240); + 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-equipment-${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/src/game/data/cityEquipmentPreparation.ts b/src/game/data/cityEquipmentPreparation.ts new file mode 100644 index 0000000..b2dd855 --- /dev/null +++ b/src/game/data/cityEquipmentPreparation.ts @@ -0,0 +1,559 @@ +import { + calculateEquipmentBonuses, + equipmentSlots, + itemCatalog, + type EquipmentBonuses, + type EquipmentSet, + type EquipmentSlot, + type ItemDefinition +} from './battleItems'; +import { + cityStayDefinitions, + type CityEquipmentOffer, + type CityStayDefinition, + type CityStayId +} from './cityStays'; +import type { UnitData } from './scenario'; + +export const cityEquipmentPreparationSelectionRecordId = + 'city-equipment-preparation' as const; +export const cityEquipmentEquipIntentRecordId = + 'city-equipment-equip-intent' as const; + +export type CityEquipmentPurchaseCounts = Record; + +export type CityEquipmentEquipIntent = { + version: 1; + intentRecordId: typeof cityEquipmentEquipIntentRecordId; + cityStayId: CityStayId; + sourceBattleId: string; + targetBattleId: string; + offerId: string; + itemId: string; + slot: EquipmentSlot; + price: number; + purchaseNumber: number; + unitId?: string; +}; + +export type CityEquipmentPreparationSnapshot = { + version: 1; + selectionRecordId: typeof cityEquipmentPreparationSelectionRecordId; + cityStayId: CityStayId; + sourceBattleId: string; + targetBattleId: string; + offerId: string; + itemId: string; + slot: EquipmentSlot; + price: number; + purchaseNumber: number; + unitId: string; + previousItemId: string; +}; + +export type CityEquipmentContributionSnapshot = + CityEquipmentPreparationSnapshot & { + deployed: boolean; + offensiveActions: number; + defensiveHits: number; + bonusDamage: number; + preventedDamage: number; + }; + +export type CityEquipmentPreparationStatDelta = EquipmentBonuses; + +export type CityEquipmentPreparationRosterUnit = Pick< + UnitData, + 'id' | 'name' | 'faction' | 'equipment' +>; + +export type CanonicalCityEquipmentOffer = { + cityStay: CityStayDefinition; + offer: CityEquipmentOffer; + item: ItemDefinition; +}; + +export type CityEquipmentPreparationNormalizationOptions = { + roster: readonly CityEquipmentPreparationRosterUnit[]; + purchaseCounts: unknown; +}; + +export type CreateCityEquipmentPreparationOptions = + CityEquipmentPreparationNormalizationOptions & { + cityStayId: unknown; + offerId: unknown; + purchaseNumber: unknown; + unitId: unknown; + previousItemId: unknown; + }; + +export type ResolveCityEquipmentPreparationOptions = + CityEquipmentPreparationNormalizationOptions & { + battleId: unknown; + }; + +export type ResolvedCityEquipmentPreparation = { + snapshot: CityEquipmentPreparationSnapshot; + cityStay: CityStayDefinition; + offer: CityEquipmentOffer; + item: ItemDefinition; + previousItem: ItemDefinition; + unit: CityEquipmentPreparationRosterUnit; + equipmentLevel: number; + equipmentExp: number; + preparedBonuses: EquipmentBonuses; + previousBonuses: EquipmentBonuses; + statDelta: CityEquipmentPreparationStatDelta; +}; + +const maxCityEquipmentPurchaseCount = 99; +const maxCityEquipmentContributionCounter = 999999; + +const canonicalOffers = cityStayDefinitions.flatMap((cityStay) => + cityStay.equipmentOffers.map((offer) => ({ + cityStay, + offer, + item: itemCatalog[offer.itemId] + })) +); + +const canonicalOfferById = new Map( + canonicalOffers.map((entry) => [entry.offer.id, entry]) +); + +export function findCanonicalCityEquipmentOffer( + cityStayId: unknown, + offerId: unknown +): CanonicalCityEquipmentOffer | undefined { + if (typeof cityStayId !== 'string' || typeof offerId !== 'string') { + return undefined; + } + const entry = canonicalOfferById.get(offerId); + if ( + !entry || + entry.cityStay.id !== cityStayId || + !entry.item || + entry.item.id !== entry.offer.itemId || + entry.item.slot !== entry.offer.slot || + entry.item.rank !== 'common' + ) { + return undefined; + } + return entry; +} + +export function normalizeCityEquipmentPurchaseCounts( + value: unknown +): CityEquipmentPurchaseCounts { + if (!isRecord(value)) { + return {}; + } + return Object.entries(value).reduce( + (counts, [offerId, rawCount]) => { + if (!canonicalOfferById.has(offerId)) { + return counts; + } + const count = cappedNonNegativeInteger( + rawCount, + maxCityEquipmentPurchaseCount + ); + if (count > 0) { + counts[offerId] = count; + } + return counts; + }, + {} + ); +} + +export function createCityEquipmentEquipIntent(options: { + cityStayId: unknown; + offerId: unknown; + purchaseNumber: unknown; + purchaseCounts: unknown; + unitId?: unknown; + roster?: readonly CityEquipmentPreparationRosterUnit[]; +}): CityEquipmentEquipIntent | undefined { + const canonical = findCanonicalCityEquipmentOffer( + options.cityStayId, + options.offerId + ); + if (!canonical) { + return undefined; + } + return normalizeCityEquipmentEquipIntent( + { + version: 1, + intentRecordId: cityEquipmentEquipIntentRecordId, + cityStayId: canonical.cityStay.id, + sourceBattleId: canonical.cityStay.afterBattleId, + targetBattleId: canonical.cityStay.nextBattleId, + offerId: canonical.offer.id, + itemId: canonical.offer.itemId, + slot: canonical.offer.slot, + price: canonical.offer.price, + purchaseNumber: options.purchaseNumber, + ...(typeof options.unitId === 'string' + ? { unitId: options.unitId } + : {}) + }, + options.purchaseCounts, + options.roster + ); +} + +export function normalizeCityEquipmentEquipIntent( + value: unknown, + purchaseCountsValue: unknown, + roster?: readonly CityEquipmentPreparationRosterUnit[] +): CityEquipmentEquipIntent | undefined { + if ( + !isRecord(value) || + value.version !== 1 || + value.intentRecordId !== cityEquipmentEquipIntentRecordId + ) { + return undefined; + } + const canonical = findCanonicalCityEquipmentOffer( + value.cityStayId, + value.offerId + ); + const purchaseNumber = strictPositiveInteger( + value.purchaseNumber, + maxCityEquipmentPurchaseCount + ); + const purchaseCounts = normalizeCityEquipmentPurchaseCounts( + purchaseCountsValue + ); + const providedUnitId = + typeof value.unitId === 'string' + ? value.unitId.trim() + : undefined; + const unitId = + providedUnitId && + (!roster || + roster.some( + (unit) => + unit.id === providedUnitId && + unit.faction === 'ally' + )) + ? providedUnitId + : undefined; + if ( + !canonical || + !purchaseNumber || + value.sourceBattleId !== canonical.cityStay.afterBattleId || + value.targetBattleId !== canonical.cityStay.nextBattleId || + value.itemId !== canonical.offer.itemId || + value.slot !== canonical.offer.slot || + value.price !== canonical.offer.price || + (purchaseCounts[canonical.offer.id] ?? 0) < purchaseNumber || + (value.unitId !== undefined && !unitId) + ) { + return undefined; + } + return { + version: 1, + intentRecordId: cityEquipmentEquipIntentRecordId, + cityStayId: canonical.cityStay.id, + sourceBattleId: canonical.cityStay.afterBattleId, + targetBattleId: canonical.cityStay.nextBattleId, + offerId: canonical.offer.id, + itemId: canonical.item.id, + slot: canonical.offer.slot, + price: canonical.offer.price, + purchaseNumber, + ...(unitId ? { unitId } : {}) + }; +} + +export function createCityEquipmentPreparationSnapshot( + options: CreateCityEquipmentPreparationOptions +): CityEquipmentPreparationSnapshot | undefined { + const canonical = findCanonicalCityEquipmentOffer( + options.cityStayId, + options.offerId + ); + if (!canonical) { + return undefined; + } + return normalizeCityEquipmentPreparationSnapshot( + { + version: 1, + selectionRecordId: cityEquipmentPreparationSelectionRecordId, + cityStayId: canonical.cityStay.id, + sourceBattleId: canonical.cityStay.afterBattleId, + targetBattleId: canonical.cityStay.nextBattleId, + offerId: canonical.offer.id, + itemId: canonical.offer.itemId, + slot: canonical.offer.slot, + price: canonical.offer.price, + purchaseNumber: options.purchaseNumber, + unitId: options.unitId, + previousItemId: options.previousItemId + }, + options + ); +} + +export function normalizeCityEquipmentPreparationSnapshot( + value: unknown, + options: CityEquipmentPreparationNormalizationOptions +): CityEquipmentPreparationSnapshot | undefined { + const identity = normalizePreparationIdentity(value); + if (!identity) { + return undefined; + } + const purchaseCounts = normalizeCityEquipmentPurchaseCounts( + options.purchaseCounts + ); + if ( + (purchaseCounts[identity.offerId] ?? 0) < + identity.purchaseNumber + ) { + return undefined; + } + const unit = findPreparedRosterUnit(options.roster, identity); + return unit ? identity : undefined; +} + +export function resolveCityEquipmentPreparation( + value: unknown, + options: ResolveCityEquipmentPreparationOptions +): ResolvedCityEquipmentPreparation | undefined { + const snapshot = normalizeCityEquipmentPreparationSnapshot(value, options); + if (!snapshot || options.battleId !== snapshot.targetBattleId) { + return undefined; + } + const canonical = findCanonicalCityEquipmentOffer( + snapshot.cityStayId, + snapshot.offerId + ); + const unit = findPreparedRosterUnit(options.roster, snapshot); + const previousItem = itemCatalog[snapshot.previousItemId]; + if (!canonical || !unit || !previousItem) { + return undefined; + } + + const preparedEquipment = cloneEquipmentSet(unit.equipment); + const equippedState = preparedEquipment[snapshot.slot]; + const previousEquipment = cloneEquipmentSet(preparedEquipment); + previousEquipment[snapshot.slot] = { + itemId: previousItem.id, + level: equippedState.level, + exp: equippedState.exp + }; + const preparedBonuses = calculateEquipmentBonuses(preparedEquipment); + const previousBonuses = calculateEquipmentBonuses(previousEquipment); + const statDelta = positiveStatDelta(preparedBonuses, previousBonuses); + if (!hasPositiveStatDelta(statDelta)) { + return undefined; + } + + return { + snapshot: { ...snapshot }, + cityStay: canonical.cityStay, + offer: canonical.offer, + item: canonical.item, + previousItem, + unit: { + id: unit.id, + name: unit.name, + faction: unit.faction, + equipment: cloneEquipmentSet(unit.equipment) + }, + equipmentLevel: equippedState.level, + equipmentExp: equippedState.exp, + preparedBonuses, + previousBonuses, + statDelta + }; +} + +export function normalizeCityEquipmentContributionSnapshot( + value: unknown, + expectedBattleId?: string, + roster?: readonly CityEquipmentPreparationRosterUnit[] +): CityEquipmentContributionSnapshot | undefined { + const identity = normalizePreparationIdentity(value); + if ( + !identity || + (expectedBattleId !== undefined && + expectedBattleId !== identity.targetBattleId) || + !isRecord(value) || + typeof value.deployed !== 'boolean' + ) { + return undefined; + } + const deployed = value.deployed; + if ( + deployed && + roster !== undefined && + !findPreparedRosterUnit(roster, identity) + ) { + return undefined; + } + const offensiveActions = deployed + ? cappedNonNegativeInteger( + value.offensiveActions, + maxCityEquipmentContributionCounter + ) + : 0; + const defensiveHits = deployed + ? cappedNonNegativeInteger( + value.defensiveHits, + maxCityEquipmentContributionCounter + ) + : 0; + return { + ...identity, + deployed, + offensiveActions, + defensiveHits, + bonusDamage: deployed && offensiveActions > 0 + ? cappedNonNegativeInteger( + value.bonusDamage, + maxCityEquipmentContributionCounter + ) + : 0, + preventedDamage: deployed && defensiveHits > 0 + ? cappedNonNegativeInteger( + value.preventedDamage, + maxCityEquipmentContributionCounter + ) + : 0 + }; +} + +export function cloneCityEquipmentContributionSnapshot( + snapshot: CityEquipmentContributionSnapshot +): CityEquipmentContributionSnapshot { + return { ...snapshot }; +} + +function normalizePreparationIdentity( + value: unknown +): CityEquipmentPreparationSnapshot | undefined { + if ( + !isRecord(value) || + value.version !== 1 || + value.selectionRecordId !== + cityEquipmentPreparationSelectionRecordId + ) { + return undefined; + } + const canonical = findCanonicalCityEquipmentOffer( + value.cityStayId, + value.offerId + ); + if ( + !canonical || + value.sourceBattleId !== canonical.cityStay.afterBattleId || + value.targetBattleId !== canonical.cityStay.nextBattleId || + value.itemId !== canonical.offer.itemId || + value.slot !== canonical.offer.slot || + value.price !== canonical.offer.price + ) { + return undefined; + } + const purchaseNumber = strictPositiveInteger( + value.purchaseNumber, + maxCityEquipmentPurchaseCount + ); + const unitId = + typeof value.unitId === 'string' ? value.unitId.trim() : ''; + const previousItem = + typeof value.previousItemId === 'string' + ? itemCatalog[value.previousItemId] + : undefined; + if ( + !purchaseNumber || + !unitId || + !previousItem || + previousItem.slot !== canonical.offer.slot + ) { + return undefined; + } + const statDelta = positiveStatDelta( + itemBonuses(canonical.item), + itemBonuses(previousItem) + ); + if (!hasPositiveStatDelta(statDelta)) { + return undefined; + } + return { + version: 1, + selectionRecordId: cityEquipmentPreparationSelectionRecordId, + cityStayId: canonical.cityStay.id, + sourceBattleId: canonical.cityStay.afterBattleId, + targetBattleId: canonical.cityStay.nextBattleId, + offerId: canonical.offer.id, + itemId: canonical.offer.itemId, + slot: canonical.offer.slot, + price: canonical.offer.price, + purchaseNumber, + unitId, + previousItemId: previousItem.id + }; +} + +function findPreparedRosterUnit( + roster: readonly CityEquipmentPreparationRosterUnit[], + snapshot: CityEquipmentPreparationSnapshot +) { + return roster.find( + (unit) => + unit.id === snapshot.unitId && + unit.faction === 'ally' && + unit.equipment?.[snapshot.slot]?.itemId === snapshot.itemId + ); +} + +function itemBonuses(item: ItemDefinition): EquipmentBonuses { + return { + attack: Math.max(0, item.attackBonus ?? 0), + defense: Math.max(0, item.defenseBonus ?? 0), + strategy: Math.max(0, item.strategyBonus ?? 0) + }; +} + +function positiveStatDelta( + prepared: EquipmentBonuses, + previous: EquipmentBonuses +): CityEquipmentPreparationStatDelta { + return { + attack: Math.max(0, prepared.attack - previous.attack), + defense: Math.max(0, prepared.defense - previous.defense), + strategy: Math.max(0, prepared.strategy - previous.strategy) + }; +} + +function hasPositiveStatDelta(delta: CityEquipmentPreparationStatDelta) { + return delta.attack > 0 || delta.defense > 0 || delta.strategy > 0; +} + +function cloneEquipmentSet(equipment: EquipmentSet): EquipmentSet { + return equipmentSlots.reduce((clone, slot) => { + clone[slot] = { ...equipment[slot] }; + return clone; + }, {} as EquipmentSet); +} + +function strictPositiveInteger(value: unknown, max: number) { + const numeric = Number(value); + return Number.isInteger(numeric) && numeric > 0 && numeric <= max + ? numeric + : undefined; +} + +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 isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 453aa95..cab66ab 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -30,6 +30,13 @@ import { } from '../data/campaignPresentationProfiles'; import { getSortieFlow } from '../data/campaignFlow'; import { findCityStayBeforeBattle } from '../data/cityStays'; +import { + cityEquipmentPreparationSelectionRecordId, + cloneCityEquipmentContributionSnapshot, + resolveCityEquipmentPreparation, + type CityEquipmentContributionSnapshot, + type ResolvedCityEquipmentPreparation +} from '../data/cityEquipmentPreparation'; import { prologueMilitiaCampPreparationPayoffs } from '../data/prologueMilitiaCamp'; import { enemyIntentOpeningGuide, @@ -184,6 +191,7 @@ import { } from '../state/campaignState'; import { normalizeBattleSaveSlot, + type BattleSaveCityEquipmentContributionState, type BattleSaveCoreResonanceStats, type BattleSaveEventPriority, type BattleSavePendingEvent, @@ -1736,6 +1744,15 @@ type ThirdCampPreparationRuntimeState = { preventedDamage: number; }; +type CityEquipmentContributionRuntime = Pick< + CityEquipmentContributionSnapshot, + | 'deployed' + | 'offensiveActions' + | 'defensiveHits' + | 'bonusDamage' + | 'preventedDamage' +>; + type ThirdCampPreparationCombatEffect = { hitBonus: number; damageReduction: number; @@ -3938,6 +3955,15 @@ export class BattleScene extends Phaser.Scene { private firstPursuitScoutMemory?: FirstPursuitScoutMemory; private secondBattleReliefMemory?: SecondBattleReliefMemory; private thirdCampPreparationMemory?: ThirdCampPreparationMemory; + private cityEquipmentPreparation?: ResolvedCityEquipmentPreparation; + private cityEquipmentContributionRuntime: CityEquipmentContributionRuntime = { + deployed: false, + offensiveActions: 0, + defensiveHits: 0, + bonusDamage: 0, + preventedDamage: 0 + }; + private cityEquipmentPreparationChipBounds?: HudBounds; private thirdCampPreparationRuntime: ThirdCampPreparationRuntimeState = { informationConsumed: false, equipmentConsumed: false, @@ -4069,6 +4095,16 @@ export class BattleScene extends Phaser.Scene { campaign, battleId: battleScenario.id }); + this.cityEquipmentPreparation = resolveCityEquipmentPreparation( + campaign.cityEquipmentPreparation, + { + battleId: battleScenario.id, + roster: campaign.roster, + purchaseCounts: campaign.cityEquipmentPurchaseCounts + } + ); + this.resetCityEquipmentContributionRuntime(); + this.cityEquipmentPreparationChipBounds = undefined; this.resetThirdCampPreparationRuntime(); this.thirdCampPreparationChipBounds = undefined; this.prologueVolunteerReassured = campaign.completedTutorialIds.includes( @@ -4142,6 +4178,7 @@ export class BattleScene extends Phaser.Scene { this.resetSortieOrderHudState(); const campaign = getCampaignState(); this.resetBattleData(campaign); + this.syncCityEquipmentPreparationDeployment(); if ( campaign.step === 'new' || campaign.step === 'prologue' || @@ -6267,6 +6304,16 @@ export class BattleScene extends Phaser.Scene { this.tacticalInitiativeChipObjects.forEach((object) => object.destroy()); this.tacticalInitiativeChipObjects = []; this.thirdCampPreparationChipBounds = undefined; + this.cityEquipmentPreparationChipBounds = undefined; + if ( + this.cityEquipmentPreparation && + this.phase !== 'deployment' && + this.phase !== 'resolved' && + !this.battleOutcome + ) { + this.renderCityEquipmentPreparationChip(); + return; + } if ( this.thirdCampPreparationMemory && this.phase !== 'deployment' && @@ -6414,6 +6461,70 @@ export class BattleScene extends Phaser.Scene { this.tacticalInitiativeChipObjects.push(background, text); } + private renderCityEquipmentPreparationChip() { + if (!this.cityEquipmentPreparation) { + return; + } + const { panelX, panelY, panelWidth } = this.layout; + const width = Math.min( + panelWidth - this.battleUiLength(48), + this.battleUiLength(310) + ); + 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; + const tone = !runtime.deployed + ? 0x647485 + : contributed + ? palette.green + : palette.gold; + const background = this.add.rectangle( + left, + top, + width, + height, + runtime.deployed ? 0x2d2619 : 0x17232e, + 0.98 + ); + background.setOrigin(0); + background.setDepth(16); + background.setStrokeStyle( + this.battleUiLength(1), + tone, + runtime.deployed ? 0.92 : 0.62 + ); + const text = this.add.text( + left + width / 2, + top + height / 2 - this.battleUiLength(1), + this.cityEquipmentPreparationDisplayLine(), + { + fontFamily: + '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: this.battleUiFontSize(10), + color: !runtime.deployed + ? '#9fb0bf' + : contributed + ? '#a8ffd0' + : '#fff2b8', + fontStyle: '700' + } + ); + text.setOrigin(0.5); + text.setDepth(17); + const bounds = background.getBounds(); + this.cityEquipmentPreparationChipBounds = { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height + }; + this.tacticalInitiativeChipObjects.push(background, text); + } + private thirdCampPreparationChipLabel( presentation = this.thirdCampPreparationPresentation('turn') @@ -8756,6 +8867,11 @@ export class BattleScene extends Phaser.Scene { if (this.firstPursuitScoutMemory) { return `${this.firstPursuitScoutMemory.campSummaryLine}. 간옹이 표시한 적의 첫 움직임을 보며 전열을 조정하세요.`; } + if (this.cityEquipmentPreparation) { + return this.cityEquipmentContributionRuntime.deployed + ? `${this.cityEquipmentPreparationDisplayLine()} 효과는 기존 장비 능력치에 이미 반영되어 있습니다.` + : `${this.cityEquipmentPreparationDisplayLine()} 준비 무장을 출전시키지 않아 이번 전투에는 반영되지 않습니다.`; + } if (this.thirdCampPreparationMemory) { return `${this.thirdCampPreparationMemory.deploymentLine}. 효과는 3턴이 끝나면 종료됩니다.`; } @@ -8796,6 +8912,15 @@ export class BattleScene extends Phaser.Scene { })(); return `${this.firstPursuitScoutMemory.campSummaryLine}\n${strategySummary}`; } + if (this.cityEquipmentPreparation) { + const strategySummary = this.launchSortieRecommendation + ? `선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}` + : (() => { + const order = sortieOrderDefinition(this.launchSortieOrderId); + return `선택 군령 · ${order.label} · ${order.summary}`; + })(); + return `${this.cityEquipmentPreparationDisplayLine()}\n${strategySummary}`; + } if (this.secondBattleReliefMemory) { const strategySummary = this.launchSortieRecommendation ? `선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}` @@ -9261,7 +9386,9 @@ export class BattleScene extends Phaser.Scene { )); statusText.setOrigin(0.5); const preparationSummaryVisible = - this.firstBattlePreparation.eligible && this.firstBattlePreparationCompletedCount() > 0; + (this.firstBattlePreparation.eligible && + this.firstBattlePreparationCompletedCount() > 0) || + Boolean(this.cityEquipmentPreparation); 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', @@ -14431,6 +14558,8 @@ export class BattleScene extends Phaser.Scene { ? cloneCampaignSortieRecommendationSnapshot(this.launchSortieRecommendation) : undefined, sortieCooperation: this.dominantSortieCooperationSnapshot(), + cityEquipmentContribution: + this.cityEquipmentContributionSnapshot(), completedCampDialogues: [], completedCampVisits: [], createdAt: new Date().toISOString() @@ -14450,6 +14579,11 @@ export class BattleScene extends Phaser.Scene { : `${battleScenario.title} 승리. 획득 보상과 장수 성장을 정산합니다.`; const preparation = this.thirdCampPreparationPresentation('result'); + const cityEquipmentContribution = + this.cityEquipmentContributionResultText(); + if (cityEquipmentContribution) { + return `${base}\n${cityEquipmentContribution}`; + } return preparation ? `${base}\n군영 준비 결과 · ${this.thirdCampPreparationResultText(preparation)}` : base; @@ -16977,6 +17111,14 @@ export class BattleScene extends Phaser.Scene { this.bondChainJudgementLast = undefined; } const preview = this.combatPreview(attacker, defender, action, usable, isCounter); + const cityEquipmentBaselinePreview = + this.cityEquipmentBaselinePreview( + attacker, + defender, + action, + usable, + isCounter + ); const withoutPreparationPreview = this.combatPreview( attacker, defender, @@ -17052,6 +17194,14 @@ export class BattleScene extends Phaser.Scene { hit, preparationPreventedDamageAmount ); + this.recordCityEquipmentContribution( + preview, + cityEquipmentBaselinePreview, + hit, + critical, + previousDefenderHp, + damage + ); const attackerGrowth = this.awardEquipmentExp(attacker, action === 'attack' ? 'weapon' : 'accessory', this.attackerEquipmentExpGain(attacker, action)); const armorGrowth = this.awardEquipmentExp(defender, 'armor', this.defenderArmorExpGain(defender, hit, preview)); const characterGrowth = this.awardCharacterExp(attacker, this.characterExpGain(preview, damage, hit, critical)); @@ -18274,6 +18424,260 @@ export class BattleScene extends Phaser.Scene { ]; } + private resetCityEquipmentContributionRuntime() { + this.cityEquipmentContributionRuntime = { + deployed: false, + offensiveActions: 0, + defensiveHits: 0, + bonusDamage: 0, + preventedDamage: 0 + }; + } + + private syncCityEquipmentPreparationDeployment() { + const preparation = this.cityEquipmentPreparation; + const deployed = Boolean( + preparation && + battleUnits.some( + (unit) => + unit.id === preparation.snapshot.unitId && + unit.faction === 'ally' && + unit.equipment[preparation.snapshot.slot].itemId === + preparation.snapshot.itemId + ) + ); + this.cityEquipmentContributionRuntime = { + ...this.cityEquipmentContributionRuntime, + deployed, + ...(deployed + ? {} + : { + offensiveActions: 0, + defensiveHits: 0, + bonusDamage: 0, + preventedDamage: 0 + }) + }; + } + + private cityEquipmentPreparationStatDeltaText() { + const delta = this.cityEquipmentPreparation?.statDelta; + if (!delta) { + return ''; + } + return [ + delta.attack > 0 ? `공격 +${delta.attack}` : '', + delta.defense > 0 ? `방어 +${delta.defense}` : '', + delta.strategy > 0 ? `책략 +${delta.strategy}` : '' + ] + .filter(Boolean) + .join(' / '); + } + + private cityEquipmentPreparationDisplayLine() { + const preparation = this.cityEquipmentPreparation; + if (!preparation) { + return ''; + } + const deployment = this.cityEquipmentContributionRuntime.deployed + ? '' + : ' · 미반영'; + return `${preparation.cityStay.city.name} 구입 장비 · ${preparation.unit.name} · ${preparation.item.name} · ${this.cityEquipmentPreparationStatDeltaText()}${deployment}`; + } + + private cityEquipmentContributionSnapshot(): + CityEquipmentContributionSnapshot | undefined { + const preparation = this.cityEquipmentPreparation; + if (!preparation) { + return undefined; + } + return cloneCityEquipmentContributionSnapshot({ + ...preparation.snapshot, + ...this.cityEquipmentContributionRuntime + }); + } + + private cityEquipmentContributionSaveState(): + BattleSaveCityEquipmentContributionState | undefined { + const snapshot = this.cityEquipmentContributionSnapshot(); + return snapshot?.deployed ? snapshot : undefined; + } + + private restoreCityEquipmentContributionRuntime( + state?: BattleSaveCityEquipmentContributionState + ) { + this.resetCityEquipmentContributionRuntime(); + this.syncCityEquipmentPreparationDeployment(); + const preparation = this.cityEquipmentPreparation; + if ( + !preparation || + !state || + !this.cityEquipmentContributionRuntime.deployed || + state.selectionRecordId !== + cityEquipmentPreparationSelectionRecordId || + state.targetBattleId !== battleScenario.id || + state.cityStayId !== preparation.snapshot.cityStayId || + state.offerId !== preparation.snapshot.offerId || + state.purchaseNumber !== + preparation.snapshot.purchaseNumber || + state.unitId !== preparation.snapshot.unitId || + state.itemId !== preparation.snapshot.itemId || + state.slot !== preparation.snapshot.slot || + state.previousItemId !== + preparation.snapshot.previousItemId + ) { + return; + } + this.cityEquipmentContributionRuntime = { + deployed: true, + offensiveActions: state.offensiveActions, + defensiveHits: state.defensiveHits, + bonusDamage: state.bonusDamage, + preventedDamage: state.preventedDamage + }; + } + + private cityEquipmentBaselinePreview( + attacker: UnitData, + defender: UnitData, + action: DamageCommand, + usable?: BattleUsable, + isCounter = false + ) { + const preparation = this.cityEquipmentPreparation; + if ( + !preparation || + !this.cityEquipmentContributionRuntime.deployed + ) { + return undefined; + } + const preparedUnit = + attacker.id === preparation.snapshot.unitId + ? attacker + : defender.id === preparation.snapshot.unitId + ? defender + : undefined; + if ( + !preparedUnit || + preparedUnit.equipment[preparation.snapshot.slot].itemId !== + preparation.snapshot.itemId + ) { + return undefined; + } + const baselineUnit: UnitData = { + ...preparedUnit, + stats: { ...preparedUnit.stats }, + equipment: this.cloneEquipment(preparedUnit.equipment) + }; + baselineUnit.equipment[preparation.snapshot.slot] = { + ...preparedUnit.equipment[preparation.snapshot.slot], + itemId: preparation.snapshot.previousItemId + }; + return this.combatPreview( + attacker.id === preparedUnit.id ? baselineUnit : attacker, + defender.id === preparedUnit.id ? baselineUnit : defender, + action, + usable, + isCounter + ); + } + + private recordCityEquipmentContribution( + preview: CombatPreview, + baselinePreview: CombatPreview | undefined, + hit: boolean, + critical: boolean, + previousDefenderHp: number, + actualDamage: number + ) { + const preparation = this.cityEquipmentPreparation; + if ( + !preparation || + !baselinePreview || + !this.cityEquipmentContributionRuntime.deployed || + !hit + ) { + return; + } + const baselineDamage = Phaser.Math.Clamp( + Math.round( + baselinePreview.damage * (critical ? 1.5 : 1) + ), + 1, + previousDefenderHp + ); + const runtime = this.cityEquipmentContributionRuntime; + if (preview.attacker.id === preparation.snapshot.unitId) { + const bonusDamage = Math.max( + 0, + actualDamage - baselineDamage + ); + if (bonusDamage <= 0) { + return; + } + runtime.offensiveActions = Math.min( + 999999, + runtime.offensiveActions + 1 + ); + runtime.bonusDamage = Math.min( + 999999, + runtime.bonusDamage + + bonusDamage + ); + } else if ( + preview.defender.id === preparation.snapshot.unitId + ) { + const preventedDamage = Math.max( + 0, + baselineDamage - actualDamage + ); + if (preventedDamage <= 0) { + return; + } + runtime.defensiveHits = Math.min( + 999999, + runtime.defensiveHits + 1 + ); + runtime.preventedDamage = Math.min( + 999999, + runtime.preventedDamage + + preventedDamage + ); + } else { + return; + } + this.renderTacticalInitiativeChip(); + } + + private cityEquipmentContributionResultText() { + const preparation = this.cityEquipmentPreparation; + if (!preparation) { + return undefined; + } + const base = `${preparation.cityStay.city.name} 구입 장비 · ${preparation.unit.name} · ${preparation.item.name}`; + const runtime = this.cityEquipmentContributionRuntime; + if (!runtime.deployed) { + return `${base} · 미출전으로 미반영`; + } + const parts: string[] = []; + if ( + preparation.statDelta.attack > 0 || + preparation.statDelta.strategy > 0 + ) { + parts.push( + runtime.offensiveActions > 0 + ? `공격 사용 ${runtime.offensiveActions}회 · 추가 피해 +${runtime.bonusDamage}` + : '직접 사용 없음 · 추가 피해 +0' + ); + } + if (preparation.statDelta.defense > 0) { + parts.push( + `피격 ${runtime.defensiveHits}회 · 피해 ${runtime.preventedDamage} 감소` + ); + } + return `${base} · ${parts.join(' / ')}`; + } + private resetThirdCampPreparationRuntime() { this.thirdCampPreparationRuntime = { informationConsumed: false, @@ -19950,6 +20354,8 @@ export class BattleScene extends Phaser.Scene { : undefined, cooperationStatsByBond: this.sortieCooperationStatsRecord(), thirdCampPreparation: this.thirdCampPreparationSaveState(), + cityEquipmentContribution: + this.cityEquipmentContributionSaveState(), savedAt: new Date().toISOString(), turnNumber: this.turnNumber, activeFaction: this.activeFaction, @@ -20103,6 +20509,18 @@ export class BattleScene extends Phaser.Scene { campaign: getCampaignState(), battleId: battleScenario.id }); + const campaign = getCampaignState(); + this.cityEquipmentPreparation = resolveCityEquipmentPreparation( + campaign.cityEquipmentPreparation, + { + battleId: battleScenario.id, + roster: campaign.roster, + purchaseCounts: campaign.cityEquipmentPurchaseCounts + } + ); + this.restoreCityEquipmentContributionRuntime( + state.cityEquipmentContribution + ); this.restoreThirdCampPreparationRuntime( state.thirdCampPreparation ); @@ -29803,6 +30221,138 @@ export class BattleScene extends Phaser.Scene { }; } + private cityEquipmentPreparationMechanicsProbe() { + const preparation = this.cityEquipmentPreparation; + if ( + !preparation || + !this.cityEquipmentContributionRuntime.deployed + ) { + return null; + } + const unit = battleUnits.find( + (candidate) => + candidate.id === preparation.snapshot.unitId && + candidate.faction === 'ally' + ); + const enemy = battleUnits.find( + (candidate) => candidate.faction === 'enemy' && candidate.hp > 0 + ) ?? battleUnits.find( + (candidate) => candidate.faction === 'enemy' + ); + if (!unit || !enemy) { + return null; + } + const offensivePrepared = this.combatPreview( + unit, + enemy, + 'attack' + ); + const offensiveBaseline = this.cityEquipmentBaselinePreview( + unit, + enemy, + 'attack' + ); + const defensivePrepared = this.combatPreview( + enemy, + unit, + 'attack' + ); + const defensiveBaseline = this.cityEquipmentBaselinePreview( + enemy, + unit, + 'attack' + ); + return { + selectionRecordId: + cityEquipmentPreparationSelectionRecordId, + preparedBonuses: { ...preparation.preparedBonuses }, + baselineBonuses: { ...preparation.previousBonuses }, + offensive: offensiveBaseline + ? { + attackerId: unit.id, + defenderId: enemy.id, + preparedDamage: offensivePrepared.damage, + baselineDamage: offensiveBaseline.damage, + bonusDamage: Math.max( + 0, + offensivePrepared.damage - + offensiveBaseline.damage + ) + } + : null, + defensive: defensiveBaseline + ? { + attackerId: enemy.id, + defenderId: unit.id, + preparedDamage: defensivePrepared.damage, + baselineDamage: defensiveBaseline.damage, + preventedDamage: Math.max( + 0, + defensiveBaseline.damage - + defensivePrepared.damage + ) + } + : null + }; + } + + private cityEquipmentPreparationDebugState() { + const preparation = this.cityEquipmentPreparation; + const contribution = this.cityEquipmentContributionSnapshot(); + if (!preparation) { + return { + active: false, + selectionRecordId: + cityEquipmentPreparationSelectionRecordId, + deployed: false, + runtime: { + ...this.cityEquipmentContributionRuntime + }, + result: null, + resultText: null, + mechanicsProbe: null, + chip: { + visible: false, + text: null, + bounds: null + } + }; + } + return { + active: true, + selectionRecordId: + cityEquipmentPreparationSelectionRecordId, + snapshot: { ...preparation.snapshot }, + cityName: preparation.cityStay.city.name, + unitName: preparation.unit.name, + itemName: preparation.item.name, + previousItemName: preparation.previousItem.name, + preparedBonuses: { ...preparation.preparedBonuses }, + previousBonuses: { ...preparation.previousBonuses }, + statDelta: { ...preparation.statDelta }, + deployed: this.cityEquipmentContributionRuntime.deployed, + runtime: { + ...this.cityEquipmentContributionRuntime + }, + result: contribution ? { ...contribution } : null, + resultText: + this.cityEquipmentContributionResultText() ?? null, + mechanicsProbe: + this.cityEquipmentPreparationMechanicsProbe(), + chip: { + visible: Boolean( + this.cityEquipmentPreparationChipBounds + ), + text: this.cityEquipmentPreparationChipBounds + ? this.cityEquipmentPreparationDisplayLine() + : null, + bounds: this.cityEquipmentPreparationChipBounds + ? { ...this.cityEquipmentPreparationChipBounds } + : null + } + }; + } + private thirdCampPreparationMemoryDebugState() { const memory = this.thirdCampPreparationMemory; if (!memory) { @@ -30210,6 +30760,8 @@ export class BattleScene extends Phaser.Scene { secondBattleReliefMemory: this.secondBattleReliefMemoryDebugState(), thirdCampPreparation: this.thirdCampPreparationMemoryDebugState(), + cityEquipmentPreparation: + this.cityEquipmentPreparationDebugState(), firstBattleNarrativeMemory: { volunteerPromise: this.firstBattleVolunteerPromiseDebugState() }, diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index e73aa17..1fb9c8c 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -59,8 +59,15 @@ import { findCityStayAfterBattle, type CityEquipmentOffer, type CityResonanceDialogueChoice, - type CityStayDefinition + type CityStayDefinition, + type CityStayId } from '../data/cityStays'; +import { + cityEquipmentPreparationSelectionRecordId, + createCityEquipmentPreparationSnapshot, + findCanonicalCityEquipmentOffer, + resolveCityEquipmentPreparation +} from '../data/cityEquipmentPreparation'; import { campaignPortraitKeysByUnitId, portraitAssetEntriesForKey, @@ -380,10 +387,25 @@ type EquipmentSwapRequest = { type EquipmentInventoryRowView = { itemId: string; canEquip: boolean; + focusedCityPurchase: boolean; background: Phaser.GameObjects.Rectangle; actionButton: Phaser.GameObjects.Rectangle; }; +type CityEquipmentContext = { + selectionRecordId: typeof cityEquipmentPreparationSelectionRecordId; + cityStayId: CityStayId; + cityName: string; + offerId: string; + itemId: string; + itemName: string; + slot: EquipmentSlot; + unitId: string; + unitName: string; + purchaseCount: number; + inventoryPage: number; +}; + type CampRosterBounds = { x: number; y: number; @@ -907,6 +929,8 @@ type CampaignTimelineChapter = { type CampSceneData = { openSortiePrep?: boolean; openSortieImprovement?: boolean; + openEquipmentCityStayId?: CityStayId; + openEquipmentOfferId?: string; retryBattleId?: BattleScenarioId; skipIntroStory?: boolean; completedAftermathBattleId?: BattleScenarioId; @@ -11451,6 +11475,11 @@ export class CampScene extends Phaser.Scene { private equipmentInventoryNextButton?: Phaser.GameObjects.Rectangle; private equipmentInventoryVisibleItemIds: string[] = []; private equipmentInventoryRowViews: EquipmentInventoryRowView[] = []; + private openEquipmentCityStayId?: CityStayId; + private openEquipmentOfferId?: string; + private cityEquipmentContext?: CityEquipmentContext; + private cityEquipmentContextBanner?: Phaser.GameObjects.Rectangle; + private sortieCityEquipmentSummaryText?: Phaser.GameObjects.Text; private equipmentSwapConfirmObjects: Phaser.GameObjects.GameObject[] = []; private pendingEquipmentSwap?: EquipmentSwapRequest; private equipmentSwapConfirmPanel?: Phaser.GameObjects.Rectangle; @@ -11487,6 +11516,7 @@ export class CampScene extends Phaser.Scene { private reportFormationHistoryView?: ReportFormationHistoryView; private reportFormationHistoryUndoState?: ReportFormationHistoryUndoState; private tabButtons: CampTabButtonView[] = []; + private sortieCommandButton?: Phaser.GameObjects.Rectangle; private sortiePrimaryActionButton?: CampActionButtonView; private sortieNextActionGuideBackground?: Phaser.GameObjects.Rectangle; private sortieNextActionGuideText?: Phaser.GameObjects.Text; @@ -11555,6 +11585,8 @@ export class CampScene extends Phaser.Scene { } this.openSortiePrepOnCreate = Boolean(data?.openSortiePrep); this.openSortieImprovementOnCreate = Boolean(data?.openSortieImprovement); + this.openEquipmentCityStayId = data?.openEquipmentCityStayId; + this.openEquipmentOfferId = data?.openEquipmentOfferId; this.retrySortieBattleId = data?.retryBattleId; this.skipIntroStoryForSortie = Boolean(data?.skipIntroStory); } @@ -11621,6 +11653,9 @@ export class CampScene extends Phaser.Scene { this.equipmentSwapConfirmPanel = undefined; this.equipmentSwapConfirmButton = undefined; this.equipmentSwapCancelButton = undefined; + this.cityEquipmentContext = undefined; + this.cityEquipmentContextBanner = undefined; + this.sortieCityEquipmentSummaryText = undefined; this.reportFormationReviewObjects = []; this.reportFormationReviewVisible = false; this.reportFormationReviewFeedback = ''; @@ -11707,6 +11742,7 @@ export class CampScene extends Phaser.Scene { this.selectedVisitId = pendingDirectExplorationVisit.id; this.activeTab = 'visit'; } + this.initializeCityEquipmentContext(); soundDirector.playSoundscape({ musicKey: this.campSoundscape.music.trackKey, ambienceKey: this.campSoundscape.ambience.trackKey, @@ -13068,7 +13104,7 @@ export class CampScene extends Phaser.Scene { }); const flow = this.currentSortieFlow(); const storyButtonLabel = this.isFinalEpilogueFlow(flow) && this.campaign?.step === flow.campaignStep ? '엔딩 보기' : '출진 준비'; - this.addCommandButton(storyButtonLabel, 1152, 38, 142, () => { + this.sortieCommandButton = this.addCommandButton(storyButtonLabel, 1152, 38, 142, () => { soundDirector.playSelect(); if (this.isFinalEpilogueFlow(flow)) { this.startVictoryStory(); @@ -14074,6 +14110,7 @@ export class CampScene extends Phaser.Scene { target.on('pointerout', () => setHovered(false)); target.on('pointerdown', action); }); + return bg; } private render() { @@ -14137,6 +14174,7 @@ export class CampScene extends Phaser.Scene { this.sortieFormationAssignments = this.normalizedSortieFormationAssignments(this.campaign.sortieFormationAssignments); this.sortieItemAssignments = this.normalizedSortieItemAssignments(this.campaign.sortieItemAssignments); this.ensureSortieFocus(); + this.sortieCityEquipmentSummaryText = undefined; const depth = 40; const width = 1188; @@ -16714,6 +16752,7 @@ export class CampScene extends Phaser.Scene { const synergy = this.sortieSynergySnapshot(); const pursuit = this.sortiePursuitPairs(); const selectedOrder = this.currentSortieOrderDefinition(); + const cityEquipment = this.cityEquipmentSortieStatus(); const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.78)); bg.setOrigin(0); bg.setDepth(depth); @@ -16729,17 +16768,69 @@ export class CampScene extends Phaser.Scene { ) ).setDepth(depth + 1); this.renderSortieBattleUiIcon('focus', x + 22, y + 44, 22, depth + 1, 0.74); - this.trackSortie( + const secondaryLine = cityEquipment?.line ?? + `${hasBattle ? `군령 ${selectedOrder?.label ?? '미선택'} · 추격 후보 ${pursuit.activePairs.length}조 · ` : ''}공명 ${synergy.activeBondCount} · 역할 ${synergy.coveredRoleCount}/3${hasBattle ? ` · 지형 ${synergy.terrainGrade}` : ''} · 대기 ${reserveUnits.length}명`; + const secondaryText = this.trackSortie( this.add.text( x + 44, y + 35, - this.compactText( - `${hasBattle ? `군령 ${selectedOrder?.label ?? '미선택'} · 추격 후보 ${pursuit.activePairs.length}조 · ` : ''}공명 ${synergy.activeBondCount} · 역할 ${synergy.coveredRoleCount}/3${hasBattle ? ` · 지형 ${synergy.terrainGrade}` : ''} · 대기 ${reserveUnits.length}명`, - 35 - ), - this.textStyle(12, reserveUnits.length > 0 ? '#c8d2dd' : '#9fb0bf', true) + this.compactText(secondaryLine, 35), + this.textStyle( + 12, + cityEquipment + ? cityEquipment.active ? '#a8ffd0' : '#ffdf7b' + : reserveUnits.length > 0 ? '#c8d2dd' : '#9fb0bf', + true + ) ) ).setDepth(depth + 1); + if (cityEquipment) { + this.sortieCityEquipmentSummaryText = secondaryText; + } + } + + private cityEquipmentSortieStatus() { + const campaign = this.campaign; + const battleId = this.currentSortieFlow().nextBattleId; + if (!campaign?.cityEquipmentPreparation || !battleId) { + return undefined; + } + const resolved = resolveCityEquipmentPreparation( + campaign.cityEquipmentPreparation, + { + battleId, + roster: campaign.roster, + purchaseCounts: campaign.cityEquipmentPurchaseCounts + } + ); + if (!resolved) { + return undefined; + } + const selected = this.selectedSortieUnitIds.includes(resolved.unit.id); + const delta = [ + resolved.statDelta.attack > 0 ? `공격 +${resolved.statDelta.attack}` : '', + resolved.statDelta.defense > 0 ? `방어 +${resolved.statDelta.defense}` : '', + resolved.statDelta.strategy > 0 ? `책략 +${resolved.statDelta.strategy}` : '' + ].filter(Boolean).join(' · '); + return { + selectionRecordId: resolved.snapshot.selectionRecordId, + sourceBattleId: resolved.snapshot.sourceBattleId, + targetBattleId: resolved.snapshot.targetBattleId, + cityStayId: resolved.snapshot.cityStayId, + offerId: resolved.snapshot.offerId, + itemId: resolved.item.id, + itemName: resolved.item.name, + unitId: resolved.unit.id, + unitName: resolved.unit.name, + statDelta: { ...resolved.statDelta }, + unitSelected: selected, + equipmentValid: true, + active: selected, + status: selected ? 'active' as const : 'unit-not-selected' as const, + line: selected + ? `${resolved.cityStay.city.name} 구입 장비 · ${resolved.unit.name} · ${resolved.item.name} · ${delta}` + : `${resolved.cityStay.city.name} 구입 장비 · ${resolved.unit.name} 미출전 · 미반영` + }; } private renderSortieBriefing(x: number, y: number, width: number, height: number, depth: number) { @@ -20978,6 +21069,7 @@ export class CampScene extends Phaser.Scene { this.sortieNextActionGuideBackground = undefined; this.sortieNextActionGuideText = undefined; this.sortieBriefingScoutSummaryText = undefined; + this.sortieCityEquipmentSummaryText = undefined; this.firstBattleFollowupCtaButton = undefined; this.firstBattleFollowupCard = undefined; this.firstBattleFollowupSummaryText = undefined; @@ -21074,6 +21166,14 @@ export class CampScene extends Phaser.Scene { bg.on('pointerdown', () => { soundDirector.playSelect(); this.selectedUnitId = unit.id; + if (this.cityEquipmentContext && this.activeTab === 'equipment') { + this.cityEquipmentContext = { + ...this.cityEquipmentContext, + unitId: unit.id, + unitName: unit.name + }; + this.persistCityEquipmentIntentUnit(unit.id); + } this.render(); }); const bounds = bg.getBounds(); @@ -24003,6 +24103,126 @@ export class CampScene extends Phaser.Scene { this.track(this.add.text(x + 14, y + 66, `${informationLabel} · 군자금 ${this.campaign?.gold ?? 0}`, this.textStyle(13, '#9fb0bf', true))); } + private initializeCityEquipmentContext() { + const campaign = this.campaign; + const persistedIntent = campaign?.cityEquipmentEquipIntent; + const persistedPreparation = + campaign?.cityEquipmentPreparation; + const cityStayId = + this.openEquipmentCityStayId ?? + persistedIntent?.cityStayId ?? + persistedPreparation?.cityStayId; + const offerId = + this.openEquipmentOfferId ?? + persistedIntent?.offerId ?? + persistedPreparation?.offerId; + const canonical = findCanonicalCityEquipmentOffer( + cityStayId, + offerId + ); + const cityStay = canonical?.cityStay; + const offer = canonical?.offer; + const purchaseCount = offer + ? campaign?.cityEquipmentPurchaseCounts?.[offer.id] ?? 0 + : 0; + if ( + !campaign || + !cityStay || + !offer || + purchaseCount <= 0 || + cityStay.nextBattleId !== this.currentSortieFlow().nextBattleId + ) { + return; + } + + const item = canonical.item; + const allies = campaign.roster.filter((unit) => unit.faction === 'ally'); + const persistedTargetId = + persistedIntent?.cityStayId === cityStay.id && + persistedIntent.offerId === offer.id + ? persistedIntent.unitId + : undefined; + const preparedTargetId = + persistedPreparation?.cityStayId === cityStay.id && + persistedPreparation.offerId === offer.id + ? persistedPreparation.unitId + : undefined; + const preferred = + allies.find( + (unit) => + unit.id === + (persistedTargetId ?? preparedTargetId) + ) ?? + (cityStay.id === 'xuzhou' + ? allies.find((unit) => unit.id === 'mi-zhu') + : undefined); + const target = preferred ?? [...allies].sort((left, right) => { + const leftCurrent = getItem(left.equipment[item.slot].itemId); + const rightCurrent = getItem(right.equipment[item.slot].itemId); + const leftDelta = this.itemTotalBonus(item) - this.itemTotalBonus(leftCurrent); + const rightDelta = this.itemTotalBonus(item) - this.itemTotalBonus(rightCurrent); + return rightDelta - leftDelta || left.name.localeCompare(right.name, 'ko-KR'); + })[0]; + if (!target) { + return; + } + + const entries = this.equipmentInventoryEntries(); + const itemIndex = entries.findIndex((entry) => entry.item.id === item.id); + const inventoryPage = itemIndex >= 0 + ? Math.floor(itemIndex / equipmentInventoryPageSize) + : 0; + this.selectedUnitId = target.id; + this.activeTab = 'equipment'; + this.equipmentInventoryPage = inventoryPage; + this.cityEquipmentContext = { + selectionRecordId: cityEquipmentPreparationSelectionRecordId, + cityStayId: cityStay.id, + cityName: cityStay.city.name, + offerId: offer.id, + itemId: item.id, + itemName: item.name, + slot: item.slot, + unitId: target.id, + unitName: target.name, + purchaseCount, + inventoryPage + }; + } + + private persistCityEquipmentIntentUnit(unitId: string) { + const context = this.cityEquipmentContext; + const snapshot = getCampaignState(); + const intent = snapshot.cityEquipmentEquipIntent; + if ( + !context || + !intent || + intent.cityStayId !== context.cityStayId || + intent.offerId !== context.offerId || + intent.unitId === unitId + ) { + return; + } + const updated = structuredClone(snapshot); + updated.cityEquipmentEquipIntent = { + ...intent, + unitId + }; + try { + this.campaign = saveCampaignState(updated); + } catch { + try { + saveCampaignState(snapshot); + } catch { + // The in-memory snapshot is still restored by the rollback attempt. + } + this.campaign = snapshot; + this.showCampNotice( + '선택한 장수를 저장하지 못했습니다. 현재 화면에서는 계속 비교할 수 있습니다.' + ); + } + } + private renderEquipmentPanel() { const unit = this.selectedUnit(); if (!unit) { @@ -24018,11 +24238,71 @@ export class CampScene extends Phaser.Scene { this.equipmentInventoryPreviousButton = undefined; this.equipmentInventoryNextButton = undefined; this.equipmentInventoryRowViews = []; + this.cityEquipmentContextBanner = undefined; bg.setOrigin(0); bg.setStrokeStyle(1, palette.gold, 0.58); this.track(this.add.text(x + 24, y + 22, '장비 관리', this.textStyle(24, '#f2e3bf', true))); - this.track(this.add.text(x + 24, y + 58, '후보를 고르면 현재 장비와 수치를 비교합니다. 교체 확정 전에는 장부에 반영되지 않습니다.', this.textStyle(14, '#d4dce6'))); + if (this.cityEquipmentContext) { + const context = this.cityEquipmentContext; + const target = this.campaign?.roster.find((unit) => unit.id === context.unitId); + const equipped = target?.equipment[context.slot].itemId === context.itemId; + const resolvedPreparation = this.campaign + ? resolveCityEquipmentPreparation( + this.campaign.cityEquipmentPreparation, + { + battleId: this.currentSortieFlow().nextBattleId, + roster: this.campaign.roster, + purchaseCounts: + this.campaign.cityEquipmentPurchaseCounts + } + ) + : undefined; + const preparationReady = Boolean( + resolvedPreparation && + resolvedPreparation.snapshot.cityStayId === context.cityStayId && + resolvedPreparation.snapshot.offerId === context.offerId && + resolvedPreparation.snapshot.unitId === context.unitId + ); + const bannerState = preparationReady + ? { + label: '전투 준비 완료', + fill: 0x173023, + stroke: palette.green, + color: '#a8ffd0' + } + : equipped + ? { + label: '장착됨 · 추가 전투 기여 없음', + fill: 0x202938, + stroke: palette.blue, + color: '#b8d8f2' + } + : { + label: '장착 비교', + fill: 0x2c2517, + stroke: palette.gold, + color: '#f2e3bf' + }; + const contextBanner = this.track(this.add.rectangle(x + 18, y + 48, width - 36, 30, bannerState.fill, 0.96)); + this.cityEquipmentContextBanner = contextBanner; + contextBanner.setOrigin(0); + contextBanner.setStrokeStyle(1, bannerState.stroke, 0.72); + this.track(this.add.text( + x + 34, + y + 55, + `${context.cityName} 시장에서 구입 · ${context.unitName}에게 ${context.itemName} · ${bannerState.label}`, + this.textStyle(13, bannerState.color, true) + )); + this.track(this.add.text( + x + width - 28, + y + 55, + `구매 ${context.purchaseCount}회 · ${context.inventoryPage + 1}쪽`, + this.textStyle(11, '#9fb0bf', true) + )).setOrigin(1, 0); + } else { + this.track(this.add.text(x + 24, y + 58, '후보를 고르면 현재 장비와 수치를 비교합니다. 교체 확정 전에는 장부에 반영되지 않습니다.', this.textStyle(14, '#d4dce6'))); + } this.renderEquipmentUnitSummary(unit, x + 24, y + 86, width - 48, 48); @@ -24136,12 +24416,32 @@ export class CampScene extends Phaser.Scene { const currentItem = getItem(unit.equipment[entry.item.slot].itemId); const sameItem = currentItem.id === entry.item.id; const deltaScore = this.itemTotalBonus(entry.item) - this.itemTotalBonus(currentItem); - const canEquip = !sameItem; - const rowColor = sameItem ? 0x111922 : deltaScore > 0 ? 0x172a22 : deltaScore < 0 ? 0x241b1b : 0x151f2a; - const strokeColor = sameItem ? 0x53606c : entry.item.rank === 'treasure' ? palette.gold : deltaScore > 0 ? palette.green : palette.blue; + const focusedCityPurchase = this.cityEquipmentContext?.itemId === entry.item.id; + const guidedSwapHasBattleValue = deltaScore > 0; + const canEquip = + !sameItem && + (!focusedCityPurchase || guidedSwapHasBattleValue); + const rowColor = focusedCityPurchase + ? 0x2c2517 + : sameItem + ? 0x111922 + : deltaScore > 0 + ? 0x172a22 + : deltaScore < 0 + ? 0x241b1b + : 0x151f2a; + const strokeColor = focusedCityPurchase + ? palette.gold + : sameItem + ? 0x53606c + : entry.item.rank === 'treasure' + ? palette.gold + : deltaScore > 0 + ? palette.green + : palette.blue; const bg = this.track(this.add.rectangle(x, y, width, height, rowColor, canEquip ? 0.94 : 0.72)); bg.setOrigin(0); - bg.setStrokeStyle(1, strokeColor, canEquip ? 0.58 : 0.3); + bg.setStrokeStyle(focusedCityPurchase ? 2 : 1, strokeColor, focusedCityPurchase ? 0.9 : canEquip ? 0.58 : 0.3); const iconFrame = this.track(this.add.rectangle(x + 20, y + height / 2, 28, 28, 0x0a1017, 0.92)); iconFrame.setStrokeStyle(1, entry.item.rank === 'treasure' ? palette.gold : 0x53606c, 0.54); @@ -24150,13 +24450,25 @@ export class CampScene extends Phaser.Scene { icon.setAlpha(canEquip ? 1 : 0.52); const nameColor = sameItem ? '#87919c' : entry.item.rank === 'treasure' ? '#f4dfad' : '#f2e3bf'; - this.track(this.add.text(x + 42, y + 5, `${entry.item.name} x${entry.amount}`, this.textStyle(13, nameColor, true))); + this.track(this.add.text( + x + 42, + y + 5, + `${entry.item.name} x${entry.amount}${focusedCityPurchase ? ' · 시장 구입' : ''}`, + this.textStyle(13, focusedCityPurchase ? '#ffdf7b' : nameColor, true) + )); this.track(this.add.text(x + 42, y + 22, `${equipmentSlotLabels[entry.item.slot]} · ${this.equipmentDeltaText(entry.item, currentItem)}`, this.textStyle(10, deltaScore > 0 ? '#a8ffd0' : deltaScore < 0 ? '#ff9d7d' : '#9fb0bf', true))); this.track(this.add.text(x + 244, y + 7, this.compactText(entry.item.effects[0] ?? entry.item.description, 13), this.textStyle(10, '#c8d2dd'))); const button = this.track(this.add.rectangle(x + width - 34, y + height / 2, 52, 24, canEquip ? 0x1a2630 : 0x121922, canEquip ? 0.96 : 0.62)); button.setStrokeStyle(1, canEquip ? palette.gold : 0x53606c, canEquip ? 0.68 : 0.3); - const buttonText = this.track(this.add.text(x + width - 34, y + height / 2 - 7, canEquip ? '비교' : '착용', this.textStyle(11, canEquip ? '#f2e3bf' : '#87919c', true))); + const actionLabel = canEquip + ? '비교' + : sameItem + ? '착용' + : focusedCityPurchase + ? '개선 없음' + : '불가'; + const buttonText = this.track(this.add.text(x + width - 34, y + height / 2 - 7, actionLabel, this.textStyle(11, canEquip ? '#f2e3bf' : '#87919c', true))); buttonText.setOrigin(0.5, 0); if (canEquip) { @@ -24173,6 +24485,7 @@ export class CampScene extends Phaser.Scene { this.equipmentInventoryRowViews.push({ itemId: entry.item.id, canEquip, + focusedCityPurchase, background: bg, actionButton: button }); @@ -25322,7 +25635,10 @@ export class CampScene extends Phaser.Scene { } private applyUnitEquipmentSwap(request: EquipmentSwapRequest) { - const campaign = this.campaign ?? getCampaignState(); + const snapshot = structuredClone( + this.campaign ?? getCampaignState() + ); + const campaign = structuredClone(snapshot); const target = campaign.roster.find((unit) => unit.id === request.unitId); if (!target) { this.showCampNotice('장비를 바꿀 장수 장부를 찾지 못했습니다.'); @@ -25339,6 +25655,21 @@ export class CampScene extends Phaser.Scene { this.showCampNotice(`${nextItem.name} 보유량이 없습니다.`); return; } + const cityEquipmentContext = this.cityEquipmentContext; + const guidedCitySwap = + cityEquipmentContext && + request.candidateItemId === cityEquipmentContext.itemId && + request.slot === cityEquipmentContext.slot && + request.unitId === cityEquipmentContext.unitId; + if ( + guidedCitySwap && + this.itemTotalBonus(nextItem) <= this.itemTotalBonus(currentItem) + ) { + this.showCampNotice( + `${target.name}의 ${currentItem.name}보다 전투 수치가 좋아지지 않아 준비 장비로 기록하지 않았습니다.` + ); + return; + } campaign.inventory[nextItem.name] -= 1; if (campaign.inventory[nextItem.name] <= 0) { @@ -25350,12 +25681,61 @@ export class CampScene extends Phaser.Scene { level: currentState.level, exp: currentState.exp }; - this.syncReportUnitEquipment(campaign, target.id, target.equipment); - - this.campaign = saveCampaignState(campaign); + let cityPreparationApplied = false; + if ( + guidedCitySwap + ) { + const purchaseNumber = + campaign.cityEquipmentPurchaseCounts?.[cityEquipmentContext.offerId] ?? 0; + if (purchaseNumber > 0) { + const preparation = createCityEquipmentPreparationSnapshot({ + cityStayId: cityEquipmentContext.cityStayId, + offerId: cityEquipmentContext.offerId, + purchaseNumber, + unitId: target.id, + previousItemId: currentItem.id, + roster: campaign.roster, + purchaseCounts: campaign.cityEquipmentPurchaseCounts + }); + if (preparation) { + campaign.cityEquipmentPreparation = preparation; + cityPreparationApplied = true; + if ( + campaign.cityEquipmentEquipIntent?.cityStayId === + cityEquipmentContext.cityStayId && + campaign.cityEquipmentEquipIntent.offerId === + cityEquipmentContext.offerId + ) { + delete campaign.cityEquipmentEquipIntent; + } + } + } + } + try { + this.campaign = saveCampaignState(campaign); + } catch { + try { + saveCampaignState(snapshot); + } catch { + // The rollback call restores the prior in-memory snapshot before persistence. + } + this.campaign = snapshot; + this.report = + this.campaign.firstBattleReport ?? this.report; + this.showCampNotice( + '장비 교체를 저장하지 못해 변경을 되돌렸습니다. 잠시 후 다시 시도하세요.' + ); + this.render(); + return; + } this.report = this.campaign.firstBattleReport ?? this.report; soundDirector.playSelect(); - this.showCampNotice(`${target.name} ${equipmentSlotLabels[request.slot]} 교체 · ${currentItem.name} → ${nextItem.name} (${this.itemBonusText(nextItem)})`); + const swapNotice = cityPreparationApplied + ? `${target.name} ${equipmentSlotLabels[request.slot]} 교체 완료 · ${currentItem.name} → ${nextItem.name} · 교체 변화 · ${this.equipmentDeltaText(nextItem, currentItem) + .replaceAll(' / ', ' · ') + .replace(/([+-]\d+)/g, ' $1')}` + : `${target.name} ${equipmentSlotLabels[request.slot]} 교체 · ${currentItem.name} → ${nextItem.name} (${this.itemBonusText(nextItem)})`; + this.showCampNotice(swapNotice); if (request.returnToSortie || this.sortieObjects.length > 0) { this.showSortiePrep(); } else { @@ -25376,13 +25756,6 @@ export class CampScene extends Phaser.Scene { return this.equipmentInventoryEntries(slot).find((entry) => entry.item.id !== currentItemId)?.item; } - private syncReportUnitEquipment(campaign: CampaignState, unitId: string, equipment: UnitData['equipment']) { - const reportUnit = campaign.firstBattleReport?.units.find((unit) => unit.id === unitId); - if (reportUnit) { - reportUnit.equipment = JSON.parse(JSON.stringify(equipment)) as UnitData['equipment']; - } - } - private statWithBonus(base: number, bonus: number) { return bonus > 0 ? `${base}+${bonus}` : `${base}`; } @@ -25667,6 +26040,7 @@ export class CampScene extends Phaser.Scene { const cityDialogueComplete = cityStay ? this.completedCampDialogues().includes(cityStay.dialogue.id) : false; + const cityEquipmentSortieStatus = this.cityEquipmentSortieStatus(); const firstBattleCampFollowup = this.firstBattleCampFollowup(); const firstBattleCamaraderieMemory = this.firstBattleCamaraderieMemory(); const firstBattleCamaraderieDialogue = this.firstBattleCamaraderieDialogue(); @@ -25738,6 +26112,26 @@ export class CampScene extends Phaser.Scene { cancelInteractive: Boolean(this.equipmentSwapCancelButton?.input?.enabled) } : null, + cityEquipmentContext: this.cityEquipmentContext + ? { + ...this.cityEquipmentContext, + preparationRecord: this.campaign?.cityEquipmentPreparation + ? { ...this.campaign.cityEquipmentPreparation } + : null, + bannerBounds: this.sortieObjectBoundsDebug(this.cityEquipmentContextBanner), + focusedRowBounds: this.sortieObjectBoundsDebug( + this.equipmentInventoryRowViews.find((row) => row.focusedCityPurchase)?.background + ), + focusedActionButtonBounds: this.sortieObjectBoundsDebug( + this.equipmentInventoryRowViews.find((row) => row.focusedCityPurchase)?.actionButton + ), + activeTab: this.activeTab, + selectedUnitId: this.selectedUnitId, + currentItemId: + this.campaign?.roster.find((unit) => unit.id === this.cityEquipmentContext?.unitId) + ?.equipment[this.cityEquipmentContext.slot].itemId ?? null + } + : null, activeTab: this.activeTab, campTabs: this.tabButtons.map((button) => ({ id: button.tab, @@ -25753,6 +26147,10 @@ export class CampScene extends Phaser.Scene { strokeColor: button.bg.strokeColor, lineWidth: button.bg.lineWidth })), + sortieCommand: { + bounds: this.sortieObjectBoundsDebug(this.sortieCommandButton), + interactive: Boolean(this.sortieCommandButton?.input?.enabled) + }, firstVictoryFollowup: firstBattleCampFollowup ? { available: true, @@ -26477,6 +26875,15 @@ export class CampScene extends Phaser.Scene { bounds: this.sortieObjectBoundsDebug(this.sortieNextActionGuideBackground) } : null, + sortieCityEquipmentPreparation: cityEquipmentSortieStatus + ? { + ...cityEquipmentSortieStatus, + statDelta: { ...cityEquipmentSortieStatus.statDelta }, + visible: Boolean(this.sortieCityEquipmentSummaryText?.active), + bounds: this.sortieTextBoundsDebug(this.sortieCityEquipmentSummaryText), + displayedText: this.sortieCityEquipmentSummaryText?.text ?? null + } + : null, sortiePortraitRosterView: { enabled: stagedSortiePrep && !this.isFirstSortiePrepSlice(), total: portraitRosterUnits.length, @@ -26814,6 +27221,7 @@ export class CampScene extends Phaser.Scene { rows: this.equipmentInventoryRowViews.map((row) => ({ itemId: row.itemId, canEquip: row.canEquip, + focusedCityPurchase: row.focusedCityPurchase, bounds: this.sortieObjectBoundsDebug(row.background), actionButtonBounds: this.sortieObjectBoundsDebug(row.actionButton), interactive: Boolean(row.background.input?.enabled && row.actionButton.input?.enabled) @@ -26976,6 +27384,14 @@ export class CampScene extends Phaser.Scene { step: this.campaign.step, gold: this.campaign.gold, inventory: this.campaign.inventory, + cityEquipmentPurchaseCounts: { ...this.campaign.cityEquipmentPurchaseCounts }, + cityEquipmentEquipIntent: + this.campaign.cityEquipmentEquipIntent + ? { ...this.campaign.cityEquipmentEquipIntent } + : undefined, + cityEquipmentPreparation: this.campaign.cityEquipmentPreparation + ? { ...this.campaign.cityEquipmentPreparation } + : null, selectedSortieUnitIds: this.campaign.selectedSortieUnitIds, sortieFormationAssignments: this.campaign.sortieFormationAssignments, sortieItemAssignments: this.campaign.sortieItemAssignments, diff --git a/src/game/scenes/CityStayScene.ts b/src/game/scenes/CityStayScene.ts index 6d76419..3410c83 100644 --- a/src/game/scenes/CityStayScene.ts +++ b/src/game/scenes/CityStayScene.ts @@ -31,7 +31,8 @@ import { ExplorationInputController } from '../input/ExplorationInputController' import { chooseCityStayResonance, collectCityStayInformation, - purchaseCityStayEquipment + purchaseCityStayEquipment, + selectCityStayEquipmentForCamp } from '../state/cityStayActions'; import { getCampaignState, @@ -96,6 +97,9 @@ type ShopOfferView = { offerId: string; button: Phaser.GameObjects.Rectangle; interactive: boolean; + equipButton?: Phaser.GameObjects.Rectangle; + equipInteractive: boolean; + iconBounds: { x: number; y: number; width: number; height: number }; }; type ChoiceView = { @@ -351,11 +355,20 @@ export class CityStayScene extends Phaser.Scene { id: offer.id, itemId: item.id, itemName: item.name, + slot: item.slot, + bonusText: this.itemBonusText(item), + iconKind: item.slot, price: offer.price, owned: campaign.inventory[itemInventoryLabel(item.id)] ?? 0, + purchaseCount: campaign.cityEquipmentPurchaseCounts?.[offer.id] ?? 0, canBuy: campaign.gold >= offer.price, interactive: Boolean(view?.interactive), - buttonBounds: view ? this.boundsSnapshot(view.button.getBounds()) : null + buttonBounds: view ? this.boundsSnapshot(view.button.getBounds()) : null, + equipCtaBounds: view?.equipButton + ? this.boundsSnapshot(view.equipButton.getBounds()) + : null, + equipInteractive: Boolean(view?.equipInteractive), + itemIconBounds: view ? { ...view.iconBounds } : null }; }) }, @@ -1383,8 +1396,11 @@ export class CityStayScene extends Phaser.Scene { return; } this.stopPlayerMovement(); + this.completionToast?.destroy(); + this.completionToast = undefined; this.shopNotice = ''; this.renderShopPanel(); + this.refreshInteractionPrompt(); } private renderShopPanel() { @@ -1411,7 +1427,7 @@ export class CityStayScene extends Phaser.Scene { color: '#f3dfaa', fontStyle: 'bold' }).setOrigin(1, 0); - const guide = this.add.text(230, 225, '일반 장비는 여러 개 구매할 수 있으며, 구입 즉시 군영 장비 탭에서 사용할 수 있습니다.', { + const guide = this.add.text(230, 225, '구입한 장비는 장착 비교를 거쳐 무장에게 배정합니다. 자동으로 장착되지는 않습니다.', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '19px', color: '#aeb8c4' @@ -1442,61 +1458,137 @@ export class CityStayScene extends Phaser.Scene { this.cityStay.equipmentOffers.forEach((offer, index) => { const item = getItem(offer.itemId); const owned = campaign.inventory[itemInventoryLabel(item.id)] ?? 0; + const purchaseCount = campaign.cityEquipmentPurchaseCounts?.[offer.id] ?? 0; const canBuy = campaign.gold >= offer.price; const rowX = 230; const rowY = 300 + index * 180; - const row = this.add.rectangle(rowX, rowY, 1460, 146, canBuy ? 0x1b2734 : 0x131a22, 0.98) + const purchasedHere = purchaseCount > 0; + const row = this.add.rectangle( + rowX, + rowY, + 1460, + 146, + purchasedHere ? 0x182a24 : canBuy ? 0x1b2734 : 0x131a22, + 0.98 + ) .setOrigin(0) - .setStrokeStyle(2, canBuy ? 0x54708a : 0x3f4853, canBuy ? 0.76 : 0.45); + .setStrokeStyle( + 2, + purchasedHere ? 0x7ea66d : canBuy ? 0x54708a : 0x3f4853, + purchasedHere ? 0.88 : canBuy ? 0.76 : 0.45 + ); const slotBadge = this.add.circle(rowX + 68, rowY + 73, 42, 0x0e151d, 0.98) .setStrokeStyle(2, this.profile.accentColor, 0.74); - const slotLabel = this.add.text(rowX + 68, rowY + 73, equipmentSlotLabels[item.slot].slice(0, 2), { + const iconObjects = this.renderShopEquipmentIcon( + item.slot, + rowX + 68, + rowY + 65, + canBuy || purchasedHere + ); + const slotLabel = this.add.text(rowX + 68, rowY + 112, equipmentSlotLabels[item.slot].slice(0, 2), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '18px', + fontSize: '14px', color: '#f1d691', fontStyle: 'bold' }).setOrigin(0.5); - const name = this.add.text(rowX + 132, rowY + 24, `${item.name} · 보유 ${owned}`, { + const name = this.add.text( + rowX + 132, + rowY + 24, + `${item.name} · 보유 ${owned} · 이 도시 구매 ${purchaseCount}회${purchasedHere ? ' · 구매 완료' : ''}`, + { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '25px', - color: canBuy ? '#f0ede5' : '#808a95', + color: canBuy || purchasedHere ? '#f0ede5' : '#808a95', fontStyle: 'bold' - }); + } + ); const description = this.add.text(rowX + 132, rowY + 66, item.description, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '18px', - color: canBuy ? '#aeb8c4' : '#69737d', - wordWrap: { width: 850, useAdvancedWrap: true } + color: canBuy || purchasedHere ? '#aeb8c4' : '#69737d', + wordWrap: { width: 780, useAdvancedWrap: true } }); const bonus = this.add.text(rowX + 132, rowY + 104, this.itemBonusText(item), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', - color: canBuy ? '#9ebd98' : '#68736a' + color: canBuy || purchasedHere ? '#9ebd98' : '#68736a' }); - const price = this.add.text(rowX + 1195, rowY + 43, `${offer.price}금`, { + const price = this.add.text( + rowX + (purchasedHere ? 1088 : 1195), + rowY + 43, + `${offer.price}금`, + { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '22px', color: canBuy ? '#f1d691' : '#7f8994', fontStyle: 'bold' - }).setOrigin(1, 0); - const button = this.add.rectangle(rowX + 1350, rowY + 73, 170, 64, canBuy ? 0x4a371d : 0x20262d, 0.99) + } + ).setOrigin(1, 0); + const purchaseButtonX = rowX + (purchasedHere ? 1155 : 1350); + const button = this.add.rectangle( + purchaseButtonX, + rowY + 73, + purchasedHere ? 120 : 170, + 64, + canBuy ? 0x4a371d : 0x20262d, + 0.99 + ) .setStrokeStyle(2, canBuy ? this.profile.accentColor : 0x56606a, canBuy ? 0.9 : 0.45); - const buttonLabel = this.add.text(rowX + 1350, rowY + 73, canBuy ? '구매' : '군자금 부족', { + const buttonLabel = this.add.text( + purchaseButtonX, + rowY + 73, + canBuy ? (purchasedHere ? '추가 구매' : '구매') : '군자금 부족', + { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: canBuy ? '21px' : '16px', + fontSize: canBuy ? (purchasedHere ? '18px' : '21px') : '16px', color: canBuy ? '#fff2b8' : '#7f8994', fontStyle: 'bold' - }).setOrigin(0.5); + } + ).setOrigin(0.5); if (canBuy) { button.setInteractive({ useHandCursor: true }); button.on('pointerover', () => button.setFillStyle(0x654c25, 0.99)); button.on('pointerout', () => button.setFillStyle(0x4a371d, 0.99)); button.on('pointerdown', () => this.buyOffer(offer)); } - this.shopOfferViews.push({ offerId: offer.id, button, interactive: canBuy }); + let equipButton: Phaser.GameObjects.Rectangle | undefined; + let equipButtonLabel: Phaser.GameObjects.Text | undefined; + if (purchasedHere) { + const hasImplementedBattleBonus = Boolean( + item.attackBonus || + item.defenseBonus || + item.strategyBonus + ); + equipButton = this.add.rectangle(rowX + 1305, rowY + 73, 170, 64, 0x23432f, 0.99) + .setStrokeStyle(2, 0x91c978, 0.94) + .setInteractive({ useHandCursor: true }); + equipButtonLabel = this.add.text( + rowX + 1305, + rowY + 73, + hasImplementedBattleBonus ? '장착 비교' : '보유 확인', + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '19px', + color: '#e4f6d4', + fontStyle: 'bold' + } + ).setOrigin(0.5); + equipButton.on('pointerover', () => equipButton?.setFillStyle(0x315d40, 0.99)); + equipButton.on('pointerout', () => equipButton?.setFillStyle(0x23432f, 0.99)); + equipButton.on('pointerdown', () => this.returnToCampForEquipment(offer)); + } + this.shopOfferViews.push({ + offerId: offer.id, + button, + interactive: canBuy, + equipButton, + equipInteractive: Boolean(equipButton?.input?.enabled), + iconBounds: { x: rowX + 36, y: rowY + 33, width: 64, height: 64 } + }); objects.push( row, slotBadge, + ...iconObjects, slotLabel, name, description, @@ -1505,6 +1597,9 @@ export class CityStayScene extends Phaser.Scene { button, buttonLabel ); + if (equipButton && equipButtonLabel) { + objects.push(equipButton, equipButtonLabel); + } }); const notice = this.add.text(230, 876, this.shopNotice || '상인에게 필요한 장비를 골라 보세요.', { @@ -1538,6 +1633,45 @@ export class CityStayScene extends Phaser.Scene { this.refreshProgressHud(); } + private renderShopEquipmentIcon( + slot: ReturnType['slot'], + x: number, + y: number, + active: boolean + ) { + const graphics = this.add.graphics(); + const color = active ? this.profile.accentColor : 0x65707b; + const highlight = active ? 0xf5dfaa : 0x87919c; + graphics.lineStyle(5, color, 0.96); + graphics.fillStyle(color, 0.88); + + if (slot === 'weapon') { + graphics.lineBetween(x - 18, y + 18, x + 15, y - 15); + graphics.fillTriangle(x + 10, y - 21, x + 23, y - 10, x + 12, y - 7); + graphics.lineStyle(4, highlight, 0.9); + graphics.lineBetween(x - 21, y + 9, x - 8, y + 22); + } else if (slot === 'armor') { + graphics.fillPoints([ + new Phaser.Geom.Point(x - 22, y - 14), + new Phaser.Geom.Point(x - 9, y - 23), + new Phaser.Geom.Point(x, y - 13), + new Phaser.Geom.Point(x + 9, y - 23), + new Phaser.Geom.Point(x + 22, y - 14), + new Phaser.Geom.Point(x + 15, y + 22), + new Phaser.Geom.Point(x - 15, y + 22) + ], true); + graphics.lineStyle(3, highlight, 0.85); + graphics.strokeTriangle(x - 12, y - 10, x, y + 12, x + 12, y - 10); + } else { + graphics.fillRoundedRect(x - 20, y - 10, 40, 32, 10); + graphics.lineStyle(4, highlight, 0.9); + graphics.strokeCircle(x, y - 15, 13); + graphics.lineBetween(x - 10, y - 5, x + 10, y - 5); + } + + return [graphics]; + } + private closeShop() { this.shopPanel?.destroy(); this.shopPanel = undefined; @@ -1547,6 +1681,9 @@ export class CityStayScene extends Phaser.Scene { } private itemBonusText(item: ReturnType) { + if (item.id === 'grain-pouch') { + return '보급품 슬롯 · 전투 효과 준비 중'; + } const bonuses = [ item.attackBonus ? `공격 +${item.attackBonus}` : '', item.defenseBonus ? `방어 +${item.defenseBonus}` : '', @@ -1578,7 +1715,35 @@ export class CityStayScene extends Phaser.Scene { this.returnToCamp(); } - private returnToCamp() { + private returnToCampForEquipment(offer: CityEquipmentOffer) { + const selection = selectCityStayEquipmentForCamp( + this.cityStay.id, + offer.id + ); + if (!selection.ok) { + const message = selection.reason === 'not-purchased' + ? '먼저 장비를 구입해야 장착 비교로 이어갈 수 있습니다.' + : selection.reason === 'save-unavailable' + ? '장착 비교 기록을 저장하지 못했습니다. 잠시 후 다시 시도하세요.' + : '현재 장비를 군영으로 연결할 수 없습니다.'; + this.shopNotice = message; + this.lastNotice = message; + soundDirector.playEffect('objective-failure', { + volume: 0.16, + stopAfterMs: 620 + }); + this.renderShopPanel(); + this.refreshProgressHud(); + return; + } + this.campaign = selection.campaign; + this.returnToCamp({ + openEquipmentCityStayId: this.cityStay.id, + openEquipmentOfferId: offer.id + }); + } + + private returnToCamp(data?: Record) { if (this.navigationPending) { return; } @@ -1588,7 +1753,7 @@ export class CityStayScene extends Phaser.Scene { this.campaign = setActiveCityStayId(); this.showTransitionOverlay(); - void startGameScene(this, 'CampScene').catch((error) => { + void startGameScene(this, 'CampScene', data).catch((error) => { console.error('Failed to return from the city stay.', error); this.campaign = setActiveCityStayId(previousActiveCityStayId ?? this.cityStay.id); this.navigationPending = false; diff --git a/src/game/state/battleSaveState.ts b/src/game/state/battleSaveState.ts index 19cdf90..2be15c6 100644 --- a/src/game/state/battleSaveState.ts +++ b/src/game/state/battleSaveState.ts @@ -17,6 +17,10 @@ import { thirdCampPreparationTargetBattleId, type ThirdCampPreparationPriorityId } from '../data/thirdCampPreparation'; +import { + normalizeCityEquipmentContributionSnapshot, + type CityEquipmentContributionSnapshot +} from '../data/cityEquipmentPreparation'; export type BattleSaveFaction = 'ally' | 'enemy'; export type BattleSaveRosterTab = 'ally' | 'enemy'; @@ -112,6 +116,9 @@ export type BattleSaveThirdCampPreparationState = { preventedDamage: number; }; +export type BattleSaveCityEquipmentContributionState = + CityEquipmentContributionSnapshot; + export type BattleSaveEventPriority = 'critical' | 'high' | 'normal' | 'low'; export type BattleSavePendingEvent = { @@ -133,6 +140,7 @@ export type BattleSaveState = { coreResonanceStats?: BattleSaveCoreResonanceStats; cooperationStatsByBond?: Record; thirdCampPreparation?: BattleSaveThirdCampPreparationState; + cityEquipmentContribution?: BattleSaveCityEquipmentContributionState; savedAt: string; turnNumber: number; activeFaction: BattleSaveFaction; @@ -230,6 +238,7 @@ export function normalizeBattleSaveState( normalizeCoreResonanceSaveFields(cloned, options); normalizeCooperationSaveFields(cloned, options); normalizeThirdCampPreparationSaveField(cloned); + normalizeCityEquipmentContributionSaveField(cloned, options); const recommendation = normalizeCampaignSortieRecommendationSnapshot(cloned.sortieRecommendation, { expectedBattleId: typeof cloned.battleId === 'string' ? cloned.battleId : options.expectedBattleId, allowedUnitIds: options.validAllyUnitIds @@ -283,6 +292,10 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida return false; } + if (!isOptionalCityEquipmentContributionState(state.cityEquipmentContribution, state, options)) { + return false; + } + const attackIntents = state.attackIntents; const units = state.units; @@ -667,6 +680,129 @@ function isOptionalThirdCampPreparationState( ); } +function normalizeCityEquipmentContributionSaveField( + state: Record, + options: BattleSaveValidationOptions +) { + const normalized = canonicalCityEquipmentContributionSaveState( + state.cityEquipmentContribution, + state, + options + ); + if (normalized) { + state.cityEquipmentContribution = normalized; + } else { + delete state.cityEquipmentContribution; + } +} + +function isOptionalCityEquipmentContributionState( + value: unknown, + state: Record, + options: BattleSaveValidationOptions +) { + if (value === undefined) { + return true; + } + const normalized = canonicalCityEquipmentContributionSaveState( + value, + state, + options + ); + if (!normalized || !isRecord(value)) { + return false; + } + const fields: Array = [ + 'version', + 'selectionRecordId', + 'cityStayId', + 'sourceBattleId', + 'targetBattleId', + 'offerId', + 'itemId', + 'slot', + 'price', + 'purchaseNumber', + 'unitId', + 'previousItemId', + 'deployed', + 'offensiveActions', + 'defensiveHits', + 'bonusDamage', + 'preventedDamage' + ]; + return ( + Object.keys(value).length === fields.length && + fields.every((field) => value[field] === normalized[field]) + ); +} + +function canonicalCityEquipmentContributionSaveState( + value: unknown, + state: Record, + options: BattleSaveValidationOptions +): BattleSaveCityEquipmentContributionState | undefined { + const battleId = + typeof state.battleId === 'string' ? state.battleId : undefined; + const normalized = + normalizeCityEquipmentContributionSnapshot(value, battleId); + if ( + !battleId || + !normalized || + !normalized.deployed || + normalized.targetBattleId !== battleId || + !Array.isArray(state.units) + ) { + return undefined; + } + + const unit = state.units.find( + (candidate) => + isRecord(candidate) && candidate.id === normalized.unitId + ); + const equipment = + unit && isRecord(unit.equipment) ? unit.equipment : undefined; + const equippedState = + equipment && isRecord(equipment[normalized.slot]) + ? equipment[normalized.slot] + : undefined; + if ( + !unit || + !equippedState || + equippedState.itemId !== normalized.itemId || + (options.validUnitIds && + !options.validUnitIds.has(normalized.unitId)) || + (options.validAllyUnitIds && + !options.validAllyUnitIds.has(normalized.unitId)) || + (options.validEquipmentItemIds && + (!options.validEquipmentItemIds.has(normalized.itemId) || + !options.validEquipmentItemIds.has( + normalized.previousItemId + ))) || + (options.validEquipmentSlotItems?.[normalized.slot] && + (!options.validEquipmentSlotItems[normalized.slot]?.has( + normalized.itemId + ) || + !options.validEquipmentSlotItems[normalized.slot]?.has( + normalized.previousItemId + ))) + ) { + return undefined; + } + + return { + ...normalized, + bonusDamage: + normalized.offensiveActions > 0 + ? normalized.bonusDamage + : 0, + preventedDamage: + normalized.defensiveHits > 0 + ? normalized.preventedDamage + : 0 + }; +} + function isOptionalCoreResonanceState( state: Record, options: BattleSaveValidationOptions diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index fd3e07e..9c4efcf 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -1,6 +1,18 @@ import { equipmentExpToNext, equipmentSlots, itemCatalog, type EquipmentSlot } from '../data/battleItems'; import { unitClasses } from '../data/battleRules'; import { battleScenarios, type BattleScenarioId } from '../data/battles'; +import { + cloneCityEquipmentContributionSnapshot, + normalizeCityEquipmentContributionSnapshot, + normalizeCityEquipmentEquipIntent, + normalizeCityEquipmentPreparationSnapshot, + normalizeCityEquipmentPurchaseCounts, + type CityEquipmentContributionSnapshot, + type CityEquipmentEquipIntent, + type CityEquipmentPreparationRosterUnit, + type CityEquipmentPreparationSnapshot, + type CityEquipmentPurchaseCounts +} from '../data/cityEquipmentPreparation'; import { findCityStayAfterBattle, type CityStayId } from '../data/cityStays'; import { isThirdCampPreparationPriorityId, @@ -177,6 +189,7 @@ export type FirstBattleReport = { sortieOrder?: CampaignSortieOrderResultSnapshot; sortieRecommendation?: CampaignSortieRecommendationSnapshot; sortieCooperation?: CampaignSortieCooperationSnapshot; + cityEquipmentContribution?: CityEquipmentContributionSnapshot; completedCampDialogues: string[]; completedCampVisits: string[]; createdAt: string; @@ -461,6 +474,7 @@ export type CampaignBattleSettlement = { sortieOrder?: CampaignSortieOrderResultSnapshot; sortieRecommendation?: CampaignSortieRecommendationSnapshot; sortieCooperation?: CampaignSortieCooperationSnapshot; + cityEquipmentContribution?: CityEquipmentContributionSnapshot; completedAt: string; }; @@ -525,6 +539,9 @@ export type CampaignState = { roster: UnitData[]; bonds: CampBondSnapshot[]; inventory: Record; + cityEquipmentPurchaseCounts: CityEquipmentPurchaseCounts; + cityEquipmentEquipIntent?: CityEquipmentEquipIntent; + cityEquipmentPreparation?: CityEquipmentPreparationSnapshot; selectedSortieUnitIds: string[]; sortieFormationAssignments: SortieFormationAssignments; sortieItemAssignments: CampaignSortieItemAssignments; @@ -755,6 +772,7 @@ export function createInitialCampaignState(): CampaignState { roster: [], bonds: [], inventory: {}, + cityEquipmentPurchaseCounts: {}, selectedSortieUnitIds: [], sortieFormationAssignments: {}, sortieItemAssignments: {}, @@ -997,6 +1015,18 @@ export function setFirstBattleReport(report: FirstBattleReport) { } else { delete reportClone.sortieRecommendation; } + const cityEquipmentContribution = + normalizeCityEquipmentContributionSnapshot( + reportClone.cityEquipmentContribution, + battleId, + reportClone.units + ); + if (cityEquipmentContribution) { + reportClone.cityEquipmentContribution = + cityEquipmentContribution; + } else { + delete reportClone.cityEquipmentContribution; + } const previousSettlement = state.battleHistory[battleId]; const completedCampDialogues = mergeCampCompletionIds( state.completedCampDialogues, @@ -1720,6 +1750,30 @@ function normalizeCampaignState(state: Partial): CampaignState { normalized.dismissedVictoryRewardNoticeBattleIds = uniqueStrings(normalized.dismissedVictoryRewardNoticeBattleIds) .filter((battleId) => battleId in battleScenarios); normalized.inventory = normalizeInventory(normalized.inventory); + normalized.cityEquipmentPurchaseCounts = + normalizeCityEquipmentPurchaseCounts( + normalized.cityEquipmentPurchaseCounts + ); + normalized.cityEquipmentEquipIntent = + normalizeCityEquipmentEquipIntent( + normalized.cityEquipmentEquipIntent, + normalized.cityEquipmentPurchaseCounts, + normalized.roster + ); + if (!normalized.cityEquipmentEquipIntent) { + delete normalized.cityEquipmentEquipIntent; + } + normalized.cityEquipmentPreparation = + normalizeCityEquipmentPreparationSnapshot( + normalized.cityEquipmentPreparation, + { + roster: normalized.roster, + purchaseCounts: normalized.cityEquipmentPurchaseCounts + } + ); + if (!normalized.cityEquipmentPreparation) { + delete normalized.cityEquipmentPreparation; + } normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory); normalized.firstBattleReport = normalizeFirstBattleReport(normalized.firstBattleReport); delete normalized.campVisitChoiceIds[thirdCampPreparationSelectionRecordId]; @@ -1799,6 +1853,17 @@ function normalizeCampaignState(state: Partial): CampaignState { ); } normalized.latestBattleId = normalizeLatestBattleId(normalized.latestBattleId, normalized.battleHistory); + if (normalized.cityEquipmentEquipIntent) { + const intent = normalized.cityEquipmentEquipIntent; + const sourceCampStep = + campaignBattleSteps[intent.sourceBattleId]?.victory; + if ( + normalized.latestBattleId !== intent.sourceBattleId || + normalized.step !== sourceCampStep + ) { + delete normalized.cityEquipmentEquipIntent; + } + } const activeCityStayId = normalizeActiveCityStayId( normalized.activeCityStayId, normalized.latestBattleId, @@ -1831,6 +1896,9 @@ function normalizeCampaignState(state: Partial): CampaignState { if (normalized.firstBattleReport && sortieUnitFilter) { normalized.firstBattleReport = filterFirstBattleReportRosterReferences(normalized.firstBattleReport, sortieUnitFilter); } + normalizeStoredCityEquipmentContributionsForContainers( + normalized + ); const recordedVictoryBattleIds = new Set( Object.entries(normalized.battleHistory) .filter(([, settlement]) => settlement.outcome === 'victory') @@ -2516,6 +2584,50 @@ function normalizeLatestBattleId(value: unknown, battleHistory: Record (campaignTimestampMs(b.completedAt) ?? 0) - (campaignTimestampMs(a.completedAt) ?? 0))[0]?.battleId; } +function campaignSettlementContributionRoster( + units: readonly CampaignUnitProgressSnapshot[] +): CityEquipmentPreparationRosterUnit[] { + return units.map((unit) => ({ + id: unit.unitId, + name: unit.name, + faction: 'ally', + equipment: unit.equipment + })); +} + +function normalizeStoredCityEquipmentContributionsForContainers( + state: CampaignState +) { + if (state.firstBattleReport) { + const contribution = + normalizeCityEquipmentContributionSnapshot( + state.firstBattleReport.cityEquipmentContribution, + state.firstBattleReport.battleId, + state.firstBattleReport.units + ); + if (contribution) { + state.firstBattleReport.cityEquipmentContribution = + contribution; + } else { + delete state.firstBattleReport.cityEquipmentContribution; + } + } + + Object.values(state.battleHistory).forEach((settlement) => { + const contribution = + normalizeCityEquipmentContributionSnapshot( + settlement.cityEquipmentContribution, + settlement.battleId, + campaignSettlementContributionRoster(settlement.units) + ); + if (contribution) { + settlement.cityEquipmentContribution = contribution; + } else { + delete settlement.cityEquipmentContribution; + } + }); +} + function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rosterUnitIds: Set): FirstBattleReport { const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(report.sortiePerformance, rosterUnitIds); const sortiePerformanceUnchanged = sortiePerformanceSnapshotsShareRoster(report.sortiePerformance, sortiePerformance); @@ -2675,6 +2787,12 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle allowedUnitIds: new Set(units.map((unit) => unit.unitId)), allowedBondIds: new Set(bonds.map((bond) => bond.id)) }); + const cityEquipmentContribution = + normalizeCityEquipmentContributionSnapshot( + settlement.cityEquipmentContribution, + battleId, + campaignSettlementContributionRoster(units) + ); return { battleId, @@ -2693,6 +2811,9 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle ...(sortieOrder ? { sortieOrder } : {}), ...(sortieRecommendation ? { sortieRecommendation } : {}), ...(sortieCooperation ? { sortieCooperation } : {}), + ...(cityEquipmentContribution + ? { cityEquipmentContribution } + : {}), completedAt }; } @@ -3044,6 +3165,12 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi allowedBondIds: new Set(bonds.map((bond) => bond.id)), bonds }); + const cityEquipmentContribution = + normalizeCityEquipmentContributionSnapshot( + report.cityEquipmentContribution, + battleId, + units + ); return { battleId, @@ -3067,6 +3194,9 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi ...(sortieOrder ? { sortieOrder } : {}), ...(sortieRecommendation ? { sortieRecommendation } : {}), ...(sortieCooperation ? { sortieCooperation } : {}), + ...(cityEquipmentContribution + ? { cityEquipmentContribution } + : {}), completedCampDialogues: uniqueCampHistoryIds(report.completedCampDialogues), completedCampVisits: uniqueCampHistoryIds(report.completedCampVisits), createdAt: normalizeCampaignTimestamp(report.createdAt) ?? new Date().toISOString() @@ -3334,6 +3464,14 @@ function createBattleSettlement(report: FirstBattleReport, reserveTraining: Camp ...(report.sortieCooperation ? { sortieCooperation: cloneCampaignSortieCooperationSnapshot(report.sortieCooperation) } : {}), + ...(report.cityEquipmentContribution + ? { + cityEquipmentContribution: + cloneCityEquipmentContributionSnapshot( + report.cityEquipmentContribution + ) + } + : {}), completedAt: report.createdAt }; } @@ -3497,6 +3635,12 @@ function cloneReport(report: FirstBattleReport): FirstBattleReport { if (cloned.sortieCooperation) { cloned.sortieCooperation = cloneCampaignSortieCooperationSnapshot(cloned.sortieCooperation); } + if (cloned.cityEquipmentContribution) { + cloned.cityEquipmentContribution = + cloneCityEquipmentContributionSnapshot( + cloned.cityEquipmentContribution + ); + } return cloned; } diff --git a/src/game/state/cityStayActions.ts b/src/game/state/cityStayActions.ts index 1a051a7..320f1f6 100644 --- a/src/game/state/cityStayActions.ts +++ b/src/game/state/cityStayActions.ts @@ -8,10 +8,14 @@ import { type CityStayId } from '../data/cityStays'; import { - getItem, itemInventoryLabel, type ItemDefinition } from '../data/battleItems'; +import { + createCityEquipmentEquipIntent, + findCanonicalCityEquipmentOffer, + normalizeCityEquipmentPurchaseCounts +} from '../data/cityEquipmentPreparation'; import { applyCampBondExp, applyCampVisitReward, @@ -63,13 +67,31 @@ export type CityEquipmentPurchaseResult = cityStay: CityStayDefinition; offer: CityEquipmentOffer; item: ItemDefinition; + purchaseNumber: number; campaign: CampaignState; } | CityStayActionFailure< 'invalid-city' | 'invalid-action' | 'restricted-item' | - 'insufficient-gold' + 'insufficient-gold' | + 'save-unavailable' + >; + +export type CityEquipmentEquipIntentResult = + | { + ok: true; + cityStay: CityStayDefinition; + offer: CityEquipmentOffer; + item: ItemDefinition; + purchaseNumber: number; + campaign: CampaignState; + } + | CityStayActionFailure< + 'invalid-city' | + 'invalid-action' | + 'not-purchased' | + 'save-unavailable' >; export function collectCityStayInformation( @@ -158,40 +180,126 @@ export function purchaseCityStayEquipment( return { ok: false, reason: 'invalid-city' }; } - const offer = cityStay.equipmentOffers.find((candidate) => candidate.id === actionId); - if (!offer) { + const canonicalOffer = findCanonicalCityEquipmentOffer( + cityStayId, + actionId + ); + if (!canonicalOffer) { return { ok: false, reason: 'invalid-action' }; } + const { offer, item } = canonicalOffer; - const item = getItem(offer.itemId); - if (item.id !== offer.itemId || item.slot !== offer.slot) { - return { ok: false, reason: 'invalid-action' }; - } if (item.rank !== 'common') { return { ok: false, reason: 'restricted-item' }; } - const campaign = getCampaignState(); - if (!campaignSupportsCityStay(campaign, cityStay)) { + const snapshot = getCampaignState(); + if (!campaignSupportsCityStay(snapshot, cityStay)) { return { ok: false, reason: 'invalid-city' }; } - if (campaign.gold < offer.price) { + if (snapshot.gold < offer.price) { return { ok: false, reason: 'insufficient-gold' }; } - campaign.gold -= offer.price; const label = itemInventoryLabel(item.id); - campaign.inventory[label] = (campaign.inventory[label] ?? 0) + 1; + const purchaseCounts = normalizeCityEquipmentPurchaseCounts( + snapshot.cityEquipmentPurchaseCounts + ); + const purchaseNumber = Math.min( + 99, + (purchaseCounts[offer.id] ?? 0) + 1 + ); + const updated: CampaignState = { + ...snapshot, + gold: snapshot.gold - offer.price, + inventory: { + ...snapshot.inventory, + [label]: (snapshot.inventory[label] ?? 0) + 1 + }, + cityEquipmentPurchaseCounts: { + ...purchaseCounts, + [offer.id]: purchaseNumber + } + }; + const persisted = persistPurchase(updated, snapshot); + if (!persisted) { + return { ok: false, reason: 'save-unavailable' }; + } return { ok: true, cityStay, offer, item, - campaign: saveCampaignState(campaign) + purchaseNumber, + campaign: persisted }; } +export function selectCityStayEquipmentForCamp( + cityStayId: CityStayId, + actionId: string +): CityEquipmentEquipIntentResult { + const cityStay = canonicalCityStay(cityStayId); + if (!cityStay) { + return { ok: false, reason: 'invalid-city' }; + } + const canonicalOffer = findCanonicalCityEquipmentOffer( + cityStayId, + actionId + ); + if (!canonicalOffer) { + return { ok: false, reason: 'invalid-action' }; + } + const snapshot = getCampaignState(); + if (!campaignSupportsCityStay(snapshot, cityStay)) { + return { ok: false, reason: 'invalid-city' }; + } + const purchaseNumber = + snapshot.cityEquipmentPurchaseCounts?.[canonicalOffer.offer.id] ?? 0; + const intent = createCityEquipmentEquipIntent({ + cityStayId, + offerId: canonicalOffer.offer.id, + purchaseNumber, + purchaseCounts: snapshot.cityEquipmentPurchaseCounts + }); + if (!intent) { + return { ok: false, reason: 'not-purchased' }; + } + const updated: CampaignState = { + ...snapshot, + cityEquipmentEquipIntent: intent + }; + const persisted = persistPurchase(updated, snapshot); + if (!persisted) { + return { ok: false, reason: 'save-unavailable' }; + } + return { + ok: true, + cityStay, + offer: canonicalOffer.offer, + item: canonicalOffer.item, + purchaseNumber, + campaign: persisted + }; +} + +function persistPurchase( + updated: CampaignState, + snapshot: CampaignState +) { + try { + return saveCampaignState(updated); + } catch { + try { + saveCampaignState(snapshot); + } catch { + // saveCampaignState restores the in-memory snapshot before persistence. + } + return undefined; + } +} + function canonicalCityStay(cityStayId: CityStayId) { return cityStayDefinitions.find((definition) => definition.id === cityStayId); }