feat: 토너먼트 자동 시작 핸들러 개선 및 관련 기능 추가

This commit is contained in:
2026-01-23 18:58:05 +00:00
parent 7a2b4c3911
commit 6e439f074d
5 changed files with 152 additions and 149 deletions
+34 -45
View File
@@ -20,35 +20,13 @@ type TournamentPrismaClient = {
findMany: (args: {
where: Record<string, unknown>;
select: Record<string, boolean>;
}) => Promise<
Array<{
id: number;
name: string;
leadership: number;
strength: number;
intel: number;
meta: unknown;
npcState: number;
gold?: number;
}>
>;
}) => Promise<Array<Record<string, unknown>>>;
update: (args: { where: { id: number }; data: { gold: number } }) => Promise<unknown>;
};
worldState: {
findFirst: () => Promise<{ meta?: unknown; currentYear?: number } | null>;
};
$transaction: <T>(actions: Promise<T>[]) => Promise<T[]>;
errorLog: {
create: (args: {
data: {
category: string;
source: string;
message: string;
trace?: string;
context?: Record<string, unknown>;
};
}) => Promise<unknown>;
findFirst: () => Promise<{ meta?: unknown; currentYear?: number; config?: unknown } | null>;
};
$transaction: (actions: Promise<unknown>[]) => Promise<unknown[]>;
};
const sleepMs = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
@@ -192,18 +170,22 @@ const fillParticipants = async (options: {
});
const applicantPool = applicants
.filter((entry) => !takenIds.has(entry.id))
.map((entry) => asRecord(entry))
.filter((entry) => typeof entry.id === 'number' && !takenIds.has(entry.id))
.map((entry) => {
const score = resolveStatValue(state.type, entry);
const leadership = typeof entry.leadership === 'number' ? entry.leadership : 0;
const strength = typeof entry.strength === 'number' ? entry.strength : 0;
const intel = typeof entry.intel === 'number' ? entry.intel : 0;
const score = resolveStatValue(state.type, { leadership, strength, intel });
const meta = asRecord(entry.meta);
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
return {
item: {
id: entry.id,
name: entry.name,
leadership: entry.leadership,
strength: entry.strength,
intel: entry.intel,
id: entry.id as number,
name: typeof entry.name === 'string' ? entry.name : '무명장수',
leadership,
strength,
intel,
level,
},
weight: Math.max(1, score ** 1.5),
@@ -247,18 +229,22 @@ const fillParticipants = async (options: {
});
const npcPool = npcRows
.filter((entry) => !takenIds.has(entry.id))
.map((entry) => asRecord(entry))
.filter((entry) => typeof entry.id === 'number' && !takenIds.has(entry.id))
.map((entry) => {
const score = resolveStatValue(state.type, entry);
const leadership = typeof entry.leadership === 'number' ? entry.leadership : 0;
const strength = typeof entry.strength === 'number' ? entry.strength : 0;
const intel = typeof entry.intel === 'number' ? entry.intel : 0;
const score = resolveStatValue(state.type, { leadership, strength, intel });
const meta = asRecord(entry.meta);
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
return {
item: {
id: entry.id,
name: entry.name,
leadership: entry.leadership,
strength: entry.strength,
intel: entry.intel,
id: entry.id as number,
name: typeof entry.name === 'string' ? entry.name : '무명장수',
leadership,
strength,
intel,
level,
},
weight: Math.max(1, score ** 1.5),
@@ -728,7 +714,10 @@ const seedNpcBets = async (options: {
},
select: { id: true, gold: true },
});
if (npcList.length === 0) {
const npcBetList = npcList
.map((entry) => asRecord(entry))
.filter((entry) => typeof entry.id === 'number' && typeof entry.gold === 'number');
if (npcBetList.length === 0) {
return;
}
@@ -743,16 +732,16 @@ const seedNpcBets = async (options: {
});
const entries = [...existing];
for (const npc of npcList) {
for (const npc of npcBetList) {
const targetId = rng.choice(candidateIds);
entries.push({ generalId: npc.id, targetId, amount: betGold });
entries.push({ generalId: npc.id as number, targetId, amount: betGold });
}
await prisma.$transaction(
npcList.map((npc) =>
npcBetList.map((npc) =>
prisma.general.update({
where: { id: npc.id },
data: { gold: npc.gold - betGold },
where: { id: npc.id as number },
data: { gold: (npc.gold as number) - betGold },
})
)
);
+2 -4
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { TournamentType } from '@sammo-ts/logic';
import type { TurnDaemonCommand } from '@sammo-ts/common';
import { createTournamentAutoStartHandler } from '@sammo-ts/common';
import { TournamentStore } from '../src/tournament/store.js';
import { buildTournamentKeys } from '../src/tournament/keys.js';
@@ -11,7 +12,6 @@ import {
settleTournamentOutcome,
} from '../src/tournament/worker.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
import { createTournamentAutoStartHandler } from '../../game-engine/src/turn/tournamentAutoStart.js';
class MemoryRedis {
private readonly store = new Map<string, string>();
@@ -153,6 +153,7 @@ const createPrismaMock = (options: {
worldState: {
findFirst: async () => ({
meta: { hiddenSeed: options.baseSeed ?? 'seed' },
config: { const: { startYear: 1 } },
currentYear: options.currentYear ?? 1,
}),
},
@@ -269,11 +270,8 @@ describe('tournament worker (in-memory)', () => {
getTickSeconds: () => 600,
});
handler.onMonthChanged?.({
previousYear: 1,
previousMonth: 1,
currentYear: 1,
currentMonth: 2,
turnTime: new Date(),
});
await delayTick();
+2 -100
View File
@@ -1,111 +1,13 @@
import { asRecord } from '@sammo-ts/common';
import { createTournamentAutoStartHandler as createCommonTournamentAutoStartHandler } from '@sammo-ts/common';
import type { RedisConnector } from '@sammo-ts/infra';
import type { TurnCalendarHandler } from './inMemoryWorld.js';
interface TournamentState {
stage: number;
phase: number;
type: number;
auto: boolean;
openYear: number;
openMonth: number;
termSeconds: number;
nextAt: string;
bettingId?: number;
bettingCloseAt?: string;
winnerId?: number;
bettingSettled?: boolean;
rewardSettled?: boolean;
participantsLockedAt?: string;
lastError?: string;
lastErrorAt?: string;
}
const safeJsonParse = <T>(raw: string | null): T | null => {
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
};
const buildTournamentKeys = (profileName: string) => ({
stateKey: `sammo:${profileName}:tournament:state`,
participantsKey: `sammo:${profileName}:tournament:participants`,
matchesKey: `sammo:${profileName}:tournament:matches`,
bettingKey: `sammo:${profileName}:tournament:betting`,
});
const resolveTermSeconds = (tickSeconds: number): number => {
const turnMinutes = Math.max(1, Math.round(tickSeconds / 60));
const clampedMinutes = Math.min(120, Math.max(5, turnMinutes));
return clampedMinutes * 60;
};
export const createTournamentAutoStartHandler = (options: {
profileName: string;
getRedisClient: () => RedisConnector['client'] | null | undefined;
getWorldConfig: () => Record<string, unknown> | null | undefined;
getTickSeconds: () => number | null | undefined;
}): TurnCalendarHandler => {
const profileName = options.profileName;
const keys = buildTournamentKeys(profileName);
const triggerAutoStart = async (currentYear: number, currentMonth: number): Promise<void> => {
if (currentMonth % 2 !== 0) {
return;
}
const config = asRecord(options.getWorldConfig() ?? {});
if (config.tournamentTrig !== true) {
return;
}
const redis = options.getRedisClient();
if (!redis) {
return;
}
const state = safeJsonParse<TournamentState>(await redis.get(keys.stateKey));
if (state && state.stage > 0) {
return;
}
const now = new Date().toISOString();
const tickSeconds = options.getTickSeconds() ?? 0;
const termSeconds =
state && Number.isFinite(state.termSeconds) && state.termSeconds > 0
? state.termSeconds
: resolveTermSeconds(tickSeconds);
const nextState: TournamentState = {
stage: 1,
phase: 0,
type: state && typeof state.type === 'number' ? state.type : 0,
auto: true,
openYear: currentYear,
openMonth: currentMonth,
termSeconds,
nextAt: now,
bettingId: undefined,
bettingCloseAt: undefined,
winnerId: undefined,
bettingSettled: false,
rewardSettled: false,
participantsLockedAt: undefined,
lastError: undefined,
lastErrorAt: undefined,
};
await redis.set(keys.participantsKey, '[]');
await redis.set(keys.matchesKey, '[]');
await redis.set(keys.bettingKey, '[]');
await redis.set(keys.stateKey, JSON.stringify(nextState));
};
return {
onMonthChanged: (context) => {
void triggerAutoStart(context.currentYear, context.currentMonth);
},
};
return createCommonTournamentAutoStartHandler(options);
};
+1
View File
@@ -11,6 +11,7 @@ export * from './util/TestRNG.js';
export * from './util/TournamentRNG.js';
export * from './util/sha512.js';
export * from './util/parse.js';
export * from './tournament/autoStart.js';
export * from './turnDaemon/types.js';
export * from './realtime/keys.js';
export * from './realtime/types.js';
+113
View File
@@ -0,0 +1,113 @@
import { asRecord } from '../util/parse.js';
interface TournamentState {
stage: number;
phase: number;
type: number;
auto: boolean;
openYear: number;
openMonth: number;
termSeconds: number;
nextAt: string;
bettingId?: number;
bettingCloseAt?: string;
winnerId?: number;
bettingSettled?: boolean;
rewardSettled?: boolean;
participantsLockedAt?: string;
lastError?: string;
lastErrorAt?: string;
}
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;
}
};
const buildTournamentKeys = (profileName: string) => ({
stateKey: `sammo:${profileName}:tournament:state`,
participantsKey: `sammo:${profileName}:tournament:participants`,
matchesKey: `sammo:${profileName}:tournament:matches`,
bettingKey: `sammo:${profileName}:tournament:betting`,
});
const resolveTermSeconds = (tickSeconds: number): number => {
const turnMinutes = Math.max(1, Math.round(tickSeconds / 60));
const clampedMinutes = Math.min(120, Math.max(5, turnMinutes));
return clampedMinutes * 60;
};
export const createTournamentAutoStartHandler = (options: {
profileName: string;
getRedisClient: () => RedisClientLike | null | undefined;
getWorldConfig: () => Record<string, unknown> | null | undefined;
getTickSeconds: () => number | null | undefined;
}): { onMonthChanged: (context: { currentYear: number; currentMonth: number }) => void } => {
const profileName = options.profileName;
const keys = buildTournamentKeys(profileName);
const triggerAutoStart = async (currentYear: number, currentMonth: number): Promise<void> => {
if (currentMonth % 2 !== 0) {
return;
}
const config = asRecord(options.getWorldConfig() ?? {});
if (config.tournamentTrig !== true) {
return;
}
const redis = options.getRedisClient();
if (!redis) {
return;
}
const state = safeJsonParse<TournamentState>(await redis.get(keys.stateKey));
if (state && state.stage > 0) {
return;
}
const now = new Date().toISOString();
const tickSeconds = options.getTickSeconds() ?? 0;
const termSeconds =
state && Number.isFinite(state.termSeconds) && state.termSeconds > 0
? state.termSeconds
: resolveTermSeconds(tickSeconds);
const nextState: TournamentState = {
stage: 1,
phase: 0,
type: state && typeof state.type === 'number' ? state.type : 0,
auto: true,
openYear: currentYear,
openMonth: currentMonth,
termSeconds,
nextAt: now,
bettingId: undefined,
bettingCloseAt: undefined,
winnerId: undefined,
bettingSettled: false,
rewardSettled: false,
participantsLockedAt: undefined,
lastError: undefined,
lastErrorAt: undefined,
};
await redis.set(keys.participantsKey, '[]');
await redis.set(keys.matchesKey, '[]');
await redis.set(keys.bettingKey, '[]');
await redis.set(keys.stateKey, JSON.stringify(nextState));
};
return {
onMonthChanged: (context) => {
void triggerAutoStart(context.currentYear, context.currentMonth);
},
};
};