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

@@ -56,8 +56,10 @@ export type BattleSaveUnitStats = {
};
export type BattleSaveTacticalCommandRole = 'front' | 'flank' | 'support';
export type BattleSaveTacticalCommandSource = 'sortie-order' | 'initiative';
export type BattleSaveTacticalCommandState = {
source?: BattleSaveTacticalCommandSource;
role: BattleSaveTacticalCommandRole;
actorId: string;
usedTurn: number;
@@ -85,6 +87,8 @@ export type BattleSaveState = {
battleBuffs?: BattleSaveBuffState[];
battleStatuses?: BattleSaveStatusState[];
battleStats?: Record<string, BattleSaveUnitStats>;
sortieOrderCommandUnlocked?: boolean;
sortieOrderCommandUnlockTurn?: number;
tacticalCommand?: BattleSaveTacticalCommandState;
enemyUsableUseKeys?: string[];
triggeredBattleEvents?: string[];
@@ -93,6 +97,7 @@ export type BattleSaveState = {
type BattleSaveValidationOptions = {
expectedBattleId?: string;
allowTacticalCommand?: boolean;
allowLegacyTacticalCommand?: boolean;
mapWidth?: number;
mapHeight?: number;
validUnitIds?: ReadonlySet<string>;
@@ -204,11 +209,20 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida
!isOptionalBuffArray(state.battleBuffs, options) ||
!isOptionalStatusArray(state.battleStatuses, options) ||
!isOptionalStatsRecord(state.battleStats, options) ||
!isOptionalSortieOrderCommandUnlockState(
state.sortieOrderCommandUnlocked,
state.sortieOrderCommandUnlockTurn,
Number(state.turnNumber),
state.battleStats,
options
) ||
!isOptionalTacticalCommandState(
state.tacticalCommand,
Number(state.turnNumber),
units,
state.battleStats,
state.sortieOrderCommandUnlocked === true,
state.sortieOrderCommandUnlockTurn,
options
) ||
!isOptionalEnemyUsableUseKeyArray(state.enemyUsableUseKeys, state.battleId, options) ||
@@ -260,6 +274,10 @@ function isTacticalCommandRole(value: unknown): value is BattleSaveTacticalComma
return value === 'front' || value === 'flank' || value === 'support';
}
function isTacticalCommandSource(value: unknown): value is BattleSaveTacticalCommandSource {
return value === 'sortie-order' || value === 'initiative';
}
function isSortieOrderId(value: unknown): value is SortieOrderId {
return value === 'elite' || value === 'mobile' || value === 'siege';
}
@@ -572,11 +590,35 @@ function isOptionalBattleUnitStatValue(value: unknown) {
return value === undefined || isBattleUnitStatValue(value);
}
function isOptionalSortieOrderCommandUnlockState(
unlocked: unknown,
unlockTurn: unknown,
turnNumber: number,
battleStats: unknown,
options: BattleSaveValidationOptions
) {
if (unlocked === undefined && unlockTurn === undefined) {
return true;
}
if (!options.allowTacticalCommand || typeof unlocked !== 'boolean') {
return false;
}
if (!unlocked) {
return unlockTurn === undefined;
}
return (
isIntegerInRange(unlockTurn, 1, turnNumber) &&
alliedActionTotal(battleStats, options) >= 1
);
}
function isOptionalTacticalCommandState(
value: unknown,
turnNumber: number,
units: unknown[],
battleStats: unknown,
sortieOrderCommandUnlocked: boolean,
sortieOrderCommandUnlockTurn: unknown,
options: BattleSaveValidationOptions
) {
if (value === undefined) {
@@ -587,6 +629,7 @@ function isOptionalTacticalCommandState(
}
if (
!isRecord(value) ||
(value.source !== undefined && !isTacticalCommandSource(value.source)) ||
!isTacticalCommandRole(value.role) ||
typeof value.actorId !== 'string' ||
!isKnownUnitId(value.actorId, options) ||
@@ -596,11 +639,26 @@ function isOptionalTacticalCommandState(
typeof value.effectPending !== 'boolean' ||
!isBattleUnitStatValue(value.effectValue) ||
!isBattleUnitStatValue(value.statusesCleared) ||
(value.effectLost !== undefined && typeof value.effectLost !== 'boolean') ||
tacticalCommandCounterplayTotal(battleStats, options) < tacticalCommandCounterplayThreshold
(value.effectLost !== undefined && typeof value.effectLost !== 'boolean')
) {
return false;
}
if (value.source === 'sortie-order') {
if (
!sortieOrderCommandUnlocked ||
!isIntegerInRange(sortieOrderCommandUnlockTurn, 1, Number(value.usedTurn))
) {
return false;
}
} else {
if (
!options.allowLegacyTacticalCommand ||
sortieOrderCommandUnlocked ||
tacticalCommandCounterplayTotal(battleStats, options) < tacticalCommandCounterplayThreshold
) {
return false;
}
}
if (value.role === 'support') {
return (
!value.effectPending &&
@@ -641,6 +699,18 @@ function tacticalCommandCounterplayTotal(value: unknown, options: BattleSaveVali
}, 0);
}
function alliedActionTotal(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.actions ?? 0);
}, 0);
}
function isOptionalEnemyUsableUseKeyArray(value: unknown, battleId: string, options: BattleSaveValidationOptions) {
return (
value === undefined ||