feat: make Xuzhou choices shape battle eight

This commit is contained in:
2026-07-27 17:39:13 +09:00
parent 2737354e30
commit eec4896002
13 changed files with 4231 additions and 84 deletions

View File

@@ -37,6 +37,7 @@
"verify:city-equipment-preparation": "node scripts/verify-city-equipment-preparation.mjs", "verify:city-equipment-preparation": "node scripts/verify-city-equipment-preparation.mjs",
"verify:city-stays:browser": "node scripts/verify-city-stay-browser.mjs", "verify:city-stays:browser": "node scripts/verify-city-stay-browser.mjs",
"verify:xuzhou-equipment-link:browser": "node scripts/verify-xuzhou-equipment-link-browser.mjs", "verify:xuzhou-equipment-link:browser": "node scripts/verify-xuzhou-equipment-link-browser.mjs",
"verify:xuzhou-stay-payoff:browser": "node scripts/verify-xuzhou-stay-battle-payoff-browser.mjs",
"verify:prologue-village": "node scripts/verify-prologue-village-data.mjs", "verify: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-camp-followup": "node scripts/verify-first-battle-camp-followup.mjs",
"verify:first-battle-camaraderie": "node scripts/verify-first-battle-camaraderie-memory.mjs", "verify:first-battle-camaraderie": "node scripts/verify-first-battle-camaraderie-memory.mjs",
@@ -79,7 +80,7 @@
"verify:interaction-ux": "node scripts/verify-interaction-ux.mjs", "verify:interaction-ux": "node scripts/verify-interaction-ux.mjs",
"verify:save-flow": "node scripts/verify-save-retry-flow.mjs", "verify:save-flow": "node scripts/verify-save-retry-flow.mjs",
"verify:release": "node scripts/verify-release-candidate.mjs", "verify:release": "node scripts/verify-release-candidate.mjs",
"verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:city-stays:browser && pnpm run verify:xuzhou-equipment-link:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief:browser && pnpm run verify:third-battle-return:browser && pnpm run verify:third-camp-preparation:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance", "verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:city-stays:browser && pnpm run verify:xuzhou-equipment-link:browser && pnpm run verify:xuzhou-stay-payoff:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief:browser && pnpm run verify:third-battle-return:browser && pnpm run verify:third-camp-preparation:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance",
"verify:1.0": "pnpm run verify:local-release && pnpm run verify:flow && pnpm run qa:campaign-complete && pnpm run qa:battle-maps:webgl", "verify: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", "verify:public-deploy": "node scripts/verify-public-deploy.mjs",
"qa:representative": "node scripts/qa-representative-battles.mjs", "qa:representative": "node scripts/qa-representative-battles.mjs",

View File

