토너먼트 준비
This commit is contained in:
@@ -14,6 +14,7 @@ export interface GameApiConfig {
|
||||
auctionTimerPollMs: number;
|
||||
auctionTimerResyncMs: number;
|
||||
auctionTimerRetentionSeconds: number;
|
||||
tournamentPollMs: number;
|
||||
gameTokenSecret: string;
|
||||
flushChannel: string;
|
||||
}
|
||||
@@ -62,6 +63,11 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env
|
||||
21600,
|
||||
'AUCTION_TIMER_RETENTION_SECONDS'
|
||||
),
|
||||
tournamentPollMs: parseNumberWithFallback(
|
||||
env.TOURNAMENT_POLL_MS,
|
||||
1000,
|
||||
'TOURNAMENT_POLL_MS'
|
||||
),
|
||||
gameTokenSecret: secret,
|
||||
flushChannel: `${gatewayPrefix}:flush`,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { runGameApiServer } from './server.js';
|
||||
import { runBattleSimWorker } from './battleSim/worker.js';
|
||||
import { runAuctionWorker } from './auction/worker.js';
|
||||
import { runTournamentWorker } from './tournament/worker.js';
|
||||
|
||||
export * from './config.js';
|
||||
export * from './context.js';
|
||||
@@ -26,6 +27,7 @@ export * from './auction/types.js';
|
||||
export * from './auction/keys.js';
|
||||
export * from './auction/scheduler.js';
|
||||
export * from './auction/worker.js';
|
||||
export * from './tournament/worker.js';
|
||||
|
||||
// Types for TRPC consumer
|
||||
export type { MessageView } from './messages/store.js';
|
||||
@@ -49,8 +51,10 @@ if (isMain()) {
|
||||
role === 'battle-sim-worker'
|
||||
? runBattleSimWorker
|
||||
: role === 'auction-worker'
|
||||
? runAuctionWorker
|
||||
: runGameApiServer;
|
||||
? runAuctionWorker
|
||||
: role === 'tournament-worker'
|
||||
? runTournamentWorker
|
||||
: runGameApiServer;
|
||||
run().catch((error) => {
|
||||
console.error('[game-api] failed to start', error);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface TournamentKeys {
|
||||
stateKey: string;
|
||||
participantsKey: string;
|
||||
matchesKey: string;
|
||||
}
|
||||
|
||||
export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
|
||||
stateKey: `sammo:${profileName}:tournament:state`,
|
||||
participantsKey: `sammo:${profileName}:tournament:participants`,
|
||||
matchesKey: `sammo:${profileName}:tournament:matches`,
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { TournamentKeys } from './keys.js';
|
||||
import type { TournamentMatchEntry, TournamentParticipantEntry, TournamentState } from './types.js';
|
||||
|
||||
interface RedisClientLike {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
const safeJsonParse = <T>(raw: string | null): T | null => {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export class TournamentStore {
|
||||
constructor(private readonly redis: RedisClientLike, private readonly keys: TournamentKeys) {}
|
||||
|
||||
async getState(): Promise<TournamentState | null> {
|
||||
return safeJsonParse<TournamentState>(await this.redis.get(this.keys.stateKey));
|
||||
}
|
||||
|
||||
async setState(state: TournamentState): Promise<void> {
|
||||
await this.redis.set(this.keys.stateKey, JSON.stringify(state));
|
||||
}
|
||||
|
||||
async getParticipants(): Promise<TournamentParticipantEntry[]> {
|
||||
return safeJsonParse<TournamentParticipantEntry[]>(await this.redis.get(this.keys.participantsKey)) ?? [];
|
||||
}
|
||||
|
||||
async setParticipants(participants: TournamentParticipantEntry[]): Promise<void> {
|
||||
await this.redis.set(this.keys.participantsKey, JSON.stringify(participants));
|
||||
}
|
||||
|
||||
async getMatches(): Promise<TournamentMatchEntry[]> {
|
||||
return safeJsonParse<TournamentMatchEntry[]>(await this.redis.get(this.keys.matchesKey)) ?? [];
|
||||
}
|
||||
|
||||
async setMatches(matches: TournamentMatchEntry[]): Promise<void> {
|
||||
await this.redis.set(this.keys.matchesKey, JSON.stringify(matches));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { TournamentType } from '@sammo-ts/logic';
|
||||
|
||||
export interface TournamentState {
|
||||
stage: number;
|
||||
phase: number;
|
||||
type: TournamentType;
|
||||
auto: boolean;
|
||||
openYear: number;
|
||||
openMonth: number;
|
||||
termSeconds: number;
|
||||
nextAt: string;
|
||||
bettingId?: number;
|
||||
lastError?: string;
|
||||
lastErrorAt?: string;
|
||||
}
|
||||
|
||||
export interface TournamentParticipantEntry {
|
||||
id: number;
|
||||
name: string;
|
||||
leadership: number;
|
||||
strength: number;
|
||||
intel: number;
|
||||
level: number;
|
||||
}
|
||||
|
||||
export interface TournamentMatchEntry {
|
||||
id: number;
|
||||
stage: number;
|
||||
roundIndex: number;
|
||||
attackerId: number;
|
||||
defenderId: number;
|
||||
winnerId?: number;
|
||||
log?: string[];
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { resolveTournamentBattle } from '@sammo-ts/logic';
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
createRedisConnector,
|
||||
resolvePostgresConfigFromEnv,
|
||||
resolveRedisConfigFromEnv,
|
||||
} from '@sammo-ts/infra';
|
||||
|
||||
import { resolveGameApiConfigFromEnv } from '../config.js';
|
||||
import { buildTournamentKeys } from './keys.js';
|
||||
import { TournamentStore } from './store.js';
|
||||
import type { TournamentMatchEntry, TournamentState } from './types.js';
|
||||
|
||||
const sleepMs = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const isBattleStage = (stage: number): boolean => stage >= 7 && stage <= 10;
|
||||
|
||||
const nextStage = (stage: number): number => {
|
||||
switch (stage) {
|
||||
case 7:
|
||||
return 8;
|
||||
case 8:
|
||||
return 9;
|
||||
case 9:
|
||||
return 10;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveNextAt = (state: TournamentState): string =>
|
||||
new Date(Date.now() + Math.max(1, state.termSeconds) * 1000).toISOString();
|
||||
|
||||
const applyBattle = async (
|
||||
store: TournamentStore,
|
||||
state: TournamentState,
|
||||
baseSeed: string
|
||||
): Promise<TournamentState> => {
|
||||
const matches = await store.getMatches();
|
||||
const participants = await store.getParticipants();
|
||||
|
||||
const pending = matches.filter((match) => match.stage === state.stage && !match.winnerId);
|
||||
if (pending.length === 0) {
|
||||
const next = nextStage(state.stage);
|
||||
const nextState: TournamentState = {
|
||||
...state,
|
||||
stage: next,
|
||||
phase: 0,
|
||||
nextAt: resolveNextAt(state),
|
||||
};
|
||||
await store.setState(nextState);
|
||||
return nextState;
|
||||
}
|
||||
|
||||
const target = pending[state.phase] ?? pending[0];
|
||||
if (!target) {
|
||||
throw new Error('토너먼트 매치가 없습니다.');
|
||||
}
|
||||
|
||||
const attacker = participants.find((entry) => entry.id === target.attackerId);
|
||||
const defender = participants.find((entry) => entry.id === target.defenderId);
|
||||
if (!attacker || !defender) {
|
||||
throw new Error('토너먼트 참가자 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
const result = resolveTournamentBattle({
|
||||
type: state.type,
|
||||
battleType: 1,
|
||||
attacker: {
|
||||
id: attacker.id,
|
||||
name: attacker.name,
|
||||
stats: {
|
||||
leadership: attacker.leadership,
|
||||
strength: attacker.strength,
|
||||
intel: attacker.intel,
|
||||
},
|
||||
level: attacker.level,
|
||||
},
|
||||
defender: {
|
||||
id: defender.id,
|
||||
name: defender.name,
|
||||
stats: {
|
||||
leadership: defender.leadership,
|
||||
strength: defender.strength,
|
||||
intel: defender.intel,
|
||||
},
|
||||
level: defender.level,
|
||||
},
|
||||
context: {
|
||||
openYear: state.openYear,
|
||||
openMonth: state.openMonth,
|
||||
stage: state.stage,
|
||||
phase: state.phase,
|
||||
matchIndex: target.id,
|
||||
},
|
||||
baseSeed,
|
||||
});
|
||||
|
||||
const updatedMatch: TournamentMatchEntry = {
|
||||
...target,
|
||||
winnerId: result.winnerId ?? undefined,
|
||||
log: result.log,
|
||||
};
|
||||
|
||||
const nextMatches = matches.map((entry) => (entry.id === target.id ? updatedMatch : entry));
|
||||
await store.setMatches(nextMatches);
|
||||
|
||||
const nextPhase = state.phase + 1;
|
||||
const nextState: TournamentState = {
|
||||
...state,
|
||||
phase: nextPhase,
|
||||
nextAt: resolveNextAt(state),
|
||||
};
|
||||
await store.setState(nextState);
|
||||
return nextState;
|
||||
};
|
||||
|
||||
export const runTournamentWorker = async (): Promise<void> => {
|
||||
const config = resolveGameApiConfigFromEnv();
|
||||
const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile }));
|
||||
const redis = createRedisConnector(resolveRedisConfigFromEnv());
|
||||
|
||||
await postgres.connect();
|
||||
await redis.connect();
|
||||
|
||||
const store = new TournamentStore(redis.client, buildTournamentKeys(config.profileName));
|
||||
|
||||
const handleExit = async () => {
|
||||
await redis.disconnect();
|
||||
await postgres.disconnect();
|
||||
};
|
||||
process.on('SIGINT', handleExit);
|
||||
process.on('SIGTERM', handleExit);
|
||||
|
||||
while (true) {
|
||||
const state = await store.getState();
|
||||
if (!state || !state.auto) {
|
||||
await sleepMs(config.tournamentPollMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isBattleStage(state.stage)) {
|
||||
await sleepMs(config.tournamentPollMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextAt = new Date(state.nextAt).getTime();
|
||||
const now = Date.now();
|
||||
if (Number.isFinite(nextAt) && nextAt > now) {
|
||||
await sleepMs(Math.min(config.tournamentPollMs, nextAt - now));
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const worldState = await postgres.prisma.worldState.findFirst();
|
||||
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
|
||||
await applyBattle(store, state, String(baseSeed));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
const nextState: TournamentState = {
|
||||
...state,
|
||||
auto: false,
|
||||
lastError: message,
|
||||
lastErrorAt: new Date().toISOString(),
|
||||
};
|
||||
await store.setState(nextState);
|
||||
}
|
||||
|
||||
await sleepMs(config.tournamentPollMs);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user