feat: port scenario 903 select-pool flow
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
|
||||
const operationNames = (route: Route): string[] => {
|
||||
const url = new URL(route.request().url());
|
||||
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||
};
|
||||
|
||||
const fulfillTrpc = async (route: Route, results: unknown[]): Promise<void> => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'access-control-allow-origin': '*',
|
||||
},
|
||||
body: JSON.stringify(results),
|
||||
});
|
||||
};
|
||||
|
||||
const installFixture = async (page: Page) => {
|
||||
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) => {
|
||||
const results = operationNames(route).map((operation) => {
|
||||
if (operation === 'me') {
|
||||
return response({
|
||||
id: 'lobby-user',
|
||||
username: 'lobby-user',
|
||||
displayName: '로비사용자',
|
||||
roles: ['user'],
|
||||
kakaoVerified: true,
|
||||
createdAt: '2026-07-30T00:00:00.000Z',
|
||||
});
|
||||
}
|
||||
if (operation === 'lobby.notice') {
|
||||
return response('');
|
||||
}
|
||||
if (operation === 'lobby.profiles') {
|
||||
return response([
|
||||
{
|
||||
profileName: 'hwe:903',
|
||||
profile: 'hwe',
|
||||
scenario: '903',
|
||||
status: 'RUNNING',
|
||||
apiPort: 15015,
|
||||
runtime: {
|
||||
apiRunning: true,
|
||||
daemonRunning: true,
|
||||
auctionRunning: true,
|
||||
battleSimRunning: true,
|
||||
tournamentRunning: true,
|
||||
},
|
||||
korName: 'hwe',
|
||||
color: '#ffffff',
|
||||
localAccountPolicy: {
|
||||
accessAllowed: true,
|
||||
canCreateGeneral: true,
|
||||
requiresKakaoVerification: false,
|
||||
graceEndsAt: null,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (operation === 'auth.issueGameSession') {
|
||||
return response({
|
||||
profile: 'hwe:903',
|
||||
gameToken: 'encrypted-gateway-game-token',
|
||||
expiresAt: '2026-07-30T01:00:00.000Z',
|
||||
});
|
||||
}
|
||||
throw new Error(`Unhandled gateway tRPC operation: ${operation}`);
|
||||
});
|
||||
await fulfillTrpc(route, results);
|
||||
});
|
||||
await page.route('http://localhost:15015/api/trpc/**', async (route) => {
|
||||
const authorization = route.request().headers().authorization;
|
||||
const results = operationNames(route).map((operation) => {
|
||||
gameOperations.push({ operation, authorization });
|
||||
if (operation === 'auth.exchangeGatewayToken') {
|
||||
return response({
|
||||
accessToken: 'ga_lobby-access-token',
|
||||
profile: 'hwe:903',
|
||||
expiresAt: '2026-07-30T01:00:00.000Z',
|
||||
});
|
||||
}
|
||||
if (operation === 'lobby.info') {
|
||||
return response({
|
||||
year: 180,
|
||||
month: 1,
|
||||
userCnt: 1,
|
||||
maxUserCnt: 500,
|
||||
npcCnt: 0,
|
||||
nationCnt: 0,
|
||||
turnTerm: 5,
|
||||
fictionMode: '가상',
|
||||
starttime: '2026-07-30 00:00:00',
|
||||
opentime: '2026-07-30 00:00:00',
|
||||
turntime: '2026-07-30 00:05:00',
|
||||
otherTextInfo: '',
|
||||
isUnited: 0,
|
||||
selectionPoolEnabled: true,
|
||||
myGeneral: {
|
||||
name: '선택장수',
|
||||
picture: 'account-hash.png',
|
||||
imageServer: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (operation === 'public.getMapLayout') {
|
||||
return response({ mapName: 'che', cityList: [] });
|
||||
}
|
||||
if (operation === 'public.getCachedMap') {
|
||||
return response({ year: 180, month: 1, cityList: [], nationList: [] });
|
||||
}
|
||||
throw new Error(`Unhandled game tRPC operation: ${operation}`);
|
||||
});
|
||||
await fulfillTrpc(route, results);
|
||||
});
|
||||
await page.route('**/gateway/api/user-icons/account-hash.png', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/png',
|
||||
body: Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
),
|
||||
});
|
||||
});
|
||||
return gameOperations;
|
||||
};
|
||||
|
||||
test('exchanges the gateway token before loading authenticated lobby general data', async ({
|
||||
page,
|
||||
}) => {
|
||||
const gameOperations = await installFixture(page);
|
||||
|
||||
await page.goto('lobby');
|
||||
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
||||
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.poll(() => portrait.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1);
|
||||
|
||||
expect(gameOperations.find(({ operation }) => operation === 'auth.exchangeGatewayToken')).toEqual({
|
||||
operation: 'auth.exchangeGatewayToken',
|
||||
authorization: undefined,
|
||||
});
|
||||
expect(gameOperations.find(({ operation }) => operation === 'lobby.info')).toEqual({
|
||||
operation: 'lobby.info',
|
||||
authorization: 'Bearer ga_lobby-access-token',
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ export default defineConfig({
|
||||
'server-operations.spec.ts',
|
||||
'admin-runtime-actions.spec.ts',
|
||||
'lobby-admin-navigation.spec.ts',
|
||||
'lobby-game-auth.spec.ts',
|
||||
'logout.spec.ts',
|
||||
],
|
||||
fullyParallel: false,
|
||||
|
||||
@@ -6,7 +6,7 @@ export type GameRouter = typeof appRouter;
|
||||
const resolveProfileUrl = (template: string, profile: string): string =>
|
||||
template.replaceAll('{profile}', encodeURIComponent(profile));
|
||||
|
||||
export const createGameTrpc = (profile: string, port: number) => {
|
||||
export const createGameTrpc = (profile: string, port: number, gameToken?: string) => {
|
||||
const urlTemplate = import.meta.env.VITE_GAME_API_URL_TEMPLATE;
|
||||
const url = urlTemplate
|
||||
? resolveProfileUrl(urlTemplate, profile)
|
||||
@@ -15,6 +15,7 @@ export const createGameTrpc = (profile: string, port: number) => {
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url,
|
||||
headers: gameToken ? { authorization: `Bearer ${gameToken}` } : undefined,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ type GameRouterOutput = inferRouterOutputs<GameRouter>;
|
||||
type MeOutput = GatewayRouterOutput['me'];
|
||||
type LobbyProfile = GatewayRouterOutput['lobby']['profiles'][number];
|
||||
type LobbyInfo = GameRouterOutput['lobby']['info'];
|
||||
type LobbyGeneral = NonNullable<LobbyInfo['myGeneral']>;
|
||||
type PublicMap = GameRouterOutput['public']['getCachedMap'];
|
||||
type PublicMapLayout = GameRouterOutput['public']['getMapLayout'];
|
||||
type MapPreviewBundle = {
|
||||
@@ -38,9 +39,25 @@ 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 formatGraceEndsAt = (value: string | null | undefined): string =>
|
||||
value ? new Date(value).toLocaleString('ko-KR') : '';
|
||||
const resolveGeneralPicture = (general: LobbyGeneral): string => {
|
||||
const picture = general.picture?.trim() || 'default.jpg';
|
||||
return general.imageServer
|
||||
? `${userIconBaseUrl.replace(/\/$/, '')}/${encodeURIComponent(picture)}`
|
||||
: `/image/icons/${encodeURIComponent(picture)}`;
|
||||
};
|
||||
const handleGeneralPictureError = (event: Event): void => {
|
||||
const image = event.currentTarget as HTMLImageElement;
|
||||
if (image.dataset.fallbackApplied === 'true') {
|
||||
return;
|
||||
}
|
||||
image.dataset.fallbackApplied = 'true';
|
||||
image.src = '/image/icons/default.jpg';
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -52,12 +69,31 @@ onMounted(async () => {
|
||||
|
||||
notice.value = await trpc.lobby.notice.query();
|
||||
profiles.value = await trpc.lobby.profiles.query();
|
||||
const sessionToken = window.localStorage.getItem('sammo-session-token');
|
||||
|
||||
const detailTasks = profiles.value.map(async (profile) => {
|
||||
if (profile.status !== 'RUNNING' && profile.status !== 'PREOPEN') {
|
||||
return;
|
||||
}
|
||||
const gameTrpc = createGameTrpc(profile.profile, profile.apiPort);
|
||||
const publicGameTrpc = createGameTrpc(profile.profile, profile.apiPort);
|
||||
let gameToken: string | undefined;
|
||||
if (sessionToken) {
|
||||
try {
|
||||
const issued = await trpc.auth.issueGameSession.mutate({
|
||||
sessionToken,
|
||||
profile: profile.profileName,
|
||||
});
|
||||
const exchanged = await publicGameTrpc.auth.exchangeGatewayToken.mutate({
|
||||
gatewayToken: issued.gameToken,
|
||||
});
|
||||
gameToken = exchanged.accessToken;
|
||||
} catch (error) {
|
||||
console.error(`Failed to authenticate lobby game session for ${profile.profileName}`, error);
|
||||
}
|
||||
}
|
||||
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(),
|
||||
@@ -69,7 +105,6 @@ onMounted(async () => {
|
||||
} else {
|
||||
console.error(`Failed to fetch info for ${profile.profileName}`, infoResult.reason);
|
||||
}
|
||||
|
||||
if (layoutResult.status === 'fulfilled' && mapResult.status === 'fulfilled') {
|
||||
profileMapPreviews.value[profile.profileName] = {
|
||||
mapLayout: layoutResult.value,
|
||||
@@ -302,8 +337,13 @@ 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="profileDetails[profile.profileName]?.myGeneral?.picture ?? undefined"
|
||||
:src="
|
||||
resolveGeneralPicture(
|
||||
profileDetails[profile.profileName]!.myGeneral!
|
||||
)
|
||||
"
|
||||
class="w-full h-full object-cover"
|
||||
@error="handleGeneralPictureError"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
@@ -332,12 +372,21 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
entryLoading[profile.profileName] ||
|
||||
profile.localAccountPolicy?.canCreateGeneral === false
|
||||
"
|
||||
@click="handleEnter(profile, '/join')"
|
||||
@click="
|
||||
handleEnter(
|
||||
profile,
|
||||
profileDetails[profile.profileName]?.selectionPoolEnabled
|
||||
? '/select-general'
|
||||
: '/join'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
profile.localAccountPolicy?.canCreateGeneral === false
|
||||
? '인증 필요'
|
||||
: '장수생성'
|
||||
: profileDetails[profile.profileName]?.selectionPoolEnabled
|
||||
? '장수선택'
|
||||
: '장수생성'
|
||||
}}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user