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

@@ -19,6 +19,16 @@ try {
const {
cityEquipmentPreparationSelectionRecordId
} = 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 = {
expectedBattleId: 'first-battle-zhuo-commandery',
@@ -630,6 +640,280 @@ try {
'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 = {
...validState,
battleStats: {

View File

@@ -58,6 +58,16 @@ try {
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
const { campaignRecruitUnits } = await server.ssrLoadModule('/src/game/data/scenario.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 = {}) => ({
unitId,
hpBefore: 30,
@@ -2601,11 +2611,268 @@ try {
`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.');
} finally {
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) {
if (!condition) {
throw new Error(message);

View File

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

View File

@@ -1936,10 +1936,10 @@ function assertBattleResultContribution(
assert(
result.visibleResultTexts.some((text) =>
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
)}`
);

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.'
);
}