feat: add tactical initiative commands
This commit is contained in:
@@ -51,6 +51,77 @@ try {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
assert(isValidBattleSaveState(validSortieStatsState, options), 'Expected sortie and counterplay contribution stats to pass validation.');
|
assert(isValidBattleSaveState(validSortieStatsState, options), 'Expected sortie and counterplay contribution stats to pass validation.');
|
||||||
|
const tacticalOptions = {
|
||||||
|
...options,
|
||||||
|
expectedBattleId: 'second-battle-yellow-turban-pursuit',
|
||||||
|
allowTacticalCommand: true
|
||||||
|
};
|
||||||
|
const validTacticalBaseState = {
|
||||||
|
...validState,
|
||||||
|
battleId: tacticalOptions.expectedBattleId,
|
||||||
|
campaignStep: 'second-battle',
|
||||||
|
enemyUsableUseKeys: [],
|
||||||
|
battleStats: {
|
||||||
|
...validState.battleStats,
|
||||||
|
'liu-bei': {
|
||||||
|
...validState.battleStats['liu-bei'],
|
||||||
|
intentDefeats: 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const validFrontCommandState = {
|
||||||
|
...validTacticalBaseState,
|
||||||
|
tacticalCommand: {
|
||||||
|
role: 'front',
|
||||||
|
actorId: 'guan-yu',
|
||||||
|
usedTurn: 2,
|
||||||
|
effectPending: true,
|
||||||
|
effectValue: 0,
|
||||||
|
statusesCleared: 0,
|
||||||
|
effectLost: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const validFlankCommandState = {
|
||||||
|
...validFrontCommandState,
|
||||||
|
tacticalCommand: { ...validFrontCommandState.tacticalCommand, role: 'flank', effectPending: false, effectValue: 4 }
|
||||||
|
};
|
||||||
|
const validSupportCommandState = {
|
||||||
|
...validFrontCommandState,
|
||||||
|
tacticalCommand: {
|
||||||
|
...validFrontCommandState.tacticalCommand,
|
||||||
|
role: 'support',
|
||||||
|
actorId: 'liu-bei',
|
||||||
|
effectPending: false,
|
||||||
|
effectValue: 6,
|
||||||
|
statusesCleared: 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const validLostFrontCommandState = {
|
||||||
|
...validFrontCommandState,
|
||||||
|
units: validFrontCommandState.units.map((unit) => (unit.id === 'guan-yu' ? { ...unit, hp: 0 } : unit)),
|
||||||
|
tacticalCommand: {
|
||||||
|
...validFrontCommandState.tacticalCommand,
|
||||||
|
effectPending: false,
|
||||||
|
effectLost: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert(isValidBattleSaveState(validFrontCommandState, tacticalOptions), 'Expected pending front tactical command 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(validFrontCommandState, options), 'Expected tactical commands to be rejected when disabled for the battle.');
|
||||||
|
const tacticalRoleOptions = {
|
||||||
|
...tacticalOptions,
|
||||||
|
validTacticalCommandRolesByActor: { 'guan-yu': 'front', 'liu-bei': 'support' }
|
||||||
|
};
|
||||||
|
assert(isValidBattleSaveState(validFrontCommandState, tacticalRoleOptions), 'Expected the assigned front actor to pass validation.');
|
||||||
|
assert(isValidBattleSaveState(validSupportCommandState, tacticalRoleOptions), 'Expected the assigned support actor to pass validation.');
|
||||||
|
assert(!isValidBattleSaveState(validFlankCommandState, tacticalRoleOptions), 'Expected a role and actor assignment mismatch to be rejected.');
|
||||||
|
const parsedTacticalCommand = parseBattleSaveState(JSON.stringify(validFrontCommandState), tacticalOptions)?.tacticalCommand;
|
||||||
|
assert(
|
||||||
|
JSON.stringify(parsedTacticalCommand) === JSON.stringify(validFrontCommandState.tacticalCommand),
|
||||||
|
'Expected every tactical command field to round-trip.'
|
||||||
|
);
|
||||||
assert(parseBattleSaveState(JSON.stringify(validState), options)?.battleId === options.expectedBattleId, 'Expected valid JSON save to parse.');
|
assert(parseBattleSaveState(JSON.stringify(validState), options)?.battleId === options.expectedBattleId, 'Expected valid JSON save to parse.');
|
||||||
assert(parseBattleSaveState('{', options) === undefined, 'Expected malformed JSON to be ignored.');
|
assert(parseBattleSaveState('{', options) === undefined, 'Expected malformed JSON to be ignored.');
|
||||||
assert(parseBattleSaveState('', options) === undefined, 'Expected empty save payload to be ignored.');
|
assert(parseBattleSaveState('', options) === undefined, 'Expected empty save payload to be ignored.');
|
||||||
@@ -162,6 +233,34 @@ try {
|
|||||||
assert(!isValidBattleSaveState(candidate, options), `Expected invalid battle save to be rejected: ${label}`);
|
assert(!isValidBattleSaveState(candidate, options), `Expected invalid battle save to be rejected: ${label}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tacticalRejectedCases = [
|
||||||
|
['invalid tactical command shape', { tacticalCommand: 'front' }],
|
||||||
|
['invalid tactical command role', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, role: 'reserve' } }],
|
||||||
|
['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 } }],
|
||||||
|
['future tactical command turn', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, usedTurn: 3 } }],
|
||||||
|
['fractional tactical command turn', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, usedTurn: 1.5 } }],
|
||||||
|
['invalid tactical command pending flag', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, effectPending: 'yes' } }],
|
||||||
|
['invalid tactical command lost flag', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, effectLost: 'yes' } }],
|
||||||
|
['support tactical command cannot remain pending', { tacticalCommand: { ...validSupportCommandState.tacticalCommand, effectPending: true } }],
|
||||||
|
['invalid tactical command effect value', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, effectValue: -1 } }],
|
||||||
|
['pending tactical command cannot have effect value', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, effectValue: 1 } }],
|
||||||
|
['pending tactical command cannot be lost', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, effectLost: true } }],
|
||||||
|
['front tactical command cannot clear statuses', { tacticalCommand: { ...validFlankCommandState.tacticalCommand, role: 'front', statusesCleared: 1 } }],
|
||||||
|
['lost tactical command cannot retain effect value', { tacticalCommand: { ...validFlankCommandState.tacticalCommand, effectLost: true } }],
|
||||||
|
['support tactical command heal exceeds limit', { tacticalCommand: { ...validSupportCommandState.tacticalCommand, effectValue: 7 } }],
|
||||||
|
['support tactical command statuses exceed limit', { tacticalCommand: { ...validSupportCommandState.tacticalCommand, statusesCleared: 3 } }],
|
||||||
|
['invalid tactical command cleared status count', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, statusesCleared: 1.5 } }],
|
||||||
|
['pending tactical command actor defeated', { units: validFrontCommandState.units.map((unit) => (unit.id === 'guan-yu' ? { ...unit, hp: 0 } : unit)) }],
|
||||||
|
['tactical command without enough counterplay', { battleStats: validState.battleStats }]
|
||||||
|
];
|
||||||
|
|
||||||
|
tacticalRejectedCases.forEach(([label, patch]) => {
|
||||||
|
const candidate = { ...validFrontCommandState, ...patch };
|
||||||
|
assert(!isValidBattleSaveState(candidate, tacticalOptions), `Expected invalid tactical command save to be rejected: ${label}`);
|
||||||
|
});
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
!isValidBattleSaveState(validState, { ...options, validUnitIds: new Set(['liu-bei', 'guan-yu', 'rebel-1', 'zhang-fei']) }),
|
!isValidBattleSaveState(validState, { ...options, validUnitIds: new Set(['liu-bei', 'guan-yu', 'rebel-1', 'zhang-fei']) }),
|
||||||
'Expected save missing a current battle unit to be rejected.'
|
'Expected save missing a current battle unit to be rejected.'
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -54,6 +54,18 @@ export type BattleSaveUnitStats = {
|
|||||||
intentGuardSuccesses?: number;
|
intentGuardSuccesses?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BattleSaveTacticalCommandRole = 'front' | 'flank' | 'support';
|
||||||
|
|
||||||
|
export type BattleSaveTacticalCommandState = {
|
||||||
|
role: BattleSaveTacticalCommandRole;
|
||||||
|
actorId: string;
|
||||||
|
usedTurn: number;
|
||||||
|
effectPending: boolean;
|
||||||
|
effectValue: number;
|
||||||
|
statusesCleared: number;
|
||||||
|
effectLost?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type BattleSaveState = {
|
export type BattleSaveState = {
|
||||||
version: 1;
|
version: 1;
|
||||||
battleId: string;
|
battleId: string;
|
||||||
@@ -71,17 +83,20 @@ export type BattleSaveState = {
|
|||||||
battleBuffs?: BattleSaveBuffState[];
|
battleBuffs?: BattleSaveBuffState[];
|
||||||
battleStatuses?: BattleSaveStatusState[];
|
battleStatuses?: BattleSaveStatusState[];
|
||||||
battleStats?: Record<string, BattleSaveUnitStats>;
|
battleStats?: Record<string, BattleSaveUnitStats>;
|
||||||
|
tacticalCommand?: BattleSaveTacticalCommandState;
|
||||||
enemyUsableUseKeys?: string[];
|
enemyUsableUseKeys?: string[];
|
||||||
triggeredBattleEvents?: string[];
|
triggeredBattleEvents?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type BattleSaveValidationOptions = {
|
type BattleSaveValidationOptions = {
|
||||||
expectedBattleId?: string;
|
expectedBattleId?: string;
|
||||||
|
allowTacticalCommand?: boolean;
|
||||||
mapWidth?: number;
|
mapWidth?: number;
|
||||||
mapHeight?: number;
|
mapHeight?: number;
|
||||||
validUnitIds?: ReadonlySet<string>;
|
validUnitIds?: ReadonlySet<string>;
|
||||||
validAllyUnitIds?: ReadonlySet<string>;
|
validAllyUnitIds?: ReadonlySet<string>;
|
||||||
validEnemyUnitIds?: ReadonlySet<string>;
|
validEnemyUnitIds?: ReadonlySet<string>;
|
||||||
|
validTacticalCommandRolesByActor?: Readonly<Record<string, BattleSaveTacticalCommandRole>>;
|
||||||
validUsableIds?: ReadonlySet<string>;
|
validUsableIds?: ReadonlySet<string>;
|
||||||
validItemIds?: ReadonlySet<string>;
|
validItemIds?: ReadonlySet<string>;
|
||||||
validEquipmentItemIds?: ReadonlySet<string>;
|
validEquipmentItemIds?: ReadonlySet<string>;
|
||||||
@@ -106,6 +121,8 @@ const maxBattleUnitAttack = 9999;
|
|||||||
const maxBattleUnitMove = 99;
|
const maxBattleUnitMove = 99;
|
||||||
const maxBattleBondBattleExp = 999999;
|
const maxBattleBondBattleExp = 999999;
|
||||||
const maxStatusKindsPerUnit = 2;
|
const maxStatusKindsPerUnit = 2;
|
||||||
|
const tacticalCommandCounterplayThreshold = 3;
|
||||||
|
const tacticalCommandSupportHealLimit = 6;
|
||||||
const maxBattleItemStockById: Record<string, number> = {
|
const maxBattleItemStockById: Record<string, number> = {
|
||||||
bean: 3,
|
bean: 3,
|
||||||
salve: 2,
|
salve: 2,
|
||||||
@@ -181,6 +198,13 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida
|
|||||||
!isOptionalBuffArray(state.battleBuffs, options) ||
|
!isOptionalBuffArray(state.battleBuffs, options) ||
|
||||||
!isOptionalStatusArray(state.battleStatuses, options) ||
|
!isOptionalStatusArray(state.battleStatuses, options) ||
|
||||||
!isOptionalStatsRecord(state.battleStats, options) ||
|
!isOptionalStatsRecord(state.battleStats, options) ||
|
||||||
|
!isOptionalTacticalCommandState(
|
||||||
|
state.tacticalCommand,
|
||||||
|
Number(state.turnNumber),
|
||||||
|
units,
|
||||||
|
state.battleStats,
|
||||||
|
options
|
||||||
|
) ||
|
||||||
!isOptionalEnemyUsableUseKeyArray(state.enemyUsableUseKeys, state.battleId, options) ||
|
!isOptionalEnemyUsableUseKeyArray(state.enemyUsableUseKeys, state.battleId, options) ||
|
||||||
!isOptionalTriggeredBattleEventArray(state.triggeredBattleEvents, options)
|
!isOptionalTriggeredBattleEventArray(state.triggeredBattleEvents, options)
|
||||||
) {
|
) {
|
||||||
@@ -226,6 +250,10 @@ function isBattleStatusKind(value: unknown): value is BattleSaveStatusKind {
|
|||||||
return value === 'burn' || value === 'confusion';
|
return value === 'burn' || value === 'confusion';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTacticalCommandRole(value: unknown): value is BattleSaveTacticalCommandRole {
|
||||||
|
return value === 'front' || value === 'flank' || value === 'support';
|
||||||
|
}
|
||||||
|
|
||||||
function isUnitIdArray(value: unknown, options: BattleSaveValidationOptions) {
|
function isUnitIdArray(value: unknown, options: BattleSaveValidationOptions) {
|
||||||
return (
|
return (
|
||||||
Array.isArray(value) &&
|
Array.isArray(value) &&
|
||||||
@@ -534,6 +562,75 @@ function isOptionalBattleUnitStatValue(value: unknown) {
|
|||||||
return value === undefined || isBattleUnitStatValue(value);
|
return value === undefined || isBattleUnitStatValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isOptionalTacticalCommandState(
|
||||||
|
value: unknown,
|
||||||
|
turnNumber: number,
|
||||||
|
units: unknown[],
|
||||||
|
battleStats: unknown,
|
||||||
|
options: BattleSaveValidationOptions
|
||||||
|
) {
|
||||||
|
if (value === undefined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!options.allowTacticalCommand) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!isRecord(value) ||
|
||||||
|
!isTacticalCommandRole(value.role) ||
|
||||||
|
typeof value.actorId !== 'string' ||
|
||||||
|
!isKnownUnitId(value.actorId, options) ||
|
||||||
|
(options.validAllyUnitIds && !options.validAllyUnitIds.has(value.actorId)) ||
|
||||||
|
(options.validTacticalCommandRolesByActor && options.validTacticalCommandRolesByActor[value.actorId] !== value.role) ||
|
||||||
|
!isIntegerInRange(value.usedTurn, 1, turnNumber) ||
|
||||||
|
typeof value.effectPending !== 'boolean' ||
|
||||||
|
!isBattleUnitStatValue(value.effectValue) ||
|
||||||
|
!isBattleUnitStatValue(value.statusesCleared) ||
|
||||||
|
(value.effectLost !== undefined && typeof value.effectLost !== 'boolean') ||
|
||||||
|
tacticalCommandCounterplayTotal(battleStats, options) < tacticalCommandCounterplayThreshold
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (value.role === 'support') {
|
||||||
|
return (
|
||||||
|
!value.effectPending &&
|
||||||
|
!value.effectLost &&
|
||||||
|
Number(value.effectValue) <= tacticalCommandSupportHealLimit &&
|
||||||
|
Number(value.statusesCleared) <= maxStatusKindsPerUnit
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Number(value.statusesCleared) !== 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (value.effectPending && (Number(value.effectValue) !== 0 || value.effectLost)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (value.effectLost && Number(value.effectValue) !== 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!value.effectPending) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return units.some((unit) => isRecord(unit) && unit.id === value.actorId && Number(unit.hp) > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tacticalCommandCounterplayTotal(value: unknown, options: BattleSaveValidationOptions) {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Object.entries(value).reduce((total, [unitId, stats]) => {
|
||||||
|
if ((options.validAllyUnitIds && !options.validAllyUnitIds.has(unitId)) || !isRecord(stats)) {
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
total +
|
||||||
|
Number(stats.intentDefeats ?? 0) +
|
||||||
|
Number(stats.intentLineBreaks ?? 0) +
|
||||||
|
Number(stats.intentGuardSuccesses ?? 0)
|
||||||
|
);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
function isOptionalEnemyUsableUseKeyArray(value: unknown, battleId: string, options: BattleSaveValidationOptions) {
|
function isOptionalEnemyUsableUseKeyArray(value: unknown, battleId: string, options: BattleSaveValidationOptions) {
|
||||||
return (
|
return (
|
||||||
value === undefined ||
|
value === undefined ||
|
||||||
|
|||||||
@@ -568,6 +568,11 @@ export function loadCampaignState(slot?: number) {
|
|||||||
return cloneCampaignState(campaignState);
|
return cloneCampaignState(campaignState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readCampaignSaveState(slot: number) {
|
||||||
|
const state = readStoredCampaignState(slot);
|
||||||
|
return state ? cloneCampaignState(state) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function saveCampaignState(state = ensureCampaignState(), slot = state.activeSaveSlot) {
|
export function saveCampaignState(state = ensureCampaignState(), slot = state.activeSaveSlot) {
|
||||||
campaignState = normalizeCampaignState(state);
|
campaignState = normalizeCampaignState(state);
|
||||||
campaignState.activeSaveSlot = normalizeSlot(slot);
|
campaignState.activeSaveSlot = normalizeSlot(slot);
|
||||||
|
|||||||
Reference in New Issue
Block a user