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
@@ -0,0 +1,77 @@
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 frontendPort = Number(process.env.PLAYWRIGHT_FRONTEND_PORT ?? 15134);
const apiPort = Number(process.env.PLAYWRIGHT_GAME_API_PORT ?? 15135);
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'hwe').replace(/^\/+|\/+$/g, '')}`;
const profileId = process.env.PLAYWRIGHT_PROFILE_ID ?? 'npc_possession_integration';
const scenario = process.env.PLAYWRIGHT_SCENARIO ?? '2';
const expectedGameProfile = `${profileId}:${scenario}`;
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? expectedGameProfile;
const baseURL = `http://127.0.0.1:${frontendPort}${basePath}/`;
const gameApiUrl = `http://127.0.0.1:${apiPort}/trpc`;
const databaseUrl = process.env.NPC_POSSESSION_LIVE_DATABASE_URL ?? '';
const redisUrl = process.env.NPC_POSSESSION_LIVE_REDIS_URL ?? '';
const gameSecret = process.env.NPC_POSSESSION_LIVE_GAME_SECRET ?? '';
const databaseSchema = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null;
if (databaseUrl && databaseSchema !== profileId) {
throw new Error(
`NPC possession live schema must exactly match PLAYWRIGHT_PROFILE_ID: ${databaseSchema ?? '(missing)'} != ${profileId}`
);
}
if (gameProfile !== expectedGameProfile) {
throw new Error(
`PLAYWRIGHT_GAME_PROFILE must match profile and scenario: ${gameProfile} != ${expectedGameProfile}`
);
}
export default defineConfig({
testDir: '.',
testMatch: ['npcPossessionLive.spec.ts'],
fullyParallel: false,
workers: 1,
timeout: 60_000,
expect: {
timeout: 10_000,
},
reporter: [['list']],
outputDir: resolve(repositoryRoot, 'test-results/npc-possession-live'),
use: {
baseURL,
...devices['Desktop Chrome'],
deviceScaleFactor: 1,
locale: 'ko-KR',
timezoneId: 'Asia/Seoul',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
webServer: [
{
command: 'node app/game-api/dist/index.js',
cwd: repositoryRoot,
url: `http://127.0.0.1:${apiPort}/healthz`,
reuseExistingServer: false,
timeout: 120_000,
env: {
DATABASE_URL: databaseUrl,
REDIS_URL: redisUrl,
GAME_TOKEN_SECRET: gameSecret,
GAME_API_HOST: '127.0.0.1',
GAME_API_PORT: String(apiPort),
PROFILE: profileId,
SCENARIO: scenario,
GAME_PROFILE_NAME: gameProfile,
},
},
{
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 ${frontendPort}`,
cwd: repositoryRoot,
url: baseURL,
reuseExistingServer: false,
timeout: 120_000,
},
],
});
+359
View File
@@ -0,0 +1,359 @@
import { expect, test, type Page, type Route } from '@playwright/test';
const response = (data: unknown) => ({ result: { data } });
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`;
const operationNames = (route: Route) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
type FixtureState = {
reservationCalls: number;
reservationInputs: Array<Record<string, unknown>>;
rawBodies: unknown[];
possessInputs: Array<Record<string, unknown>>;
hasGeneral: boolean;
injectTimeout: boolean;
};
const candidates = Array.from({ length: 5 }, (_, index) => ({
id: index + 1,
name: `빙의후보${index + 1}`,
nation: { id: 0, name: '재야', color: '#aaaaaa' },
stats: {
leadership: 40 + index,
strength: 50 + index,
intelligence: 60 + index,
},
picture: 'default.jpg',
imageServer: index === 0 ? 1 : 0,
personality: { code: 'che_안전', name: '안전', info: '안전을 중시합니다.' },
specialDomestic: { code: 'che_인덕', name: '인덕', info: '인덕 설명' },
specialWar: { code: 'che_무쌍', name: '무쌍', info: '무쌍 설명' },
keepCount: 3,
}));
const generalList = Array.from({ length: 55 }, (_, index) => {
const candidate = candidates[index % candidates.length]!;
return {
id: index + 1,
name: `전체장수${String(index + 1).padStart(2, '0')}`,
picture: candidate.picture,
imageServer: candidate.imageServer,
npcState: index === 54 ? 1 : 2,
ownerName: index === 54 ? '빙의자' : '',
age: 25 + (index % 20),
level: 3 + (index % 5),
officerLevel: index % 5,
killturn: index % 10,
nationId: 0,
nationName: '재야',
nationLevel: 0,
personality: { key: 'che_안전', name: '안전', info: '안전을 중시합니다.' },
specialDomestic: { key: 'che_인덕', name: '인덕', info: '인덕 설명' },
specialWar: { key: 'che_무쌍', name: '무쌍', info: '무쌍 설명' },
statTotal: 150 + index,
leadership: 40 + (index % 30),
strength: 50 + (index % 20),
intelligence: 60 + (index % 10),
experience: 800 + index,
experienceText: '무명',
dedication: 700 + index,
dedicationText: '28품관',
};
});
generalList.push({
...generalList[0]!,
id: 56,
name: '사용자장수',
npcState: 0,
ownerName: '',
statTotal: 230,
leadership: 80,
strength: 70,
intelligence: 80,
experience: 16_000,
experienceText: '지역적',
dedication: 10_000,
dedicationText: '21품관',
});
const findInput = (value: unknown): Record<string, unknown> => {
if (!value || typeof value !== 'object') return {};
if ('json' in value && value.json && typeof value.json === 'object') {
return value.json as Record<string, unknown>;
}
const record = value as Record<string, unknown>;
if (
['refresh', 'keepIds', 'generalId', 'tokenNonce', 'clientRequestId'].some((key) => Object.hasOwn(record, key))
) {
return record;
}
for (const nested of Object.values(value)) {
const input = findInput(nested);
if (Object.keys(input).length > 0) return input;
}
return {};
};
const installFixture = async (page: Page, state: FixtureState): Promise<void> => {
await page.addInitScript(() => {
localStorage.setItem('sammo-game-token', 'ga_npc_possession');
localStorage.setItem('sammo-game-profile', 'che:default');
});
await page.route('**/image/**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>',
});
});
await page.route('**/gateway/api/user-icons/default.jpg', async (route) => {
await route.fulfill({
status: 200,
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#8855aa"/></svg>',
});
});
await page.route('**/events**', async (route) => route.abort());
await page.route(`**${basePath}/api/trpc/**`, async (route) => {
const operations = operationNames(route);
const rawBody: unknown = route.request().postData() ? route.request().postDataJSON() : {};
state.rawBodies.push(rawBody);
const body = rawBody && typeof rawBody === 'object' ? (rawBody as Record<string, unknown>) : {};
const results = operations.map((operation, index) => {
const input = findInput(body[String(index)] ?? body);
if (operation === 'lobby.info') {
return response({
myGeneral: state.hasGeneral ? { id: 1, name: '빙의후보1' } : null,
year: 180,
month: 1,
turnTerm: 5,
});
}
if (operation === 'join.getConfig') {
return response({
rules: {
stat: { total: 165, min: 15, max: 80, bonusMin: 3, bonusMax: 5 },
allowCustomName: true,
},
user: { id: 'npc-user', displayName: '빙의사용자', canCreateGeneral: true },
personalities: [{ key: 'Random', name: '???', info: '' }],
warSpecials: [],
nations: [],
serverInfo: {
currentYear: 180,
currentMonth: 1,
tickMinutes: 5,
maxGeneral: 500,
userGeneralCount: 0,
npcGeneralCount: 12,
},
inherit: {
totalPoint: 0,
costs: {
inheritBornSpecialPoint: 0,
inheritBornTurntimePoint: 0,
inheritBornCityPoint: 0,
inheritBornStatPoint: 0,
},
availableCities: [],
turnTimeZones: [],
availableSpecialWar: [],
},
selectionPool: { enabled: false },
npcPossession: { enabled: true },
});
}
if (operation === 'join.listPossessCandidates') {
state.reservationCalls += 1;
state.reservationInputs.push(input);
const refresh = input.refresh === true;
const now = Date.now();
const keepIds = Array.isArray(input.keepIds) ? input.keepIds : [];
return response({
tokenNonce: refresh ? 202 : 101,
validUntil: new Date(now + 90_000).toISOString(),
pickMoreFrom: new Date(refresh ? now + 1_200 : now - 1_000).toISOString(),
pickMoreSeconds: refresh ? 2 : 0,
candidates: candidates.map((candidate) => ({
...candidate,
keepCount: refresh && keepIds.includes(candidate.id) ? 2 : 3,
})),
});
}
if (operation === 'public.getNpcList') {
return response({
sort: 1,
generals: generalList,
tokenKeepCounts: { 1: 2, 2: 1 },
});
}
if (operation === 'join.possessGeneral') {
state.possessInputs.push(input);
if (state.injectTimeout) {
state.injectTimeout = false;
return {
error: {
message:
'NPC 빙의 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.',
code: -32008,
data: {
code: 'TIMEOUT',
httpStatus: 408,
path: 'join.possessGeneral',
},
},
};
}
state.hasGeneral = true;
return response({ ok: true, generalId: Number(input.generalId) });
}
return response({});
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(operations.length === 1 ? results[0] : results),
});
});
};
test('renders Ref-shaped token cards, preserves keep cooldown and retries possession with one ID', async ({
page,
}, testInfo) => {
const state: FixtureState = {
reservationCalls: 0,
reservationInputs: [],
rawBodies: [],
possessInputs: [],
hasGeneral: false,
injectTimeout: true,
};
await installFixture(page, state);
await page.setViewportSize({ width: 1024, height: 900 });
await page.goto('join?tab=possess');
await expect(page.getByRole('button', { name: 'NPC 빙의' })).toHaveClass(/active/);
await expect(page.locator('.npc-card')).toHaveCount(5);
await expect(page.getByText(/까지 유효/)).toBeVisible();
const geometry = await page.locator('.npc-possession-section').evaluate((section) => {
const sectionRect = section.getBoundingClientRect();
const cards = [...section.querySelectorAll<HTMLElement>('.npc-card')];
const image = section.querySelector<HTMLImageElement>('.npc-card-image');
return {
sectionWidth: sectionRect.width,
cardWidths: cards.map((card) => card.getBoundingClientRect().width),
imageWidth: image?.getBoundingClientRect().width,
imageHeight: image?.getBoundingClientRect().height,
imageNaturalWidth: image?.naturalWidth,
imageNaturalHeight: image?.naturalHeight,
};
});
expect(geometry).toEqual({
sectionWidth: 1000,
cardWidths: [125, 125, 125, 125, 125],
imageWidth: 64,
imageHeight: 64,
imageNaturalWidth: 64,
imageNaturalHeight: 64,
});
const tooltip = page.locator('.npc-tooltip').first();
const tooltipPopup = tooltip.getByRole('tooltip');
await expect(tooltipPopup).toBeHidden();
await tooltip.hover();
await expect(tooltipPopup).toBeVisible();
await tooltip.focus();
await expect(tooltip).toBeFocused();
await expect(tooltipPopup).toHaveText('안전을 중시합니다.');
await expect(page.locator('.npc-card-image').first()).toHaveAttribute('src', '/gateway/api/user-icons/default.jpg');
await page.locator('#btn-load-general-list').click();
await expect(page.locator('#tb-general-list')).toBeVisible();
await expect(page.locator('#tb-general-list tbody tr')).toHaveCount(50);
await expect(page.locator('#tb-general-list tbody tr').first()).toHaveAttribute('data-general-id', '56');
await expect(page.locator('#tb-general-list tbody tr').first()).toHaveAttribute('data-reservation-state', '2');
const selectedRow = page.locator('#tb-general-list tbody tr[data-general-id="1"]');
await expect(selectedRow).toHaveAttribute('data-reservation-state', '1');
await expect(selectedRow.locator('.npc-general-name')).toHaveCSS('color', 'rgb(238, 130, 238)');
await expect(selectedRow.locator('.npc-general-name')).toContainText('(2회)');
const listGeometry = await page.locator('#tb-general-list').evaluate((table) => {
const rect = table.getBoundingClientRect();
const icon = table.querySelector<HTMLImageElement>('.npc-general-icon');
return {
width: rect.width,
iconWidth: icon?.getBoundingClientRect().width,
iconHeight: icon?.getBoundingClientRect().height,
iconNaturalWidth: icon?.naturalWidth,
iconNaturalHeight: icon?.naturalHeight,
};
});
expect(listGeometry).toEqual({
width: 970,
iconWidth: 64,
iconHeight: 64,
iconNaturalWidth: 64,
iconNaturalHeight: 64,
});
await page.locator('#btn-print-more-generals').click();
await expect(page.locator('#tb-general-list tbody tr')).toHaveCount(56);
await expect(page.locator('#btn-print-more-generals')).toHaveCount(0);
await page.screenshot({
path: testInfo.outputPath('npc-possession-candidates-and-list.png'),
fullPage: true,
});
await page.locator('.npc-keep input').first().check();
await page.getByRole('button', { name: '다른 장수 보기' }).click();
await expect.poll(() => state.reservationCalls).toBe(2);
expect(state.reservationInputs.at(-1), JSON.stringify(state.rawBodies.at(-1))).toMatchObject({
refresh: true,
keepIds: [1],
});
await expect(page.locator('.npc-keep').first()).toContainText('보관(2회)');
const refreshButton = page.locator('#btn-pick-more');
await expect(refreshButton).toBeDisabled();
await expect(refreshButton).toContainText(/다른 장수 보기\([12]초\)/);
await expect(refreshButton).toBeEnabled({ timeout: 3_000 });
const dialogs: string[] = [];
page.on('dialog', async (dialog) => {
dialogs.push(dialog.message());
if (
dialog.type() === 'confirm' &&
dialogs.filter((message) => message.startsWith('빙의할까요?')).length === 1
) {
await dialog.dismiss();
return;
}
await dialog.accept();
});
const possessButton = page.locator('.npc-action').first();
await possessButton.click();
expect(state.possessInputs).toHaveLength(0);
await possessButton.click();
await expect(page.locator('.join-error')).toContainText('같은 요청으로 다시 시도해 주세요.');
expect(state.possessInputs).toHaveLength(1);
const firstRequestId = state.possessInputs[0]?.clientRequestId;
expect(firstRequestId).toMatch(/^[0-9a-f-]{36}$/);
expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action'))).toContain(
firstRequestId as string
);
await page.evaluate(() => {
const expiredNow = Date.now() + 120_000;
Date.now = () => expiredNow;
});
await expect(page.locator('.npc-token-expired')).toBeVisible();
await expect(possessButton).toBeEnabled();
await expect(refreshButton).toBeDisabled();
await page.locator('#btn-retry-possession').click();
await expect(page).toHaveURL(new RegExp(`${basePath}/$`));
expect(state.possessInputs).toHaveLength(2);
expect(state.possessInputs[1]?.clientRequestId).toBe(firstRequestId);
expect(dialogs).toContain('빙의에 성공했습니다.');
expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action'))).toBeNull();
await page.screenshot({
path: testInfo.outputPath('npc-possession-success.png'),
fullPage: true,
});
});
@@ -0,0 +1,241 @@
import { randomUUID } from 'node:crypto';
import { expect, test, type Page } from '@playwright/test';
import { encryptGameSessionToken } from '../../../packages/common/dist/auth/gameToken.js';
import { createTurnDaemonRuntime, seedScenarioToDatabase } from '../../game-engine/dist/index.js';
import { createGamePostgresConnector } from '../../../packages/infra/dist/index.js';
const databaseUrl = process.env.NPC_POSSESSION_LIVE_DATABASE_URL;
const redisUrl = process.env.NPC_POSSESSION_LIVE_REDIS_URL;
const gameTokenSecret = process.env.NPC_POSSESSION_LIVE_GAME_SECRET;
const profile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'npc_possession_integration:2';
const scenarioId = Number(process.env.PLAYWRIGHT_SCENARIO ?? '2');
const userId = 'npc-possession-live-user';
const hasLiveFixture = Boolean(databaseUrl && redisUrl && gameTokenSecret);
if (!Number.isSafeInteger(scenarioId) || scenarioId <= 0 || profile !== `${profile.split(':', 1)[0]}:${scenarioId}`) {
throw new Error(`NPC possession live scenario/profile mismatch: ${profile} / ${scenarioId}`);
}
const installSession = async (page: Page): 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: `npc-possession-live-${randomUUID()}`,
user: {
id: userId,
username: 'npc-possession-live-user',
displayName: '브라우저빙의',
roles: ['user'],
legacyMemberNo: 7_710,
canUseGeneralPicture: false,
},
sanctions: {},
identity: {
kakaoVerified: true,
canCreateGeneral: true,
requiresKakaoVerification: false,
graceEndsAt: null,
},
},
gameTokenSecret!
);
await page.addInitScript(
({ token, gameProfile }) => {
window.localStorage.setItem('sammo-game-token', token);
window.localStorage.setItem('sammo-game-profile', gameProfile);
},
{ token: gameToken, gameProfile: profile }
);
};
test.describe('NPC possession through live PostgreSQL, Redis, API, daemon, and Chromium', () => {
test.skip(!hasLiveFixture, 'live NPC possession token and database are required');
let db: ReturnType<typeof createGamePostgresConnector>['prisma'];
let closeDb: (() => Promise<void>) | undefined;
let runtime: Awaited<ReturnType<typeof createTurnDaemonRuntime>> | undefined;
let daemonLoop: Promise<void> | undefined;
test.beforeAll(async () => {
const schema = new URL(databaseUrl!).searchParams.get('schema');
const profileId = profile.split(':', 1)[0] ?? '';
if (schema !== profileId || !schema.endsWith('npc_possession_integration')) {
throw new Error(
`Refusing mismatched or non-dedicated schema: ${schema ?? '(missing)'} != ${profileId ?? '(missing)'}`
);
}
const previousSeed = process.env.INTEGRATION_WORLD_SEED;
process.env.INTEGRATION_WORLD_SEED = 'npc-possession-live-seed';
try {
await seedScenarioToDatabase({
scenarioId,
databaseUrl: databaseUrl!,
now: new Date('2099-07-31T12:00:00.000Z'),
installOptions: {
turnTermMinutes: 5,
npcMode: 1,
showImgLevel: 3,
serverId: profile,
season: 1,
},
});
} finally {
if (previousSeed === undefined) {
delete process.env.INTEGRATION_WORLD_SEED;
} else {
process.env.INTEGRATION_WORLD_SEED = previousSeed;
}
}
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.inputEvent.deleteMany();
await db.logEntry.deleteMany();
await db.npcSelectionToken.deleteMany();
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
await db.general.createMany({
data: Array.from({ length: 12 }, (_, index) => ({
id: index + 1,
userId: null,
name: `실브라우저후보${index + 1}`,
nationId: 0,
cityId: city.id,
npcState: 2,
leadership: 40 + index,
strength: 50 + index,
intel: 60 + index,
turnTime: new Date('2099-07-31T12:05:00.000Z'),
personalCode: 'che_안전',
specialCode: 'che_인덕',
special2Code: 'che_무쌍',
picture: 'default.jpg',
imageServer: 0,
meta: { killturn: 6 },
penalty: {},
})),
});
runtime = await createTurnDaemonRuntime({
profile,
databaseUrl: databaseUrl!,
enableDatabaseFlush: true,
enableLeaseHeartbeat: false,
leaseOwnerId: 'npc-possession-live-daemon',
});
daemonLoop = runtime.lifecycle.start();
});
test.afterAll(async () => {
if (runtime) {
await runtime.lifecycle.stop('NPC possession live complete');
await daemonLoop;
await runtime.close();
}
await closeDb?.();
});
test('replays one completed possession after the browser receives an indeterminate timeout', async ({
page,
}, testInfo) => {
const requestIds: string[] = [];
let injectTimeout = true;
await installSession(page);
await page.route('**/image/icons/**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>',
});
});
await page.route('**/trpc/join.possessGeneral?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}$/);
requestIds.push(clientRequestId!);
if (!injectTimeout) {
await route.continue();
return;
}
injectTimeout = false;
const accepted = await route.fetch();
expect(accepted.ok()).toBe(true);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
error: {
message:
'NPC 빙의 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.',
code: -32008,
data: {
code: 'TIMEOUT',
httpStatus: 408,
path: 'join.possessGeneral',
},
},
},
]),
});
});
await page.setViewportSize({ width: 1024, height: 900 });
await page.goto('join?tab=possess');
await expect(page.getByRole('button', { name: 'NPC 빙의' })).toHaveClass(/active/);
await expect(page.locator('.npc-card')).toHaveCount(5);
const geometry = await page.locator('.npc-possession-section').evaluate((section) => ({
width: section.getBoundingClientRect().width,
cardWidths: [...section.querySelectorAll<HTMLElement>('.npc-card')].map(
(card) => card.getBoundingClientRect().width
),
}));
expect(geometry).toEqual({
width: 1000,
cardWidths: [125, 125, 125, 125, 125],
});
const dialogs: string[] = [];
page.on('dialog', async (dialog) => {
dialogs.push(dialog.message());
await dialog.accept();
});
const possessButton = page.locator('.npc-action').first();
await possessButton.click();
await expect(page.locator('.join-error')).toContainText('같은 요청으로 다시 시도해 주세요.');
await expect.poll(() => db.general.count({ where: { userId } })).toBe(1);
const pending = await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action'));
expect(pending).toContain(requestIds[0]);
await possessButton.click();
await expect(page).toHaveURL(/\/hwe\/$/);
expect(requestIds).toHaveLength(2);
expect(requestIds[1]).toBe(requestIds[0]);
expect(await db.general.count({ where: { userId } })).toBe(1);
expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action'))).toBeNull();
expect(dialogs).toContain('빙의에 성공했습니다.');
const event = await db.inputEvent.findFirstOrThrow({
where: { actorUserId: userId, eventType: 'npcPossessGeneral' },
});
expect(event).toMatchObject({ status: 'SUCCEEDED', attempts: 1 });
await page.screenshot({
path: testInfo.outputPath('npc-possession-live-success.png'),
fullPage: true,
});
});
});
@@ -29,6 +29,7 @@ export default defineConfig({
'commandArgumentsLive.spec.ts',
'mainNavigation.spec.ts',
'session-auth.spec.ts',
'npcPossession.spec.ts',
],
fullyParallel: false,
workers: 1,
@@ -10,5 +10,10 @@
"target": "ES2022",
"types": ["node", "@playwright/test"]
},
"include": ["./joinGeneralLive.spec.ts", "./joinGeneral.live.playwright.config.mjs"]
"include": [
"./joinGeneralLive.spec.ts",
"./joinGeneral.live.playwright.config.mjs",
"./npcPossessionLive.spec.ts",
"./npcPossession.live.playwright.config.mjs"
]
}
+2
View File
@@ -15,6 +15,8 @@
"test:e2e:auction": "playwright test auction.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:battle-simulator": "playwright test battleSimulator.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:join-live": "playwright test --config e2e/joinGeneral.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
"test:e2e:npc-possession": "playwright test npcPossession.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:npc-possession-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/npcPossession.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "node -e \"console.log('test not configured')\"",
+1
View File
@@ -14,6 +14,7 @@ interface ImportMetaEnv {
readonly VITE_GAME_ASSET_URL?: string;
readonly VITE_GAME_PROFILE?: string;
readonly VITE_GATEWAY_WEB_URL?: string;
readonly VITE_GATEWAY_USER_ICON_BASE_URL?: string;
readonly VITE_BOARD_COMMUNITY_URL?: string;
readonly VITE_BOARD_REQUEST_URL?: string;
readonly VITE_BOARD_TIP_URL?: string;
+628 -66
View File
@@ -1,15 +1,23 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { RouterLink, useRouter } from 'vue-router';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { trpc } from '../utils/trpc';
import { useSessionStore } from '../stores/session';
import { cityLevelMap, regionMap } from '../utils/nationFormat';
import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat';
import { getNpcColor } from '../utils/npcColor';
import { formatSeoulDateTime } from '../utils/legacyDateTime';
type JoinConfig = Awaited<ReturnType<typeof trpc.join.getConfig.query>>;
type JoinInput = Parameters<typeof trpc.join.createGeneral.mutate>[0];
type PossessCandidate = Awaited<ReturnType<typeof trpc.join.listPossessCandidates.query>>[0];
type PossessReservation = Awaited<ReturnType<typeof trpc.join.listPossessCandidates.mutate>>;
type PossessCandidate = PossessReservation['candidates'][number];
type NpcGeneralList = Awaited<ReturnType<typeof trpc.public.getNpcList.query>>;
type NpcGeneralRow = NpcGeneralList['generals'][number] & {
reservationState: 0 | 1 | 2;
keepCount: number | null;
};
type JoinForm = Omit<JoinInput, 'inheritBonusStat' | 'clientRequestId'> & {
inheritBonusStat: [number, number, number];
};
@@ -18,8 +26,15 @@ type PendingJoinAction = {
input: JoinForm;
clientRequestId: string;
};
type PendingPossessAction = {
ownerUserId: string;
generalId: number;
tokenNonce: number;
clientRequestId: string;
};
const router = useRouter();
const route = useRoute();
const session = useSessionStore();
const loading = ref(true);
@@ -29,6 +44,7 @@ const submitting = ref(false);
const joinConfig = ref<JoinConfig | null>(null);
const activeTab = ref<'create' | 'possess'>('create');
const pendingJoinStorageKey = 'sammo-join-create-pending-action';
const pendingPossessStorageKey = 'sammo-npc-possess-pending-action';
const form = ref<JoinForm>({
name: '',
@@ -83,17 +99,147 @@ const clearPendingJoin = (pending: PendingJoinAction): void => {
}
};
const readPendingPossess = (): PendingPossessAction | null => {
try {
const raw = window.sessionStorage.getItem(pendingPossessStorageKey);
if (!raw) return null;
const value = JSON.parse(raw) as Partial<PendingPossessAction>;
if (
typeof value.ownerUserId !== 'string' ||
typeof value.generalId !== 'number' ||
typeof value.tokenNonce !== 'number' ||
typeof value.clientRequestId !== 'string'
) {
return null;
}
return value as PendingPossessAction;
} catch {
return null;
}
};
const getPendingPossess = (generalId: number, tokenNonce: number): PendingPossessAction => {
const ownerUserId = joinConfig.value?.user.id ?? '';
const current = pendingPossessAction.value ?? readPendingPossess();
if (
current &&
current.ownerUserId === ownerUserId &&
current.generalId === generalId &&
current.tokenNonce === tokenNonce
) {
return current;
}
const pending: PendingPossessAction = {
ownerUserId,
generalId,
tokenNonce,
clientRequestId: crypto.randomUUID(),
};
window.sessionStorage.setItem(pendingPossessStorageKey, JSON.stringify(pending));
pendingPossessAction.value = pending;
return pending;
};
const clearPendingPossess = (pending: PendingPossessAction): void => {
if (readPendingPossess()?.clientRequestId === pending.clientRequestId) {
window.sessionStorage.removeItem(pendingPossessStorageKey);
}
if (pendingPossessAction.value?.clientRequestId === pending.clientRequestId) {
pendingPossessAction.value = null;
}
};
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 npcCandidates = ref<PossessCandidate[]>([]);
const isTrpcBusinessError = (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 && typeof data.code === 'string');
};
const npcImageUrl = (candidate: { picture: string | null; imageServer: number }): string => {
const picture = candidate.picture ?? 'default.jpg';
const userIconBaseUrl = import.meta.env.VITE_GATEWAY_USER_ICON_BASE_URL ?? '/gateway/api/user-icons';
return candidate.imageServer
? `${userIconBaseUrl.replace(/\/$/, '')}/${encodeURIComponent(picture)}`
: `/image/icons/${encodeURIComponent(picture)}`;
};
const useDefaultNpcImage = (event: Event): void => {
const image = event.currentTarget;
if (image instanceof HTMLImageElement && !image.src.endsWith('/image/icons/default.jpg')) {
image.src = '/image/icons/default.jpg';
}
};
const npcReservation = ref<PossessReservation | null>(null);
const npcLoading = ref(false);
const npcError = ref<string | null>(null);
const npcOffset = ref(0);
const npcLimit = 20;
const keptNpcIds = ref<number[]>([]);
const nowMs = ref(Date.now());
const npcPickMoreAvailableAtMs = ref(0);
const pendingPossessAction = ref<PendingPossessAction | null>(null);
const npcGeneralList = ref<NpcGeneralList | null>(null);
const npcGeneralListLoading = ref(false);
const npcGeneralListError = ref('');
const npcGeneralListVisibleCount = ref(50);
let npcTimer: number | null = null;
const npcCandidates = computed<PossessCandidate[]>(() => npcReservation.value?.candidates ?? []);
const npcValidUntilMs = computed(() => {
const value = npcReservation.value?.validUntil;
return value ? new Date(value).getTime() : 0;
});
const npcExpired = computed(() => npcValidUntilMs.value > 0 && npcValidUntilMs.value < nowMs.value);
const npcPickMoreSeconds = computed(() =>
Math.max(0, Math.ceil((npcPickMoreAvailableAtMs.value - nowMs.value) / 1000))
);
const hasPendingPossession = computed(
() => pendingPossessAction.value !== null && pendingPossessAction.value.ownerUserId === joinConfig.value?.user.id
);
const isPendingPossessCandidate = (candidate: PossessCandidate): boolean => {
const pending = pendingPossessAction.value;
return Boolean(
pending &&
pending.ownerUserId === joinConfig.value?.user.id &&
pending.generalId === candidate.id &&
pending.tokenNonce === npcReservation.value?.tokenNonce
);
};
const npcGeneralRows = computed<NpcGeneralRow[]>(() => {
const list = npcGeneralList.value;
if (!list) {
return [];
}
return list.generals
.map((general) => {
const keepCount = list.tokenKeepCounts[String(general.id)];
return {
...general,
reservationState: general.npcState < 2 ? 2 : keepCount !== undefined ? 1 : 0,
keepCount: keepCount ?? null,
} as NpcGeneralRow;
})
.sort(
(left, right) =>
right.reservationState - left.reservationState ||
right.statTotal - left.statTotal ||
// Ref select_npc.ts has this asymmetric comparator. Keep it because the visible order is contractual.
right.leadership - left.statTotal ||
(left.name < right.name ? -1 : left.name > right.name ? 1 : 0)
);
});
const visibleNpcGeneralRows = computed(() => npcGeneralRows.value.slice(0, npcGeneralListVisibleCount.value));
const npcValidColor = computed(() => {
const remaining = npcValidUntilMs.value - nowMs.value;
if (remaining > 30_000) return '#ffffff';
const channel = Math.max(0, Math.min(255, Math.round((remaining / 30_000) * 255)));
return `rgb(255, ${channel}, ${channel})`;
});
const statRules = computed(() => joinConfig.value?.rules.stat ?? null);
const statTotal = computed(() => form.value.leadership + form.value.strength + form.value.intel);
@@ -256,6 +402,11 @@ const loadConfig = async () => {
return;
}
joinConfig.value = config;
const storedPossession = readPendingPossess();
pendingPossessAction.value = storedPossession?.ownerUserId === config.user.id ? storedPossession : null;
if (route.query.tab === 'possess' && config.npcPossession.enabled && config.user.canCreateGeneral) {
activeTab.value = 'possess';
}
const pending = readPendingJoin();
if (pending?.ownerUserId === config.user.id) {
form.value = pending.input;
@@ -270,21 +421,31 @@ const loadConfig = async () => {
}
};
const loadNpcCandidates = async (reset = false) => {
const loadNpcCandidates = async (refresh = false) => {
npcLoading.value = true;
npcError.value = null;
try {
if (reset) {
npcOffset.value = 0;
}
const list = await trpc.join.listPossessCandidates.query({
limit: npcLimit,
offset: npcOffset.value,
const reservation = await trpc.join.listPossessCandidates.mutate({
refresh,
...(refresh ? { keepIds: keptNpcIds.value } : {}),
});
npcCandidates.value = reset ? list : [...npcCandidates.value, ...list];
npcOffset.value += list.length;
npcReservation.value = reservation;
keptNpcIds.value = [];
const receivedAt = Date.now();
nowMs.value = receivedAt;
npcPickMoreAvailableAtMs.value = receivedAt + reservation.pickMoreSeconds * 1000;
} catch (err) {
npcError.value = err instanceof Error ? err.message : 'npc_list_failed';
if (refresh) {
window.alert(npcError.value);
if (isTrpcBusinessError(err)) {
window.location.reload();
}
} else if (isTrpcBusinessError(err)) {
window.alert(npcError.value);
} else {
window.alert(`알 수 없는 에러: ${npcError.value}`);
}
} finally {
npcLoading.value = false;
}
@@ -317,34 +478,96 @@ const submitJoin = async () => {
}
};
const possessGeneral = async (generalId: number) => {
const submitPossession = async (pending: PendingPossessAction) => {
if (submitting.value) {
return;
}
submitting.value = true;
error.value = null;
try {
await trpc.join.possessGeneral.mutate({ generalId });
await trpc.join.possessGeneral.mutate({
generalId: pending.generalId,
tokenNonce: pending.tokenNonce,
clientRequestId: pending.clientRequestId,
});
clearPendingPossess(pending);
window.alert('빙의에 성공했습니다.');
await session.refreshGeneralStatus();
if (session.hasGeneral) {
await router.push({ name: 'home' });
}
} catch (err) {
if (!isIndeterminateTimeout(err)) {
clearPendingPossess(pending);
}
error.value = err instanceof Error ? err.message : 'possess_failed';
if (isTrpcBusinessError(err) && !isIndeterminateTimeout(err)) {
window.alert(error.value);
window.location.reload();
} else if (!isIndeterminateTimeout(err)) {
window.alert(`알 수 없는 에러: ${error.value}`);
}
} finally {
submitting.value = false;
}
};
const possessGeneral = async (candidate: PossessCandidate) => {
const reservation = npcReservation.value;
if (
submitting.value ||
!reservation ||
(hasPendingPossession.value && !isPendingPossessCandidate(candidate)) ||
!window.confirm(`빙의할까요? : ${candidate.name}`)
) {
return;
}
await submitPossession(getPendingPossess(candidate.id, reservation.tokenNonce));
};
const retryPendingPossession = async () => {
const pending = pendingPossessAction.value;
if (!pending || pending.ownerUserId !== joinConfig.value?.user.id) {
return;
}
await submitPossession(pending);
};
const loadNpcGeneralList = async () => {
if (npcGeneralListLoading.value) {
return;
}
npcGeneralListLoading.value = true;
npcGeneralListError.value = '';
try {
npcGeneralList.value = await trpc.public.getNpcList.query({ sort: 1, includeAllWithToken: true });
npcGeneralListVisibleCount.value = 50;
} catch (err) {
npcGeneralListError.value = err instanceof Error ? err.message : 'npc_general_list_failed';
window.alert(`실패했습니다: ${npcGeneralListError.value}`);
} finally {
npcGeneralListLoading.value = false;
}
};
watch(activeTab, (value) => {
if (value === 'possess' && npcCandidates.value.length === 0) {
void loadNpcCandidates(true);
if (value === 'possess' && !npcReservation.value) {
void loadNpcCandidates(false);
}
});
onMounted(() => {
npcTimer = window.setInterval(() => {
nowMs.value = Date.now();
}, 250);
void loadConfig();
});
onUnmounted(() => {
if (npcTimer !== null) {
window.clearInterval(npcTimer);
}
});
</script>
<template>
@@ -357,8 +580,21 @@ onMounted(() => {
<div class="join-tabs">
<RouterLink class="simulator-link" to="/past-plays"> 지난 플레이</RouterLink>
<RouterLink class="simulator-link" to="/battle-simulator">전투 시뮬레이터</RouterLink>
<button :class="{ active: activeTab === 'create' }" @click="activeTab = 'create'">장수 생성</button>
<button :class="{ active: activeTab === 'possess' }" @click="activeTab = 'possess'">NPC 빙의</button>
<button
:class="{ active: activeTab === 'create' }"
:disabled="joinConfig?.user.canCreateGeneral === false"
@click="activeTab = 'create'"
>
장수 생성
</button>
<button
v-if="joinConfig?.npcPossession.enabled"
:class="{ active: activeTab === 'possess' }"
:disabled="joinConfig?.user.canCreateGeneral === false"
@click="activeTab = 'possess'"
>
NPC 빙의
</button>
</div>
</header>
@@ -543,36 +779,231 @@ onMounted(() => {
</PanelCard>
</section>
<section v-else class="join-grid">
<PanelCard title="빙의 가능한 NPC 목록" subtitle="NPC 타입2 장수를 선택해 빙의합니다.">
<template #actions>
<button class="ghost" :disabled="npcLoading" @click="loadNpcCandidates(true)">목록 새로고침</button>
</template>
<section v-else class="npc-possession-section">
<PanelCard title="장수 빙의">
<div v-if="npcError" class="muted">{{ npcError }}</div>
<div v-if="npcLoading && npcCandidates.length === 0">
<SkeletonLines :lines="3" />
</div>
<div v-else-if="npcCandidates.length === 0" class="muted">빙의 가능한 NPC가 없습니다.</div>
<div v-else class="npc-list">
<div v-for="npc in npcCandidates" :key="npc.id" class="npc-card">
<div class="npc-header">
<div class="npc-name">{{ npc.name }}</div>
<div class="npc-nation" :style="{ color: npc.nation.color }">
{{ npc.nation.name }}
</div>
</div>
<div class="npc-meta">
<div>통솔 {{ npc.stats.leadership }}</div>
<div>무력 {{ npc.stats.strength }}</div>
<div>지력 {{ npc.stats.intelligence }}</div>
<div>나이 {{ npc.age }}</div>
<div>도시 {{ npc.city?.name ?? '-' }}</div>
</div>
<button class="npc-action" :disabled="submitting" @click="possessGeneral(npc.id)">빙의</button>
<template v-else>
<div class="npc-token-status">
<span v-if="!npcExpired">
(<span :style="{ color: npcValidColor }">{{
npcReservation?.validUntil ? formatSeoulDateTime(npcReservation.validUntil) : ''
}}</span
>까지 유효)
</span>
<span v-else class="npc-token-expired">- 만료 -</span>
</div>
</div>
<form class="npc-card-holder" @submit.prevent>
<div v-for="npc in npcCandidates" :key="npc.id" class="npc-card">
<h4 class="npc-card-name">{{ npc.name }}</h4>
<h4>
<img
class="npc-card-image"
:src="npcImageUrl(npc)"
:alt="`${npc.name} 얼굴`"
width="64"
height="64"
@error="useDefaultNpcImage"
/>
</h4>
<p>
{{ npc.stats.leadership }} / {{ npc.stats.strength }} / {{ npc.stats.intelligence
}}<br />
<span :style="{ color: npc.nation.color }">{{ npc.nation.name }}</span
><br />
<span class="npc-tooltip" tabindex="0">
{{ npc.personality.name }}
<span role="tooltip">{{ npc.personality.info }}</span>
</span>
<br />
<span class="npc-tooltip" tabindex="0">
{{ npc.specialDomestic.name }}
<span role="tooltip">{{ npc.specialDomestic.info }}</span>
</span>
/
<span class="npc-tooltip" tabindex="0">
{{ npc.specialWar.name }}
<span role="tooltip">{{ npc.specialWar.info }}</span>
</span>
</p>
<button
class="npc-action"
type="button"
:disabled="submitting || (hasPendingPossession && !isPendingPossessCandidate(npc))"
@click="possessGeneral(npc)"
>
빙의하기
</button>
<label class="npc-keep">
<input
v-model="keptNpcIds"
type="checkbox"
:value="npc.id"
:disabled="npc.keepCount <= 0"
/>
보관({{ npc.keepCount }})
</label>
</div>
</form>
</template>
<div class="npc-footer">
<button class="ghost" :disabled="npcLoading" @click="loadNpcCandidates()"> 보기</button>
<button
id="btn-pick-more"
class="ghost"
type="button"
:disabled="npcLoading || npcPickMoreSeconds > 0 || submitting || hasPendingPossession"
@click="loadNpcCandidates(true)"
>
다른 장수 보기<span v-if="npcPickMoreSeconds > 0">({{ npcPickMoreSeconds }})</span>
</button>
<button
v-if="hasPendingPossession"
id="btn-retry-possession"
class="ghost"
type="button"
:disabled="submitting"
@click="retryPendingPossession"
>
접수 결과 다시 확인
</button>
<button
id="btn-load-general-list"
class="ghost npc-list-link"
type="button"
:disabled="npcGeneralListLoading"
@click="loadNpcGeneralList"
>
{{ npcGeneralListLoading ? '불러오는 중...' : '장수 목록 보기' }}
</button>
</div>
<div v-if="npcGeneralListError" class="npc-general-list-error" role="alert">
{{ npcGeneralListError }}
</div>
<div v-if="npcGeneralList" class="npc-general-list-wrap">
<table id="tb-general-list" class="npc-general-table">
<colgroup>
<col style="width: 64px" />
<col style="width: 140px" />
<col style="width: 40px" />
<col style="width: 40px" />
<col style="width: 80px" />
<col style="width: 45px" />
<col style="width: 140px" />
<col style="width: 50px" />
<col style="width: 50px" />
<col style="width: 75px" />
<col style="width: 60px" />
<col style="width: 45px" />
<col style="width: 45px" />
<col style="width: 45px" />
<col style="width: 45px" />
</colgroup>
<thead>
<tr>
<th> </th>
<th> </th>
<th>연령</th>
<th>성격</th>
<th>특기</th>
<th> </th>
<th> </th>
<th> </th>
<th> </th>
<th> </th>
<th>종능</th>
<th>통솔</th>
<th>무력</th>
<th>지력</th>
<th>삭턴</th>
</tr>
</thead>
<tbody>
<tr
v-for="general in visibleNpcGeneralRows"
:key="general.id"
:data-general-id="general.id"
:data-reservation-state="general.reservationState"
>
<td>
<img
class="npc-general-icon"
:src="npcImageUrl(general)"
:alt="`${general.name} 얼굴`"
width="64"
height="64"
@error="useDefaultNpcImage"
/>
</td>
<td
class="npc-general-name"
:style="{
color:
general.reservationState === 1
? 'violet'
: general.npcState > 0
? getNpcColor(general.npcState)
: '',
}"
>
{{ general.name }}
<template v-if="general.ownerName">
<br /><small>({{ general.ownerName }})</small>
</template>
<template v-if="general.reservationState === 1">
<br /><small>({{ general.keepCount }})</small>
</template>
</td>
<td>{{ general.age }}</td>
<td>
<span v-if="general.personality" class="npc-tooltip" tabindex="0">
{{ general.personality.name }}
<span role="tooltip">{{ general.personality.info }}</span>
</span>
<span v-else>-</span>
</td>
<td>
<span v-if="general.specialDomestic" class="npc-tooltip" tabindex="0">
{{ general.specialDomestic.name }}
<span role="tooltip">{{ general.specialDomestic.info }}</span>
</span>
<span v-else>-</span>
/
<span v-if="general.specialWar" class="npc-tooltip" tabindex="0">
{{ general.specialWar.name }}
<span role="tooltip">{{ general.specialWar.info }}</span>
</span>
<span v-else>-</span>
</td>
<td>Lv {{ general.level }}</td>
<td>{{ general.nationName }}</td>
<td>{{ general.experienceText }}</td>
<td>{{ general.dedicationText }}</td>
<td>{{ formatOfficerLevelText(general.officerLevel, general.nationLevel) }}</td>
<td>{{ general.statTotal }}</td>
<td>{{ general.leadership }}</td>
<td>{{ general.strength }}</td>
<td>{{ general.intelligence }}</td>
<td>{{ general.killturn }}</td>
</tr>
</tbody>
<tfoot v-if="visibleNpcGeneralRows.length < npcGeneralRows.length">
<tr>
<td colspan="15">
<button
id="btn-print-more-generals"
type="button"
@click="npcGeneralListVisibleCount += 50"
>
보기
</button>
</td>
</tr>
</tfoot>
</table>
</div>
</PanelCard>
</section>
@@ -779,47 +1210,178 @@ onMounted(() => {
background: transparent;
}
.npc-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
.npc-possession-section {
width: 1000px;
align-self: center;
}
.npc-token-status {
min-height: 22px;
text-align: center;
font-size: 0.75rem;
}
.npc-token-expired {
color: red;
}
.npc-card-holder {
text-align: center;
white-space: nowrap;
}
.npc-card {
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 8px;
display: flex;
width: 125px;
display: inline-flex;
flex-direction: column;
gap: 8px;
vertical-align: top;
white-space: normal;
}
.npc-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 6px;
.npc-card h4,
.npc-card p {
margin: 0;
}
.npc-name {
font-weight: 600;
.npc-card-name {
min-height: 25px;
border: 1px solid rgba(201, 164, 90, 0.3);
font-size: 1rem;
line-height: 23px;
}
.npc-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
gap: 4px;
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.7);
.npc-card-image {
width: 64px;
height: 64px;
}
.npc-card p {
min-height: 78px;
font-size: 0.75rem;
line-height: 1.3;
}
.npc-tooltip {
position: relative;
cursor: help;
text-decoration: underline dotted;
}
.npc-tooltip [role='tooltip'] {
display: none;
position: absolute;
z-index: 10;
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;
word-break: keep-all;
}
.npc-tooltip:hover [role='tooltip'],
.npc-tooltip:focus [role='tooltip'] {
display: block;
}
.npc-action {
width: 100%;
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 4px 8px;
font-size: 0.75rem;
}
.npc-keep {
display: block;
padding-left: 15px;
text-indent: -15px;
font-size: 0.75rem;
}
.npc-keep input {
width: 13px;
height: 13px;
padding: 0;
margin: 0;
vertical-align: bottom;
position: relative;
top: -1px;
}
.npc-footer {
margin-top: 8px;
padding: 20px 0;
text-align: center;
display: flex;
justify-content: center;
gap: 2ch;
}
.npc-list-link {
display: inline-block;
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 4px 8px;
color: inherit;
text-decoration: none;
}
.npc-general-list-error {
margin-bottom: 8px;
color: rgba(240, 150, 150, 0.9);
text-align: center;
font-size: 0.75rem;
}
.npc-general-list-wrap {
width: 970px;
margin: 0 auto 20px;
overflow-x: auto;
}
.npc-general-table {
width: 970px;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
word-break: break-all;
}
.npc-general-table th,
.npc-general-table td {
border: 1px solid gray;
padding: 0;
text-align: center;
}
.npc-general-table th {
height: 24px;
background: rgba(201, 164, 90, 0.15);
font-weight: 600;
}
.npc-general-table tbody tr {
height: 65px;
}
.npc-general-icon {
display: block;
width: 64px;
height: 64px;
max-width: none;
}
.npc-general-name small {
font-size: 10px;
}
#btn-print-more-generals {
width: 100%;
min-height: 28px;
border: 0;
}
.muted {