토너먼트 준비
This commit is contained in:
@@ -18,6 +18,8 @@
|
|||||||
"worker:dev": "GAME_API_ROLE=battle-sim-worker tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch",
|
"worker:dev": "GAME_API_ROLE=battle-sim-worker tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch",
|
||||||
"worker:auction": "GAME_API_ROLE=auction-worker node dist/index.js",
|
"worker:auction": "GAME_API_ROLE=auction-worker node dist/index.js",
|
||||||
"worker:auction:dev": "GAME_API_ROLE=auction-worker tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch",
|
"worker:auction:dev": "GAME_API_ROLE=auction-worker tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch",
|
||||||
|
"worker:tournament": "GAME_API_ROLE=tournament-worker node dist/index.js",
|
||||||
|
"worker:tournament:dev": "GAME_API_ROLE=tournament-worker tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"test": "vitest run --config vitest.config.ts",
|
"test": "vitest run --config vitest.config.ts",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface GameApiConfig {
|
|||||||
auctionTimerPollMs: number;
|
auctionTimerPollMs: number;
|
||||||
auctionTimerResyncMs: number;
|
auctionTimerResyncMs: number;
|
||||||
auctionTimerRetentionSeconds: number;
|
auctionTimerRetentionSeconds: number;
|
||||||
|
tournamentPollMs: number;
|
||||||
gameTokenSecret: string;
|
gameTokenSecret: string;
|
||||||
flushChannel: string;
|
flushChannel: string;
|
||||||
}
|
}
|
||||||
@@ -62,6 +63,11 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env
|
|||||||
21600,
|
21600,
|
||||||
'AUCTION_TIMER_RETENTION_SECONDS'
|
'AUCTION_TIMER_RETENTION_SECONDS'
|
||||||
),
|
),
|
||||||
|
tournamentPollMs: parseNumberWithFallback(
|
||||||
|
env.TOURNAMENT_POLL_MS,
|
||||||
|
1000,
|
||||||
|
'TOURNAMENT_POLL_MS'
|
||||||
|
),
|
||||||
gameTokenSecret: secret,
|
gameTokenSecret: secret,
|
||||||
flushChannel: `${gatewayPrefix}:flush`,
|
flushChannel: `${gatewayPrefix}:flush`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import { runGameApiServer } from './server.js';
|
import { runGameApiServer } from './server.js';
|
||||||
import { runBattleSimWorker } from './battleSim/worker.js';
|
import { runBattleSimWorker } from './battleSim/worker.js';
|
||||||
import { runAuctionWorker } from './auction/worker.js';
|
import { runAuctionWorker } from './auction/worker.js';
|
||||||
|
import { runTournamentWorker } from './tournament/worker.js';
|
||||||
|
|
||||||
export * from './config.js';
|
export * from './config.js';
|
||||||
export * from './context.js';
|
export * from './context.js';
|
||||||
@@ -26,6 +27,7 @@ export * from './auction/types.js';
|
|||||||
export * from './auction/keys.js';
|
export * from './auction/keys.js';
|
||||||
export * from './auction/scheduler.js';
|
export * from './auction/scheduler.js';
|
||||||
export * from './auction/worker.js';
|
export * from './auction/worker.js';
|
||||||
|
export * from './tournament/worker.js';
|
||||||
|
|
||||||
// Types for TRPC consumer
|
// Types for TRPC consumer
|
||||||
export type { MessageView } from './messages/store.js';
|
export type { MessageView } from './messages/store.js';
|
||||||
@@ -49,8 +51,10 @@ if (isMain()) {
|
|||||||
role === 'battle-sim-worker'
|
role === 'battle-sim-worker'
|
||||||
? runBattleSimWorker
|
? runBattleSimWorker
|
||||||
: role === 'auction-worker'
|
: role === 'auction-worker'
|
||||||
? runAuctionWorker
|
? runAuctionWorker
|
||||||
: runGameApiServer;
|
: role === 'tournament-worker'
|
||||||
|
? runTournamentWorker
|
||||||
|
: runGameApiServer;
|
||||||
run().catch((error) => {
|
run().catch((error) => {
|
||||||
console.error('[game-api] failed to start', error);
|
console.error('[game-api] failed to start', error);
|
||||||
process.exitCode = 1;
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -210,6 +210,29 @@ const normalizeCommand = (envelope: TurnDaemonCommandEnvelope): TurnDaemonComman
|
|||||||
officerLevel: command.officerLevel,
|
officerLevel: command.officerLevel,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case 'tournamentRefund': {
|
||||||
|
if (!Array.isArray(command.refunds)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const refunds = command.refunds
|
||||||
|
.filter((entry) =>
|
||||||
|
entry && typeof entry.generalId === 'number' && typeof entry.amount === 'number'
|
||||||
|
)
|
||||||
|
.map((entry) => ({
|
||||||
|
generalId: entry.generalId,
|
||||||
|
amount: entry.amount,
|
||||||
|
}));
|
||||||
|
if (refunds.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'tournamentRefund',
|
||||||
|
requestId: envelope.requestId,
|
||||||
|
bettingId: typeof command.bettingId === 'number' ? command.bettingId : undefined,
|
||||||
|
reason: typeof command.reason === 'string' ? command.reason : undefined,
|
||||||
|
refunds,
|
||||||
|
};
|
||||||
|
}
|
||||||
case 'getStatus': {
|
case 'getStatus': {
|
||||||
const requestId = typeof command.requestId === 'string' ? command.requestId : envelope.requestId;
|
const requestId = typeof command.requestId === 'string' ? command.requestId : envelope.requestId;
|
||||||
return { type: 'getStatus', requestId };
|
return { type: 'getStatus', requestId };
|
||||||
|
|||||||
@@ -451,6 +451,54 @@ async function handleAppoint(
|
|||||||
return { type: 'appoint', ok: true, generalId: command.generalId };
|
return { type: 'appoint', ok: true, generalId: command.generalId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleTournamentRefund(
|
||||||
|
ctx: CommandHandlerContext,
|
||||||
|
command: Extract<TurnDaemonCommand, { type: 'tournamentRefund' }>
|
||||||
|
): Promise<TurnDaemonCommandResult> {
|
||||||
|
const { world, hooks } = ctx;
|
||||||
|
if (!command.refunds || command.refunds.length === 0) {
|
||||||
|
return {
|
||||||
|
type: 'tournamentRefund',
|
||||||
|
ok: false,
|
||||||
|
bettingId: command.bettingId,
|
||||||
|
reason: '환불 대상이 없습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
let missing = 0;
|
||||||
|
let totalRefund = 0;
|
||||||
|
|
||||||
|
for (const refund of command.refunds) {
|
||||||
|
if (!refund || typeof refund.generalId !== 'number' || typeof refund.amount !== 'number') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (refund.amount <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const general = world.getGeneralById(refund.generalId);
|
||||||
|
if (!general) {
|
||||||
|
missing += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
world.updateGeneral(refund.generalId, {
|
||||||
|
gold: general.gold + refund.amount,
|
||||||
|
});
|
||||||
|
processed += 1;
|
||||||
|
totalRefund += refund.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
await flushWorld(world, hooks);
|
||||||
|
return {
|
||||||
|
type: 'tournamentRefund',
|
||||||
|
ok: true,
|
||||||
|
bettingId: command.bettingId,
|
||||||
|
processed,
|
||||||
|
missing,
|
||||||
|
totalRefund,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const createTurnDaemonCommandHandler = (options: {
|
export const createTurnDaemonCommandHandler = (options: {
|
||||||
world: InMemoryTurnWorld;
|
world: InMemoryTurnWorld;
|
||||||
hooks?: TurnDaemonHooks;
|
hooks?: TurnDaemonHooks;
|
||||||
@@ -485,6 +533,8 @@ export const createTurnDaemonCommandHandler = (options: {
|
|||||||
return handleKick(ctx, command);
|
return handleKick(ctx, command);
|
||||||
case 'appoint':
|
case 'appoint':
|
||||||
return handleAppoint(ctx, command);
|
return handleAppoint(ctx, command);
|
||||||
|
case 'tournamentRefund':
|
||||||
|
return handleTournamentRefund(ctx, command);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export * from './util/JosaUtil.js';
|
|||||||
export * from './util/RNG.js';
|
export * from './util/RNG.js';
|
||||||
export * from './util/RandUtil.js';
|
export * from './util/RandUtil.js';
|
||||||
export * from './util/TestRNG.js';
|
export * from './util/TestRNG.js';
|
||||||
|
export * from './util/TournamentRNG.js';
|
||||||
export * from './util/sha512.js';
|
export * from './util/sha512.js';
|
||||||
export * from './util/parse.js';
|
export * from './util/parse.js';
|
||||||
export * from './turnDaemon/types.js';
|
export * from './turnDaemon/types.js';
|
||||||
|
|||||||
@@ -85,7 +85,17 @@ export type TurnDaemonCommand =
|
|||||||
destGeneralId: number;
|
destGeneralId: number;
|
||||||
destCityId: number;
|
destCityId: number;
|
||||||
officerLevel: number;
|
officerLevel: number;
|
||||||
};
|
}
|
||||||
|
| {
|
||||||
|
type: 'tournamentRefund';
|
||||||
|
requestId?: string;
|
||||||
|
bettingId?: number;
|
||||||
|
reason?: string;
|
||||||
|
refunds: Array<{
|
||||||
|
generalId: number;
|
||||||
|
amount: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
export type TurnDaemonCommandResult =
|
export type TurnDaemonCommandResult =
|
||||||
| {
|
| {
|
||||||
@@ -132,7 +142,21 @@ export type TurnDaemonCommandResult =
|
|||||||
| { type: 'dropItem'; ok: boolean; generalId: number; reason?: string }
|
| { type: 'dropItem'; ok: boolean; generalId: number; reason?: string }
|
||||||
| { type: 'changePermission'; ok: boolean; generalId: number; reason?: string }
|
| { type: 'changePermission'; ok: boolean; generalId: number; reason?: string }
|
||||||
| { type: 'kick'; ok: boolean; generalId: number; reason?: string }
|
| { type: 'kick'; ok: boolean; generalId: number; reason?: string }
|
||||||
| { type: 'appoint'; ok: boolean; generalId: number; reason?: string };
|
| { type: 'appoint'; ok: boolean; generalId: number; reason?: string }
|
||||||
|
| {
|
||||||
|
type: 'tournamentRefund';
|
||||||
|
ok: true;
|
||||||
|
bettingId?: number;
|
||||||
|
processed: number;
|
||||||
|
missing: number;
|
||||||
|
totalRefund: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'tournamentRefund';
|
||||||
|
ok: false;
|
||||||
|
bettingId?: number;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type TurnDaemonEvent =
|
export type TurnDaemonEvent =
|
||||||
| { type: 'status'; requestId?: string; status: TurnDaemonStatus }
|
| { type: 'status'; requestId?: string; status: TurnDaemonStatus }
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { LiteHashDRBG } from './LiteHashDRBG.js';
|
||||||
|
import { RandUtil } from './RandUtil.js';
|
||||||
|
|
||||||
|
export interface TournamentRngContext {
|
||||||
|
openYear: number;
|
||||||
|
openMonth: number;
|
||||||
|
stage: number;
|
||||||
|
phase: number;
|
||||||
|
matchIndex: number;
|
||||||
|
participantIndex: number;
|
||||||
|
gameIndex?: number;
|
||||||
|
extraSeed?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildTournamentSeedKey = (baseSeed: string, context: TournamentRngContext): string => {
|
||||||
|
const gameIndex = context.gameIndex !== undefined ? `|game:${context.gameIndex}` : '';
|
||||||
|
const extraSeed = context.extraSeed !== undefined ? `|extra:${context.extraSeed}` : '';
|
||||||
|
return [
|
||||||
|
'Tournament',
|
||||||
|
`open:${context.openYear}-${context.openMonth}`,
|
||||||
|
`stage:${context.stage}`,
|
||||||
|
`phase:${context.phase}`,
|
||||||
|
`match:${context.matchIndex}`,
|
||||||
|
`participant:${context.participantIndex}`,
|
||||||
|
gameIndex,
|
||||||
|
extraSeed,
|
||||||
|
`seed:${baseSeed}`,
|
||||||
|
]
|
||||||
|
.filter((value) => value.length > 0)
|
||||||
|
.join('|');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createTournamentRng = (baseSeed: string, context: TournamentRngContext): RandUtil =>
|
||||||
|
new RandUtil(LiteHashDRBG.build(buildTournamentSeedKey(baseSeed, context)));
|
||||||
|
|
||||||
|
export const createTournamentSeedKey = (baseSeed: string, context: TournamentRngContext): string =>
|
||||||
|
buildTournamentSeedKey(baseSeed, context);
|
||||||
@@ -220,6 +220,19 @@ model LogEntry {
|
|||||||
@@map("log_entry")
|
@@map("log_entry")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ErrorLog {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
category String
|
||||||
|
source String? @map("source")
|
||||||
|
message String
|
||||||
|
trace String?
|
||||||
|
context Json @default(dbgenerated("'{}'::jsonb"))
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([category, id])
|
||||||
|
@@map("error_log")
|
||||||
|
}
|
||||||
|
|
||||||
model InheritancePoint {
|
model InheritancePoint {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { GamePrisma, GamePrismaClient } from './gamePrisma.js';
|
||||||
|
|
||||||
|
export interface ErrorLogQueryOptions {
|
||||||
|
limit?: number;
|
||||||
|
beforeId?: number;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorLogCreateInput {
|
||||||
|
category: string;
|
||||||
|
message: string;
|
||||||
|
source?: string;
|
||||||
|
trace?: string;
|
||||||
|
context?: GamePrisma.InputJsonValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorLogView {
|
||||||
|
id: number;
|
||||||
|
category: string;
|
||||||
|
source: string | null;
|
||||||
|
message: string;
|
||||||
|
trace: string | null;
|
||||||
|
context: GamePrisma.JsonValue;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildPaginationWhere = (
|
||||||
|
base: GamePrisma.ErrorLogWhereInput,
|
||||||
|
options: ErrorLogQueryOptions
|
||||||
|
): GamePrisma.ErrorLogWhereInput => {
|
||||||
|
if (options.beforeId) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
id: { lt: options.beforeId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildFindArgs = (
|
||||||
|
where: GamePrisma.ErrorLogWhereInput,
|
||||||
|
options: ErrorLogQueryOptions
|
||||||
|
): GamePrisma.ErrorLogFindManyArgs => ({
|
||||||
|
where: buildPaginationWhere(where, options),
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
take: options.limit ?? 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
export class ErrorLogRepository {
|
||||||
|
constructor(private readonly prisma: GamePrismaClient) {}
|
||||||
|
|
||||||
|
async createErrorLog(input: ErrorLogCreateInput): Promise<ErrorLogView> {
|
||||||
|
return this.prisma.errorLog.create({
|
||||||
|
data: {
|
||||||
|
category: input.category,
|
||||||
|
source: input.source ?? null,
|
||||||
|
message: input.message,
|
||||||
|
trace: input.trace ?? null,
|
||||||
|
context: input.context ?? {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listErrorLogs(options: ErrorLogQueryOptions = {}): Promise<ErrorLogView[]> {
|
||||||
|
const base: GamePrisma.ErrorLogWhereInput = options.category ? { category: options.category } : {};
|
||||||
|
return this.prisma.errorLog.findMany(buildFindArgs(base, options));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export {
|
|||||||
} from './gatewayPrisma.js';
|
} from './gatewayPrisma.js';
|
||||||
export type { GatewayPrismaClient } from './gatewayPrisma.js';
|
export type { GatewayPrismaClient } from './gatewayPrisma.js';
|
||||||
export * from './db.js';
|
export * from './db.js';
|
||||||
|
export * from './errorLogRepository.js';
|
||||||
export * from './logRepository.js';
|
export * from './logRepository.js';
|
||||||
export * from './redis.js';
|
export * from './redis.js';
|
||||||
export * from './turnEngineDb.js';
|
export * from './turnEngineDb.js';
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ export * from './ports/worldSnapshot.js';
|
|||||||
export * from './scenario/index.js';
|
export * from './scenario/index.js';
|
||||||
export * from './triggers/index.js';
|
export * from './triggers/index.js';
|
||||||
export * from './turn/index.js';
|
export * from './turn/index.js';
|
||||||
|
export * from './tournament/index.js';
|
||||||
export * from './world/index.js';
|
export * from './world/index.js';
|
||||||
export * from './war/index.js';
|
export * from './war/index.js';
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { createTournamentRng } from '@sammo-ts/common';
|
||||||
|
|
||||||
|
import {
|
||||||
|
TournamentType,
|
||||||
|
type TournamentBattleInput,
|
||||||
|
type TournamentBattleResult,
|
||||||
|
type TournamentBattleLogEntry,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
const clampPositive = (value: number, fallback = 0): number => {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const round = (value: number): number => Math.round(value);
|
||||||
|
|
||||||
|
const getLogRatio = (lvl1: number, lvl2: number): number => {
|
||||||
|
if (lvl1 >= lvl2) {
|
||||||
|
return 1 + Math.log10(1 + lvl1 - lvl2) / 10;
|
||||||
|
}
|
||||||
|
return 1 - Math.log10(1 + lvl2 - lvl1) / 10;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveTournamentStat = (type: TournamentType, stats: { leadership: number; strength: number; intel: number }): number => {
|
||||||
|
switch (type) {
|
||||||
|
case TournamentType.LEADERSHIP:
|
||||||
|
return stats.leadership;
|
||||||
|
case TournamentType.STRENGTH:
|
||||||
|
return stats.strength;
|
||||||
|
case TournamentType.INTEL:
|
||||||
|
return stats.intel;
|
||||||
|
case TournamentType.TOTAL:
|
||||||
|
default:
|
||||||
|
return (stats.leadership + stats.strength + stats.intel) * (7 / 15);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveTournamentBattle = (input: TournamentBattleInput): TournamentBattleResult => {
|
||||||
|
const { attacker, defender, battleType, context } = input;
|
||||||
|
const attackerStat = resolveTournamentStat(input.type, attacker.stats);
|
||||||
|
const defenderStat = resolveTournamentStat(input.type, defender.stats);
|
||||||
|
|
||||||
|
const rng = createTournamentRng(input.baseSeed, {
|
||||||
|
openYear: context.openYear,
|
||||||
|
openMonth: context.openMonth,
|
||||||
|
stage: context.stage,
|
||||||
|
phase: context.phase,
|
||||||
|
matchIndex: context.matchIndex,
|
||||||
|
participantIndex: 0,
|
||||||
|
extraSeed: `${attacker.id}:${defender.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const energyBaseAttacker = round(attackerStat * getLogRatio(attacker.level, defender.level) * 10);
|
||||||
|
const energyBaseDefender = round(defenderStat * getLogRatio(attacker.level, defender.level) * 10);
|
||||||
|
let energyAttacker = energyBaseAttacker;
|
||||||
|
let energyDefender = energyBaseDefender;
|
||||||
|
|
||||||
|
const maxTurns = battleType === 0 ? 10 : 100;
|
||||||
|
const log: string[] = [];
|
||||||
|
const logEntries: TournamentBattleLogEntry[] = [];
|
||||||
|
|
||||||
|
log.push(`<S>●</> <Y>${attacker.name}</> <C>(${energyBaseAttacker})</> vs <C>(${energyBaseDefender})</> <Y>${defender.name}</>`);
|
||||||
|
|
||||||
|
let totalDamageAttacker = 0;
|
||||||
|
let totalDamageDefender = 0;
|
||||||
|
let selected = 2;
|
||||||
|
|
||||||
|
for (let phase = 1; phase <= maxTurns; phase += 1) {
|
||||||
|
const baseDamageAttacker = round(defenderStat * (rng.nextInt(21) + 90) / 130);
|
||||||
|
const baseDamageDefender = round(attackerStat * (rng.nextInt(21) + 90) / 130);
|
||||||
|
let damageAttacker = baseDamageAttacker;
|
||||||
|
let damageDefender = baseDamageDefender;
|
||||||
|
|
||||||
|
if (attackerStat >= rng.nextInt(100)) {
|
||||||
|
damageDefender += round(attackerStat * (rng.nextInt(41) + 10) / 130);
|
||||||
|
}
|
||||||
|
if (defenderStat >= rng.nextInt(100)) {
|
||||||
|
damageAttacker += round(defenderStat * (rng.nextInt(41) + 10) / 130);
|
||||||
|
}
|
||||||
|
|
||||||
|
let criticalAttacker = false;
|
||||||
|
let criticalDefender = false;
|
||||||
|
let factorAttacker = 1;
|
||||||
|
let factorDefender = 1;
|
||||||
|
|
||||||
|
if (energyBaseAttacker / 5 > energyAttacker && damageAttacker > damageDefender && attackerStat >= rng.nextInt(300)) {
|
||||||
|
factorDefender = round((rng.nextInt(301) + 200) / 100);
|
||||||
|
criticalAttacker = true;
|
||||||
|
log.push(`<S>●</> <Y>${attacker.name}</>의 분노의 일격!`);
|
||||||
|
}
|
||||||
|
if (energyBaseDefender / 5 > energyDefender && damageDefender > damageAttacker && defenderStat >= rng.nextInt(300)) {
|
||||||
|
factorAttacker = round((rng.nextInt(301) + 200) / 100);
|
||||||
|
criticalDefender = true;
|
||||||
|
log.push(`<S>●</> <Y>${defender.name}</>의 분노의 일격!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
damageAttacker = round(damageAttacker * factorAttacker);
|
||||||
|
damageDefender = round(damageDefender * factorDefender);
|
||||||
|
|
||||||
|
if (phase === 1) {
|
||||||
|
if (attackerStat * 0.9 > defenderStat && attackerStat >= rng.nextInt(400)) {
|
||||||
|
damageDefender += round(attackerStat * (rng.nextInt(31) + 70) / 100);
|
||||||
|
}
|
||||||
|
if (defenderStat * 0.9 > attackerStat && defenderStat >= rng.nextInt(400)) {
|
||||||
|
damageAttacker += round(defenderStat * (rng.nextInt(31) + 70) / 100);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!criticalAttacker && attackerStat >= rng.nextInt(1000)) {
|
||||||
|
damageDefender += round(attackerStat * (rng.nextInt(31) + 20) / 100);
|
||||||
|
}
|
||||||
|
if (!criticalDefender && defenderStat >= rng.nextInt(1000)) {
|
||||||
|
damageAttacker += round(defenderStat * (rng.nextInt(31) + 20) / 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
damageAttacker = clampPositive(round(damageAttacker), 0);
|
||||||
|
damageDefender = clampPositive(round(damageDefender), 0);
|
||||||
|
|
||||||
|
energyAttacker -= damageAttacker;
|
||||||
|
energyDefender -= damageDefender;
|
||||||
|
|
||||||
|
totalDamageAttacker += damageAttacker;
|
||||||
|
totalDamageDefender += damageDefender;
|
||||||
|
|
||||||
|
const entryText =
|
||||||
|
`<S>●</> ${String(phase).padStart(2, '0')}合 : ` +
|
||||||
|
`<C>${String(round(energyAttacker)).padStart(3, '0')}</>` +
|
||||||
|
`<span class="ev_highlight">(-${String(round(damageAttacker)).padStart(3, '0')})</span>` +
|
||||||
|
' vs ' +
|
||||||
|
`<span class="ev_highlight">(-${String(round(damageDefender)).padStart(3, '0')})</span>` +
|
||||||
|
`<C>${String(round(energyDefender)).padStart(3, '0')}</>`;
|
||||||
|
|
||||||
|
log.push(entryText);
|
||||||
|
logEntries.push({
|
||||||
|
phase,
|
||||||
|
attackerEnergy: round(energyAttacker),
|
||||||
|
defenderEnergy: round(energyDefender),
|
||||||
|
attackerDamage: damageAttacker,
|
||||||
|
defenderDamage: damageDefender,
|
||||||
|
text: entryText,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (energyAttacker <= 0 && energyDefender <= 0) {
|
||||||
|
if (battleType === 0) {
|
||||||
|
selected = 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
selected = energyAttacker > energyDefender ? 0 : 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (energyAttacker <= 0) {
|
||||||
|
selected = 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (energyDefender <= 0) {
|
||||||
|
selected = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected === 0) {
|
||||||
|
log.push(`<S>●</> <Y>${attacker.name}</> <S>승리</>!`);
|
||||||
|
} else if (selected === 1) {
|
||||||
|
log.push(`<S>●</> <Y>${defender.name}</> <S>승리</>!`);
|
||||||
|
} else {
|
||||||
|
log.push(`<S>●</> <Y>${attacker.name}</> <S>무승부</>!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const winnerId = selected === 0 ? attacker.id : selected === 1 ? defender.id : null;
|
||||||
|
const loserId = selected === 0 ? defender.id : selected === 1 ? attacker.id : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
winnerId,
|
||||||
|
loserId,
|
||||||
|
draw: selected === 2,
|
||||||
|
rounds: logEntries.length,
|
||||||
|
totalDamage: {
|
||||||
|
attacker: totalDamageAttacker,
|
||||||
|
defender: totalDamageDefender,
|
||||||
|
},
|
||||||
|
log,
|
||||||
|
logEntries,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './types.js';
|
||||||
|
export * from './battle.js';
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
export const TournamentType = {
|
||||||
|
TOTAL: 0,
|
||||||
|
LEADERSHIP: 1,
|
||||||
|
STRENGTH: 2,
|
||||||
|
INTEL: 3,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TournamentType = (typeof TournamentType)[keyof typeof TournamentType];
|
||||||
|
|
||||||
|
export interface TournamentStats {
|
||||||
|
leadership: number;
|
||||||
|
strength: number;
|
||||||
|
intel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TournamentParticipant {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
stats: TournamentStats;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TournamentBattleLogEntry {
|
||||||
|
phase: number;
|
||||||
|
attackerEnergy: number;
|
||||||
|
defenderEnergy: number;
|
||||||
|
attackerDamage: number;
|
||||||
|
defenderDamage: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TournamentBattleResult {
|
||||||
|
winnerId: number | null;
|
||||||
|
loserId: number | null;
|
||||||
|
draw: boolean;
|
||||||
|
rounds: number;
|
||||||
|
totalDamage: {
|
||||||
|
attacker: number;
|
||||||
|
defender: number;
|
||||||
|
};
|
||||||
|
log: string[];
|
||||||
|
logEntries: TournamentBattleLogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TournamentBattleContext {
|
||||||
|
openYear: number;
|
||||||
|
openMonth: number;
|
||||||
|
stage: number;
|
||||||
|
phase: number;
|
||||||
|
matchIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TournamentBattleInput {
|
||||||
|
type: TournamentType;
|
||||||
|
battleType: 0 | 1; // 0: 승무패, 1: 승패
|
||||||
|
attacker: TournamentParticipant;
|
||||||
|
defender: TournamentParticipant;
|
||||||
|
context: TournamentBattleContext;
|
||||||
|
baseSeed: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user