feat: 토너먼트 자동 시작 핸들러 개선 및 관련 기능 추가
This commit is contained in:
@@ -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 },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user