Files
core2026/app/game-api/src/router/general/index.ts
T

642 lines
24 KiB
TypeScript

import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { LogCategory, LogScope } from '@sammo-ts/infra';
import { asRecord } from '@sammo-ts/common';
import type { GameApiContext } from '../../context.js';
import {
accessAuthedProcedure,
accessAuthedInputProcedure,
accessEngineAuthedProcedure,
accessEngineAuthedInputProcedure,
authedProcedure,
engineAuthedProcedure,
router,
} from '../../trpc.js';
import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTransport.js';
import { resolveAccessWindows } from '../../services/generalAccess.js';
import { adjustAccountIconForUser } from '../../services/accountIconSync.js';
import { getMyGeneral } from '../shared/general.js';
import { resolveNationNotice } from '../nation/shared.js';
const zGeneralSettings = z.object({
tnmt: z.number().int().optional(),
defence_train: z.number().int().optional(),
use_treatment: z.number().int().optional(),
use_auto_nation_turn: z.number().int().optional(),
});
const zGeneralLogType = z.enum(['generalHistory', 'battleDetail', 'battleResult', 'generalAction']);
const zImmediateActionInput = z
.object({
clientRequestId: z.string().uuid().optional(),
})
.optional();
const MAIN_RECORD_LIMIT = 15;
const resolveImmediateActionRequestId = (
contextRequestId: string | undefined,
userId: string,
clientRequestId: string | undefined,
action: 'buildNationCandidate' | 'dieOnPrestart' | 'instantRetreat'
): string | undefined => {
if (clientRequestId) {
return `general:${action}:${userId}:${clientRequestId}`;
}
return contextRequestId ? `${contextRequestId}:general.${action}` : undefined;
};
const requestImmediateAction = async (
ctx: GameApiContext,
input: { clientRequestId?: string } | undefined,
action: 'buildNationCandidate' | 'dieOnPrestart' | 'instantRetreat'
): Promise<{ ok: true }> => {
const userId = ctx.auth?.user.id;
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const general =
action === 'dieOnPrestart'
? await ctx.db.general.findFirst({ where: { userId, npcState: 0 } })
: await getMyGeneral(ctx);
if (!general) {
throw new TRPCError({ code: 'NOT_FOUND', message: '장수가 없습니다' });
}
const requestId = resolveImmediateActionRequestId(ctx.requestId, userId, input?.clientRequestId, action);
try {
const result = await ctx.turnDaemon.requestCommand({
type: action,
...(requestId ? { requestId } : {}),
userId,
generalId: general.id,
});
if (!result) {
throw new TRPCError({
code: 'TIMEOUT',
message: '요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.',
});
}
if (result.type !== action) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: '턴 데몬이 올바르지 않은 즉시 행동 결과를 반환했습니다.',
});
}
if (!result.ok) {
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
}
if (action === 'dieOnPrestart' && ctx.accessToken) {
await ctx.accessTokenStore.revoke(ctx.accessToken);
}
return { ok: true };
} catch (error) {
if (
error instanceof ConflictingTurnDaemonCommandError ||
(error instanceof Error && error.name === 'ConflictingTurnDaemonCommandError')
) {
throw new TRPCError({
code: 'CONFLICT',
message: '이미 접수된 즉시 행동 요청과 입력이 다릅니다. 새 요청 번호로 다시 시도해 주세요.',
});
}
throw error;
}
};
const trimRecentRecords = <Entry extends { id: number }>(entries: Entry[], cursor: number): Entry[] => {
if (entries.length === 0) {
return entries;
}
const result = [...entries];
if (result.at(-1)?.id === cursor || result.length > MAIN_RECORD_LIMIT) {
result.pop();
}
return result;
};
const readNumber = (value: unknown, fallback: number): 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 normalizeItemCode = (value: string | null): string | null => {
if (!value || value === 'None') {
return null;
}
return value;
};
const resolveUserSettings = (meta: Record<string, unknown>) => {
// The legacy general columns are persisted at the top level of General.meta.
// Keep reading the short-lived nested shape for installations that ran the
// initial rewrite implementation before this compatibility fix.
const nestedSettings = asRecord(meta.userSettings);
const readSetting = (key: string): unknown => meta[key] ?? nestedSettings[key];
const mysetRaw = readSetting('myset');
const myset = typeof mysetRaw === 'number' && Number.isFinite(mysetRaw) ? mysetRaw : null;
return {
tnmt: readNumber(readSetting('tnmt'), 1),
defence_train: readNumber(readSetting('defence_train'), 80),
use_treatment: readNumber(readSetting('use_treatment'), 10),
use_auto_nation_turn: readNumber(readSetting('use_auto_nation_turn'), 1),
myset,
};
};
const resolvePenalty = (penalty: unknown): Record<string, number> => {
const penaltyRecord = asRecord(penalty);
const result: Record<string, number> = {};
for (const [key, value] of Object.entries(penaltyRecord)) {
if (typeof value === 'number' && Number.isFinite(value)) {
result[key] = value;
continue;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
result[key] = parsed;
}
}
}
return result;
};
export const generalRouter = router({
adjustIcon: engineAuthedProcedure
.input(
z.object({ iconId: z.string().uuid().optional(), clientRequestId: z.string().uuid().optional() }).optional()
)
.mutation(({ ctx, input }) => {
const userId = ctx.auth?.user.id;
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const selected = input?.iconId ? ctx.auth?.user.icons?.find((icon) => icon.id === input.iconId) : undefined;
if (input?.iconId && (!selected || ctx.auth?.user.canUseGeneralPicture === false)) {
throw new TRPCError({ code: 'FORBIDDEN', message: '사용 가능한 내 전용 아이콘이 아닙니다.' });
}
return adjustAccountIconForUser(
ctx,
userId,
selected
? {
picture: selected.picture,
imageServer: selected.imageServer,
revision: ctx.auth?.user.iconUpdatedAt ?? selected.createdAt,
}
: undefined,
true,
input?.clientRequestId ?? ctx.requestId
);
}),
me: authedProcedure.query(async ({ ctx }) => {
const userId = ctx.auth?.user.id;
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const general = await ctx.db.general.findFirst({
where: { userId },
select: {
id: true,
name: true,
npcState: true,
nationId: true,
cityId: true,
troopId: true,
picture: true,
imageServer: true,
leadership: true,
strength: true,
intel: true,
officerLevel: true,
gold: true,
rice: true,
crew: true,
train: true,
atmos: true,
injury: true,
experience: true,
dedication: true,
age: true,
turnTime: true,
crewTypeId: true,
personalCode: true,
specialCode: true,
special2Code: true,
weaponCode: true,
horseCode: true,
bookCode: true,
itemCode: true,
meta: true,
penalty: true,
},
});
if (!general) {
return null;
}
const [city, nation] = await Promise.all([
general.cityId > 0
? ctx.db.city.findUnique({
where: { id: general.cityId },
select: {
id: true,
name: true,
level: true,
nationId: true,
population: true,
agriculture: true,
commerce: true,
security: true,
defence: true,
wall: true,
supplyState: true,
frontState: true,
},
})
: null,
general.nationId > 0
? ctx.db.nation.findUnique({
where: { id: general.nationId },
select: {
id: true,
name: true,
color: true,
level: true,
gold: true,
rice: true,
tech: true,
typeCode: true,
capitalCityId: true,
},
})
: null,
]);
const metaRecord = asRecord(general.meta);
const settings = resolveUserSettings(metaRecord);
const penalties = resolvePenalty(general.penalty);
return {
general: {
id: general.id,
name: general.name,
npcState: general.npcState,
nationId: general.nationId,
cityId: general.cityId,
troopId: general.troopId,
picture: general.picture,
imageServer: general.imageServer,
officerLevel: general.officerLevel,
stats: {
leadership: general.leadership,
strength: general.strength,
intelligence: general.intel,
},
gold: general.gold,
rice: general.rice,
crew: general.crew,
train: general.train,
atmos: general.atmos,
injury: general.injury,
experience: general.experience,
dedication: general.dedication,
age: general.age,
turnTime: general.turnTime.toISOString(),
crewTypeId: general.crewTypeId,
traits: {
personal: general.personalCode,
specialWar: general.specialCode,
specialDomestic: general.special2Code,
},
progression: {
experienceLevel: readNumber(metaRecord.explevel, 0),
dedicationLevel: readNumber(metaRecord.dedlevel, 0),
dex: [1, 2, 3, 4, 5].map((index) => readNumber(metaRecord[`dex${index}`], 0)),
},
items: {
horse: normalizeItemCode(general.horseCode),
weapon: normalizeItemCode(general.weaponCode),
book: normalizeItemCode(general.bookCode),
item: normalizeItemCode(general.itemCode),
},
},
iconChoices: ctx.auth?.user.canUseGeneralPicture === false ? [] : (ctx.auth?.user.icons ?? []),
canChangeIcon: general.npcState === 0 && ctx.auth?.user.canUseGeneralPicture !== false,
iconChangeAvailableAt:
typeof metaRecord.generalIconChangedAt === 'string'
? new Date(new Date(metaRecord.generalIconChangedAt).getTime() + 24 * 60 * 60 * 1000).toISOString()
: null,
city,
nation,
settings,
penalties,
};
}),
ensureDieOnPrestartStatus: accessEngineAuthedProcedure.mutation(async ({ ctx }) => {
const userId = ctx.auth?.user.id;
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const general = await ctx.db.general.findFirst({
where: { userId, npcState: 0 },
select: { id: true },
});
if (!general) {
return { show: false, available: false, availableAt: null };
}
const result = await ctx.turnDaemon.requestCommand({
type: 'ensureDieOnPrestartStatus',
...(ctx.requestId ? { requestId: `${ctx.requestId}:general.ensureDieOnPrestartStatus` } : {}),
userId,
generalId: general.id,
});
if (!result) {
throw new TRPCError({
code: 'TIMEOUT',
message: '삭제 가능 시각을 아직 확인하지 못했습니다. 다시 시도해 주세요.',
});
}
if (result.type !== 'ensureDieOnPrestartStatus') {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: '턴 데몬이 올바르지 않은 삭제 상태를 반환했습니다.',
});
}
return {
show: result.show,
available: result.available,
availableAt: result.availableAt ?? null,
};
}),
dieOnPrestart: accessEngineAuthedInputProcedure(zImmediateActionInput).mutation(({ ctx, input }) =>
requestImmediateAction(ctx, input, 'dieOnPrestart')
),
buildNationCandidate: accessEngineAuthedInputProcedure(zImmediateActionInput).mutation(({ ctx, input }) =>
requestImmediateAction(ctx, input, 'buildNationCandidate')
),
instantRetreat: accessEngineAuthedInputProcedure(zImmediateActionInput).mutation(({ ctx, input }) =>
requestImmediateAction(ctx, input, 'instantRetreat')
),
vacation: authedProcedure.mutation(async ({ ctx }) => {
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'vacation',
generalId: general.id,
});
if (!result || result.type !== 'vacation') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
if (!result.ok) {
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
}
return { ok: true };
}),
setMySetting: accessAuthedInputProcedure(zGeneralSettings).mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'setMySetting',
generalId: general.id,
settings: input,
});
if (!result || result.type !== 'setMySetting') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
if (!result.ok) {
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
}
return { ok: true };
}),
dropItem: authedProcedure.input(z.object({ itemType: z.string() })).mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
const result = await ctx.turnDaemon.requestCommand({
type: 'dropItem',
generalId: general.id,
itemType: input.itemType,
});
if (!result || result.type !== 'dropItem') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
}
if (!result.ok) {
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
}
return { ok: true };
}),
getMyLog: authedProcedure
.input(
z.object({
type: zGeneralLogType,
beforeId: z.number().int().positive().optional(),
})
)
.query(async ({ ctx, input }) => {
const me = await getMyGeneral(ctx);
const categoryMap: Record<z.infer<typeof zGeneralLogType>, LogCategory> = {
generalHistory: LogCategory.HISTORY,
generalAction: LogCategory.ACTION,
battleResult: LogCategory.BATTLE_BRIEF,
battleDetail: LogCategory.BATTLE_DETAIL,
};
const logs = await ctx.db.logEntry.findMany({
where: {
generalId: me.id,
scope: LogScope.GENERAL,
category: categoryMap[input.type],
...(input.type !== 'generalHistory' && input.beforeId ? { id: { lt: input.beforeId } } : {}),
},
orderBy: { id: 'desc' },
...(input.type === 'generalHistory' ? {} : { take: 24 }),
});
return {
type: input.type,
logs: logs.map((entry) => ({
id: entry.id,
text: entry.text,
})),
};
}),
getRecentRecords: authedProcedure
.input(
z.object({
lastGeneralRecordId: z.number().int().nonnegative().default(0),
lastWorldHistoryId: z.number().int().nonnegative().default(0),
})
)
.query(async ({ ctx, input }) => {
const me = await getMyGeneral(ctx);
const take = MAIN_RECORD_LIMIT + 1;
const [global, general, history] = await Promise.all([
ctx.db.logEntry.findMany({
where: {
scope: LogScope.SYSTEM,
category: { in: [LogCategory.SUMMARY, LogCategory.ACTION] },
id: { gte: input.lastGeneralRecordId },
},
orderBy: { id: 'desc' },
take,
select: { id: true, text: true, createdAt: true },
}),
ctx.db.logEntry.findMany({
where: {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: me.id,
id: { gte: input.lastGeneralRecordId },
},
orderBy: { id: 'desc' },
take,
select: { id: true, text: true, createdAt: true },
}),
ctx.db.logEntry.findMany({
where: {
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
id: { gte: input.lastWorldHistoryId },
},
orderBy: { id: 'desc' },
take,
select: { id: true, text: true, createdAt: true },
}),
]);
return {
global: trimRecentRecords(global, input.lastGeneralRecordId),
general: trimRecentRecords(general, input.lastGeneralRecordId),
history: trimRecentRecords(history, input.lastWorldHistoryId),
};
}),
getFrontStatus: accessAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
const worldState = await ctx.db.worldState.findFirst({
orderBy: { id: 'asc' },
select: {
tickSeconds: true,
meta: true,
},
});
if (!worldState) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' });
}
const now = new Date();
const { scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, worldState.meta);
const [onlineAccess, ownNation, latestVote] = await Promise.all([
ctx.db.generalAccessLog.findMany({
where: {
lastRefresh: {
gte: scoreStartedAt,
},
},
select: { generalId: true },
}),
me.nationId > 0
? ctx.db.nation.findUnique({
where: { id: me.nationId },
select: { meta: true },
})
: Promise.resolve(null),
ctx.db.votePoll.findFirst({
where: {
startAt: { lte: now },
closedAt: null,
OR: [{ endAt: null }, { endAt: { gte: now } }],
},
orderBy: { id: 'desc' },
select: {
id: true,
title: true,
},
}),
]);
const onlineGeneralIds = onlineAccess.map((entry) => entry.generalId);
const onlineGenerals =
onlineGeneralIds.length > 0
? await ctx.db.general.findMany({
where: { id: { in: onlineGeneralIds } },
orderBy: { id: 'asc' },
select: {
id: true,
name: true,
nationId: true,
},
})
: [];
const nationIds = [...new Set(onlineGenerals.map((general) => general.nationId).filter((id) => id > 0))];
const nations =
nationIds.length > 0
? await ctx.db.nation.findMany({
where: { id: { in: nationIds } },
select: {
id: true,
name: true,
},
})
: [];
const nationNames = new Map(nations.map((nation) => [nation.id, nation.name]));
const onlineByNation = new Map<number, typeof onlineGenerals>();
for (const general of onlineGenerals) {
const bucket = onlineByNation.get(general.nationId) ?? [];
bucket.push(general);
onlineByNation.set(general.nationId, bucket);
}
const onlineNations = [...onlineByNation.entries()]
.sort((left, right) => right[1].length - left[1].length || left[0] - right[0])
.map(([nationId]) => `【${nationId === 0 ? '재야' : (nationNames.get(nationId) ?? `세력 ${nationId}`)}】`)
.join(', ');
const myOnlineGenerals = onlineGenerals
.filter((general) => general.nationId === me.nationId)
.map((general) => general.name)
.join(', ');
const myVote = latestVote
? await ctx.db.vote.findFirst({
where: {
voteId: latestVote.id,
generalId: me.id,
},
select: { id: true },
})
: null;
const worldMeta = asRecord(worldState.meta);
const rawLastExecuted = worldMeta.lastTurnTime ?? worldMeta.turntime;
const parsedLastExecuted =
typeof rawLastExecuted === 'string' || rawLastExecuted instanceof Date ? new Date(rawLastExecuted) : null;
return {
onlineUserCount: onlineGenerals.length,
onlineNations,
onlineGenerals: myOnlineGenerals,
nationNotice: ownNation ? resolveNationNotice(asRecord(ownNation.meta)) : '',
lastExecuted:
parsedLastExecuted && Number.isFinite(parsedLastExecuted.getTime())
? parsedLastExecuted.toISOString()
: null,
latestVote: latestVote
? {
id: latestVote.id,
title: latestVote.title,
hasVoted: Boolean(myVote),
}
: null,
};
}),
});