feat: execute MyPage immediate actions in daemon

This commit is contained in:
2026-07-31 01:56:16 +00:00
parent c6a2a0da93
commit d82e493109
13 changed files with 1836 additions and 98 deletions
+79 -3
View File
@@ -23,6 +23,11 @@ type FixtureState = {
permission: 'head' | 'member';
myset: number;
scenarioEffect?: string | null;
instantRetreatEnabled?: boolean;
instantRetreatAttempts?: number;
instantRetreatInputs?: Array<Record<string, unknown>>;
generalMeQueries?: number;
generalLogQueries?: number;
settingMutations: Array<Record<string, unknown>>;
accessPages: string[];
};
@@ -149,7 +154,10 @@ const install = async (page: Page, state: FixtureState) => {
payload?.json ?? payload?.input?.json ?? (payload as Record<string, unknown> | undefined) ?? {};
if (operation === 'lobby.info') return response({ myGeneral: { id: 7, name: '검증장수' } });
if (operation === 'join.getConfig') return response({});
if (operation === 'general.me') return response(myGeneral(state));
if (operation === 'general.me') {
state.generalMeQueries = (state.generalMeQueries ?? 0) + 1;
return response(myGeneral(state));
}
if (operation === 'world.getState')
return response({
currentYear: 185,
@@ -157,7 +165,11 @@ const install = async (page: Page, state: FixtureState) => {
tickSeconds: 600,
config: {
npcMode: 0,
const: { availableInstantAction: {} },
const: {
availableInstantAction: {
instantRetreat: state.instantRetreatEnabled ?? false,
},
},
environment: { scenarioEffect: state.scenarioEffect ?? null },
},
meta: {
@@ -179,8 +191,24 @@ const install = async (page: Page, state: FixtureState) => {
{ generalId: 7, name: '검증장수', refresh: 240, refreshScoreTotal: 24 },
],
});
if (operation === 'general.getMyLog')
if (operation === 'general.getMyLog') {
state.generalLogQueries = (state.generalLogQueries ?? 0) + 1;
return response({ type: 'generalAction', logs: [{ id: 1, text: '<Y>기록</>' }] });
}
if (operation === 'general.instantRetreat') {
state.instantRetreatInputs?.push(jsonInput);
state.instantRetreatAttempts = (state.instantRetreatAttempts ?? 0) + 1;
if (state.instantRetreatAttempts === 1) {
return {
error: {
message: '요청 처리 결과를 확인하지 못했습니다.',
code: -32000,
data: { code: 'TIMEOUT', httpStatus: 408, path: operation },
},
};
}
return response({ ok: true });
}
if (operation === 'general.setMySetting') {
state.settingMutations.push(jsonInput);
state.myset = Math.max(0, state.myset - 1);
@@ -404,6 +432,54 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
await persistParityArtifact(page, 'core-my-page-mobile', mobile);
});
test('내 정보 즉시행동은 timeout 재시도 ID를 유지하고 성공 후 새 ID를 만든다', async ({ page }) => {
const state: FixtureState = {
permission: 'head',
myset: 3,
instantRetreatEnabled: true,
instantRetreatAttempts: 0,
instantRetreatInputs: [],
generalMeQueries: 0,
generalLogQueries: 0,
settingMutations: [],
accessPages: [],
};
const dialogs: string[] = [];
page.on('dialog', async (dialog) => {
dialogs.push(`${dialog.type()}:${dialog.message()}`);
await dialog.accept();
});
await install(page, state);
await page.goto('my-page');
const instantRetreatButton = page.getByRole('button', { name: '접경 귀환' });
await expect(instantRetreatButton).toBeVisible();
await expect.poll(() => state.generalMeQueries).toBe(1);
await instantRetreatButton.click();
await expect.poll(() => state.instantRetreatInputs?.length).toBe(1);
await expect
.poll(() => dialogs.some((message) => message.includes('요청 처리 결과를 확인하지 못했습니다.')))
.toBe(true);
expect(state.generalMeQueries).toBe(1);
await instantRetreatButton.click();
await expect.poll(() => state.instantRetreatInputs?.length).toBe(2);
await expect.poll(() => state.generalMeQueries).toBe(2);
await expect.poll(() => state.generalLogQueries).toBe(8);
await page.evaluate(() => new Promise<void>((resolveFrame) => requestAnimationFrame(() => resolveFrame())));
await instantRetreatButton.click();
await expect.poll(() => state.instantRetreatInputs?.length).toBe(3);
const requestIds = state.instantRetreatInputs?.map((input) => input.clientRequestId);
expect(requestIds?.[0]).toEqual(expect.stringMatching(/^[0-9a-f-]{36}$/i));
expect(requestIds?.[1]).toBe(requestIds?.[0]);
expect(requestIds?.[2]).toEqual(expect.stringMatching(/^[0-9a-f-]{36}$/i));
expect(requestIds?.[2]).not.toBe(requestIds?.[1]);
expect(state.instantRetreatInputs?.every((input) => !('generalId' in input))).toBe(true);
});
test('감찰부 keeps the selector interaction and shows the permission error path', async ({ page }) => {
const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
await install(page, head);
+20 -9
View File
@@ -37,6 +37,15 @@ const screenMode = ref<ScreenMode>('auto');
const customCss = ref('');
const cssSaving = ref(false);
let cssTimer: number | null = null;
const immediateActionRequestIds = reactive({
buildNationCandidate: crypto.randomUUID(),
instantRetreat: crypto.randomUUID(),
});
const resetImmediateActionRequestIds = () => {
immediateActionRequestIds.buildNationCandidate = crypto.randomUUID();
immediateActionRequestIds.instantRetreat = crypto.randomUUID();
};
const form = reactive<SettingForm>({
tnmt: 1,
@@ -181,6 +190,7 @@ const loadPage = async () => {
Object.assign(form, general.settings);
}
await Promise.all(logTypes.map((type) => loadLog(type)));
resetImmediateActionRequestIds();
} catch (cause) {
error.value = errorText(cause);
} finally {
@@ -391,7 +401,9 @@ onMounted(() => {
class="action-button"
@click="
confirmMutation('거병 이후 장수를 삭제할 수 없게됩니다. 거병하시겠습니까?', () =>
trpc.general.buildNationCandidate.mutate()
trpc.general.buildNationCandidate.mutate({
clientRequestId: immediateActionRequestIds.buildNationCandidate,
})
)
"
>
@@ -403,20 +415,19 @@ onMounted(() => {
<button
class="action-button"
@click="
confirmMutation('아군 접경으로 이동할까요?', () => trpc.general.instantRetreat.mutate())
confirmMutation('아군 접경으로 이동할까요?', () =>
trpc.general.instantRetreat.mutate({
clientRequestId: immediateActionRequestIds.instantRetreat,
})
)
"
>
접경 귀환
</button>
</div>
<div
v-if="actionAvailability.selectOtherGeneral && selectionPoolStatus?.enabled"
class="action-line"
>
<div v-if="actionAvailability.selectOtherGeneral && selectionPoolStatus?.enabled" class="action-line">
다른 장수 선택
<template v-if="formatSelectionAvailableAt">
({{ formatSelectionAvailableAt }} 부터)
</template>
<template v-if="formatSelectionAvailableAt"> ({{ formatSelectionAvailableAt }} 부터) </template>
<br />
<RouterLink class="action-button select-general-link" to="/select-general">
다른 장수 선택