feat: port NPC possession through turn daemon
This commit is contained in:
@@ -39,9 +39,7 @@ const resetScenario = async (page: Page, scenarioId: string, sourceCommit: strin
|
||||
const previousLatestOperation = await latestOperation.textContent();
|
||||
await page.getByTestId('request-reset').click();
|
||||
await expect(page.getByText('초기화 작업을 시작했습니다.')).toBeVisible();
|
||||
await expect
|
||||
.poll(() => latestOperation.textContent(), { timeout: 15_000 })
|
||||
.not.toBe(previousLatestOperation);
|
||||
await expect.poll(() => latestOperation.textContent(), { timeout: 15_000 }).not.toBe(previousLatestOperation);
|
||||
await expect(latestOperation).toContainText(sourceCommit, { timeout: 15_000 });
|
||||
await expect(latestOperation.locator('td').nth(3)).toHaveText('SUCCEEDED', {
|
||||
timeout: 300_000,
|
||||
@@ -71,7 +69,9 @@ const resetScenario = async (page: Page, scenarioId: string, sourceCommit: strin
|
||||
|
||||
type PossessCandidateBatch = Array<{
|
||||
result?: {
|
||||
data?: Array<{ picture?: string | null }>;
|
||||
data?: {
|
||||
candidates?: Array<{ picture?: string | null }>;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
@@ -99,11 +99,10 @@ const inspectScenarioIcons = async (
|
||||
await page.getByRole('button', { name: 'NPC 빙의' }).click();
|
||||
const payload = (await (await candidatesResponse).json()) as PossessCandidateBatch;
|
||||
const iconPaths = payload
|
||||
.flatMap((entry) => entry.result?.data ?? [])
|
||||
.flatMap((entry) => entry.result?.data?.candidates ?? [])
|
||||
.map((candidate) => candidate.picture)
|
||||
.filter(
|
||||
(picture): picture is string =>
|
||||
typeof picture === 'string' && picture.startsWith(`${expectedDirectory}/`)
|
||||
(picture): picture is string => typeof picture === 'string' && picture.startsWith(`${expectedDirectory}/`)
|
||||
);
|
||||
expect(iconPaths.length).toBeGreaterThan(0);
|
||||
|
||||
|
||||
@@ -18,12 +18,37 @@ const fulfillTrpc = async (route: Route, results: unknown[]): Promise<void> => {
|
||||
});
|
||||
};
|
||||
|
||||
const installFixture = async (page: Page) => {
|
||||
type LobbyFixtureOptions = {
|
||||
canCreateGeneral?: boolean;
|
||||
myGeneral?: {
|
||||
name: string;
|
||||
picture: string;
|
||||
imageServer: number;
|
||||
} | null;
|
||||
selectionPoolEnabled?: boolean;
|
||||
npcPossessionEnabled?: boolean;
|
||||
userCnt?: number;
|
||||
maxUserCnt?: number;
|
||||
};
|
||||
|
||||
const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => {
|
||||
const {
|
||||
canCreateGeneral = true,
|
||||
myGeneral = {
|
||||
name: '선택장수',
|
||||
picture: 'account-hash.png',
|
||||
imageServer: 1,
|
||||
},
|
||||
selectionPoolEnabled = true,
|
||||
npcPossessionEnabled = false,
|
||||
userCnt = 1,
|
||||
maxUserCnt = 500,
|
||||
} = options;
|
||||
const gameOperations: Array<{ operation: string; authorization: string | undefined }> = [];
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('sammo-session-token', 'gateway-lobby-session');
|
||||
});
|
||||
await page.route('http://127.0.0.1:15130/api/trpc/**', async (route) => {
|
||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||
const results = operationNames(route).map((operation) => {
|
||||
if (operation === 'me') {
|
||||
return response({
|
||||
@@ -57,7 +82,7 @@ const installFixture = async (page: Page) => {
|
||||
color: '#ffffff',
|
||||
localAccountPolicy: {
|
||||
accessAllowed: true,
|
||||
canCreateGeneral: true,
|
||||
canCreateGeneral,
|
||||
requiresKakaoVerification: false,
|
||||
graceEndsAt: null,
|
||||
},
|
||||
@@ -90,8 +115,8 @@ const installFixture = async (page: Page) => {
|
||||
return response({
|
||||
year: 180,
|
||||
month: 1,
|
||||
userCnt: 1,
|
||||
maxUserCnt: 500,
|
||||
userCnt,
|
||||
maxUserCnt,
|
||||
npcCnt: 0,
|
||||
nationCnt: 0,
|
||||
turnTerm: 5,
|
||||
@@ -101,12 +126,9 @@ const installFixture = async (page: Page) => {
|
||||
turntime: '2026-07-30 00:05:00',
|
||||
otherTextInfo: '',
|
||||
isUnited: 0,
|
||||
selectionPoolEnabled: true,
|
||||
myGeneral: {
|
||||
name: '선택장수',
|
||||
picture: 'account-hash.png',
|
||||
imageServer: 1,
|
||||
},
|
||||
selectionPoolEnabled,
|
||||
npcPossessionEnabled,
|
||||
myGeneral,
|
||||
});
|
||||
}
|
||||
if (operation === 'public.getMapLayout') {
|
||||
@@ -132,9 +154,7 @@ const installFixture = async (page: Page) => {
|
||||
return gameOperations;
|
||||
};
|
||||
|
||||
test('exchanges the gateway token before loading authenticated lobby general data', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('exchanges the gateway token before loading authenticated lobby general data', async ({ page }) => {
|
||||
const gameOperations = await installFixture(page);
|
||||
|
||||
await page.goto('lobby');
|
||||
@@ -142,10 +162,7 @@ test('exchanges the gateway token before loading authenticated lobby general dat
|
||||
await expect(row).toContainText('선택장수');
|
||||
await expect(row.getByRole('button', { name: '입장' })).toBeVisible();
|
||||
const portrait = row.locator('img');
|
||||
await expect(portrait).toHaveAttribute(
|
||||
'src',
|
||||
'/gateway/api/user-icons/account-hash.png'
|
||||
);
|
||||
await expect(portrait).toHaveAttribute('src', '/gateway/api/user-icons/account-hash.png');
|
||||
await expect.poll(() => portrait.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1);
|
||||
|
||||
expect(gameOperations.find(({ operation }) => operation === 'auth.exchangeGatewayToken')).toEqual({
|
||||
@@ -157,3 +174,59 @@ test('exchanges the gateway token before loading authenticated lobby general dat
|
||||
authorization: 'Bearer ga_lobby-access-token',
|
||||
});
|
||||
});
|
||||
|
||||
test('applies the signed general-acquisition policy to both create and possession actions', async ({ page }) => {
|
||||
await installFixture(page, {
|
||||
canCreateGeneral: false,
|
||||
myGeneral: null,
|
||||
selectionPoolEnabled: false,
|
||||
npcPossessionEnabled: true,
|
||||
});
|
||||
|
||||
await page.goto('lobby');
|
||||
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
||||
await expect(row.getByRole('button', { name: '인증 필요' })).toBeDisabled();
|
||||
await expect(row.getByRole('button', { name: '장수빙의' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('opens the mode-1 possession tab with a fresh gateway game token', async ({ page }) => {
|
||||
await installFixture(page, {
|
||||
myGeneral: null,
|
||||
selectionPoolEnabled: false,
|
||||
npcPossessionEnabled: true,
|
||||
});
|
||||
await page.route('**/hwe/join?**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/html',
|
||||
body: '<title>NPC possession target</title>',
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('lobby');
|
||||
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
||||
await expect(row.getByRole('button', { name: '장수빙의' })).toBeEnabled();
|
||||
await row.getByRole('button', { name: '장수빙의' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/hwe\/join\?/);
|
||||
const target = new URL(page.url());
|
||||
expect(target.searchParams.get('tab')).toBe('possess');
|
||||
expect(target.searchParams.get('profile')).toBe('hwe:903');
|
||||
expect(target.searchParams.get('gameToken')).toBe('encrypted-gateway-game-token');
|
||||
});
|
||||
|
||||
test('shows registration closed instead of acquisition actions at the Ref capacity boundary', async ({ page }) => {
|
||||
await installFixture(page, {
|
||||
myGeneral: null,
|
||||
selectionPoolEnabled: false,
|
||||
npcPossessionEnabled: true,
|
||||
userCnt: 300,
|
||||
maxUserCnt: 300,
|
||||
});
|
||||
|
||||
await page.goto('lobby');
|
||||
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
||||
await expect(row).toContainText('장수 등록 마감');
|
||||
await expect(row.getByRole('button', { name: '장수생성' })).toHaveCount(0);
|
||||
await expect(row.getByRole('button', { name: '장수빙의' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ export default defineConfig({
|
||||
},
|
||||
webServer: {
|
||||
command:
|
||||
'VITE_APP_BASE_PATH=/gateway pnpm --filter @sammo-ts/gateway-frontend preview --host 127.0.0.1 --port 15130',
|
||||
"VITE_APP_BASE_PATH=/gateway VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' pnpm --filter @sammo-ts/gateway-frontend preview --host 127.0.0.1 --port 15130",
|
||||
cwd: repositoryRoot,
|
||||
url: 'http://127.0.0.1:15130/gateway/',
|
||||
reuseExistingServer: false,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"test:e2e:operations": "VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc pnpm build && playwright test --config e2e/playwright.config.mjs",
|
||||
"test:e2e:operations": "VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' pnpm build && playwright test --config e2e/playwright.config.mjs",
|
||||
"test:e2e:hwe-lifecycle": "playwright test --config e2e/hwe-lifecycle.playwright.config.mjs",
|
||||
"test:e2e:general-icons": "playwright test --config e2e/general-icon-lifecycle.playwright.config.mjs",
|
||||
"build": "vue-tsc && vite build",
|
||||
|
||||
@@ -39,8 +39,7 @@ const canAccessAdmin = computed(
|
||||
) ?? false
|
||||
);
|
||||
const needsKakaoVerification = computed(() => me.value !== null && !me.value.kakaoVerified);
|
||||
const userIconBaseUrl =
|
||||
import.meta.env.VITE_GATEWAY_USER_ICON_BASE_URL ?? '/gateway/api/user-icons';
|
||||
const userIconBaseUrl = import.meta.env.VITE_GATEWAY_USER_ICON_BASE_URL ?? '/gateway/api/user-icons';
|
||||
|
||||
const formatGraceEndsAt = (value: string | null | undefined): string =>
|
||||
value ? new Date(value).toLocaleString('ko-KR') : '';
|
||||
@@ -91,9 +90,7 @@ onMounted(async () => {
|
||||
console.error(`Failed to authenticate lobby game session for ${profile.profileName}`, error);
|
||||
}
|
||||
}
|
||||
const gameTrpc = gameToken
|
||||
? createGameTrpc(profile.profile, profile.apiPort, gameToken)
|
||||
: publicGameTrpc;
|
||||
const gameTrpc = gameToken ? createGameTrpc(profile.profile, profile.apiPort, gameToken) : publicGameTrpc;
|
||||
const [infoResult, layoutResult, mapResult] = await Promise.allSettled([
|
||||
gameTrpc.lobby.info.query(),
|
||||
gameTrpc.public.getMapLayout.query(),
|
||||
@@ -225,8 +222,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
<div>
|
||||
<strong class="block text-amber-300">카카오 인증이 필요합니다.</strong>
|
||||
<span class="text-sm">
|
||||
유예기간이 지나면 게임에 입장할 수 없습니다. che·kwe·twe는 인증 전 장수 생성도
|
||||
제한됩니다.
|
||||
유예기간이 지나면 게임에 입장할 수 없습니다. che·kwe·twe는 인증 전 장수 생성도 제한됩니다.
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@@ -337,11 +333,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
class="w-12 h-12 mx-auto bg-zinc-800 rounded overflow-hidden border border-zinc-700"
|
||||
>
|
||||
<img
|
||||
:src="
|
||||
resolveGeneralPicture(
|
||||
profileDetails[profile.profileName]!.myGeneral!
|
||||
)
|
||||
"
|
||||
:src="resolveGeneralPicture(profileDetails[profile.profileName]!.myGeneral!)"
|
||||
class="w-full h-full object-cover"
|
||||
@error="handleGeneralPictureError"
|
||||
/>
|
||||
@@ -365,30 +357,52 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
>
|
||||
입장
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
||||
:disabled="
|
||||
entryLoading[profile.profileName] ||
|
||||
profile.localAccountPolicy?.canCreateGeneral === false
|
||||
"
|
||||
@click="
|
||||
handleEnter(
|
||||
profile,
|
||||
profileDetails[profile.profileName]?.selectionPoolEnabled
|
||||
? '/select-general'
|
||||
: '/join'
|
||||
)
|
||||
<div
|
||||
v-else-if="
|
||||
profileDetails[profile.profileName]!.userCnt >=
|
||||
profileDetails[profile.profileName]!.maxUserCnt
|
||||
"
|
||||
class="text-zinc-500"
|
||||
>
|
||||
{{
|
||||
profile.localAccountPolicy?.canCreateGeneral === false
|
||||
? '인증 필요'
|
||||
: profileDetails[profile.profileName]?.selectionPoolEnabled
|
||||
? '장수선택'
|
||||
: '장수생성'
|
||||
}}
|
||||
</button>
|
||||
- 장수 등록 마감 -
|
||||
</div>
|
||||
<div v-else class="grid gap-1">
|
||||
<button
|
||||
v-if="profileDetails[profile.profileName]?.selectionPoolEnabled"
|
||||
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
||||
:disabled="entryLoading[profile.profileName]"
|
||||
@click="handleEnter(profile, '/select-general')"
|
||||
>
|
||||
장수선택
|
||||
</button>
|
||||
<template v-else>
|
||||
<button
|
||||
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
||||
:disabled="
|
||||
entryLoading[profile.profileName] ||
|
||||
profile.localAccountPolicy?.canCreateGeneral === false
|
||||
"
|
||||
@click="handleEnter(profile, '/join')"
|
||||
>
|
||||
{{
|
||||
profile.localAccountPolicy?.canCreateGeneral === false
|
||||
? '인증 필요'
|
||||
: '장수생성'
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
v-if="profileDetails[profile.profileName]?.npcPossessionEnabled"
|
||||
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
||||
:disabled="
|
||||
entryLoading[profile.profileName] ||
|
||||
profile.localAccountPolicy?.canCreateGeneral === false
|
||||
"
|
||||
@click="handleEnter(profile, '/join?tab=possess')"
|
||||
>
|
||||
장수빙의
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="profile.status === 'STOPPED'">
|
||||
<span class="text-zinc-700">-</span>
|
||||
|
||||
Reference in New Issue
Block a user