feat: make Xuzhou choices shape battle eight
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
|
|||||||
1281
scripts/verify-xuzhou-stay-battle-payoff-browser.mjs
Normal file
1281
scripts/verify-xuzhou-stay-battle-payoff-browser.mjs
Normal file
File diff suppressed because it is too large
Load Diff
575
scripts/verify-xuzhou-stay-battle-payoff.mjs
Normal file
575
scripts/verify-xuzhou-stay-battle-payoff.mjs
Normal 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.'
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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턴'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
511
src/game/data/xuzhouStayBattlePayoff.ts
Normal file
511
src/game/data/xuzhouStayBattlePayoff.ts
Normal 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
@@ -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 · 나중에 결정', {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user