576 lines
16 KiB
JavaScript
576 lines
16 KiB
JavaScript
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.'
|
|
);
|
|
}
|