From 4647b67ba335ed51d1fbe99787cf3f29e12333a0 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Sun, 18 Jan 2026 17:41:13 +0000 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=ED=88=AC=20=EC=8B=9C=EB=AE=AC?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=ED=84=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/battleSim/environment.ts | 32 +- app/game-api/src/battleSim/processor.ts | 1 + app/game-api/src/battleSim/schema.ts | 5 +- .../src/battleSim/simulatorOptions.ts | 144 ++ app/game-api/src/battleSim/types.ts | 1 + app/game-api/src/battleSim/unitSetLoader.ts | 3 +- app/game-api/src/router/battle/index.ts | 258 +++- app/game-api/test/battleSimProcessor.test.ts | 2 + app/game-api/test/battleSimRouter.test.ts | 265 ++++ .../components/battle/BattleGeneralCard.vue | 433 ++++++ app/game-frontend/src/router/index.ts | 10 + .../src/utils/battleSimulatorTypes.ts | 71 + .../src/views/BattleSimulatorView.vue | 1373 +++++++++++++++++ app/game-frontend/src/views/MainView.vue | 1 + 14 files changed, 2589 insertions(+), 10 deletions(-) create mode 100644 app/game-api/src/battleSim/simulatorOptions.ts create mode 100644 app/game-api/test/battleSimRouter.test.ts create mode 100644 app/game-frontend/src/components/battle/BattleGeneralCard.vue create mode 100644 app/game-frontend/src/utils/battleSimulatorTypes.ts create mode 100644 app/game-frontend/src/views/BattleSimulatorView.vue diff --git a/app/game-api/src/battleSim/environment.ts b/app/game-api/src/battleSim/environment.ts index 569cd81..d8b1cb2 100644 --- a/app/game-api/src/battleSim/environment.ts +++ b/app/game-api/src/battleSim/environment.ts @@ -3,6 +3,7 @@ import type { BattleSimJobPayload, BattleSimRequestPayload } from './types.js'; import { loadUnitSetDefinitionByName } from './unitSetLoader.js'; import type { WarEngineConfig } from '@sammo-ts/logic'; import { asRecord } from '@sammo-ts/common'; +import type { UnitSetDefinition } from '@sammo-ts/logic'; const DEFAULT_WAR_CONFIG = { armPerPhase: 500, @@ -73,11 +74,17 @@ const resolveCastleArmType = ( 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, - request: BattleSimRequestPayload, profileFallback: string -): Promise => { +): Promise => { const unitSetName = resolveUnitSetName(worldState, profileFallback); const unitSet = await loadUnitSetDefinitionByName(unitSetName); @@ -105,13 +112,28 @@ export const buildBattleSimJobPayload = async ( }; return { - ...request, + unitSetName, unitSet, config, + startYear: resolveStartYear(worldState), + }; +}; + +export const buildBattleSimJobPayload = async ( + worldState: WorldStateRow, + request: BattleSimRequestPayload, + profileFallback: string +): Promise => { + const environment = await buildBattleSimEnvironment(worldState, profileFallback); + + return { + ...request, + unitSet: environment.unitSet, + config: environment.config, time: { year: request.year, month: request.month, - startYear: resolveStartYear(worldState), + startYear: environment.startYear, }, }; }; diff --git a/app/game-api/src/battleSim/processor.ts b/app/game-api/src/battleSim/processor.ts index 8fc07a3..345bd05 100644 --- a/app/game-api/src/battleSim/processor.ts +++ b/app/game-api/src/battleSim/processor.ts @@ -133,6 +133,7 @@ const mapGeneralPayload = (payload: BattleSimJobPayload['attackerGeneral']): Gen intelExp: payload.intel_exp, strengthExp: payload.strength_exp, leadershipExp: payload.leadership_exp, + defenceTrain: payload.defence_train, rank_warnum: payload.warnum, rank_killnum: payload.killnum, rank_killcrew: payload.killcrew, diff --git a/app/game-api/src/battleSim/schema.ts b/app/game-api/src/battleSim/schema.ts index a16cdbb..3327a81 100644 --- a/app/game-api/src/battleSim/schema.ts +++ b/app/game-api/src/battleSim/schema.ts @@ -28,7 +28,7 @@ export const zBattleSimGeneral = z.object({ experience: z.number().int().min(0), dedication: z.number().int().min(0), 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), rice: 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), dex4: z.number().int().min(0), dex5: z.number().int().min(0), + defence_train: z.number().int().min(0), recent_war: z.string().nullable(), warnum: 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({ type: z.string().min(1), tech: z.number().min(0), - level: z.number().int().min(1), + level: z.number().int().min(0), capital: z.number().int().min(0), nation: z.number().int().min(0), name: z.string().min(1), diff --git a/app/game-api/src/battleSim/simulatorOptions.ts b/app/game-api/src/battleSim/simulatorOptions.ts new file mode 100644 index 0000000..6a1779f --- /dev/null +++ b/app/game-api/src/battleSim/simulatorOptions.ts @@ -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 | null = null; + +const toItemOption = (module: ItemModule): BattleSimItemOption => ({ + key: module.key, + name: module.name, +}); + +export const loadBattleSimItemOptions = async (): Promise => { + 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; +}; diff --git a/app/game-api/src/battleSim/types.ts b/app/game-api/src/battleSim/types.ts index eb80b82..6850b20 100644 --- a/app/game-api/src/battleSim/types.ts +++ b/app/game-api/src/battleSim/types.ts @@ -38,6 +38,7 @@ export interface BattleSimGeneralPayload { dex3: number; dex4: number; dex5: number; + defence_train: number; recent_war: string | null; warnum: number; killnum: number; diff --git a/app/game-api/src/battleSim/unitSetLoader.ts b/app/game-api/src/battleSim/unitSetLoader.ts index e09f65f..edc86d5 100644 --- a/app/game-api/src/battleSim/unitSetLoader.ts +++ b/app/game-api/src/battleSim/unitSetLoader.ts @@ -6,7 +6,8 @@ import { parseUnitSetDefinition, type UnitSetDefinition } from '@sammo-ts/logic' const __filename = fileURLToPath(import.meta.url); 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 { unitSetRoot?: string; diff --git a/app/game-api/src/router/battle/index.ts b/app/game-api/src/router/battle/index.ts index caa1293..172e595 100644 --- a/app/game-api/src/router/battle/index.ts +++ b/app/game-api/src/router/battle/index.ts @@ -1,8 +1,51 @@ import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; -import { procedure, router } from '../../trpc.js'; -import { buildBattleSimJobPayload } from '../../battleSim/environment.js'; +import { asRecord } from '@sammo-ts/common'; +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 { + 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, 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, 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({ simulate: procedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => { @@ -24,4 +67,215 @@ export const battleRouter = router({ } 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(); + for (const nation of nations) { + nationMap.set(nation.id, nation); + } + + const generalsByNation: Record> = {}; + 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 }; + }), }); diff --git a/app/game-api/test/battleSimProcessor.test.ts b/app/game-api/test/battleSimProcessor.test.ts index b4fb1bb..9ac59b0 100644 --- a/app/game-api/test/battleSimProcessor.test.ts +++ b/app/game-api/test/battleSimProcessor.test.ts @@ -43,6 +43,7 @@ const buildPayload = (action: BattleSimJobPayload['action']): BattleSimJobPayloa dex3: 0, dex4: 0, dex5: 0, + defence_train: 0, recent_war: null, warnum: 0, killnum: 0, @@ -117,6 +118,7 @@ const buildPayload = (action: BattleSimJobPayload['action']): BattleSimJobPayloa dex3: 0, dex4: 0, dex5: 0, + defence_train: 0, recent_war: null, warnum: 0, killnum: 0, diff --git a/app/game-api/test/battleSimRouter.test.ts b/app/game-api/test/battleSimRouter.test.ts new file mode 100644 index 0000000..d2ca826 --- /dev/null +++ b/app/game-api/test/battleSimRouter.test.ts @@ -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(); + + 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); + }); +}); diff --git a/app/game-frontend/src/components/battle/BattleGeneralCard.vue b/app/game-frontend/src/components/battle/BattleGeneralCard.vue new file mode 100644 index 0000000..8f60d54 --- /dev/null +++ b/app/game-frontend/src/components/battle/BattleGeneralCard.vue @@ -0,0 +1,433 @@ + + + + + diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index c788f1f..a7b9066 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -10,6 +10,7 @@ import NationPersonnelView from '../views/NationPersonnelView.vue'; import NationStratFinanView from '../views/NationStratFinanView.vue'; import ChiefCenterView from '../views/ChiefCenterView.vue'; import BattleCenterView from '../views/BattleCenterView.vue'; +import BattleSimulatorView from '../views/BattleSimulatorView.vue'; import NpcControlView from '../views/NpcControlView.vue'; import NotFoundView from '../views/NotFoundView.vue'; import { useSessionStore } from '../stores/session'; @@ -101,6 +102,15 @@ const routes = [ requiresGeneral: true, }, }, + { + path: '/battle-simulator', + name: 'battle-simulator', + component: BattleSimulatorView, + meta: { + requiresAuth: true, + requiresGeneral: true, + }, + }, { path: '/npc-control', name: 'npc-control', diff --git a/app/game-frontend/src/utils/battleSimulatorTypes.ts b/app/game-frontend/src/utils/battleSimulatorTypes.ts new file mode 100644 index 0000000..ee277d6 --- /dev/null +++ b/app/game-frontend/src/utils/battleSimulatorTypes.ts @@ -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 }>; +}; diff --git a/app/game-frontend/src/views/BattleSimulatorView.vue b/app/game-frontend/src/views/BattleSimulatorView.vue new file mode 100644 index 0000000..9b6dec7 --- /dev/null +++ b/app/game-frontend/src/views/BattleSimulatorView.vue @@ -0,0 +1,1373 @@ + + + + + diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index cd68027..45e922e 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -97,6 +97,7 @@ watch( 내무부 사령부 감찰부 + 전투 시뮬레이터 NPC 정책 유산 강화