feat: connect Xuzhou equipment to battle outcomes
This commit is contained in:
@@ -206,9 +206,38 @@ RPG로 다듬는다.
|
||||
- 전투 장면을 연속으로 재사용해도 이전 전투의 턴과 진영이 남지 않도록
|
||||
매 전투를 `1턴 · 아군`에서 초기화한다.
|
||||
|
||||
다음 묶음은 서주 체류에서 장비를 구매한 뒤 실제 무장에게 장착하고, 그
|
||||
선택을 여덟 번째 전투의 출진 전 비교·전투 수치·결과 피드백까지 같은
|
||||
장비 기록으로 이어지게 한다.
|
||||
### 완료한 10차 묶음: 서주 구입 장비의 장착과 여덟 번째 전투 회수
|
||||
|
||||
- 서주 시장의 무기·방어구·보조구를 서로 다른 아이콘으로 보여 주고,
|
||||
보유량과 해당 도시의 실제 구매 횟수를 함께 남긴다.
|
||||
- 구입 뒤 `장착 비교`로 군영의 정확한 장비와 권장 무장에게 이어지되
|
||||
자동 장착하지 않는다. 비교 취소는 장비·재고·준비 기록을 바꾸지 않는다.
|
||||
- 시장에서 군영으로 넘어가는 장착 의도는 저장 슬롯에 남아 새로고침해도
|
||||
복원되며, 대상 무장을 바꾸면 그 선택도 보존한다. 준비 확정 또는 해당
|
||||
군영 종료 뒤에는 임시 의도를 제거한다.
|
||||
- 현재 장비보다 전투 수치가 좋아지지 않는 후보는 준비 장비로 잘못
|
||||
기록하지 않는다. 아직 전투 효과가 없는 군량 주머니는 이를 숨기지 않고
|
||||
`보유 확인`과 `추가 전투 기여 없음`으로 구분한다.
|
||||
- 장비의 기존 능력치는 전투 계산에 이미 적용되므로 별도 강화 효과를
|
||||
중복 가산하지 않는다. 같은 공격을 이전 장비로 계산한 기준값과 비교해
|
||||
실제로 늘어난 피해나 줄어든 피해만 기여도로 집계한다.
|
||||
- 출전 편성, 여덟 번째 전투 배치, 전투 HUD와 결과 보고에서 같은 도시,
|
||||
구매 기록, 무장, 장비와 교체 수치를 사용한다. 준비 무장을 빼면
|
||||
`미반영`으로 표시하고 자동으로 다시 편성하지 않는다.
|
||||
- 실제 기여 횟수와 추가 피해·감소 피해를 전투 저장, 최신 보고서와 해당
|
||||
전투 정산에 보존한다. 이후 장비를 바꿔도 과거의 정당한 기록은 지우지
|
||||
않되, 위조된 장비·유령 무장 참조는 각 전투 당시 명단으로 걸러 낸다.
|
||||
- 장비 교체 저장이 실패하면 재고와 장착 상태를 함께 되돌려 반쪽 상태가
|
||||
남지 않게 한다.
|
||||
- 전투 HUD와 결과가 강조하는 도시 장비는 `이번 출전의 대표 준비 1건`이다.
|
||||
여러 장비를 장착하면 모든 능력치는 정상 적용되지만, 시장에서 군영으로
|
||||
마지막 연결한 준비만 성과로 강조해 결과가 장비 목록처럼 번잡해지지
|
||||
않도록 한다.
|
||||
|
||||
다음 묶음은 서주 관청에서 얻은 정보와 미축과 나눈 공명 선택을 여덟 번째
|
||||
전투의 적 의도·보급 판단·동료 행동과 결과 회고로 각각 구분해 회수한다.
|
||||
장비와 마찬가지로 이미 한 준비를 중복 강화하지 않고, 플레이어가 실제로
|
||||
고른 한 가지 관점이 전투 중 눈에 보이는 판단 차이를 만들게 한다.
|
||||
|
||||
## 장기 개선 순서
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
910
scripts/verify-city-equipment-preparation.mjs
Normal file
910
scripts/verify-city-equipment-preparation.mjs
Normal file
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
2350
scripts/verify-xuzhou-equipment-link-browser.mjs
Normal file
2350
scripts/verify-xuzhou-equipment-link-browser.mjs
Normal file
File diff suppressed because it is too large
Load Diff
559
src/game/data/cityEquipmentPreparation.ts
Normal file
559
src/game/data/cityEquipmentPreparation.ts
Normal file
@@ -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<string, number>;
|
||||
|
||||
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<CityEquipmentPurchaseCounts>(
|
||||
(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<EquipmentSet>((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<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof getItem>['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<typeof getItem>) {
|
||||
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<string, unknown>) {
|
||||
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;
|
||||
|
||||
@@ -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<string, BattleSaveCooperationStats>;
|
||||
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<string, unknown>,
|
||||
options: BattleSaveValidationOptions
|
||||
) {
|
||||
const normalized = canonicalCityEquipmentContributionSaveState(
|
||||
state.cityEquipmentContribution,
|
||||
state,
|
||||
options
|
||||
);
|
||||
if (normalized) {
|
||||
state.cityEquipmentContribution = normalized;
|
||||
} else {
|
||||
delete state.cityEquipmentContribution;
|
||||
}
|
||||
}
|
||||
|
||||
function isOptionalCityEquipmentContributionState(
|
||||
value: unknown,
|
||||
state: Record<string, unknown>,
|
||||
options: BattleSaveValidationOptions
|
||||
) {
|
||||
if (value === undefined) {
|
||||
return true;
|
||||
}
|
||||
const normalized = canonicalCityEquipmentContributionSaveState(
|
||||
value,
|
||||
state,
|
||||
options
|
||||
);
|
||||
if (!normalized || !isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const fields: Array<keyof BattleSaveCityEquipmentContributionState> = [
|
||||
'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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
options: BattleSaveValidationOptions
|
||||
|
||||
@@ -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<string, number>;
|
||||
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>): 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>): 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>): 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<string, C
|
||||
.sort((a, b) => (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<string>): 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user