Expand sortie prep battle data

This commit is contained in:
2026-07-03 22:53:30 +09:00
parent 6fd5615bfd
commit aafe210977
4 changed files with 304 additions and 23 deletions

View File

@@ -268,6 +268,8 @@ import {
type StoryPage,
type UnitData
} from './scenario';
import type { UnitClassKey } from './battleRules';
import type { SortieDeploymentSlot, SortieFormationRole } from './sortieDeployment';
export type BattleScenarioId =
| 'first-battle-zhuo-commandery'
@@ -354,12 +356,35 @@ export type BattleDefeatConditionDefinition = {
unitId: string;
};
export type BattleSortieUnitRecommendation = {
unitId: string;
reason: string;
role?: SortieFormationRole;
};
export type BattleSortieClassRecommendation = {
label: string;
classKeys: UnitClassKey[];
reason: string;
};
export type BattleSortieDefinition = {
sortieLimit: number;
requiredUnits?: string[];
excludedUnits?: string[];
recommendedUnits: BattleSortieUnitRecommendation[];
recommendedClasses: BattleSortieClassRecommendation[];
deploymentSlots: SortieDeploymentSlot[];
note: string;
};
export type BattleScenarioDefinition = {
id: BattleScenarioId;
title: string;
victoryConditionLabel: string;
defeatConditionLabel: string;
openingObjectiveLines: string[];
sortie?: BattleSortieDefinition;
map: BattleMap;
units: UnitData[];
bonds: BattleBond[];
@@ -437,6 +462,26 @@ export const secondBattleScenario: BattleScenarioDefinition = {
'중앙 나루를 건너기 전에 길목의 습격조를 먼저 정리하면 북쪽 마을을 안전하게 확보할 수 있습니다.',
'세 형제를 모두 출전시키고 14턴 이내에 승리하면 추격전 명성과 추가 보상이 붙습니다.'
],
sortie: {
sortieLimit: 3,
requiredUnits: ['liu-bei'],
recommendedUnits: [
{ unitId: 'liu-bei', role: 'support', reason: '주장 생존이 패배 조건입니다. 뒤에서 지휘하며 마을 확보를 맡깁니다.' },
{ unitId: 'guan-yu', role: 'front', reason: '나루 앞 숲길을 버티며 적 보병을 안정적으로 밀어냅니다.' },
{ unitId: 'zhang-fei', role: 'flank', reason: '도망치는 잔당과 궁병을 빠르게 압박하기 좋습니다.' }
],
recommendedClasses: [
{ label: '보병 전열', classKeys: ['infantry'], reason: '좁은 나루와 숲길에서 먼저 맞받아칠 전열이 필요합니다.' },
{ label: '창병 돌파', classKeys: ['spearman'], reason: '북쪽으로 달아나는 적을 따라붙을 돌파 담당이 필요합니다.' },
{ label: '군주 후원', classKeys: ['lord'], reason: '유비는 패배 조건이므로 후방 지휘 위치가 안전합니다.' }
],
deploymentSlots: [
{ role: 'front', unitId: 'guan-yu', x: 3, y: 15, label: '나루 전열' },
{ role: 'flank', unitId: 'zhang-fei', x: 2, y: 16, label: '남쪽 돌파' },
{ role: 'support', unitId: 'liu-bei', x: 2, y: 15, label: '후방 지휘' }
],
note: '세 형제 전원 출전이 가장 안정적입니다. 관우를 전열, 장비를 돌파, 유비를 후원에 두면 시작 위치가 의도대로 잡힙니다.'
},
map: secondBattleMap,
units: secondBattleUnits,
bonds: secondBattleBonds,
@@ -490,6 +535,26 @@ export const thirdBattleScenario: BattleScenarioDefinition = {
'강가 길목과 요새 주변의 궁병을 방치하면 전선이 오래 묶입니다. 지형 보정을 살펴 진군하십시오.',
'18턴 이내에 승리하면 광종 구원로 확보 명성이 오릅니다.'
],
sortie: {
sortieLimit: 3,
requiredUnits: ['liu-bei'],
recommendedUnits: [
{ unitId: 'liu-bei', role: 'support', reason: '강가 길목에서 무리하지 않고 전선을 묶어 주는 지휘가 필요합니다.' },
{ unitId: 'guan-yu', role: 'front', reason: '요새와 숲의 방어선을 정면에서 받아내기 좋습니다.' },
{ unitId: 'zhang-fei', role: 'flank', reason: '전령을 놓치지 않도록 좁은 길을 빠르게 압박합니다.' }
],
recommendedClasses: [
{ label: '전열 보병', classKeys: ['infantry'], reason: '요새 주변에서 궁병 견제를 버틸 전열이 필요합니다.' },
{ label: '돌파 창병', classKeys: ['spearman'], reason: '전령 추격과 좁은 길 돌파에 힘을 싣습니다.' },
{ label: '군주 후원', classKeys: ['lord'], reason: '유비는 뒤에서 생존과 공명 연결을 책임지는 편이 안전합니다.' }
],
deploymentSlots: [
{ role: 'front', unitId: 'guan-yu', x: 3, y: 16, label: '강가 전열' },
{ role: 'flank', unitId: 'zhang-fei', x: 2, y: 17, label: '추격 출발' },
{ role: 'support', unitId: 'liu-bei', x: 2, y: 16, label: '후방 지휘' }
],
note: '강가 길목은 오래 버티는 전열과 빠른 추격이 모두 필요합니다. 유비는 뒤에 두고 관우와 장비를 앞세우십시오.'
},
map: thirdBattleMap,
units: thirdBattleUnits,
bonds: thirdBattleBonds,
@@ -543,6 +608,26 @@ export const fourthBattleScenario: BattleScenarioDefinition = {
'요새와 숲의 궁병이 진입로를 막고 있습니다. 측면의 기병과 중앙 수비병을 나누어 상대하십시오.',
'16턴 이내에 승리하면 황건적 토벌의 공적이 크게 오릅니다.'
],
sortie: {
sortieLimit: 3,
requiredUnits: ['liu-bei'],
recommendedUnits: [
{ unitId: 'liu-bei', role: 'support', reason: '장각 격파까지 전선이 무너지지 않도록 후방 지휘가 필요합니다.' },
{ unitId: 'guan-yu', role: 'front', reason: '본영 정면의 요새 방어선을 뚫는 핵심 전열입니다.' },
{ unitId: 'zhang-fei', role: 'flank', reason: '측면 궁병과 기병을 흔들어 본영 진입 시간을 줄입니다.' }
],
recommendedClasses: [
{ label: '강한 전열', classKeys: ['infantry'], reason: '요새와 궁병 앞에서 버티며 중앙 길을 여는 역할입니다.' },
{ label: '측면 돌파', classKeys: ['spearman'], reason: '본영 측면을 흔들어 장각까지 빠르게 접근합니다.' },
{ label: '후방 지휘', classKeys: ['lord'], reason: '패배 조건인 유비를 안전하게 두고 공명 보정을 유지합니다.' }
],
deploymentSlots: [
{ role: 'front', unitId: 'guan-yu', x: 3, y: 15, label: '본영 입구' },
{ role: 'flank', unitId: 'zhang-fei', x: 2, y: 16, label: '측면 압박' },
{ role: 'support', unitId: 'liu-bei', x: 2, y: 15, label: '후방 지휘' }
],
note: '황건 본영은 세 형제 전원 출전과 역할 분담이 중요합니다. 장비를 돌파로 두면 측면 압박이 빨라집니다.'
},
map: fourthBattleMap,
units: fourthBattleUnits,
bonds: fourthBattleBonds,
@@ -596,6 +681,26 @@ export const fifthBattleScenario: BattleScenarioDefinition = {
'중앙 길은 빠르지만 궁병과 기병의 반격을 받기 쉽습니다. 숲길 척후를 처리하고 요새 주변을 차근히 압박하십시오.',
'14턴 이내에 승리하면 공손찬 진영에서 세 형제의 무공이 더 크게 알려집니다.'
],
sortie: {
sortieLimit: 3,
requiredUnits: ['liu-bei'],
recommendedUnits: [
{ unitId: 'liu-bei', role: 'support', reason: '반동탁 연합에서 명분을 세워야 하므로 생존과 지휘가 우선입니다.' },
{ unitId: 'guan-yu', role: 'front', reason: '전초 요새 앞 보병과 궁병의 반격을 안정적으로 받아냅니다.' },
{ unitId: 'zhang-fei', role: 'flank', reason: '호진의 전열을 흔들고 숲길 척후를 빠르게 정리합니다.' }
],
recommendedClasses: [
{ label: '요새 전열', classKeys: ['infantry'], reason: '중앙 길의 궁병 반격을 견딜 단단한 전열이 필요합니다.' },
{ label: '숲길 돌파', classKeys: ['spearman'], reason: '측면 척후를 잡고 호진에게 접근할 돌파 담당이 좋습니다.' },
{ label: '지휘 후원', classKeys: ['lord'], reason: '유비는 후방에서 세 형제 공명과 생존 조건을 유지합니다.' }
],
deploymentSlots: [
{ role: 'front', unitId: 'guan-yu', x: 3, y: 15, label: '요새 전열' },
{ role: 'flank', unitId: 'zhang-fei', x: 2, y: 16, label: '숲길 돌파' },
{ role: 'support', unitId: 'liu-bei', x: 2, y: 15, label: '후방 지휘' }
],
note: '사수관 전초전은 중앙 길을 서두르되 숲길 척후를 놓치지 않는 편성이 좋습니다.'
},
map: fifthBattleMap,
units: fifthBattleUnits,
bonds: fifthBattleBonds,
@@ -649,6 +754,26 @@ export const sixthBattleScenario: BattleScenarioDefinition = {
'강가의 궁병과 측면 기병이 동시에 압박합니다. 유비는 뒤에 두고 관우와 장비로 중앙 길을 확보하십시오.',
'15턴 이내에 승리하면 공손찬 진영에서 세 형제의 신뢰가 커집니다.'
],
sortie: {
sortieLimit: 3,
requiredUnits: ['liu-bei'],
recommendedUnits: [
{ unitId: 'liu-bei', role: 'support', reason: '공손찬 원군로의 명분을 지키되, 강가 압박에서 보호해야 합니다.' },
{ unitId: 'guan-yu', role: 'front', reason: '강가와 진영 지형에서 적 선봉을 받아내는 전열이 됩니다.' },
{ unitId: 'zhang-fei', role: 'flank', reason: '원소군 별동대와 측면 기병을 빠르게 압박합니다.' }
],
recommendedClasses: [
{ label: '강가 전열', classKeys: ['infantry'], reason: '궁병 견제를 맞으며 중앙 길을 확보해야 합니다.' },
{ label: '측면 돌파', classKeys: ['spearman'], reason: '별동대와 기병 압박을 끊어 내는 역할이 필요합니다.' },
{ label: '안전 지휘', classKeys: ['lord'], reason: '유비는 후방 배치로 생존과 지휘를 동시에 챙깁니다.' }
],
deploymentSlots: [
{ role: 'front', unitId: 'guan-yu', x: 3, y: 16, label: '강가 전열' },
{ role: 'flank', unitId: 'zhang-fei', x: 2, y: 17, label: '측면 대응' },
{ role: 'support', unitId: 'liu-bei', x: 2, y: 16, label: '후방 지휘' }
],
note: '계교 원군로는 빠른 압박보다 길목 안정이 중요합니다. 유비를 보호하고 관우와 장비로 먼저 맞받으십시오.'
},
map: sixthBattleMap,
units: sixthBattleUnits,
bonds: sixthBattleBonds,

View File

@@ -12,12 +12,22 @@ export type SortieDeploymentPlanEntry = {
y: number;
};
export type SortieDeploymentSlot = {
x: number;
y: number;
role?: SortieFormationRole;
unitId?: string;
label?: string;
};
type ScoredTile = {
x: number;
y: number;
forward: number;
lateral: number;
enemyDistance: number;
role?: SortieFormationRole;
unitId?: string;
};
export function isSortieFormationRole(value: unknown): value is SortieFormationRole {
@@ -40,13 +50,18 @@ export function createSortieDeploymentPlan(
sourceUnits: UnitData[],
selectedUnitIds: string[],
assignments: SortieFormationAssignments,
fallbackRoleForUnit: (unit: UnitData) => SortieFormationRole
fallbackRoleForUnit: (unit: UnitData) => SortieFormationRole,
deploymentSlots?: SortieDeploymentSlot[]
) {
const selectedOrder = new Map(selectedUnitIds.map((unitId, index) => [unitId, index]));
const deployedAllies = deployedUnits
.filter((unit) => unit.faction === 'ally')
.sort((a, b) => (selectedOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (selectedOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER));
const sourceAllyTiles = uniqueTiles(sourceUnits.filter((unit) => unit.faction === 'ally').map((unit) => ({ x: unit.x, y: unit.y })));
const sourceAllyTiles = uniqueTiles(
deploymentSlots?.length
? deploymentSlots.map((slot) => ({ x: slot.x, y: slot.y, role: slot.role, unitId: slot.unitId }))
: sourceUnits.filter((unit) => unit.faction === 'ally').map((unit) => ({ x: unit.x, y: unit.y }))
);
if (deployedAllies.length === 0 || sourceAllyTiles.length === 0) {
return new Map<string, SortieDeploymentPlanEntry>();
@@ -60,8 +75,25 @@ export function createSortieDeploymentPlan(
const plan = new Map<string, SortieDeploymentPlanEntry>();
const usedTiles = new Set<string>();
deployedAllies.forEach((unit) => {
const role = roleForUnit(unit, assignments, fallbackRoleForUnit);
const tile = scoredTiles.find(
(candidate) => candidate.unitId === unit.id && (!candidate.role || candidate.role === role) && !usedTiles.has(tileKey(candidate))
);
if (!tile) {
return;
}
usedTiles.add(tileKey(tile));
plan.set(unit.id, {
unitId: unit.id,
role,
x: tile.x,
y: tile.y
});
});
sortieFormationRoles.forEach((role) => {
const roleUnits = deployedAllies.filter((unit) => roleForUnit(unit, assignments, fallbackRoleForUnit) === role);
const roleUnits = deployedAllies.filter((unit) => !plan.has(unit.id) && roleForUnit(unit, assignments, fallbackRoleForUnit) === role);
roleUnits.forEach((unit) => {
const tile = bestTileForRole(role, scoredTiles, usedTiles);
if (!tile) {
@@ -126,6 +158,11 @@ function bestTileForRole(role: SortieFormationRole, tiles: ScoredTile[], usedTil
}
function compareTilesForRole(role: SortieFormationRole, a: ScoredTile, b: ScoredTile) {
const aRoleMatch = a.role === role ? 1 : 0;
const bRoleMatch = b.role === role ? 1 : 0;
if (aRoleMatch !== bRoleMatch) {
return bRoleMatch - aRoleMatch;
}
if (role === 'front') {
return b.forward - a.forward || a.enemyDistance - b.enemyDistance || a.lateral - b.lateral;
}
@@ -139,7 +176,7 @@ function compareTilesForRole(role: SortieFormationRole, a: ScoredTile, b: Scored
}
function scoreTile(
tile: { x: number; y: number },
tile: { x: number; y: number; role?: SortieFormationRole; unitId?: string },
allyCenter: { x: number; y: number },
enemyCenter: { x: number; y: number },
direction: { x: number; y: number }
@@ -150,11 +187,13 @@ function scoreTile(
y: tile.y,
forward: relative.x * direction.x + relative.y * direction.y,
lateral: Math.abs(relative.x * -direction.y + relative.y * direction.x),
enemyDistance: Math.hypot(tile.x - enemyCenter.x, tile.y - enemyCenter.y)
enemyDistance: Math.hypot(tile.x - enemyCenter.x, tile.y - enemyCenter.y),
role: tile.role,
unitId: tile.unitId
};
}
function uniqueTiles(tiles: { x: number; y: number }[]) {
function uniqueTiles<T extends { x: number; y: number }>(tiles: T[]) {
const seen = new Set<string>();
return tiles.filter((tile) => {
const key = tileKey(tile);