diff --git a/app/game-api/package.json b/app/game-api/package.json index 1e018e8..07a3f9f 100644 --- a/app/game-api/package.json +++ b/app/game-api/package.json @@ -18,6 +18,8 @@ "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: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:fix": "eslint . --fix", "test": "vitest run --config vitest.config.ts", diff --git a/app/game-api/src/config.ts b/app/game-api/src/config.ts index 6ba5c09..0b68bfa 100644 --- a/app/game-api/src/config.ts +++ b/app/game-api/src/config.ts @@ -14,6 +14,7 @@ export interface GameApiConfig { auctionTimerPollMs: number; auctionTimerResyncMs: number; auctionTimerRetentionSeconds: number; + tournamentPollMs: number; gameTokenSecret: string; flushChannel: string; } @@ -62,6 +63,11 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env 21600, 'AUCTION_TIMER_RETENTION_SECONDS' ), + tournamentPollMs: parseNumberWithFallback( + env.TOURNAMENT_POLL_MS, + 1000, + 'TOURNAMENT_POLL_MS' + ), gameTokenSecret: secret, flushChannel: `${gatewayPrefix}:flush`, }; diff --git a/app/game-api/src/index.ts b/app/game-api/src/index.ts index 3e9da27..6aa5373 100644 --- a/app/game-api/src/index.ts +++ b/app/game-api/src/index.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; import { runGameApiServer } from './server.js'; import { runBattleSimWorker } from './battleSim/worker.js'; import { runAuctionWorker } from './auction/worker.js'; +import { runTournamentWorker } from './tournament/worker.js'; export * from './config.js'; export * from './context.js'; @@ -26,6 +27,7 @@ export * from './auction/types.js'; export * from './auction/keys.js'; export * from './auction/scheduler.js'; export * from './auction/worker.js'; +export * from './tournament/worker.js'; // Types for TRPC consumer export type { MessageView } from './messages/store.js'; @@ -49,8 +51,10 @@ if (isMain()) { role === 'battle-sim-worker' ? runBattleSimWorker : role === 'auction-worker' - ? runAuctionWorker - : runGameApiServer; + ? runAuctionWorker + : role === 'tournament-worker' + ? runTournamentWorker + : runGameApiServer; run().catch((error) => { console.error('[game-api] failed to start', error); process.exitCode = 1; diff --git a/app/game-api/src/tournament/keys.ts b/app/game-api/src/tournament/keys.ts new file mode 100644 index 0000000..2caa186 --- /dev/null +++ b/app/game-api/src/tournament/keys.ts @@ -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`, +}); diff --git a/app/game-api/src/tournament/store.ts b/app/game-api/src/tournament/store.ts new file mode 100644 index 0000000..1376de3 --- /dev/null +++ b/app/game-api/src/tournament/store.ts @@ -0,0 +1,46 @@ +import type { TournamentKeys } from './keys.js'; +import type { TournamentMatchEntry, TournamentParticipantEntry, TournamentState } from './types.js'; + +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; + } +}; + +export class TournamentStore { + constructor(private readonly redis: RedisClientLike, private readonly keys: TournamentKeys) {} + + async getState(): Promise { + return safeJsonParse(await this.redis.get(this.keys.stateKey)); + } + + async setState(state: TournamentState): Promise { + await this.redis.set(this.keys.stateKey, JSON.stringify(state)); + } + + async getParticipants(): Promise { + return safeJsonParse(await this.redis.get(this.keys.participantsKey)) ?? []; + } + + async setParticipants(participants: TournamentParticipantEntry[]): Promise { + await this.redis.set(this.keys.participantsKey, JSON.stringify(participants)); + } + + async getMatches(): Promise { + return safeJsonParse(await this.redis.get(this.keys.matchesKey)) ?? []; + } + + async setMatches(matches: TournamentMatchEntry[]): Promise { + await this.redis.set(this.keys.matchesKey, JSON.stringify(matches)); + } +} diff --git a/app/game-api/src/tournament/types.ts b/app/game-api/src/tournament/types.ts new file mode 100644 index 0000000..7f1e142 --- /dev/null +++ b/app/game-api/src/tournament/types.ts @@ -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[]; +} diff --git a/app/game-api/src/tournament/worker.ts b/app/game-api/src/tournament/worker.ts new file mode 100644 index 0000000..a16903e --- /dev/null +++ b/app/game-api/src/tournament/worker.ts @@ -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 => 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 => { + 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 => { + 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 | 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); + } +}; diff --git a/app/game-engine/src/lifecycle/redisCommandStream.ts b/app/game-engine/src/lifecycle/redisCommandStream.ts index 7886766..1eff2b7 100644 --- a/app/game-engine/src/lifecycle/redisCommandStream.ts +++ b/app/game-engine/src/lifecycle/redisCommandStream.ts @@ -210,6 +210,29 @@ const normalizeCommand = (envelope: TurnDaemonCommandEnvelope): TurnDaemonComman 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': { const requestId = typeof command.requestId === 'string' ? command.requestId : envelope.requestId; return { type: 'getStatus', requestId }; diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 86c7463..ce0ac17 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -451,6 +451,54 @@ async function handleAppoint( return { type: 'appoint', ok: true, generalId: command.generalId }; } +async function handleTournamentRefund( + ctx: CommandHandlerContext, + command: Extract +): Promise { + 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: { world: InMemoryTurnWorld; hooks?: TurnDaemonHooks; @@ -485,6 +533,8 @@ export const createTurnDaemonCommandHandler = (options: { return handleKick(ctx, command); case 'appoint': return handleAppoint(ctx, command); + case 'tournamentRefund': + return handleTournamentRefund(ctx, command); default: return null; } diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 738fe0f..f786bc6 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -8,6 +8,7 @@ export * from './util/JosaUtil.js'; export * from './util/RNG.js'; export * from './util/RandUtil.js'; export * from './util/TestRNG.js'; +export * from './util/TournamentRNG.js'; export * from './util/sha512.js'; export * from './util/parse.js'; export * from './turnDaemon/types.js'; diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index f662a22..fde5a1d 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -85,7 +85,17 @@ export type TurnDaemonCommand = destGeneralId: number; destCityId: number; officerLevel: number; - }; + } + | { + type: 'tournamentRefund'; + requestId?: string; + bettingId?: number; + reason?: string; + refunds: Array<{ + generalId: number; + amount: number; + }>; + }; export type TurnDaemonCommandResult = | { @@ -132,7 +142,21 @@ export type TurnDaemonCommandResult = | { type: 'dropItem'; ok: boolean; generalId: number; reason?: string } | { type: 'changePermission'; 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 = | { type: 'status'; requestId?: string; status: TurnDaemonStatus } diff --git a/packages/common/src/util/TournamentRNG.ts b/packages/common/src/util/TournamentRNG.ts new file mode 100644 index 0000000..6aec88c --- /dev/null +++ b/packages/common/src/util/TournamentRNG.ts @@ -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); diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index 6684958..8aa629a 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -220,6 +220,19 @@ model LogEntry { @@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 { id Int @id @default(autoincrement()) userId String @map("user_id") diff --git a/packages/infra/src/errorLogRepository.ts b/packages/infra/src/errorLogRepository.ts new file mode 100644 index 0000000..e6847b7 --- /dev/null +++ b/packages/infra/src/errorLogRepository.ts @@ -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 { + 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 { + const base: GamePrisma.ErrorLogWhereInput = options.category ? { category: options.category } : {}; + return this.prisma.errorLog.findMany(buildFindArgs(base, options)); + } +} diff --git a/packages/infra/src/index.ts b/packages/infra/src/index.ts index 4e3738b..25bf645 100644 --- a/packages/infra/src/index.ts +++ b/packages/infra/src/index.ts @@ -10,6 +10,7 @@ export { } from './gatewayPrisma.js'; export type { GatewayPrismaClient } from './gatewayPrisma.js'; export * from './db.js'; +export * from './errorLogRepository.js'; export * from './logRepository.js'; export * from './redis.js'; export * from './turnEngineDb.js'; diff --git a/packages/logic/src/index.ts b/packages/logic/src/index.ts index 68b8a52..fcb6d06 100644 --- a/packages/logic/src/index.ts +++ b/packages/logic/src/index.ts @@ -15,5 +15,6 @@ export * from './ports/worldSnapshot.js'; export * from './scenario/index.js'; export * from './triggers/index.js'; export * from './turn/index.js'; +export * from './tournament/index.js'; export * from './world/index.js'; export * from './war/index.js'; diff --git a/packages/logic/src/tournament/battle.ts b/packages/logic/src/tournament/battle.ts new file mode 100644 index 0000000..94abd91 --- /dev/null +++ b/packages/logic/src/tournament/battle.ts @@ -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(`${attacker.name} (${energyBaseAttacker}) vs (${energyBaseDefender}) ${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(`${attacker.name}의 분노의 일격!`); + } + if (energyBaseDefender / 5 > energyDefender && damageDefender > damageAttacker && defenderStat >= rng.nextInt(300)) { + factorAttacker = round((rng.nextInt(301) + 200) / 100); + criticalDefender = true; + log.push(`${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 = + `● ${String(phase).padStart(2, '0')}合 : ` + + `${String(round(energyAttacker)).padStart(3, '0')}` + + `(-${String(round(damageAttacker)).padStart(3, '0')})` + + ' vs ' + + `(-${String(round(damageDefender)).padStart(3, '0')})` + + `${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(`${attacker.name} 승리!`); + } else if (selected === 1) { + log.push(`${defender.name} 승리!`); + } else { + log.push(`${attacker.name} 무승부!`); + } + + 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, + }; +}; diff --git a/packages/logic/src/tournament/index.ts b/packages/logic/src/tournament/index.ts new file mode 100644 index 0000000..cc70a9d --- /dev/null +++ b/packages/logic/src/tournament/index.ts @@ -0,0 +1,2 @@ +export * from './types.js'; +export * from './battle.js'; diff --git a/packages/logic/src/tournament/types.ts b/packages/logic/src/tournament/types.ts new file mode 100644 index 0000000..cd032ce --- /dev/null +++ b/packages/logic/src/tournament/types.ts @@ -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; +}