feat: unlock tactical commands from sortie orders

This commit is contained in:
2026-07-11 02:42:04 +09:00
parent 9f49069db2
commit 2b9e33f4c0
8 changed files with 1060 additions and 70 deletions

View File

@@ -63,7 +63,8 @@ try {
const tacticalOptions = {
...options,
expectedBattleId: 'second-battle-yellow-turban-pursuit',
allowTacticalCommand: true
allowTacticalCommand: true,
allowLegacyTacticalCommand: true
};
const validTacticalBaseState = {
...validState,
@@ -114,10 +115,62 @@ try {
effectLost: true
}
};
const validInitiativeCommandState = {
...validFrontCommandState,
tacticalCommand: { ...validFrontCommandState.tacticalCommand, source: 'initiative' }
};
const validUnlockedOrderCommandState = {
...validTacticalBaseState,
battleStats: validState.battleStats,
sortieOrderCommandUnlocked: true,
sortieOrderCommandUnlockTurn: 2,
tacticalCommand: {
...validFrontCommandState.tacticalCommand,
source: 'sortie-order'
}
};
assert(isValidBattleSaveState(validFrontCommandState, tacticalOptions), 'Expected pending front tactical command to pass validation.');
assert(isValidBattleSaveState(validInitiativeCommandState, tacticalOptions), 'Expected an explicit initiative source to pass validation.');
assert(isValidBattleSaveState(validFlankCommandState, tacticalOptions), 'Expected resolved flank tactical command to pass validation.');
assert(isValidBattleSaveState(validSupportCommandState, tacticalOptions), 'Expected immediate support tactical command to pass validation.');
assert(isValidBattleSaveState(validLostFrontCommandState, tacticalOptions), 'Expected a lost tactical command to retain its result record.');
assert(
isValidBattleSaveState(validUnlockedOrderCommandState, tacticalOptions),
'Expected an unlocked sortie-order command to replace the counterplay requirement.'
);
assert(
isValidBattleSaveState({ ...validUnlockedOrderCommandState, tacticalCommand: undefined }, tacticalOptions),
'Expected an unlocked sortie-order command to remain unused in a valid save.'
);
assert(
isValidBattleSaveState({ ...validTacticalBaseState, sortieOrderCommandUnlocked: false }, tacticalOptions),
'Expected an explicitly locked sortie-order command without an unlock turn to pass validation.'
);
const otherSortieOptions = {
...options,
allowTacticalCommand: true,
allowLegacyTacticalCommand: false
};
const otherSortieLegacyCommandState = {
...validFrontCommandState,
battleId: options.expectedBattleId,
campaignStep: 'first-battle',
tacticalCommand: { ...validFrontCommandState.tacticalCommand }
};
const otherSortieOrderCommandState = {
...validUnlockedOrderCommandState,
battleId: options.expectedBattleId,
campaignStep: 'first-battle'
};
assert(
isValidBattleSaveState(otherSortieOrderCommandState, otherSortieOptions) &&
!isValidBattleSaveState(otherSortieLegacyCommandState, otherSortieOptions) &&
!isValidBattleSaveState({
...otherSortieLegacyCommandState,
tacticalCommand: { ...otherSortieLegacyCommandState.tacticalCommand, source: 'initiative' }
}, otherSortieOptions),
'Expected another sortie battle to allow sortie-order source while rejecting source-less and explicit legacy initiative commands.'
);
assert(!isValidBattleSaveState(validFrontCommandState, options), 'Expected tactical commands to be rejected when disabled for the battle.');
const tacticalRoleOptions = {
...tacticalOptions,
@@ -131,6 +184,13 @@ try {
JSON.stringify(parsedTacticalCommand) === JSON.stringify(validFrontCommandState.tacticalCommand),
'Expected every tactical command field to round-trip.'
);
const parsedOrderCommand = parseBattleSaveState(JSON.stringify(validUnlockedOrderCommandState), tacticalOptions);
assert(
parsedOrderCommand?.sortieOrderCommandUnlocked === true &&
parsedOrderCommand.sortieOrderCommandUnlockTurn === 2 &&
parsedOrderCommand.tacticalCommand?.source === 'sortie-order',
'Expected sortie-order command unlock and source fields to round-trip.'
);
assert(parseBattleSaveState(JSON.stringify(validState), options)?.battleId === options.expectedBattleId, 'Expected valid JSON save to parse.');
assert(
parseBattleSaveState(JSON.stringify(validState), options)?.sortieOrderId === 'elite',
@@ -250,6 +310,7 @@ try {
const tacticalRejectedCases = [
['invalid tactical command shape', { tacticalCommand: 'front' }],
['invalid tactical command role', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, role: 'reserve' } }],
['invalid tactical command source', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, source: 'morale' } }],
['unknown tactical command actor', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, actorId: 'ghost-unit' } }],
['enemy tactical command actor', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, actorId: 'rebel-1' } }],
['invalid tactical command turn', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, usedTurn: 0 } }],
@@ -275,6 +336,68 @@ try {
assert(!isValidBattleSaveState(candidate, tacticalOptions), `Expected invalid tactical command save to be rejected: ${label}`);
});
const orderCommandRejectedCases = [
['unlocked command missing unlock turn', { sortieOrderCommandUnlockTurn: undefined }],
['zero unlock turn', { sortieOrderCommandUnlockTurn: 0 }],
['future unlock turn', { sortieOrderCommandUnlockTurn: 3 }],
['fractional unlock turn', { sortieOrderCommandUnlockTurn: 1.5 }],
['locked command retaining unlock turn', { sortieOrderCommandUnlocked: false }],
['unlocked command without allied actions', {
battleStats: {
'liu-bei': { ...validState.battleStats['liu-bei'], actions: 0 },
'rebel-1': { ...validState.battleStats['liu-bei'], actions: 3 }
}
}],
['sortie-order source without unlock', {
sortieOrderCommandUnlocked: undefined,
sortieOrderCommandUnlockTurn: undefined
}]
];
orderCommandRejectedCases.forEach(([label, patch]) => {
const candidate = { ...validUnlockedOrderCommandState, ...patch };
assert(!isValidBattleSaveState(candidate, tacticalOptions), `Expected invalid sortie-order command save to be rejected: ${label}`);
});
assert(
!isValidBattleSaveState(validUnlockedOrderCommandState, options),
'Expected sortie-order command unlock state to remain gated by allowTacticalCommand.'
);
assert(
!isValidBattleSaveState(
{
...validUnlockedOrderCommandState,
turnNumber: 3,
sortieOrderCommandUnlockTurn: 3,
tacticalCommand: { ...validUnlockedOrderCommandState.tacticalCommand, usedTurn: 2 }
},
tacticalOptions
),
'Expected a sortie-order tactical command used before its unlock turn to be rejected.'
);
assert(
!isValidBattleSaveState(
{
...validInitiativeCommandState,
battleStats: validState.battleStats
},
tacticalOptions
),
'Expected an explicit initiative command to retain the legacy counterplay threshold.'
);
const unlockedInitiativeState = {
...validTacticalBaseState,
sortieOrderCommandUnlocked: true,
sortieOrderCommandUnlockTurn: 2,
tacticalCommand: { ...validInitiativeCommandState.tacticalCommand }
};
assert(
!isValidBattleSaveState(unlockedInitiativeState, tacticalOptions) &&
!isValidBattleSaveState({
...unlockedInitiativeState,
tacticalCommand: { ...unlockedInitiativeState.tacticalCommand, source: undefined }
}, tacticalOptions),
'Expected unlocked sortie-order state to reject explicit and source-less legacy initiative commands.'
);
assert(
!isValidBattleSaveState(validState, { ...options, validUnitIds: new Set(['liu-bei', 'guan-yu', 'rebel-1', 'zhang-fei']) }),
'Expected save missing a current battle unit to be rejected.'