feat: complete auction menu parity
This commit is contained in:
@@ -594,7 +594,7 @@ export const auctionRouter = router({
|
|||||||
auctionId: auction.id,
|
auctionId: auction.id,
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
amount: input.amount,
|
amount: input.amount,
|
||||||
tryExtendCloseDate: input.tryExtendCloseDate ?? true,
|
tryExtendCloseDate: input.tryExtendCloseDate ?? false,
|
||||||
});
|
});
|
||||||
if (!result || result.type !== 'auctionBid') {
|
if (!result || result.type !== 'auctionBid') {
|
||||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
|
import type { GamePrisma, RedisConnector } from '@sammo-ts/infra';
|
||||||
|
|
||||||
|
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||||
|
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||||
|
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
|
||||||
|
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
||||||
|
import { appRouter } from '../src/router.js';
|
||||||
|
|
||||||
|
const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
|
||||||
|
id: 7,
|
||||||
|
userId: 'user-1',
|
||||||
|
name: '유비',
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
npcState: 0,
|
||||||
|
affinity: null,
|
||||||
|
bornYear: 180,
|
||||||
|
deadYear: 300,
|
||||||
|
picture: null,
|
||||||
|
imageServer: 0,
|
||||||
|
leadership: 50,
|
||||||
|
strength: 50,
|
||||||
|
intel: 50,
|
||||||
|
injury: 0,
|
||||||
|
experience: 0,
|
||||||
|
dedication: 0,
|
||||||
|
officerLevel: 1,
|
||||||
|
gold: 10_000,
|
||||||
|
rice: 10_000,
|
||||||
|
crew: 0,
|
||||||
|
crewTypeId: 0,
|
||||||
|
train: 0,
|
||||||
|
atmos: 0,
|
||||||
|
weaponCode: 'None',
|
||||||
|
bookCode: 'None',
|
||||||
|
horseCode: 'None',
|
||||||
|
itemCode: 'None',
|
||||||
|
turnTime: new Date('2026-07-26T00:00:00Z'),
|
||||||
|
recentWarTime: null,
|
||||||
|
age: 20,
|
||||||
|
startAge: 20,
|
||||||
|
personalCode: 'None',
|
||||||
|
specialCode: 'None',
|
||||||
|
special2Code: 'None',
|
||||||
|
lastTurn: {},
|
||||||
|
meta: {},
|
||||||
|
penalty: {},
|
||||||
|
createdAt: new Date('2026-07-26T00:00:00Z'),
|
||||||
|
updatedAt: new Date('2026-07-26T00:00:00Z'),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildAuth = (userId = 'user-1'): GameSessionTokenPayload => ({
|
||||||
|
version: 1,
|
||||||
|
profile: 'che:default',
|
||||||
|
issuedAt: '2026-07-26T00:00:00.000Z',
|
||||||
|
expiresAt: '2026-07-27T00:00:00.000Z',
|
||||||
|
sessionId: `session-${userId}`,
|
||||||
|
user: {
|
||||||
|
id: userId,
|
||||||
|
username: userId,
|
||||||
|
displayName: userId,
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
sanctions: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sqlText = (query: GamePrisma.Sql): string => query.strings.join(' ');
|
||||||
|
|
||||||
|
const buildContext = (options: {
|
||||||
|
auth?: GameSessionTokenPayload | null;
|
||||||
|
general?: GeneralRow | null;
|
||||||
|
auctions?: Array<Record<string, unknown>>;
|
||||||
|
queryRaw?: (query: GamePrisma.Sql) => Promise<unknown>;
|
||||||
|
}) => {
|
||||||
|
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
||||||
|
const general = options.general === undefined ? buildGeneral() : options.general;
|
||||||
|
const requestCommand = vi.fn(async (command: { type: string }) => {
|
||||||
|
if (command.type === 'auctionOpen') {
|
||||||
|
return {
|
||||||
|
type: 'auctionOpen' as const,
|
||||||
|
ok: true as const,
|
||||||
|
auctionId: 91,
|
||||||
|
closeAt: '2026-07-27T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'auctionBid' as const,
|
||||||
|
ok: true as const,
|
||||||
|
auctionId: 91,
|
||||||
|
closeAt: '2026-07-27T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const queryRaw = vi.fn(options.queryRaw ?? (async () => []));
|
||||||
|
const worldState = {
|
||||||
|
id: 1,
|
||||||
|
scenarioCode: 'default',
|
||||||
|
currentYear: 200,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 3600,
|
||||||
|
config: {
|
||||||
|
const: {
|
||||||
|
auctionName: ['청룡', '백호', '주작', '현무'],
|
||||||
|
allItems: { weapon: { che_무기_12_칠성검: 1 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
meta: { hiddenSeed: 'auction-hidden-seed' },
|
||||||
|
updatedAt: new Date('2026-07-26T00:00:00Z'),
|
||||||
|
};
|
||||||
|
const db = {
|
||||||
|
$queryRaw: queryRaw,
|
||||||
|
general: {
|
||||||
|
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
|
||||||
|
general?.userId === where.userId ? general : null
|
||||||
|
),
|
||||||
|
findMany: vi.fn(async ({ where }: { where: { id: { in: number[] } } }) =>
|
||||||
|
where.id.in.map((id) => ({ id, name: id === 88 ? '관우' : '조조' }))
|
||||||
|
),
|
||||||
|
},
|
||||||
|
auction: {
|
||||||
|
findMany: vi.fn(async () => options.auctions ?? []),
|
||||||
|
findFirst: vi.fn(async () => null),
|
||||||
|
},
|
||||||
|
worldState: {
|
||||||
|
findFirst: vi.fn(async () => worldState),
|
||||||
|
},
|
||||||
|
inheritancePoint: {
|
||||||
|
findUnique: vi.fn(async () => ({ value: 10_000 })),
|
||||||
|
},
|
||||||
|
logEntry: {
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const redis = {
|
||||||
|
zAdd: vi.fn(async () => 1),
|
||||||
|
};
|
||||||
|
const accessTokenStore = new RedisAccessTokenStore(
|
||||||
|
{
|
||||||
|
get: async () => null,
|
||||||
|
set: async () => null,
|
||||||
|
},
|
||||||
|
'che:default'
|
||||||
|
);
|
||||||
|
const context: GameApiContext = {
|
||||||
|
db: db as unknown as DatabaseClient,
|
||||||
|
redis: redis as unknown as RedisConnector['client'],
|
||||||
|
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
|
||||||
|
battleSim: {} as GameApiContext['battleSim'],
|
||||||
|
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||||
|
auth,
|
||||||
|
uploadDir: 'uploads',
|
||||||
|
uploadPath: '/uploads',
|
||||||
|
uploadPublicUrl: null,
|
||||||
|
accessTokenStore,
|
||||||
|
flushStore: new InMemoryFlushStore(),
|
||||||
|
gameTokenSecret: 'test-secret',
|
||||||
|
};
|
||||||
|
return { context, db, queryRaw, redis, requestCommand };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('auction router actor and permission boundaries', () => {
|
||||||
|
it('rejects unauthenticated auction reads', async () => {
|
||||||
|
const fixture = buildContext({ auth: null });
|
||||||
|
|
||||||
|
await expect(appRouter.createCaller(fixture.context).auction.getOverview()).rejects.toMatchObject({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects reads and mutations when the authenticated user owns no general', async () => {
|
||||||
|
const fixture = buildContext({
|
||||||
|
auth: buildAuth('user-2'),
|
||||||
|
general: buildGeneral({ userId: 'user-1' }),
|
||||||
|
});
|
||||||
|
const caller = appRouter.createCaller(fixture.context);
|
||||||
|
|
||||||
|
await expect(caller.auction.getOverview()).rejects.toMatchObject({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
message: 'General not found.',
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
caller.auction.openBuyRice({
|
||||||
|
amount: 1000,
|
||||||
|
closeTurnCnt: 3,
|
||||||
|
startBidAmount: 500,
|
||||||
|
finishBidAmount: 2000,
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
message: 'General not found.',
|
||||||
|
});
|
||||||
|
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives the daemon actor from the session-owned general and ignores a forged generalId field', async () => {
|
||||||
|
const fixture = buildContext({ general: buildGeneral({ id: 7, userId: 'user-1' }) });
|
||||||
|
const input = {
|
||||||
|
amount: 1000,
|
||||||
|
closeTurnCnt: 3,
|
||||||
|
startBidAmount: 500,
|
||||||
|
finishBidAmount: 2000,
|
||||||
|
generalId: 999,
|
||||||
|
};
|
||||||
|
|
||||||
|
await appRouter.createCaller(fixture.context).auction.openBuyRice(input);
|
||||||
|
|
||||||
|
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||||
|
type: 'auctionOpen',
|
||||||
|
auctionType: 'BUY_RICE',
|
||||||
|
generalId: 7,
|
||||||
|
amount: 1000,
|
||||||
|
closeTurnCnt: 3,
|
||||||
|
startBidAmount: 500,
|
||||||
|
finishBidAmount: 2000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts real unique-auction identities while preserving caller markers', async () => {
|
||||||
|
const openedAt = new Date('2026-07-26T01:00:00Z');
|
||||||
|
const fixture = buildContext({
|
||||||
|
auctions: [
|
||||||
|
{
|
||||||
|
id: 31,
|
||||||
|
type: 'UNIQUE_ITEM',
|
||||||
|
targetCode: 'che_무기_12_칠성검',
|
||||||
|
hostGeneralId: 7,
|
||||||
|
hostName: null,
|
||||||
|
detail: { title: '칠성검 경매', startBidAmount: 5000 },
|
||||||
|
status: 'OPEN',
|
||||||
|
closeAt: new Date('2026-07-27T00:00:00Z'),
|
||||||
|
bids: [
|
||||||
|
{
|
||||||
|
id: 41,
|
||||||
|
generalId: 88,
|
||||||
|
amount: 5500,
|
||||||
|
eventAt: openedAt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await appRouter.createCaller(fixture.context).auction.getOverview();
|
||||||
|
const unique = result.uniqueAuctions[0];
|
||||||
|
|
||||||
|
expect(unique).toMatchObject({
|
||||||
|
id: 31,
|
||||||
|
hostGeneralId: null,
|
||||||
|
isCallerHost: true,
|
||||||
|
highestBid: { amount: 5500, isCaller: false },
|
||||||
|
});
|
||||||
|
expect(unique?.hostName).not.toBe('유비');
|
||||||
|
expect(unique?.highestBid?.bidderName).not.toBe('관우');
|
||||||
|
expect(JSON.stringify(unique)).not.toContain('"generalId"');
|
||||||
|
expect(JSON.stringify(unique)).not.toContain('"hostGeneralId":7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the legacy default of no requested close extension for a unique bid', async () => {
|
||||||
|
const fixture = buildContext({
|
||||||
|
queryRaw: async (query) => {
|
||||||
|
const text = sqlText(query);
|
||||||
|
if (text.includes('FROM auction') && text.includes('WHERE id =')) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 31,
|
||||||
|
type: 'UNIQUE_ITEM',
|
||||||
|
targetCode: 'che_무기_12_칠성검',
|
||||||
|
hostGeneralId: 88,
|
||||||
|
detail: { startBidAmount: 100, isReverse: false },
|
||||||
|
status: 'OPEN',
|
||||||
|
closeAt: new Date('2026-07-27T00:00:00Z'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (text.includes('FROM auction_bid') && text.includes('general_id =')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (text.includes('SELECT bid.auction_id')) {
|
||||||
|
return [{ auctionId: 31, generalId: 88, amount: 100 }];
|
||||||
|
}
|
||||||
|
if (text.includes('FROM auction_bid')) {
|
||||||
|
return [{ id: 41, generalId: 88, amount: 100, meta: {} }];
|
||||||
|
}
|
||||||
|
if (text.includes('SELECT id, target_code')) {
|
||||||
|
return [{ id: 31, targetCode: 'che_무기_12_칠성검' }];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await appRouter.createCaller(fixture.context).auction.bidUnique({
|
||||||
|
auctionId: 31,
|
||||||
|
amount: 110,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||||
|
type: 'auctionBid',
|
||||||
|
auctionId: 31,
|
||||||
|
generalId: 7,
|
||||||
|
amount: 110,
|
||||||
|
tryExtendCloseDate: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||||
|
const imageRoots = [resolve(repositoryRoot, '../image/game'), resolve(repositoryRoot, '../../image/game')];
|
||||||
|
|
||||||
|
type AuctionFixture = {
|
||||||
|
failResourceBid?: boolean;
|
||||||
|
resourceBidCount: number;
|
||||||
|
uniqueBidCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
|
const errorResponse = (path: string, message: string) => ({
|
||||||
|
error: {
|
||||||
|
message,
|
||||||
|
code: -32000,
|
||||||
|
data: { code: 'BAD_REQUEST', httpStatus: 400, path },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const operationNames = (route: Route): string[] => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||||
|
};
|
||||||
|
|
||||||
|
const readReferenceImage = async (filename: string): Promise<Buffer> => {
|
||||||
|
for (const imageRoot of imageRoots) {
|
||||||
|
try {
|
||||||
|
return await readFile(resolve(imageRoot, filename));
|
||||||
|
} catch {
|
||||||
|
// The main checkout and nested feature worktrees have different parents.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Reference image not found: ${filename}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const overview = {
|
||||||
|
resourceAuctions: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'BUY_RICE',
|
||||||
|
targetCode: '1000',
|
||||||
|
status: 'OPEN',
|
||||||
|
hostGeneralId: 11,
|
||||||
|
hostName: '조조',
|
||||||
|
isCallerHost: false,
|
||||||
|
closeAt: '2026-07-27T02:30:00.000Z',
|
||||||
|
detail: {
|
||||||
|
title: '쌀 1000 경매',
|
||||||
|
amount: 1000,
|
||||||
|
isReverse: false,
|
||||||
|
startBidAmount: 500,
|
||||||
|
finishBidAmount: 1800,
|
||||||
|
},
|
||||||
|
highestBid: {
|
||||||
|
amount: 750,
|
||||||
|
bidderName: '관우',
|
||||||
|
isCaller: false,
|
||||||
|
eventAt: '2026-07-26T01:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
type: 'SELL_RICE',
|
||||||
|
targetCode: '900',
|
||||||
|
status: 'OPEN',
|
||||||
|
hostGeneralId: 7,
|
||||||
|
hostName: '유비',
|
||||||
|
isCallerHost: true,
|
||||||
|
closeAt: '2026-07-27T03:00:00.000Z',
|
||||||
|
detail: {
|
||||||
|
title: '금 900 경매',
|
||||||
|
amount: 900,
|
||||||
|
isReverse: false,
|
||||||
|
startBidAmount: 600,
|
||||||
|
finishBidAmount: 1700,
|
||||||
|
},
|
||||||
|
highestBid: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
uniqueAuctions: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
type: 'UNIQUE_ITEM',
|
||||||
|
targetCode: 'che_무기_12_칠성검',
|
||||||
|
status: 'OPEN',
|
||||||
|
hostGeneralId: null,
|
||||||
|
hostName: '청룡',
|
||||||
|
isCallerHost: false,
|
||||||
|
closeAt: '2026-07-27T04:00:00.000Z',
|
||||||
|
detail: {
|
||||||
|
title: '칠성검 경매',
|
||||||
|
startBidAmount: 5000,
|
||||||
|
remainCloseDateExtensionCnt: 1,
|
||||||
|
availableLatestBidCloseDate: '2026-07-27T04:30:00.000Z',
|
||||||
|
},
|
||||||
|
highestBid: {
|
||||||
|
amount: 5500,
|
||||||
|
bidderName: '백호',
|
||||||
|
isCaller: false,
|
||||||
|
eventAt: '2026-07-26T02:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
type: 'UNIQUE_ITEM',
|
||||||
|
targetCode: 'che_서적_15_손자병법',
|
||||||
|
status: 'FINISHED',
|
||||||
|
hostGeneralId: null,
|
||||||
|
hostName: '현무',
|
||||||
|
isCallerHost: true,
|
||||||
|
closeAt: '2026-07-25T04:00:00.000Z',
|
||||||
|
detail: {
|
||||||
|
title: '손자병법 경매',
|
||||||
|
startBidAmount: 5000,
|
||||||
|
remainCloseDateExtensionCnt: 0,
|
||||||
|
availableLatestBidCloseDate: '2026-07-25T04:30:00.000Z',
|
||||||
|
},
|
||||||
|
highestBid: {
|
||||||
|
amount: 6000,
|
||||||
|
bidderName: '현무',
|
||||||
|
isCaller: true,
|
||||||
|
eventAt: '2026-07-25T03:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
callerAlias: '현무',
|
||||||
|
remainPoint: 9000,
|
||||||
|
recentLogs: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
text: '<C>●</>경매 1번 거래가 성사되었습니다.',
|
||||||
|
createdAt: '2026-07-25T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const uniqueDetail = {
|
||||||
|
auction: {
|
||||||
|
id: 10,
|
||||||
|
targetCode: 'che_무기_12_칠성검',
|
||||||
|
status: 'OPEN',
|
||||||
|
hostName: '청룡',
|
||||||
|
isCallerHost: false,
|
||||||
|
closeAt: '2026-07-27T04:00:00.000Z',
|
||||||
|
detail: {
|
||||||
|
title: '칠성검 경매',
|
||||||
|
startBidAmount: 5000,
|
||||||
|
remainCloseDateExtensionCnt: 1,
|
||||||
|
availableLatestBidCloseDate: '2026-07-27T04:30:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bids: [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
amount: 5500,
|
||||||
|
bidderName: '백호',
|
||||||
|
isCaller: false,
|
||||||
|
eventAt: '2026-07-26T02:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 100,
|
||||||
|
amount: 5000,
|
||||||
|
bidderName: '현무',
|
||||||
|
isCaller: true,
|
||||||
|
eventAt: '2026-07-26T01:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
callerAlias: '현무',
|
||||||
|
remainPoint: 9000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const installFixture = async (page: Page, state: AuctionFixture) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.localStorage.setItem('sammo-game-token', 'ga_auction_playwright');
|
||||||
|
window.localStorage.setItem('sammo-game-profile', 'che:default');
|
||||||
|
});
|
||||||
|
for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) {
|
||||||
|
await page.route(`**/image/game/${filename}`, async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
body: await readReferenceImage(filename),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await page.route('**/che/api/trpc/**', async (route) => {
|
||||||
|
const results = operationNames(route).map((operation) => {
|
||||||
|
if (operation === 'lobby.info') {
|
||||||
|
return response({ myGeneral: { id: 7, name: '유비' } });
|
||||||
|
}
|
||||||
|
if (operation === 'join.getConfig') {
|
||||||
|
return response({});
|
||||||
|
}
|
||||||
|
if (operation === 'auction.getOverview') {
|
||||||
|
return response(overview);
|
||||||
|
}
|
||||||
|
if (operation === 'auction.getUniqueDetail') {
|
||||||
|
return response(uniqueDetail);
|
||||||
|
}
|
||||||
|
if (operation === 'auction.bidBuyRice') {
|
||||||
|
if (state.failResourceBid) {
|
||||||
|
state.failResourceBid = false;
|
||||||
|
return errorResponse(operation, '금이 부족합니다.');
|
||||||
|
}
|
||||||
|
state.resourceBidCount += 1;
|
||||||
|
return response({ ok: true });
|
||||||
|
}
|
||||||
|
if (operation === 'auction.bidUnique') {
|
||||||
|
state.uniqueBidCount += 1;
|
||||||
|
return response({ ok: true });
|
||||||
|
}
|
||||||
|
if (operation === 'auction.openBuyRice' || operation === 'auction.openSellRice') {
|
||||||
|
return response({ auctionId: 20, closeAt: '2026-07-28T00:00:00.000Z' });
|
||||||
|
}
|
||||||
|
return errorResponse(operation, `Unhandled fixture operation: ${operation}`);
|
||||||
|
});
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(results),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const gotoAuction = async (page: Page, suffix = 'auction') => {
|
||||||
|
const lobbyResponse = page.waitForResponse((response) => response.url().includes('/trpc/lobby.info'));
|
||||||
|
await page.goto(suffix);
|
||||||
|
await lobbyResponse;
|
||||||
|
await expect(page.locator('#container')).toBeVisible();
|
||||||
|
};
|
||||||
|
|
||||||
|
test('resource auction preserves the legacy desktop structure, geometry, and interaction states', async ({ page }) => {
|
||||||
|
const state = { failResourceBid: true, resourceBidCount: 0, uniqueBidCount: 0 };
|
||||||
|
await installFixture(page, state);
|
||||||
|
await page.setViewportSize({ width: 1000, height: 800 });
|
||||||
|
await gotoAuction(page);
|
||||||
|
await expect(page.getByRole('heading', { name: '경매장', exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText('쌀 구매', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText('쌀 판매', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText('단가', { exact: true }).first()).toBeVisible();
|
||||||
|
|
||||||
|
const geometry = await page.locator('#container').evaluate((container) => {
|
||||||
|
const box = (selector: string) => {
|
||||||
|
const rect = container.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
|
||||||
|
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||||
|
};
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const row = container.querySelector<HTMLElement>('.resource-row')!;
|
||||||
|
const rowRect = row.getBoundingClientRect();
|
||||||
|
const cells = [...row.children].map((cell) => cell.getBoundingClientRect().width);
|
||||||
|
const button = container.querySelector<HTMLElement>('.tab-button')!;
|
||||||
|
const buttonStyle = getComputedStyle(button);
|
||||||
|
return {
|
||||||
|
container: { x: containerRect.x, width: containerRect.width },
|
||||||
|
topBar: box('.top-back-bar'),
|
||||||
|
row: { width: rowRect.width, height: rowRect.height },
|
||||||
|
cells,
|
||||||
|
button: {
|
||||||
|
height: button.getBoundingClientRect().height,
|
||||||
|
borderRadius: buttonStyle.borderRadius,
|
||||||
|
cursor: buttonStyle.cursor,
|
||||||
|
fontSize: buttonStyle.fontSize,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.container).toEqual({ x: 0, width: 1000 });
|
||||||
|
expect(geometry.topBar).toMatchObject({ x: 0, width: 1000, height: 32 });
|
||||||
|
expect(geometry.row).toEqual({ width: 1000, height: 22 });
|
||||||
|
expect(geometry.cells[0]).toBeCloseTo(66.66, 1);
|
||||||
|
expect(geometry.cells[1]).toBeCloseTo(133.34, 1);
|
||||||
|
expect(geometry.cells[6]).toBeCloseTo(200, 1);
|
||||||
|
expect(geometry.button).toEqual({
|
||||||
|
height: 35.5,
|
||||||
|
borderRadius: '5.25px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
});
|
||||||
|
await page.screenshot({ path: 'test-results/auction/resource-desktop-initial.png', fullPage: true });
|
||||||
|
|
||||||
|
const firstRow = page.locator('.resource-row.clickable-row').first();
|
||||||
|
await firstRow.click();
|
||||||
|
const bidInput = page.getByRole('spinbutton', { name: '1번 경매 입찰가' });
|
||||||
|
await bidInput.fill('800');
|
||||||
|
await page.getByRole('button', { name: '입찰', exact: true }).click();
|
||||||
|
await expect(page.getByRole('alert')).toContainText('금이 부족합니다.');
|
||||||
|
await page.screenshot({ path: 'test-results/auction/resource-desktop-error.png', fullPage: true });
|
||||||
|
expect(state.resourceBidCount).toBe(0);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '입찰', exact: true }).click();
|
||||||
|
await expect(page.getByRole('status')).toContainText('입찰했습니다.');
|
||||||
|
expect(state.resourceBidCount).toBe(1);
|
||||||
|
|
||||||
|
await firstRow.hover();
|
||||||
|
expect(await firstRow.evaluate((row) => getComputedStyle(row).cursor)).toBe('pointer');
|
||||||
|
await page.screenshot({ path: 'test-results/auction/resource-desktop.png', fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resource auction keeps the legacy 500px two-row grid', async ({ page }) => {
|
||||||
|
await installFixture(page, { resourceBidCount: 0, uniqueBidCount: 0 });
|
||||||
|
await page.setViewportSize({ width: 500, height: 800 });
|
||||||
|
await gotoAuction(page);
|
||||||
|
|
||||||
|
const geometry = await page
|
||||||
|
.locator('.resource-row')
|
||||||
|
.first()
|
||||||
|
.evaluate((row) => {
|
||||||
|
const origin = row.getBoundingClientRect();
|
||||||
|
const relative = (selector: string) => {
|
||||||
|
const rect = row.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
|
||||||
|
return { x: rect.x - origin.x, y: rect.y - origin.y, width: rect.width, height: rect.height };
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
row: { width: origin.width, height: origin.height },
|
||||||
|
idx: relative('.idx'),
|
||||||
|
host: relative('.host'),
|
||||||
|
amount: relative('.amount'),
|
||||||
|
close: relative('.close-date'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.row).toEqual({ width: 500, height: 43 });
|
||||||
|
expect(geometry.idx).toEqual({ x: 0, y: 10.5, width: 41.65625, height: 21 });
|
||||||
|
expect(geometry.host).toEqual({ x: 41.65625, y: 0, width: 125, height: 21 });
|
||||||
|
expect(geometry.amount).toEqual({ x: 41.65625, y: 21, width: 125, height: 21 });
|
||||||
|
expect(geometry.close).toEqual({ x: 416.65625, y: 10.5, width: 83.34375, height: 21 });
|
||||||
|
await page.screenshot({ path: 'test-results/auction/resource-mobile.png', fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unique auction separates ongoing and finished lists and auto-loads the legacy detail', async ({ page }) => {
|
||||||
|
const state = { resourceBidCount: 0, uniqueBidCount: 0 };
|
||||||
|
await installFixture(page, state);
|
||||||
|
await page.setViewportSize({ width: 1000, height: 800 });
|
||||||
|
await gotoAuction(page, 'auction?type=unique');
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: '유니크 경매장', exact: true })).toBeVisible();
|
||||||
|
await expect(page.locator('.caller-alias')).toContainText('내 가명: 현무');
|
||||||
|
await expect(page.getByRole('heading', { name: '경매 10번 상세' })).toBeVisible();
|
||||||
|
await expect(page.getByText('최대지연', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: '진행중인 경매 목록' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: '종료된 경매 목록' })).toBeVisible();
|
||||||
|
await expect(page.getByText('남음', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText('소진', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
const aliasStyle = await page.locator('.caller-alias strong').evaluate((element) => {
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return { color: style.color, fontWeight: style.fontWeight };
|
||||||
|
});
|
||||||
|
expect(aliasStyle).toEqual({ color: 'rgb(0, 255, 255)', fontWeight: '700' });
|
||||||
|
|
||||||
|
const input = page.getByRole('spinbutton', { name: '유산포인트' });
|
||||||
|
await input.fill('5600');
|
||||||
|
page.once('dialog', async (dialog) => {
|
||||||
|
expect(dialog.message()).toBe('칠성검 경매에 5600유산포인트를 입찰하시겠습니까?');
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
await page.getByRole('button', { name: '입찰', exact: true }).click();
|
||||||
|
await expect(page.getByRole('status')).toContainText('입찰이 완료되었습니다.');
|
||||||
|
expect(state.uniqueBidCount).toBe(1);
|
||||||
|
await page.screenshot({ path: 'test-results/auction/unique-desktop.png', fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resource host cannot bid on the auction opened by its own general', async ({ page }) => {
|
||||||
|
await installFixture(page, { resourceBidCount: 0, uniqueBidCount: 0 });
|
||||||
|
await gotoAuction(page);
|
||||||
|
|
||||||
|
await page.locator('.resource-row.clickable-row').filter({ hasText: '유비' }).click();
|
||||||
|
await expect(page.getByRole('button', { name: '입찰', exact: true })).toBeDisabled();
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ const baseURL = `http://127.0.0.1:${port}/che/`;
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: '.',
|
testDir: '.',
|
||||||
testMatch: ['troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', 'nationOffices.spec.ts'],
|
testMatch: ['troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', 'nationOffices.spec.ts', 'auction.spec.ts'],
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"test:e2e:troop": "playwright test troop.spec.ts --config e2e/playwright.config.mjs",
|
"test:e2e:troop": "playwright test troop.spec.ts --config e2e/playwright.config.mjs",
|
||||||
"test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs",
|
"test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs",
|
||||||
"test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs",
|
"test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs",
|
||||||
|
"test:e2e:auction": "playwright test auction.spec.ts --config e2e/playwright.config.mjs",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"test": "node -e \"console.log('test not configured')\"",
|
"test": "node -e \"console.log('test not configured')\"",
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue';
|
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
import PanelCard from '../components/ui/PanelCard.vue';
|
|
||||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
|
||||||
import { formatLog } from '../utils/formatLog';
|
import { formatLog } from '../utils/formatLog';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
@@ -11,7 +10,8 @@ type ResourceAuction = AuctionOverview['resourceAuctions'][number];
|
|||||||
type UniqueAuction = AuctionOverview['uniqueAuctions'][number];
|
type UniqueAuction = AuctionOverview['uniqueAuctions'][number];
|
||||||
type UniqueDetail = Awaited<ReturnType<typeof trpc.auction.getUniqueDetail.query>>;
|
type UniqueDetail = Awaited<ReturnType<typeof trpc.auction.getUniqueDetail.query>>;
|
||||||
|
|
||||||
const activeTab = ref<'resource' | 'unique'>('resource');
|
const route = useRoute();
|
||||||
|
const activeTab = ref<'resource' | 'unique'>(route.query.type === 'unique' ? 'unique' : 'resource');
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const actionBusy = ref(false);
|
const actionBusy = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
@@ -38,22 +38,57 @@ const resolveErrorMessage = (value: unknown): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatNumber = (value: number | null | undefined): string => (value ?? 0).toLocaleString();
|
const formatNumber = (value: number | null | undefined): string => (value ?? 0).toLocaleString();
|
||||||
const formatDate = (value: string): string =>
|
const cutDateTime = (value: string | null | undefined, showSecond = false): string => {
|
||||||
new Intl.DateTimeFormat('ko-KR', {
|
if (!value) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value.slice(5, showSecond ? 19 : 16);
|
||||||
|
}
|
||||||
|
const parts = new Intl.DateTimeFormat('ko-KR', {
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
second: '2-digit',
|
...(showSecond ? { second: '2-digit' } : {}),
|
||||||
}).format(new Date(value));
|
hour12: false,
|
||||||
|
}).formatToParts(date);
|
||||||
|
const part = (type: Intl.DateTimeFormatPartTypes): string =>
|
||||||
|
parts.find((entry) => entry.type === type)?.value ?? '';
|
||||||
|
return `${part('month')}-${part('day')} ${part('hour')}:${part('minute')}${showSecond ? `:${part('second')}` : ''}`;
|
||||||
|
};
|
||||||
|
|
||||||
const resourceTitle = (auction: ResourceAuction): string =>
|
const buyRice = computed(() =>
|
||||||
auction.type === 'BUY_RICE' ? '쌀 구매' : '쌀 판매';
|
(overview.value?.resourceAuctions ?? []).filter((auction) => auction.type === 'BUY_RICE')
|
||||||
const hostResource = (auction: ResourceAuction): string => (auction.type === 'BUY_RICE' ? '쌀' : '금');
|
);
|
||||||
const bidResource = (auction: ResourceAuction): string => (auction.type === 'BUY_RICE' ? '금' : '쌀');
|
const sellRice = computed(() =>
|
||||||
|
(overview.value?.resourceAuctions ?? []).filter((auction) => auction.type === 'SELL_RICE')
|
||||||
|
);
|
||||||
|
const ongoingUnique = computed(() =>
|
||||||
|
(overview.value?.uniqueAuctions ?? []).filter((auction) => auction.status === 'OPEN')
|
||||||
|
);
|
||||||
|
const finishedUnique = computed(() =>
|
||||||
|
(overview.value?.uniqueAuctions ?? []).filter((auction) => auction.status !== 'OPEN')
|
||||||
|
);
|
||||||
|
|
||||||
const resourceAuctions = computed(() => overview.value?.resourceAuctions ?? []);
|
const selectResource = (auction: ResourceAuction): void => {
|
||||||
const uniqueAuctions = computed(() => overview.value?.uniqueAuctions ?? []);
|
selectedResource.value = auction;
|
||||||
|
bidAmount.value = auction.highestBid?.amount ?? auction.detail.startBidAmount ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectUnique = async (auction: UniqueAuction): Promise<void> => {
|
||||||
|
selectedUnique.value = auction;
|
||||||
|
uniqueDetail.value = null;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
uniqueDetail.value = await trpc.auction.getUniqueDetail.query({ auctionId: auction.id });
|
||||||
|
const highest = uniqueDetail.value.bids[0]?.amount ?? auction.detail.startBidAmount ?? 0;
|
||||||
|
bidAmount.value = Math.max(Math.ceil(highest * 1.01), highest + 10);
|
||||||
|
} catch (err) {
|
||||||
|
error.value = resolveErrorMessage(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadOverview = async (): Promise<void> => {
|
const loadOverview = async (): Promise<void> => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -65,8 +100,14 @@ const loadOverview = async (): Promise<void> => {
|
|||||||
overview.value.resourceAuctions.find((auction) => auction.id === selectedResource.value?.id) ?? null;
|
overview.value.resourceAuctions.find((auction) => auction.id === selectedResource.value?.id) ?? null;
|
||||||
}
|
}
|
||||||
if (selectedUnique.value) {
|
if (selectedUnique.value) {
|
||||||
selectedUnique.value =
|
const updated =
|
||||||
overview.value.uniqueAuctions.find((auction) => auction.id === selectedUnique.value?.id) ?? null;
|
overview.value.uniqueAuctions.find((auction) => auction.id === selectedUnique.value?.id) ?? null;
|
||||||
|
selectedUnique.value = updated;
|
||||||
|
if (updated) {
|
||||||
|
await selectUnique(updated);
|
||||||
|
}
|
||||||
|
} else if (activeTab.value === 'unique' && ongoingUnique.value[0]) {
|
||||||
|
await selectUnique(ongoingUnique.value[0]);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = resolveErrorMessage(err);
|
||||||
@@ -75,23 +116,6 @@ const loadOverview = async (): Promise<void> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectResource = (auction: ResourceAuction): void => {
|
|
||||||
selectedResource.value = auction;
|
|
||||||
bidAmount.value = auction.highestBid?.amount ?? auction.detail.startBidAmount ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectUnique = async (auction: UniqueAuction): Promise<void> => {
|
|
||||||
selectedUnique.value = auction;
|
|
||||||
error.value = null;
|
|
||||||
try {
|
|
||||||
uniqueDetail.value = await trpc.auction.getUniqueDetail.query({ auctionId: auction.id });
|
|
||||||
const highest = uniqueDetail.value.bids[0]?.amount ?? auction.detail.startBidAmount ?? 0;
|
|
||||||
bidAmount.value = Math.max(Math.ceil(highest * 1.01), highest + 10);
|
|
||||||
} catch (err) {
|
|
||||||
error.value = resolveErrorMessage(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const runAction = async (action: () => Promise<void>): Promise<void> => {
|
const runAction = async (action: () => Promise<void>): Promise<void> => {
|
||||||
if (actionBusy.value) {
|
if (actionBusy.value) {
|
||||||
return;
|
return;
|
||||||
@@ -138,7 +162,7 @@ const bidResourceAuction = (): Promise<void> =>
|
|||||||
} else {
|
} else {
|
||||||
await trpc.auction.bidSellRice.mutate({ auctionId: auction.id, amount: bidAmount.value });
|
await trpc.auction.bidSellRice.mutate({ auctionId: auction.id, amount: bidAmount.value });
|
||||||
}
|
}
|
||||||
message.value = `${auction.id}번 경매에 입찰했습니다.`;
|
message.value = '입찰했습니다.';
|
||||||
});
|
});
|
||||||
|
|
||||||
const bidUniqueAuction = (): Promise<void> =>
|
const bidUniqueAuction = (): Promise<void> =>
|
||||||
@@ -147,206 +171,669 @@ const bidUniqueAuction = (): Promise<void> =>
|
|||||||
if (!auction) {
|
if (!auction) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!window.confirm(`${auction.detail.title ?? auction.targetCode ?? '유니크'}에 ${bidAmount.value} 포인트를 입찰하시겠습니까?`)) {
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`${auction.detail.title ?? auction.targetCode ?? '유니크'}에 ${bidAmount.value}유산포인트를 입찰하시겠습니까?`
|
||||||
|
)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await trpc.auction.bidUnique.mutate({
|
await trpc.auction.bidUnique.mutate({
|
||||||
auctionId: auction.id,
|
auctionId: auction.id,
|
||||||
amount: bidAmount.value,
|
amount: bidAmount.value,
|
||||||
tryExtendCloseDate: true,
|
tryExtendCloseDate: false,
|
||||||
});
|
});
|
||||||
message.value = `${auction.id}번 유니크 경매에 입찰했습니다.`;
|
message.value = '입찰이 완료되었습니다.';
|
||||||
await selectUnique(auction);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const closeWindow = (): void => window.close();
|
||||||
|
|
||||||
|
watch(activeTab, (tab) => {
|
||||||
|
error.value = null;
|
||||||
|
message.value = null;
|
||||||
|
if (tab === 'unique' && !selectedUnique.value && ongoingUnique.value[0]) {
|
||||||
|
void selectUnique(ongoingUnique.value[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadOverview();
|
void loadOverview();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="auction-page">
|
<main id="container" class="legacy-auction-page bg0">
|
||||||
<header class="page-header">
|
<header class="top-back-bar bg0">
|
||||||
<div>
|
<button class="legacy-button close-button" type="button" @click="closeWindow">창 닫기</button>
|
||||||
<h1>거래장</h1>
|
<button class="legacy-button reload-button" type="button" :disabled="loading" @click="loadOverview">
|
||||||
<p>금·쌀 거래와 유니크 아이템 경매를 확인합니다.</p>
|
갱신
|
||||||
</div>
|
</button>
|
||||||
<button class="ghost" :disabled="loading" @click="loadOverview">새로고침</button>
|
<h1>{{ activeTab === 'resource' ? '경매장' : '유니크 경매장' }}</h1>
|
||||||
|
<button
|
||||||
|
class="legacy-button tab-button"
|
||||||
|
:aria-pressed="activeTab === 'resource'"
|
||||||
|
@click="activeTab = 'resource'"
|
||||||
|
>
|
||||||
|
금/쌀
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="legacy-button tab-button"
|
||||||
|
:aria-pressed="activeTab === 'unique'"
|
||||||
|
@click="activeTab = 'unique'"
|
||||||
|
>
|
||||||
|
유니크
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="tabs" aria-label="경매 종류">
|
<p v-if="error" class="auction-notice error" role="alert">{{ error }}</p>
|
||||||
<button :class="{ active: activeTab === 'resource' }" @click="activeTab = 'resource'">금·쌀 경매</button>
|
<p v-if="message" class="auction-notice success" role="status">{{ message }}</p>
|
||||||
<button :class="{ active: activeTab === 'unique' }" @click="activeTab = 'unique'">유니크 경매</button>
|
<div v-if="loading && !overview" class="loading-state">불러오는 중...</div>
|
||||||
</nav>
|
|
||||||
|
|
||||||
<p v-if="error" class="notice error">{{ error }}</p>
|
<section v-else-if="activeTab === 'resource'" class="resource-auction bg0">
|
||||||
<p v-if="message" class="notice success">{{ message }}</p>
|
<h2 class="section-title bg2">거래장</h2>
|
||||||
<SkeletonLines v-if="loading && !overview" :lines="8" />
|
|
||||||
|
|
||||||
<template v-else-if="activeTab === 'resource'">
|
<section class="resource-section" aria-labelledby="buy-rice-heading">
|
||||||
<PanelCard title="진행 중인 금·쌀 경매" subtitle="행을 선택하면 아래에서 입찰할 수 있습니다.">
|
<h3 id="buy-rice-heading" class="resource-kind buy-rice">쌀 구매</h3>
|
||||||
<div class="auction-table resource-table">
|
<div class="resource-row resource-header">
|
||||||
<div class="table-head">
|
<span class="idx">번호</span><span class="host">판매자</span><span class="amount">수량</span>
|
||||||
<span>번호</span><span>종류</span><span>판매자</span><span>수량</span><span>입찰자</span>
|
<span class="highest-bidder">입찰자</span><span class="highest-bid">입찰가</span>
|
||||||
<span>현재가</span><span>마감가</span><span>종료</span>
|
<span class="bid-ratio">단가</span><span class="finish-bid">마감가</span>
|
||||||
</div>
|
<span class="close-date">거래 종료</span>
|
||||||
<button
|
|
||||||
v-for="auction in resourceAuctions"
|
|
||||||
:key="auction.id"
|
|
||||||
class="table-row"
|
|
||||||
:class="{ selected: selectedResource?.id === auction.id }"
|
|
||||||
@click="selectResource(auction)"
|
|
||||||
>
|
|
||||||
<span>{{ auction.id }}</span>
|
|
||||||
<span>{{ resourceTitle(auction) }}</span>
|
|
||||||
<span>{{ auction.hostName }}</span>
|
|
||||||
<span>{{ hostResource(auction) }} {{ formatNumber(auction.detail.amount) }}</span>
|
|
||||||
<span>{{ auction.highestBid?.bidderName ?? '-' }}</span>
|
|
||||||
<span>{{ bidResource(auction) }} {{ formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount) }}</span>
|
|
||||||
<span>{{ bidResource(auction) }} {{ formatNumber(auction.detail.finishBidAmount) }}</span>
|
|
||||||
<span>{{ formatDate(auction.closeAt) }}</span>
|
|
||||||
</button>
|
|
||||||
<p v-if="resourceAuctions.length === 0" class="empty">진행 중인 경매가 없습니다.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
v-for="auction in buyRice"
|
||||||
|
:key="auction.id"
|
||||||
|
class="resource-row clickable-row"
|
||||||
|
:class="{ selected: selectedResource?.id === auction.id }"
|
||||||
|
@click="selectResource(auction)"
|
||||||
|
>
|
||||||
|
<span class="idx tnum">{{ auction.id }}</span>
|
||||||
|
<span class="host">{{ auction.hostName }}</span>
|
||||||
|
<span class="amount tnum">쌀 {{ formatNumber(auction.detail.amount) }}</span>
|
||||||
|
<span class="highest-bidder">{{ auction.highestBid?.bidderName ?? '-' }}</span>
|
||||||
|
<span class="highest-bid tnum" :class="{ 'no-bid': !auction.highestBid }">
|
||||||
|
금 {{ formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount) }}
|
||||||
|
</span>
|
||||||
|
<span class="bid-ratio tnum">
|
||||||
|
{{
|
||||||
|
auction.highestBid && auction.detail.amount
|
||||||
|
? (auction.highestBid.amount / auction.detail.amount).toFixed(2)
|
||||||
|
: '-'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span class="finish-bid tnum">금 {{ formatNumber(auction.detail.finishBidAmount) }}</span>
|
||||||
|
<span class="close-date tnum">{{ cutDateTime(auction.closeAt) }}</span>
|
||||||
|
</button>
|
||||||
|
<p v-if="buyRice.length === 0" class="empty-row">진행 중인 쌀 구매 경매가 없습니다.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<form v-if="selectedResource" class="bid-form" @submit.prevent="bidResourceAuction">
|
<section class="resource-section" aria-labelledby="sell-rice-heading">
|
||||||
<strong>{{ selectedResource.id }}번 {{ resourceTitle(selectedResource) }}</strong>
|
<h3 id="sell-rice-heading" class="resource-kind sell-rice">쌀 판매</h3>
|
||||||
<label>
|
<div class="resource-row resource-header">
|
||||||
<span>입찰가 ({{ bidResource(selectedResource) }})</span>
|
<span class="idx">번호</span><span class="host">판매자</span><span class="amount">수량</span>
|
||||||
<input v-model.number="bidAmount" type="number" min="1" step="10" required />
|
<span class="highest-bidder">입찰자</span><span class="highest-bid">입찰가</span>
|
||||||
</label>
|
<span class="bid-ratio">단가</span><span class="finish-bid">마감가</span>
|
||||||
<button :disabled="actionBusy || selectedResource.isCallerHost">입찰</button>
|
<span class="close-date">거래 종료</span>
|
||||||
</form>
|
|
||||||
</PanelCard>
|
|
||||||
|
|
||||||
<PanelCard title="경매 등록" subtitle="레거시와 동일하게 한 장수는 자원 경매를 한 건만 진행할 수 있습니다.">
|
|
||||||
<form class="open-form" @submit.prevent="openResourceAuction">
|
|
||||||
<label>
|
|
||||||
<span>매물</span>
|
|
||||||
<select v-model="openForm.type">
|
|
||||||
<option value="BUY_RICE">쌀</option>
|
|
||||||
<option value="SELL_RICE">금</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label><span>수량</span><input v-model.number="openForm.amount" type="number" min="100" max="10000" step="10" /></label>
|
|
||||||
<label><span>기간(턴)</span><input v-model.number="openForm.closeTurnCnt" type="number" min="1" max="24" /></label>
|
|
||||||
<label><span>시작가</span><input v-model.number="openForm.startBidAmount" type="number" min="1" step="10" /></label>
|
|
||||||
<label><span>마감가</span><input v-model.number="openForm.finishBidAmount" type="number" min="1" step="10" /></label>
|
|
||||||
<button :disabled="actionBusy">등록</button>
|
|
||||||
</form>
|
|
||||||
</PanelCard>
|
|
||||||
|
|
||||||
<PanelCard title="이전 경매" subtitle="최근 경매 기록 20건">
|
|
||||||
<ol class="log-list">
|
|
||||||
<!-- eslint-disable vue/no-v-html -->
|
|
||||||
<li v-for="log in overview?.recentLogs ?? []" :key="log.id" v-html="formatLog(log.text)" />
|
|
||||||
<!-- eslint-enable vue/no-v-html -->
|
|
||||||
<li v-if="(overview?.recentLogs.length ?? 0) === 0" class="empty">경매 기록이 없습니다.</li>
|
|
||||||
</ol>
|
|
||||||
</PanelCard>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<PanelCard title="유니크 경매" :subtitle="`내 가명: ${overview?.callerAlias ?? '-'}`">
|
|
||||||
<div class="auction-table unique-table">
|
|
||||||
<div class="table-head">
|
|
||||||
<span>번호</span><span>경매명</span><span>주최자</span><span>종료</span><span>1순위</span><span>포인트</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
v-for="auction in uniqueAuctions"
|
|
||||||
:key="auction.id"
|
|
||||||
class="table-row"
|
|
||||||
:class="{ selected: selectedUnique?.id === auction.id }"
|
|
||||||
@click="selectUnique(auction)"
|
|
||||||
>
|
|
||||||
<span>{{ auction.id }}</span>
|
|
||||||
<span>{{ auction.detail.title ?? auction.targetCode }}</span>
|
|
||||||
<span :class="{ me: auction.isCallerHost }">{{ auction.hostName }}</span>
|
|
||||||
<span>{{ formatDate(auction.closeAt) }}</span>
|
|
||||||
<span :class="{ me: auction.highestBid?.isCaller }">{{ auction.highestBid?.bidderName ?? '-' }}</span>
|
|
||||||
<span>{{ formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount) }}</span>
|
|
||||||
</button>
|
|
||||||
<p v-if="uniqueAuctions.length === 0" class="empty">유니크 경매가 없습니다.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</PanelCard>
|
<button
|
||||||
|
v-for="auction in sellRice"
|
||||||
|
:key="auction.id"
|
||||||
|
class="resource-row clickable-row"
|
||||||
|
:class="{ selected: selectedResource?.id === auction.id }"
|
||||||
|
@click="selectResource(auction)"
|
||||||
|
>
|
||||||
|
<span class="idx tnum">{{ auction.id }}</span>
|
||||||
|
<span class="host">{{ auction.hostName }}</span>
|
||||||
|
<span class="amount tnum">금 {{ formatNumber(auction.detail.amount) }}</span>
|
||||||
|
<span class="highest-bidder">{{ auction.highestBid?.bidderName ?? '-' }}</span>
|
||||||
|
<span class="highest-bid tnum" :class="{ 'no-bid': !auction.highestBid }">
|
||||||
|
쌀 {{ formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount) }}
|
||||||
|
</span>
|
||||||
|
<span class="bid-ratio tnum">
|
||||||
|
{{
|
||||||
|
auction.highestBid && auction.detail.amount
|
||||||
|
? (auction.highestBid.amount / auction.detail.amount).toFixed(2)
|
||||||
|
: '-'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span class="finish-bid tnum">쌀 {{ formatNumber(auction.detail.finishBidAmount) }}</span>
|
||||||
|
<span class="close-date tnum">{{ cutDateTime(auction.closeAt) }}</span>
|
||||||
|
</button>
|
||||||
|
<p v-if="sellRice.length === 0" class="empty-row">진행 중인 쌀 판매 경매가 없습니다.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<PanelCard v-if="uniqueDetail" title="유니크 경매 상세">
|
<form v-if="selectedResource" class="resource-bid-form" @submit.prevent="bidResourceAuction">
|
||||||
|
<span class="bid-description">
|
||||||
|
{{ selectedResource.id }}번 {{ selectedResource.type === 'BUY_RICE' ? '쌀' : '금' }}
|
||||||
|
{{ formatNumber(selectedResource.detail.amount) }} 경매에
|
||||||
|
{{ selectedResource.type === 'BUY_RICE' ? '금' : '쌀' }}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
v-model.number="bidAmount"
|
||||||
|
:aria-label="`${selectedResource.id}번 경매 입찰가`"
|
||||||
|
type="number"
|
||||||
|
:min="selectedResource.detail.startBidAmount ?? 1"
|
||||||
|
:max="selectedResource.detail.finishBidAmount ?? undefined"
|
||||||
|
step="10"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button class="legacy-button" :disabled="actionBusy || selectedResource.isCallerHost">입찰</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h3 class="subsection-title">경매 등록</h3>
|
||||||
|
<form class="open-form" @submit.prevent="openResourceAuction">
|
||||||
|
<fieldset>
|
||||||
|
<legend>매물</legend>
|
||||||
|
<div class="item-toggle">
|
||||||
|
<button
|
||||||
|
class="legacy-button"
|
||||||
|
type="button"
|
||||||
|
:aria-pressed="openForm.type === 'BUY_RICE'"
|
||||||
|
@click="openForm.type = 'BUY_RICE'"
|
||||||
|
>
|
||||||
|
쌀
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="legacy-button"
|
||||||
|
type="button"
|
||||||
|
:aria-pressed="openForm.type === 'SELL_RICE'"
|
||||||
|
@click="openForm.type = 'SELL_RICE'"
|
||||||
|
>
|
||||||
|
금
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<label>
|
||||||
|
<span>수량 ({{ openForm.type === 'BUY_RICE' ? '쌀' : '금' }})</span>
|
||||||
|
<input v-model.number="openForm.amount" type="number" min="100" max="10000" step="10" />
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
><span>기간(턴)</span><input v-model.number="openForm.closeTurnCnt" type="number" min="3" max="24"
|
||||||
|
/></label>
|
||||||
|
<label>
|
||||||
|
<span>시작가 ({{ openForm.type === 'BUY_RICE' ? '금' : '쌀' }})</span>
|
||||||
|
<input v-model.number="openForm.startBidAmount" type="number" min="100" max="10000" step="10" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>마감가 ({{ openForm.type === 'BUY_RICE' ? '금' : '쌀' }})</span>
|
||||||
|
<input v-model.number="openForm.finishBidAmount" type="number" min="100" max="10000" step="10" />
|
||||||
|
</label>
|
||||||
|
<button class="legacy-button register-button" :disabled="actionBusy">등록</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h3 class="subsection-title">이전 경매(최근 20건)</h3>
|
||||||
|
<div class="recent-logs">
|
||||||
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
|
<div v-for="log in overview?.recentLogs ?? []" :key="log.id" v-html="formatLog(log.text)" />
|
||||||
|
<!-- eslint-enable vue/no-v-html -->
|
||||||
|
<div v-if="(overview?.recentLogs.length ?? 0) === 0" class="empty-row">경매 기록이 없습니다.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else class="unique-auction bg0">
|
||||||
|
<div class="caller-alias">
|
||||||
|
내 가명: <strong>{{ overview?.callerAlias ?? '-' }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-if="uniqueDetail" class="unique-detail">
|
||||||
|
<h2 class="section-title bg2">경매 {{ uniqueDetail.auction.id }}번 상세</h2>
|
||||||
<dl class="detail-grid">
|
<dl class="detail-grid">
|
||||||
<dt>경매명</dt><dd>{{ uniqueDetail.auction.detail.title ?? uniqueDetail.auction.targetCode }}</dd>
|
<dt class="bg1">경매명</dt>
|
||||||
<dt>주최자(익명)</dt><dd :class="{ me: uniqueDetail.auction.isCallerHost }">{{ uniqueDetail.auction.hostName }}</dd>
|
<dd>{{ uniqueDetail.auction.detail.title ?? uniqueDetail.auction.targetCode }}</dd>
|
||||||
<dt>종료일시</dt><dd>{{ formatDate(uniqueDetail.auction.closeAt) }}</dd>
|
<dt class="bg1">주최자(익명)</dt>
|
||||||
<dt>잔여 포인트</dt><dd>{{ formatNumber(uniqueDetail.remainPoint) }}</dd>
|
<dd :class="{ 'is-me': uniqueDetail.auction.isCallerHost }">{{ uniqueDetail.auction.hostName }}</dd>
|
||||||
|
<dt class="bg1">종료일시</dt>
|
||||||
|
<dd class="tnum">{{ cutDateTime(uniqueDetail.auction.closeAt, true) }}</dd>
|
||||||
|
<dt class="bg1">최대지연</dt>
|
||||||
|
<dd class="tnum">
|
||||||
|
{{ cutDateTime(uniqueDetail.auction.detail.availableLatestBidCloseDate, true) }}
|
||||||
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<div class="bid-history">
|
<h3 class="subsection-title bg1">입찰자 목록</h3>
|
||||||
<div v-for="bid in uniqueDetail.bids" :key="bid.id" class="bid-entry">
|
<div class="bid-row bid-header"><span>입찰자</span><span>입찰포인트</span><span>시각</span></div>
|
||||||
<span :class="{ me: bid.isCaller }">{{ bid.bidderName }}</span>
|
<div v-for="bid in uniqueDetail.bids" :key="bid.id" class="bid-row">
|
||||||
<strong>{{ formatNumber(bid.amount) }}</strong>
|
<span :class="{ 'is-me': bid.isCaller }">{{ bid.bidderName }}</span>
|
||||||
<time>{{ formatDate(bid.eventAt) }}</time>
|
<span class="tnum">{{ formatNumber(bid.amount) }}</span>
|
||||||
</div>
|
<time class="tnum">{{ cutDateTime(bid.eventAt) }}</time>
|
||||||
</div>
|
</div>
|
||||||
<form v-if="uniqueDetail.auction.status === 'OPEN'" class="bid-form" @submit.prevent="bidUniqueAuction">
|
<template v-if="uniqueDetail.auction.status === 'OPEN'">
|
||||||
<label><span>유산 포인트</span><input v-model.number="bidAmount" type="number" min="1" required /></label>
|
<h3 class="subsection-title bg1">입찰하기</h3>
|
||||||
<button :disabled="actionBusy">입찰</button>
|
<form class="unique-bid-form" @submit.prevent="bidUniqueAuction">
|
||||||
</form>
|
<label for="unique-bid">
|
||||||
</PanelCard>
|
유산포인트 (잔여: {{ formatNumber(uniqueDetail.remainPoint) }}포인트)
|
||||||
</template>
|
</label>
|
||||||
|
<input id="unique-bid" v-model.number="bidAmount" type="number" min="1" required />
|
||||||
|
<button class="legacy-button" :disabled="actionBusy">입찰</button>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="unique-list-section">
|
||||||
|
<h2 class="subsection-title bg1">진행중인 경매 목록</h2>
|
||||||
|
<div class="unique-row unique-header">
|
||||||
|
<span>번호</span><span>경매명</span><span>주최자</span><span>종료일시</span> <span>연장</span
|
||||||
|
><span>1순위</span><span>포인트</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-for="auction in ongoingUnique"
|
||||||
|
:key="auction.id"
|
||||||
|
class="unique-row clickable-row"
|
||||||
|
:class="{ selected: selectedUnique?.id === auction.id }"
|
||||||
|
@click="selectUnique(auction)"
|
||||||
|
>
|
||||||
|
<span>{{ auction.id }}</span
|
||||||
|
><span>{{ auction.detail.title ?? auction.targetCode }}</span>
|
||||||
|
<span :class="{ 'is-me': auction.isCallerHost }">{{ auction.hostName }}</span>
|
||||||
|
<span class="tnum">{{ cutDateTime(auction.closeAt) }}</span>
|
||||||
|
<span>{{ (auction.detail.remainCloseDateExtensionCnt ?? 0) > 0 ? '남음' : '소진' }}</span>
|
||||||
|
<span :class="{ 'is-me': auction.highestBid?.isCaller }">{{
|
||||||
|
auction.highestBid?.bidderName ?? '-'
|
||||||
|
}}</span>
|
||||||
|
<span class="tnum">{{
|
||||||
|
formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount)
|
||||||
|
}}</span>
|
||||||
|
</button>
|
||||||
|
<p v-if="ongoingUnique.length === 0" class="empty-row">진행중인 유니크 경매가 없습니다.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="unique-list-section">
|
||||||
|
<h2 class="subsection-title bg1">종료된 경매 목록</h2>
|
||||||
|
<div class="unique-row unique-header">
|
||||||
|
<span>번호</span><span>경매명</span><span>주최자</span><span>종료일시</span> <span>연장</span
|
||||||
|
><span>1순위</span><span>포인트</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-for="auction in finishedUnique"
|
||||||
|
:key="auction.id"
|
||||||
|
class="unique-row clickable-row"
|
||||||
|
:class="{ selected: selectedUnique?.id === auction.id }"
|
||||||
|
@click="selectUnique(auction)"
|
||||||
|
>
|
||||||
|
<span>{{ auction.id }}</span
|
||||||
|
><span>{{ auction.detail.title ?? auction.targetCode }}</span>
|
||||||
|
<span :class="{ 'is-me': auction.isCallerHost }">{{ auction.hostName }}</span>
|
||||||
|
<span class="tnum">{{ cutDateTime(auction.closeAt) }}</span>
|
||||||
|
<span>{{ (auction.detail.remainCloseDateExtensionCnt ?? 0) > 0 ? '남음' : '소진' }}</span>
|
||||||
|
<span :class="{ 'is-me': auction.highestBid?.isCaller }">{{
|
||||||
|
auction.highestBid?.bidderName ?? '-'
|
||||||
|
}}</span>
|
||||||
|
<span class="tnum">{{
|
||||||
|
formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount)
|
||||||
|
}}</span>
|
||||||
|
</button>
|
||||||
|
<p v-if="finishedUnique.length === 0" class="empty-row">종료된 유니크 경매가 없습니다.</p>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="bottom-bar bg0">
|
||||||
|
<button class="legacy-button close-button" type="button" @click="closeWindow">창 닫기</button>
|
||||||
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.auction-page {
|
.legacy-auction-page {
|
||||||
min-height: 100%;
|
width: 100%;
|
||||||
padding: 18px;
|
max-width: 1000px;
|
||||||
color: #e8ddc4;
|
box-sizing: border-box;
|
||||||
background: radial-gradient(circle at top, rgba(93, 57, 26, 0.25), transparent 42%), #080807;
|
margin: 0 auto;
|
||||||
|
color: #fff;
|
||||||
|
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 21px;
|
||||||
}
|
}
|
||||||
.page-header, .tabs, .bid-form, .open-form, .detail-grid, .bid-entry {
|
.bg0 {
|
||||||
display: flex;
|
background-color: #302016;
|
||||||
|
background-image: url('/image/game/back_walnut.jpg');
|
||||||
|
}
|
||||||
|
.bg1 {
|
||||||
|
background-color: #14241b;
|
||||||
|
background-image: url('/image/game/back_green.jpg');
|
||||||
|
}
|
||||||
|
.bg2 {
|
||||||
|
background-color: #172a52;
|
||||||
|
background-image: url('/image/game/back_blue.jpg');
|
||||||
|
}
|
||||||
|
.top-back-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 32px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 90px 90px 1fr 90px 90px;
|
||||||
|
}
|
||||||
|
.top-back-bar h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.legacy-button {
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: solid #3d3d3d;
|
||||||
|
border-width: 0 1px 4px;
|
||||||
|
border-radius: 5.25px;
|
||||||
|
padding: 5.25px 10.5px;
|
||||||
|
color: #fff;
|
||||||
|
background: #444;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 21px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.legacy-button:hover,
|
||||||
|
.legacy-button:focus {
|
||||||
|
border-color: #353535;
|
||||||
|
background: #393939;
|
||||||
|
}
|
||||||
|
.legacy-button:focus-visible {
|
||||||
|
outline: 2px solid #8ab4f8;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
.legacy-button:active,
|
||||||
|
.legacy-button[aria-pressed='true'] {
|
||||||
|
border-color: #303030;
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
|
.legacy-button:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
.close-button,
|
||||||
|
.reload-button {
|
||||||
|
margin-right: 2px;
|
||||||
|
border-color: #004f28;
|
||||||
|
background: #00582c;
|
||||||
|
}
|
||||||
|
.close-button:hover,
|
||||||
|
.close-button:focus,
|
||||||
|
.reload-button:hover,
|
||||||
|
.reload-button:focus {
|
||||||
|
border-color: #004523;
|
||||||
|
background: #004a25;
|
||||||
|
}
|
||||||
|
.top-back-bar .close-button,
|
||||||
|
.top-back-bar .reload-button {
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
.tab-button {
|
||||||
|
border-color: #3d3d3d;
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
.tab-button[aria-pressed='true'] {
|
||||||
|
border-color: #3d3d3d;
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
.tab-button:hover,
|
||||||
|
.tab-button:focus {
|
||||||
|
border-color: #353535;
|
||||||
|
background: #393939;
|
||||||
|
}
|
||||||
|
.section-title,
|
||||||
|
.subsection-title,
|
||||||
|
.resource-kind {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 18px;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.resource-kind.buy-rice {
|
||||||
|
color: #000;
|
||||||
|
background: orange;
|
||||||
|
}
|
||||||
|
.resource-kind.sell-rice {
|
||||||
|
color: #000;
|
||||||
|
background: skyblue;
|
||||||
|
}
|
||||||
|
.resource-row {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 22px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr 2fr 2fr 2fr 1fr 3fr 2fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid gray;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
.page-header { justify-content: space-between; gap: 16px; margin-bottom: 12px; }
|
.clickable-row {
|
||||||
.page-header h1 { margin: 0; font-size: 1.45rem; }
|
cursor: pointer;
|
||||||
.page-header p { margin: 4px 0 0; color: rgba(232, 221, 196, 0.7); }
|
|
||||||
.tabs { gap: 6px; margin-bottom: 12px; }
|
|
||||||
button, input, select {
|
|
||||||
border: 1px solid rgba(201, 164, 90, 0.55);
|
|
||||||
background: rgba(20, 17, 12, 0.95);
|
|
||||||
color: #e8ddc4;
|
|
||||||
padding: 8px 10px;
|
|
||||||
}
|
}
|
||||||
button { cursor: pointer; }
|
.clickable-row:hover,
|
||||||
button:hover, button:focus-visible, button.active { background: rgba(201, 164, 90, 0.22); }
|
.clickable-row:focus-visible,
|
||||||
button:disabled { cursor: not-allowed; opacity: 0.45; }
|
.clickable-row.selected {
|
||||||
.ghost { background: transparent; }
|
background-color: rgb(255 255 255 / 12%);
|
||||||
.notice { padding: 9px 12px; border: 1px solid; }
|
outline: 0;
|
||||||
.notice.error { color: #ffb3a9; border-color: rgba(255, 90, 70, 0.45); }
|
}
|
||||||
.notice.success { color: #b9e6af; border-color: rgba(94, 177, 75, 0.45); }
|
.no-bid {
|
||||||
.auction-page :deep(.panel-card) { margin-bottom: 12px; }
|
color: #ccc;
|
||||||
.auction-table { overflow-x: auto; }
|
}
|
||||||
.table-head, .table-row { display: grid; min-width: 820px; align-items: center; text-align: center; }
|
.tnum {
|
||||||
.resource-table .table-head, .resource-table .table-row { grid-template-columns: 52px 84px 1fr 1fr 1fr 1fr 1fr 150px; }
|
font-variant-numeric: tabular-nums;
|
||||||
.unique-table .table-head, .unique-table .table-row { grid-template-columns: 52px 2fr 1fr 150px 1fr 110px; }
|
}
|
||||||
.table-head { border-bottom: 1px solid rgba(232, 221, 196, 0.4); padding: 7px; color: rgba(232, 221, 196, 0.7); }
|
.empty-row {
|
||||||
.table-row { width: 100%; border: 0; border-bottom: 1px solid rgba(232, 221, 196, 0.12); background: transparent; }
|
min-height: 24px;
|
||||||
.table-row.selected { background: rgba(201, 164, 90, 0.18); }
|
margin: 0;
|
||||||
.table-row > span { padding: 8px 5px; }
|
padding: 4px 8px;
|
||||||
.bid-form { justify-content: center; gap: 12px; margin-top: 14px; flex-wrap: wrap; }
|
text-align: center;
|
||||||
.bid-form label, .open-form label { display: grid; gap: 5px; }
|
}
|
||||||
.open-form { align-items: end; gap: 10px; flex-wrap: wrap; }
|
.resource-bid-form {
|
||||||
.open-form label { min-width: 110px; flex: 1; }
|
min-height: 42px;
|
||||||
.empty { padding: 14px; text-align: center; color: rgba(232, 221, 196, 0.6); }
|
display: grid;
|
||||||
.log-list { margin: 0; padding-left: 24px; }
|
grid-template-columns: 2fr 2fr 1fr;
|
||||||
.log-list li { padding: 4px 0; }
|
align-items: center;
|
||||||
.detail-grid { display: grid; grid-template-columns: 130px 1fr 130px 1fr; gap: 1px; background: rgba(232, 221, 196, 0.18); }
|
gap: 4px;
|
||||||
.detail-grid dt, .detail-grid dd { margin: 0; padding: 9px; background: #11100d; }
|
padding-right: 33.3333%;
|
||||||
.detail-grid dt { color: rgba(232, 221, 196, 0.65); }
|
padding-left: 25%;
|
||||||
.bid-history { margin-top: 12px; }
|
}
|
||||||
.bid-entry { justify-content: space-between; gap: 12px; padding: 7px 10px; border-bottom: 1px solid rgba(232, 221, 196, 0.14); }
|
.bid-description {
|
||||||
.bid-entry time { color: rgba(232, 221, 196, 0.65); }
|
text-align: right;
|
||||||
.me { color: aquamarine; font-weight: 700; }
|
}
|
||||||
@media (max-width: 720px) {
|
input {
|
||||||
.auction-page { padding: 10px; }
|
width: 100%;
|
||||||
.page-header { align-items: flex-start; }
|
min-width: 0;
|
||||||
.detail-grid { grid-template-columns: 110px 1fr; }
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #000;
|
||||||
|
border-radius: 5.25px;
|
||||||
|
padding: 5.25px 10.5px;
|
||||||
|
color: #303030;
|
||||||
|
background: #ddd;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
input:focus-visible {
|
||||||
|
outline: 2px solid #8ab4f8;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
.open-form {
|
||||||
|
min-height: 76px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr 1fr 2fr 2fr 1fr;
|
||||||
|
align-items: end;
|
||||||
|
gap: 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding-right: 8.3333%;
|
||||||
|
padding-left: 16.6667%;
|
||||||
|
}
|
||||||
|
.open-form fieldset,
|
||||||
|
.open-form label {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.open-form legend {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.open-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.item-toggle {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.item-toggle .legacy-button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.register-button {
|
||||||
|
height: 100%;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
.recent-logs {
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
.caller-alias {
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
.caller-alias strong,
|
||||||
|
.is-me {
|
||||||
|
color: aqua;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr 1fr 2fr 1fr 2fr 1fr 2fr;
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.detail-grid dt,
|
||||||
|
.detail-grid dd {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 18px;
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.bid-row {
|
||||||
|
min-height: 22px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 3fr 2fr 3fr;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 20%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.bid-header {
|
||||||
|
border-bottom: 1px solid #fff;
|
||||||
|
}
|
||||||
|
.bid-row > :nth-child(2) {
|
||||||
|
padding-right: 20px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.unique-bid-form {
|
||||||
|
min-height: 40px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 3fr 2fr 1fr;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 25%;
|
||||||
|
}
|
||||||
|
.unique-bid-form label {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.unique-row {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 22px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 4fr 1fr 2fr 1fr 1fr 2fr;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.unique-header {
|
||||||
|
border-bottom: 1px solid #fff;
|
||||||
|
}
|
||||||
|
.unique-row > :last-child {
|
||||||
|
padding-right: 8px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.auction-notice {
|
||||||
|
min-height: 28px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 5px 8px;
|
||||||
|
}
|
||||||
|
.auction-notice.error {
|
||||||
|
background: #842029;
|
||||||
|
}
|
||||||
|
.auction-notice.success {
|
||||||
|
background: #0f5132;
|
||||||
|
}
|
||||||
|
.loading-state {
|
||||||
|
min-height: 120px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.bottom-bar {
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
@media (max-width: 991px) {
|
||||||
|
.legacy-auction-page {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
.resource-row {
|
||||||
|
min-height: 43px;
|
||||||
|
grid-template-columns: 1fr 3fr 3fr 1fr 2fr 2fr;
|
||||||
|
grid-template-rows: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.resource-row .idx {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
}
|
||||||
|
.resource-row .host {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
.resource-row .amount {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
.resource-row .highest-bidder {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
.resource-row .highest-bid {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
.resource-row .bid-ratio {
|
||||||
|
grid-column: 4;
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
}
|
||||||
|
.resource-row .finish-bid {
|
||||||
|
grid-column: 5;
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
}
|
||||||
|
.resource-row .close-date {
|
||||||
|
grid-column: 6;
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
}
|
||||||
|
.resource-bid-form {
|
||||||
|
grid-template-columns: 4fr 3fr 2fr;
|
||||||
|
padding-right: 16.6667%;
|
||||||
|
padding-left: 8.3333%;
|
||||||
|
}
|
||||||
|
.open-form {
|
||||||
|
grid-template-columns: 2fr 2.3333fr 2fr 2.3333fr 2.3333fr 1fr;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.detail-grid {
|
||||||
|
grid-template-columns: 2fr 4fr 2fr 4fr;
|
||||||
|
}
|
||||||
|
.bid-row {
|
||||||
|
padding: 0;
|
||||||
|
grid-template-columns: 4fr 4fr 4fr;
|
||||||
|
}
|
||||||
|
.unique-bid-form {
|
||||||
|
padding: 0;
|
||||||
|
grid-template-columns: 5fr 4fr 3fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -817,6 +817,7 @@ export const adminRouter = router({
|
|||||||
profileName: profile.profileName,
|
profileName: profile.profileName,
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
daemonRunning: false,
|
daemonRunning: false,
|
||||||
|
auctionRunning: false,
|
||||||
tournamentRunning: false,
|
tournamentRunning: false,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export type LobbyProfileStatus = {
|
|||||||
runtime: {
|
runtime: {
|
||||||
apiRunning: boolean;
|
apiRunning: boolean;
|
||||||
daemonRunning: boolean;
|
daemonRunning: boolean;
|
||||||
|
auctionRunning: boolean;
|
||||||
tournamentRunning: boolean;
|
tournamentRunning: boolean;
|
||||||
};
|
};
|
||||||
korName: string;
|
korName: string;
|
||||||
@@ -69,7 +70,10 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
|
|||||||
|
|
||||||
private mapProfile(
|
private mapProfile(
|
||||||
row: GatewayProfileRecord,
|
row: GatewayProfileRecord,
|
||||||
runtimeMap: Map<string, { apiRunning: boolean; daemonRunning: boolean; tournamentRunning: boolean }>
|
runtimeMap: Map<
|
||||||
|
string,
|
||||||
|
{ apiRunning: boolean; daemonRunning: boolean; auctionRunning: boolean; tournamentRunning: boolean }
|
||||||
|
>
|
||||||
): LobbyProfileStatus {
|
): LobbyProfileStatus {
|
||||||
const meta = row.meta;
|
const meta = row.meta;
|
||||||
return {
|
return {
|
||||||
@@ -81,6 +85,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
|
|||||||
runtime: runtimeMap.get(row.profileName) ?? {
|
runtime: runtimeMap.get(row.profileName) ?? {
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
daemonRunning: false,
|
daemonRunning: false,
|
||||||
|
auctionRunning: false,
|
||||||
tournamentRunning: false,
|
tournamentRunning: false,
|
||||||
},
|
},
|
||||||
korName: (meta.korName as string | undefined) ?? row.profile,
|
korName: (meta.korName as string | undefined) ?? row.profile,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface GatewayOrchestratorOptions {
|
|||||||
export interface ProfileRuntimeState {
|
export interface ProfileRuntimeState {
|
||||||
apiRunning: boolean;
|
apiRunning: boolean;
|
||||||
daemonRunning: boolean;
|
daemonRunning: boolean;
|
||||||
|
auctionRunning: boolean;
|
||||||
tournamentRunning: boolean;
|
tournamentRunning: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,13 +67,18 @@ export const planProfileReconcile = (
|
|||||||
): { shouldStart: boolean; shouldStop: boolean } => {
|
): { shouldStart: boolean; shouldStop: boolean } => {
|
||||||
if (status === 'RUNNING' || status === 'PREOPEN' || status === 'PAUSED' || status === 'COMPLETED') {
|
if (status === 'RUNNING' || status === 'PREOPEN' || status === 'PAUSED' || status === 'COMPLETED') {
|
||||||
return {
|
return {
|
||||||
shouldStart: !(runtime.apiRunning && runtime.daemonRunning && runtime.tournamentRunning),
|
shouldStart: !(
|
||||||
|
runtime.apiRunning &&
|
||||||
|
runtime.daemonRunning &&
|
||||||
|
runtime.auctionRunning &&
|
||||||
|
runtime.tournamentRunning
|
||||||
|
),
|
||||||
shouldStop: false,
|
shouldStop: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
shouldStart: false,
|
shouldStart: false,
|
||||||
shouldStop: runtime.apiRunning || runtime.daemonRunning || runtime.tournamentRunning,
|
shouldStop: runtime.apiRunning || runtime.daemonRunning || runtime.auctionRunning || runtime.tournamentRunning,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -273,8 +279,16 @@ const parseInstallOptions = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'tournament'): string =>
|
const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'auction' | 'tournament'): string =>
|
||||||
`sammo:${profileName}:${role === 'api' ? 'game-api' : role === 'daemon' ? 'turn-daemon' : 'tournament-worker'}`;
|
`sammo:${profileName}:${
|
||||||
|
role === 'api'
|
||||||
|
? 'game-api'
|
||||||
|
: role === 'daemon'
|
||||||
|
? 'turn-daemon'
|
||||||
|
: role === 'auction'
|
||||||
|
? 'auction-worker'
|
||||||
|
: 'tournament-worker'
|
||||||
|
}`;
|
||||||
|
|
||||||
const isMissingProcessError = (error: unknown): boolean =>
|
const isMissingProcessError = (error: unknown): boolean =>
|
||||||
error instanceof Error && /process or namespace not found/i.test(error.message);
|
error instanceof Error && /process or namespace not found/i.test(error.message);
|
||||||
@@ -285,11 +299,13 @@ export const buildProcessDefinitions = (
|
|||||||
): {
|
): {
|
||||||
api: { name: string; script: string; cwd: string; env: Record<string, string> };
|
api: { name: string; script: string; cwd: string; env: Record<string, string> };
|
||||||
daemon: { name: string; script: string; cwd: string; env: Record<string, string> };
|
daemon: { name: string; script: string; cwd: string; env: Record<string, string> };
|
||||||
|
auction: { name: string; script: string; cwd: string; env: Record<string, string> };
|
||||||
tournament: { name: string; script: string; cwd: string; env: Record<string, string> };
|
tournament: { name: string; script: string; cwd: string; env: Record<string, string> };
|
||||||
} => {
|
} => {
|
||||||
const baseEnv = { ...(config.baseEnv ?? {}) };
|
const baseEnv = { ...(config.baseEnv ?? {}) };
|
||||||
const apiName = buildProcessName(profile.profileName, 'api');
|
const apiName = buildProcessName(profile.profileName, 'api');
|
||||||
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
||||||
|
const auctionName = buildProcessName(profile.profileName, 'auction');
|
||||||
const tournamentName = buildProcessName(profile.profileName, 'tournament');
|
const tournamentName = buildProcessName(profile.profileName, 'tournament');
|
||||||
const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot;
|
const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot;
|
||||||
const apiCwd = path.join(runtimeWorkspace, 'app', 'game-api');
|
const apiCwd = path.join(runtimeWorkspace, 'app', 'game-api');
|
||||||
@@ -327,6 +343,15 @@ export const buildProcessDefinitions = (
|
|||||||
cwd: daemonCwd,
|
cwd: daemonCwd,
|
||||||
env: daemonEnv,
|
env: daemonEnv,
|
||||||
},
|
},
|
||||||
|
auction: {
|
||||||
|
name: auctionName,
|
||||||
|
script: apiScript,
|
||||||
|
cwd: apiCwd,
|
||||||
|
env: {
|
||||||
|
...apiEnv,
|
||||||
|
GAME_API_ROLE: 'auction-worker',
|
||||||
|
},
|
||||||
|
},
|
||||||
tournament: {
|
tournament: {
|
||||||
name: tournamentName,
|
name: tournamentName,
|
||||||
script: apiScript,
|
script: apiScript,
|
||||||
@@ -376,11 +401,13 @@ const mapRuntimeStates = (profileNames: string[], processNames: Map<string, bool
|
|||||||
profileNames.map((profileName) => {
|
profileNames.map((profileName) => {
|
||||||
const apiName = buildProcessName(profileName, 'api');
|
const apiName = buildProcessName(profileName, 'api');
|
||||||
const daemonName = buildProcessName(profileName, 'daemon');
|
const daemonName = buildProcessName(profileName, 'daemon');
|
||||||
|
const auctionName = buildProcessName(profileName, 'auction');
|
||||||
const tournamentName = buildProcessName(profileName, 'tournament');
|
const tournamentName = buildProcessName(profileName, 'tournament');
|
||||||
return {
|
return {
|
||||||
profileName,
|
profileName,
|
||||||
apiRunning: processNames.get(apiName) ?? false,
|
apiRunning: processNames.get(apiName) ?? false,
|
||||||
daemonRunning: processNames.get(daemonName) ?? false,
|
daemonRunning: processNames.get(daemonName) ?? false,
|
||||||
|
auctionRunning: processNames.get(auctionName) ?? false,
|
||||||
tournamentRunning: processNames.get(tournamentName) ?? false,
|
tournamentRunning: processNames.get(tournamentName) ?? false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -981,6 +1008,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
try {
|
try {
|
||||||
await this.processManager.start(definitions.api);
|
await this.processManager.start(definitions.api);
|
||||||
await this.processManager.start(definitions.daemon);
|
await this.processManager.start(definitions.daemon);
|
||||||
|
await this.processManager.start(definitions.auction);
|
||||||
await this.processManager.start(definitions.tournament);
|
await this.processManager.start(definitions.tournament);
|
||||||
await this.repository.updateLastError(profile.profileName, null);
|
await this.repository.updateLastError(profile.profileName, null);
|
||||||
return true;
|
return true;
|
||||||
@@ -996,10 +1024,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
private async stopProfile(profile: GatewayProfileRecord): Promise<void> {
|
private async stopProfile(profile: GatewayProfileRecord): Promise<void> {
|
||||||
const apiName = buildProcessName(profile.profileName, 'api');
|
const apiName = buildProcessName(profile.profileName, 'api');
|
||||||
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
||||||
|
const auctionName = buildProcessName(profile.profileName, 'auction');
|
||||||
const tournamentName = buildProcessName(profile.profileName, 'tournament');
|
const tournamentName = buildProcessName(profile.profileName, 'tournament');
|
||||||
const existingNames = new Set((await this.processManager.list()).map((process) => process.name));
|
const existingNames = new Set((await this.processManager.list()).map((process) => process.name));
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
for (const name of [apiName, daemonName, tournamentName]) {
|
for (const name of [apiName, daemonName, auctionName, tournamentName]) {
|
||||||
if (!existingNames.has(name)) {
|
if (!existingNames.has(name)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ const createHarness = (
|
|||||||
? [
|
? [
|
||||||
{ name: 'sammo:che:2:game-api', status: 'online' },
|
{ name: 'sammo:che:2:game-api', status: 'online' },
|
||||||
{ name: 'sammo:che:2:turn-daemon', status: 'online' },
|
{ name: 'sammo:che:2:turn-daemon', status: 'online' },
|
||||||
|
{ name: 'sammo:che:2:auction-worker', status: 'online' },
|
||||||
{ name: 'sammo:che:2:tournament-worker', status: 'online' },
|
{ name: 'sammo:che:2:tournament-worker', status: 'online' },
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
@@ -138,7 +139,7 @@ const createHarness = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('GatewayOrchestrator first-class operations', () => {
|
describe('GatewayOrchestrator first-class operations', () => {
|
||||||
it('starts both profile processes and records success', async () => {
|
it('starts all profile processes and records success', async () => {
|
||||||
const harness = createHarness(buildOperation('START'));
|
const harness = createHarness(buildOperation('START'));
|
||||||
|
|
||||||
await harness.orchestrator.runOperationsNow();
|
await harness.orchestrator.runOperationsNow();
|
||||||
@@ -147,12 +148,13 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
expect(harness.started.map((definition) => definition.name)).toEqual([
|
expect(harness.started.map((definition) => definition.name)).toEqual([
|
||||||
'sammo:che:2:game-api',
|
'sammo:che:2:game-api',
|
||||||
'sammo:che:2:turn-daemon',
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:auction-worker',
|
||||||
'sammo:che:2:tournament-worker',
|
'sammo:che:2:tournament-worker',
|
||||||
]);
|
]);
|
||||||
expect(harness.completions).toEqual(['SUCCEEDED']);
|
expect(harness.completions).toEqual(['SUCCEEDED']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stops both profile processes and records success', async () => {
|
it('stops all profile processes and records success', async () => {
|
||||||
const harness = createHarness(buildOperation('STOP'));
|
const harness = createHarness(buildOperation('STOP'));
|
||||||
|
|
||||||
await harness.orchestrator.runOperationsNow();
|
await harness.orchestrator.runOperationsNow();
|
||||||
@@ -161,11 +163,13 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
expect(harness.stopped).toEqual([
|
expect(harness.stopped).toEqual([
|
||||||
'sammo:che:2:game-api',
|
'sammo:che:2:game-api',
|
||||||
'sammo:che:2:turn-daemon',
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:auction-worker',
|
||||||
'sammo:che:2:tournament-worker',
|
'sammo:che:2:tournament-worker',
|
||||||
]);
|
]);
|
||||||
expect(harness.deleted).toEqual([
|
expect(harness.deleted).toEqual([
|
||||||
'sammo:che:2:game-api',
|
'sammo:che:2:game-api',
|
||||||
'sammo:che:2:turn-daemon',
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:auction-worker',
|
||||||
'sammo:che:2:tournament-worker',
|
'sammo:che:2:tournament-worker',
|
||||||
]);
|
]);
|
||||||
expect(harness.completions).toEqual(['SUCCEEDED']);
|
expect(harness.completions).toEqual(['SUCCEEDED']);
|
||||||
@@ -190,6 +194,7 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
expect(harness.deleted).toEqual([
|
expect(harness.deleted).toEqual([
|
||||||
'sammo:che:2:game-api',
|
'sammo:che:2:game-api',
|
||||||
'sammo:che:2:turn-daemon',
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:auction-worker',
|
||||||
'sammo:che:2:tournament-worker',
|
'sammo:che:2:tournament-worker',
|
||||||
]);
|
]);
|
||||||
expect(harness.completions).toEqual(['SUCCEEDED']);
|
expect(harness.completions).toEqual(['SUCCEEDED']);
|
||||||
@@ -203,7 +208,7 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
expect(harness.completions).toEqual(['FAILED']);
|
expect(harness.completions).toEqual(['FAILED']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('attempts to stop both roles before reporting a partial PM2 failure', async () => {
|
it('attempts to stop every role before reporting a partial PM2 failure', async () => {
|
||||||
const harness = createHarness(buildOperation('STOP'), false, true);
|
const harness = createHarness(buildOperation('STOP'), false, true);
|
||||||
|
|
||||||
await harness.orchestrator.runOperationsNow();
|
await harness.orchestrator.runOperationsNow();
|
||||||
@@ -211,11 +216,13 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
expect(harness.stopped).toEqual([
|
expect(harness.stopped).toEqual([
|
||||||
'sammo:che:2:game-api',
|
'sammo:che:2:game-api',
|
||||||
'sammo:che:2:turn-daemon',
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:auction-worker',
|
||||||
'sammo:che:2:tournament-worker',
|
'sammo:che:2:tournament-worker',
|
||||||
]);
|
]);
|
||||||
expect(harness.deleted).toEqual([
|
expect(harness.deleted).toEqual([
|
||||||
'sammo:che:2:game-api',
|
'sammo:che:2:game-api',
|
||||||
'sammo:che:2:turn-daemon',
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:auction-worker',
|
||||||
'sammo:che:2:tournament-worker',
|
'sammo:che:2:tournament-worker',
|
||||||
]);
|
]);
|
||||||
expect(harness.completions).toEqual(['FAILED']);
|
expect(harness.completions).toEqual(['FAILED']);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ describe('planProfileReconcile', () => {
|
|||||||
planProfileReconcile('RUNNING', {
|
planProfileReconcile('RUNNING', {
|
||||||
apiRunning: true,
|
apiRunning: true,
|
||||||
daemonRunning: false,
|
daemonRunning: false,
|
||||||
|
auctionRunning: true,
|
||||||
tournamentRunning: true,
|
tournamentRunning: true,
|
||||||
})
|
})
|
||||||
).toEqual({ shouldStart: true, shouldStop: false });
|
).toEqual({ shouldStart: true, shouldStop: false });
|
||||||
@@ -39,6 +40,7 @@ describe('planProfileReconcile', () => {
|
|||||||
planProfileReconcile('PREOPEN', {
|
planProfileReconcile('PREOPEN', {
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
daemonRunning: false,
|
daemonRunning: false,
|
||||||
|
auctionRunning: false,
|
||||||
tournamentRunning: false,
|
tournamentRunning: false,
|
||||||
})
|
})
|
||||||
).toEqual({ shouldStart: true, shouldStop: false });
|
).toEqual({ shouldStart: true, shouldStop: false });
|
||||||
@@ -49,16 +51,29 @@ describe('planProfileReconcile', () => {
|
|||||||
planProfileReconcile('RUNNING', {
|
planProfileReconcile('RUNNING', {
|
||||||
apiRunning: true,
|
apiRunning: true,
|
||||||
daemonRunning: true,
|
daemonRunning: true,
|
||||||
|
auctionRunning: true,
|
||||||
tournamentRunning: true,
|
tournamentRunning: true,
|
||||||
})
|
})
|
||||||
).toEqual({ shouldStart: false, shouldStop: false });
|
).toEqual({ shouldStart: false, shouldStop: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('restarts a running profile when only the auction worker is missing', () => {
|
||||||
|
expect(
|
||||||
|
planProfileReconcile('RUNNING', {
|
||||||
|
apiRunning: true,
|
||||||
|
daemonRunning: true,
|
||||||
|
auctionRunning: false,
|
||||||
|
tournamentRunning: true,
|
||||||
|
})
|
||||||
|
).toEqual({ shouldStart: true, shouldStop: false });
|
||||||
|
});
|
||||||
|
|
||||||
it('stops processes for non-running profiles', () => {
|
it('stops processes for non-running profiles', () => {
|
||||||
expect(
|
expect(
|
||||||
planProfileReconcile('STOPPED', {
|
planProfileReconcile('STOPPED', {
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
daemonRunning: true,
|
daemonRunning: true,
|
||||||
|
auctionRunning: false,
|
||||||
tournamentRunning: false,
|
tournamentRunning: false,
|
||||||
})
|
})
|
||||||
).toEqual({ shouldStart: false, shouldStop: true });
|
).toEqual({ shouldStart: false, shouldStop: true });
|
||||||
@@ -69,6 +84,7 @@ describe('planProfileReconcile', () => {
|
|||||||
planProfileReconcile('RESERVED', {
|
planProfileReconcile('RESERVED', {
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
daemonRunning: false,
|
daemonRunning: false,
|
||||||
|
auctionRunning: false,
|
||||||
tournamentRunning: false,
|
tournamentRunning: false,
|
||||||
})
|
})
|
||||||
).toEqual({ shouldStart: false, shouldStop: false });
|
).toEqual({ shouldStart: false, shouldStop: false });
|
||||||
@@ -95,6 +111,11 @@ describe('buildProcessDefinitions', () => {
|
|||||||
});
|
});
|
||||||
expect(definitions.daemon.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine'));
|
expect(definitions.daemon.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine'));
|
||||||
expect(definitions.daemon.script).toBe(path.join(buildWorkspace, 'app', 'game-engine', 'dist', 'index.js'));
|
expect(definitions.daemon.script).toBe(path.join(buildWorkspace, 'app', 'game-engine', 'dist', 'index.js'));
|
||||||
|
expect(definitions.auction).toMatchObject({
|
||||||
|
cwd: path.join(buildWorkspace, 'app', 'game-api'),
|
||||||
|
script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'),
|
||||||
|
env: { GAME_API_ROLE: 'auction-worker' },
|
||||||
|
});
|
||||||
expect(definitions.tournament).toMatchObject({
|
expect(definitions.tournament).toMatchObject({
|
||||||
cwd: path.join(buildWorkspace, 'app', 'game-api'),
|
cwd: path.join(buildWorkspace, 'app', 'game-api'),
|
||||||
script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'),
|
script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'),
|
||||||
@@ -107,6 +128,7 @@ describe('buildProcessDefinitions', () => {
|
|||||||
|
|
||||||
expect(definitions.api.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
expect(definitions.api.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
||||||
expect(definitions.daemon.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-engine'));
|
expect(definitions.daemon.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-engine'));
|
||||||
|
expect(definitions.auction.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
||||||
expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const profile = (runtimeRunning: boolean) => ({
|
|||||||
profileName: 'che:2',
|
profileName: 'che:2',
|
||||||
apiRunning: runtimeRunning,
|
apiRunning: runtimeRunning,
|
||||||
daemonRunning: runtimeRunning,
|
daemonRunning: runtimeRunning,
|
||||||
|
auctionRunning: runtimeRunning,
|
||||||
tournamentRunning: runtimeRunning,
|
tournamentRunning: runtimeRunning,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -212,7 +213,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
|||||||
await page.screenshot({ path: testInfo.outputPath('mobile-operations.png'), fullPage: true });
|
await page.screenshot({ path: testInfo.outputPath('mobile-operations.png'), fullPage: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('starts and stops both runtime roles through the operation controls', async ({ page }) => {
|
test('starts and stops all runtime roles through the operation controls', async ({ page }) => {
|
||||||
const state: FixtureState = { operations: [], runtimeRunning: false, requestBodies: [] };
|
const state: FixtureState = { operations: [], runtimeRunning: false, requestBodies: [] };
|
||||||
await installFixture(page, state);
|
await installFixture(page, state);
|
||||||
page.on('dialog', (dialog) => dialog.accept());
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ type AdminProfile = {
|
|||||||
runtime: {
|
runtime: {
|
||||||
apiRunning: boolean;
|
apiRunning: boolean;
|
||||||
daemonRunning: boolean;
|
daemonRunning: boolean;
|
||||||
|
auctionRunning: boolean;
|
||||||
tournamentRunning: boolean;
|
tournamentRunning: boolean;
|
||||||
};
|
};
|
||||||
buildCommitSha?: string;
|
buildCommitSha?: string;
|
||||||
@@ -1352,7 +1353,8 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-zinc-400">
|
<div class="text-xs text-zinc-400">
|
||||||
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
|
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
|
||||||
DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / TOURNAMENT:
|
DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / AUCTION:
|
||||||
|
{{ profile.runtime.auctionRunning ? 'ON' : 'OFF' }} / TOURNAMENT:
|
||||||
{{ profile.runtime.tournamentRunning ? 'ON' : 'OFF' }}
|
{{ profile.runtime.tournamentRunning ? 'ON' : 'OFF' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ type Profile = {
|
|||||||
buildWorkspace?: string;
|
buildWorkspace?: string;
|
||||||
buildError?: string;
|
buildError?: string;
|
||||||
lastError?: string;
|
lastError?: string;
|
||||||
runtime: { apiRunning: boolean; daemonRunning: boolean; tournamentRunning: boolean };
|
runtime: {
|
||||||
|
apiRunning: boolean;
|
||||||
|
daemonRunning: boolean;
|
||||||
|
auctionRunning: boolean;
|
||||||
|
tournamentRunning: boolean;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type Scenario = {
|
type Scenario = {
|
||||||
@@ -377,6 +382,12 @@ onBeforeUnmount(() => {
|
|||||||
{{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }}
|
{{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="rounded bg-zinc-950 p-3">
|
||||||
|
<div class="text-xs text-zinc-500">Auction worker</div>
|
||||||
|
<div :class="selectedProfile.runtime.auctionRunning ? 'text-emerald-400' : 'text-zinc-500'">
|
||||||
|
{{ selectedProfile.runtime.auctionRunning ? 'RUNNING' : 'STOPPED' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="rounded bg-zinc-950 p-3">
|
<div class="rounded bg-zinc-950 p-3">
|
||||||
<div class="text-xs text-zinc-500">Tournament worker</div>
|
<div class="text-xs text-zinc-500">Tournament worker</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
|
||||||
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
const targetRoot = process.env.REF_AUCTION_URL ?? 'https://dev-sam-ref.hided.net/sam/';
|
||||||
|
const secretRoot = process.env.REF_SECRET_ROOT;
|
||||||
|
const username = process.env.REF_USER_ID ?? 'refuser1';
|
||||||
|
const passwordFile = process.env.REF_PASSWORD_FILE ?? 'user1_password';
|
||||||
|
const allowGeneralCreate = process.env.REF_CREATE_GENERAL === '1';
|
||||||
|
const outputRoot = resolve(
|
||||||
|
process.env.REF_AUCTION_ARTIFACT_DIR ?? resolve(repositoryRoot, 'test-results/auction-reference')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!secretRoot) {
|
||||||
|
throw new Error('REF_SECRET_ROOT is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = (await readFile(resolve(secretRoot, passwordFile), 'utf8')).trim();
|
||||||
|
const viewports = [
|
||||||
|
{ name: 'desktop', width: 1000, height: 800 },
|
||||||
|
{ name: 'mobile', width: 500, height: 800 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const login = async (context) => {
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(targetRoot, { waitUntil: 'networkidle', timeout: 60_000 });
|
||||||
|
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||||
|
const clientPasswordHash = createHash('sha512')
|
||||||
|
.update(globalSalt + password + globalSalt)
|
||||||
|
.digest('hex');
|
||||||
|
const response = await context.request.post(new URL('api.php?path=Login/LoginByID', targetRoot).toString(), {
|
||||||
|
data: { username, password: clientPasswordHash },
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok() || result.result !== true) {
|
||||||
|
throw new Error('Reference login failed.');
|
||||||
|
}
|
||||||
|
if (allowGeneralCreate) {
|
||||||
|
const joinUrl = new URL('hwe/v_join.php', targetRoot).toString();
|
||||||
|
await page.goto(joinUrl, { waitUntil: 'networkidle', timeout: 60_000 });
|
||||||
|
if (page.url().includes('v_join.php')) {
|
||||||
|
const createButton = page.getByRole('button', { name: '장수 생성', exact: true });
|
||||||
|
try {
|
||||||
|
await createButton.waitFor({ state: 'visible', timeout: 30_000 });
|
||||||
|
} catch {
|
||||||
|
const pageText = (await page.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 300);
|
||||||
|
throw new Error(`Reference general form did not render: ${page.url()} | ${pageText}`);
|
||||||
|
}
|
||||||
|
page.on('dialog', async (dialog) => dialog.accept());
|
||||||
|
await createButton.click();
|
||||||
|
await page.waitForURL((url) => !url.pathname.endsWith('/v_join.php'), { timeout: 60_000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const roundedRect = (rect) => ({
|
||||||
|
x: Math.round(rect.x * 100) / 100,
|
||||||
|
y: Math.round(rect.y * 100) / 100,
|
||||||
|
width: Math.round(rect.width * 100) / 100,
|
||||||
|
height: Math.round(rect.height * 100) / 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const measurePage = async (page, type) => {
|
||||||
|
const diagnostics = [];
|
||||||
|
const onPageError = (error) => diagnostics.push(`pageerror: ${error.message}`);
|
||||||
|
const onConsole = (message) => {
|
||||||
|
if (message.type() === 'error') {
|
||||||
|
diagnostics.push(`console: ${message.text()}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onResponse = (response) => {
|
||||||
|
if (response.status() >= 400) {
|
||||||
|
diagnostics.push(`http ${response.status()}: ${response.url()}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
page.on('pageerror', onPageError);
|
||||||
|
page.on('console', onConsole);
|
||||||
|
page.on('response', onResponse);
|
||||||
|
const relative = type === 'unique' ? 'hwe/v_auction.php?type=unique' : 'hwe/v_auction.php';
|
||||||
|
await page.goto(new URL(relative, targetRoot).toString(), { waitUntil: 'networkidle', timeout: 60_000 });
|
||||||
|
try {
|
||||||
|
await page.locator('#container').waitFor({ state: 'visible', timeout: 30_000 });
|
||||||
|
} catch {
|
||||||
|
const pageText = (await page.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 300);
|
||||||
|
const scripts = await page.locator('script').evaluateAll((elements) =>
|
||||||
|
elements.map((element) => ({
|
||||||
|
src: element.getAttribute('src'),
|
||||||
|
type: element.getAttribute('type'),
|
||||||
|
length: element.textContent?.length ?? 0,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`Reference auction did not render: ${page.url()} | ${await page.title()} | ${pageText} | ${JSON.stringify(scripts)} | ${diagnostics.slice(0, 8).join(' | ')}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
page.off('pageerror', onPageError);
|
||||||
|
page.off('console', onConsole);
|
||||||
|
page.off('response', onResponse);
|
||||||
|
}
|
||||||
|
await page.locator('button').first().waitFor({ state: 'visible' });
|
||||||
|
await page.evaluate(() => document.fonts.ready);
|
||||||
|
|
||||||
|
const measurement = await page.evaluate(() => {
|
||||||
|
const rect = (element) => {
|
||||||
|
if (!(element instanceof HTMLElement)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = element.getBoundingClientRect();
|
||||||
|
return { x: value.x, y: value.y, width: value.width, height: value.height };
|
||||||
|
};
|
||||||
|
const style = (element) => {
|
||||||
|
if (!(element instanceof HTMLElement)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
color: value.color,
|
||||||
|
backgroundColor: value.backgroundColor,
|
||||||
|
backgroundImage: value.backgroundImage,
|
||||||
|
borderColor: value.borderColor,
|
||||||
|
borderWidth: value.borderWidth,
|
||||||
|
borderRadius: value.borderRadius,
|
||||||
|
fontFamily: value.fontFamily,
|
||||||
|
fontSize: value.fontSize,
|
||||||
|
fontWeight: value.fontWeight,
|
||||||
|
lineHeight: value.lineHeight,
|
||||||
|
padding: value.padding,
|
||||||
|
cursor: value.cursor,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const container = document.querySelector('#container');
|
||||||
|
const topBar = container?.firstElementChild ?? null;
|
||||||
|
const topBarButtons = [...(topBar?.querySelectorAll('button') ?? [])].map((element) => ({
|
||||||
|
text: element.textContent?.trim() ?? '',
|
||||||
|
rect: rect(element),
|
||||||
|
style: style(element),
|
||||||
|
}));
|
||||||
|
const firstButton = document.querySelector('button');
|
||||||
|
const firstInput = [...document.querySelectorAll('input')].find(
|
||||||
|
(element) => element.getBoundingClientRect().width > 0
|
||||||
|
);
|
||||||
|
const firstAuctionRow = document.querySelector('.auctionItem');
|
||||||
|
const firstAuctionRowChildren = [...(firstAuctionRow?.children ?? [])].map((element) => ({
|
||||||
|
className: element.className,
|
||||||
|
rect: rect(element),
|
||||||
|
style: style(element),
|
||||||
|
}));
|
||||||
|
const firstSection = [...document.querySelectorAll('#container > div')].find((element) =>
|
||||||
|
['쌀 구매', '쌀 판매'].includes(element.textContent?.trim() ?? '')
|
||||||
|
);
|
||||||
|
const directChildren = [...(container?.children ?? [])].slice(0, 12).map((element) => ({
|
||||||
|
tag: element.tagName,
|
||||||
|
className: element.className,
|
||||||
|
text: element.textContent?.trim().replace(/\s+/g, ' ').slice(0, 80) ?? '',
|
||||||
|
rect: rect(element),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||||||
|
document: {
|
||||||
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
|
clientWidth: document.documentElement.clientWidth,
|
||||||
|
},
|
||||||
|
body: { rect: rect(document.body), style: style(document.body) },
|
||||||
|
container: { rect: rect(container), style: style(container) },
|
||||||
|
topBar: { rect: rect(topBar), style: style(topBar) },
|
||||||
|
topBarButtons,
|
||||||
|
firstButton: { rect: rect(firstButton), style: style(firstButton) },
|
||||||
|
firstInput: { rect: rect(firstInput), style: style(firstInput) },
|
||||||
|
firstAuctionRow: { rect: rect(firstAuctionRow), style: style(firstAuctionRow) },
|
||||||
|
firstAuctionRowChildren,
|
||||||
|
firstSection: { rect: rect(firstSection), style: style(firstSection) },
|
||||||
|
directChildren,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...measurement,
|
||||||
|
body: {
|
||||||
|
...measurement.body,
|
||||||
|
rect: measurement.body.rect ? roundedRect(measurement.body.rect) : null,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
...measurement.container,
|
||||||
|
rect: measurement.container.rect ? roundedRect(measurement.container.rect) : null,
|
||||||
|
},
|
||||||
|
topBar: {
|
||||||
|
...measurement.topBar,
|
||||||
|
rect: measurement.topBar.rect ? roundedRect(measurement.topBar.rect) : null,
|
||||||
|
},
|
||||||
|
topBarButtons: measurement.topBarButtons.map((button) => ({
|
||||||
|
...button,
|
||||||
|
rect: button.rect ? roundedRect(button.rect) : null,
|
||||||
|
})),
|
||||||
|
firstButton: {
|
||||||
|
...measurement.firstButton,
|
||||||
|
rect: measurement.firstButton.rect ? roundedRect(measurement.firstButton.rect) : null,
|
||||||
|
},
|
||||||
|
firstInput: {
|
||||||
|
...measurement.firstInput,
|
||||||
|
rect: measurement.firstInput.rect ? roundedRect(measurement.firstInput.rect) : null,
|
||||||
|
},
|
||||||
|
firstAuctionRow: {
|
||||||
|
...measurement.firstAuctionRow,
|
||||||
|
rect: measurement.firstAuctionRow.rect ? roundedRect(measurement.firstAuctionRow.rect) : null,
|
||||||
|
},
|
||||||
|
firstAuctionRowChildren: measurement.firstAuctionRowChildren.map((child) => ({
|
||||||
|
...child,
|
||||||
|
rect: child.rect ? roundedRect(child.rect) : null,
|
||||||
|
})),
|
||||||
|
firstSection: {
|
||||||
|
...measurement.firstSection,
|
||||||
|
rect: measurement.firstSection.rect ? roundedRect(measurement.firstSection.rect) : null,
|
||||||
|
},
|
||||||
|
directChildren: measurement.directChildren.map((child) => ({
|
||||||
|
...child,
|
||||||
|
rect: child.rect ? roundedRect(child.rect) : null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
await mkdir(outputRoot, { recursive: true });
|
||||||
|
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
|
||||||
|
const results = {};
|
||||||
|
try {
|
||||||
|
for (const viewport of viewports) {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: viewport.width, height: viewport.height },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
colorScheme: 'dark',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await login(context);
|
||||||
|
const page = await context.newPage();
|
||||||
|
for (const type of ['resource', 'unique']) {
|
||||||
|
results[`${viewport.name}-${type}`] = await measurePage(page, type);
|
||||||
|
await page.screenshot({
|
||||||
|
path: resolve(outputRoot, `${viewport.name}-${type}.png`),
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputPath = resolve(outputRoot, 'computed-dom.json');
|
||||||
|
await writeFile(outputPath, `${JSON.stringify(results, null, 2)}\n`);
|
||||||
|
console.log(JSON.stringify({ outputPath, views: Object.keys(results) }));
|
||||||
@@ -451,6 +451,10 @@ describe('auction integration flow', () => {
|
|||||||
const initialScore = await redis.zScore(keys.timerKey, String(auction.id));
|
const initialScore = await redis.zScore(keys.timerKey, String(auction.id));
|
||||||
expect(Number(initialScore)).toBe(initialCloseAt.getTime());
|
expect(Number(initialScore)).toBe(initialCloseAt.getTime());
|
||||||
|
|
||||||
|
await expect(hostClient.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 300 })).rejects.toThrow(
|
||||||
|
'자신이 연 경매에 입찰할 수 없습니다.'
|
||||||
|
);
|
||||||
|
|
||||||
const bidder1Client = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
const bidder1Client = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
||||||
value: bidder1.accessToken,
|
value: bidder1.accessToken,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user