전투 시뮬레이터 구현
This commit is contained in:
@@ -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<BattleSimJobPayload> => {
|
||||
): Promise<BattleSimEnvironment> => {
|
||||
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<BattleSimJobPayload> => {
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
dex4: number;
|
||||
dex5: number;
|
||||
defence_train: number;
|
||||
recent_war: string | null;
|
||||
warnum: number;
|
||||
killnum: number;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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({
|
||||
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<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,
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user