feat: port NPC possession through turn daemon

This commit is contained in:
2026-07-31 03:46:02 +00:00
parent d82e493109
commit 2de8f64da4
30 changed files with 3271 additions and 319 deletions
@@ -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,
+1 -1
View File
@@ -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",
+48 -34
View File
@@ -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>