전투 시뮬레이터 구현
This commit is contained in:
@@ -3,6 +3,7 @@ import type { BattleSimJobPayload, BattleSimRequestPayload } from './types.js';
|
|||||||
import { loadUnitSetDefinitionByName } from './unitSetLoader.js';
|
import { loadUnitSetDefinitionByName } from './unitSetLoader.js';
|
||||||
import type { WarEngineConfig } from '@sammo-ts/logic';
|
import type { WarEngineConfig } from '@sammo-ts/logic';
|
||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
|
import type { UnitSetDefinition } from '@sammo-ts/logic';
|
||||||
|
|
||||||
const DEFAULT_WAR_CONFIG = {
|
const DEFAULT_WAR_CONFIG = {
|
||||||
armPerPhase: 500,
|
armPerPhase: 500,
|
||||||
@@ -73,11 +74,17 @@ const resolveCastleArmType = (
|
|||||||
return crewTypes.find((crewType) => crewType.id === castleCrewTypeId)?.armType ?? 0;
|
return crewTypes.find((crewType) => crewType.id === castleCrewTypeId)?.armType ?? 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildBattleSimJobPayload = async (
|
export interface BattleSimEnvironment {
|
||||||
|
unitSetName: string;
|
||||||
|
unitSet: UnitSetDefinition;
|
||||||
|
config: WarEngineConfig;
|
||||||
|
startYear: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildBattleSimEnvironment = async (
|
||||||
worldState: WorldStateRow,
|
worldState: WorldStateRow,
|
||||||
request: BattleSimRequestPayload,
|
|
||||||
profileFallback: string
|
profileFallback: string
|
||||||
): Promise<BattleSimJobPayload> => {
|
): Promise<BattleSimEnvironment> => {
|
||||||
const unitSetName = resolveUnitSetName(worldState, profileFallback);
|
const unitSetName = resolveUnitSetName(worldState, profileFallback);
|
||||||
const unitSet = await loadUnitSetDefinitionByName(unitSetName);
|
const unitSet = await loadUnitSetDefinitionByName(unitSetName);
|
||||||
|
|
||||||
@@ -105,13 +112,28 @@ export const buildBattleSimJobPayload = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...request,
|
unitSetName,
|
||||||
unitSet,
|
unitSet,
|
||||||
config,
|
config,
|
||||||
|
startYear: resolveStartYear(worldState),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildBattleSimJobPayload = async (
|
||||||
|
worldState: WorldStateRow,
|
||||||
|
request: BattleSimRequestPayload,
|
||||||
|
profileFallback: string
|
||||||
|
): Promise<BattleSimJobPayload> => {
|
||||||
|
const environment = await buildBattleSimEnvironment(worldState, profileFallback);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...request,
|
||||||
|
unitSet: environment.unitSet,
|
||||||
|
config: environment.config,
|
||||||
time: {
|
time: {
|
||||||
year: request.year,
|
year: request.year,
|
||||||
month: request.month,
|
month: request.month,
|
||||||
startYear: resolveStartYear(worldState),
|
startYear: environment.startYear,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ const mapGeneralPayload = (payload: BattleSimJobPayload['attackerGeneral']): Gen
|
|||||||
intelExp: payload.intel_exp,
|
intelExp: payload.intel_exp,
|
||||||
strengthExp: payload.strength_exp,
|
strengthExp: payload.strength_exp,
|
||||||
leadershipExp: payload.leadership_exp,
|
leadershipExp: payload.leadership_exp,
|
||||||
|
defenceTrain: payload.defence_train,
|
||||||
rank_warnum: payload.warnum,
|
rank_warnum: payload.warnum,
|
||||||
rank_killnum: payload.killnum,
|
rank_killnum: payload.killnum,
|
||||||
rank_killcrew: payload.killcrew,
|
rank_killcrew: payload.killcrew,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const zBattleSimGeneral = z.object({
|
|||||||
experience: z.number().int().min(0),
|
experience: z.number().int().min(0),
|
||||||
dedication: z.number().int().min(0),
|
dedication: z.number().int().min(0),
|
||||||
officer_level: z.number().int().min(1),
|
officer_level: z.number().int().min(1),
|
||||||
officer_city: z.number().int().positive(),
|
officer_city: z.number().int().min(0),
|
||||||
gold: z.number().int().min(0),
|
gold: z.number().int().min(0),
|
||||||
rice: z.number().int().min(0),
|
rice: z.number().int().min(0),
|
||||||
dex1: z.number().int().min(0),
|
dex1: z.number().int().min(0),
|
||||||
@@ -36,6 +36,7 @@ export const zBattleSimGeneral = z.object({
|
|||||||
dex3: z.number().int().min(0),
|
dex3: z.number().int().min(0),
|
||||||
dex4: z.number().int().min(0),
|
dex4: z.number().int().min(0),
|
||||||
dex5: z.number().int().min(0),
|
dex5: z.number().int().min(0),
|
||||||
|
defence_train: z.number().int().min(0),
|
||||||
recent_war: z.string().nullable(),
|
recent_war: z.string().nullable(),
|
||||||
warnum: z.number().int().min(0),
|
warnum: z.number().int().min(0),
|
||||||
killnum: z.number().int().min(0),
|
killnum: z.number().int().min(0),
|
||||||
@@ -70,7 +71,7 @@ export const zBattleSimCity = z.object({
|
|||||||
export const zBattleSimNation = z.object({
|
export const zBattleSimNation = z.object({
|
||||||
type: z.string().min(1),
|
type: z.string().min(1),
|
||||||
tech: z.number().min(0),
|
tech: z.number().min(0),
|
||||||
level: z.number().int().min(1),
|
level: z.number().int().min(0),
|
||||||
capital: z.number().int().min(0),
|
capital: z.number().int().min(0),
|
||||||
nation: z.number().int().min(0),
|
nation: z.number().int().min(0),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import {
|
||||||
|
ITEM_KEYS,
|
||||||
|
loadItemModules,
|
||||||
|
loadNationTraitModules,
|
||||||
|
loadPersonalityTraitModules,
|
||||||
|
loadWarTraitModules,
|
||||||
|
NATION_TRAIT_KEYS,
|
||||||
|
PERSONALITY_TRAIT_KEYS,
|
||||||
|
WAR_TRAIT_KEYS,
|
||||||
|
type ItemModule,
|
||||||
|
type TraitModule,
|
||||||
|
} from '@sammo-ts/logic';
|
||||||
|
|
||||||
|
export type BattleSimTraitOption = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
info: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BattleSimItemOption = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BattleSimItemOptions = {
|
||||||
|
horse: BattleSimItemOption[];
|
||||||
|
weapon: BattleSimItemOption[];
|
||||||
|
book: BattleSimItemOption[];
|
||||||
|
item: BattleSimItemOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BattleSimDexLevel = {
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BATTLE_SIM_NATION_LEVELS: Array<{ level: number; name: string }> = [
|
||||||
|
{ level: 0, name: '방랑군' },
|
||||||
|
{ level: 1, name: '호족' },
|
||||||
|
{ level: 2, name: '군벌' },
|
||||||
|
{ level: 3, name: '주자사' },
|
||||||
|
{ level: 4, name: '주목' },
|
||||||
|
{ level: 5, name: '공' },
|
||||||
|
{ level: 6, name: '왕' },
|
||||||
|
{ level: 7, name: '황제' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const BATTLE_SIM_CITY_LEVELS: Array<{ level: number; name: string }> = [
|
||||||
|
{ level: 1, name: '수' },
|
||||||
|
{ level: 2, name: '진' },
|
||||||
|
{ level: 3, name: '관' },
|
||||||
|
{ level: 4, name: '이' },
|
||||||
|
{ level: 5, name: '소' },
|
||||||
|
{ level: 6, name: '중' },
|
||||||
|
{ level: 7, name: '대' },
|
||||||
|
{ level: 8, name: '특' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const BATTLE_SIM_DEX_LEVELS: BattleSimDexLevel[] = [
|
||||||
|
{ value: 0, color: 'navy', label: 'F-' },
|
||||||
|
{ value: 350, color: 'navy', label: 'F' },
|
||||||
|
{ value: 1375, color: 'navy', label: 'F+' },
|
||||||
|
{ value: 3500, color: 'skyblue', label: 'E-' },
|
||||||
|
{ value: 7125, color: 'skyblue', label: 'E' },
|
||||||
|
{ value: 12650, color: 'skyblue', label: 'E+' },
|
||||||
|
{ value: 20475, color: 'seagreen', label: 'D-' },
|
||||||
|
{ value: 31000, color: 'seagreen', label: 'D' },
|
||||||
|
{ value: 44625, color: 'seagreen', label: 'D+' },
|
||||||
|
{ value: 61750, color: 'teal', label: 'C-' },
|
||||||
|
{ value: 82775, color: 'teal', label: 'C' },
|
||||||
|
{ value: 108100, color: 'teal', label: 'C+' },
|
||||||
|
{ value: 138125, color: 'limegreen', label: 'B-' },
|
||||||
|
{ value: 173250, color: 'limegreen', label: 'B' },
|
||||||
|
{ value: 213875, color: 'limegreen', label: 'B+' },
|
||||||
|
{ value: 260400, color: 'darkorange', label: 'A-' },
|
||||||
|
{ value: 313225, color: 'darkorange', label: 'A' },
|
||||||
|
{ value: 372750, color: 'darkorange', label: 'A+' },
|
||||||
|
{ value: 439375, color: 'tomato', label: 'S-' },
|
||||||
|
{ value: 513500, color: 'tomato', label: 'S' },
|
||||||
|
{ value: 595525, color: 'tomato', label: 'S+' },
|
||||||
|
{ value: 685850, color: 'darkviolet', label: 'Z-' },
|
||||||
|
{ value: 784875, color: 'darkviolet', label: 'Z' },
|
||||||
|
{ value: 893000, color: 'darkviolet', label: 'Z+' },
|
||||||
|
{ value: 1010625, color: 'gold', label: 'EX-' },
|
||||||
|
{ value: 1138150, color: 'gold', label: 'EX' },
|
||||||
|
{ value: 1275975, color: 'white', label: 'EX+' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const toTraitOption = (module: TraitModule): BattleSimTraitOption => ({
|
||||||
|
key: module.key,
|
||||||
|
name: module.name,
|
||||||
|
info: module.info,
|
||||||
|
});
|
||||||
|
|
||||||
|
let cachedTraitOptions: Promise<{
|
||||||
|
nationTypes: BattleSimTraitOption[];
|
||||||
|
warTraits: BattleSimTraitOption[];
|
||||||
|
personalities: BattleSimTraitOption[];
|
||||||
|
}> | null = null;
|
||||||
|
|
||||||
|
export const loadBattleSimTraitOptions = async (): Promise<{
|
||||||
|
nationTypes: BattleSimTraitOption[];
|
||||||
|
warTraits: BattleSimTraitOption[];
|
||||||
|
personalities: BattleSimTraitOption[];
|
||||||
|
}> => {
|
||||||
|
if (!cachedTraitOptions) {
|
||||||
|
cachedTraitOptions = Promise.all([
|
||||||
|
loadNationTraitModules([...NATION_TRAIT_KEYS]),
|
||||||
|
loadWarTraitModules([...WAR_TRAIT_KEYS]),
|
||||||
|
loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS]),
|
||||||
|
]).then(([nationTraits, warTraits, personalities]) => ({
|
||||||
|
nationTypes: nationTraits.map(toTraitOption),
|
||||||
|
warTraits: warTraits.map(toTraitOption),
|
||||||
|
personalities: personalities.map(toTraitOption),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return cachedTraitOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
let cachedItemOptions: Promise<BattleSimItemOptions> | null = null;
|
||||||
|
|
||||||
|
const toItemOption = (module: ItemModule): BattleSimItemOption => ({
|
||||||
|
key: module.key,
|
||||||
|
name: module.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const loadBattleSimItemOptions = async (): Promise<BattleSimItemOptions> => {
|
||||||
|
if (!cachedItemOptions) {
|
||||||
|
cachedItemOptions = loadItemModules([...ITEM_KEYS]).then((modules) => {
|
||||||
|
const items: BattleSimItemOptions = {
|
||||||
|
horse: [],
|
||||||
|
weapon: [],
|
||||||
|
book: [],
|
||||||
|
item: [],
|
||||||
|
};
|
||||||
|
for (const module of modules) {
|
||||||
|
items[module.slot].push(toItemOption(module));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cachedItemOptions;
|
||||||
|
};
|
||||||
@@ -38,6 +38,7 @@ export interface BattleSimGeneralPayload {
|
|||||||
dex3: number;
|
dex3: number;
|
||||||
dex4: number;
|
dex4: number;
|
||||||
dex5: number;
|
dex5: number;
|
||||||
|
defence_train: number;
|
||||||
recent_war: string | null;
|
recent_war: string | null;
|
||||||
warnum: number;
|
warnum: number;
|
||||||
killnum: number;
|
killnum: number;
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { parseUnitSetDefinition, type UnitSetDefinition } from '@sammo-ts/logic'
|
|||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
const DEFAULT_UNIT_SET_ROOT = path.resolve(__dirname, '..', '..', '..', 'game-engine', 'resources', 'unitset');
|
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
|
||||||
|
const DEFAULT_UNIT_SET_ROOT = path.resolve(REPO_ROOT, 'resources', 'unitset');
|
||||||
|
|
||||||
export interface UnitSetLoaderOptions {
|
export interface UnitSetLoaderOptions {
|
||||||
unitSetRoot?: string;
|
unitSetRoot?: string;
|
||||||
|
|||||||
@@ -1,8 +1,51 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { procedure, router } from '../../trpc.js';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
import { buildBattleSimJobPayload } from '../../battleSim/environment.js';
|
import { getDexLevel } from '@sammo-ts/logic';
|
||||||
|
|
||||||
|
import { authedProcedure, procedure, router } from '../../trpc.js';
|
||||||
|
import { buildBattleSimEnvironment, buildBattleSimJobPayload } from '../../battleSim/environment.js';
|
||||||
import { zBattleSimJobId, zBattleSimRequest } from '../../battleSim/schema.js';
|
import { zBattleSimJobId, zBattleSimRequest } from '../../battleSim/schema.js';
|
||||||
|
import {
|
||||||
|
BATTLE_SIM_CITY_LEVELS,
|
||||||
|
BATTLE_SIM_DEX_LEVELS,
|
||||||
|
BATTLE_SIM_NATION_LEVELS,
|
||||||
|
loadBattleSimItemOptions,
|
||||||
|
loadBattleSimTraitOptions,
|
||||||
|
} from '../../battleSim/simulatorOptions.js';
|
||||||
|
import { getMyGeneral } from '../shared/general.js';
|
||||||
|
|
||||||
|
const readNumber = (value: unknown, fallback = 0): number => {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeOptionalKey = (value: string | null): string | null => {
|
||||||
|
if (!value || value === 'None') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveExpLevel = (meta: Record<string, unknown>, experience: number): number => {
|
||||||
|
const expLevel = meta.explevel ?? meta.expLevel;
|
||||||
|
if (typeof expLevel === 'number' && Number.isFinite(expLevel)) {
|
||||||
|
return Math.max(0, Math.floor(expLevel));
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(experience) || experience <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.floor(Math.sqrt(experience));
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveDexValue = (meta: Record<string, unknown>, key: string): number => {
|
||||||
|
const raw = readNumber(meta[key], 0);
|
||||||
|
const level = getDexLevel(raw);
|
||||||
|
return BATTLE_SIM_DEX_LEVELS[level]?.value ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
export const battleRouter = router({
|
export const battleRouter = router({
|
||||||
simulate: procedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => {
|
simulate: procedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => {
|
||||||
@@ -24,4 +67,215 @@ export const battleRouter = router({
|
|||||||
}
|
}
|
||||||
return { status: 'completed', jobId: input.jobId, payload: result };
|
return { status: 'completed', jobId: input.jobId, payload: result };
|
||||||
}),
|
}),
|
||||||
|
getSimulatorContext: authedProcedure.query(async ({ ctx }) => {
|
||||||
|
const worldState = await ctx.db.worldState.findFirst();
|
||||||
|
if (!worldState) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: 'World state is not initialized.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const environment = await buildBattleSimEnvironment(worldState, ctx.profile.id);
|
||||||
|
const [traits, items] = await Promise.all([loadBattleSimTraitOptions(), loadBattleSimItemOptions()]);
|
||||||
|
|
||||||
|
const crewTypes = (environment.unitSet.crewTypes ?? [])
|
||||||
|
.filter((crewType) => crewType.armType !== environment.config.armTypes.castle)
|
||||||
|
.map((crewType) => ({
|
||||||
|
id: crewType.id,
|
||||||
|
name: crewType.name,
|
||||||
|
armType: crewType.armType,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
world: {
|
||||||
|
startYear: environment.startYear,
|
||||||
|
currentYear: worldState.currentYear,
|
||||||
|
currentMonth: worldState.currentMonth,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
maxTrainByWar: environment.config.maxTrainByWar,
|
||||||
|
maxAtmosByWar: environment.config.maxAtmosByWar,
|
||||||
|
maxTrainByCommand: environment.config.maxTrainByCommand,
|
||||||
|
maxAtmosByCommand: environment.config.maxAtmosByCommand,
|
||||||
|
},
|
||||||
|
unitSet: {
|
||||||
|
defaultCrewTypeId: environment.unitSet.defaultCrewTypeId ?? crewTypes[0]?.id ?? 0,
|
||||||
|
crewTypes,
|
||||||
|
},
|
||||||
|
nationTypes: traits.nationTypes,
|
||||||
|
warTraits: traits.warTraits,
|
||||||
|
personalities: traits.personalities,
|
||||||
|
items,
|
||||||
|
nationLevels: BATTLE_SIM_NATION_LEVELS,
|
||||||
|
cityLevels: BATTLE_SIM_CITY_LEVELS,
|
||||||
|
dexLevels: BATTLE_SIM_DEX_LEVELS,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
getGeneralList: authedProcedure.query(async ({ ctx }) => {
|
||||||
|
const me = await getMyGeneral(ctx);
|
||||||
|
const [generals, nations] = await Promise.all([
|
||||||
|
ctx.db.general.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
npcState: true,
|
||||||
|
nationId: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ctx.db.nation.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
color: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const nationMap = new Map<number, { id: number; name: string; color: string }>();
|
||||||
|
for (const nation of nations) {
|
||||||
|
nationMap.set(nation.id, nation);
|
||||||
|
}
|
||||||
|
|
||||||
|
const generalsByNation: Record<number, Array<{ id: number; name: string; npcState: number }>> = {};
|
||||||
|
for (const general of generals) {
|
||||||
|
if (!generalsByNation[general.nationId]) {
|
||||||
|
generalsByNation[general.nationId] = [];
|
||||||
|
}
|
||||||
|
generalsByNation[general.nationId]?.push({
|
||||||
|
id: general.id,
|
||||||
|
name: general.name,
|
||||||
|
npcState: general.npcState,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generalsByNation[0] && !nationMap.has(0)) {
|
||||||
|
nationMap.set(0, { id: 0, name: '재야', color: '#000000' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
myNationId: me.nationId,
|
||||||
|
myGeneralId: me.id,
|
||||||
|
nations: Array.from(nationMap.values()),
|
||||||
|
generalsByNation,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
getGeneralDetail: authedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
generalId: z.number().int().positive(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const worldState = await ctx.db.worldState.findFirst();
|
||||||
|
if (!worldState) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: 'World state is not initialized.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const me = await getMyGeneral(ctx);
|
||||||
|
const general = await ctx.db.general.findUnique({
|
||||||
|
where: { id: input.generalId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
npcState: true,
|
||||||
|
nationId: true,
|
||||||
|
leadership: true,
|
||||||
|
strength: true,
|
||||||
|
intel: true,
|
||||||
|
officerLevel: true,
|
||||||
|
injury: true,
|
||||||
|
rice: true,
|
||||||
|
crew: true,
|
||||||
|
crewTypeId: true,
|
||||||
|
atmos: true,
|
||||||
|
train: true,
|
||||||
|
experience: true,
|
||||||
|
horseCode: true,
|
||||||
|
weaponCode: true,
|
||||||
|
bookCode: true,
|
||||||
|
itemCode: true,
|
||||||
|
personalCode: true,
|
||||||
|
special2Code: true,
|
||||||
|
meta: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!general) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: 'General not found.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const environment = await buildBattleSimEnvironment(worldState, ctx.profile.id);
|
||||||
|
const defaultCrewTypeId =
|
||||||
|
environment.unitSet.defaultCrewTypeId ?? environment.unitSet.crewTypes?.[0]?.id ?? 0;
|
||||||
|
|
||||||
|
const meta = asRecord(general.meta);
|
||||||
|
const isSameNation = me.nationId > 0 && me.nationId === general.nationId;
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
no: general.id,
|
||||||
|
name: general.name,
|
||||||
|
officer_level: general.officerLevel,
|
||||||
|
explevel: resolveExpLevel(meta, general.experience),
|
||||||
|
leadership: general.leadership,
|
||||||
|
horse: normalizeOptionalKey(general.horseCode),
|
||||||
|
strength: general.strength,
|
||||||
|
weapon: normalizeOptionalKey(general.weaponCode),
|
||||||
|
intel: general.intel,
|
||||||
|
book: normalizeOptionalKey(general.bookCode),
|
||||||
|
item: normalizeOptionalKey(general.itemCode),
|
||||||
|
injury: general.injury,
|
||||||
|
rice: general.rice,
|
||||||
|
personal: normalizeOptionalKey(general.personalCode),
|
||||||
|
special2: normalizeOptionalKey(general.special2Code),
|
||||||
|
crew: general.crew,
|
||||||
|
crewtype: general.crewTypeId,
|
||||||
|
atmos: general.atmos,
|
||||||
|
train: general.train,
|
||||||
|
dex1: resolveDexValue(meta, 'dex1'),
|
||||||
|
dex2: resolveDexValue(meta, 'dex2'),
|
||||||
|
dex3: resolveDexValue(meta, 'dex3'),
|
||||||
|
dex4: resolveDexValue(meta, 'dex4'),
|
||||||
|
dex5: resolveDexValue(meta, 'dex5'),
|
||||||
|
defence_train: readNumber(meta.defenceTrain, 80),
|
||||||
|
warnum: readNumber(meta.rank_warnum, 0),
|
||||||
|
killnum: readNumber(meta.rank_killnum, 0),
|
||||||
|
killcrew: readNumber(meta.rank_killcrew, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isSameNation) {
|
||||||
|
return {
|
||||||
|
general: {
|
||||||
|
...base,
|
||||||
|
officer_level: 1,
|
||||||
|
horse: null,
|
||||||
|
weapon: null,
|
||||||
|
book: null,
|
||||||
|
item: null,
|
||||||
|
crew: 0,
|
||||||
|
crewtype: defaultCrewTypeId,
|
||||||
|
rice: 10000,
|
||||||
|
train: environment.config.maxTrainByCommand,
|
||||||
|
atmos: environment.config.maxAtmosByCommand,
|
||||||
|
dex1: 0,
|
||||||
|
dex2: 0,
|
||||||
|
dex3: 0,
|
||||||
|
dex4: 0,
|
||||||
|
dex5: 0,
|
||||||
|
defence_train: 80,
|
||||||
|
warnum: 0,
|
||||||
|
killnum: 0,
|
||||||
|
killcrew: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { general: base };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const buildPayload = (action: BattleSimJobPayload['action']): BattleSimJobPayloa
|
|||||||
dex3: 0,
|
dex3: 0,
|
||||||
dex4: 0,
|
dex4: 0,
|
||||||
dex5: 0,
|
dex5: 0,
|
||||||
|
defence_train: 0,
|
||||||
recent_war: null,
|
recent_war: null,
|
||||||
warnum: 0,
|
warnum: 0,
|
||||||
killnum: 0,
|
killnum: 0,
|
||||||
@@ -117,6 +118,7 @@ const buildPayload = (action: BattleSimJobPayload['action']): BattleSimJobPayloa
|
|||||||
dex3: 0,
|
dex3: 0,
|
||||||
dex4: 0,
|
dex4: 0,
|
||||||
dex5: 0,
|
dex5: 0,
|
||||||
|
defence_train: 0,
|
||||||
recent_war: null,
|
recent_war: null,
|
||||||
warnum: 0,
|
warnum: 0,
|
||||||
killnum: 0,
|
killnum: 0,
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { BattleSimJobPayload, BattleSimResultPayload } from '../src/battleSim/types.js';
|
||||||
|
import type { BattleSimTransport } from '../src/battleSim/transport.js';
|
||||||
|
import type { DatabaseClient, GameApiContext, GameProfile, WorldStateRow } from '../src/context.js';
|
||||||
|
import type { RedisConnector } from '@sammo-ts/infra';
|
||||||
|
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
||||||
|
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||||
|
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||||
|
import { appRouter } from '../src/router.js';
|
||||||
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
|
|
||||||
|
const profile: GameProfile = {
|
||||||
|
id: 'che',
|
||||||
|
scenario: 'default',
|
||||||
|
name: 'che:default',
|
||||||
|
};
|
||||||
|
|
||||||
|
class QueuedBattleSimTransport implements BattleSimTransport {
|
||||||
|
public simulateCalls = 0;
|
||||||
|
public lastPayload: BattleSimJobPayload | null = null;
|
||||||
|
private readonly results = new Map<string, BattleSimResultPayload>();
|
||||||
|
|
||||||
|
async simulate(payload: BattleSimJobPayload) {
|
||||||
|
this.simulateCalls += 1;
|
||||||
|
this.lastPayload = payload;
|
||||||
|
return { status: 'queued', jobId: 'job-1' } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSimulationResult(jobId: string) {
|
||||||
|
return this.results.get(jobId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushResult(jobId: string, payload: BattleSimResultPayload) {
|
||||||
|
this.results.set(jobId, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildBattleRequest = () => ({
|
||||||
|
action: 'battle' as const,
|
||||||
|
repeatCnt: 1,
|
||||||
|
year: 200,
|
||||||
|
month: 1,
|
||||||
|
seed: 'test-seed',
|
||||||
|
attackerGeneral: {
|
||||||
|
no: 1,
|
||||||
|
name: 'Attacker',
|
||||||
|
nation: 1,
|
||||||
|
turntime: '2026-01-01 00:00:00',
|
||||||
|
personal: null,
|
||||||
|
special2: null,
|
||||||
|
crew: 1000,
|
||||||
|
crewtype: 100,
|
||||||
|
atmos: 100,
|
||||||
|
train: 100,
|
||||||
|
intel: 70,
|
||||||
|
intel_exp: 0,
|
||||||
|
book: null,
|
||||||
|
strength: 70,
|
||||||
|
strength_exp: 0,
|
||||||
|
weapon: null,
|
||||||
|
injury: 0,
|
||||||
|
leadership: 70,
|
||||||
|
leadership_exp: 0,
|
||||||
|
horse: null,
|
||||||
|
item: null,
|
||||||
|
explevel: 0,
|
||||||
|
experience: 100,
|
||||||
|
dedication: 100,
|
||||||
|
officer_level: 3,
|
||||||
|
officer_city: 1,
|
||||||
|
gold: 1000,
|
||||||
|
rice: 1000,
|
||||||
|
dex1: 0,
|
||||||
|
dex2: 0,
|
||||||
|
dex3: 0,
|
||||||
|
dex4: 0,
|
||||||
|
dex5: 0,
|
||||||
|
defence_train: 0,
|
||||||
|
recent_war: null,
|
||||||
|
warnum: 0,
|
||||||
|
killnum: 0,
|
||||||
|
killcrew: 0,
|
||||||
|
},
|
||||||
|
attackerCity: {
|
||||||
|
city: 1,
|
||||||
|
nation: 1,
|
||||||
|
supply: 1,
|
||||||
|
name: 'AttackerCity',
|
||||||
|
pop: 10000,
|
||||||
|
agri: 1000,
|
||||||
|
comm: 1000,
|
||||||
|
secu: 1000,
|
||||||
|
def: 100,
|
||||||
|
wall: 100,
|
||||||
|
trust: 100,
|
||||||
|
level: 2,
|
||||||
|
pop_max: 10000,
|
||||||
|
agri_max: 1000,
|
||||||
|
comm_max: 1000,
|
||||||
|
secu_max: 1000,
|
||||||
|
def_max: 200,
|
||||||
|
wall_max: 200,
|
||||||
|
dead: 0,
|
||||||
|
state: 0,
|
||||||
|
conflict: '{}',
|
||||||
|
},
|
||||||
|
attackerNation: {
|
||||||
|
type: 'test',
|
||||||
|
tech: 1000,
|
||||||
|
level: 1,
|
||||||
|
capital: 1,
|
||||||
|
nation: 1,
|
||||||
|
name: 'AttackerNation',
|
||||||
|
gold: 1000,
|
||||||
|
rice: 1000,
|
||||||
|
gennum: 1,
|
||||||
|
},
|
||||||
|
defenderGenerals: [
|
||||||
|
{
|
||||||
|
no: 2,
|
||||||
|
name: 'Defender',
|
||||||
|
nation: 2,
|
||||||
|
turntime: '2026-01-01 00:00:00',
|
||||||
|
personal: null,
|
||||||
|
special2: null,
|
||||||
|
crew: 1000,
|
||||||
|
crewtype: 100,
|
||||||
|
atmos: 100,
|
||||||
|
train: 100,
|
||||||
|
intel: 60,
|
||||||
|
intel_exp: 0,
|
||||||
|
book: null,
|
||||||
|
strength: 60,
|
||||||
|
strength_exp: 0,
|
||||||
|
weapon: null,
|
||||||
|
injury: 0,
|
||||||
|
leadership: 60,
|
||||||
|
leadership_exp: 0,
|
||||||
|
horse: null,
|
||||||
|
item: null,
|
||||||
|
explevel: 0,
|
||||||
|
experience: 100,
|
||||||
|
dedication: 100,
|
||||||
|
officer_level: 3,
|
||||||
|
officer_city: 2,
|
||||||
|
gold: 1000,
|
||||||
|
rice: 1000,
|
||||||
|
dex1: 0,
|
||||||
|
dex2: 0,
|
||||||
|
dex3: 0,
|
||||||
|
dex4: 0,
|
||||||
|
dex5: 0,
|
||||||
|
defence_train: 0,
|
||||||
|
recent_war: null,
|
||||||
|
warnum: 0,
|
||||||
|
killnum: 0,
|
||||||
|
killcrew: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defenderCity: {
|
||||||
|
city: 2,
|
||||||
|
nation: 2,
|
||||||
|
supply: 1,
|
||||||
|
name: 'DefenderCity',
|
||||||
|
pop: 10000,
|
||||||
|
agri: 1000,
|
||||||
|
comm: 1000,
|
||||||
|
secu: 1000,
|
||||||
|
def: 100,
|
||||||
|
wall: 100,
|
||||||
|
trust: 100,
|
||||||
|
level: 2,
|
||||||
|
pop_max: 10000,
|
||||||
|
agri_max: 1000,
|
||||||
|
comm_max: 1000,
|
||||||
|
secu_max: 1000,
|
||||||
|
def_max: 200,
|
||||||
|
wall_max: 200,
|
||||||
|
dead: 0,
|
||||||
|
state: 0,
|
||||||
|
conflict: '{}',
|
||||||
|
},
|
||||||
|
defenderNation: {
|
||||||
|
type: 'test',
|
||||||
|
tech: 1000,
|
||||||
|
level: 1,
|
||||||
|
capital: 2,
|
||||||
|
nation: 2,
|
||||||
|
name: 'DefenderNation',
|
||||||
|
gold: 1000,
|
||||||
|
rice: 1000,
|
||||||
|
gennum: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildContext = (options: { state: WorldStateRow; battleSim: BattleSimTransport }): GameApiContext => {
|
||||||
|
const db = {
|
||||||
|
worldState: {
|
||||||
|
findFirst: async () => options.state,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const accessTokenStore = new RedisAccessTokenStore(
|
||||||
|
{
|
||||||
|
get: async () => null,
|
||||||
|
set: async () => null,
|
||||||
|
},
|
||||||
|
profile.name
|
||||||
|
);
|
||||||
|
const auth: GameSessionTokenPayload = {
|
||||||
|
version: 1,
|
||||||
|
profile: profile.name,
|
||||||
|
issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(),
|
||||||
|
expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(),
|
||||||
|
sessionId: 'session-1',
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'tester',
|
||||||
|
displayName: 'Tester',
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
sanctions: {},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
db: db as unknown as DatabaseClient,
|
||||||
|
turnDaemon: new InMemoryTurnDaemonTransport(),
|
||||||
|
battleSim: options.battleSim,
|
||||||
|
profile,
|
||||||
|
auth,
|
||||||
|
redis: {} as unknown as RedisConnector['client'],
|
||||||
|
accessTokenStore,
|
||||||
|
flushStore: new InMemoryFlushStore(),
|
||||||
|
gameTokenSecret: 'test-secret',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('battle router orchestration', () => {
|
||||||
|
it('returns queued then completed results via transport', async () => {
|
||||||
|
const battleSim = new QueuedBattleSimTransport();
|
||||||
|
const state: WorldStateRow = {
|
||||||
|
id: 1,
|
||||||
|
scenarioCode: 'default',
|
||||||
|
currentYear: 200,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
config: {},
|
||||||
|
meta: {},
|
||||||
|
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
};
|
||||||
|
const caller = appRouter.createCaller(buildContext({ state, battleSim }));
|
||||||
|
|
||||||
|
const response = await caller.battle.simulate(buildBattleRequest());
|
||||||
|
expect(response.status).toBe('queued');
|
||||||
|
expect(battleSim.simulateCalls).toBe(1);
|
||||||
|
|
||||||
|
const queued = await caller.battle.getSimulation({ jobId: response.jobId });
|
||||||
|
expect(queued.status).toBe('queued');
|
||||||
|
|
||||||
|
battleSim.pushResult(response.jobId, { result: true, reason: 'success', avgWar: 1 });
|
||||||
|
|
||||||
|
const completed = await caller.battle.getSimulation({ jobId: response.jobId });
|
||||||
|
expect(completed.status).toBe('completed');
|
||||||
|
expect(completed.payload?.result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import type { BattleSimOptions, GeneralDraft } from '../../utils/battleSimulatorTypes';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
general: GeneralDraft;
|
||||||
|
options: BattleSimOptions;
|
||||||
|
mode: 'attacker' | 'defender';
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'import'): void;
|
||||||
|
(event: 'save'): void;
|
||||||
|
(event: 'load', payload: { file: File }): void;
|
||||||
|
(event: 'copy'): void;
|
||||||
|
(event: 'remove'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isDefender = computed(() => props.mode === 'defender');
|
||||||
|
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const triggerLoad = () => {
|
||||||
|
fileInput.value?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement | null;
|
||||||
|
const file = target?.files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('load', { file });
|
||||||
|
if (target) {
|
||||||
|
target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const officerLevelOptions = [
|
||||||
|
{ value: 1, label: '일반' },
|
||||||
|
{ value: 4, label: '태수' },
|
||||||
|
{ value: 3, label: '군사' },
|
||||||
|
{ value: 2, label: '종사' },
|
||||||
|
{ value: 10, label: '무장 수뇌' },
|
||||||
|
{ value: 9, label: '지장 수뇌' },
|
||||||
|
{ value: 11, label: '참모' },
|
||||||
|
{ value: 12, label: '군주' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="general-card">
|
||||||
|
<header class="general-header">
|
||||||
|
<div>
|
||||||
|
<div class="general-title">{{ title }}</div>
|
||||||
|
<div class="general-subtitle">No {{ general.no }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="general-actions">
|
||||||
|
<button class="action" type="button" @click="emit('import')">서버에서 가져오기</button>
|
||||||
|
<button class="action" type="button" @click="emit('save')">저장</button>
|
||||||
|
<input ref="fileInput" type="file" accept=".json" hidden @change="handleFileChange" />
|
||||||
|
<button class="action" type="button" @click="triggerLoad">불러오기</button>
|
||||||
|
<button v-if="isDefender" class="action ghost" type="button" @click="emit('copy')">복제</button>
|
||||||
|
<button v-if="isDefender" class="action danger" type="button" @click="emit('remove')">제거</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="form-block">
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span>이름</span>
|
||||||
|
<input v-model="general.name" type="text" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>직위</span>
|
||||||
|
<select v-model.number="general.officerLevel">
|
||||||
|
<option v-for="option in officerLevelOptions" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Level</span>
|
||||||
|
<input v-model.number="general.expLevel" type="number" min="0" max="300" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-block">
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span>통솔</span>
|
||||||
|
<input v-model.number="general.leadership" type="number" min="1" max="300" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>무력</span>
|
||||||
|
<input v-model.number="general.strength" type="number" min="1" max="300" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>지력</span>
|
||||||
|
<input v-model.number="general.intel" type="number" min="1" max="300" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-block">
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span>명마</span>
|
||||||
|
<select v-model="general.horse">
|
||||||
|
<option :value="null">-</option>
|
||||||
|
<option v-for="item in options.items.horse" :key="item.key" :value="item.key">
|
||||||
|
{{ item.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>무기</span>
|
||||||
|
<select v-model="general.weapon">
|
||||||
|
<option :value="null">-</option>
|
||||||
|
<option v-for="item in options.items.weapon" :key="item.key" :value="item.key">
|
||||||
|
{{ item.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>서적</span>
|
||||||
|
<select v-model="general.book">
|
||||||
|
<option :value="null">-</option>
|
||||||
|
<option v-for="item in options.items.book" :key="item.key" :value="item.key">
|
||||||
|
{{ item.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span>부상</span>
|
||||||
|
<input v-model.number="general.injury" type="number" min="0" max="80" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>군량</span>
|
||||||
|
<input v-model.number="general.rice" type="number" min="50" max="40000" step="50" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>도구</span>
|
||||||
|
<select v-model="general.item">
|
||||||
|
<option :value="null">-</option>
|
||||||
|
<option v-for="item in options.items.item" :key="item.key" :value="item.key">
|
||||||
|
{{ item.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-block">
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span>병종</span>
|
||||||
|
<select v-model.number="general.crewtype">
|
||||||
|
<option v-for="crew in options.unitSet.crewTypes" :key="crew.id" :value="crew.id">
|
||||||
|
{{ crew.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>병사</span>
|
||||||
|
<input v-model.number="general.crew" type="number" min="100" step="100" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>성격</span>
|
||||||
|
<select v-model="general.personal">
|
||||||
|
<option :value="null">-</option>
|
||||||
|
<option v-for="trait in options.personalities" :key="trait.key" :value="trait.key">
|
||||||
|
{{ trait.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-block">
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span>훈련</span>
|
||||||
|
<input
|
||||||
|
v-model.number="general.train"
|
||||||
|
type="number"
|
||||||
|
min="40"
|
||||||
|
:max="options.config.maxTrainByWar"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>사기</span>
|
||||||
|
<input
|
||||||
|
v-model.number="general.atmos"
|
||||||
|
type="number"
|
||||||
|
min="40"
|
||||||
|
:max="options.config.maxAtmosByWar"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>전특</span>
|
||||||
|
<select v-model="general.special2">
|
||||||
|
<option :value="null">-</option>
|
||||||
|
<option v-for="trait in options.warTraits" :key="trait.key" :value="trait.key">
|
||||||
|
{{ trait.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-block">
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span>보병숙련</span>
|
||||||
|
<select v-model.number="general.dex1">
|
||||||
|
<option v-for="dex in options.dexLevels" :key="dex.value" :value="dex.value">
|
||||||
|
{{ dex.label }} ({{ dex.value.toLocaleString('ko-KR') }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>궁병숙련</span>
|
||||||
|
<select v-model.number="general.dex2">
|
||||||
|
<option v-for="dex in options.dexLevels" :key="dex.value" :value="dex.value">
|
||||||
|
{{ dex.label }} ({{ dex.value.toLocaleString('ko-KR') }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>기병숙련</span>
|
||||||
|
<select v-model.number="general.dex3">
|
||||||
|
<option v-for="dex in options.dexLevels" :key="dex.value" :value="dex.value">
|
||||||
|
{{ dex.label }} ({{ dex.value.toLocaleString('ko-KR') }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span>귀병숙련</span>
|
||||||
|
<select v-model.number="general.dex4">
|
||||||
|
<option v-for="dex in options.dexLevels" :key="dex.value" :value="dex.value">
|
||||||
|
{{ dex.label }} ({{ dex.value.toLocaleString('ko-KR') }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>차병숙련</span>
|
||||||
|
<select v-model.number="general.dex5">
|
||||||
|
<option v-for="dex in options.dexLevels" :key="dex.value" :value="dex.value">
|
||||||
|
{{ dex.label }} ({{ dex.value.toLocaleString('ko-KR') }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label v-if="isDefender" class="field">
|
||||||
|
<span>수비여부</span>
|
||||||
|
<select v-model.number="general.defenceTrain">
|
||||||
|
<option :value="90">훈사 90</option>
|
||||||
|
<option :value="80">훈사 80</option>
|
||||||
|
<option :value="60">훈사 60</option>
|
||||||
|
<option :value="40">훈사 40</option>
|
||||||
|
<option :value="999">안함</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-block">
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span>전투 수</span>
|
||||||
|
<input v-model.number="general.warnum" type="number" min="0" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>승리 수</span>
|
||||||
|
<input v-model.number="general.killnum" type="number" min="0" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>사살 수</span>
|
||||||
|
<input v-model.number="general.killcrew" type="number" min="0" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-block">
|
||||||
|
<div class="buff-title">유산 버프</div>
|
||||||
|
<div class="form-row buff-row">
|
||||||
|
<label class="field">
|
||||||
|
<span>회피 확률</span>
|
||||||
|
<input v-model.number="general.inheritBuff.warAvoidRatio" type="number" min="0" max="5" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>필살 확률</span>
|
||||||
|
<input v-model.number="general.inheritBuff.warCriticalRatio" type="number" min="0" max="5" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>계략 시도</span>
|
||||||
|
<input v-model.number="general.inheritBuff.warMagicTrialProb" type="number" min="0" max="5" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-row buff-row">
|
||||||
|
<label class="field">
|
||||||
|
<span>상대 회피</span>
|
||||||
|
<input
|
||||||
|
v-model.number="general.inheritBuff.warAvoidRatioOppose"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="5"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>상대 필살</span>
|
||||||
|
<input
|
||||||
|
v-model.number="general.inheritBuff.warCriticalRatioOppose"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="5"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>상대 계략</span>
|
||||||
|
<input
|
||||||
|
v-model.number="general.inheritBuff.warMagicTrialProbOppose"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="5"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.general-card {
|
||||||
|
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||||
|
background: rgba(10, 10, 10, 0.75);
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.general-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border-bottom: 1px solid rgba(201, 164, 90, 0.3);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.general-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.general-subtitle {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgba(232, 221, 196, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.general-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action {
|
||||||
|
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||||
|
background: rgba(16, 16, 16, 0.7);
|
||||||
|
color: inherit;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action.ghost {
|
||||||
|
background: rgba(30, 30, 30, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action.danger {
|
||||||
|
border-color: rgba(233, 94, 94, 0.6);
|
||||||
|
color: #f0b6b6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buff-title {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(201, 164, 90, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buff-row {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -10,6 +10,7 @@ import NationPersonnelView from '../views/NationPersonnelView.vue';
|
|||||||
import NationStratFinanView from '../views/NationStratFinanView.vue';
|
import NationStratFinanView from '../views/NationStratFinanView.vue';
|
||||||
import ChiefCenterView from '../views/ChiefCenterView.vue';
|
import ChiefCenterView from '../views/ChiefCenterView.vue';
|
||||||
import BattleCenterView from '../views/BattleCenterView.vue';
|
import BattleCenterView from '../views/BattleCenterView.vue';
|
||||||
|
import BattleSimulatorView from '../views/BattleSimulatorView.vue';
|
||||||
import NpcControlView from '../views/NpcControlView.vue';
|
import NpcControlView from '../views/NpcControlView.vue';
|
||||||
import NotFoundView from '../views/NotFoundView.vue';
|
import NotFoundView from '../views/NotFoundView.vue';
|
||||||
import { useSessionStore } from '../stores/session';
|
import { useSessionStore } from '../stores/session';
|
||||||
@@ -101,6 +102,15 @@ const routes = [
|
|||||||
requiresGeneral: true,
|
requiresGeneral: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/battle-simulator',
|
||||||
|
name: 'battle-simulator',
|
||||||
|
component: BattleSimulatorView,
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
requiresGeneral: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/npc-control',
|
path: '/npc-control',
|
||||||
name: 'npc-control',
|
name: 'npc-control',
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
export type InheritBuff = {
|
||||||
|
warAvoidRatio: number;
|
||||||
|
warCriticalRatio: number;
|
||||||
|
warMagicTrialProb: number;
|
||||||
|
warAvoidRatioOppose: number;
|
||||||
|
warCriticalRatioOppose: number;
|
||||||
|
warMagicTrialProbOppose: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GeneralDraft = {
|
||||||
|
id: string;
|
||||||
|
no: number;
|
||||||
|
name: string;
|
||||||
|
officerLevel: number;
|
||||||
|
expLevel: number;
|
||||||
|
leadership: number;
|
||||||
|
strength: number;
|
||||||
|
intel: number;
|
||||||
|
horse: string | null;
|
||||||
|
weapon: string | null;
|
||||||
|
book: string | null;
|
||||||
|
item: string | null;
|
||||||
|
injury: number;
|
||||||
|
rice: number;
|
||||||
|
personal: string | null;
|
||||||
|
special2: string | null;
|
||||||
|
crew: number;
|
||||||
|
crewtype: number;
|
||||||
|
atmos: number;
|
||||||
|
train: number;
|
||||||
|
dex1: number;
|
||||||
|
dex2: number;
|
||||||
|
dex3: number;
|
||||||
|
dex4: number;
|
||||||
|
dex5: number;
|
||||||
|
defenceTrain: number;
|
||||||
|
warnum: number;
|
||||||
|
killnum: number;
|
||||||
|
killcrew: number;
|
||||||
|
inheritBuff: InheritBuff;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BattleSimOptions = {
|
||||||
|
world: {
|
||||||
|
startYear: number;
|
||||||
|
currentYear: number;
|
||||||
|
currentMonth: number;
|
||||||
|
};
|
||||||
|
config: {
|
||||||
|
maxTrainByWar: number;
|
||||||
|
maxAtmosByWar: number;
|
||||||
|
maxTrainByCommand: number;
|
||||||
|
maxAtmosByCommand: number;
|
||||||
|
};
|
||||||
|
unitSet: {
|
||||||
|
defaultCrewTypeId: number;
|
||||||
|
crewTypes: Array<{ id: number; name: string; armType: number }>;
|
||||||
|
};
|
||||||
|
nationTypes: Array<{ key: string; name: string; info: string }>;
|
||||||
|
warTraits: Array<{ key: string; name: string; info: string }>;
|
||||||
|
personalities: Array<{ key: string; name: string; info: string }>;
|
||||||
|
items: {
|
||||||
|
horse: Array<{ key: string; name: string }>;
|
||||||
|
weapon: Array<{ key: string; name: string }>;
|
||||||
|
book: Array<{ key: string; name: string }>;
|
||||||
|
item: Array<{ key: string; name: string }>;
|
||||||
|
};
|
||||||
|
nationLevels: Array<{ level: number; name: string }>;
|
||||||
|
cityLevels: Array<{ level: number; name: string }>;
|
||||||
|
dexLevels: Array<{ value: number; color: string; label: string }>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,1373 @@
|
|||||||
|
<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>>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
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) => {
|
||||||
|
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 confirmImport = async () => {
|
||||||
|
if (!importTarget.value || !selectedGeneralId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await trpc.battle.getGeneralDetail.query({ generalId: selectedGeneralId.value });
|
||||||
|
applyGeneralExport(importTarget.value, {
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
importTarget.value.no =
|
||||||
|
importTarget.value === attackerGeneral.value
|
||||||
|
? 1
|
||||||
|
: resolveGeneralNo(response.general.no, importTarget.value.id);
|
||||||
|
} 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>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
:general="attackerGeneral!"
|
||||||
|
:options="options!"
|
||||||
|
mode="attacker"
|
||||||
|
title="출병자 설정"
|
||||||
|
@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"
|
||||||
|
:general="defender"
|
||||||
|
:options="options!"
|
||||||
|
mode="defender"
|
||||||
|
:title="`수비자 설정 ${index + 1}`"
|
||||||
|
@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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -97,6 +97,7 @@ watch(
|
|||||||
<RouterLink class="ghost" to="/nation/finance">내무부</RouterLink>
|
<RouterLink class="ghost" to="/nation/finance">내무부</RouterLink>
|
||||||
<RouterLink class="ghost" to="/chief-center">사령부</RouterLink>
|
<RouterLink class="ghost" to="/chief-center">사령부</RouterLink>
|
||||||
<RouterLink class="ghost" to="/battle-center">감찰부</RouterLink>
|
<RouterLink class="ghost" to="/battle-center">감찰부</RouterLink>
|
||||||
|
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
|
||||||
<RouterLink class="ghost" to="/npc-control">NPC 정책</RouterLink>
|
<RouterLink class="ghost" to="/npc-control">NPC 정책</RouterLink>
|
||||||
<RouterLink class="ghost" to="/inherit">유산 강화</RouterLink>
|
<RouterLink class="ghost" to="/inherit">유산 강화</RouterLink>
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user