@@ -19,6 +19,16 @@ try {
const { const {
cityEquipmentPreparationSelectionRecordId cityEquipmentPreparationSelectionRecordId
} = await server.ssrLoadModule('/src/game/data/cityEquipmentPreparation.ts'); } = await server.ssrLoadModule('/src/game/data/cityEquipmentPreparation.ts');
const {
xuzhouStayBattlePayoffRecordId,
xuzhouStayBattlePayoffSourceBattleId,
xuzhouStayBattlePayoffTargetBattleId,
xuzhouStayId,
xuzhouStayInformationVisitId,
xuzhouStayResonanceDialogueId,
xuzhouStayShareStorehouseDutyChoiceId,
xuzhouStayTrustMiZhuLedgerChoiceId
} = await server.ssrLoadModule('/src/game/data/xuzhouStayBattlePayoff.ts');
const options = { const options = {
expectedBattleId: 'first-battle-zhuo-commandery', expectedBattleId: 'first-battle-zhuo-commandery',
@@ -630,6 +640,280 @@ try {
'Expected legacy eighth-battle saves without city-equipment contribution progress to remain valid.' 'Expected legacy eighth-battle saves without city-equipment contribution progress to remain valid.'
); );
const eighthBattlePayoffOptions = {
...eighthBattleOptions,
validUnitIds: new Set([
...eighthBattleOptions.validUnitIds,
'mi-zhu'
]),
validAllyUnitIds: new Set([
...eighthBattleOptions.validAllyUnitIds,
'mi-zhu'
])
};
const miZhuBattleSaveUnit = {
...eighthBattleUnits.find((unit) => unit.id === 'guan-yu'),
id: 'mi-zhu',
hp: 28,
maxHp: 28,
attack: 7,
stats: {
might: 40,
intelligence: 74,
leadership: 70,
agility: 58,
luck: 82
},
x: 3,
y: 2,
equipment: createEquipmentSet()
};
const eighthBattlePayoffBaseState = {
...eighthBattleBaseState,
units: [
...eighthBattleUnits.map((unit) => ({
...unit,
stats: unit.stats ? { ...unit.stats } : undefined,
equipment: {
weapon: { ...unit.equipment.weapon },
armor: { ...unit.equipment.armor },
accessory: { ...unit.equipment.accessory }
}
})),
miZhuBattleSaveUnit
]
};
const xuzhouStayPayoffIdentity = {
version: 1,
recordId: xuzhouStayBattlePayoffRecordId,
cityStayId: xuzhouStayId,
sourceBattleId: xuzhouStayBattlePayoffSourceBattleId,
targetBattleId: xuzhouStayBattlePayoffTargetBattleId,
informationVisitId: xuzhouStayInformationVisitId,
dialogueId: xuzhouStayResonanceDialogueId
};
const validInformationOnlyPayoff = {
...xuzhouStayPayoffIdentity,
informationCompleted: true,
informationCounterplays: 2,
miZhuDeployed: true,
supportAttempts: 0,
supportUses: 0,
bonusHealing: 0,
bonusBuffTurns: 0
};
const validSharedDutyPayoff = {
...validInformationOnlyPayoff,
choiceId: xuzhouStayShareStorehouseDutyChoiceId,
supportAttempts: 2,
supportUses: 1,
bonusHealing: 4
};
const validEntrustedLedgerPayoff = {
...validInformationOnlyPayoff,
choiceId: xuzhouStayTrustMiZhuLedgerChoiceId,
supportAttempts: 1,
supportUses: 1,
bonusBuffTurns: 1
};
[
['information-only', validInformationOnlyPayoff],
['shared-duty choice', validSharedDutyPayoff],
['entrusted-ledger choice', validEntrustedLedgerPayoff]
].forEach(([label, xuzhouStayBattlePayoff]) => {
const candidate = {
...eighthBattlePayoffBaseState,
xuzhouStayBattlePayoff
};
const parsed = parseBattleSaveState(
JSON.stringify(candidate),
eighthBattlePayoffOptions
);
const parsedPayoff = parsed?.xuzhouStayBattlePayoff;
assert(
isValidBattleSaveState(candidate, eighthBattlePayoffOptions) &&
parsedPayoff &&
Object.keys(parsedPayoff).length ===
Object.keys(xuzhouStayBattlePayoff).length &&
Object.entries(xuzhouStayBattlePayoff).every(
([key, value]) => parsedPayoff[key] === value
) &&
parsedPayoff !== xuzhouStayBattlePayoff,
`Expected canonical ${label} Xuzhou payoff progress to round-trip as a deep-cloned battle-save field: ${JSON.stringify({
strict: isValidBattleSaveState(
candidate,
eighthBattlePayoffOptions
),
expected: xuzhouStayBattlePayoff,
actual: parsedPayoff
})}`
);
});
assert(
isValidBattleSaveState(
eighthBattlePayoffBaseState,
eighthBattlePayoffOptions
) &&
parseBattleSaveState(
JSON.stringify(eighthBattlePayoffBaseState),
eighthBattlePayoffOptions
)?.xuzhouStayBattlePayoff === undefined,
'Expected legacy eighth-battle saves without Xuzhou stay payoff progress to remain valid.'
);
const normalizedGhostMiZhuPayoff = normalizeBattleSaveState(
{
...eighthBattleBaseState,
xuzhouStayBattlePayoff: {
...validSharedDutyPayoff,
informationCounterplays: 1,
miZhuDeployed: true,
supportAttempts: 8,
supportUses: 1,
bonusHealing: 4
}
},
eighthBattleOptions
)?.xuzhouStayBattlePayoff;
assert(
normalizedGhostMiZhuPayoff?.miZhuDeployed === false &&
normalizedGhostMiZhuPayoff.supportAttempts === 0 &&
normalizedGhostMiZhuPayoff.supportUses === 0 &&
normalizedGhostMiZhuPayoff.bonusHealing === 0 &&
normalizedGhostMiZhuPayoff.bonusBuffTurns === 0,
`Expected a claimed Mi Zhu deployment absent from the saved roster to clear all support counters: ${JSON.stringify(normalizedGhostMiZhuPayoff)}`
);
const normalizedSharedDutyCrossEffect =
normalizeBattleSaveState(
{
...eighthBattlePayoffBaseState,
xuzhouStayBattlePayoff: {
...validSharedDutyPayoff,
bonusBuffTurns: 999
}
},
eighthBattlePayoffOptions
)?.xuzhouStayBattlePayoff;
const normalizedEntrustedLedgerCrossEffect =
normalizeBattleSaveState(
{
...eighthBattlePayoffBaseState,
xuzhouStayBattlePayoff: {
...validEntrustedLedgerPayoff,
bonusHealing: 999
}
},
eighthBattlePayoffOptions
)?.xuzhouStayBattlePayoff;
assert(
normalizedSharedDutyCrossEffect?.bonusHealing === 4 &&
normalizedSharedDutyCrossEffect.bonusBuffTurns === 0 &&
normalizedEntrustedLedgerCrossEffect?.bonusHealing === 0 &&
normalizedEntrustedLedgerCrossEffect.bonusBuffTurns === 1,
'Expected each Xuzhou resonance choice to remove the other choice effect during normalization.'
);
const normalizedOversizedXuzhouPayoff =
normalizeBattleSaveState(
{
...eighthBattlePayoffBaseState,
xuzhouStayBattlePayoff: {
...validSharedDutyPayoff,
informationCounterplays: Number.MAX_SAFE_INTEGER,
supportAttempts: Number.MAX_SAFE_INTEGER,
supportUses: Number.MAX_SAFE_INTEGER,
bonusHealing: Number.MAX_SAFE_INTEGER,
bonusBuffTurns: Number.MAX_SAFE_INTEGER
}
},
eighthBattlePayoffOptions
)?.xuzhouStayBattlePayoff;
assert(
normalizedOversizedXuzhouPayoff?.informationCounterplays ===
2 &&
normalizedOversizedXuzhouPayoff.supportAttempts ===
999999 &&
normalizedOversizedXuzhouPayoff.supportUses === 1 &&
normalizedOversizedXuzhouPayoff.bonusHealing === 4 &&
normalizedOversizedXuzhouPayoff.bonusBuffTurns === 0,
`Expected oversized Xuzhou payoff counters to clamp to canonical limits: ${JSON.stringify(normalizedOversizedXuzhouPayoff)}`
);
const forgedXuzhouPayoffCases = [
{
label: 'target battle identity',
payoff: {
...validSharedDutyPayoff,
targetBattleId: 'forged-target-battle'
}
},
{
label: 'source battle identity',
payoff: {
...validSharedDutyPayoff,
sourceBattleId: 'forged-source-battle'
}
},
{
label: 'information identity',
payoff: {
...validSharedDutyPayoff,
informationVisitId: 'forged-information'
}
},
{
label: 'dialogue identity',
payoff: {
...validSharedDutyPayoff,
dialogueId: 'forged-dialogue'
}
},
{
label: 'resonance choice',
payoff: {
...validSharedDutyPayoff,
choiceId: 'forged-choice'
}
}
];
forgedXuzhouPayoffCases.forEach(({ label, payoff }) => {
const candidate = {
...eighthBattlePayoffBaseState,
xuzhouStayBattlePayoff: payoff
};
const normalized = normalizeBattleSaveState(
candidate,
eighthBattlePayoffOptions
);
assert(
normalized?.xuzhouStayBattlePayoff === undefined &&
!isValidBattleSaveState(
candidate,
eighthBattlePayoffOptions
),
`Expected forged Xuzhou ${label} progress to be removed and rejected by strict validation.`
);
});
const payoffAttachedToWrongBattle = {
...validState,
xuzhouStayBattlePayoff: validSharedDutyPayoff
};
assert(
normalizeBattleSaveState(
payoffAttachedToWrongBattle,
options
)?.xuzhouStayBattlePayoff === undefined &&
!isValidBattleSaveState(
payoffAttachedToWrongBattle,
options
),
'Expected Xuzhou payoff progress attached to a non-eighth battle save to be removed and rejected by strict validation.'
);
const validSortieStatsState = { const validSortieStatsState = {
...validState, ...validState,
battleStats: { battleStats: {

View File

@@ -58,6 +58,16 @@ try {
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
const { campaignRecruitUnits } = await server.ssrLoadModule('/src/game/data/scenario.ts'); const { campaignRecruitUnits } = await server.ssrLoadModule('/src/game/data/scenario.ts');
const { normalizeSortieFormationAssignments } = await server.ssrLoadModule('/src/game/data/sortieDeployment.ts'); const { normalizeSortieFormationAssignments } = await server.ssrLoadModule('/src/game/data/sortieDeployment.ts');
const {
xuzhouStayBattlePayoffRecordId,
xuzhouStayBattlePayoffSourceBattleId,
xuzhouStayBattlePayoffTargetBattleId,
xuzhouStayId,
xuzhouStayInformationVisitId,
xuzhouStayResonanceDialogueId,
xuzhouStayShareStorehouseDutyChoiceId,
xuzhouStayTrustMiZhuLedgerChoiceId
} = await server.ssrLoadModule('/src/game/data/xuzhouStayBattlePayoff.ts');
const sortieStatsFixture = (unitId, overrides = {}) => ({ const sortieStatsFixture = (unitId, overrides = {}) => ({
unitId, unitId,
hpBefore: 30, hpBefore: 30,
@@ -2601,11 +2611,268 @@ try {
`Expected explicitly loaded corrupted slot timestamp to be normalized: ${JSON.stringify(corruptedSlot)}` `Expected explicitly loaded corrupted slot timestamp to be normalized: ${JSON.stringify(corruptedSlot)}`
); );
const eighthScenario =
battleScenarios[xuzhouStayBattlePayoffTargetBattleId];
const eighthAlliedUnits = eighthScenario.units.filter(
(unit) => unit.faction === 'ally'
);
const xuzhouStayPayoffIdentity = {
version: 1,
recordId: xuzhouStayBattlePayoffRecordId,
cityStayId: xuzhouStayId,
sourceBattleId: xuzhouStayBattlePayoffSourceBattleId,
targetBattleId: xuzhouStayBattlePayoffTargetBattleId,
informationVisitId: xuzhouStayInformationVisitId,
dialogueId: xuzhouStayResonanceDialogueId
};
const eighthReportBase = {
battleId: eighthScenario.id,
battleTitle: eighthScenario.title,
outcome: 'victory',
turnNumber: 8,
rewardGold: 0,
defeatedEnemies: 8,
totalEnemies: 8,
objectives: [],
units: eighthAlliedUnits,
bonds: eighthScenario.bonds.map((bond) => ({
...bond,
battleExp: 0
})),
itemRewards: [],
campaignRewards: {
supplies: [],
equipment: [],
reputation: [],
recruits: [],
unlocks: []
},
completedCampDialogues: [],
completedCampVisits: [],
createdAt: '2026-07-27T08:00:00.000Z'
};
const seedXuzhouStayMemory = (choiceId) => {
resetCampaignState();
setFirstBattleReport(seventhBattleReport);
const sourceState = getCampaignState();
sourceState.completedCampVisits = [
...new Set([
...sourceState.completedCampVisits,
xuzhouStayInformationVisitId
])
];
if (choiceId) {
sourceState.completedCampDialogues = [
...new Set([
...sourceState.completedCampDialogues,
xuzhouStayResonanceDialogueId
])
];
sourceState.campDialogueChoiceIds = {
...sourceState.campDialogueChoiceIds,
[xuzhouStayResonanceDialogueId]: choiceId
};
} else {
sourceState.completedCampDialogues =
sourceState.completedCampDialogues.filter(
(dialogueId) =>
dialogueId !== xuzhouStayResonanceDialogueId
);
delete sourceState.campDialogueChoiceIds[
xuzhouStayResonanceDialogueId
];
}
setCampaignState(sourceState);
};
const campaignPayoffFixtures = [
{
label: 'information-only',
choiceId: undefined,
snapshot: {
...xuzhouStayPayoffIdentity,
informationCompleted: true,
informationCounterplays: 2,
miZhuDeployed: true,
supportAttempts: 0,
supportUses: 0,
bonusHealing: 0,
bonusBuffTurns: 0
}
},
{
label: 'shared-duty choice',
choiceId: xuzhouStayShareStorehouseDutyChoiceId,
snapshot: {
...xuzhouStayPayoffIdentity,
informationCompleted: true,
choiceId: xuzhouStayShareStorehouseDutyChoiceId,
informationCounterplays: 2,
miZhuDeployed: true,
supportAttempts: 2,
supportUses: 1,
bonusHealing: 4,
bonusBuffTurns: 0
}
},
{
label: 'entrusted-ledger choice',
choiceId: xuzhouStayTrustMiZhuLedgerChoiceId,
snapshot: {
...xuzhouStayPayoffIdentity,
informationCompleted: true,
choiceId: xuzhouStayTrustMiZhuLedgerChoiceId,
informationCounterplays: 1,
miZhuDeployed: true,
supportAttempts: 1,
supportUses: 1,
bonusHealing: 0,
bonusBuffTurns: 1
}
}
];
seedXuzhouStayMemory(
xuzhouStayShareStorehouseDutyChoiceId
);
setFirstBattleReport({ ...eighthReportBase });
const legacyEighthCampaignState = loadCampaignState();
assert(
legacyEighthCampaignState.firstBattleReport
?.xuzhouStayBattlePayoff === undefined &&
legacyEighthCampaignState.battleHistory[
eighthScenario.id
]?.xuzhouStayBattlePayoff === undefined,
'Expected legacy eighth-battle campaign reports without a Xuzhou payoff field to remain valid.'
);
for (const fixture of campaignPayoffFixtures) {
seedXuzhouStayMemory(fixture.choiceId);
const inputSnapshot = { ...fixture.snapshot };
const normalizedReport = setFirstBattleReport({
...eighthReportBase,
xuzhouStayBattlePayoff: inputSnapshot
});
const recordedCampaign = loadCampaignState();
const reportSnapshot =
recordedCampaign.firstBattleReport
?.xuzhouStayBattlePayoff;
const historySnapshot =
recordedCampaign.battleHistory[eighthScenario.id]
?.xuzhouStayBattlePayoff;
assert(
flatRecordsEqual(
normalizedReport.xuzhouStayBattlePayoff,
fixture.snapshot
) &&
flatRecordsEqual(reportSnapshot, fixture.snapshot) &&
flatRecordsEqual(historySnapshot, fixture.snapshot) &&
reportSnapshot !== historySnapshot,
`Expected canonical ${fixture.label} payoff to copy from the eighth-battle report into battle history: ${JSON.stringify({
normalizedReport:
normalizedReport.xuzhouStayBattlePayoff,
reportSnapshot,
historySnapshot
})}`
);
inputSnapshot.informationCounterplays = 0;
inputSnapshot.supportAttempts = 0;
inputSnapshot.supportUses = 0;
inputSnapshot.bonusHealing = 0;
inputSnapshot.bonusBuffTurns = 0;
const detachedCampaign = getCampaignState();
assert(
flatRecordsEqual(
detachedCampaign.battleHistory[eighthScenario.id]
?.xuzhouStayBattlePayoff,
fixture.snapshot
),
`Expected later mutations of the report input not to alter the stored ${fixture.label} settlement snapshot.`
);
if (
fixture.choiceId ===
xuzhouStayShareStorehouseDutyChoiceId
) {
const changedCurrentState = getCampaignState();
changedCurrentState.campDialogueChoiceIds = {
...changedCurrentState.campDialogueChoiceIds,
[xuzhouStayResonanceDialogueId]:
xuzhouStayTrustMiZhuLedgerChoiceId
};
changedCurrentState.roster =
changedCurrentState.roster.map((unit) =>
unit.id === 'mi-zhu'
? {
...unit,
hp: 1,
equipment: {
weapon: {
itemId: 'training-sword',
level: 1,
exp: 0
},
armor: {
itemId: 'cloth-armor',
level: 1,
exp: 0
},
accessory: {
itemId: 'grain-pouch',
level: 1,
exp: 0
}
}
}
: unit
);
changedCurrentState.selectedSortieUnitIds =
changedCurrentState.selectedSortieUnitIds.filter(
(unitId) => unitId !== 'mi-zhu'
);
setCampaignState(changedCurrentState);
const historicalCampaign = loadCampaignState();
assert(
flatRecordsEqual(
historicalCampaign.firstBattleReport
?.xuzhouStayBattlePayoff,
fixture.snapshot
) &&
flatRecordsEqual(
historicalCampaign.battleHistory[
eighthScenario.id
]?.xuzhouStayBattlePayoff,
fixture.snapshot
),
`Expected completed Xuzhou payoff history to remain a report/settlement snapshot instead of being recomputed from later resonance or roster state: ${JSON.stringify({
report:
historicalCampaign.firstBattleReport
?.xuzhouStayBattlePayoff,
history:
historicalCampaign.battleHistory[
eighthScenario.id
]?.xuzhouStayBattlePayoff
})}`
);
}
}
console.log('Verified campaign save normalization, reward resettlement, camp reward idempotence, malformed-field/roster-equipment/bond-growth/bond-roster/report-reference/sortie-roster/sortie-preset/sortie-performance/sortie-review/sortie-order/latest-history/detail recovery, sortie performance/review/order legacy/deep-clone/retry/reward-idempotence safety, timestamp-safe history/slot recovery, unknown-step/report/history recovery, unsupported-version rejection, and slotted fallback.'); console.log('Verified campaign save normalization, reward resettlement, camp reward idempotence, malformed-field/roster-equipment/bond-growth/bond-roster/report-reference/sortie-roster/sortie-preset/sortie-performance/sortie-review/sortie-order/latest-history/detail recovery, sortie performance/review/order legacy/deep-clone/retry/reward-idempotence safety, timestamp-safe history/slot recovery, unknown-step/report/history recovery, unsupported-version rejection, and slotted fallback.');
} finally { } finally {
await server.close(); await server.close();
} }
function flatRecordsEqual(left, right) {
if (!left || !right) {
return left === right;
}
const leftEntries = Object.entries(left);
return (
leftEntries.length === Object.keys(right).length &&
leftEntries.every(([key, value]) => right[key] === value)
);
}
function assert(condition, message) { function assert(condition, message) {
if (!condition) { if (!condition) {
throw new Error(message); throw new Error(message);

View File

@@ -23,6 +23,7 @@ const checks = [
'scripts/verify-camp-reward-data.mjs', 'scripts/verify-camp-reward-data.mjs',
'scripts/verify-city-stay-data.mjs', 'scripts/verify-city-stay-data.mjs',
'scripts/verify-city-equipment-preparation.mjs', 'scripts/verify-city-equipment-preparation.mjs',
'scripts/verify-xuzhou-stay-battle-payoff.mjs',
'scripts/verify-prologue-village-data.mjs', 'scripts/verify-prologue-village-data.mjs',
'scripts/verify-first-battle-camp-followup.mjs', 'scripts/verify-first-battle-camp-followup.mjs',
'scripts/verify-first-battle-camaraderie-memory.mjs', 'scripts/verify-first-battle-camaraderie-memory.mjs',

View File

@@ -1936,10 +1936,10 @@ function assertBattleResultContribution(
assert( assert(
result.visibleResultTexts.some((text) => result.visibleResultTexts.some((text) =>
text.includes( text.includes(
battle.cityEquipmentPreparation.resultText battle.cityEquipmentPreparation.compactResultText
) )
), ),
`${renderer}: visible result subtitle must contain the persisted contribution feedback: ${JSON.stringify( `${renderer}: visible result subtitle must contain the compact persisted contribution feedback: ${JSON.stringify(
result.visibleResultTexts result.visibleResultTexts
)}` )}`
); );

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,575 @@
import assert from 'node:assert/strict';
import { createServer } from 'vite';
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true, hmr: false },
appType: 'custom'
});
try {
const payoff = await server.ssrLoadModule(
'/src/game/data/xuzhouStayBattlePayoff.ts'
);
const cityStays = await server.ssrLoadModule(
'/src/game/data/cityStays.ts'
);
const battleUsables = await server.ssrLoadModule(
'/src/game/data/battleUsables.ts'
);
const scenario = await server.ssrLoadModule(
'/src/game/data/scenario.ts'
);
verifyCanonicalDefinition({
payoff,
cityStays,
battleUsables,
scenario
});
const memories = verifyCampaignResolution(payoff);
verifyRuntimeAndSnapshotNormalization(payoff, memories);
console.log(
'Xuzhou stay battle payoff verification passed (canonical city provenance, source-victory precedence, independent information/dialogue resolution, target-battle isolation, first-three-turn aid/encourage effects, counterplay bounds, Mi Zhu deployment neutralization, choice-exclusive counters, snapshot cloning, and forged-state rejection).'
);
} finally {
await server.close();
}
function verifyCanonicalDefinition({
payoff,
cityStays,
battleUsables,
scenario
}) {
const city = cityStays.getCityStayDefinition('xuzhou');
const definition = payoff.xuzhouStayBattlePayoffDefinition;
assert.equal(definition.version, 1);
assert.equal(definition.recordId, 'xuzhou-stay-battle-payoff');
assert.equal(definition.cityStayId, city.id);
assert.equal(definition.sourceBattleId, city.afterBattleId);
assert.equal(definition.targetBattleId, city.nextBattleId);
assert.equal(definition.information.visitId, city.information.id);
assert.equal(definition.dialogue.dialogueId, city.dialogue.id);
assert.deepEqual(
Object.keys(definition.dialogue.choices),
city.dialogue.choices.map((choice) => choice.id),
'Both canonical Xuzhou dialogue choices must have a battle payoff.'
);
assert.deepEqual(payoff.xuzhouStayBattlePayoffChoiceIds, [
'city-xuzhou-share-storehouse-duty',
'city-xuzhou-trust-mi-zhu-ledger'
]);
assert.deepEqual(definition.information.effect, {
kind: 'deployment-intent-preview',
enemyUnitIds: ['xiaopei-guard-a', 'xiaopei-guard-b'],
previewCount: 2
});
const eighthBattleEnemyIds = new Set(
scenario.eighthBattleUnits
.filter((unit) => unit.faction === 'enemy')
.map((unit) => unit.id)
);
definition.information.effect.enemyUnitIds.forEach((unitId) => {
assert(
eighthBattleEnemyIds.has(unitId),
`Information preview target is missing from battle 8: ${unitId}`
);
});
const aid = payoff.getXuzhouStayResonanceEffect(
'city-xuzhou-share-storehouse-duty'
);
assert.deepEqual(aid, {
kind: 'mi-zhu-first-aid-healing',
actorUnitId: 'mi-zhu',
usableId: 'aid',
activeThroughTurn: 3,
bonusHealing: 4,
maxBattleUses: 1
});
const encourage = payoff.getXuzhouStayResonanceEffect(
'city-xuzhou-trust-mi-zhu-ledger'
);
assert.deepEqual(encourage, {
kind: 'mi-zhu-first-encourage-duration',
actorUnitId: 'mi-zhu',
usableId: 'encourage',
activeThroughTurn: 3,
bonusBuffTurns: 1,
maxBattleUses: 1
});
assert.equal(
payoff.getXuzhouStayResonanceEffect('forged-choice'),
undefined
);
assert(
battleUsables.unitStrategyIds['mi-zhu'].includes(aid.usableId),
'Mi Zhu must know aid before his aid payoff can be applied.'
);
assert(
battleUsables.unitStrategyIds['mi-zhu'].includes(
encourage.usableId
),
'Mi Zhu must know encourage before his encourage payoff can be applied.'
);
assert.equal(
battleUsables.usableCatalog.encourage.duration + encourage.bonusBuffTurns,
3,
'The entrusted ledger choice should extend encourage from two to three turns.'
);
assert.equal(payoff.isXuzhouStayBattlePayoffActiveTurn(1), true);
assert.equal(payoff.isXuzhouStayBattlePayoffActiveTurn(3), true);
assert.equal(payoff.isXuzhouStayBattlePayoffActiveTurn(0), false);
assert.equal(payoff.isXuzhouStayBattlePayoffActiveTurn(4), false);
assert.equal(payoff.isXuzhouStayBattlePayoffActiveTurn(2.5), false);
}
function verifyCampaignResolution(payoff) {
const sourceBattleId =
payoff.xuzhouStayBattlePayoffSourceBattleId;
const targetBattleId =
payoff.xuzhouStayBattlePayoffTargetBattleId;
const victory = {
battleId: sourceBattleId,
outcome: 'victory'
};
const baseCampaign = {
battleHistory: {
[sourceBattleId]: victory
},
firstBattleReport: null,
completedCampVisits: [],
completedCampDialogues: [],
campDialogueChoiceIds: {}
};
assert.equal(
payoff.resolveXuzhouStayBattlePayoff(baseCampaign),
undefined,
'A victory without either city activity must not create a payoff.'
);
assert.equal(
payoff.resolveXuzhouStayBattlePayoff(
{
...baseCampaign,
completedCampVisits: [payoff.xuzhouStayInformationVisitId]
},
'ninth-battle-xuzhou-gate-night-raid'
),
undefined,
'Xuzhou preparation must not leak beyond battle 8.'
);
const informationOnly =
payoff.resolveXuzhouStayBattlePayoff({
...baseCampaign,
completedCampVisits: [
'unrelated-visit',
payoff.xuzhouStayInformationVisitId
]
});
assert(informationOnly);
assert.equal(informationOnly.recordSource, 'battle-history');
assert.equal(informationOnly.informationCompleted, true);
assert.equal(informationOnly.choiceId, undefined);
assert.deepEqual(informationOnly.informationEffect, {
kind: 'deployment-intent-preview',
enemyUnitIds: ['xiaopei-guard-a', 'xiaopei-guard-b'],
previewCount: 2
});
assert.notEqual(
informationOnly.informationEffect.enemyUnitIds,
payoff.xuzhouStayInformationEnemyUnitIds,
'Resolved memories must not expose the canonical tuple by reference.'
);
const aidOnly = payoff.resolveXuzhouStayBattlePayoff({
...baseCampaign,
completedCampDialogues: [
payoff.xuzhouStayResonanceDialogueId
],
campDialogueChoiceIds: {
[payoff.xuzhouStayResonanceDialogueId]:
payoff.xuzhouStayShareStorehouseDutyChoiceId
}
});
assert(aidOnly);
assert.equal(aidOnly.informationCompleted, false);
assert.equal(
aidOnly.choiceId,
payoff.xuzhouStayShareStorehouseDutyChoiceId
);
assert.equal(
aidOnly.resonanceEffect.kind,
'mi-zhu-first-aid-healing'
);
const bothWithEncourage =
payoff.resolveXuzhouStayBattlePayoff({
...baseCampaign,
completedCampVisits: [
payoff.xuzhouStayInformationVisitId
],
completedCampDialogues: [
payoff.xuzhouStayResonanceDialogueId
],
campDialogueChoiceIds: {
[payoff.xuzhouStayResonanceDialogueId]:
payoff.xuzhouStayTrustMiZhuLedgerChoiceId
}
});
assert(bothWithEncourage);
assert.equal(bothWithEncourage.informationCompleted, true);
assert.equal(
bothWithEncourage.choiceId,
payoff.xuzhouStayTrustMiZhuLedgerChoiceId
);
assert.equal(
bothWithEncourage.resonanceEffect.kind,
'mi-zhu-first-encourage-duration'
);
assert.equal(
payoff.resolveXuzhouStayBattlePayoff({
...baseCampaign,
completedCampDialogues: [],
campDialogueChoiceIds: {
[payoff.xuzhouStayResonanceDialogueId]:
payoff.xuzhouStayShareStorehouseDutyChoiceId
}
}),
undefined,
'A remembered choice without completed dialogue is not evidence.'
);
assert.equal(
payoff.resolveXuzhouStayBattlePayoff({
...baseCampaign,
completedCampDialogues: [
payoff.xuzhouStayResonanceDialogueId
],
campDialogueChoiceIds: {
[payoff.xuzhouStayResonanceDialogueId]: 'forged-choice'
}
}),
undefined,
'A completed dialogue with a forged choice must stay neutral.'
);
const informationSurvivesForgedDialogue =
payoff.resolveXuzhouStayBattlePayoff({
...baseCampaign,
completedCampVisits: [
payoff.xuzhouStayInformationVisitId
],
completedCampDialogues: [
payoff.xuzhouStayResonanceDialogueId
],
campDialogueChoiceIds: {
[payoff.xuzhouStayResonanceDialogueId]: 'forged-choice'
}
});
assert(informationSurvivesForgedDialogue);
assert.equal(
informationSurvivesForgedDialogue.informationCompleted,
true
);
assert.equal(
informationSurvivesForgedDialogue.choiceId,
undefined,
'Invalid dialogue data must not erase independently earned information.'
);
const legacyInformation =
payoff.resolveXuzhouStayBattlePayoff({
...baseCampaign,
battleHistory: {},
firstBattleReport: victory,
completedCampVisits: [
payoff.xuzhouStayInformationVisitId
]
});
assert(legacyInformation);
assert.equal(legacyInformation.recordSource, 'legacy-report');
assert.equal(
payoff.resolveXuzhouStayBattlePayoff({
...baseCampaign,
battleHistory: {
[sourceBattleId]: {
battleId: sourceBattleId,
outcome: 'defeat'
}
},
firstBattleReport: victory,
completedCampVisits: [
payoff.xuzhouStayInformationVisitId
]
}),
undefined,
'An explicit invalid history entry must shadow a stale legacy report.'
);
assert.equal(
payoff.resolveXuzhouStayBattlePayoff({
...baseCampaign,
battleHistory: {},
firstBattleReport: {
battleId: targetBattleId,
outcome: 'victory'
},
completedCampVisits: [
payoff.xuzhouStayInformationVisitId
]
}),
undefined,
'A victory from another battle must not authorize this payoff.'
);
return {
informationOnly,
aidOnly,
bothWithEncourage
};
}
function verifyRuntimeAndSnapshotNormalization(payoff, memories) {
const aidRuntime =
payoff.createXuzhouStayBattlePayoffRuntime(
memories.aidOnly,
{
miZhuDeployed: true,
expectedRosterUnitIds: ['liu-bei', 'mi-zhu']
}
);
assert.deepEqual(aidRuntime, {
version: 1,
recordId: 'xuzhou-stay-battle-payoff',
cityStayId: 'xuzhou',
sourceBattleId: 'seventh-battle-xuzhou-rescue',
targetBattleId: 'eighth-battle-xiaopei-supply-road',
informationVisitId: 'city-xuzhou-xiaopei-ledger',
dialogueId: 'city-xuzhou-liu-mi-supply-trust',
informationCompleted: false,
choiceId: 'city-xuzhou-share-storehouse-duty',
informationCounterplays: 0,
miZhuDeployed: true,
supportAttempts: 0,
supportUses: 0,
bonusHealing: 0,
bonusBuffTurns: 0
});
const saturatedAid =
payoff.normalizeXuzhouStayBattlePayoffRuntime(
{
...aidRuntime,
informationCounterplays: 99,
supportAttempts: 4.9,
supportUses: 8,
bonusHealing: 99,
bonusBuffTurns: 99
},
{
expectedBattleId:
payoff.xuzhouStayBattlePayoffTargetBattleId,
expectedRosterUnitIds: new Set(['mi-zhu']),
expectedMemory: memories.aidOnly
}
);
assert.deepEqual(saturatedAid, {
...aidRuntime,
supportAttempts: 4,
supportUses: 1,
bonusHealing: 4
});
const zeroBenefitAid =
payoff.normalizeXuzhouStayBattlePayoffRuntime({
...aidRuntime,
supportAttempts: 1,
supportUses: 1,
bonusHealing: 0
});
assert.equal(
zeroBenefitAid.supportUses,
0,
'Aid cannot be consumed unless it provided at least one point of real bonus healing.'
);
assert.equal(zeroBenefitAid.bonusHealing, 0);
const informationRuntime =
payoff.createXuzhouStayBattlePayoffRuntime(
memories.informationOnly,
{
miZhuDeployed: false,
expectedRosterUnitIds: ['liu-bei']
}
);
const boundedInformation =
payoff.normalizeXuzhouStayBattlePayoffRuntime({
...informationRuntime,
informationCounterplays: 500,
supportAttempts: 3,
supportUses: 1,
bonusHealing: 4,
bonusBuffTurns: 1
});
assert.equal(boundedInformation.informationCounterplays, 2);
assert.equal(boundedInformation.supportAttempts, 0);
assert.equal(boundedInformation.supportUses, 0);
assert.equal(boundedInformation.bonusHealing, 0);
assert.equal(boundedInformation.bonusBuffTurns, 0);
const neutralizedGhost =
payoff.normalizeXuzhouStayBattlePayoffRuntime(
{
...saturatedAid,
informationCompleted: true,
informationCounterplays: 2,
miZhuDeployed: true
},
{
expectedRosterUnitIds: ['liu-bei']
}
);
assert.equal(neutralizedGhost.miZhuDeployed, false);
assert.equal(neutralizedGhost.supportAttempts, 0);
assert.equal(neutralizedGhost.supportUses, 0);
assert.equal(neutralizedGhost.bonusHealing, 0);
assert.equal(neutralizedGhost.bonusBuffTurns, 0);
assert.equal(
neutralizedGhost.informationCounterplays,
2,
'Mi Zhu deployment must not erase the independent information payoff.'
);
const rosterRepairsDeployment =
payoff.normalizeXuzhouStayBattlePayoffRuntime(
{
...aidRuntime,
miZhuDeployed: false,
supportAttempts: 2,
supportUses: 1,
bonusHealing: 3
},
{
expectedRosterUnitIds: ['mi-zhu']
}
);
assert.equal(rosterRepairsDeployment.miZhuDeployed, true);
assert.equal(rosterRepairsDeployment.supportAttempts, 2);
assert.equal(rosterRepairsDeployment.supportUses, 1);
assert.equal(rosterRepairsDeployment.bonusHealing, 3);
const encourageRuntime =
payoff.createXuzhouStayBattlePayoffRuntime(
memories.bothWithEncourage,
{
miZhuDeployed: true,
expectedRosterUnitIds: ['mi-zhu']
}
);
const appliedEncourage =
payoff.normalizeXuzhouStayBattlePayoffRuntime(
{
...encourageRuntime,
informationCounterplays: 2,
supportAttempts: 3,
supportUses: 7,
bonusHealing: 50,
bonusBuffTurns: 50
},
{
expectedMemory: memories.bothWithEncourage
}
);
assert.equal(appliedEncourage.informationCounterplays, 2);
assert.equal(appliedEncourage.supportAttempts, 3);
assert.equal(appliedEncourage.supportUses, 1);
assert.equal(appliedEncourage.bonusHealing, 0);
assert.equal(appliedEncourage.bonusBuffTurns, 1);
const noEncourageUse =
payoff.normalizeXuzhouStayBattlePayoffRuntime({
...encourageRuntime,
supportAttempts: 1,
supportUses: 0,
bonusBuffTurns: 99
});
assert.equal(noEncourageUse.supportAttempts, 1);
assert.equal(noEncourageUse.supportUses, 0);
assert.equal(noEncourageUse.bonusBuffTurns, 0);
const snapshot =
payoff.createXuzhouStayBattlePayoffSnapshot(
appliedEncourage,
{
expectedBattleId:
payoff.xuzhouStayBattlePayoffTargetBattleId,
expectedRosterUnitIds: ['mi-zhu']
}
);
assert.deepEqual(snapshot, appliedEncourage);
assert.notEqual(snapshot, appliedEncourage);
const snapshotClone =
payoff.cloneXuzhouStayBattlePayoffSnapshot(snapshot);
const runtimeClone =
payoff.cloneXuzhouStayBattlePayoffRuntime(appliedEncourage);
assert.deepEqual(snapshotClone, snapshot);
assert.deepEqual(runtimeClone, appliedEncourage);
assert.notEqual(snapshotClone, snapshot);
assert.notEqual(runtimeClone, appliedEncourage);
assert.equal(
payoff.normalizeXuzhouStayBattlePayoffSnapshot(snapshot, {
expectedBattleId: 'ninth-battle-xuzhou-gate-night-raid'
}),
undefined,
'A containing report from another battle must reject the snapshot.'
);
assert.equal(
payoff.normalizeXuzhouStayBattlePayoffSnapshot(
{
...snapshot,
targetBattleId: 'forged-battle'
}
),
undefined
);
assert.equal(
payoff.normalizeXuzhouStayBattlePayoffSnapshot(
{
...snapshot,
choiceId: 'forged-choice'
}
),
undefined
);
assert.equal(
payoff.normalizeXuzhouStayBattlePayoffSnapshot(
{
...snapshot,
informationCompleted: false
},
{
expectedMemory: memories.bothWithEncourage
}
),
undefined,
'Loaded runtime evidence must match the campaign-derived memory.'
);
assert.equal(
payoff.normalizeXuzhouStayBattlePayoffSnapshot(
{
...snapshot,
choiceId: payoff.xuzhouStayShareStorehouseDutyChoiceId
},
{
expectedMemory: memories.bothWithEncourage
}
),
undefined,
'A save cannot exchange one completed resonance choice for another.'
);
}

View File

@@ -24,6 +24,7 @@ export type CityResonanceDialogueChoice = {
label: string; label: string;
response: string; response: string;
rewardExp: number; rewardExp: number;
battleEffect?: string;
}; };
export type CityResonanceDialogue = { export type CityResonanceDialogue = {
@@ -70,8 +71,8 @@ export const cityStayDefinitions: readonly CityStayDefinition[] = [
location: '서주 관청', location: '서주 관청',
description: '관청의 창고 장부와 상인들의 증언을 맞추어 소패로 향하는 보급로의 약한 지점을 찾습니다.', description: '관청의 창고 장부와 상인들의 증언을 맞추어 소패로 향하는 보급로의 약한 지점을 찾습니다.',
briefingLines: [ briefingLines: [
'소패의 창고는 서쪽 길목과 마을 어귀를 통해 보급을 받습니다.', '창고 보급은 서쪽 길목으로 들어오며, 장부의 빈틈을 노린 여포군 기병은 동쪽 길에서 창고를 우회 압박합니다.',
'습격대보다 먼저 마을과 창고 주변을 안정시키면 고순의 압박을 견디기 쉬워집니다.' '마을 어귀와 창고 습격대의 움직임을 배치 전에 확인해 먼저 대응하십시오.'
], ],
itemReward: '소패 보급로 장부 1' itemReward: '소패 보급로 장부 1'
}, },
@@ -90,13 +91,15 @@ export const cityStayDefinitions: readonly CityStayDefinition[] = [
id: 'city-xuzhou-share-storehouse-duty', id: 'city-xuzhou-share-storehouse-duty',
label: '창고와 백성의 몫을 함께 지킨다', label: '창고와 백성의 몫을 함께 지킨다',
response: '미축은 군량과 구휼 장부를 나누어 적고, 어느 쪽도 희생시키지 않겠다고 답했습니다.', response: '미축은 군량과 구휼 장부를 나누어 적고, 어느 쪽도 희생시키지 않겠다고 답했습니다.',
rewardExp: 10 rewardExp: 12,
battleEffect: '8차 전투 3턴까지 미축의 첫 ‘응급’ 실제 회복량 +4'
}, },
{ {
id: 'city-xuzhou-trust-mi-zhu-ledger', id: 'city-xuzhou-trust-mi-zhu-ledger',
label: '보급 판단을 미축에게 맡긴다', label: '보급 판단을 미축에게 맡긴다',
response: '미축은 유비의 신뢰에 고개를 숙이고 소패까지 이어지는 상단과 창고를 직접 정리했습니다.', response: '미축은 유비의 신뢰에 고개를 숙이고 소패까지 이어지는 상단과 창고를 직접 정리했습니다.',
rewardExp: 12 rewardExp: 12,
battleEffect: '8차 전투 3턴까지 미축의 첫 ‘격려’ 지속 +1턴'
} }
] ]
}, },

View File

@@ -0,0 +1,511 @@
export const xuzhouStayBattlePayoffVersion = 1 as const;
export const xuzhouStayBattlePayoffRecordId =
'xuzhou-stay-battle-payoff' as const;
export const xuzhouStayId = 'xuzhou' as const;
export const xuzhouStayBattlePayoffSourceBattleId =
'seventh-battle-xuzhou-rescue' as const;
export const xuzhouStayBattlePayoffTargetBattleId =
'eighth-battle-xiaopei-supply-road' as const;
export const xuzhouStayInformationVisitId =
'city-xuzhou-xiaopei-ledger' as const;
export const xuzhouStayResonanceDialogueId =
'city-xuzhou-liu-mi-supply-trust' as const;
export const xuzhouStayShareStorehouseDutyChoiceId =
'city-xuzhou-share-storehouse-duty' as const;
export const xuzhouStayTrustMiZhuLedgerChoiceId =
'city-xuzhou-trust-mi-zhu-ledger' as const;
export const xuzhouStayMiZhuUnitId = 'mi-zhu' as const;
export const xuzhouStayBattlePayoffActiveThroughTurn = 3 as const;
export const xuzhouStayInformationEnemyUnitIds = [
'xiaopei-guard-a',
'xiaopei-guard-b'
] as const;
export const xuzhouStayBattlePayoffChoiceIds = [
xuzhouStayShareStorehouseDutyChoiceId,
xuzhouStayTrustMiZhuLedgerChoiceId
] as const;
export type XuzhouStayBattlePayoffChoiceId =
(typeof xuzhouStayBattlePayoffChoiceIds)[number];
export type XuzhouStayInformationEffect = {
kind: 'deployment-intent-preview';
enemyUnitIds: readonly string[];
previewCount: 2;
};
export type XuzhouStayAidEffect = {
kind: 'mi-zhu-first-aid-healing';
actorUnitId: typeof xuzhouStayMiZhuUnitId;
usableId: 'aid';
activeThroughTurn: typeof xuzhouStayBattlePayoffActiveThroughTurn;
bonusHealing: 4;
maxBattleUses: 1;
};
export type XuzhouStayEncourageEffect = {
kind: 'mi-zhu-first-encourage-duration';
actorUnitId: typeof xuzhouStayMiZhuUnitId;
usableId: 'encourage';
activeThroughTurn: typeof xuzhouStayBattlePayoffActiveThroughTurn;
bonusBuffTurns: 1;
maxBattleUses: 1;
};
export type XuzhouStayResonanceEffect =
| XuzhouStayAidEffect
| XuzhouStayEncourageEffect;
export const xuzhouStayBattlePayoffDefinition = {
version: xuzhouStayBattlePayoffVersion,
recordId: xuzhouStayBattlePayoffRecordId,
cityStayId: xuzhouStayId,
sourceBattleId: xuzhouStayBattlePayoffSourceBattleId,
targetBattleId: xuzhouStayBattlePayoffTargetBattleId,
information: {
visitId: xuzhouStayInformationVisitId,
effect: {
kind: 'deployment-intent-preview',
enemyUnitIds: xuzhouStayInformationEnemyUnitIds,
previewCount: 2
} satisfies XuzhouStayInformationEffect
},
dialogue: {
dialogueId: xuzhouStayResonanceDialogueId,
choices: {
[xuzhouStayShareStorehouseDutyChoiceId]: {
kind: 'mi-zhu-first-aid-healing',
actorUnitId: xuzhouStayMiZhuUnitId,
usableId: 'aid',
activeThroughTurn:
xuzhouStayBattlePayoffActiveThroughTurn,
bonusHealing: 4,
maxBattleUses: 1
} satisfies XuzhouStayAidEffect,
[xuzhouStayTrustMiZhuLedgerChoiceId]: {
kind: 'mi-zhu-first-encourage-duration',
actorUnitId: xuzhouStayMiZhuUnitId,
usableId: 'encourage',
activeThroughTurn:
xuzhouStayBattlePayoffActiveThroughTurn,
bonusBuffTurns: 1,
maxBattleUses: 1
} satisfies XuzhouStayEncourageEffect
}
}
} as const;
export type XuzhouStayBattleRecord = {
battleId?: unknown;
outcome?: unknown;
};
export type XuzhouStayBattlePayoffCampaignState = {
battleHistory?: Readonly<
Record<string, XuzhouStayBattleRecord | null | undefined>
>;
firstBattleReport?: XuzhouStayBattleRecord | null;
completedCampVisits?: readonly unknown[];
completedCampDialogues?: readonly unknown[];
campDialogueChoiceIds?: Readonly<Record<string, unknown>> | null;
};
export type XuzhouStayBattlePayoffRecordSource =
| 'battle-history'
| 'legacy-report';
export type ResolvedXuzhouStayBattlePayoff = {
recordId: typeof xuzhouStayBattlePayoffRecordId;
cityStayId: typeof xuzhouStayId;
sourceBattleId: typeof xuzhouStayBattlePayoffSourceBattleId;
targetBattleId: typeof xuzhouStayBattlePayoffTargetBattleId;
informationVisitId: typeof xuzhouStayInformationVisitId;
dialogueId: typeof xuzhouStayResonanceDialogueId;
recordSource: XuzhouStayBattlePayoffRecordSource;
informationCompleted: boolean;
informationEffect?: XuzhouStayInformationEffect;
choiceId?: XuzhouStayBattlePayoffChoiceId;
resonanceEffect?: XuzhouStayResonanceEffect;
};
export type XuzhouStayBattlePayoffSnapshot = {
version: typeof xuzhouStayBattlePayoffVersion;
recordId: typeof xuzhouStayBattlePayoffRecordId;
cityStayId: typeof xuzhouStayId;
sourceBattleId: typeof xuzhouStayBattlePayoffSourceBattleId;
targetBattleId: typeof xuzhouStayBattlePayoffTargetBattleId;
informationVisitId: typeof xuzhouStayInformationVisitId;
dialogueId: typeof xuzhouStayResonanceDialogueId;
informationCompleted: boolean;
choiceId?: XuzhouStayBattlePayoffChoiceId;
informationCounterplays: number;
miZhuDeployed: boolean;
supportAttempts: number;
supportUses: number;
bonusHealing: number;
bonusBuffTurns: number;
};
export type XuzhouStayBattlePayoffRuntime =
XuzhouStayBattlePayoffSnapshot;
export type XuzhouStayBattlePayoffRuntimeState =
XuzhouStayBattlePayoffRuntime;
export type XuzhouStayBattlePayoffNormalizationOptions = {
expectedBattleId?: unknown;
expectedRosterUnitIds?: readonly unknown[] | ReadonlySet<string>;
expectedMemory?: ResolvedXuzhouStayBattlePayoff | null;
};
export function isXuzhouStayBattlePayoffChoiceId(
value: unknown
): value is XuzhouStayBattlePayoffChoiceId {
return (
typeof value === 'string' &&
(xuzhouStayBattlePayoffChoiceIds as readonly string[]).includes(
value
)
);
}
export function getXuzhouStayResonanceEffect(
choiceId: unknown
): XuzhouStayResonanceEffect | undefined {
if (!isXuzhouStayBattlePayoffChoiceId(choiceId)) {
return undefined;
}
return {
...xuzhouStayBattlePayoffDefinition.dialogue.choices[choiceId]
};
}
export function isXuzhouStayBattlePayoffActiveTurn(
turnNumber: unknown
) {
return (
typeof turnNumber === 'number' &&
Number.isInteger(turnNumber) &&
turnNumber >= 1 &&
turnNumber <= xuzhouStayBattlePayoffActiveThroughTurn
);
}
export function resolveXuzhouStayBattlePayoff(
campaign?: XuzhouStayBattlePayoffCampaignState | null,
battleId: unknown = xuzhouStayBattlePayoffTargetBattleId
): ResolvedXuzhouStayBattlePayoff | undefined {
if (battleId !== xuzhouStayBattlePayoffTargetBattleId) {
return undefined;
}
const recordSource = selectSourceVictoryRecord(campaign);
if (!recordSource) {
return undefined;
}
const informationCompleted = arrayValue(
campaign?.completedCampVisits
).includes(xuzhouStayInformationVisitId);
const dialogueCompleted = arrayValue(
campaign?.completedCampDialogues
).includes(xuzhouStayResonanceDialogueId);
const rawChoiceId = isRecord(campaign?.campDialogueChoiceIds)
? campaign.campDialogueChoiceIds[
xuzhouStayResonanceDialogueId
]
: undefined;
const choiceId =
dialogueCompleted &&
isXuzhouStayBattlePayoffChoiceId(rawChoiceId)
? rawChoiceId
: undefined;
if (!informationCompleted && !choiceId) {
return undefined;
}
return {
recordId: xuzhouStayBattlePayoffRecordId,
cityStayId: xuzhouStayId,
sourceBattleId: xuzhouStayBattlePayoffSourceBattleId,
targetBattleId: xuzhouStayBattlePayoffTargetBattleId,
informationVisitId: xuzhouStayInformationVisitId,
dialogueId: xuzhouStayResonanceDialogueId,
recordSource,
informationCompleted,
...(informationCompleted
? {
informationEffect: {
...xuzhouStayBattlePayoffDefinition.information.effect,
enemyUnitIds: [
...xuzhouStayInformationEnemyUnitIds
]
}
}
: {}),
...(choiceId
? {
choiceId,
resonanceEffect:
getXuzhouStayResonanceEffect(choiceId)!
}
: {})
};
}
export function createXuzhouStayBattlePayoffRuntime(
memory: ResolvedXuzhouStayBattlePayoff,
options: {
miZhuDeployed: unknown;
expectedRosterUnitIds?: readonly unknown[] | ReadonlySet<string>;
}
): XuzhouStayBattlePayoffRuntime | undefined {
return normalizeXuzhouStayBattlePayoffRuntime(
{
version: xuzhouStayBattlePayoffVersion,
recordId: memory.recordId,
cityStayId: memory.cityStayId,
sourceBattleId: memory.sourceBattleId,
targetBattleId: memory.targetBattleId,
informationVisitId: memory.informationVisitId,
dialogueId: memory.dialogueId,
informationCompleted: memory.informationCompleted,
...(memory.choiceId ? { choiceId: memory.choiceId } : {}),
informationCounterplays: 0,
miZhuDeployed: options.miZhuDeployed,
supportAttempts: 0,
supportUses: 0,
bonusHealing: 0,
bonusBuffTurns: 0
},
{
expectedBattleId: memory.targetBattleId,
expectedRosterUnitIds: options.expectedRosterUnitIds,
expectedMemory: memory
}
);
}
export function normalizeXuzhouStayBattlePayoffRuntime(
value: unknown,
options: XuzhouStayBattlePayoffNormalizationOptions = {}
): XuzhouStayBattlePayoffRuntime | undefined {
return normalizePayoffState(value, options);
}
export function cloneXuzhouStayBattlePayoffRuntime(
runtime: XuzhouStayBattlePayoffRuntime
): XuzhouStayBattlePayoffRuntime {
return { ...runtime };
}
export function createXuzhouStayBattlePayoffSnapshot(
runtime: unknown,
options: XuzhouStayBattlePayoffNormalizationOptions = {}
): XuzhouStayBattlePayoffSnapshot | undefined {
return normalizeXuzhouStayBattlePayoffSnapshot(runtime, options);
}
export function normalizeXuzhouStayBattlePayoffSnapshot(
value: unknown,
options: XuzhouStayBattlePayoffNormalizationOptions = {}
): XuzhouStayBattlePayoffSnapshot | undefined {
return normalizePayoffState(value, options);
}
export function cloneXuzhouStayBattlePayoffSnapshot(
snapshot: XuzhouStayBattlePayoffSnapshot
): XuzhouStayBattlePayoffSnapshot {
return { ...snapshot };
}
function normalizePayoffState(
value: unknown,
options: XuzhouStayBattlePayoffNormalizationOptions
): XuzhouStayBattlePayoffSnapshot | undefined {
if (
!isRecord(value) ||
value.version !== xuzhouStayBattlePayoffVersion ||
value.recordId !== xuzhouStayBattlePayoffRecordId ||
value.cityStayId !== xuzhouStayId ||
value.sourceBattleId !==
xuzhouStayBattlePayoffSourceBattleId ||
value.targetBattleId !==
xuzhouStayBattlePayoffTargetBattleId ||
value.informationVisitId !==
xuzhouStayInformationVisitId ||
value.dialogueId !== xuzhouStayResonanceDialogueId ||
typeof value.informationCompleted !== 'boolean' ||
typeof value.miZhuDeployed !== 'boolean' ||
(
options.expectedBattleId !== undefined &&
options.expectedBattleId !==
xuzhouStayBattlePayoffTargetBattleId
)
) {
return undefined;
}
const choiceId =
value.choiceId === undefined
? undefined
: isXuzhouStayBattlePayoffChoiceId(value.choiceId)
? value.choiceId
: undefined;
if (value.choiceId !== undefined && !choiceId) {
return undefined;
}
if (options.expectedMemory === null) {
return undefined;
}
const expectedMemory = options.expectedMemory;
if (
expectedMemory &&
(
expectedMemory.recordId !== xuzhouStayBattlePayoffRecordId ||
expectedMemory.sourceBattleId !==
xuzhouStayBattlePayoffSourceBattleId ||
expectedMemory.targetBattleId !==
xuzhouStayBattlePayoffTargetBattleId ||
expectedMemory.informationCompleted !==
value.informationCompleted ||
expectedMemory.choiceId !== choiceId
)
) {
return undefined;
}
const rosterDeployment = expectedMiZhuDeployment(
options.expectedRosterUnitIds
);
const miZhuDeployed =
rosterDeployment ?? value.miZhuDeployed;
const informationCounterplays = value.informationCompleted
? cappedNonNegativeInteger(
value.informationCounterplays,
xuzhouStayInformationEnemyUnitIds.length
)
: 0;
const supportEnabled = Boolean(choiceId && miZhuDeployed);
const supportAttempts = supportEnabled
? cappedNonNegativeInteger(
value.supportAttempts,
maxXuzhouStayBattlePayoffCounter
)
: 0;
const requestedSupportUses = supportEnabled
? Math.min(
cappedNonNegativeInteger(value.supportUses, 1),
supportAttempts
)
: 0;
const bonusHealing =
choiceId === xuzhouStayShareStorehouseDutyChoiceId &&
requestedSupportUses > 0
? cappedNonNegativeInteger(
value.bonusHealing,
xuzhouStayBattlePayoffDefinition.dialogue.choices[
xuzhouStayShareStorehouseDutyChoiceId
].bonusHealing
)
: 0;
const supportUses =
choiceId === xuzhouStayShareStorehouseDutyChoiceId
? bonusHealing > 0
? requestedSupportUses
: 0
: requestedSupportUses;
const bonusBuffTurns =
choiceId === xuzhouStayTrustMiZhuLedgerChoiceId &&
supportUses > 0
? xuzhouStayBattlePayoffDefinition.dialogue.choices[
xuzhouStayTrustMiZhuLedgerChoiceId
].bonusBuffTurns
: 0;
return {
version: xuzhouStayBattlePayoffVersion,
recordId: xuzhouStayBattlePayoffRecordId,
cityStayId: xuzhouStayId,
sourceBattleId: xuzhouStayBattlePayoffSourceBattleId,
targetBattleId: xuzhouStayBattlePayoffTargetBattleId,
informationVisitId: xuzhouStayInformationVisitId,
dialogueId: xuzhouStayResonanceDialogueId,
informationCompleted: value.informationCompleted,
...(choiceId ? { choiceId } : {}),
informationCounterplays,
miZhuDeployed,
supportAttempts,
supportUses,
bonusHealing,
bonusBuffTurns
};
}
function selectSourceVictoryRecord(
campaign?: XuzhouStayBattlePayoffCampaignState | null
): XuzhouStayBattlePayoffRecordSource | undefined {
const history = campaign?.battleHistory;
if (
history &&
Object.prototype.hasOwnProperty.call(
history,
xuzhouStayBattlePayoffSourceBattleId
)
) {
return isSourceVictoryRecord(
history[xuzhouStayBattlePayoffSourceBattleId]
)
? 'battle-history'
: undefined;
}
return isSourceVictoryRecord(campaign?.firstBattleReport)
? 'legacy-report'
: undefined;
}
function isSourceVictoryRecord(
value?: XuzhouStayBattleRecord | null
) {
return Boolean(
value &&
value.battleId ===
xuzhouStayBattlePayoffSourceBattleId &&
value.outcome === 'victory'
);
}
function expectedMiZhuDeployment(
value?: readonly unknown[] | ReadonlySet<string>
) {
if (value === undefined) {
return undefined;
}
if (value instanceof Set) {
return value.has(xuzhouStayMiZhuUnitId);
}
if (!Array.isArray(value)) {
return false;
}
return value.some((unitId) => unitId === xuzhouStayMiZhuUnitId);
}
function cappedNonNegativeInteger(value: unknown, max: number) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) {
return 0;
}
return Math.min(max, Math.floor(numeric));
}
function arrayValue(value: unknown): readonly unknown[] {
return Array.isArray(value) ? value : [];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
const maxXuzhouStayBattlePayoffCounter = 999999;

File diff suppressed because it is too large Load Diff

View File

@@ -381,6 +381,7 @@ export class CityStayScene extends Phaser.Scene {
id: choice.id, id: choice.id,
label: choice.label, label: choice.label,
rewardExp: this.cityStay.dialogue.rewardExp + choice.rewardExp, rewardExp: this.cityStay.dialogue.rewardExp + choice.rewardExp,
battleEffect: choice.battleEffect ?? null,
interactive: Boolean(view?.interactive), interactive: Boolean(view?.interactive),
bounds: view ? this.boundsSnapshot(view.button.getBounds()) : null bounds: view ? this.boundsSnapshot(view.button.getBounds()) : null
}; };
@@ -1289,7 +1290,7 @@ export class CityStayScene extends Phaser.Scene {
color: '#f1d691', color: '#f1d691',
fontStyle: 'bold' fontStyle: 'bold'
}); });
const hint = this.add.text(150, 697, '유비의 답을 선택하세요. 선택은 저장되며 공명 경험치가 달라집니다.', { const hint = this.add.text(150, 697, '유비의 답을 선택하세요. 선택은 저장되며 공명과 다음 전투의 지원 방식이 달라집니다.', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '19px', fontSize: '19px',
color: '#aeb8c4' color: '#aeb8c4'
@@ -1318,7 +1319,7 @@ export class CityStayScene extends Phaser.Scene {
}); });
const reward = this.add.text( const reward = this.add.text(
x + 76, x + 76,
y + 98, y + 126,
`공명 +${dialogue.rewardExp + choice.rewardExp}`, `공명 +${dialogue.rewardExp + choice.rewardExp}`,
{ {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
@@ -1327,10 +1328,21 @@ export class CityStayScene extends Phaser.Scene {
fontStyle: 'bold' fontStyle: 'bold'
} }
); );
const battleEffect = this.add.text(
x + 76,
y + 82,
choice.battleEffect ?? '선택한 방침은 이후 대화와 공명에 반영됩니다.',
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '17px',
color: '#9fd7ef',
wordWrap: { width: 670, useAdvancedWrap: true }
}
);
button.on('pointerover', () => button.setFillStyle(0x2a3c4e, 0.99)); button.on('pointerover', () => button.setFillStyle(0x2a3c4e, 0.99));
button.on('pointerout', () => button.setFillStyle(0x1d2936, 0.98)); button.on('pointerout', () => button.setFillStyle(0x1d2936, 0.98));
button.on('pointerdown', () => this.chooseDialogue(choice)); button.on('pointerdown', () => this.chooseDialogue(choice));
objects.push(button, number, label, reward); objects.push(button, number, label, battleEffect, reward);
return { choiceId: choice.id, button, interactive: true }; return { choiceId: choice.id, button, interactive: true };
}); });
const cancel = this.add.text(1740, 940, 'ESC · 나중에 결정', { const cancel = this.add.text(1740, 940, 'ESC · 나중에 결정', {

View File

@@ -21,6 +21,10 @@ import {
normalizeCityEquipmentContributionSnapshot, normalizeCityEquipmentContributionSnapshot,
type CityEquipmentContributionSnapshot type CityEquipmentContributionSnapshot
} from '../data/cityEquipmentPreparation'; } from '../data/cityEquipmentPreparation';
import {
normalizeXuzhouStayBattlePayoffRuntime,
type XuzhouStayBattlePayoffRuntime
} from '../data/xuzhouStayBattlePayoff';
export type BattleSaveFaction = 'ally' | 'enemy'; export type BattleSaveFaction = 'ally' | 'enemy';
export type BattleSaveRosterTab = 'ally' | 'enemy'; export type BattleSaveRosterTab = 'ally' | 'enemy';
@@ -118,6 +122,8 @@ export type BattleSaveThirdCampPreparationState = {
export type BattleSaveCityEquipmentContributionState = export type BattleSaveCityEquipmentContributionState =
CityEquipmentContributionSnapshot; CityEquipmentContributionSnapshot;
export type BattleSaveXuzhouStayBattlePayoffState =
XuzhouStayBattlePayoffRuntime;
export type BattleSaveEventPriority = 'critical' | 'high' | 'normal' | 'low'; export type BattleSaveEventPriority = 'critical' | 'high' | 'normal' | 'low';
@@ -141,6 +147,7 @@ export type BattleSaveState = {
cooperationStatsByBond?: Record<string, BattleSaveCooperationStats>; cooperationStatsByBond?: Record<string, BattleSaveCooperationStats>;
thirdCampPreparation?: BattleSaveThirdCampPreparationState; thirdCampPreparation?: BattleSaveThirdCampPreparationState;
cityEquipmentContribution?: BattleSaveCityEquipmentContributionState; cityEquipmentContribution?: BattleSaveCityEquipmentContributionState;
xuzhouStayBattlePayoff?: BattleSaveXuzhouStayBattlePayoffState;
savedAt: string; savedAt: string;
turnNumber: number; turnNumber: number;
activeFaction: BattleSaveFaction; activeFaction: BattleSaveFaction;
@@ -239,6 +246,7 @@ export function normalizeBattleSaveState(
normalizeCooperationSaveFields(cloned, options); normalizeCooperationSaveFields(cloned, options);
normalizeThirdCampPreparationSaveField(cloned); normalizeThirdCampPreparationSaveField(cloned);
normalizeCityEquipmentContributionSaveField(cloned, options); normalizeCityEquipmentContributionSaveField(cloned, options);
normalizeXuzhouStayBattlePayoffSaveField(cloned, options);
const recommendation = normalizeCampaignSortieRecommendationSnapshot(cloned.sortieRecommendation, { const recommendation = normalizeCampaignSortieRecommendationSnapshot(cloned.sortieRecommendation, {
expectedBattleId: typeof cloned.battleId === 'string' ? cloned.battleId : options.expectedBattleId, expectedBattleId: typeof cloned.battleId === 'string' ? cloned.battleId : options.expectedBattleId,
allowedUnitIds: options.validAllyUnitIds allowedUnitIds: options.validAllyUnitIds
@@ -296,6 +304,10 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida
return false; return false;
} }
if (!isOptionalXuzhouStayBattlePayoffState(state.xuzhouStayBattlePayoff, state, options)) {
return false;
}
const attackIntents = state.attackIntents; const attackIntents = state.attackIntents;
const units = state.units; const units = state.units;
@@ -803,6 +815,100 @@ function canonicalCityEquipmentContributionSaveState(
}; };
} }
function normalizeXuzhouStayBattlePayoffSaveField(
state: Record<string, unknown>,
options: BattleSaveValidationOptions
) {
const normalized = canonicalXuzhouStayBattlePayoffSaveState(
state.xuzhouStayBattlePayoff,
state,
options
);
if (normalized) {
state.xuzhouStayBattlePayoff = normalized;
} else {
delete state.xuzhouStayBattlePayoff;
}
}
function isOptionalXuzhouStayBattlePayoffState(
value: unknown,
state: Record<string, unknown>,
options: BattleSaveValidationOptions
) {
if (value === undefined) {
return true;
}
const normalized = canonicalXuzhouStayBattlePayoffSaveState(
value,
state,
options
);
if (!normalized || !isRecord(value)) {
return false;
}
const fields: Array<
keyof BattleSaveXuzhouStayBattlePayoffState
> = [
'version',
'recordId',
'cityStayId',
'sourceBattleId',
'targetBattleId',
'informationVisitId',
'dialogueId',
'informationCompleted',
'informationCounterplays',
'miZhuDeployed',
'supportAttempts',
'supportUses',
'bonusHealing',
'bonusBuffTurns'
];
if (normalized.choiceId) {
fields.push('choiceId');
}
return (
Object.keys(value).length === fields.length &&
fields.every((field) => value[field] === normalized[field])
);
}
function canonicalXuzhouStayBattlePayoffSaveState(
value: unknown,
state: Record<string, unknown>,
options: BattleSaveValidationOptions
): BattleSaveXuzhouStayBattlePayoffState | undefined {
const battleId =
typeof state.battleId === 'string' ? state.battleId : undefined;
if (!battleId || !Array.isArray(state.units)) {
return undefined;
}
const deployedUnitIds = state.units
.filter(isRecord)
.map((unit) => unit.id)
.filter(
(unitId): unitId is string =>
typeof unitId === 'string' &&
(!options.validAllyUnitIds ||
options.validAllyUnitIds.has(unitId))
);
const normalized = normalizeXuzhouStayBattlePayoffRuntime(
value,
{
expectedBattleId: battleId,
expectedRosterUnitIds: deployedUnitIds
}
);
if (
!normalized ||
(!normalized.informationCompleted && !normalized.choiceId)
) {
return undefined;
}
return normalized;
}
function isOptionalCoreResonanceState( function isOptionalCoreResonanceState(
state: Record<string, unknown>, state: Record<string, unknown>,
options: BattleSaveValidationOptions options: BattleSaveValidationOptions

View File

@@ -13,6 +13,12 @@ import {
type CityEquipmentPreparationSnapshot, type CityEquipmentPreparationSnapshot,
type CityEquipmentPurchaseCounts type CityEquipmentPurchaseCounts
} from '../data/cityEquipmentPreparation'; } from '../data/cityEquipmentPreparation';
import {
cloneXuzhouStayBattlePayoffSnapshot,
normalizeXuzhouStayBattlePayoffSnapshot,
resolveXuzhouStayBattlePayoff,
type XuzhouStayBattlePayoffSnapshot
} from '../data/xuzhouStayBattlePayoff';
import { findCityStayAfterBattle, type CityStayId } from '../data/cityStays'; import { findCityStayAfterBattle, type CityStayId } from '../data/cityStays';
import { import {
isThirdCampPreparationPriorityId, isThirdCampPreparationPriorityId,
@@ -190,6 +196,7 @@ export type FirstBattleReport = {
sortieRecommendation?: CampaignSortieRecommendationSnapshot; sortieRecommendation?: CampaignSortieRecommendationSnapshot;
sortieCooperation?: CampaignSortieCooperationSnapshot; sortieCooperation?: CampaignSortieCooperationSnapshot;
cityEquipmentContribution?: CityEquipmentContributionSnapshot; cityEquipmentContribution?: CityEquipmentContributionSnapshot;
xuzhouStayBattlePayoff?: XuzhouStayBattlePayoffSnapshot;
completedCampDialogues: string[]; completedCampDialogues: string[];
completedCampVisits: string[]; completedCampVisits: string[];
createdAt: string; createdAt: string;
@@ -475,6 +482,7 @@ export type CampaignBattleSettlement = {
sortieRecommendation?: CampaignSortieRecommendationSnapshot; sortieRecommendation?: CampaignSortieRecommendationSnapshot;
sortieCooperation?: CampaignSortieCooperationSnapshot; sortieCooperation?: CampaignSortieCooperationSnapshot;
cityEquipmentContribution?: CityEquipmentContributionSnapshot; cityEquipmentContribution?: CityEquipmentContributionSnapshot;
xuzhouStayBattlePayoff?: XuzhouStayBattlePayoffSnapshot;
completedAt: string; completedAt: string;
}; };
@@ -1027,6 +1035,24 @@ export function setFirstBattleReport(report: FirstBattleReport) {
} else { } else {
delete reportClone.cityEquipmentContribution; delete reportClone.cityEquipmentContribution;
} }
const xuzhouStayBattlePayoff =
normalizeXuzhouStayBattlePayoffSnapshot(
reportClone.xuzhouStayBattlePayoff,
{
expectedBattleId: battleId,
expectedRosterUnitIds: reportClone.units
.filter((unit) => unit.faction === 'ally')
.map((unit) => unit.id),
expectedMemory:
resolveXuzhouStayBattlePayoff(state, battleId) ?? null
}
);
if (xuzhouStayBattlePayoff) {
reportClone.xuzhouStayBattlePayoff =
xuzhouStayBattlePayoff;
} else {
delete reportClone.xuzhouStayBattlePayoff;
}
const previousSettlement = state.battleHistory[battleId]; const previousSettlement = state.battleHistory[battleId];
const completedCampDialogues = mergeCampCompletionIds( const completedCampDialogues = mergeCampCompletionIds(
state.completedCampDialogues, state.completedCampDialogues,
@@ -1899,6 +1925,7 @@ function normalizeCampaignState(state: Partial<CampaignState>): CampaignState {
normalizeStoredCityEquipmentContributionsForContainers( normalizeStoredCityEquipmentContributionsForContainers(
normalized normalized
); );
normalizeStoredXuzhouStayBattlePayoffsForContainers(normalized);
const recordedVictoryBattleIds = new Set( const recordedVictoryBattleIds = new Set(
Object.entries(normalized.battleHistory) Object.entries(normalized.battleHistory)
.filter(([, settlement]) => settlement.outcome === 'victory') .filter(([, settlement]) => settlement.outcome === 'victory')
@@ -2628,6 +2655,44 @@ function normalizeStoredCityEquipmentContributionsForContainers(
}); });
} }
function normalizeStoredXuzhouStayBattlePayoffsForContainers(
state: CampaignState
) {
if (state.firstBattleReport) {
const payoff = normalizeXuzhouStayBattlePayoffSnapshot(
state.firstBattleReport.xuzhouStayBattlePayoff,
{
expectedBattleId: state.firstBattleReport.battleId,
expectedRosterUnitIds: state.firstBattleReport.units
.filter((unit) => unit.faction === 'ally')
.map((unit) => unit.id)
}
);
if (payoff) {
state.firstBattleReport.xuzhouStayBattlePayoff = payoff;
} else {
delete state.firstBattleReport.xuzhouStayBattlePayoff;
}
}
Object.values(state.battleHistory).forEach((settlement) => {
const payoff = normalizeXuzhouStayBattlePayoffSnapshot(
settlement.xuzhouStayBattlePayoff,
{
expectedBattleId: settlement.battleId,
expectedRosterUnitIds: settlement.units.map(
(unit) => unit.unitId
)
}
);
if (payoff) {
settlement.xuzhouStayBattlePayoff = payoff;
} else {
delete settlement.xuzhouStayBattlePayoff;
}
});
}
function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rosterUnitIds: Set<string>): FirstBattleReport { function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rosterUnitIds: Set<string>): FirstBattleReport {
const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(report.sortiePerformance, rosterUnitIds); const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(report.sortiePerformance, rosterUnitIds);
const sortiePerformanceUnchanged = sortiePerformanceSnapshotsShareRoster(report.sortiePerformance, sortiePerformance); const sortiePerformanceUnchanged = sortiePerformanceSnapshotsShareRoster(report.sortiePerformance, sortiePerformance);
@@ -2793,6 +2858,14 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle
battleId, battleId,
campaignSettlementContributionRoster(units) campaignSettlementContributionRoster(units)
); );
const xuzhouStayBattlePayoff =
normalizeXuzhouStayBattlePayoffSnapshot(
settlement.xuzhouStayBattlePayoff,
{
expectedBattleId: battleId,
expectedRosterUnitIds: units.map((unit) => unit.unitId)
}
);
return { return {
battleId, battleId,
@@ -2814,6 +2887,9 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle
...(cityEquipmentContribution ...(cityEquipmentContribution
? { cityEquipmentContribution } ? { cityEquipmentContribution }
: {}), : {}),
...(xuzhouStayBattlePayoff
? { xuzhouStayBattlePayoff }
: {}),
completedAt completedAt
}; };
} }
@@ -3171,6 +3247,16 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi
battleId, battleId,
units units
); );
const xuzhouStayBattlePayoff =
normalizeXuzhouStayBattlePayoffSnapshot(
report.xuzhouStayBattlePayoff,
{
expectedBattleId: battleId,
expectedRosterUnitIds: units
.filter((unit) => unit.faction === 'ally')
.map((unit) => unit.id)
}
);
return { return {
battleId, battleId,
@@ -3197,6 +3283,9 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi
...(cityEquipmentContribution ...(cityEquipmentContribution
? { cityEquipmentContribution } ? { cityEquipmentContribution }
: {}), : {}),
...(xuzhouStayBattlePayoff
? { xuzhouStayBattlePayoff }
: {}),
completedCampDialogues: uniqueCampHistoryIds(report.completedCampDialogues), completedCampDialogues: uniqueCampHistoryIds(report.completedCampDialogues),
completedCampVisits: uniqueCampHistoryIds(report.completedCampVisits), completedCampVisits: uniqueCampHistoryIds(report.completedCampVisits),
createdAt: normalizeCampaignTimestamp(report.createdAt) ?? new Date().toISOString() createdAt: normalizeCampaignTimestamp(report.createdAt) ?? new Date().toISOString()
@@ -3472,6 +3561,14 @@ function createBattleSettlement(report: FirstBattleReport, reserveTraining: Camp
) )
} }
: {}), : {}),
...(report.xuzhouStayBattlePayoff
? {
xuzhouStayBattlePayoff:
cloneXuzhouStayBattlePayoffSnapshot(
report.xuzhouStayBattlePayoff
)
}
: {}),
completedAt: report.createdAt completedAt: report.createdAt
}; };
} }
@@ -3641,6 +3738,12 @@ function cloneReport(report: FirstBattleReport): FirstBattleReport {
cloned.cityEquipmentContribution cloned.cityEquipmentContribution
); );
} }
if (cloned.xuzhouStayBattlePayoff) {
cloned.xuzhouStayBattlePayoff =
cloneXuzhouStayBattlePayoffSnapshot(
cloned.xuzhouStayBattlePayoff
);
}
return cloned; return cloned;
} }