feat: port scenario 903 select-pool flow

This commit is contained in:
2026-07-30 23:27:59 +00:00
parent 9bd057456b
commit 115218ded8
80 changed files with 6859 additions and 48 deletions
+60 -2
View File
@@ -52,6 +52,7 @@ const simulatorOptions = {
],
},
nationTypes: [{ key: 'che_중립', name: '중립', info: '특별한 효과 없음' }],
eventDomesticTraits: [{ key: 'che_event_신산', name: '신산', info: '계략 강화' }],
warTraits: [{ key: 'che_필살', name: '필살', info: '필살 확률 증가' }],
personalities: [{ key: 'che_대담', name: '대담', info: '공격적인 성격' }],
items: { horse: [], weapon: [], book: [], item: [] },
@@ -163,6 +164,7 @@ type Fixture = {
queueFirst?: boolean;
pollingCount: number;
requests: string[];
simulationPayloads: unknown[];
};
const installImages = async (page: Page) => {
@@ -185,8 +187,14 @@ const installApi = async (page: Page, fixture: Fixture) => {
});
await page.route('**/che/api/trpc/**', async (route) => {
const operations = operationNames(route);
const results = operations.map((operation) => {
const rawRequestBody: unknown = route.request().postData() ? route.request().postDataJSON() : {};
const requestBody =
rawRequestBody && typeof rawRequestBody === 'object'
? (rawRequestBody as Record<string, unknown>)
: {};
const results = operations.map((operation, operationIndex) => {
fixture.requests.push(operation);
if (operation === 'auth.status') return response({ userId: 'battle-sim-user' });
if (operation === 'lobby.info') {
return response({
year: 205,
@@ -206,6 +214,18 @@ const installApi = async (page: Page, fixture: Fixture) => {
}
if (operation === 'battle.getGeneralDetail') return response(importedGeneral);
if (operation === 'battle.simulate') {
const rawPayload =
requestBody[String(operationIndex)] ?? (operations.length === 1 ? requestBody : undefined);
const payload =
rawPayload && typeof rawPayload === 'object'
? (rawPayload as {
json?: unknown;
input?: { json?: unknown };
})
: undefined;
fixture.simulationPayloads.push(
payload?.json ?? payload?.input?.json ?? rawPayload
);
if (fixture.failNextSimulation) {
fixture.failNextSimulation = false;
return errorResponse(operation, '시뮬레이터 입력 오류');
@@ -244,7 +264,13 @@ const gotoSimulator = async (page: Page) => {
};
test('operates independent/game presets, imports my general, and renders battle logs', async ({ page }) => {
const fixture: Fixture = { hasGeneral: true, queueFirst: true, pollingCount: 0, requests: [] };
const fixture: Fixture = {
hasGeneral: true,
queueFirst: true,
pollingCount: 0,
requests: [],
simulationPayloads: [],
};
await installApi(page, fixture);
await page.setViewportSize({ width: 1280, height: 900 });
await gotoSimulator(page);
@@ -265,6 +291,9 @@ test('operates independent/game presets, imports my general, and renders battle
await page.getByRole('button', { name: '내 장수를 출병자로' }).click();
await expect(page.getByLabel('이름').first()).toHaveValue('유비');
await expect(page.getByLabel('병사').first()).toHaveValue('4321');
const attackerDomesticTrait = page.getByLabel('내정특기').first();
await attackerDomesticTrait.selectOption('che_event_신산');
await expect(attackerDomesticTrait).toHaveValue('che_event_신산');
const battleButton = page.getByRole('button', { name: '전투', exact: true });
await battleButton.hover();
@@ -276,6 +305,34 @@ test('operates independent/game presets, imports my general, and renders battle
await expect(page.getByText('5', { exact: true })).toBeVisible();
expect(fixture.pollingCount).toBe(2);
expect(fixture.requests).toContain('battle.getSimulation');
expect(fixture.simulationPayloads[0]).toMatchObject({
attackerGeneral: { special: 'che_event_신산' },
});
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: '모두 저장' }).click();
const download = await downloadPromise;
const downloadPath = await download.path();
expect(downloadPath).not.toBeNull();
const exportedBattle = JSON.parse(await readFile(downloadPath!, 'utf8')) as {
objType: string;
data: { attackerGeneral: { special?: string | null } };
};
expect(exportedBattle).toMatchObject({
objType: 'battle',
data: { attackerGeneral: { special: 'che_event_신산' } },
});
await attackerDomesticTrait.selectOption({ label: '-' });
await expect(attackerDomesticTrait).toHaveValue('-');
await page.locator('.header-actions input[type="file"]').setInputFiles(downloadPath!);
await expect(attackerDomesticTrait).toHaveValue('che_event_신산');
await battleButton.click();
await expect.poll(() => fixture.simulationPayloads.length).toBe(2);
expect(fixture.simulationPayloads[1]).toMatchObject({
attackerGeneral: { special: 'che_event_신산' },
});
if (artifactRoot) {
await page.screenshot({
@@ -292,6 +349,7 @@ test('keeps simulation available without a game general and preserves input afte
failNextSimulation: true,
pollingCount: 0,
requests: [],
simulationPayloads: [],
};
await installApi(page, fixture);
await page.setViewportSize({ width: 500, height: 900 });
@@ -28,6 +28,7 @@ export default defineConfig({
'commandArguments.spec.ts',
'commandArgumentsLive.spec.ts',
'mainNavigation.spec.ts',
'session-auth.spec.ts',
],
fullyParallel: false,
workers: 1,
@@ -0,0 +1,40 @@
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig, devices } from '@playwright/test';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const port = Number(process.env.PLAYWRIGHT_FRONTEND_PORT ?? 15124);
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'hwe').replace(/^\/+|\/+$/g, '')}`;
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'hwe:903';
const baseURL = `http://127.0.0.1:${port}${basePath}/`;
const gameApiUrl = process.env.PLAYWRIGHT_GAME_API_URL ?? 'http://127.0.0.1:15125/trpc';
export default defineConfig({
testDir: '.',
testMatch: ['selectGeneralLive.spec.ts'],
fullyParallel: false,
workers: 1,
timeout: 60_000,
expect: {
timeout: 10_000,
},
reporter: [['list']],
outputDir: resolve(repositoryRoot, 'test-results/select-pool-live'),
use: {
baseURL,
...devices['Desktop Chrome'],
deviceScaleFactor: 1,
colorScheme: 'dark',
locale: 'ko-KR',
timezoneId: 'Asia/Seoul',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
webServer: {
command: `VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=/gateway/ pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${port}`,
cwd: repositoryRoot,
url: baseURL,
reuseExistingServer: false,
timeout: 120_000,
},
});
@@ -0,0 +1,519 @@
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { expect, test, type Page } from '@playwright/test';
import { encryptGameSessionToken } from '@sammo-ts/common/auth/gameToken';
import {
createGamePostgresConnector,
type GamePrisma,
type GamePrismaClient,
} from '@sammo-ts/infra';
const gameTokenSecret = process.env.SELECT_POOL_LIVE_GAME_SECRET;
const databaseUrl = process.env.SELECT_POOL_LIVE_DATABASE_URL;
const userId = process.env.SELECT_POOL_LIVE_USER_ID;
const profile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'hwe:903';
const hasLiveFixture = Boolean(gameTokenSecret && databaseUrl && userId);
const workspaceRoot =
process.env.SAMMO_WORKSPACE_ROOT ??
path.resolve(import.meta.dirname, '../../../../../sam_rebuild');
const defaultIcon = path.resolve(
process.env.SELECT_POOL_LIVE_DEFAULT_ICON ??
path.join(workspaceRoot, 'image/icons/default.jpg')
);
const walnutTexture = path.join(workspaceRoot, 'image/game/back_walnut.jpg');
const greenTexture = path.join(workspaceRoot, 'image/game/back_green.jpg');
const fixtureNationIds = [990_901, 990_902, 990_903];
interface AssetTracker {
userIconRequests: number;
}
const installSession = async (page: Page, tracker?: AssetTracker): Promise<void> => {
const now = new Date();
const gameToken = encryptGameSessionToken(
{
version: 1,
profile,
issuedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + 3_600_000).toISOString(),
sessionId: `select-pool-live-${randomUUID()}`,
user: {
id: userId!,
username: 'select-pool-live',
displayName: '선택실사용자',
roles: ['user'],
legacyMemberNo: 42,
},
sanctions: {},
identity: {
kakaoVerified: true,
canCreateGeneral: true,
requiresKakaoVerification: false,
graceEndsAt: null,
},
},
gameTokenSecret!
);
await page.addInitScript(
({ token, gameProfile }) => {
if (!window.localStorage.getItem('sammo-game-token')) {
window.localStorage.setItem('sammo-game-token', token);
}
window.localStorage.setItem('sammo-game-profile', gameProfile);
},
{ token: gameToken, gameProfile: profile }
);
await page.addInitScript(() => {
const values = [1, 0];
Object.defineProperty(window.crypto, 'getRandomValues', {
configurable: true,
value: <T extends ArrayBufferView | null>(array: T): T => {
if (array && (array as Uint32Array).length > 0) {
(array as Uint32Array)[0] = values.shift() ?? 0;
}
return array;
},
});
});
await page.route('**/image/icons/**', (route) =>
route.fulfill({ path: defaultIcon, contentType: 'image/jpeg' })
);
await page.route('**/gateway/api/user-icons/**', (route) => {
if (tracker) {
tracker.userIconRequests += 1;
}
return route.fulfill({ status: 404, body: '' });
});
await page.route('**/image/game/back_walnut.jpg', (route) =>
route.fulfill({ path: walnutTexture, contentType: 'image/jpeg' })
);
await page.route('**/image/game/back_green.jpg', (route) =>
route.fulfill({ path: greenTexture, contentType: 'image/jpeg' })
);
};
const waitForPool = async (page: Page): Promise<void> => {
await expect(page.locator('.card-holder > .general-card')).toHaveCount(14);
await page.evaluate(async () => {
await document.fonts.ready;
});
};
test.describe('scenario 903 live selection pool', () => {
test.skip(!hasLiveFixture, 'live selection-pool token and database are required');
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
test.beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
});
test.afterAll(async () => {
await closeDb?.();
});
test('renders Ref-width desktop/mobile cards, tooltip, focus, and expiration states', async ({
page,
}, testInfo) => {
await page.clock.install({ time: new Date() });
const assetTracker: AssetTracker = { userIconRequests: 0 };
await installSession(page, assetTracker);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('select-general');
await waitForPool(page);
await expect(page.locator('.server-info-table')).toContainText(
'현재 : 180年 1月 (5분 턴 서버)'
);
await expect(page.locator('.server-info-table')).toContainText(
'등록 장수 : 유저 0 / 500 명'
);
await expect(page.locator('.invitation-table')).toContainText('임관 권유 메시지');
const geometry = await page.evaluate(() => {
const root = document.querySelector<HTMLElement>('.select-pool-page')!;
const cards = Array.from(
document.querySelectorAll<HTMLElement>('.card-holder > .general-card')
);
const images = Array.from(
document.querySelectorAll<HTMLImageElement>('.card-holder .portrait img')
);
const selectionBody = document.querySelector<HTMLElement>('.selection-body')!;
const createSection = document.querySelector<HTMLElement>('.create-section')!;
const createBody = document.querySelector<HTMLElement>('.create-body')!;
const pageTitle = document.querySelector<HTMLElement>('.page-title')!;
const serverInfoTable =
document.querySelector<HTMLElement>('.server-info-table')!;
const invitationTable =
document.querySelector<HTMLElement>('.invitation-table')!;
const footerBack = document.querySelector<HTMLElement>('.footer-back')!;
const footerBanner = document.querySelector<HTMLElement>('.footer-banner')!;
const firstButton = document.querySelector<HTMLElement>(
'.card-holder .select-button'
)!;
return {
root: root.getBoundingClientRect().toJSON(),
pageTitle: pageTitle.getBoundingClientRect().toJSON(),
serverInfoTable: serverInfoTable.getBoundingClientRect().toJSON(),
invitationTable: invitationTable.getBoundingClientRect().toJSON(),
cards: cards.map((card) => card.getBoundingClientRect().toJSON()),
images: images.map((image) => ({
rect: image.getBoundingClientRect().toJSON(),
naturalWidth: image.naturalWidth,
naturalHeight: image.naturalHeight,
objectFit: getComputedStyle(image).objectFit,
})),
selectionBody: selectionBody.getBoundingClientRect().toJSON(),
createSection: createSection.getBoundingClientRect().toJSON(),
createBody: createBody.getBoundingClientRect().toJSON(),
footerBack: footerBack.getBoundingClientRect().toJSON(),
footerBanner: footerBanner.getBoundingClientRect().toJSON(),
firstButton: firstButton.getBoundingClientRect().toJSON(),
rootStyle: {
fontFamily: getComputedStyle(root).fontFamily,
fontSize: getComputedStyle(root).fontSize,
lineHeight: getComputedStyle(root).lineHeight,
backgroundImage: getComputedStyle(root).backgroundImage,
},
scrollWidth: document.documentElement.scrollWidth,
};
});
expect(geometry.root).toMatchObject({ x: 100, y: 8, width: 1000 });
expect(geometry.pageTitle).toMatchObject({ x: 100, y: 8, width: 1000 });
expect(Math.abs(geometry.pageTitle.height - 42.1875)).toBeLessThan(0.6);
expect(Math.abs(geometry.serverInfoTable.y - 50.1875)).toBeLessThan(0.6);
expect(Math.abs(geometry.serverInfoTable.height - 40.375)).toBeLessThan(0.6);
expect(Math.abs(geometry.invitationTable.x - 553)).toBeLessThan(0.6);
expect(Math.abs(geometry.invitationTable.y - 90.5625)).toBeLessThan(0.6);
expect(geometry.invitationTable.width).toBe(94);
expect(Math.abs(geometry.invitationTable.height - 20.1875)).toBeLessThan(0.1);
expect(geometry.rootStyle).toMatchObject({
fontSize: '14px',
lineHeight: '18.2px',
});
expect(geometry.rootStyle.fontFamily).toContain('Pretendard');
expect(geometry.rootStyle.backgroundImage).toContain('back_walnut.jpg');
expect(geometry.cards.every((card) => card.width === 127)).toBe(true);
expect(new Set(geometry.cards.map((card) => card.y)).size).toBe(2);
const shortestCard = Math.min(...geometry.cards.map((card) => card.height));
expect(Math.abs(shortestCard - 254.875)).toBeLessThan(0.6);
expect(
geometry.images.every(
(image, index) =>
image.rect.width === 64 &&
image.rect.height === 64 &&
Math.abs(
image.rect.x -
(geometry.cards[index]!.x +
(geometry.cards[index]!.width - image.rect.width) / 2)
) < 0.1
)
).toBe(true);
expect(
geometry.images.every(
(image) => image.naturalWidth > 0 && image.naturalHeight > 0
)
).toBe(true);
expect(geometry.images.every((image) => image.objectFit === 'fill')).toBe(true);
const fallbackImages = page.locator(
'.card-holder .portrait img[data-fallback-applied="true"]'
);
await expect(fallbackImages).not.toHaveCount(0);
expect(assetTracker.userIconRequests).toBe(await fallbackImages.count());
expect(Math.abs(geometry.selectionBody.y - 130.9375)).toBeLessThan(0.6);
expect(Math.abs(geometry.createSection.height - 87.375)).toBeLessThan(0.6);
expect(geometry.firstButton.height).toBe(19);
expect(geometry.footerBanner.height).toBeCloseTo(20.1875, 3);
await expect(page.locator('.invitation-table tbody tr')).toHaveCount(0);
await expect(page.locator('.footer-banner')).toContainText(
'삼국지 모의전투 HiDCHe core2026'
);
await expect(page.locator('.footer-banner a')).toHaveText('Credit');
const firstTrait = page.locator('.card-holder .trait-tooltip').first();
await firstTrait.hover();
await expect(firstTrait.getByRole('tooltip')).toBeVisible();
await page.screenshot({
path: testInfo.outputPath('select-general-desktop-hover.png'),
fullPage: true,
});
const firstButton = page.locator('.card-holder .select-button').first();
await firstButton.focus();
await expect(firstButton).toHaveCSS('outline-style', 'auto');
await expect(firstButton).toHaveCSS('outline-width', '1px');
await firstButton.hover();
await expect(firstButton).toHaveCSS('background-color', 'rgb(25, 25, 25)');
await page.setViewportSize({ width: 500, height: 900 });
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth))
.toBeGreaterThanOrEqual(1008);
await page.screenshot({
path: testInfo.outputPath('select-general-mobile.png'),
fullPage: true,
});
const validText = await page.locator('.selection-body small span').textContent();
expect(validText).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
const displayedExpiry = new Date(`${validText!.replace(' ', 'T')}+09:00`).getTime();
const delta = Math.max(displayedExpiry - Date.now(), 0);
await page.clock.fastForward(delta);
await expect(page.locator('.expired-text')).toHaveCount(0);
await page.clock.fastForward(2_000);
await expect(page.locator('.expired-text')).toHaveText('- 만료 -');
});
test('shuffles non-neutral invitation nations with Ref-style row content and colors', async ({
page,
}) => {
await db.nation.deleteMany({ where: { id: { in: fixtureNationIds } } });
await db.nation.createMany({
data: [
{
id: fixtureNationIds[0]!,
name: '테스트국A',
color: '#330000',
level: 1,
meta: { infoText: 'A 권유' },
},
{
id: fixtureNationIds[1]!,
name: '테스트국B',
color: '#FFFF00',
level: 1,
meta: { infoText: 'B 권유' },
},
{
id: fixtureNationIds[2]!,
name: '테스트국C',
color: '#000080',
level: 1,
meta: { infoText: 'C 권유' },
},
],
});
try {
await installSession(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('select-general');
await waitForPool(page);
const rows = page.locator('.invitation-table tbody tr');
await expect(rows.locator('.invitation-nation')).toHaveText([
'테스트국C',
'테스트국A',
'테스트국B',
]);
await expect(rows.locator('.invitation-message')).toHaveText([
'C 권유',
'A 권유',
'B 권유',
]);
await expect(rows.nth(0)).toHaveCSS('background-color', 'rgb(0, 0, 128)');
await expect(rows.nth(1)).toHaveCSS('background-color', 'rgb(51, 0, 0)');
await expect(rows.nth(2)).toHaveCSS('background-color', 'rgb(255, 255, 0)');
await expect(rows.nth(0)).toHaveCSS('color', 'rgb(255, 255, 255)');
await expect(rows.nth(2)).toHaveCSS('color', 'rgb(0, 0, 0)');
const invitationGeometry = await page.locator('.invitation-table').evaluate((table) => {
const rect = table.getBoundingClientRect();
return { x: rect.x, width: rect.width };
});
expect(invitationGeometry).toEqual({ x: 100, width: 1000 });
} finally {
await db.nation.deleteMany({ where: { id: { in: fixtureNationIds } } });
}
});
test('creates, rejects cooldown, exposes MyPage action, and reselects through the live API', async ({
page,
}) => {
const dialogs: string[] = [];
const createClientRequestIds: string[] = [];
let injectCreateTimeout = true;
page.on('dialog', async (dialog) => {
dialogs.push(dialog.message());
await dialog.accept();
});
await page.route('**/trpc/join.selectPoolGeneral?batch=1', async (route) => {
const findClientRequestId = (value: unknown): string | undefined => {
if (!value || typeof value !== 'object') return undefined;
if (
'clientRequestId' in value &&
typeof value.clientRequestId === 'string'
) {
return value.clientRequestId;
}
for (const nested of Object.values(value)) {
const found = findClientRequestId(nested);
if (found) return found;
}
return undefined;
};
const clientRequestId = findClientRequestId(route.request().postDataJSON());
expect(clientRequestId).toMatch(/^[0-9a-f-]{36}$/);
createClientRequestIds.push(clientRequestId!);
if (!injectCreateTimeout) {
await route.continue();
return;
}
injectCreateTimeout = false;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
error: {
message:
'장수 선택 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.',
code: -32008,
data: {
code: 'TIMEOUT',
httpStatus: 408,
path: 'join.selectPoolGeneral',
},
},
},
]),
});
});
const assetTracker: AssetTracker = { userIconRequests: 0 };
await installSession(page, assetTracker);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('select-general');
await waitForPool(page);
const candidateCards = page.locator('.card-holder > .general-card');
const fallbackCard = candidateCards
.filter({ has: page.locator('img[data-fallback-applied="true"]') })
.first();
await expect(fallbackCard).toBeVisible();
const initialName = await fallbackCard.locator('h4').first().textContent();
const userIconRequestsBeforePreview = assetTracker.userIconRequests;
await fallbackCard.locator('.select-button').click();
await expect(page.locator('.selected-card')).toHaveCount(1);
await expect(
page.locator('.selected-card img[data-fallback-applied="true"]')
).toBeVisible();
expect(assetTracker.userIconRequests).toBeGreaterThan(userIconRequestsBeforePreview);
await page.locator('.custom-form select').selectOption('che_안전');
await page.getByRole('button', { name: '다시입력' }).click();
await expect(page.locator('.custom-form select')).toHaveValue('Random');
await expect(page.locator('.selected-card')).toHaveCount(1);
await page.locator('.custom-form select').selectOption('che_안전');
await page.locator('#build-general').click();
await expect.poll(() => dialogs).toContain(
'실패했습니다: 장수 선택 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.'
);
await waitForPool(page);
const retryCard = page
.locator('.card-holder > .general-card')
.filter({ has: page.locator('h4', { hasText: initialName?.trim() ?? '' }) })
.first();
await retryCard.locator('.select-button').click();
await page.locator('.custom-form select').selectOption('che_안전');
await page.locator('#build-general').click();
await expect(page).toHaveURL(/\/hwe\/$/);
expect(dialogs.filter((message) => message === '이 장수로 생성할까요?')).toHaveLength(2);
await expect.poll(() => dialogs).toContain('선택한 장수로 생성했습니다.');
expect(createClientRequestIds).toHaveLength(2);
expect(createClientRequestIds[1]).toBe(createClientRequestIds[0]);
const created = await db.general.findFirstOrThrow({ where: { userId } });
expect(created.name).toBe(initialName?.trim());
expect(created.personalCode).toBe('che_안전');
expect(created.specialCode).toMatch(/^che_event_/);
const createEvent = await db.inputEvent.findFirstOrThrow({
where: { actorUserId: userId, eventType: 'selectPoolCreate' },
orderBy: { sequence: 'desc' },
});
expect(createEvent).toMatchObject({ status: 'SUCCEEDED', attempts: 1 });
expect(createEvent.requestId).toMatch(
new RegExp(`^select-pool:${userId}:[0-9a-f-]{36}:create$`)
);
expect(
await page.evaluate(() =>
window.sessionStorage.getItem('sammo-select-pool-pending-action')
)
).toBeNull();
await page.goto('my-page');
const actionLink = page.locator('.select-general-link');
await expect(actionLink).toBeVisible();
await expect(actionLink.locator('..')).toContainText(
/다른 장수 선택\s*\(\d{4}-\d{2}-\d{2}/
);
await expect(actionLink).toHaveCSS('width', '160px');
await expect(actionLink).toHaveCSS('height', '30px');
dialogs.length = 0;
await page.goto('select-general');
await expect.poll(() => dialogs).toContain('실패했습니다: 아직 다시 고를 수 없습니다');
await expect(page.locator('.error-text')).toHaveText('아직 다시 고를 수 없습니다');
const availableAt = '2026-07-29T00:00:00.000Z';
const cooldownRequestId = `select-pool-live-cooldown-${randomUUID()}`;
await db.inputEvent.create({
data: {
requestId: cooldownRequestId,
target: 'ENGINE',
eventType: 'patchGeneral',
payload: {
type: 'patchGeneral',
requestId: cooldownRequestId,
generalId: created.id,
patch: {
meta: {
next_change: availableAt,
nextChangeAt: availableAt,
},
},
} as GamePrisma.InputJsonValue,
},
});
await expect
.poll(
async () =>
(
await db.inputEvent.findUniqueOrThrow({
where: { requestId: cooldownRequestId },
})
).status
)
.toBe('SUCCEEDED');
dialogs.length = 0;
await page.reload();
await waitForPool(page);
const cards = page.locator('.card-holder > .general-card');
const names = await cards.locator('h4').allTextContents();
const targetIndex = names.findIndex((name) => name.trim() !== created.name);
expect(targetIndex).toBeGreaterThanOrEqual(0);
const targetName = names[targetIndex]!.trim();
await cards.nth(targetIndex).locator('.select-button').click();
await expect(page).toHaveURL(/\/hwe\/$/);
await expect.poll(() => dialogs).toContain(`이 장수를 선택할까요? : ${targetName}`);
await expect.poll(() => dialogs).toContain('선택한 장수로 변경했습니다.');
await expect
.poll(async () => (await db.general.findUniqueOrThrow({ where: { id: created.id } })).name)
.toBe(targetName);
const reselectEvent = await db.inputEvent.findFirstOrThrow({
where: { actorUserId: userId, eventType: 'selectPoolReselect' },
orderBy: { sequence: 'desc' },
});
expect(reselectEvent).toMatchObject({ status: 'SUCCEEDED', attempts: 1 });
expect(reselectEvent.requestId).toMatch(
new RegExp(`^select-pool:${userId}:[0-9a-f-]{36}:reselect$`)
);
expect(
await page.evaluate(() =>
window.sessionStorage.getItem('sammo-select-pool-pending-action')
)
).toBeNull();
});
});
+114
View File
@@ -0,0 +1,114 @@
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 publicResponse = (operation: string): unknown => {
if (operation === 'public.getMapLayout') {
return response({ mapName: 'che', cityList: [] });
}
if (operation === 'public.getCachedMap') {
return response({ year: 180, month: 1, cityList: [], nationList: [], history: [] });
}
if (operation === 'public.getWorldTrend') {
return response({ year: 180, month: 1, turnTerm: 5 });
}
if (operation === 'public.getNationList' || operation === 'public.getGeneralList') {
return response([]);
}
throw new Error(`Unhandled public tRPC operation: ${operation}`);
};
const seedGameStorage = async (page: Page, gameToken: string): Promise<void> => {
await page.addInitScript((token) => {
window.localStorage.setItem('sammo-game-token', token);
window.localStorage.setItem('sammo-game-profile', 'che:default');
}, gameToken);
};
test('removes an invalid ga_ token and redirects an authenticated route to public', async ({
page,
}) => {
await seedGameStorage(page, 'ga_invalid');
let gatewayRequests = 0;
await page.route('http://127.0.0.1:15120/api/trpc/**', async (route) => {
gatewayRequests += 1;
await route.abort();
});
await page.route('**/che/api/trpc/**', async (route) => {
const operations = operationNames(route);
if (operations.includes('auth.status')) {
expect(route.request().headers().authorization).toBe('Bearer ga_invalid');
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: 'invalid token' }),
});
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(operations.map(publicResponse)),
});
});
await page.goto('select-general');
await expect(page).toHaveURL(/\/che\/public$/);
await expect(page.getByRole('heading', { name: '공개 동향' })).toBeVisible();
expect(await page.evaluate(() => window.localStorage.getItem('sammo-game-token'))).toBeNull();
expect(gatewayRequests).toBe(0);
});
test('keeps a valid ga_ token when only lobby.info is unavailable', async ({ page }) => {
await seedGameStorage(page, 'ga_valid');
page.on('dialog', (dialog) => dialog.accept());
let gatewayRequests = 0;
let statusRequests = 0;
let lobbyRequests = 0;
await page.route('http://127.0.0.1:15120/api/trpc/**', async (route) => {
gatewayRequests += 1;
await route.abort();
});
await page.route('**/che/api/trpc/**', async (route) => {
const operations = operationNames(route);
if (operations.includes('auth.status')) {
statusRequests += 1;
expect(route.request().headers().authorization).toBe('Bearer ga_valid');
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([response({ userId: 'valid-user' })]),
});
return;
}
if (operations.includes('lobby.info')) {
lobbyRequests += 1;
await route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: 'lobby unavailable' }),
});
return;
}
await route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: 'fixture page data unavailable' }),
});
});
await page.goto('select-general');
await expect(page).toHaveURL(/\/che\/select-general$/);
await expect(page.locator('.page-title')).toContainText('장 수 선 택');
expect(await page.evaluate(() => window.localStorage.getItem('sammo-game-token'))).toBe(
'ga_valid'
);
expect(statusRequests).toBe(1);
expect(lobbyRequests).toBe(1);
expect(gatewayRequests).toBe(0);
});
@@ -206,6 +206,21 @@ const officerLevelOptions = [
<span>사기</span>
<input v-model.number="general.atmos" type="number" min="40" :max="options.config.maxAtmosByWar" />
</label>
<label class="field">
<span>내정특기</span>
<select v-model="general.special">
<option :value="null">-</option>
<option
v-for="trait in options.eventDomesticTraits"
:key="trait.key"
:value="trait.key"
>
{{ trait.name }}
</option>
</select>
</label>
</div>
<div class="form-row">
<label class="field">
<span>전특</span>
<select v-model="general.special2">
+9
View File
@@ -3,6 +3,7 @@ import MainView from '../views/MainView.vue';
import PublicView from '../views/PublicView.vue';
import LoginView from '../views/LoginView.vue';
import JoinView from '../views/JoinView.vue';
import SelectGeneralView from '../views/SelectGeneralView.vue';
import InheritView from '../views/InheritView.vue';
import AuctionView from '../views/AuctionView.vue';
import NationCitiesView from '../views/NationCitiesView.vue';
@@ -92,6 +93,14 @@ const routes = [
requiresNoGeneral: true,
},
},
{
path: '/select-general',
name: 'select-general',
component: SelectGeneralView,
meta: {
requiresAuth: true,
},
},
{
path: '/inherit',
name: 'inherit',
+35 -2
View File
@@ -101,7 +101,7 @@ export const useSessionStore = defineStore('session', {
},
async refreshGeneralStatus() {
if (!this.gameToken) {
this.status = 'authed';
this.status = this.sessionToken ? 'authed' : 'public';
return;
}
if (!isAccessToken(this.gameToken)) {
@@ -111,11 +111,44 @@ export const useSessionStore = defineStore('session', {
return;
}
}
try {
await gameTrpc.auth.status.query();
} catch {
this.setGameToken(null);
if (this.sessionToken && this.profile) {
try {
const issued = await gatewayTrpc.auth.issueGameSession.mutate({
sessionToken: this.sessionToken,
profile: this.profile,
});
this.setGameToken(issued.gameToken);
if (await this.exchangeGatewayToken()) {
await gameTrpc.auth.status.query();
} else {
throw new Error('Game token exchange failed.');
}
} catch {
this.setGameToken(null);
this.error = 'game_session_invalid';
this.status = 'public';
return;
}
} else {
this.error = 'game_session_invalid';
this.status = 'public';
return;
}
}
try {
const lobby = await gameTrpc.lobby.info.query();
this.status = lobby.myGeneral ? 'general' : 'authed';
} catch {
this.error = 'game_status_unavailable';
this.error = 'game_lobby_unavailable';
if (this.status === 'unknown' || this.status === 'public') {
this.status = 'authed';
}
}
},
async exchangeGatewayToken(): Promise<boolean> {
@@ -23,6 +23,7 @@ export type GeneralDraft = {
injury: number;
rice: number;
personal: string | null;
special: string | null;
special2: string | null;
crew: number;
crewtype: number;
@@ -57,6 +58,7 @@ export type BattleSimOptions = {
crewTypes: Array<{ id: number; name: string; armType: number }>;
};
nationTypes: Array<{ key: string; name: string; info: string }>;
eventDomesticTraits: Array<{ key: string; name: string; info: string }>;
warTraits: Array<{ key: string; name: string; info: string }>;
personalities: Array<{ key: string; name: string; info: string }>;
items: {
@@ -0,0 +1,22 @@
const KOREA_TIME_OFFSET_MS = 9 * 60 * 60 * 1000;
const pad = (value: number): string => String(value).padStart(2, '0');
export const formatSeoulDateTime = (value: string | Date): string => {
if (
typeof value === 'string' &&
!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(value.trim())
) {
return value.trim().replace('T', ' ').slice(0, 19);
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return typeof value === 'string' ? value.slice(0, 19) : '';
}
const koreaTime = new Date(date.getTime() + KOREA_TIME_OFFSET_MS);
return `${koreaTime.getUTCFullYear()}-${pad(koreaTime.getUTCMonth() + 1)}-${pad(
koreaTime.getUTCDate()
)} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(
koreaTime.getUTCSeconds()
)}`;
};
@@ -134,6 +134,7 @@ const createGeneralDraft = (overrides?: Partial<GeneralDraft>): GeneralDraft =>
injury: 0,
rice: 5000,
personal: null,
special: null,
special2: null,
crew: 7000,
crewtype: baseCrew,
@@ -193,6 +194,7 @@ const applyGeneralExport = (target: GeneralDraft, data: GeneralExport) => {
target.injury = data.injury;
target.rice = data.rice;
target.personal = data.personal;
target.special = data.special;
target.special2 = data.special2;
target.crew = data.crew;
target.crewtype = data.crewtype;
@@ -228,6 +230,7 @@ const toExportedGeneral = (general: GeneralDraft): GeneralExport => ({
injury: general.injury,
rice: general.rice,
personal: general.personal,
special: general.special,
special2: general.special2,
crew: general.crew,
crewtype: general.crewtype,
@@ -401,6 +404,7 @@ const normalizeGeneralExport = (raw: Record<string, unknown>): GeneralExport =>
injury: readNumberValue(raw.injury, 0),
rice: readNumberValue(raw.rice, 0),
personal: readOptionalString(raw.personal),
special: readOptionalString(raw.special),
special2: readOptionalString(raw.special2),
crew: readNumberValue(raw.crew, 0),
crewtype: readNumberValue(raw.crewtype, 0),
@@ -438,6 +442,7 @@ const buildGeneralPayload = (
nation: nationId,
turntime: timestamp,
personal: general.personal,
special: general.special,
special2: general.special2,
crew: general.crew,
crewtype: general.crewtype,
@@ -872,6 +877,7 @@ const applyServerGeneral = async (target: GeneralDraft, generalId: number) => {
injury: response.general.injury,
rice: response.general.rice,
personal: response.general.personal,
special: response.general.special,
special2: response.general.special2,
crew: response.general.crew,
crewtype: response.general.crewtype,
+4
View File
@@ -194,6 +194,10 @@ const loadConfig = async () => {
error.value = null;
try {
const config = await trpc.join.getConfig.query();
if (config.selectionPool.enabled) {
await router.replace({ name: 'select-general' });
return;
}
joinConfig.value = config;
form.value.name = config.user.displayName || '';
applyBalancedStats();
+33 -1
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { trpc } from '../utils/trpc';
import { formatLog } from '../utils/formatLog';
import { formatSeoulDateTime } from '../utils/legacyDateTime';
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic';
const SCREEN_MODE_KEY = 'sam.screenMode';
@@ -10,6 +11,7 @@ type ScreenMode = 'auto' | '500px' | '1000px';
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
type SelectionPoolStatus = Awaited<ReturnType<typeof trpc.join.getConfig.query>>['selectionPool'];
type WorldSnapshot = {
currentYear: number;
@@ -28,6 +30,7 @@ type SettingForm = {
const data = ref<MyGeneralResponse | null>(null);
const world = ref<WorldSnapshot>(null);
const selectionPoolStatus = ref<SelectionPoolStatus | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const screenMode = ref<ScreenMode>('auto');
@@ -130,6 +133,11 @@ const actionAvailability = computed(() => {
selectOtherGeneral: Boolean(npcMode === 2 && general?.npcState === 0),
};
});
const formatSelectionAvailableAt = computed(() => {
const value = selectionPoolStatus.value?.nextChangeAt;
if (!value) return '';
return formatSeoulDateTime(value);
});
const applyCustomCss = (text: string) => {
let style = document.getElementById('sammo-custom-css') as HTMLStyleElement | null;
@@ -161,12 +169,14 @@ const loadPage = async () => {
loading.value = true;
error.value = null;
try {
const [general, state] = await Promise.all([
const [general, state, joinConfig] = await Promise.all([
trpc.general.me.query(),
trpc.world.getState.query() as Promise<WorldSnapshot>,
trpc.join.getConfig.query(),
]);
data.value = general;
world.value = state;
selectionPoolStatus.value = joinConfig.selectionPool;
if (general) {
Object.assign(form, general.settings);
}
@@ -399,6 +409,20 @@ onMounted(() => {
접경 귀환
</button>
</div>
<div
v-if="actionAvailability.selectOtherGeneral && selectionPoolStatus?.enabled"
class="action-line"
>
다른 장수 선택
<template v-if="formatSelectionAvailableAt">
({{ formatSelectionAvailableAt }} 부터)
</template>
<br />
<RouterLink class="action-button select-general-link" to="/select-general">
다른 장수 선택
</RouterLink>
<br /><br />
</div>
<div class="screen-mode-row">
<span>500px/1000px 모드<br />(모바일 전용, 즉시 설정)</span>
@@ -612,6 +636,14 @@ dt {
margin: 4px 0;
background: #225500;
}
.select-general-link {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
color: #fff;
text-decoration: none;
}
.action-line {
margin: 12px 0;
}
@@ -0,0 +1,728 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useSessionStore } from '../stores/session';
import { formatSeoulDateTime } from '../utils/legacyDateTime';
import { trpc } from '../utils/trpc';
type JoinConfig = Awaited<ReturnType<typeof trpc.join.getConfig.query>>;
type Reservation = Awaited<ReturnType<typeof trpc.join.getSelectionPool.mutate>>;
type Candidate = Reservation['candidates'][number];
type Nation = JoinConfig['nations'][number];
type PendingSelectionAction = {
operation: 'create' | 'reselect';
uniqueName: string;
personality?: string;
clientRequestId: string;
};
const router = useRouter();
const session = useSessionStore();
const config = ref<JoinConfig | null>(null);
const reservation = ref<Reservation | null>(null);
const selectedUniqueName = ref<string | null>(null);
const nations = ref<Nation[]>([]);
const personality = ref('Random');
const loading = ref(true);
const submitting = ref(false);
const error = ref('');
const now = ref(Date.now());
let timer: number | null = null;
const pendingActionStorageKey = 'sammo-select-pool-pending-action';
const candidates = computed(() => reservation.value?.candidates ?? []);
const selectedCandidate = computed(
() => candidates.value.find((candidate) => candidate.uniqueName === selectedUniqueName.value) ?? null
);
const hasGeneral = computed(() => reservation.value?.hasGeneral ?? config.value?.selectionPool.hasGeneral ?? false);
const allowPersonality = computed(() => config.value?.selectionPool.allowOptions.includes('ego') ?? false);
const personalities = computed(() => config.value?.personalities ?? []);
const serverInfo = computed(() => config.value?.serverInfo ?? null);
const validUntil = computed(() => {
const value = reservation.value?.validUntil;
return value ? new Date(value).getTime() : 0;
});
const expired = computed(() => validUntil.value > 0 && now.value > validUntil.value);
const validUntilColor = computed(() => {
const remaining = validUntil.value - now.value;
if (remaining <= 0 || remaining > 30_000) {
return '#fff';
}
const channel = Math.max(0, Math.round((255 * remaining) / 30_000));
return `rgb(255, ${channel}, ${channel})`;
});
const errorText = (value: unknown): string =>
value instanceof Error ? value.message : typeof value === 'string' ? value : 'unknown_error';
const readPendingAction = (): PendingSelectionAction | null => {
try {
const raw = window.sessionStorage.getItem(pendingActionStorageKey);
if (!raw) return null;
const value = JSON.parse(raw) as Partial<PendingSelectionAction>;
if (
(value.operation !== 'create' && value.operation !== 'reselect') ||
typeof value.uniqueName !== 'string' ||
typeof value.clientRequestId !== 'string'
) {
return null;
}
return value as PendingSelectionAction;
} catch {
return null;
}
};
const getPendingAction = (
operation: PendingSelectionAction['operation'],
uniqueName: string,
requestedPersonality?: string
): PendingSelectionAction => {
const current = readPendingAction();
if (
current?.operation === operation &&
current.uniqueName === uniqueName &&
current.personality === requestedPersonality
) {
return current;
}
const next: PendingSelectionAction = {
operation,
uniqueName,
...(requestedPersonality ? { personality: requestedPersonality } : {}),
clientRequestId: crypto.randomUUID(),
};
window.sessionStorage.setItem(pendingActionStorageKey, JSON.stringify(next));
return next;
};
const clearPendingAction = (action: PendingSelectionAction): void => {
if (readPendingAction()?.clientRequestId === action.clientRequestId) {
window.sessionStorage.removeItem(pendingActionStorageKey);
}
};
const isIndeterminateTimeout = (value: unknown): boolean => {
if (!value || typeof value !== 'object' || !('data' in value)) return false;
const data = value.data;
return Boolean(
data &&
typeof data === 'object' &&
'code' in data &&
data.code === 'TIMEOUT'
);
};
const formatDateTime = (value: string | null | undefined): string => {
if (!value) return '';
return formatSeoulDateTime(value);
};
const shuffleNations = (source: Nation[]): Nation[] => {
const shuffled = [...source];
for (let index = shuffled.length - 1; index > 0; index -= 1) {
const random = new Uint32Array(1);
crypto.getRandomValues(random);
const swapIndex = random[0]! % (index + 1);
[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex]!, shuffled[index]!];
}
return shuffled;
};
const userIconBaseUrl =
import.meta.env.VITE_GATEWAY_USER_ICON_BASE_URL ?? '/gateway/api/user-icons';
const imageUrl = (candidate: Candidate): string =>
candidate.imageServer
? `${userIconBaseUrl.replace(/\/$/, '')}/${candidate.picture}`
: `/image/icons/${candidate.picture}`;
const useFallbackImage = (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';
};
const personalityName = (key: string | null): string | null => {
if (!key) return null;
return personalities.value.find((entry) => entry.key === key)?.name ?? key;
};
const personalityInfo = (key: string | null): string =>
key ? personalities.value.find((entry) => entry.key === key)?.info ?? '' : '';
const lightTextNationColors = new Set([
'',
'#330000',
'#FF0000',
'#800000',
'#A0522D',
'#FF6347',
'#808000',
'#008000',
'#2E8B57',
'#008080',
'#6495ED',
'#0000FF',
'#000080',
'#483D8B',
'#7B68EE',
'#800080',
'#A9A9A9',
'#000000',
]);
const nationTextColor = (color: string): string =>
lightTextNationColors.has(color.toUpperCase()) ? '#FFFFFF' : '#000000';
const selectCandidate = async (candidate: Candidate): Promise<void> => {
if (!hasGeneral.value) {
selectedUniqueName.value = candidate.uniqueName;
return;
}
if (!confirm(`이 장수를 선택할까요? : ${candidate.generalName}`)) {
return;
}
submitting.value = true;
const pending = getPendingAction('reselect', candidate.uniqueName);
try {
await trpc.join.reselectPoolGeneral.mutate({
uniqueName: candidate.uniqueName,
clientRequestId: pending.clientRequestId,
});
clearPendingAction(pending);
alert('선택한 장수로 변경했습니다.');
await session.refreshGeneralStatus();
await router.push('/');
} catch (cause) {
console.error(cause);
if (!isIndeterminateTimeout(cause)) {
clearPendingAction(pending);
}
alert(`실패했습니다: ${errorText(cause)}`);
await loadPage();
} finally {
submitting.value = false;
}
};
const createGeneral = async (): Promise<void> => {
const candidate = selectedCandidate.value;
if (!candidate) {
alert('장수를 선택해주세요!');
return;
}
if (!confirm('이 장수로 생성할까요?')) {
return;
}
submitting.value = true;
const pending = getPendingAction('create', candidate.uniqueName, personality.value);
try {
await trpc.join.selectPoolGeneral.mutate({
uniqueName: candidate.uniqueName,
personality: personality.value,
clientRequestId: pending.clientRequestId,
});
clearPendingAction(pending);
alert('선택한 장수로 생성했습니다.');
await session.refreshGeneralStatus();
await router.push('/');
} catch (cause) {
console.error(cause);
if (!isIndeterminateTimeout(cause)) {
clearPendingAction(pending);
}
alert(`실패했습니다: ${errorText(cause)}`);
await loadPage();
} finally {
submitting.value = false;
}
};
async function loadPage(): Promise<void> {
loading.value = true;
error.value = '';
selectedUniqueName.value = null;
try {
const nextConfig = await trpc.join.getConfig.query();
config.value = nextConfig;
nations.value = shuffleNations(nextConfig.nations);
if (!nextConfig.selectionPool.enabled) {
await router.replace(nextConfig.selectionPool.hasGeneral ? '/' : '/join');
return;
}
reservation.value = await trpc.join.getSelectionPool.mutate();
now.value = Date.now();
} catch (cause) {
console.error(cause);
error.value = errorText(cause);
alert(`실패했습니다: ${error.value}`);
} finally {
loading.value = false;
}
}
const goBack = (): void => {
if (window.history.length > 1) {
router.back();
return;
}
void router.push(hasGeneral.value ? '/' : '/join');
};
onMounted(() => {
timer = window.setInterval(() => {
now.value = Date.now();
}, 1_000);
void loadPage();
});
onBeforeUnmount(() => {
if (timer !== null) {
window.clearInterval(timer);
}
});
</script>
<template>
<main class="select-pool-page legacy-bg0">
<header class="page-title with-border">
<br />
<button class="legacy-button" type="button" @click="goBack">돌아가기</button>
</header>
<table v-if="serverInfo" class="server-info-table legacy-bg0">
<tbody>
<tr>
<td>
현재 : {{ serverInfo.currentYear }} {{ serverInfo.currentMonth }}
(<span class="cyan">{{ serverInfo.tickMinutes }} </span> 서버)<br />
등록 장수 : 유저 {{ serverInfo.userGeneralCount }} / {{ serverInfo.maxGeneral }} +
<span class="cyan">NPC {{ serverInfo.npcGeneralCount }} </span>
</td>
</tr>
</tbody>
</table>
<table class="invitation-table legacy-bg0">
<thead>
<tr>
<td colspan="2" class="legacy-bg1">임관 권유 메시지</td>
</tr>
</thead>
<tbody>
<tr
v-for="nation in nations"
:key="nation.id"
:style="{
color: nationTextColor(nation.color),
backgroundColor: nation.color,
}"
>
<td class="invitation-nation">{{ nation.name }}</td>
<td><div class="invitation-message">{{ nation.scoutMessage ?? '-' }}</div></td>
</tr>
</tbody>
</table>
<section class="selection-section">
<h1 class="section-title legacy-bg1 with-border">장수 선택</h1>
<div class="selection-body with-border">
<div v-if="loading">불러오는 중...</div>
<div v-else-if="error" class="error-text">{{ error }}</div>
<template v-else-if="reservation">
<small v-if="!expired">
(<span :style="{ color: validUntilColor }">{{ formatDateTime(reservation.validUntil) }}</span
>까지 유효)
</small>
<small v-else class="expired-text">- 만료 -</small>
<br />
<div class="card-holder">
<article
v-for="candidate in candidates"
:key="candidate.uniqueName"
class="general-card"
>
<h4 class="legacy-bg1 with-border">{{ candidate.generalName }}</h4>
<h4 class="portrait">
<img
:src="imageUrl(candidate)"
:alt="candidate.generalName"
width="64"
height="64"
@error="useFallbackImage"
/>
</h4>
<p>
{{ candidate.leadership }} / {{ candidate.strength }} / {{ candidate.intel }}<br />
<span v-if="candidate.ego" class="trait-tooltip" tabindex="0">
{{ personalityName(candidate.ego) }}
<span role="tooltip">{{ personalityInfo(candidate.ego) }}</span>
</span>
<br v-if="candidate.ego" />
<span class="trait-tooltip" tabindex="0">
{{ candidate.specialDomesticName }}
<span role="tooltip">{{ candidate.specialDomesticInfo }}</span>
</span>
/
<span>{{ candidate.specialWar ?? '-' }}</span
><br /><br />
보병: {{ Math.trunc(candidate.dex[0] / 1000) }}K<br />
궁병: {{ Math.trunc(candidate.dex[1] / 1000) }}K<br />
기병: {{ Math.trunc(candidate.dex[2] / 1000) }}K<br />
귀병: {{ Math.trunc(candidate.dex[3] / 1000) }}K<br />
차병: {{ Math.trunc(candidate.dex[4] / 1000) }}K<br />
</p>
<button
class="select-button with-border"
type="button"
:disabled="submitting"
@click="selectCandidate(candidate)"
>
선택하기
</button>
</article>
</div>
</template>
</div>
</section>
<section v-if="reservation && !hasGeneral" class="create-section">
<h1 class="section-title legacy-bg1 with-border">장수 생성</h1>
<div class="create-body with-border">
<div id="left-pad">
<article v-if="selectedCandidate" class="general-card selected-card">
<h4 class="legacy-bg1 with-border">{{ selectedCandidate.generalName }}</h4>
<h4 class="portrait">
<img
:src="imageUrl(selectedCandidate)"
:alt="selectedCandidate.generalName"
width="64"
height="64"
@error="useFallbackImage"
/>
</h4>
<p>
{{ selectedCandidate.leadership }} / {{ selectedCandidate.strength }} /
{{ selectedCandidate.intel }}<br />
<span v-if="selectedCandidate.ego" class="trait-tooltip" tabindex="0">
{{ personalityName(selectedCandidate.ego) }}
<span role="tooltip">{{ personalityInfo(selectedCandidate.ego) }}</span>
</span>
<br v-if="selectedCandidate.ego" />
<span class="trait-tooltip" tabindex="0">
{{ selectedCandidate.specialDomesticName }}
<span role="tooltip">{{ selectedCandidate.specialDomesticInfo }}</span>
</span>
/
<span>{{ selectedCandidate.specialWar ?? '-' }}</span
><br /><br />
보병: {{ Math.trunc(selectedCandidate.dex[0] / 1000) }}K<br />
궁병: {{ Math.trunc(selectedCandidate.dex[1] / 1000) }}K<br />
기병: {{ Math.trunc(selectedCandidate.dex[2] / 1000) }}K<br />
귀병: {{ Math.trunc(selectedCandidate.dex[3] / 1000) }}K<br />
차병: {{ Math.trunc(selectedCandidate.dex[4] / 1000) }}K<br />
</p>
<button class="select-button with-border" type="button">선택하기</button>
</article>
<template v-else>장수를<br />선택해주세요!</template>
</div>
<form class="custom-form" @submit.prevent="createGeneral">
<table>
<tbody>
<tr v-if="allowPersonality">
<th class="legacy-bg1">성격</th>
<td>
<select v-model="personality">
<option value="Random">????</option>
<option
v-for="entry in personalities.filter((item) => item.key !== 'Random')"
:key="entry.key"
:value="entry.key"
>
{{ entry.name }}
</option>
</select>
<span>
{{ personalities.find((entry) => entry.key === personality)?.info ?? '' }}
</span>
</td>
</tr>
<tr>
<td colspan="2" class="join-guidance">
임의의 도시에서 재야로 시작하며 건국과 임관은 게임 내에서 실행합니다.
</td>
</tr>
<tr>
<td class="create-action">
<button
id="build-general"
class="legacy-button"
type="submit"
:disabled="submitting"
>
장수생성
</button>
</td>
<td>
<button
class="legacy-button"
type="reset"
:disabled="submitting"
@click="personality = 'Random'"
>
다시입력
</button>
</td>
</tr>
</tbody>
</table>
</form>
</div>
</section>
<footer class="page-footer">
<div class="footer-back with-border">
<button class="legacy-button" type="button" @click="goBack">돌아가기</button>
</div>
<div class="footer-banner with-border">
<small>
삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD /
<a
href="https://sam.hided.net/wiki/hidche/credit"
target="_blank"
rel="noopener noreferrer"
>Credit</a
>
</small>
</div>
</footer>
</main>
</template>
<style scoped>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css');
.select-pool-page {
width: 1000px;
min-width: 1000px;
margin: 8px auto 0;
color: #fff;
line-height: 1.3;
text-align: center;
overflow: visible;
}
.with-border {
border: solid 1px;
border-top-color: gray;
border-left-color: gray;
border-right-color: #000;
border-bottom-color: #000;
}
.page-title {
padding: 0;
text-align: left;
}
.page-title .legacy-button,
.footer-back .legacy-button {
padding: 1px 6px;
font-weight: 400;
line-height: 1.3;
}
.server-info-table,
.invitation-table {
border: 1px solid;
border-collapse: collapse;
border-top-color: gray;
border-left-color: gray;
border-right-color: #000;
border-bottom-color: #000;
border-spacing: 0;
font-size: 14px;
text-align: center;
word-break: break-all;
}
.server-info-table {
width: 100%;
}
.server-info-table td,
.invitation-table td {
border: 1px solid gray;
padding: 0;
}
.server-info-table td {
padding: 1px 0;
}
.invitation-table {
margin: 0 auto;
}
.invitation-nation {
width: 130px;
}
.invitation-message {
width: 870px;
max-width: 870px;
max-height: 200px;
overflow: hidden;
}
.cyan {
color: cyan;
}
.selection-section,
.create-section {
margin: 0;
}
.section-title {
margin: 0;
padding: 0;
color: inherit;
font-size: 14px;
font-weight: 700;
line-height: 18.2px;
}
.selection-body {
padding: 0;
}
.card-holder {
text-align: center;
white-space: normal;
}
.general-card {
width: 125px;
display: inline-block;
box-sizing: content-box;
border: solid 1px;
border-top-color: gray;
border-left-color: gray;
border-right-color: #000;
border-bottom-color: #000;
vertical-align: top;
}
.general-card h4,
.general-card p {
margin: 0;
}
.general-card h4 {
padding: 0;
}
.portrait {
text-align: center;
}
.portrait img {
display: inline;
width: 64px;
height: 64px;
vertical-align: baseline;
object-fit: fill;
}
.select-button {
width: 100%;
height: 19px;
padding: 0 4px;
border-radius: 0;
background: #191919;
color: #fff;
line-height: normal;
}
.expired-text,
.error-text {
color: red;
}
.create-section {
margin-top: 10px;
}
.create-body {
display: flex;
text-align: left;
}
#left-pad {
flex: 1;
padding-top: 8px;
text-align: center;
}
.selected-card .select-button {
display: none;
}
.custom-form {
flex: 4;
}
.custom-form table {
width: 100%;
border-collapse: collapse;
}
.custom-form th,
.custom-form td {
padding: 0;
text-align: left;
}
.custom-form th {
width: 200px;
text-align: right;
}
.custom-form select {
color: #fff;
background: #000;
}
.custom-form .legacy-button {
padding: 3px 6px;
font-weight: 400;
}
.join-guidance {
text-align: center;
}
.create-action {
width: 200px;
text-align: right;
}
.footer-back,
.footer-banner {
text-align: left;
}
.footer-banner a {
color: #fff;
text-decoration: underline;
}
button:disabled {
cursor: default;
opacity: 0.55;
}
.select-button:disabled {
background: #333;
}
.select-button:focus-visible,
.custom-form select:focus-visible,
.trait-tooltip:focus-visible {
outline: auto 1px;
outline-offset: 0;
}
.trait-tooltip {
position: relative;
cursor: help;
}
.trait-tooltip [role='tooltip'] {
display: none;
position: absolute;
z-index: 20;
left: 50%;
bottom: calc(100% + 4px);
width: 220px;
padding: 5px 7px;
transform: translateX(-50%);
border: 1px solid #888;
background: #202020;
color: #fff;
text-align: left;
white-space: normal;
word-break: keep-all;
}
.trait-tooltip:hover [role='tooltip'],
.trait-tooltip:focus [role='tooltip'] {
display: block;
}
@media (max-width: 1000px) {
.select-pool-page {
margin-left: 8px;
margin-right: 0;
}
}
</style>