473 lines
19 KiB
TypeScript
473 lines
19 KiB
TypeScript
import path from 'node:path';
|
|
import fs from 'node:fs/promises';
|
|
import { execFile } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
|
|
import { sealGatewayPassword } from '../src/passwordEnvelope.js';
|
|
|
|
import type { AppRouter as GatewayAppRouter } from '@sammo-ts/gateway-api';
|
|
import { createGatewayApiServer } from '@sammo-ts/gateway-api';
|
|
import type { AppRouter as GameAppRouter } from '@sammo-ts/game-api';
|
|
import {
|
|
buildTournamentKeys,
|
|
createGameApiServer,
|
|
DatabaseTurnDaemonTransport,
|
|
processTournamentTick,
|
|
TournamentStore,
|
|
} from '@sammo-ts/game-api';
|
|
import { createTurnDaemonRuntime } from '@sammo-ts/game-engine';
|
|
import {
|
|
createGamePostgresConnector,
|
|
createGatewayPostgresConnector,
|
|
createRedisConnector,
|
|
resolvePostgresConfigFromEnv,
|
|
resolveRedisConfigFromEnv,
|
|
type GamePrisma,
|
|
} from '@sammo-ts/infra';
|
|
|
|
const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
|
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
const parseEnvFile = (rawText: string): Record<string, string> => {
|
|
const env: Record<string, string> = {};
|
|
for (const line of rawText.split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) {
|
|
continue;
|
|
}
|
|
const separator = trimmed.indexOf('=');
|
|
if (separator < 0) {
|
|
continue;
|
|
}
|
|
const key = trimmed.slice(0, separator).trim();
|
|
let value = trimmed.slice(separator + 1).trim();
|
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
value = value.slice(1, -1);
|
|
}
|
|
env[key] = value;
|
|
}
|
|
return env;
|
|
};
|
|
|
|
const loadEnv = async (): Promise<void> => {
|
|
const values = parseEnvFile(await fs.readFile(path.join(workspaceRoot, '.env.ci'), 'utf8'));
|
|
for (const [key, value] of Object.entries(values)) {
|
|
if (process.env[key] === undefined) {
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
process.env.INTEGRATION_JOIN_ALLOW_CITY ??= 'true';
|
|
process.env.INTEGRATION_WORLD_SEED ??= 'tournament-lifecycle-seed';
|
|
};
|
|
|
|
const execCommand = (command: string, args: string[], env?: NodeJS.ProcessEnv): Promise<void> =>
|
|
new Promise((resolve, reject) => {
|
|
execFile(command, args, { env, cwd: workspaceRoot }, (error, stdout, stderr) => {
|
|
if (error) {
|
|
reject(new Error(`${command} ${args.join(' ')} failed:\n${stdout}\n${stderr}`));
|
|
return;
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
const ensureSchema = async (schema: string): Promise<void> => {
|
|
const connector = createGatewayPostgresConnector({
|
|
url: resolvePostgresConfigFromEnv({ schema: 'public' }).url,
|
|
});
|
|
await connector.connect();
|
|
try {
|
|
await connector.prisma.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
|
|
} finally {
|
|
await connector.disconnect();
|
|
}
|
|
};
|
|
|
|
const truncateSchema = async (schema: string): Promise<void> => {
|
|
const connector = createGatewayPostgresConnector({
|
|
url: resolvePostgresConfigFromEnv({ schema: 'public' }).url,
|
|
});
|
|
await connector.connect();
|
|
try {
|
|
const rows = (await connector.prisma.$queryRawUnsafe(
|
|
`SELECT tablename FROM pg_tables WHERE schemaname = '${schema}'`
|
|
)) as Array<{ tablename: string }>;
|
|
if (rows.length === 0) {
|
|
return;
|
|
}
|
|
const tableList = rows.map((row) => `"${schema}"."${row.tablename}"`).join(', ');
|
|
await connector.prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tableList} RESTART IDENTITY CASCADE`);
|
|
} finally {
|
|
await connector.disconnect();
|
|
}
|
|
};
|
|
|
|
const resetServices = async (): Promise<void> => {
|
|
await ensureSchema('public');
|
|
await ensureSchema('che');
|
|
await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:gateway', '--accept-data-loss'], {
|
|
...process.env,
|
|
POSTGRES_SCHEMA: 'public',
|
|
});
|
|
await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:game', '--accept-data-loss'], {
|
|
...process.env,
|
|
POSTGRES_SCHEMA: 'che',
|
|
});
|
|
await truncateSchema('public');
|
|
await truncateSchema('che');
|
|
|
|
const redis = createRedisConnector(resolveRedisConfigFromEnv());
|
|
await redis.connect();
|
|
try {
|
|
await redis.client.flushDb();
|
|
} finally {
|
|
await redis.disconnect();
|
|
}
|
|
};
|
|
|
|
const createGatewayClient = (baseUrl: string, pathName: string, token: { value?: string }) =>
|
|
createTRPCProxyClient<GatewayAppRouter>({
|
|
links: [
|
|
httpBatchLink({
|
|
url: `${baseUrl}${pathName}`,
|
|
headers: () => (token.value ? { 'x-session-token': token.value } : {}),
|
|
}),
|
|
],
|
|
});
|
|
|
|
const createGameClient = (baseUrl: string, pathName: string, token: { value?: string }) =>
|
|
createTRPCProxyClient<GameAppRouter>({
|
|
links: [
|
|
httpBatchLink({
|
|
url: `${baseUrl}${pathName}`,
|
|
headers: () => (token.value ? { authorization: `Bearer ${token.value}` } : {}),
|
|
}),
|
|
],
|
|
});
|
|
|
|
describe('actual tournament lifecycle', () => {
|
|
let gatewayServer: Awaited<ReturnType<typeof createGatewayApiServer>> | null = null;
|
|
let gameServer: Awaited<ReturnType<typeof createGameApiServer>> | null = null;
|
|
let turnDaemon: Awaited<ReturnType<typeof createTurnDaemonRuntime>> | null = null;
|
|
let turnDaemonLoop: Promise<void> | null = null;
|
|
let gameConnector: Awaited<ReturnType<typeof createGamePostgresConnector>> | null = null;
|
|
let redisConnector: Awaited<ReturnType<typeof createRedisConnector>> | null = null;
|
|
let store: TournamentStore | null = null;
|
|
let transport: DatabaseTurnDaemonTransport | null = null;
|
|
|
|
const clients = new Map<string, ReturnType<typeof createGameClient>>();
|
|
const generalIds = new Map<string, number>();
|
|
|
|
beforeAll(async () => {
|
|
await loadEnv();
|
|
process.env.SCENARIO = '908';
|
|
process.chdir(workspaceRoot);
|
|
await resetServices();
|
|
|
|
gatewayServer = await createGatewayApiServer();
|
|
await gatewayServer.app.listen({ host: gatewayServer.config.host, port: gatewayServer.config.port });
|
|
gameServer = await createGameApiServer();
|
|
await gameServer.app.listen({ host: gameServer.config.host, port: gameServer.config.port });
|
|
|
|
const gatewayUrl = `http://localhost:${gatewayServer.config.port}`;
|
|
const gameUrl = `http://localhost:${gameServer.config.port}`;
|
|
const adminSession = { value: undefined as string | undefined };
|
|
const gatewayClient = createGatewayClient(gatewayUrl, gatewayServer.config.trpcPath, adminSession);
|
|
|
|
const bootstrap = await gatewayClient.auth.bootstrapLocal.mutate({
|
|
token: process.env.GATEWAY_BOOTSTRAP_TOKEN ?? '',
|
|
username: 'admin',
|
|
password: 'admin-pass-123',
|
|
displayName: '관리자',
|
|
});
|
|
adminSession.value = bootstrap.sessionToken;
|
|
|
|
const users = [
|
|
['participant', '대회참가자'],
|
|
['bettor-a', '베팅유저A'],
|
|
['bettor-b', '베팅유저B'],
|
|
['no-general', '무장수유저'],
|
|
] as const;
|
|
for (const [username, displayName] of users) {
|
|
await gatewayClient.admin.users.createLocal.mutate({
|
|
username,
|
|
password: `${username}-pass`,
|
|
displayName,
|
|
});
|
|
}
|
|
|
|
await gatewayClient.admin.profiles.upsert.mutate({
|
|
profile: 'che',
|
|
scenario: '908',
|
|
apiPort: Number(process.env.GAME_API_PORT ?? 14000),
|
|
status: 'RUNNING',
|
|
});
|
|
await gatewayClient.admin.profiles.installNow.mutate({
|
|
profileName: 'che:908',
|
|
install: {
|
|
scenarioId: 908,
|
|
turnTermMinutes: 1,
|
|
sync: false,
|
|
fiction: 0,
|
|
extend: true,
|
|
blockGeneralCreate: 0,
|
|
npcMode: 0,
|
|
showImgLevel: 0,
|
|
tournamentTrig: true,
|
|
joinMode: 'full',
|
|
autorunUser: null,
|
|
},
|
|
});
|
|
|
|
for (const [username, displayName] of users) {
|
|
const login = await gatewayClient.auth.login.mutate({
|
|
username,
|
|
credential: sealGatewayPassword(
|
|
`${username}-pass`,
|
|
await gatewayClient.auth.passwordKey.query()
|
|
),
|
|
});
|
|
const gatewayToken = await gatewayClient.auth.issueGameSession.mutate({
|
|
sessionToken: login.sessionToken,
|
|
profile: 'che:908',
|
|
});
|
|
const accessRef = { value: undefined as string | undefined };
|
|
const client = createGameClient(gameUrl, gameServer.config.trpcPath, accessRef);
|
|
const access = await client.auth.exchangeGatewayToken.mutate({ gatewayToken: gatewayToken.gameToken });
|
|
accessRef.value = access.accessToken;
|
|
clients.set(username, client);
|
|
|
|
if (username !== 'no-general') {
|
|
const created = await client.join.createGeneral.mutate({
|
|
name: displayName,
|
|
leadership: 55,
|
|
strength: 55,
|
|
intel: 55,
|
|
character: 'Random',
|
|
});
|
|
generalIds.set(username, created.generalId);
|
|
}
|
|
}
|
|
|
|
const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url;
|
|
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
|
gameConnector = createGamePostgresConnector({ url: gameDatabaseUrl });
|
|
await gameConnector.connect();
|
|
await gameConnector.prisma.general.updateMany({
|
|
where: { id: { in: [...generalIds.values()] } },
|
|
data: { gold: 10_000 },
|
|
});
|
|
const maxGeneralId = (await gameConnector.prisma.general.aggregate({ _max: { id: true } }))._max.id ?? 0;
|
|
const npcTemplate = await gameConnector.prisma.general.findFirstOrThrow({
|
|
where: { id: { in: [...generalIds.values()] } },
|
|
});
|
|
const {
|
|
id: _templateId,
|
|
userId: _templateUserId,
|
|
name: _templateName,
|
|
createdAt: _templateCreatedAt,
|
|
updatedAt: _templateUpdatedAt,
|
|
...npcTemplateData
|
|
} = npcTemplate;
|
|
const templateMeta =
|
|
typeof npcTemplate.meta === 'object' && npcTemplate.meta !== null && !Array.isArray(npcTemplate.meta)
|
|
? npcTemplate.meta
|
|
: {};
|
|
await gameConnector.prisma.general.createMany({
|
|
data: Array.from({ length: 48 }, (_, index): GamePrisma.GeneralCreateManyInput => ({
|
|
...npcTemplateData,
|
|
id: maxGeneralId + index + 1,
|
|
userId: null,
|
|
name: `대회NPC${index + 1}`,
|
|
npcState: 2,
|
|
leadership: 40 + (index % 31),
|
|
strength: 40 + ((index * 3) % 31),
|
|
intel: 40 + ((index * 7) % 31),
|
|
gold: 10_000,
|
|
lastTurn: npcTemplate.lastTurn as GamePrisma.InputJsonValue,
|
|
meta: { ...templateMeta, explevel: 20 },
|
|
penalty: npcTemplate.penalty as GamePrisma.InputJsonValue,
|
|
})),
|
|
});
|
|
|
|
redisConnector = createRedisConnector(resolveRedisConfigFromEnv());
|
|
await redisConnector.connect();
|
|
store = new TournamentStore(redisConnector.client, buildTournamentKeys('che:908'));
|
|
transport = new DatabaseTurnDaemonTransport(gameConnector.prisma, 30_000);
|
|
|
|
turnDaemon = await createTurnDaemonRuntime({
|
|
profile: 'che',
|
|
profileName: 'che:908',
|
|
databaseUrl: gameDatabaseUrl,
|
|
gatewayDatabaseUrl,
|
|
redisUrl: resolveRedisConfigFromEnv().url,
|
|
});
|
|
|
|
for (let attempt = 0; attempt < 36; attempt += 1) {
|
|
const current = turnDaemon.world.getState().lastTurnTime;
|
|
const next = new Date(current.getTime());
|
|
next.setUTCMonth(next.getUTCMonth() + 1);
|
|
await turnDaemon.world.advanceMonth(next);
|
|
if ((await store.getState())?.stage === 1) {
|
|
break;
|
|
}
|
|
}
|
|
expect(await store.getState()).toMatchObject({ stage: 1, auto: true });
|
|
|
|
turnDaemonLoop = turnDaemon.lifecycle.start();
|
|
const status = await transport.requestStatus(10_000);
|
|
expect(status).not.toBeNull();
|
|
}, 120_000);
|
|
|
|
afterAll(async () => {
|
|
if (turnDaemon) {
|
|
await turnDaemon.lifecycle.stop('tournament-lifecycle-test');
|
|
await turnDaemon.close();
|
|
await turnDaemonLoop;
|
|
}
|
|
await redisConnector?.disconnect();
|
|
await gameConnector?.disconnect();
|
|
await gameServer?.app.close();
|
|
await gatewayServer?.app.close();
|
|
}, 30_000);
|
|
|
|
it('runs auto-open, enrollment, betting, finals, rewards, and payout through the real daemon', async () => {
|
|
if (!store || !transport || !gameConnector) {
|
|
throw new Error('integration runtime is not ready');
|
|
}
|
|
const participant = clients.get('participant')!;
|
|
const bettorA = clients.get('bettor-a')!;
|
|
const bettorB = clients.get('bettor-b')!;
|
|
const noGeneral = clients.get('no-general')!;
|
|
|
|
await expect(noGeneral.tournament.getState.query()).rejects.toThrow();
|
|
await expect(participant.tournament.join.mutate()).resolves.toMatchObject({ ok: true });
|
|
await expect(participant.tournament.join.mutate()).resolves.toMatchObject({ ok: true });
|
|
|
|
for (let steps = 0; steps < 2000; steps += 1) {
|
|
const state = await store.getState();
|
|
if (!state) {
|
|
throw new Error('tournament state disappeared');
|
|
}
|
|
if (state.stage === 6) {
|
|
break;
|
|
}
|
|
await store.setState({
|
|
...state,
|
|
nextAt: new Date(Date.now() - 1_000).toISOString(),
|
|
});
|
|
await processTournamentTick({
|
|
store,
|
|
prisma: gameConnector.prisma,
|
|
daemonTransport: transport,
|
|
});
|
|
}
|
|
|
|
const bettingState = await store.getState();
|
|
expect(bettingState).toMatchObject({ stage: 6, auto: true });
|
|
const matches = await store.getMatches();
|
|
const candidates = Array.from(
|
|
new Set(
|
|
matches.filter((match) => match.stage === 7).flatMap((match) => [match.attackerId, match.defenderId])
|
|
)
|
|
);
|
|
expect(candidates).toHaveLength(16);
|
|
|
|
const idleDeadline = Date.now() + 60_000;
|
|
while (Date.now() < idleDeadline) {
|
|
const pending = await gameConnector.prisma.inputEvent.count({
|
|
where: { status: { in: ['PENDING', 'PROCESSING'] } },
|
|
});
|
|
if (pending === 0) {
|
|
break;
|
|
}
|
|
await sleep(100);
|
|
}
|
|
expect(
|
|
await gameConnector.prisma.inputEvent.count({
|
|
where: { status: { in: ['PENDING', 'PROCESSING'] } },
|
|
})
|
|
).toBe(0);
|
|
|
|
for (const targetId of candidates) {
|
|
await bettorA.tournament.placeBet.mutate({ targetId, amount: 10 });
|
|
}
|
|
await bettorB.tournament.placeBet.mutate({ targetId: candidates[0]!, amount: 100 });
|
|
|
|
const bets = await store.getBettingEntries();
|
|
const bettorAId = generalIds.get('bettor-a')!;
|
|
const bettorBId = generalIds.get('bettor-b')!;
|
|
expect(bets.filter((entry) => entry.generalId === bettorAId)).toHaveLength(16);
|
|
expect(bets.some((entry) => entry.generalId === bettorBId && entry.targetId === candidates[0])).toBe(true);
|
|
expect(bets.every((entry) => entry.generalId !== generalIds.get('participant'))).toBe(true);
|
|
|
|
const bettorBeforeSettlement = await gameConnector.prisma.general.findUniqueOrThrow({
|
|
where: { id: bettorAId },
|
|
select: { gold: true, meta: true },
|
|
});
|
|
|
|
await store.setState({
|
|
...bettingState!,
|
|
bettingCloseAt: new Date(Date.now() - 1_000).toISOString(),
|
|
nextAt: new Date(Date.now() - 1_000).toISOString(),
|
|
});
|
|
|
|
for (let steps = 0; steps < 100; steps += 1) {
|
|
const state = await store.getState();
|
|
if (!state) {
|
|
throw new Error('tournament state disappeared');
|
|
}
|
|
if (state.stage === 0 && state.rewardSettled && state.bettingSettled) {
|
|
break;
|
|
}
|
|
if (state.stage > 0) {
|
|
await store.setState({
|
|
...state,
|
|
bettingCloseAt:
|
|
state.stage === 6 ? new Date(Date.now() - 1_000).toISOString() : state.bettingCloseAt,
|
|
nextAt: new Date(Date.now() - 1_000).toISOString(),
|
|
});
|
|
}
|
|
await processTournamentTick({
|
|
store,
|
|
prisma: gameConnector.prisma,
|
|
daemonTransport: transport,
|
|
});
|
|
}
|
|
|
|
const finalState = await store.getState();
|
|
expect(finalState).toMatchObject({
|
|
stage: 0,
|
|
auto: false,
|
|
rewardSettled: true,
|
|
bettingSettled: true,
|
|
});
|
|
expect(finalState?.winnerId).toBeTypeOf('number');
|
|
|
|
const bettorAfterSettlement = await gameConnector.prisma.general.findUniqueOrThrow({
|
|
where: { id: bettorAId },
|
|
select: { gold: true, meta: true },
|
|
});
|
|
expect(bettorAfterSettlement.gold).toBeGreaterThan(bettorBeforeSettlement.gold);
|
|
expect(bettorAfterSettlement.meta).toMatchObject({
|
|
betgold: 160,
|
|
betwin: 1,
|
|
});
|
|
expect(Number((bettorAfterSettlement.meta as Record<string, unknown>).betwingold)).toBeGreaterThan(0);
|
|
|
|
const settlementEvents = await gameConnector.prisma.inputEvent.findMany({
|
|
where: { eventType: { in: ['tournamentReward', 'tournamentBettingPayout'] } },
|
|
select: { eventType: true, status: true, result: true },
|
|
});
|
|
expect(settlementEvents).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ eventType: 'tournamentReward', status: 'SUCCEEDED' }),
|
|
expect.objectContaining({ eventType: 'tournamentBettingPayout', status: 'SUCCEEDED' }),
|
|
])
|
|
);
|
|
expect(settlementEvents.every((event) => (event.result as { ok?: boolean } | null)?.ok === true)).toBe(true);
|
|
}, 120_000);
|
|
});
|