Files
core2026/app/game-frontend/src/views/BattleSimulatorView.vue
T
Hide_D 7c2cf84384 Merge branch 'main' into audit/lint-test-baseline-20260726
# Conflicts:
#	app/game-api/src/battleSim/worker.ts
#	app/game-api/src/router/general/index.ts
2026-07-26 05:57:45 +00:00

1509 lines
49 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import type { BattleSimRequestPayload, BattleSimResultPayload } from '@sammo-ts/game-api';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import BattleGeneralCard from '../components/battle/BattleGeneralCard.vue';
import { trpc } from '../utils/trpc';
import { getNpcColor } from '../utils/npcColor';
import type { BattleSimOptions, GeneralDraft, InheritBuff } from '../utils/battleSimulatorTypes';
type GeneralExport = Omit<GeneralDraft, 'id'>;
type BattleExport = {
attackerGeneral: GeneralExport;
attackerCity: { level: number };
attackerNation: { type: string; tech: number; level: number; capital: number };
defenderGenerals: GeneralExport[];
defenderCity: { level: number; def: number; wall: number };
defenderNation: { type: string; tech: number; level: number; capital: number };
year: number;
month: number;
repeatCnt: number;
seed?: string;
};
type ExportedInfo = { objType: 'general'; data: GeneralExport } | { objType: 'battle'; data: BattleExport };
type GeneralListResponse = Awaited<ReturnType<typeof trpc.battle.getGeneralList.query>>;
type GeneralMeResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
const loading = ref(true);
const error = ref<string | null>(null);
const options = ref<BattleSimOptions | null>(null);
const battleResult = ref<BattleSimResultPayload | null>(null);
const attackerNation = reactive({
type: '',
tech: 1,
level: 0,
isCapital: false,
});
const attackerCity = reactive({
level: 5,
});
const defenderNation = reactive({
type: '',
tech: 1,
level: 0,
isCapital: false,
});
const defenderCity = reactive({
level: 5,
def: 1000,
wall: 1000,
});
const year = ref(0);
const month = ref(1);
const repeatCnt = ref(1);
const seed = ref('');
const attackerGeneral = ref<GeneralDraft | null>(null);
const defenders = ref<GeneralDraft[]>([]);
const isSimulating = ref(false);
const statusMessage = ref<string | null>(null);
const importOpen = ref(false);
const importTarget = ref<GeneralDraft | null>(null);
const generalList = ref<GeneralListResponse | null>(null);
const generalListLoading = ref(false);
const selectedGeneralId = ref<number | null>(null);
const gameDefaults = ref<GeneralMeResponse>(null);
let generalIdSeed = 0;
const createInheritBuff = (): InheritBuff => ({
warAvoidRatio: 0,
warCriticalRatio: 0,
warMagicTrialProb: 0,
warAvoidRatioOppose: 0,
warCriticalRatioOppose: 0,
warMagicTrialProbOppose: 0,
});
const nextGeneralId = (): string => {
generalIdSeed += 1;
return `general-${Date.now()}-${generalIdSeed}`;
};
const getUsedGeneralNos = (excludeId?: string): Set<number> => {
const used = new Set<number>();
if (attackerGeneral.value && attackerGeneral.value.id !== excludeId) {
used.add(attackerGeneral.value.no);
}
for (const defender of defenders.value) {
if (defender.id !== excludeId) {
used.add(defender.no);
}
}
return used;
};
const resolveGeneralNo = (preferred: number | null, excludeId?: string): number => {
const used = getUsedGeneralNos(excludeId);
if (preferred && preferred !== 1 && !used.has(preferred)) {
return preferred;
}
let candidate = 2;
while (used.has(candidate) || candidate === 1) {
candidate += 1;
}
return candidate;
};
const createGeneralDraft = (overrides?: Partial<GeneralDraft>): GeneralDraft => {
const baseCrew = options.value?.unitSet.defaultCrewTypeId ?? options.value?.unitSet.crewTypes[0]?.id ?? 1;
const draft: GeneralDraft = {
id: nextGeneralId(),
no: 0,
name: '무명',
officerLevel: 1,
expLevel: 20,
leadership: 50,
strength: 50,
intel: 50,
horse: null,
weapon: null,
book: null,
item: null,
injury: 0,
rice: 5000,
personal: null,
special2: null,
crew: 7000,
crewtype: baseCrew,
atmos: 100,
train: 100,
dex1: 0,
dex2: 0,
dex3: 0,
dex4: 0,
dex5: 0,
defenceTrain: 90,
warnum: 0,
killnum: 0,
killcrew: 0,
inheritBuff: createInheritBuff(),
};
Object.assign(draft, overrides);
draft.no = resolveGeneralNo(overrides?.no ?? null, draft.id);
if (draft.crewtype <= 0) {
draft.crewtype = baseCrew;
}
return draft;
};
const createAttackerGeneral = () => {
const draft = createGeneralDraft();
draft.no = 1;
attackerGeneral.value = draft;
};
const addDefender = (general?: GeneralExport) => {
const draft = createGeneralDraft(general ?? undefined);
defenders.value.push(draft);
return draft;
};
const removeDefender = (generalId: string) => {
defenders.value = defenders.value.filter((item) => item.id !== generalId);
};
const copyDefender = (source: GeneralDraft) => {
const clone = toExportedGeneral(source);
addDefender(clone);
};
const applyGeneralExport = (target: GeneralDraft, data: GeneralExport) => {
target.name = data.name;
target.officerLevel = data.officerLevel;
target.expLevel = data.expLevel;
target.leadership = data.leadership;
target.strength = data.strength;
target.intel = data.intel;
target.horse = data.horse;
target.weapon = data.weapon;
target.book = data.book;
target.item = data.item;
target.injury = data.injury;
target.rice = data.rice;
target.personal = data.personal;
target.special2 = data.special2;
target.crew = data.crew;
target.crewtype = data.crewtype;
target.atmos = data.atmos;
target.train = data.train;
target.dex1 = data.dex1;
target.dex2 = data.dex2;
target.dex3 = data.dex3;
target.dex4 = data.dex4;
target.dex5 = data.dex5;
target.defenceTrain = data.defenceTrain;
target.warnum = data.warnum;
target.killnum = data.killnum;
target.killcrew = data.killcrew;
target.inheritBuff = data.inheritBuff ? { ...data.inheritBuff } : createInheritBuff();
if (target.crewtype <= 0) {
target.crewtype = options.value?.unitSet.defaultCrewTypeId ?? options.value?.unitSet.crewTypes[0]?.id ?? 1;
}
};
const toExportedGeneral = (general: GeneralDraft): GeneralExport => ({
no: general.no,
name: general.name,
officerLevel: general.officerLevel,
expLevel: general.expLevel,
leadership: general.leadership,
strength: general.strength,
intel: general.intel,
horse: general.horse,
weapon: general.weapon,
book: general.book,
item: general.item,
injury: general.injury,
rice: general.rice,
personal: general.personal,
special2: general.special2,
crew: general.crew,
crewtype: general.crewtype,
atmos: general.atmos,
train: general.train,
dex1: general.dex1,
dex2: general.dex2,
dex3: general.dex3,
dex4: general.dex4,
dex5: general.dex5,
defenceTrain: general.defenceTrain,
warnum: general.warnum,
killnum: general.killnum,
killcrew: general.killcrew,
inheritBuff: { ...general.inheritBuff },
});
const initializeDefaults = async () => {
loading.value = true;
error.value = null;
try {
const [context, me] = await Promise.all([trpc.battle.getSimulatorContext.query(), trpc.general.me.query()]);
options.value = context;
gameDefaults.value = me;
year.value = context.world.currentYear;
month.value = context.world.currentMonth;
repeatCnt.value = 1;
seed.value = '';
const nationTypeDefault = context.nationTypes[0]?.key ?? 'che_중립';
attackerNation.type = me?.nation?.typeCode ?? nationTypeDefault;
defenderNation.type = me?.nation?.typeCode ?? nationTypeDefault;
attackerNation.level = me?.nation?.level ?? 0;
defenderNation.level = me?.nation?.level ?? 0;
attackerNation.tech = me?.nation?.tech ? Math.floor(me.nation.tech / 1000) : 1;
defenderNation.tech = attackerNation.tech;
attackerCity.level = me?.city?.level ?? 5;
defenderCity.level = me?.city?.level ?? 5;
defenderCity.def = me?.city?.defence ?? 1000;
defenderCity.wall = me?.city?.wall ?? 1000;
attackerNation.isCapital = me?.nation?.capitalCityId === me?.city?.id;
defenderNation.isCapital = attackerNation.isCapital;
createAttackerGeneral();
defenders.value = [];
addDefender();
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
loading.value = false;
}
};
const hasGameGeneral = computed(() => !!gameDefaults.value?.general?.id);
const applyGameEnvironment = () => {
if (!options.value) {
return;
}
const me = gameDefaults.value;
const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립';
year.value = options.value.world.currentYear;
month.value = options.value.world.currentMonth;
attackerNation.type = me?.nation?.typeCode ?? nationTypeDefault;
defenderNation.type = attackerNation.type;
attackerNation.level = me?.nation?.level ?? 0;
defenderNation.level = attackerNation.level;
attackerNation.tech = me?.nation?.tech ? Math.floor(me.nation.tech / 1000) : 1;
defenderNation.tech = attackerNation.tech;
attackerCity.level = me?.city?.level ?? 5;
defenderCity.level = attackerCity.level;
defenderCity.def = me?.city?.defence ?? 1000;
defenderCity.wall = me?.city?.wall ?? 1000;
attackerNation.isCapital = !!me?.city && me.nation?.capitalCityId === me.city.id;
defenderNation.isCapital = attackerNation.isCapital;
error.value = null;
};
const applyIndependentEnvironment = () => {
if (!options.value) {
return;
}
const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립';
year.value = options.value.world.startYear;
month.value = 1;
seed.value = '';
repeatCnt.value = 1;
Object.assign(attackerNation, { type: nationTypeDefault, tech: 1, level: 0, isCapital: false });
Object.assign(defenderNation, { type: nationTypeDefault, tech: 1, level: 0, isCapital: false });
attackerCity.level = 5;
Object.assign(defenderCity, { level: 5, def: 1000, wall: 1000 });
error.value = null;
};
onMounted(() => {
void initializeDefaults();
});
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
}
if (typeof value === 'string') {
return value;
}
return 'unknown_error';
};
const readNumberValue = (value: unknown, fallback = 0): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
};
const readStringValue = (value: unknown, fallback = ''): string => {
if (typeof value === 'string') {
return value;
}
return fallback;
};
const readOptionalString = (value: unknown): string | null => {
if (typeof value !== 'string') {
return null;
}
if (!value || value === 'None') {
return null;
}
return value;
};
const normalizeInheritBuff = (raw: unknown): InheritBuff => {
if (!raw || typeof raw !== 'object') {
return createInheritBuff();
}
const buff = raw as Record<string, unknown>;
return {
warAvoidRatio: readNumberValue(buff.warAvoidRatio, 0),
warCriticalRatio: readNumberValue(buff.warCriticalRatio, 0),
warMagicTrialProb: readNumberValue(buff.warMagicTrialProb, 0),
warAvoidRatioOppose: readNumberValue(buff.warAvoidRatioOppose, 0),
warCriticalRatioOppose: readNumberValue(buff.warCriticalRatioOppose, 0),
warMagicTrialProbOppose: readNumberValue(buff.warMagicTrialProbOppose, 0),
};
};
const normalizeGeneralExport = (raw: Record<string, unknown>): GeneralExport => ({
no: readNumberValue(raw.no, 0),
name: readStringValue(raw.name, '무명'),
officerLevel: readNumberValue(raw.officerLevel ?? raw.officer_level, 1),
expLevel: readNumberValue(raw.expLevel ?? raw.explevel, 0),
leadership: readNumberValue(raw.leadership, 50),
strength: readNumberValue(raw.strength, 50),
intel: readNumberValue(raw.intel, 50),
horse: readOptionalString(raw.horse),
weapon: readOptionalString(raw.weapon),
book: readOptionalString(raw.book),
item: readOptionalString(raw.item),
injury: readNumberValue(raw.injury, 0),
rice: readNumberValue(raw.rice, 0),
personal: readOptionalString(raw.personal),
special2: readOptionalString(raw.special2),
crew: readNumberValue(raw.crew, 0),
crewtype: readNumberValue(raw.crewtype, 0),
atmos: readNumberValue(raw.atmos, 0),
train: readNumberValue(raw.train, 0),
dex1: readNumberValue(raw.dex1, 0),
dex2: readNumberValue(raw.dex2, 0),
dex3: readNumberValue(raw.dex3, 0),
dex4: readNumberValue(raw.dex4, 0),
dex5: readNumberValue(raw.dex5, 0),
defenceTrain: readNumberValue(raw.defenceTrain ?? raw.defence_train, 0),
warnum: readNumberValue(raw.warnum, 0),
killnum: readNumberValue(raw.killnum, 0),
killcrew: readNumberValue(raw.killcrew, 0),
inheritBuff: normalizeInheritBuff(raw.inheritBuff),
});
const formatBattleTime = (date: Date): string => {
const pad = (value: number) => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(
date.getMinutes()
)}`;
};
const buildGeneralPayload = (
general: GeneralDraft,
nationId: number,
officerCityId: number,
timestamp: string
): BattleSimRequestPayload['attackerGeneral'] => {
const officerCity = general.officerLevel >= 2 && general.officerLevel <= 4 ? officerCityId : 0;
return {
no: general.no,
name: general.name,
nation: nationId,
turntime: timestamp,
personal: general.personal,
special2: general.special2,
crew: general.crew,
crewtype: general.crewtype,
atmos: general.atmos,
train: general.train,
intel: general.intel,
intel_exp: 0,
book: general.book,
strength: general.strength,
strength_exp: 0,
weapon: general.weapon,
injury: general.injury,
leadership: general.leadership,
leadership_exp: 0,
horse: general.horse,
item: general.item,
explevel: general.expLevel,
experience: Math.round(Math.pow(general.expLevel, 2)),
dedication: 0,
officer_level: general.officerLevel,
officer_city: officerCity,
gold: 10000,
rice: general.rice,
dex1: general.dex1,
dex2: general.dex2,
dex3: general.dex3,
dex4: general.dex4,
dex5: general.dex5,
defence_train: general.defenceTrain,
recent_war: timestamp,
warnum: general.warnum,
killnum: general.killnum,
killcrew: general.killcrew,
inheritBuff: { ...general.inheritBuff },
};
};
const buildBattlePayload = (action: BattleSimRequestPayload['action']): BattleSimRequestPayload => {
if (!attackerGeneral.value) {
throw new Error('attacker_general_missing');
}
const now = formatBattleTime(new Date());
const attackerNationPayload = {
nation: 1,
name: '출병국',
capital: attackerNation.isCapital ? 1 : 2,
level: attackerNation.level,
gold: 0,
rice: 2000,
type: attackerNation.type,
tech: attackerNation.tech * 1000,
gennum: 200,
};
const defenderNationPayload = {
nation: 2,
name: '수비국',
capital: defenderNation.isCapital ? 3 : 4,
level: defenderNation.level,
gold: 0,
rice: 2000,
type: defenderNation.type,
tech: defenderNation.tech * 1000,
gennum: 200,
};
const baseCity = {
nation: 0,
supply: 1,
name: '도시',
pop: 500000,
agri: 10000,
comm: 10000,
secu: 10000,
def: 1000,
wall: 1000,
trust: 100,
pop_max: 600000,
agri_max: 12000,
comm_max: 12000,
secu_max: 10000,
def_max: 12000,
wall_max: 12000,
dead: 0,
state: 0,
conflict: '{}',
};
const attackerCityPayload = {
...baseCity,
nation: 1,
city: 1,
level: attackerCity.level,
};
const defenderCityPayload = {
...baseCity,
nation: 2,
city: 3,
level: defenderCity.level,
def: defenderCity.def,
wall: defenderCity.wall,
def_max: Math.floor((defenderCity.def / 5) * 6),
wall_max: Math.floor((defenderCity.wall / 5) * 6),
};
return {
action,
seed: seed.value.trim() ? seed.value.trim() : undefined,
repeatCnt: repeatCnt.value,
year: year.value,
month: month.value,
attackerGeneral: buildGeneralPayload(attackerGeneral.value, 1, 1, now),
attackerCity: attackerCityPayload,
attackerNation: attackerNationPayload,
defenderGenerals: defenders.value.map((general) => buildGeneralPayload(general, 2, 3, now)),
defenderCity: defenderCityPayload,
defenderNation: defenderNationPayload,
};
};
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const waitForSimulationResult = async (jobId: string): Promise<BattleSimResultPayload> => {
const deadline = Date.now() + 15000;
while (Date.now() < deadline) {
const response = await trpc.battle.getSimulation.query({ jobId });
if ('payload' in response && response.payload) {
return response.payload;
}
await delay(800);
}
throw new Error('simulation_timeout');
};
const runSimulation = async (action: BattleSimRequestPayload['action']) => {
if (!options.value) {
return;
}
isSimulating.value = true;
error.value = null;
if (action === 'battle') {
battleResult.value = null;
}
statusMessage.value = action === 'battle' ? '전투를 진행 중입니다.' : '수비자 순서를 계산 중입니다.';
try {
const payload = buildBattlePayload(action);
const response = await trpc.battle.simulate.mutate(payload);
const result =
'payload' in response && response.payload
? response.payload
: await waitForSimulationResult(response.jobId);
if (!result.result) {
error.value = result.reason || 'battle_failed';
return;
}
if (action === 'reorder' && result.order) {
reorderDefenders(result.order);
return;
}
battleResult.value = result;
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
isSimulating.value = false;
statusMessage.value = null;
}
};
const reorderDefenders = (order: number[]) => {
const map = new Map(defenders.value.map((general) => [general.no, general]));
const next: GeneralDraft[] = [];
for (const generalNo of order) {
const general = map.get(generalNo);
if (!general) {
continue;
}
next.push(general);
map.delete(generalNo);
}
for (const general of map.values()) {
next.push(general);
}
defenders.value = next;
};
const formatNumber = (value?: number | null) => {
if (value === null || value === undefined) {
return '-';
}
const rounded = Number.isInteger(value) ? Math.floor(value) : Number(value.toFixed(2));
return new Intl.NumberFormat('ko-KR').format(rounded);
};
const formatSkillCounts = (skills?: Record<string, number>) => {
if (!skills || Object.keys(skills).length === 0) {
return '-';
}
return Object.entries(skills)
.map(([name, count]) => `${name}(${formatNumber(count)}회)`)
.join(', ');
};
const exportBattleData = (): BattleExport => ({
attackerGeneral: toExportedGeneral(attackerGeneral.value ?? createGeneralDraft()),
attackerCity: { level: attackerCity.level },
attackerNation: {
type: attackerNation.type,
tech: attackerNation.tech * 1000,
level: attackerNation.level,
capital: attackerNation.isCapital ? 1 : 2,
},
defenderGenerals: defenders.value.map((general) => toExportedGeneral(general)),
defenderCity: {
level: defenderCity.level,
def: defenderCity.def,
wall: defenderCity.wall,
},
defenderNation: {
type: defenderNation.type,
tech: defenderNation.tech * 1000,
level: defenderNation.level,
capital: defenderNation.isCapital ? 3 : 4,
},
year: year.value,
month: month.value,
repeatCnt: repeatCnt.value,
seed: seed.value.trim() ? seed.value.trim() : undefined,
});
const downloadJson = (payload: ExportedInfo, filename: string) => {
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
};
const formatDateTag = (): string => {
const now = new Date();
const pad = (value: number) => String(value).padStart(2, '0');
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(
now.getMinutes()
)}`;
};
const saveBattle = () => {
const payload: ExportedInfo = { objType: 'battle', data: exportBattleData() };
downloadJson(payload, `battle_${formatDateTag()}.json`);
};
const saveGeneral = (general: GeneralDraft) => {
const payload: ExportedInfo = { objType: 'general', data: toExportedGeneral(general) };
downloadJson(payload, `general_${general.name}_${formatDateTag()}.json`);
};
const parseImport = async (file: File): Promise<ExportedInfo> => {
const raw = await file.text();
const data = JSON.parse(raw) as Record<string, unknown>;
if (!data || typeof data !== 'object' || !('objType' in data)) {
throw new Error('invalid_file');
}
const objType = data.objType;
if (objType === 'general') {
return {
objType: 'general',
data: normalizeGeneralExport(data.data as Record<string, unknown>),
};
}
if (objType === 'battle') {
const battle = data.data as Record<string, unknown>;
const attackerGeneral = normalizeGeneralExport(battle.attackerGeneral as Record<string, unknown>);
const defenderGenerals = Array.isArray(battle.defenderGenerals)
? battle.defenderGenerals.map((item) => normalizeGeneralExport(item as Record<string, unknown>))
: [];
return {
objType: 'battle',
data: {
attackerGeneral,
defenderGenerals,
attackerCity: battle.attackerCity as BattleExport['attackerCity'],
defenderCity: battle.defenderCity as BattleExport['defenderCity'],
attackerNation: battle.attackerNation as BattleExport['attackerNation'],
defenderNation: battle.defenderNation as BattleExport['defenderNation'],
year: readNumberValue(battle.year, year.value),
month: readNumberValue(battle.month, month.value),
repeatCnt: readNumberValue(battle.repeatCnt, repeatCnt.value),
seed: readStringValue(battle.seed ?? ''),
},
};
}
throw new Error('invalid_file');
};
const importBattle = (data: BattleExport) => {
seed.value = data.seed ?? '';
year.value = data.year;
month.value = data.month;
repeatCnt.value = data.repeatCnt;
attackerNation.type = data.attackerNation.type;
attackerNation.level = data.attackerNation.level;
attackerNation.tech = Math.floor(data.attackerNation.tech / 1000);
attackerNation.isCapital = data.attackerNation.capital === 1;
attackerCity.level = data.attackerCity.level;
defenderNation.type = data.defenderNation.type;
defenderNation.level = data.defenderNation.level;
defenderNation.tech = Math.floor(data.defenderNation.tech / 1000);
defenderNation.isCapital = data.defenderNation.capital === 3;
defenderCity.level = data.defenderCity.level;
defenderCity.def = data.defenderCity.def;
defenderCity.wall = data.defenderCity.wall;
if (!attackerGeneral.value) {
createAttackerGeneral();
}
if (attackerGeneral.value) {
applyGeneralExport(attackerGeneral.value, data.attackerGeneral);
attackerGeneral.value.no = 1;
}
defenders.value = [];
for (const general of data.defenderGenerals) {
const draft = addDefender();
applyGeneralExport(draft, general);
draft.no = resolveGeneralNo(general.no, draft.id);
}
};
const handleGeneralLoad = async (payload: { target: GeneralDraft; file: File }) => {
try {
const data = await parseImport(payload.file);
if (data.objType === 'battle') {
importBattle(data.data);
return;
}
applyGeneralExport(payload.target, data.data);
payload.target.no =
payload.target === attackerGeneral.value ? 1 : resolveGeneralNo(data.data.no, payload.target.id);
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const handleBattleLoad = async (file: File) => {
try {
const data = await parseImport(file);
if (data.objType !== 'battle') {
throw new Error('invalid_battle_file');
}
importBattle(data.data);
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const battleFileInput = ref<HTMLInputElement | null>(null);
const triggerBattleLoad = () => {
battleFileInput.value?.click();
};
const handleBattleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement | null;
const file = target?.files?.[0];
if (!file) {
return;
}
void handleBattleLoad(file);
if (target) {
target.value = '';
}
};
const loadGeneralList = async () => {
if (generalListLoading.value) {
return;
}
generalListLoading.value = true;
try {
generalList.value = await trpc.battle.getGeneralList.query();
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
generalListLoading.value = false;
}
};
const openImportModal = async (target: GeneralDraft) => {
if (!hasGameGeneral.value) {
error.value = '게임 장수를 보유한 사용자만 서버 장수 정보를 가져올 수 있습니다.';
return;
}
importTarget.value = target;
importOpen.value = true;
if (!generalList.value) {
await loadGeneralList();
}
if (generalList.value) {
selectedGeneralId.value = generalList.value.myGeneralId;
}
};
const closeImportModal = () => {
importOpen.value = false;
importTarget.value = null;
};
const applyServerGeneral = async (target: GeneralDraft, generalId: number) => {
const response = await trpc.battle.getGeneralDetail.query({ generalId });
applyGeneralExport(target, {
no: response.general.no,
name: response.general.name,
officerLevel: response.general.officer_level,
expLevel: response.general.explevel,
leadership: response.general.leadership,
strength: response.general.strength,
intel: response.general.intel,
horse: response.general.horse,
weapon: response.general.weapon,
book: response.general.book,
item: response.general.item,
injury: response.general.injury,
rice: response.general.rice,
personal: response.general.personal,
special2: response.general.special2,
crew: response.general.crew,
crewtype: response.general.crewtype,
atmos: response.general.atmos,
train: response.general.train,
dex1: response.general.dex1,
dex2: response.general.dex2,
dex3: response.general.dex3,
dex4: response.general.dex4,
dex5: response.general.dex5,
defenceTrain: response.general.defence_train,
warnum: response.general.warnum,
killnum: response.general.killnum,
killcrew: response.general.killcrew,
inheritBuff: createInheritBuff(),
});
target.no = target === attackerGeneral.value ? 1 : resolveGeneralNo(response.general.no, target.id);
};
const applyMyGeneralToAttacker = async () => {
const generalId = gameDefaults.value?.general?.id;
if (!attackerGeneral.value || !generalId) {
error.value = '불러올 내 장수가 없습니다.';
return;
}
try {
error.value = null;
await applyServerGeneral(attackerGeneral.value, generalId);
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const confirmImport = async () => {
if (!importTarget.value || !selectedGeneralId.value) {
return;
}
try {
await applyServerGeneral(importTarget.value, selectedGeneralId.value);
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
closeImportModal();
}
};
const generalGroups = computed(() => {
if (!generalList.value) {
return [];
}
const nationOrder = [generalList.value.myNationId, ...Object.keys(generalList.value.generalsByNation).map(Number)];
const orderedIds = Array.from(new Set(nationOrder));
const nationMap = new Map(generalList.value.nations.map((nation) => [nation.id, nation]));
return orderedIds
.filter((nationId) => generalList.value?.generalsByNation[nationId]?.length)
.map((nationId) => {
const nation = nationMap.get(nationId) ?? { id: nationId, name: '재야', color: '#000000' };
const generals = [...(generalList.value?.generalsByNation[nationId] ?? [])];
generals.sort((lhs, rhs) => {
if (lhs.npcState !== rhs.npcState) {
return lhs.npcState - rhs.npcState;
}
return lhs.name.localeCompare(rhs.name);
});
return { nation, generals };
});
});
const summaryRows = computed(() => {
if (!battleResult.value) {
return [];
}
return [
{ label: '전투 일시', value: battleResult.value.datetime ?? '-' },
{ label: '전투 횟수', value: formatNumber(battleResult.value.avgWar) },
{ label: '전투 페이즈', value: formatNumber(battleResult.value.phase) },
{
label: '준 피해',
value:
battleResult.value.minKilled !== battleResult.value.maxKilled
? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber(
battleResult.value.maxKilled
)})`
: formatNumber(battleResult.value.killed),
},
{
label: '받은 피해',
value:
battleResult.value.minDead !== battleResult.value.maxDead
? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber(
battleResult.value.maxDead
)})`
: formatNumber(battleResult.value.dead),
},
{ label: '출병자 군량 소모', value: formatNumber(battleResult.value.attackerRice) },
{ label: '수비자 군량 소모', value: formatNumber(battleResult.value.defenderRice) },
{ label: '공격자 스킬', value: formatSkillCounts(battleResult.value.attackerSkills) },
];
});
const defenderSkillRows = computed(() => {
if (!battleResult.value?.defendersSkills) {
return [];
}
return battleResult.value.defendersSkills.map((skills, idx) => ({
label: `수비자${idx + 1} 스킬`,
value: formatSkillCounts(skills),
}));
});
const shouldShowUI = computed(() => !loading.value && !!options.value);
</script>
<template>
<main class="battle-simulator">
<header class="battle-header">
<div>
<h1>전투 시뮬레이터</h1>
<p>시드와 환경을 고정해 전투 결과를 재현합니다.</p>
</div>
<div class="header-actions">
<button
class="primary"
type="button"
:disabled="isSimulating || !options"
@click="runSimulation('battle')"
>
전투
</button>
<button class="ghost" type="button" @click="saveBattle">모두 저장</button>
<input ref="battleFileInput" type="file" accept=".json" hidden @change="handleBattleFileChange" />
<button class="ghost" type="button" @click="triggerBattleLoad">모두 불러오기</button>
</div>
</header>
<section class="independence-notice" aria-label="시뮬레이터 데이터 안내">
<div>
<strong>게임 상태와 분리된 모의 계산</strong>
<p>
현재 연도·국가·도시는 시작값으로만 읽으며, 아래 편집과 전투 결과는 ·DB·장수 상태를 변경하지
않습니다.
</p>
</div>
<div class="notice-actions">
<button class="ghost" type="button" :disabled="!options" @click="applyGameEnvironment">
현재 게임 환경 적용
</button>
<button class="ghost" type="button" :disabled="!options" @click="applyIndependentEnvironment">
독립 기본값
</button>
<button
class="ghost"
type="button"
:disabled="!hasGameGeneral || !attackerGeneral"
@click="applyMyGeneralToAttacker"
>
장수를 출병자로
</button>
</div>
</section>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="statusMessage" class="status">{{ statusMessage }}</div>
<PanelCard title="전역 설정" subtitle="전투 시점과 난수 설정">
<template #actions>
<button
class="ghost"
type="button"
:disabled="isSimulating || !options"
@click="runSimulation('reorder')"
>
수비 순서대로 정렬
</button>
<button class="ghost" type="button" :disabled="!options" @click="addDefender()">수비자 추가</button>
</template>
<div v-if="loading">
<SkeletonLines :lines="3" />
</div>
<div v-else-if="options" class="settings-grid">
<label class="field">
<span>시나리오 시작 연도</span>
<input type="number" :value="options.world.startYear" disabled />
</label>
<label class="field">
<span>연도</span>
<input v-model.number="year" type="number" :min="options.world.startYear" />
</label>
<label class="field">
<span></span>
<input v-model.number="month" type="number" min="1" max="12" />
</label>
<label class="field">
<span>시드</span>
<input v-model="seed" type="text" placeholder="빈값이면 랜덤" />
</label>
<label class="field">
<span>반복 횟수</span>
<select v-model.number="repeatCnt">
<option :value="1">1 (로그 표기)</option>
<option :value="1000">1000 (요약 표기)</option>
</select>
</label>
</div>
</PanelCard>
<div v-if="shouldShowUI" class="battle-grid">
<div class="column">
<PanelCard title="출병국 설정">
<div class="form-row">
<label class="field">
<span>국가 성향</span>
<select v-model="attackerNation.type">
<option v-for="trait in options!.nationTypes" :key="trait.key" :value="trait.key">
{{ trait.name }} ({{ trait.info }})
</option>
</select>
</label>
<label class="field">
<span>기술</span>
<input v-model.number="attackerNation.tech" type="number" min="0" max="12" />
</label>
<label class="field">
<span>등급</span>
<select v-model.number="attackerNation.level">
<option v-for="level in options!.nationLevels" :key="level.level" :value="level.level">
{{ level.name }}
</option>
</select>
</label>
</div>
<div class="form-row">
<label class="field">
<span>도시 규모</span>
<select v-model.number="attackerCity.level">
<option v-for="level in options!.cityLevels" :key="level.level" :value="level.level">
{{ level.name }}
</option>
</select>
</label>
<label class="field">
<span>수도</span>
<select v-model="attackerNation.isCapital">
<option :value="true">Y</option>
<option :value="false">N</option>
</select>
</label>
</div>
</PanelCard>
<BattleGeneralCard
v-if="attackerGeneral"
v-model:general="attackerGeneral"
:options="options!"
mode="attacker"
title="출병자 설정"
:can-import-server="hasGameGeneral"
@import="openImportModal(attackerGeneral!)"
@save="saveGeneral(attackerGeneral!)"
@load="(payload) => handleGeneralLoad({ target: attackerGeneral!, file: payload.file })"
/>
</div>
<div class="column">
<PanelCard title="수비국 설정">
<div class="form-row">
<label class="field">
<span>국가 성향</span>
<select v-model="defenderNation.type">
<option v-for="trait in options!.nationTypes" :key="trait.key" :value="trait.key">
{{ trait.name }} ({{ trait.info }})
</option>
</select>
</label>
<label class="field">
<span>기술</span>
<input v-model.number="defenderNation.tech" type="number" min="0" max="12" />
</label>
<label class="field">
<span>등급</span>
<select v-model.number="defenderNation.level">
<option v-for="level in options!.nationLevels" :key="level.level" :value="level.level">
{{ level.name }}
</option>
</select>
</label>
</div>
<div class="form-row">
<label class="field">
<span>도시 규모</span>
<select v-model.number="defenderCity.level">
<option v-for="level in options!.cityLevels" :key="level.level" :value="level.level">
{{ level.name }}
</option>
</select>
</label>
<label class="field">
<span>수도</span>
<select v-model="defenderNation.isCapital">
<option :value="true">Y</option>
<option :value="false">N</option>
</select>
</label>
</div>
<div class="form-row">
<label class="field">
<span>수비</span>
<input v-model.number="defenderCity.def" type="number" min="10" step="10" />
</label>
<label class="field">
<span>성벽</span>
<input v-model.number="defenderCity.wall" type="number" min="0" step="10" />
</label>
</div>
</PanelCard>
<div class="defender-list">
<BattleGeneralCard
v-for="(defender, index) in defenders"
:key="defender.id"
v-model:general="defenders[index]"
:options="options!"
mode="defender"
:title="`수비자 설정 ${index + 1}`"
:can-import-server="hasGameGeneral"
@import="openImportModal(defender)"
@save="saveGeneral(defender)"
@load="(payload) => handleGeneralLoad({ target: defender, file: payload.file })"
@copy="copyDefender(defender)"
@remove="removeDefender(defender.id)"
/>
</div>
</div>
</div>
<PanelCard title="전투 요약">
<table class="summary-table">
<tbody>
<tr v-for="row in summaryRows" :key="row.label">
<th>{{ row.label }}</th>
<td>{{ row.value }}</td>
</tr>
<tr v-for="row in defenderSkillRows" :key="row.label">
<th>{{ row.label }}</th>
<td>{{ row.value }}</td>
</tr>
</tbody>
</table>
</PanelCard>
<div class="log-grid">
<PanelCard title="마지막 전투 로그">
<div class="log-body" v-html="battleResult?.lastWarLog?.generalBattleResultLog ?? ''" />
</PanelCard>
<PanelCard title="마지막 전투 상세 로그">
<div class="log-body" v-html="battleResult?.lastWarLog?.generalBattleDetailLog ?? ''" />
</PanelCard>
</div>
<div v-if="importOpen" class="modal-backdrop">
<div class="modal">
<header>
<h3>장수 목록</h3>
<button type="button" class="ghost" @click="closeImportModal">닫기</button>
</header>
<div class="modal-body">
<p class="hint">타국 장수는 숙련과 아이템이 초기화됩니다.</p>
<div v-if="generalListLoading">
<SkeletonLines :lines="4" />
</div>
<div v-else class="select-wrap">
<select v-model.number="selectedGeneralId">
<optgroup v-for="group in generalGroups" :key="group.nation.id" :label="group.nation.name">
<option
v-for="general in group.generals"
:key="general.id"
:value="general.id"
:style="{ color: getNpcColor(general.npcState) ?? '#e8ddc4' }"
>
{{ general.name }}
</option>
</optgroup>
</select>
</div>
</div>
<footer>
<button type="button" class="primary" @click="confirmImport">가져오기</button>
</footer>
</div>
</div>
</main>
</template>
<style scoped>
.battle-simulator {
display: flex;
flex-direction: column;
gap: 18px;
width: min(100%, 1000px);
margin: 0 auto;
padding-bottom: 30px;
background:
radial-gradient(circle at top left, rgba(201, 164, 90, 0.15), transparent 45%),
radial-gradient(circle at bottom right, rgba(120, 140, 110, 0.15), transparent 40%);
}
.battle-header {
display: flex;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.battle-header h1 {
font-size: 1.2rem;
margin: 0;
}
.battle-header p {
margin: 6px 0 0;
font-size: 0.85rem;
color: rgba(232, 221, 196, 0.7);
}
.header-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.independence-notice {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 10px 12px;
border: 1px solid rgba(112, 170, 141, 0.45);
background: rgba(18, 52, 40, 0.35);
}
.independence-notice strong {
color: #bfe2cd;
font-size: 0.85rem;
}
.independence-notice p {
margin: 4px 0 0;
color: rgba(221, 239, 228, 0.75);
font-size: 0.75rem;
}
.notice-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 6px;
}
button {
font-family: inherit;
background: none;
border: none;
color: inherit;
}
.primary,
.ghost {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 6px 12px;
font-size: 0.8rem;
cursor: pointer;
}
.primary {
background: rgba(201, 164, 90, 0.2);
}
.ghost {
background: rgba(16, 16, 16, 0.6);
}
button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.error {
color: #f5b7b1;
font-size: 0.85rem;
}
.status {
color: #f5d08a;
font-size: 0.85rem;
}
.settings-grid,
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.75);
}
.field input,
.field select {
background: rgba(6, 6, 6, 0.7);
border: 1px solid rgba(201, 164, 90, 0.35);
color: #e8ddc4;
padding: 4px 6px;
font-size: 0.8rem;
}
.battle-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}
.column {
display: flex;
flex-direction: column;
gap: 14px;
}
.defender-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.summary-table {
width: 100%;
border-collapse: collapse;
}
.summary-table th,
.summary-table td {
padding: 6px 8px;
border-bottom: 1px solid rgba(201, 164, 90, 0.2);
text-align: left;
}
.summary-table th {
width: 18ch;
color: rgba(232, 221, 196, 0.7);
font-weight: 600;
}
.log-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 12px;
}
.log-body {
min-height: 120px;
line-height: 1.5;
font-size: 0.85rem;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(5, 5, 5, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 30;
}
.modal {
background: rgba(12, 12, 12, 0.95);
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 16px;
width: min(520px, 90vw);
display: flex;
flex-direction: column;
gap: 12px;
}
.modal header,
.modal footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.modal h3 {
margin: 0;
font-size: 1rem;
}
.modal-body {
display: flex;
flex-direction: column;
gap: 10px;
}
.hint {
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.7);
}
.select-wrap select {
width: 100%;
min-height: 180px;
background: rgba(6, 6, 6, 0.7);
border: 1px solid rgba(201, 164, 90, 0.35);
color: #e8ddc4;
padding: 6px;
}
@media (max-width: 720px) {
.battle-header {
flex-direction: column;
align-items: flex-start;
}
.independence-notice {
align-items: flex-start;
flex-direction: column;
}
}
</style>