fix(game): keep in-game status pages available
This commit is contained in:
@@ -48,6 +48,8 @@ const BUFF_LABELS: Record<InheritBuffType, string> = {
|
|||||||
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
|
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const POSTGRES_INTEGER_MAX = 2_147_483_647;
|
||||||
|
|
||||||
const parseBuffRecord = (raw: unknown): Record<string, number> => {
|
const parseBuffRecord = (raw: unknown): Record<string, number> => {
|
||||||
if (typeof raw === 'string') {
|
if (typeof raw === 'string') {
|
||||||
const parsed = parseJson<Record<string, number>>(raw);
|
const parsed = parseJson<Record<string, number>>(raw);
|
||||||
@@ -319,7 +321,7 @@ export const inheritRouter = router({
|
|||||||
getLogs: authedProcedure
|
getLogs: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
lastId: z.number().int().optional(),
|
lastId: z.number().int().min(1).max(POSTGRES_INTEGER_MAX).optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
@@ -327,11 +329,10 @@ export const inheritRouter = router({
|
|||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
const lastId = input.lastId ?? Number.MAX_SAFE_INTEGER;
|
|
||||||
const logs = await ctx.db.inheritanceLog.findMany({
|
const logs = await ctx.db.inheritanceLog.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
id: { lt: lastId },
|
...(input.lastId === undefined ? {} : { id: { lt: input.lastId } }),
|
||||||
},
|
},
|
||||||
orderBy: { id: 'desc' },
|
orderBy: { id: 'desc' },
|
||||||
take: 30,
|
take: 30,
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ const buildContext = (options: {
|
|||||||
general?: GeneralRow | null;
|
general?: GeneralRow | null;
|
||||||
target?: GeneralRow | null;
|
target?: GeneralRow | null;
|
||||||
inheritancePoint?: number;
|
inheritancePoint?: number;
|
||||||
|
inheritanceLogs?: Array<{ id: number; year: number; month: number; text: string; createdAt: Date }>;
|
||||||
}) => {
|
}) => {
|
||||||
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
||||||
const general = options.general === undefined ? buildGeneral() : options.general;
|
const general = options.general === undefined ? buildGeneral() : options.general;
|
||||||
@@ -110,6 +111,7 @@ const buildContext = (options: {
|
|||||||
const pointUpsert = vi.fn(async () => ({}));
|
const pointUpsert = vi.fn(async () => ({}));
|
||||||
const logCreate = vi.fn(async () => ({}));
|
const logCreate = vi.fn(async () => ({}));
|
||||||
const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : []));
|
const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : []));
|
||||||
|
const inheritanceLogFindMany = vi.fn(async () => options.inheritanceLogs ?? []);
|
||||||
const db = {
|
const db = {
|
||||||
$queryRaw: vi.fn(async () => [{ value: options.inheritancePoint ?? 10_000 }]),
|
$queryRaw: vi.fn(async () => [{ value: options.inheritancePoint ?? 10_000 }]),
|
||||||
worldState: {
|
worldState: {
|
||||||
@@ -129,7 +131,7 @@ const buildContext = (options: {
|
|||||||
},
|
},
|
||||||
inheritanceLog: {
|
inheritanceLog: {
|
||||||
create: logCreate,
|
create: logCreate,
|
||||||
findMany: vi.fn(async () => []),
|
findMany: inheritanceLogFindMany,
|
||||||
},
|
},
|
||||||
inheritanceUserState: {
|
inheritanceUserState: {
|
||||||
findUnique: vi.fn(async () => null),
|
findUnique: vi.fn(async () => null),
|
||||||
@@ -157,7 +159,7 @@ const buildContext = (options: {
|
|||||||
flushStore: new InMemoryFlushStore(),
|
flushStore: new InMemoryFlushStore(),
|
||||||
gameTokenSecret: 'test-secret',
|
gameTokenSecret: 'test-secret',
|
||||||
};
|
};
|
||||||
return { context, requestCommand, pointUpsert, logCreate, findMany };
|
return { context, requestCommand, pointUpsert, logCreate, findMany, inheritanceLogFindMany };
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('inherit router actor and permission boundaries', () => {
|
describe('inherit router actor and permission boundaries', () => {
|
||||||
@@ -189,6 +191,45 @@ describe('inherit router actor and permission boundaries', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('loads the first inheritance-log page without an out-of-range integer cursor', async () => {
|
||||||
|
const createdAt = new Date('2026-07-26T00:00:00Z');
|
||||||
|
const fixture = buildContext({
|
||||||
|
inheritanceLogs: [{ id: 2_147_483_647, year: 200, month: 4, text: '경계 로그', createdAt }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(appRouter.createCaller(fixture.context).inherit.getLogs({})).resolves.toEqual([
|
||||||
|
{ id: 2_147_483_647, year: 200, month: 4, text: '경계 로그', createdAt },
|
||||||
|
]);
|
||||||
|
expect(fixture.inheritanceLogFindMany).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'user-1' },
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
take: 30,
|
||||||
|
select: { id: true, year: true, month: true, text: true, createdAt: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a bounded cursor for following and empty inheritance-log pages', async () => {
|
||||||
|
const fixture = buildContext({ inheritanceLogs: [] });
|
||||||
|
const caller = appRouter.createCaller(fixture.context);
|
||||||
|
|
||||||
|
await expect(caller.inherit.getLogs({ lastId: 2_147_483_647 })).resolves.toEqual([]);
|
||||||
|
expect(fixture.inheritanceLogFindMany).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'user-1', id: { lt: 2_147_483_647 } },
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
take: 30,
|
||||||
|
select: { id: true, year: true, month: true, text: true, createdAt: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([0, -1, 1.5, 2_147_483_648])('rejects an invalid inheritance-log cursor: %s', async (lastId) => {
|
||||||
|
const fixture = buildContext({});
|
||||||
|
|
||||||
|
await expect(appRouter.createCaller(fixture.context).inherit.getLogs({ lastId })).rejects.toMatchObject({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
});
|
||||||
|
expect(fixture.inheritanceLogFindMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('does not dispatch or charge when the authenticated user owns no general', async () => {
|
it('does not dispatch or charge when the authenticated user owns no general', async () => {
|
||||||
const fixture = buildContext({
|
const fixture = buildContext({
|
||||||
auth: buildAuth('user-2'),
|
auth: buildAuth('user-2'),
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type FixtureState = {
|
|||||||
generalMeQueries?: number;
|
generalMeQueries?: number;
|
||||||
generalLogQueries?: number;
|
generalLogQueries?: number;
|
||||||
ensurePrestartQueries?: number;
|
ensurePrestartQueries?: number;
|
||||||
|
ensurePrestartFailure?: 'TIMEOUT' | 'INTERNAL_SERVER_ERROR';
|
||||||
nationNoticeInput?: string;
|
nationNoticeInput?: string;
|
||||||
settingMutations: Array<Record<string, unknown>>;
|
settingMutations: Array<Record<string, unknown>>;
|
||||||
accessPages: string[];
|
accessPages: string[];
|
||||||
@@ -188,6 +189,22 @@ const install = async (page: Page, state: FixtureState) => {
|
|||||||
}
|
}
|
||||||
if (operation === 'general.ensureDieOnPrestartStatus') {
|
if (operation === 'general.ensureDieOnPrestartStatus') {
|
||||||
state.ensurePrestartQueries = (state.ensurePrestartQueries ?? 0) + 1;
|
state.ensurePrestartQueries = (state.ensurePrestartQueries ?? 0) + 1;
|
||||||
|
if (state.ensurePrestartFailure) {
|
||||||
|
const isTimeout = state.ensurePrestartFailure === 'TIMEOUT';
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
message: isTimeout
|
||||||
|
? '요청 처리 결과를 확인하지 못했습니다.'
|
||||||
|
: '엔진 transaction을 시작하지 못했습니다.',
|
||||||
|
code: -32000,
|
||||||
|
data: {
|
||||||
|
code: state.ensurePrestartFailure,
|
||||||
|
httpStatus: isTimeout ? 408 : 500,
|
||||||
|
path: operation,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
return response({
|
return response({
|
||||||
show: state.dieOnPrestartShow ?? false,
|
show: state.dieOnPrestartShow ?? false,
|
||||||
available: false,
|
available: false,
|
||||||
@@ -568,6 +585,35 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
|
|||||||
await persistParityArtifact(page, 'core-my-page-mobile', mobile);
|
await persistParityArtifact(page, 'core-my-page-mobile', mobile);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const [label, failure] of [
|
||||||
|
['daemon timeout', 'TIMEOUT'],
|
||||||
|
['engine transaction 오류', 'INTERNAL_SERVER_ERROR'],
|
||||||
|
] as const) {
|
||||||
|
test(`내 정보 기본 출력은 ${label}에도 표시되고 사전 삭제 동작만 비활성화된다`, async ({ page }) => {
|
||||||
|
const state: FixtureState = {
|
||||||
|
permission: 'head',
|
||||||
|
myset: 3,
|
||||||
|
ensurePrestartFailure: failure,
|
||||||
|
settingMutations: [],
|
||||||
|
accessPages: [],
|
||||||
|
};
|
||||||
|
await install(page, state);
|
||||||
|
await page.setViewportSize({ width: 1000, height: 900 });
|
||||||
|
await page.goto('my-page');
|
||||||
|
|
||||||
|
await expect(page.locator('.general-table')).toContainText('검증장수');
|
||||||
|
await expect(page.locator('#set_my_setting')).toBeVisible();
|
||||||
|
await expect(page.locator('.log-panel').first()).toContainText('기록');
|
||||||
|
await expect(page.locator('.error-row')).toHaveCount(0);
|
||||||
|
|
||||||
|
const statusError = page.locator('.prestart-status-error');
|
||||||
|
await expect(statusError).toBeVisible();
|
||||||
|
await expect(statusError.getByRole('button', { name: '장수 삭제' })).toBeDisabled();
|
||||||
|
await expect(statusError.getByRole('button', { name: '상태 재확인' })).toBeEnabled();
|
||||||
|
await expect.poll(() => state.ensurePrestartQueries).toBe(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('내 정보에서 사람 장수의 등록 전콘을 골라 변경한다', async ({ page }) => {
|
test('내 정보에서 사람 장수의 등록 전콘을 골라 변경한다', async ({ page }) => {
|
||||||
const iconId = '3f804277-584f-4f44-b39c-9ecf40d1ed31';
|
const iconId = '3f804277-584f-4f44-b39c-9ecf40d1ed31';
|
||||||
const state: FixtureState = {
|
const state: FixtureState = {
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ const data = ref<MyGeneralResponse | null>(null);
|
|||||||
const world = ref<WorldSnapshot>(null);
|
const world = ref<WorldSnapshot>(null);
|
||||||
const selectionPoolStatus = ref<SelectionPoolStatus | null>(null);
|
const selectionPoolStatus = ref<SelectionPoolStatus | null>(null);
|
||||||
const dieOnPrestartStatus = ref<DieOnPrestartStatus | null>(null);
|
const dieOnPrestartStatus = ref<DieOnPrestartStatus | null>(null);
|
||||||
|
const dieOnPrestartStatusLoading = ref(false);
|
||||||
|
const dieOnPrestartStatusError = ref<string | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const screenMode = ref<ScreenMode>('auto');
|
const screenMode = ref<ScreenMode>('auto');
|
||||||
@@ -190,21 +192,34 @@ const loadLog = async (type: LogType, beforeId?: number) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadDieOnPrestartStatus = async () => {
|
||||||
|
if (dieOnPrestartStatusLoading.value) return;
|
||||||
|
dieOnPrestartStatusLoading.value = true;
|
||||||
|
dieOnPrestartStatusError.value = null;
|
||||||
|
try {
|
||||||
|
dieOnPrestartStatus.value = await trpc.general.ensureDieOnPrestartStatus.mutate();
|
||||||
|
} catch (cause) {
|
||||||
|
dieOnPrestartStatus.value = null;
|
||||||
|
dieOnPrestartStatusError.value = errorText(cause);
|
||||||
|
} finally {
|
||||||
|
dieOnPrestartStatusLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadPage = async (resetImmediateActionIds = true) => {
|
const loadPage = async (resetImmediateActionIds = true) => {
|
||||||
if (loading.value) return;
|
if (loading.value) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
void loadDieOnPrestartStatus();
|
||||||
try {
|
try {
|
||||||
const [general, state, joinConfig, prestartStatus] = await Promise.all([
|
const [general, state, joinConfig] = await Promise.all([
|
||||||
trpc.general.me.query(),
|
trpc.general.me.query(),
|
||||||
trpc.world.getState.query() as Promise<WorldSnapshot>,
|
trpc.world.getState.query() as Promise<WorldSnapshot>,
|
||||||
trpc.join.getConfig.query(),
|
trpc.join.getConfig.query(),
|
||||||
trpc.general.ensureDieOnPrestartStatus.mutate(),
|
|
||||||
]);
|
]);
|
||||||
data.value = general;
|
data.value = general;
|
||||||
world.value = state;
|
world.value = state;
|
||||||
selectionPoolStatus.value = joinConfig.selectionPool;
|
selectionPoolStatus.value = joinConfig.selectionPool;
|
||||||
dieOnPrestartStatus.value = prestartStatus;
|
|
||||||
if (general) {
|
if (general) {
|
||||||
Object.assign(form, general.settings);
|
Object.assign(form, general.settings);
|
||||||
selectedIconId.value =
|
selectedIconId.value =
|
||||||
@@ -470,6 +485,19 @@ onMounted(() => {
|
|||||||
가오픈 기간 내 장수 삭제 ({{ formatDieOnPrestartAvailableAt }} 부터)<br />
|
가오픈 기간 내 장수 삭제 ({{ formatDieOnPrestartAvailableAt }} 부터)<br />
|
||||||
<button class="action-button" @click="dieOnPrestart">장수 삭제</button>
|
<button class="action-button" @click="dieOnPrestart">장수 삭제</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="dieOnPrestartStatusError" class="action-line prestart-status-error">
|
||||||
|
가오픈 기간 내 장수 삭제 상태를 확인하지 못했습니다.<br />
|
||||||
|
<span class="hint">{{ dieOnPrestartStatusError }}</span><br />
|
||||||
|
<button class="action-button" type="button" disabled>장수 삭제</button>
|
||||||
|
<button
|
||||||
|
class="action-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="dieOnPrestartStatusLoading"
|
||||||
|
@click="loadDieOnPrestartStatus"
|
||||||
|
>
|
||||||
|
{{ dieOnPrestartStatusLoading ? '확인 중' : '상태 재확인' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div v-if="actionAvailability.buildNationCandidate" class="action-line">
|
<div v-if="actionAvailability.buildNationCandidate" class="action-line">
|
||||||
서버 개시 이전 거병(2턴부터 건국 가능)<br />
|
서버 개시 이전 거병(2턴부터 건국 가능)<br />
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user