feat: port pre-start general deletion lifecycle
This commit is contained in:
@@ -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')),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user