From 6e439f074de3a4d1cb226c2fd7440364caefc5ac Mon Sep 17 00:00:00 2001 From: Hide_D Date: Fri, 23 Jan 2026 18:58:05 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=86=A0=EB=84=88=EB=A8=BC=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=8B=9C=EC=9E=91=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=9F=AC=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/tournament/worker.ts | 79 ++++++------ app/game-api/test/tournamentWorker.test.ts | 6 +- .../src/turn/tournamentAutoStart.ts | 102 +--------------- packages/common/src/index.ts | 1 + packages/common/src/tournament/autoStart.ts | 113 ++++++++++++++++++ 5 files changed, 152 insertions(+), 149 deletions(-) create mode 100644 packages/common/src/tournament/autoStart.ts diff --git a/app/game-api/src/tournament/worker.ts b/app/game-api/src/tournament/worker.ts index bbcca50..3b9c686 100644 --- a/app/game-api/src/tournament/worker.ts +++ b/app/game-api/src/tournament/worker.ts @@ -20,35 +20,13 @@ type TournamentPrismaClient = { findMany: (args: { where: Record; select: Record; - }) => Promise< - Array<{ - id: number; - name: string; - leadership: number; - strength: number; - intel: number; - meta: unknown; - npcState: number; - gold?: number; - }> - >; + }) => Promise>>; update: (args: { where: { id: number }; data: { gold: number } }) => Promise; }; worldState: { - findFirst: () => Promise<{ meta?: unknown; currentYear?: number } | null>; - }; - $transaction: (actions: Promise[]) => Promise; - errorLog: { - create: (args: { - data: { - category: string; - source: string; - message: string; - trace?: string; - context?: Record; - }; - }) => Promise; + findFirst: () => Promise<{ meta?: unknown; currentYear?: number; config?: unknown } | null>; }; + $transaction: (actions: Promise[]) => Promise; }; const sleepMs = (ms: number): Promise => 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 }, }) ) ); diff --git a/app/game-api/test/tournamentWorker.test.ts b/app/game-api/test/tournamentWorker.test.ts index 77d19e2..29b1e49 100644 --- a/app/game-api/test/tournamentWorker.test.ts +++ b/app/game-api/test/tournamentWorker.test.ts @@ -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(); @@ -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(); diff --git a/app/game-engine/src/turn/tournamentAutoStart.ts b/app/game-engine/src/turn/tournamentAutoStart.ts index 67f78da..57993d4 100644 --- a/app/game-engine/src/turn/tournamentAutoStart.ts +++ b/app/game-engine/src/turn/tournamentAutoStart.ts @@ -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 = (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 | null | undefined; getTickSeconds: () => number | null | undefined; }): TurnCalendarHandler => { - const profileName = options.profileName; - const keys = buildTournamentKeys(profileName); - const triggerAutoStart = async (currentYear: number, currentMonth: number): Promise => { - 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(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); }; \ No newline at end of file diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index f786bc6..f661d45 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -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'; diff --git a/packages/common/src/tournament/autoStart.ts b/packages/common/src/tournament/autoStart.ts new file mode 100644 index 0000000..88753a7 --- /dev/null +++ b/packages/common/src/tournament/autoStart.ts @@ -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; + set(key: string, value: string): Promise; +} + +const safeJsonParse = (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 | 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 => { + 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(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); + }, + }; +}; \ No newline at end of file