feat: 토너먼트 자동 시작 핸들러 개선 및 관련 기능 추가
This commit is contained in:
@@ -20,35 +20,13 @@ type TournamentPrismaClient = {
|
|||||||
findMany: (args: {
|
findMany: (args: {
|
||||||
where: Record<string, unknown>;
|
where: Record<string, unknown>;
|
||||||
select: Record<string, boolean>;
|
select: Record<string, boolean>;
|
||||||
}) => Promise<
|
}) => Promise<Array<Record<string, unknown>>>;
|
||||||
Array<{
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
leadership: number;
|
|
||||||
strength: number;
|
|
||||||
intel: number;
|
|
||||||
meta: unknown;
|
|
||||||
npcState: number;
|
|
||||||
gold?: number;
|
|
||||||
}>
|
|
||||||
>;
|
|
||||||
update: (args: { where: { id: number }; data: { gold: number } }) => Promise<unknown>;
|
update: (args: { where: { id: number }; data: { gold: number } }) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
worldState: {
|
worldState: {
|
||||||
findFirst: () => Promise<{ meta?: unknown; currentYear?: number } | null>;
|
findFirst: () => Promise<{ meta?: unknown; currentYear?: number; config?: unknown } | 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>;
|
|
||||||
};
|
};
|
||||||
|
$transaction: (actions: Promise<unknown>[]) => Promise<unknown[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sleepMs = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleepMs = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
@@ -192,18 +170,22 @@ const fillParticipants = async (options: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const applicantPool = applicants
|
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) => {
|
.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 meta = asRecord(entry.meta);
|
||||||
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
|
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
|
||||||
return {
|
return {
|
||||||
item: {
|
item: {
|
||||||
id: entry.id,
|
id: entry.id as number,
|
||||||
name: entry.name,
|
name: typeof entry.name === 'string' ? entry.name : '무명장수',
|
||||||
leadership: entry.leadership,
|
leadership,
|
||||||
strength: entry.strength,
|
strength,
|
||||||
intel: entry.intel,
|
intel,
|
||||||
level,
|
level,
|
||||||
},
|
},
|
||||||
weight: Math.max(1, score ** 1.5),
|
weight: Math.max(1, score ** 1.5),
|
||||||
@@ -247,18 +229,22 @@ const fillParticipants = async (options: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const npcPool = npcRows
|
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) => {
|
.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 meta = asRecord(entry.meta);
|
||||||
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
|
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
|
||||||
return {
|
return {
|
||||||
item: {
|
item: {
|
||||||
id: entry.id,
|
id: entry.id as number,
|
||||||
name: entry.name,
|
name: typeof entry.name === 'string' ? entry.name : '무명장수',
|
||||||
leadership: entry.leadership,
|
leadership,
|
||||||
strength: entry.strength,
|
strength,
|
||||||
intel: entry.intel,
|
intel,
|
||||||
level,
|
level,
|
||||||
},
|
},
|
||||||
weight: Math.max(1, score ** 1.5),
|
weight: Math.max(1, score ** 1.5),
|
||||||
@@ -728,7 +714,10 @@ const seedNpcBets = async (options: {
|
|||||||
},
|
},
|
||||||
select: { id: true, gold: true },
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,16 +732,16 @@ const seedNpcBets = async (options: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const entries = [...existing];
|
const entries = [...existing];
|
||||||
for (const npc of npcList) {
|
for (const npc of npcBetList) {
|
||||||
const targetId = rng.choice(candidateIds);
|
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(
|
await prisma.$transaction(
|
||||||
npcList.map((npc) =>
|
npcBetList.map((npc) =>
|
||||||
prisma.general.update({
|
prisma.general.update({
|
||||||
where: { id: npc.id },
|
where: { id: npc.id as number },
|
||||||
data: { gold: npc.gold - betGold },
|
data: { gold: (npc.gold as number) - betGold },
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { TournamentType } from '@sammo-ts/logic';
|
import { TournamentType } from '@sammo-ts/logic';
|
||||||
import type { TurnDaemonCommand } from '@sammo-ts/common';
|
import type { TurnDaemonCommand } from '@sammo-ts/common';
|
||||||
|
import { createTournamentAutoStartHandler } from '@sammo-ts/common';
|
||||||
|
|
||||||
import { TournamentStore } from '../src/tournament/store.js';
|
import { TournamentStore } from '../src/tournament/store.js';
|
||||||
import { buildTournamentKeys } from '../src/tournament/keys.js';
|
import { buildTournamentKeys } from '../src/tournament/keys.js';
|
||||||
@@ -11,7 +12,6 @@ import {
|
|||||||
settleTournamentOutcome,
|
settleTournamentOutcome,
|
||||||
} from '../src/tournament/worker.js';
|
} from '../src/tournament/worker.js';
|
||||||
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
||||||
import { createTournamentAutoStartHandler } from '../../game-engine/src/turn/tournamentAutoStart.js';
|
|
||||||
|
|
||||||
class MemoryRedis {
|
class MemoryRedis {
|
||||||
private readonly store = new Map<string, string>();
|
private readonly store = new Map<string, string>();
|
||||||
@@ -153,6 +153,7 @@ const createPrismaMock = (options: {
|
|||||||
worldState: {
|
worldState: {
|
||||||
findFirst: async () => ({
|
findFirst: async () => ({
|
||||||
meta: { hiddenSeed: options.baseSeed ?? 'seed' },
|
meta: { hiddenSeed: options.baseSeed ?? 'seed' },
|
||||||
|
config: { const: { startYear: 1 } },
|
||||||
currentYear: options.currentYear ?? 1,
|
currentYear: options.currentYear ?? 1,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -269,11 +270,8 @@ describe('tournament worker (in-memory)', () => {
|
|||||||
getTickSeconds: () => 600,
|
getTickSeconds: () => 600,
|
||||||
});
|
});
|
||||||
handler.onMonthChanged?.({
|
handler.onMonthChanged?.({
|
||||||
previousYear: 1,
|
|
||||||
previousMonth: 1,
|
|
||||||
currentYear: 1,
|
currentYear: 1,
|
||||||
currentMonth: 2,
|
currentMonth: 2,
|
||||||
turnTime: new Date(),
|
|
||||||
});
|
});
|
||||||
await delayTick();
|
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 { RedisConnector } from '@sammo-ts/infra';
|
||||||
|
|
||||||
import type { TurnCalendarHandler } from './inMemoryWorld.js';
|
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: {
|
export const createTournamentAutoStartHandler = (options: {
|
||||||
profileName: string;
|
profileName: string;
|
||||||
getRedisClient: () => RedisConnector['client'] | null | undefined;
|
getRedisClient: () => RedisConnector['client'] | null | undefined;
|
||||||
getWorldConfig: () => Record<string, unknown> | null | undefined;
|
getWorldConfig: () => Record<string, unknown> | null | undefined;
|
||||||
getTickSeconds: () => number | null | undefined;
|
getTickSeconds: () => number | null | undefined;
|
||||||
}): TurnCalendarHandler => {
|
}): TurnCalendarHandler => {
|
||||||
const profileName = options.profileName;
|
return createCommonTournamentAutoStartHandler(options);
|
||||||
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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
@@ -11,6 +11,7 @@ export * from './util/TestRNG.js';
|
|||||||
export * from './util/TournamentRNG.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 './tournament/autoStart.js';
|
||||||
export * from './turnDaemon/types.js';
|
export * from './turnDaemon/types.js';
|
||||||
export * from './realtime/keys.js';
|
export * from './realtime/keys.js';
|
||||||
export * from './realtime/types.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