feat: connect Xuzhou equipment to battle outcomes

This commit is contained in:
2026-07-27 15:39:11 +09:00
parent adf9e65668
commit 2737354e30
13 changed files with 5664 additions and 68 deletions

View File

@@ -206,9 +206,38 @@ RPG로 다듬는다.
- 전투 장면을 연속으로 재사용해도 이전 전투의 턴과 진영이 남지 않도록
매 전투를 `1턴 · 아군`에서 초기화한다.
다음 묶음 서주 체류에서 장비를 구매한 뒤 실제 무장에게 장착하고, 그
선택을 여덟 번째 전투의 출진 전 비교·전투 수치·결과 피드백까지 같은
장비 기록으로 이어지게 한다.
### 완료한 10차 묶음: 서주 구입 장비의 장착과 여덟 번째 전투 회수
- 서주 시장의 무기·방어구·보조구를 서로 다른 아이콘으로 보여 주고,
보유량과 해당 도시의 실제 구매 횟수를 함께 남긴다.
- 구입 뒤 `장착 비교`로 군영의 정확한 장비와 권장 무장에게 이어지되
자동 장착하지 않는다. 비교 취소는 장비·재고·준비 기록을 바꾸지 않는다.
- 시장에서 군영으로 넘어가는 장착 의도는 저장 슬롯에 남아 새로고침해도
복원되며, 대상 무장을 바꾸면 그 선택도 보존한다. 준비 확정 또는 해당
군영 종료 뒤에는 임시 의도를 제거한다.
- 현재 장비보다 전투 수치가 좋아지지 않는 후보는 준비 장비로 잘못
기록하지 않는다. 아직 전투 효과가 없는 군량 주머니는 이를 숨기지 않고
`보유 확인``추가 전투 기여 없음`으로 구분한다.
- 장비의 기존 능력치는 전투 계산에 이미 적용되므로 별도 강화 효과를
중복 가산하지 않는다. 같은 공격을 이전 장비로 계산한 기준값과 비교해
실제로 늘어난 피해나 줄어든 피해만 기여도로 집계한다.
- 출전 편성, 여덟 번째 전투 배치, 전투 HUD와 결과 보고에서 같은 도시,
구매 기록, 무장, 장비와 교체 수치를 사용한다. 준비 무장을 빼면
`미반영`으로 표시하고 자동으로 다시 편성하지 않는다.
- 실제 기여 횟수와 추가 피해·감소 피해를 전투 저장, 최신 보고서와 해당
전투 정산에 보존한다. 이후 장비를 바꿔도 과거의 정당한 기록은 지우지
않되, 위조된 장비·유령 무장 참조는 각 전투 당시 명단으로 걸러 낸다.
- 장비 교체 저장이 실패하면 재고와 장착 상태를 함께 되돌려 반쪽 상태가
남지 않게 한다.
- 전투 HUD와 결과가 강조하는 도시 장비는 `이번 출전의 대표 준비 1건`이다.
여러 장비를 장착하면 모든 능력치는 정상 적용되지만, 시장에서 군영으로
마지막 연결한 준비만 성과로 강조해 결과가 장비 목록처럼 번잡해지지
않도록 한다.
다음 묶음은 서주 관청에서 얻은 정보와 미축과 나눈 공명 선택을 여덟 번째
전투의 적 의도·보급 판단·동료 행동과 결과 회고로 각각 구분해 회수한다.
장비와 마찬가지로 이미 한 준비를 중복 강화하지 않고, 플레이어가 실제로
고른 한 가지 관점이 전투 중 눈에 보이는 판단 차이를 만들게 한다.
## 장기 개선 순서

View File

@@ -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",

View File

@@ -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: {

View 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
);
}

View File

@@ -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',

File diff suppressed because it is too large Load Diff

View 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);
}

View File

@@ -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()
},

View File

@@ -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,

View File

@@ -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;

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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);
}