권위 실행 context를 화면 진입 때 한 번 전달하고 준비 응답은 seed base만 반환한다. 반복별 seed 배열은 결정적 파생값으로 대체하되 구형 queue seed 우선순위와 고정 seed 동작을 보존한다.
288 lines
9.9 KiB
TypeScript
288 lines
9.9 KiB
TypeScript
import { TRPCError } from '@trpc/server';
|
|
import { z } from 'zod';
|
|
|
|
import { asRecord } from '@sammo-ts/common';
|
|
import { getDexLevel } from '@sammo-ts/logic';
|
|
|
|
import {
|
|
accessAuthedInputProcedure,
|
|
accessReadOnlyAuthedInputProcedure,
|
|
authedProcedure,
|
|
readOnlyAuthedProcedure,
|
|
router,
|
|
} from '../../trpc.js';
|
|
import {
|
|
buildBattleSimEnvironment,
|
|
buildBattleSimJobPayload,
|
|
buildBattleSimSeedBase,
|
|
} 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 getAuthenticatedUserId = (auth: { user: { id: string } } | null): string => {
|
|
const userId = auth?.user.id;
|
|
if (!userId) {
|
|
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' });
|
|
}
|
|
return userId;
|
|
};
|
|
|
|
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({
|
|
prepareSimulation: accessReadOnlyAuthedInputProcedure(zBattleSimRequest).mutation(({ input }) => ({
|
|
seedBase: buildBattleSimSeedBase(input),
|
|
})),
|
|
simulate: accessReadOnlyAuthedInputProcedure(zBattleSimRequest).mutation(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 payload = await buildBattleSimJobPayload(worldState, input, ctx.profile.id);
|
|
return ctx.battleSim.simulate(payload, getAuthenticatedUserId(ctx.auth));
|
|
}),
|
|
getSimulation: readOnlyAuthedProcedure.input(zBattleSimJobId).query(async ({ ctx, input }) => {
|
|
const result = await ctx.battleSim.getSimulationResult(input.jobId, getAuthenticatedUserId(ctx.auth));
|
|
if (!result) {
|
|
return { status: 'queued', jobId: input.jobId };
|
|
}
|
|
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()]);
|
|
|
|
return {
|
|
world: {
|
|
startYear: environment.startYear,
|
|
currentYear: worldState.currentYear,
|
|
currentMonth: worldState.currentMonth,
|
|
},
|
|
config: environment.config,
|
|
unitSet: environment.unitSet,
|
|
scenarioEffect: environment.scenarioEffect,
|
|
nationTypes: traits.nationTypes,
|
|
eventDomesticTraits: traits.eventDomesticTraits,
|
|
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: accessAuthedInputProcedure(
|
|
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,
|
|
specialCode: 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),
|
|
special: normalizeOptionalKey(general.specialCode),
|
|
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 };
|
|
}),
|
|
});
|