feat: add tactical initiative commands

This commit is contained in:
2026-07-10 12:24:08 +09:00
parent 26ef353a7f
commit 3d2c09c24e
4 changed files with 1042 additions and 21 deletions

View File

@@ -54,6 +54,18 @@ export type BattleSaveUnitStats = {
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 = {
version: 1;
battleId: string;
@@ -71,17 +83,20 @@ export type BattleSaveState = {
battleBuffs?: BattleSaveBuffState[];
battleStatuses?: BattleSaveStatusState[];
battleStats?: Record<string, BattleSaveUnitStats>;
tacticalCommand?: BattleSaveTacticalCommandState;
enemyUsableUseKeys?: string[];
triggeredBattleEvents?: string[];
};
type BattleSaveValidationOptions = {
expectedBattleId?: string;
allowTacticalCommand?: boolean;
mapWidth?: number;
mapHeight?: number;
validUnitIds?: ReadonlySet<string>;
validAllyUnitIds?: ReadonlySet<string>;
validEnemyUnitIds?: ReadonlySet<string>;
validTacticalCommandRolesByActor?: Readonly<Record<string, BattleSaveTacticalCommandRole>>;
validUsableIds?: ReadonlySet<string>;
validItemIds?: ReadonlySet<string>;
validEquipmentItemIds?: ReadonlySet<string>;
@@ -106,6 +121,8 @@ const maxBattleUnitAttack = 9999;
const maxBattleUnitMove = 99;
const maxBattleBondBattleExp = 999999;
const maxStatusKindsPerUnit = 2;
const tacticalCommandCounterplayThreshold = 3;
const tacticalCommandSupportHealLimit = 6;
const maxBattleItemStockById: Record<string, number> = {
bean: 3,
salve: 2,
@@ -181,6 +198,13 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida
!isOptionalBuffArray(state.battleBuffs, options) ||
!isOptionalStatusArray(state.battleStatuses, options) ||
!isOptionalStatsRecord(state.battleStats, options) ||
!isOptionalTacticalCommandState(
state.tacticalCommand,
Number(state.turnNumber),
units,
state.battleStats,
options
) ||
!isOptionalEnemyUsableUseKeyArray(state.enemyUsableUseKeys, state.battleId, options) ||
!isOptionalTriggeredBattleEventArray(state.triggeredBattleEvents, options)
) {
@@ -226,6 +250,10 @@ function isBattleStatusKind(value: unknown): value is BattleSaveStatusKind {
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) {
return (
Array.isArray(value) &&
@@ -534,6 +562,75 @@ function isOptionalBattleUnitStatValue(value: unknown) {
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) {
return (
value === undefined ||