feat: port pre-start general deletion lifecycle

This commit is contained in:
2026-07-31 05:44:36 +00:00
parent f1929be3fe
commit 71ec02d091
24 changed files with 1257 additions and 160 deletions
+42 -11
View File
@@ -30,7 +30,7 @@ const resolveImmediateActionRequestId = (
contextRequestId: string | undefined,
userId: string,
clientRequestId: string | undefined,
action: 'buildNationCandidate' | 'instantRetreat'
action: 'buildNationCandidate' | 'dieOnPrestart' | 'instantRetreat'
): string | undefined => {
if (clientRequestId) {
return `general:${action}:${userId}:${clientRequestId}`;
@@ -41,13 +41,19 @@ const resolveImmediateActionRequestId = (
const requestImmediateAction = async (
ctx: GameApiContext,
input: { clientRequestId?: string } | undefined,
action: 'buildNationCandidate' | 'instantRetreat'
action: 'buildNationCandidate' | 'dieOnPrestart' | 'instantRetreat'
): Promise<{ ok: true }> => {
const userId = ctx.auth?.user.id;
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const general = await getMyGeneral(ctx);
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({
@@ -277,20 +283,45 @@ export const generalRouter = router({
penalties,
};
}),
dieOnPrestart: authedProcedure.mutation(async ({ ctx }) => {
const general = await getMyGeneral(ctx);
ensureDieOnPrestartStatus: engineAuthedProcedure.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: 'dieOnPrestart',
type: 'ensureDieOnPrestartStatus',
...(ctx.requestId ? { requestId: `${ctx.requestId}:general.ensureDieOnPrestartStatus` } : {}),
userId,
generalId: general.id,
});
if (!result || result.type !== 'dieOnPrestart') {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
if (!result) {
throw new TRPCError({
code: 'TIMEOUT',
message: '삭제 가능 시각을 아직 확인하지 못했습니다. 다시 시도해 주세요.',
});
}
if (!result.ok) {
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
if (result.type !== 'ensureDieOnPrestartStatus') {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: '턴 데몬이 올바르지 않은 삭제 상태를 반환했습니다.',
});
}
return { ok: true };
return {
show: result.show,
available: result.available,
availableAt: result.availableAt ?? null,
};
}),
dieOnPrestart: engineAuthedProcedure
.input(zImmediateActionInput)
.mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'dieOnPrestart')),
buildNationCandidate: engineAuthedProcedure
.input(zImmediateActionInput)
.mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'buildNationCandidate')),
+3 -2
View File
@@ -475,8 +475,9 @@ export const settleTournamentOutcome = async (options: {
if (result.type !== expectedType) {
throw new Error(`${expectedType} 명령에 잘못된 응답(${result.type})을 받았습니다.`);
}
if (!result.ok) {
throw new Error(`${expectedType} 명령이 실패했습니다: ${result.reason}`);
if (!('ok' in result) || !result.ok) {
const reason = 'reason' in result ? result.reason : '성공 여부가 없는 응답';
throw new Error(`${expectedType} 명령이 실패했습니다: ${reason}`);
}
};
@@ -274,6 +274,14 @@ integration('generic general creation through the durable turn daemon', () => {
killturn: 6,
inherit_spent_dyn: 4500,
});
const createdAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: created.id } });
if (!createdAccess.lastRefresh) {
throw new Error('created general must have an initial access timestamp');
}
expect(
new Date((created.meta as Record<string, unknown>).prestart_delete_after as string).getTime() -
createdAccess.lastRefresh.getTime()
).toBe(2 * 5 * 60 * 1_000);
expect(runtime!.world.getGeneralById(created.id)).toMatchObject({
id: created.id,
userId,
@@ -74,15 +74,15 @@ const auth: GameSessionTokenPayload = {
};
const createContext = (options: {
me?: GeneralRow;
me?: GeneralRow | null;
targets?: GeneralRow[];
nationMeta?: Record<string, unknown>;
requestCommand?: ReturnType<typeof vi.fn>;
}) => {
const me = options.me ?? buildGeneral();
const targets = options.targets ?? [me];
const me = options.me === undefined ? buildGeneral() : options.me;
const targets = options.targets ?? (me ? [me] : []);
const requestCommand =
options.requestCommand ?? vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: me.id }));
options.requestCommand ?? vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: me?.id ?? 0 }));
const generalFindUnique = vi.fn(
async ({ where }: { where: { id: number } }) => targets.find((general) => general.id === where.id) ?? null
);
@@ -90,7 +90,7 @@ const createContext = (options: {
general: {
findFirst: vi.fn(async () => me),
findUnique: generalFindUnique,
findMany: vi.fn(async () => targets.filter((general) => general.nationId === me.nationId)),
findMany: vi.fn(async () => targets.filter((general) => general.nationId === (me?.nationId ?? 0))),
update: vi.fn(),
},
city: { findUnique: vi.fn(async () => null) },
@@ -232,6 +232,7 @@ describe('in-game my information ownership', () => {
});
it.each([
['dieOnPrestart', 'dieOnPrestart'],
['buildNationCandidate', 'buildNationCandidate'],
['instantRetreat', 'instantRetreat'],
] as const)('dispatches %s only for the session-owned general', async (procedure, commandType) => {
@@ -253,6 +254,32 @@ describe('in-game my information ownership', () => {
});
});
it('gets the server-owned pre-start deletion status without accepting a general id', async () => {
const requestCommand = vi.fn(async () => ({
type: 'ensureDieOnPrestartStatus' as const,
generalId: 7,
show: true,
available: false,
availableAt: '2026-01-01T00:20:00.000Z',
}));
const fixture = createContext({ requestCommand });
await expect(appRouter.createCaller(fixture.context).general.ensureDieOnPrestartStatus()).resolves.toEqual({
show: true,
available: false,
availableAt: '2026-01-01T00:20:00.000Z',
});
expect(requestCommand).toHaveBeenCalledWith({
type: 'ensureDieOnPrestartStatus',
userId: 'user-7',
generalId: 7,
});
expect(fixture.db.general.findFirst).toHaveBeenCalledWith({
where: { userId: 'user-7', npcState: 0 },
select: { id: true },
});
});
it('returns the daemon compatibility failure without performing an API-side mutation', async () => {
const requestCommand = vi.fn(async () => ({
type: 'instantRetreat' as const,
@@ -287,6 +314,38 @@ describe('in-game my information ownership', () => {
generalId: 7,
});
});
it('keeps the die-on-prestart request identity when its destructive result times out', async () => {
const requestCommand = vi.fn(async () => null);
const fixture = createContext({ requestCommand });
const caller = appRouter.createCaller(fixture.context);
const clientRequestId = '33333333-3333-4333-8333-333333333333';
await expect(caller.general.dieOnPrestart({ clientRequestId })).rejects.toMatchObject({
code: 'TIMEOUT',
message: expect.stringContaining('같은 요청으로 다시 시도'),
});
expect(requestCommand).toHaveBeenCalledWith({
type: 'dieOnPrestart',
requestId: `general:dieOnPrestart:user-7:${clientRequestId}`,
userId: 'user-7',
generalId: 7,
});
});
it('rejects deletion with the legacy no-general message before dispatching a daemon command', async () => {
const requestCommand = vi.fn();
const fixture = createContext({ me: null, requestCommand });
await expect(appRouter.createCaller(fixture.context).general.dieOnPrestart()).rejects.toMatchObject({
code: 'NOT_FOUND',
message: '장수가 없습니다',
});
expect(fixture.db.general.findFirst).toHaveBeenCalledWith({
where: { userId: 'user-7', npcState: 0 },
});
expect(requestCommand).not.toHaveBeenCalled();
});
});
describe('battle-center general and user permissions', () => {
@@ -209,6 +209,14 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
const initial = await db.general.findFirstOrThrow({ where: { userId } });
const initialRuntime = runtime!.world.getGeneralById(initial.id);
const initialAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: initial.id } });
if (!initialAccess.lastRefresh) {
throw new Error('selected general must have an initial access timestamp');
}
expect(
new Date((initial.meta as Record<string, unknown>).prestart_delete_after as string).getTime() -
initialAccess.lastRefresh.getTime()
).toBe(2 * 5 * 60 * 1_000);
expect(initialRuntime).toMatchObject({
id: initial.id,
userId,