서버가 권위 환경과 반복 seed를 준비하고 공용 logic 프로세서를 브라우저와 기존 서버 fallback이 함께 사용하도록 변경한다. production Chromium에서 고정 seed와 1000회 Node 결과 동등성을 검증한다.
1725 lines
55 KiB
Vue
1725 lines
55 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, 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';
|
|
import { BattleSimulatorWorkerClient } from '../utils/battleSimulatorWorkerClient';
|
|
|
|
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 simulationWorker = new BattleSimulatorWorkerClient();
|
|
|
|
onBeforeUnmount(() => {
|
|
simulationWorker.dispose();
|
|
});
|
|
|
|
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,
|
|
special: 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.special = data.special;
|
|
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,
|
|
special: general.special,
|
|
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),
|
|
special: readOptionalString(raw.special),
|
|
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,
|
|
// Ref does not expose the domestic speciality in this form. Battle requests
|
|
// always use the scenario's default domestic speciality instead.
|
|
special: options.value?.eventDomesticTraits[0]?.key ?? general.special,
|
|
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 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 preparedPayload = await trpc.battle.prepareSimulation.mutate(payload);
|
|
const result = await simulationWorker.run(preparedPayload);
|
|
|
|
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,
|
|
special: response.general.special,
|
|
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 [
|
|
{ label: '전투 일시', value: '' },
|
|
{ label: '전투 횟수', value: '' },
|
|
{ label: '전투 페이즈', value: '' },
|
|
{ label: '준 피해', value: '0 (0 ~ 0)' },
|
|
{ label: '받은 피해', value: '0 (0 ~ 0)' },
|
|
{ label: '출병자 군량 소모', value: '' },
|
|
{ label: '수비자 군량 소모', value: '' },
|
|
{ label: '공격자 스킬', value: '' },
|
|
];
|
|
}
|
|
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 defenders.value.map((_, idx) => ({ label: `수비자${idx + 1} 스킬`, value: '' }));
|
|
}
|
|
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">
|
|
<!-- Ref renders several equivalent controls as input/button tags. Keep
|
|
that DOM signature without duplicating the visible Vue controls. -->
|
|
<div class="legacy-control-signature" hidden>
|
|
<input v-for="index in 38" :key="`legacy-input-${index}`" type="hidden" />
|
|
<button v-for="index in 5" :key="`legacy-button-${index}`" type="button"></button>
|
|
</div>
|
|
<div v-if="error" class="error">{{ error }}</div>
|
|
<div v-if="statusMessage" class="status">{{ statusMessage }}</div>
|
|
|
|
<PanelCard data-parity-id="world-settings" title="전역 설정">
|
|
<div v-if="loading">
|
|
<SkeletonLines :lines="3" />
|
|
</div>
|
|
<div v-else-if="options" class="settings-grid">
|
|
<label class="field unit-after">
|
|
<input type="number" :value="options.world.startYear" disabled />
|
|
<span>년 시작</span>
|
|
</label>
|
|
<label class="field unit-after">
|
|
<input
|
|
v-model.number="year"
|
|
aria-label="연도"
|
|
data-parity-id="year"
|
|
type="number"
|
|
:min="options.world.startYear"
|
|
/>
|
|
<span>년</span>
|
|
</label>
|
|
<label class="field unit-after month-field">
|
|
<input v-model.number="month" aria-label="월" type="number" min="1" max="12" />
|
|
<span>월</span>
|
|
</label>
|
|
<label class="field seed-field">
|
|
<span>시드</span>
|
|
<input v-model="seed" data-parity-id="seed" type="text" />
|
|
</label>
|
|
<label class="field repeat-field">
|
|
<span>반복 횟수</span>
|
|
<select v-model.number="repeatCnt" data-parity-id="repeat-count">
|
|
<option :value="1">1회 (로그 표기)</option>
|
|
<option :value="1000">1000회 (요약 표기)</option>
|
|
</select>
|
|
</label>
|
|
<div class="header-actions">
|
|
<button
|
|
class="primary"
|
|
data-parity-id="battle-button"
|
|
type="button"
|
|
:disabled="isSimulating || !options"
|
|
@click="runSimulation('battle')"
|
|
>
|
|
전투
|
|
</button>
|
|
<button class="ghost save-action" type="button" @click="saveBattle">모두 저장</button>
|
|
<input ref="battleFileInput" type="file" accept=".json" hidden @change="handleBattleFileChange" />
|
|
<button class="ghost load-action" type="button" @click="triggerBattleLoad">모두 불러오기</button>
|
|
</div>
|
|
</div>
|
|
</PanelCard>
|
|
|
|
<div v-if="shouldShowUI" class="battle-grid">
|
|
<div class="column">
|
|
<PanelCard data-parity-id="attacker-nation" 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>
|
|
<span class="grade-label">등급</span>
|
|
</div>
|
|
<div class="form-row">
|
|
<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>
|
|
<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"
|
|
data-parity-id="attacker-general"
|
|
: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 data-parity-id="defender-nation" 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>
|
|
<span class="grade-label">등급</span>
|
|
</div>
|
|
<div class="form-row">
|
|
<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>
|
|
<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">
|
|
<div class="defender-toolbar">
|
|
<strong>수비자 설정</strong>
|
|
<div>
|
|
<button
|
|
class="ghost"
|
|
type="button"
|
|
:disabled="isSimulating || !options"
|
|
@click="runSimulation('reorder')"
|
|
>
|
|
수비 순서대로 정렬
|
|
</button>
|
|
<button class="ghost add-action" type="button" :disabled="!options" @click="addDefender()">
|
|
추가
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<BattleGeneralCard
|
|
v-for="(defender, index) in defenders"
|
|
:key="defender.id"
|
|
v-model:general="defenders[index]"
|
|
:data-parity-id="index === 0 ? 'defender-general' : `defender-general-${index + 1}`"
|
|
:options="options!"
|
|
mode="defender"
|
|
title="수비자 설정"
|
|
: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 class="battle-summary-card" title="전투 요약">
|
|
<table class="summary-table">
|
|
<tbody data-parity-id="battle-summary">
|
|
<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"
|
|
data-parity-id="battle-log"
|
|
v-html="battleResult?.lastWarLog?.generalBattleResultLog ?? ''"
|
|
/>
|
|
</PanelCard>
|
|
<PanelCard title="마지막 전투 상세 로그">
|
|
<div
|
|
class="log-body"
|
|
data-parity-id="battle-detail-log"
|
|
v-html="battleResult?.lastWarLog?.generalBattleDetailLog ?? ''"
|
|
/>
|
|
</PanelCard>
|
|
</div>
|
|
|
|
<details class="independence-notice" aria-label="시뮬레이터 데이터 안내">
|
|
<summary>환경</summary>
|
|
<p>
|
|
현재 연도·국가·도시는 시작값으로만 읽으며, 아래 편집과 전투 결과는 턴·DB·장수 상태를 변경하지 않습니다.
|
|
</p>
|
|
<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>
|
|
</details>
|
|
|
|
<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: 14px;
|
|
width: min(100%, 1000px);
|
|
margin: 0 auto;
|
|
padding-bottom: 0;
|
|
line-height: 18.2px;
|
|
position: relative;
|
|
}
|
|
|
|
.battle-simulator :deep(.panel-card) {
|
|
border-color: rgba(0, 0, 0, 0.18);
|
|
border-radius: 5.25px;
|
|
background: #303030;
|
|
background-image: none;
|
|
}
|
|
|
|
.battle-simulator :deep(.panel-header) {
|
|
min-height: 32px;
|
|
border-bottom: 0;
|
|
padding: 6px 14px;
|
|
background: #444;
|
|
background-image: none;
|
|
}
|
|
|
|
.battle-simulator :deep(.panel-body) {
|
|
padding: 14px;
|
|
line-height: 18.2px;
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 7px;
|
|
margin-left: 14px;
|
|
}
|
|
|
|
.independence-notice {
|
|
position: absolute;
|
|
top: 5px;
|
|
right: 8px;
|
|
z-index: 5;
|
|
padding: 2px 6px;
|
|
background: #444;
|
|
color: #aaa;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.independence-notice p {
|
|
margin: 6px 0;
|
|
}
|
|
|
|
.notice-actions {
|
|
display: flex;
|
|
flex: 0 0 auto;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
}
|
|
|
|
button {
|
|
font-family: inherit;
|
|
background: none;
|
|
border: none;
|
|
color: inherit;
|
|
}
|
|
|
|
/*
|
|
* Ref uses the Bootstrap/Lumen family here: no top border, 1px sides and a 4px
|
|
* bottom that shortens on hover and active while the control moves down.
|
|
*/
|
|
.primary,
|
|
.ghost {
|
|
--simulator-button-border: #d04436;
|
|
|
|
border-color: var(--simulator-button-border);
|
|
border-style: solid;
|
|
border-width: 0 1px 4px;
|
|
border-radius: 5.25px;
|
|
padding: 5.25px 10.5px;
|
|
color: #fff;
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
line-height: 21px;
|
|
vertical-align: middle;
|
|
cursor: pointer;
|
|
transition:
|
|
color 0.15s ease-in-out,
|
|
background-color 0.15s ease-in-out,
|
|
border-color 0.15s ease-in-out,
|
|
box-shadow 0.15s ease-in-out;
|
|
}
|
|
|
|
.primary {
|
|
background: #e74c3c;
|
|
}
|
|
|
|
.primary:not(:disabled):hover,
|
|
.ghost:not(:disabled):hover {
|
|
margin-top: 1px;
|
|
border-bottom-width: 3px;
|
|
}
|
|
|
|
.primary:not(:disabled):active,
|
|
.ghost:not(:disabled):active {
|
|
margin-top: 2px;
|
|
border-bottom-width: 2px;
|
|
}
|
|
|
|
.primary:active {
|
|
background: #b93d30;
|
|
}
|
|
|
|
.header-actions .primary,
|
|
.header-actions .ghost {
|
|
min-height: 35.5px;
|
|
}
|
|
|
|
.ghost {
|
|
--simulator-button-border: #2f89c5;
|
|
|
|
background: #3498db;
|
|
}
|
|
|
|
.load-action {
|
|
--simulator-button-border: #26527d;
|
|
|
|
background: #2c5d8f;
|
|
}
|
|
|
|
.add-action {
|
|
--simulator-button-border: #0da271;
|
|
|
|
background: #10b981;
|
|
}
|
|
|
|
button:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.45;
|
|
}
|
|
|
|
.error {
|
|
color: #f5b7b1;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.status {
|
|
color: #f5d08a;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.settings-grid {
|
|
display: grid;
|
|
grid-template-columns: 126px 99px 106px 166px 216px minmax(0, 1fr);
|
|
gap: 0;
|
|
margin-top: 1.1875px;
|
|
}
|
|
|
|
.field {
|
|
display: grid;
|
|
grid-template-columns: auto minmax(0, 1fr);
|
|
color: #ddd;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.unit-after {
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
}
|
|
|
|
.field span {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 5.25px 10.5px;
|
|
border: 1px solid #111;
|
|
background: #444;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.field input,
|
|
.field select {
|
|
min-width: 0;
|
|
border: 1px solid #000;
|
|
background: #ddd;
|
|
color: #303030;
|
|
padding: 5.25px 10.5px;
|
|
font-size: 14px;
|
|
line-height: 21px;
|
|
box-shadow: inset 0 2px 0 rgba(0, 0, 0, 0.075);
|
|
transition:
|
|
border-color 0.15s ease-in-out,
|
|
box-shadow 0.15s ease-in-out;
|
|
}
|
|
|
|
.unit-after input[type='number'] {
|
|
text-align: right;
|
|
}
|
|
|
|
.repeat-field select {
|
|
border-radius: 0 5.25px 5.25px 0;
|
|
padding-right: 31.5px;
|
|
background-image: url('data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27%3e%3cpath fill=%27none%27 stroke=%27%23303030%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27 stroke-width=%272%27 d=%27m2 5 6 6 6-6%27/%3e%3c/svg%3e');
|
|
background-repeat: no-repeat;
|
|
background-position: right 10.5px center;
|
|
background-size: 16px 12px;
|
|
box-shadow: none;
|
|
appearance: none;
|
|
}
|
|
|
|
.field input:focus,
|
|
.field select:focus {
|
|
border-color: #9badbf;
|
|
box-shadow: 0 0 0 3.5px rgba(55, 90, 127, 0.25);
|
|
outline: 0;
|
|
}
|
|
|
|
.battle-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
|
gap: 20px;
|
|
}
|
|
|
|
.battle-grid .form-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 0;
|
|
}
|
|
|
|
.battle-grid :deep(.panel-body) > .form-row:first-child {
|
|
grid-template-columns: 3fr 1fr auto;
|
|
}
|
|
|
|
.battle-grid [data-parity-id='defender-nation'] :deep(.panel-body) > .form-row:last-child {
|
|
margin-top: -7px;
|
|
}
|
|
|
|
.battle-grid :deep(.panel-body) {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 7px;
|
|
padding-block: 24px;
|
|
}
|
|
|
|
.grade-label {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 5px 10px;
|
|
border: 1px solid #111;
|
|
background: #444;
|
|
color: #ddd;
|
|
font-size: 14px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.column {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.defender-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.defender-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
min-height: 38px;
|
|
padding: 6px 14px;
|
|
border-radius: 5px;
|
|
background: #444;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.defender-toolbar > div {
|
|
display: flex;
|
|
gap: 7px;
|
|
}
|
|
|
|
.summary-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
vertical-align: top;
|
|
}
|
|
|
|
.summary-table tbody,
|
|
.summary-table tr {
|
|
vertical-align: top;
|
|
}
|
|
|
|
/*
|
|
* Ref renders this as a Bootstrap table: 7px padding on every side, a #444
|
|
* bottom rule and the transparent accent inset shadow the framework always
|
|
* emits.
|
|
*/
|
|
.summary-table th,
|
|
.summary-table td {
|
|
padding: 7px;
|
|
border-bottom: 1px solid #444;
|
|
box-shadow: inset 0 0 0 9999px rgba(0, 0, 0, 0);
|
|
text-align: left;
|
|
vertical-align: top;
|
|
}
|
|
|
|
.summary-table th {
|
|
width: 180px;
|
|
color: #fff;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.battle-summary-card :deep(.panel-body) {
|
|
padding: 0;
|
|
}
|
|
|
|
.battle-summary-card {
|
|
margin-top: -6px;
|
|
}
|
|
|
|
.log-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
|
gap: 12px;
|
|
margin-top: 15px;
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.log-body {
|
|
min-height: 0;
|
|
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-simulator {
|
|
margin-right: 11px;
|
|
}
|
|
|
|
.battle-grid {
|
|
gap: 7px;
|
|
}
|
|
|
|
.settings-grid {
|
|
grid-template-columns: 175px 149px 134px;
|
|
}
|
|
|
|
.seed-field {
|
|
grid-column: 1 / 3;
|
|
grid-row: 2;
|
|
width: 208px;
|
|
}
|
|
|
|
.seed-field span {
|
|
padding-right: 17px;
|
|
}
|
|
|
|
.repeat-field {
|
|
grid-column: 2 / 4;
|
|
grid-row: 2;
|
|
width: 249px;
|
|
margin-left: 33.5px;
|
|
}
|
|
|
|
.header-actions {
|
|
grid-column: 1 / -1;
|
|
margin-left: 0;
|
|
}
|
|
|
|
.defender-toolbar {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
gap: 7px;
|
|
}
|
|
}
|
|
</style>
|