diff --git a/app/game-api/src/router.ts b/app/game-api/src/router.ts index 24ba073..4c76a38 100644 --- a/app/game-api/src/router.ts +++ b/app/game-api/src/router.ts @@ -23,6 +23,7 @@ import { yearbookRouter } from './router/yearbook/index.js'; import { rankingRouter } from './router/ranking/index.js'; import { dynastyRouter } from './router/dynasty/index.js'; import { voteRouter } from './router/vote/index.js'; +import { bettingRouter } from './router/betting/index.js'; export const appRouter = router({ health: healthRouter, @@ -48,6 +49,7 @@ export const appRouter = router({ ranking: rankingRouter, dynasty: dynastyRouter, vote: voteRouter, + betting: bettingRouter, }); export type AppRouter = typeof appRouter; diff --git a/app/game-api/src/router/betting/index.ts b/app/game-api/src/router/betting/index.ts new file mode 100644 index 0000000..b1bf204 --- /dev/null +++ b/app/game-api/src/router/betting/index.ts @@ -0,0 +1,234 @@ +import { TRPCError } from '@trpc/server'; +import { GamePrisma } from '@sammo-ts/infra'; +import { z } from 'zod'; + +import { authedProcedure, router } from '../../trpc.js'; +import { appendInheritanceLog, readInheritancePoint, setInheritancePoint } from '../../services/inheritance.js'; +import { getMyGeneral } from '../shared/general.js'; + +const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1; + +const purifySelection = (selection: readonly number[]): number[] => + [...new Set(selection)].sort((left, right) => left - right); + +const requireUserId = (auth: { user: { id: string } } | null): string => { + const userId = auth?.user.id; + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + return userId; +}; + +const loadWorldDate = async (db: Parameters[0]['db']) => { + const world = await db.worldState.findFirst({ + select: { currentYear: true, currentMonth: true }, + }); + if (!world) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state not found.' }); + } + return world; +}; + +export const bettingRouter = router({ + getList: authedProcedure + .input(z.object({ req: z.enum(['bettingNation', 'tournament']).optional() }).optional()) + .query(async ({ ctx, input }) => { + requireUserId(ctx.auth); + await getMyGeneral(ctx); + const [world, rows] = await Promise.all([ + loadWorldDate(ctx.db), + ctx.db.nationBetting.findMany({ + where: input?.req ? { type: input.req } : undefined, + orderBy: { id: 'asc' }, + include: { bets: { select: { amount: true } } }, + }), + ]); + const bettingList = Object.fromEntries( + rows.map((row) => [ + row.id, + { + id: row.id, + type: row.type, + name: row.name, + finished: row.finished, + selectCnt: row.selectCount, + isExclusive: row.isExclusive, + reqInheritancePoint: row.requiresInheritancePoint, + openYearMonth: row.openYearMonth, + closeYearMonth: row.closeYearMonth, + winner: row.winner, + totalAmount: row.bets.reduce((sum, bet) => sum + bet.amount, 0), + }, + ]) + ); + return { + result: true, + bettingList, + year: world.currentYear, + month: world.currentMonth, + }; + }), + + getDetail: authedProcedure + .input(z.object({ bettingId: z.number().int().positive() })) + .query(async ({ ctx, input }) => { + const userId = requireUserId(ctx.auth); + const general = await getMyGeneral(ctx); + const [world, betting, remainPoint] = await Promise.all([ + loadWorldDate(ctx.db), + ctx.db.nationBetting.findUnique({ + where: { id: input.bettingId }, + include: { bets: { orderBy: { id: 'asc' } } }, + }), + ctx.db.inheritancePoint.findUnique({ + where: { userId_key: { userId, key: 'previous' } }, + select: { value: true }, + }), + ]); + if (!betting) { + throw new TRPCError({ code: 'NOT_FOUND', message: '해당 베팅이 없습니다' }); + } + const group = (bets: typeof betting.bets) => { + const totals = new Map(); + for (const bet of bets) { + totals.set(bet.selectionKey, (totals.get(bet.selectionKey) ?? 0) + bet.amount); + } + return Array.from(totals, ([selection, amount]) => [selection, amount] as const); + }; + return { + result: true, + bettingInfo: { + id: betting.id, + type: betting.type, + name: betting.name, + finished: betting.finished, + selectCnt: betting.selectCount, + isExclusive: betting.isExclusive, + reqInheritancePoint: betting.requiresInheritancePoint, + openYearMonth: betting.openYearMonth, + closeYearMonth: betting.closeYearMonth, + candidates: betting.candidates, + winner: betting.winner, + }, + bettingDetail: group(betting.bets), + myBetting: group(betting.bets.filter((bet) => bet.userId === userId)), + remainPoint: betting.requiresInheritancePoint ? (remainPoint?.value ?? 0) : general.gold, + year: world.currentYear, + month: world.currentMonth, + }; + }), + + bet: authedProcedure + .input( + z.object({ + bettingId: z.number().int().positive(), + bettingType: z.array(z.number().int().nonnegative()), + amount: z.number().int().min(10), + }) + ) + .mutation(async ({ ctx, input }) => { + const userId = requireUserId(ctx.auth); + const general = await getMyGeneral(ctx); + await ctx.db.$queryRaw` + SELECT id + FROM nation_betting + WHERE id = ${input.bettingId} + FOR UPDATE + `; + const betting = await ctx.db.nationBetting.findUnique({ where: { id: input.bettingId } }); + if (!betting) { + throw new TRPCError({ code: 'NOT_FOUND', message: `해당 베팅이 없습니다: ${input.bettingId}` }); + } + if (betting.finished) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 종료된 베팅입니다' }); + } + const world = await loadWorldDate(ctx.db); + const yearMonth = joinYearMonth(world.currentYear, world.currentMonth); + if (betting.closeYearMonth <= yearMonth) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 마감된 베팅입니다' }); + } + if (betting.openYearMonth > yearMonth) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '아직 시작되지 않은 베팅입니다' }); + } + + const selection = purifySelection(input.bettingType); + if (selection.length !== betting.selectCount || input.bettingType.length !== betting.selectCount) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '필요한 선택 수를 채우지 못했습니다.' }); + } + const candidates = Array.isArray(betting.candidates) ? betting.candidates : []; + if (selection.some((index) => index >= candidates.length)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '올바른 후보가 아닙니다.' }); + } + const selectionKey = JSON.stringify(selection); + const totals = await ctx.db.$queryRaw>( + GamePrisma.sql` + SELECT SUM(amount)::float8 AS total + FROM nation_bet + WHERE betting_id = ${input.bettingId} + AND user_id = ${userId} + ` + ); + const previousBetAmount = totals[0]?.total ?? 0; + if (previousBetAmount + input.amount > 1_000) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `${1_000 - previousBetAmount}${betting.requiresInheritancePoint ? '유산포인트' : '금'}까지만 베팅 가능합니다.`, + }); + } + + if (betting.requiresInheritancePoint) { + const remainingPoint = await readInheritancePoint(ctx.db, userId, 'previous'); + if (remainingPoint < input.amount) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '유산포인트가 충분하지 않습니다.' }); + } + await setInheritancePoint(ctx.db, userId, 'previous', remainingPoint - input.amount); + await appendInheritanceLog( + ctx.db, + userId, + world.currentYear, + world.currentMonth, + `${input.amount} 포인트를 베팅에 사용` + ); + await ctx.db.rankData.upsert({ + where: { + generalId_type: { + generalId: general.id, + type: 'inherit_spent_dyn', + }, + }, + update: { value: { increment: input.amount } }, + create: { + generalId: general.id, + nationId: general.nationId, + type: 'inherit_spent_dyn', + value: input.amount, + }, + }); + } else { + throw new TRPCError({ + code: 'NOT_IMPLEMENTED', + message: 'Nation betting currently requires inheritance points.', + }); + } + + await ctx.db.nationBet.upsert({ + where: { + bettingId_userId_selectionKey: { + bettingId: input.bettingId, + userId, + selectionKey, + }, + }, + update: { amount: { increment: input.amount }, generalId: general.id, selection }, + create: { + bettingId: input.bettingId, + generalId: general.id, + userId, + selection, + selectionKey, + amount: input.amount, + }, + }); + return { result: true }; + }), +}); diff --git a/app/game-api/test/nationBettingRouter.integration.test.ts b/app/game-api/test/nationBettingRouter.integration.test.ts new file mode 100644 index 0000000..b1f6832 --- /dev/null +++ b/app/game-api/test/nationBettingRouter.integration.test.ts @@ -0,0 +1,240 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import { createGamePostgresConnector, type GamePrismaClient, type RedisConnector } from '@sammo-ts/infra'; + +import type { GameApiContext } from '../src/context.js'; +import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; +import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { appRouter } from '../src/router.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const bettingId = 990_071; +const concurrentBettingId = 990_072; +const generalId = 9_971; +const nationId = 990_071; +const userId = 'nation-betting-router-user'; + +const auth: GameSessionTokenPayload = { + version: 1, + profile: 'che:2', + issuedAt: '2026-07-25T00:00:00.000Z', + expiresAt: '2026-07-26T00:00:00.000Z', + sessionId: 'nation-betting-router-session', + user: { + id: userId, + username: 'bettor', + displayName: 'Bettor', + roles: ['user'], + }, + sanctions: {}, +}; + +integration('nation betting router', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + let worldStateId: number; + + const buildContext = (requestId: string): GameApiContext => { + const redisClient = { + get: async () => null, + set: async () => null, + }; + return { + requestId, + db, + redis: redisClient as unknown as RedisConnector['client'], + turnDaemon: new InMemoryTurnDaemonTransport(), + battleSim: new InMemoryBattleSimTransport(), + profile: { id: 'che', scenario: '2', name: 'che:2' }, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + auth, + accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:2'), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; + }; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await db.inputEvent.deleteMany({ where: { actorUserId: userId } }); + await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } }); + await db.rankData.deleteMany({ where: { generalId } }); + await db.inheritanceLog.deleteMany({ where: { userId } }); + await db.inheritancePoint.deleteMany({ where: { userId } }); + await db.general.deleteMany({ where: { id: generalId } }); + await db.nation.deleteMany({ where: { id: nationId } }); + + await db.nation.create({ + data: { + id: nationId, + name: '베팅국', + color: '#123456', + level: 2, + }, + }); + await db.general.create({ + data: { + id: generalId, + userId, + name: '베팅장수', + nationId, + cityId: 1, + npcState: 0, + turnTime: new Date('0200-01-01T00:00:00.000Z'), + meta: {}, + }, + }); + const world = await db.worldState.create({ + data: { + scenarioCode: 'nation-betting-router', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: {}, + }, + }); + worldStateId = world.id; + await db.nationBetting.create({ + data: { + id: bettingId, + name: '천통국 예상', + selectCount: 1, + requiresInheritancePoint: true, + openYearMonth: 2_400, + closeYearMonth: 2_424, + candidates: [ + { + title: '베팅국', + info: '국력: 100
장수 수: 1
도시 수: 1', + isHtml: true, + aux: { nation: nationId }, + }, + ], + }, + }); + await db.nationBetting.create({ + data: { + id: concurrentBettingId, + name: '동시 베팅', + selectCount: 1, + requiresInheritancePoint: true, + openYearMonth: 2_400, + closeYearMonth: 2_424, + candidates: [{ title: '베팅국', info: '', isHtml: true, aux: { nation: nationId } }], + }, + }); + await db.inheritancePoint.create({ + data: { userId, key: 'previous', value: 1_000 }, + }); + }); + + afterAll(async () => { + await db.inputEvent.deleteMany({ where: { actorUserId: userId } }); + await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } }); + await db.rankData.deleteMany({ where: { generalId } }); + await db.inheritanceLog.deleteMany({ where: { userId } }); + await db.inheritancePoint.deleteMany({ where: { userId } }); + await db.general.deleteMany({ where: { id: generalId } }); + await db.nation.deleteMany({ where: { id: nationId } }); + await db.worldState.delete({ where: { id: worldStateId } }); + await closeDb?.(); + }); + + it('returns legacy-shaped data and atomically deducts and accumulates a bet', async () => { + const detailBefore = await appRouter + .createCaller(buildContext('nation-betting-detail-before')) + .betting.getDetail({ bettingId }); + expect(detailBefore).toMatchObject({ + result: true, + bettingInfo: { id: bettingId, selectCnt: 1, reqInheritancePoint: true }, + remainPoint: 1_000, + year: 200, + month: 1, + }); + + await expect( + appRouter.createCaller(buildContext('nation-betting-duplicate')).betting.bet({ + bettingId, + bettingType: [0, 0], + amount: 100, + }) + ).rejects.toMatchObject({ message: '필요한 선택 수를 채우지 못했습니다.' }); + + await expect( + appRouter.createCaller(buildContext('nation-betting-first')).betting.bet({ + bettingId, + bettingType: [0], + amount: 100, + }) + ).resolves.toEqual({ result: true }); + await expect( + appRouter.createCaller(buildContext('nation-betting-second')).betting.bet({ + bettingId, + bettingType: [0], + amount: 50, + }) + ).resolves.toEqual({ result: true }); + + expect(await db.nationBet.findMany({ where: { bettingId } })).toEqual([ + expect.objectContaining({ + generalId, + userId, + selection: [0], + selectionKey: '[0]', + amount: 150, + }), + ]); + expect( + await db.inheritancePoint.findUniqueOrThrow({ + where: { userId_key: { userId, key: 'previous' } }, + }) + ).toMatchObject({ value: 850 }); + expect( + await db.rankData.findUniqueOrThrow({ + where: { generalId_type: { generalId, type: 'inherit_spent_dyn' } }, + }) + ).toMatchObject({ nationId, value: 150 }); + expect(await db.inheritanceLog.count({ where: { userId } })).toBe(2); + + const detailAfter = await appRouter + .createCaller(buildContext('nation-betting-detail-after')) + .betting.getDetail({ bettingId }); + expect(detailAfter).toMatchObject({ + bettingDetail: [['[0]', 150]], + myBetting: [['[0]', 150]], + remainPoint: 850, + }); + }); + + it('serializes concurrent bets so the cumulative 1,000 point limit cannot be overspent', async () => { + const results = await Promise.allSettled([ + appRouter.createCaller(buildContext('nation-betting-concurrent-a')).betting.bet({ + bettingId: concurrentBettingId, + bettingType: [0], + amount: 600, + }), + appRouter.createCaller(buildContext('nation-betting-concurrent-b')).betting.bet({ + bettingId: concurrentBettingId, + bettingType: [0], + amount: 600, + }), + ]); + expect(results.map((result) => result.status).sort()).toEqual(['fulfilled', 'rejected']); + expect(await db.nationBet.aggregate({ where: { bettingId: concurrentBettingId }, _sum: { amount: true } })) + .toMatchObject({ _sum: { amount: 600 } }); + expect( + await db.inheritancePoint.findUniqueOrThrow({ + where: { userId_key: { userId, key: 'previous' } }, + }) + ).toMatchObject({ value: 250 }); + }); +}); diff --git a/app/game-engine/src/betting/nationBettingSettlement.ts b/app/game-engine/src/betting/nationBettingSettlement.ts new file mode 100644 index 0000000..5e71496 --- /dev/null +++ b/app/game-engine/src/betting/nationBettingSettlement.ts @@ -0,0 +1,120 @@ +export interface NationBettingStake { + generalId: number; + userId: string | null; + selection: number[]; + amount: number; +} + +export interface NationBettingReward { + generalId: number; + userId: string | null; + amount: number; + matchPoint: number; +} + +export const purifyNationBettingSelection = ( + selection: readonly number[], + selectCount: number +): number[] => { + const purified = [...selection].sort((left, right) => left - right); + const unique = purified.filter((value, index) => index === 0 || value !== purified[index - 1]); + if (unique.length !== selectCount) { + throw new Error('중복된 값이 있습니다.'); + } + return unique; +}; + +export const calculateNationBettingRewards = (options: { + selectCount: number; + isExclusive: boolean | null; + winner: readonly number[]; + stakes: readonly NationBettingStake[]; +}): NationBettingReward[] => { + const winner = purifyNationBettingSelection(options.winner, options.selectCount); + const totalAmount = options.stakes.reduce((sum, stake) => sum + stake.amount, 0); + if (totalAmount === 0) { + return []; + } + + if (options.selectCount === 1 || options.isExclusive === true) { + const winnerKey = JSON.stringify(winner); + const winnerList = options.stakes.filter( + (stake) => stake.generalId > 0 && JSON.stringify(stake.selection) === winnerKey + ); + const winnerAmount = winnerList.reduce((sum, stake) => sum + stake.amount, 0); + // Legacy creates a refundList here but accidentally returns an empty + // result. Preserve that observable no-winner behavior. + if (winnerAmount === 0) { + return []; + } + const multiplier = totalAmount / winnerAmount; + return winnerList.map((stake) => ({ + generalId: stake.generalId, + userId: stake.userId, + amount: stake.amount * multiplier, + matchPoint: options.selectCount, + })); + } + + const winnerSet = new Set(winner); + const stakesByMatch = new Map(); + const amountByMatch = new Map(); + for (let matchPoint = 0; matchPoint <= options.selectCount; matchPoint += 1) { + stakesByMatch.set(matchPoint, []); + amountByMatch.set(matchPoint, 0); + } + for (const stake of options.stakes) { + const matchPoint = stake.selection.reduce( + (count, selected) => count + (winnerSet.has(selected) ? 1 : 0), + 0 + ); + if (stake.generalId === 0) { + continue; + } + stakesByMatch.get(matchPoint)?.push(stake); + amountByMatch.set(matchPoint, (amountByMatch.get(matchPoint) ?? 0) + stake.amount); + } + + let remainingReward = totalAmount; + let accumulatedReward = 0; + let givenReward = totalAmount; + const rewardByMatch = new Map(); + for (let matchPoint = options.selectCount; matchPoint >= 1; matchPoint -= 1) { + givenReward /= 2; + accumulatedReward += givenReward; + if ((stakesByMatch.get(matchPoint)?.length ?? 0) === 0 || (amountByMatch.get(matchPoint) ?? 0) === 0) { + continue; + } + rewardByMatch.set(matchPoint, accumulatedReward); + remainingReward -= accumulatedReward; + accumulatedReward = 0; + } + + for (let matchPoint = options.selectCount; matchPoint >= 0; matchPoint -= 1) { + const reward = rewardByMatch.get(matchPoint); + if (reward === undefined) { + continue; + } + rewardByMatch.set(matchPoint, reward + remainingReward); + break; + } + + const result: NationBettingReward[] = []; + for (let matchPoint = options.selectCount; matchPoint >= 1; matchPoint -= 1) { + const reward = rewardByMatch.get(matchPoint); + const staked = amountByMatch.get(matchPoint) ?? 0; + if (!reward || staked === 0) { + continue; + } + const multiplier = reward / staked; + for (const stake of stakesByMatch.get(matchPoint) ?? []) { + result.push({ + generalId: stake.generalId, + userId: stake.userId, + amount: stake.amount * multiplier, + matchPoint, + }); + } + } + return result; +}; diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index f4888c3..7f058a7 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -16,6 +16,7 @@ import { import { finalizeLogEntry, LogCategory, + LogFormat, LogScope, sendMessage, type LogEntryDraft, @@ -30,6 +31,12 @@ import { buildDiplomacyMeta } from '@sammo-ts/logic'; import { ensureItemInventory, withSerializedItemInventory } from '@sammo-ts/logic/items/index.js'; import { persistGeneralLifecycleEvents } from './generalTurnLifecyclePersistence.js'; import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLease.js'; +import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js'; +import type { + NationBettingCandidate, + PendingNationBettingFinish, + PendingNationBettingOpen, +} from './types.js'; export interface DatabaseTurnHooks { hooks: TurnDaemonHooks; @@ -37,6 +44,225 @@ export interface DatabaseTurnHooks { } const asJson = (value: unknown): InputJsonValue => value as InputJsonValue; +const formatLegacyNumber = (value: number): string => Math.round(value).toLocaleString('en-US'); + +const readBettingCandidates = (value: unknown): NationBettingCandidate[] => { + if (!Array.isArray(value)) { + return []; + } + return value.flatMap((candidate) => { + const item = asRecord(candidate); + const aux = asRecord(item.aux); + if ( + typeof item.title !== 'string' || + typeof aux.nation !== 'number' || + !Number.isInteger(aux.nation) + ) { + return []; + } + return [ + { + title: item.title, + info: typeof item.info === 'string' ? item.info : '', + isHtml: true as const, + aux: { + nation: aux.nation, + name: typeof aux.name === 'string' ? aux.name : item.title, + color: typeof aux.color === 'string' ? aux.color : '#000000', + type: typeof aux.type === 'string' ? aux.type : '', + level: typeof aux.level === 'number' ? aux.level : 0, + capital: typeof aux.capital === 'number' ? aux.capital : null, + gennum: typeof aux.gennum === 'number' ? aux.gennum : 0, + power: typeof aux.power === 'number' ? aux.power : 0, + city_cnt: typeof aux.city_cnt === 'number' ? aux.city_cnt : 0, + }, + }, + ]; + }); +}; + +const persistNationBettingOpen = async ( + prisma: GamePrisma.TransactionClient, + betting: PendingNationBettingOpen +): Promise => { + await prisma.nationBetting.create({ + data: { + id: betting.id, + type: 'bettingNation', + name: betting.name, + finished: false, + selectCount: betting.selectCount, + isExclusive: betting.isExclusive, + requiresInheritancePoint: betting.requiresInheritancePoint, + openYearMonth: betting.openYearMonth, + closeYearMonth: betting.closeYearMonth, + candidates: asJson(betting.candidates), + }, + }); + if (betting.bonusPoint > 0) { + await prisma.nationBet.create({ + data: { + bettingId: betting.id, + generalId: 0, + userId: null, + selection: [-1], + selectionKey: '[-1]', + amount: betting.bonusPoint, + }, + }); + } +}; + +const persistNationBettingFinish = async ( + prisma: GamePrisma.TransactionClient, + finish: PendingNationBettingFinish +): Promise => { + await prisma.$queryRaw` + SELECT id + FROM nation_betting + WHERE id = ${finish.id} + FOR UPDATE + `; + const betting = await prisma.nationBetting.findUnique({ + where: { id: finish.id }, + include: { bets: { orderBy: { id: 'asc' } } }, + }); + if (!betting || betting.type !== 'bettingNation' || betting.finished) { + return; + } + if (finish.winnerNationIds.length !== betting.selectCount) { + return; + } + + const candidates = readBettingCandidates(betting.candidates); + const candidateIndexByNation = new Map(candidates.map((candidate, index) => [candidate.aux.nation, index])); + let newNationOffset = 0; + const winner = finish.winnerNationIds.map((nationId) => { + const candidateIndex = candidateIndexByNation.get(nationId); + if (candidateIndex !== undefined) { + return candidateIndex; + } + const result = candidates.length + newNationOffset; + newNationOffset += 1; + return result; + }); + const purifiedWinner = [...new Set(winner)].sort((left, right) => left - right); + if (purifiedWinner.length !== betting.selectCount) { + return; + } + + const rewards = calculateNationBettingRewards({ + selectCount: betting.selectCount, + isExclusive: betting.isExclusive, + winner: purifiedWinner, + stakes: betting.bets.map((bet) => ({ + generalId: bet.generalId, + userId: bet.userId, + selection: Array.isArray(bet.selection) + ? bet.selection.filter((value): value is number => typeof value === 'number') + : [], + amount: bet.amount, + })), + }); + + for (const reward of rewards) { + if (!reward.userId) { + continue; + } + const existing = await prisma.inheritancePoint.findUnique({ + where: { userId_key: { userId: reward.userId, key: 'previous' } }, + select: { value: true }, + }); + const previousPoint = existing?.value ?? 0; + const nextPoint = previousPoint + reward.amount; + await prisma.inheritancePoint.upsert({ + where: { userId_key: { userId: reward.userId, key: 'previous' } }, + update: { value: nextPoint }, + create: { userId: reward.userId, key: 'previous', value: nextPoint }, + }); + await prisma.rankData.upsert({ + where: { + generalId_type: { + generalId: reward.generalId, + type: 'inherit_earned_act', + }, + }, + update: { value: { increment: Math.trunc(reward.amount) } }, + create: { + generalId: reward.generalId, + nationId: + ( + await prisma.general.findUnique({ + where: { id: reward.generalId }, + select: { nationId: true }, + }) + )?.nationId ?? 0, + type: 'inherit_earned_act', + value: Math.trunc(reward.amount), + }, + }); + const partialText = + reward.matchPoint === betting.selectCount + ? '베팅 당첨' + : `베팅 부분 당첨(${reward.matchPoint}/${betting.selectCount})`; + await prisma.inheritanceLog.createMany({ + data: [ + { + userId: reward.userId, + year: finish.year, + month: finish.month, + text: `${betting.name} ${partialText} 보상으로 ${formatLegacyNumber(reward.amount)} 포인트 획득.`, + }, + { + userId: reward.userId, + year: finish.year, + month: finish.month, + text: `포인트 ${formatLegacyNumber(previousPoint)} => ${formatLegacyNumber(nextPoint)}`, + }, + ], + }); + } + + await prisma.nationBetting.update({ + where: { id: finish.id }, + data: { + finished: true, + winner: purifiedWinner, + }, + }); + const openYear = Math.floor(betting.openYearMonth / 12); + const openMonth = (betting.openYearMonth % 12) + 1; + const finishLog = finalizeLogEntry( + { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + text: `【내기】 ${openYear}년 ${openMonth}월에 열렸던 ${betting.name} 내기의 결과가 나왔습니다!`, + }, + { + year: finish.year, + month: finish.month, + at: finish.turnTime, + } + ); + if (finishLog) { + await prisma.logEntry.create({ + data: { + scope: finishLog.scope, + category: finishLog.category, + subType: finishLog.subType ?? null, + year: finishLog.year, + month: finishLog.month, + text: finishLog.text, + generalId: finishLog.generalId ?? null, + nationId: finishLog.nationId ?? null, + userId: finishLog.userId ?? null, + meta: asJson(finishLog.meta ?? {}), + createdAt: finishLog.createdAt, + }, + }); + } +}; const toCode = (value: string | null | undefined): string => (value && value !== 'None' ? value : 'None'); @@ -399,6 +625,8 @@ export const createDatabaseTurnHooks = async ( lifecycleEvents, pendingNeutralAuctions, inheritancePointAdjustments, + pendingNationBettingOpens, + pendingNationBettingFinishes, } = changes; const reservedTurnChanges = options?.reservedTurns?.peekDirtyState(); @@ -440,6 +668,13 @@ export const createDatabaseTurnHooks = async ( data: worldStateUpdate, }); + for (const betting of pendingNationBettingOpens) { + await persistNationBettingOpen(prisma, betting); + } + for (const finish of pendingNationBettingFinishes) { + await persistNationBettingFinish(prisma, finish); + } + const meta = asRecord(state.meta); const serverId = typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : 'default'; diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index b6bbc77..a9bd277 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -4,6 +4,8 @@ import { getNextTurnAt } from '@sammo-ts/logic'; import type { TurnCheckpoint } from '../lifecycle/types.js'; import type { PendingNeutralAuction, + PendingNationBettingFinish, + PendingNationBettingOpen, TurnDiplomacy, TurnEvent, TurnGeneral, @@ -112,6 +114,8 @@ export interface TurnWorldChanges { lifecycleEvents: GeneralLifecycleEvent[]; pendingNeutralAuctions: PendingNeutralAuction[]; inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }>; + pendingNationBettingOpens: PendingNationBettingOpen[]; + pendingNationBettingFinishes: PendingNationBettingFinish[]; } const compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => { @@ -283,6 +287,8 @@ export class InMemoryTurnWorld { private readonly lifecycleEvents: GeneralLifecycleEvent[] = []; private readonly pendingNeutralAuctions: PendingNeutralAuction[] = []; private readonly inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }> = []; + private readonly pendingNationBettingOpens: PendingNationBettingOpen[] = []; + private readonly pendingNationBettingFinishes: PendingNationBettingFinish[] = []; private readonly scenarioConfig: ScenarioConfig; private checkpoint?: TurnCheckpoint; private state: TurnWorldState; @@ -366,6 +372,10 @@ export class InMemoryTurnWorld { this.logs.push(entry); } + queueMessage(draft: MessageDraft): void { + this.messages.push(draft); + } + queueNeutralAuction(auction: PendingNeutralAuction): void { this.pendingNeutralAuctions.push({ ...auction, @@ -381,6 +391,24 @@ export class InMemoryTurnWorld { this.inheritancePointAdjustments.push({ userId, key, amount }); } + queueNationBettingOpen(betting: PendingNationBettingOpen): void { + this.pendingNationBettingOpens.push({ + ...betting, + candidates: betting.candidates.map((candidate) => ({ + ...candidate, + aux: { ...candidate.aux }, + })), + }); + } + + queueNationBettingFinish(finish: PendingNationBettingFinish): void { + this.pendingNationBettingFinishes.push({ + ...finish, + winnerNationIds: [...finish.winnerNationIds], + turnTime: new Date(finish.turnTime.getTime()), + }); + } + getScenarioConfig(): ScenarioConfig { return this.scenarioConfig; } @@ -918,6 +946,18 @@ export class InMemoryTurnWorld { closeAt: new Date(auction.closeAt.getTime()), })); const inheritancePointAdjustments = this.inheritancePointAdjustments.map((entry) => ({ ...entry })); + const pendingNationBettingOpens = this.pendingNationBettingOpens.map((entry) => ({ + ...entry, + candidates: entry.candidates.map((candidate) => ({ + ...candidate, + aux: { ...candidate.aux }, + })), + })); + const pendingNationBettingFinishes = this.pendingNationBettingFinishes.map((entry) => ({ + ...entry, + winnerNationIds: [...entry.winnerNationIds], + turnTime: new Date(entry.turnTime.getTime()), + })); return { generals, @@ -940,6 +980,8 @@ export class InMemoryTurnWorld { lifecycleEvents, pendingNeutralAuctions, inheritancePointAdjustments, + pendingNationBettingOpens, + pendingNationBettingFinishes, }; } @@ -968,6 +1010,8 @@ export class InMemoryTurnWorld { this.lifecycleEvents.splice(0, changes.lifecycleEvents.length); this.pendingNeutralAuctions.splice(0, changes.pendingNeutralAuctions.length); this.inheritancePointAdjustments.splice(0, changes.inheritancePointAdjustments.length); + this.pendingNationBettingOpens.splice(0, changes.pendingNationBettingOpens.length); + this.pendingNationBettingFinishes.splice(0, changes.pendingNationBettingFinishes.length); } consumeDirtyState(): TurnWorldChanges { diff --git a/app/game-engine/src/turn/monthlyNationBettingAction.ts b/app/game-engine/src/turn/monthlyNationBettingAction.ts new file mode 100644 index 0000000..4d6f0fe --- /dev/null +++ b/app/game-engine/src/turn/monthlyNationBettingAction.ts @@ -0,0 +1,165 @@ +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; + +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import type { MonthlyEventActionHandler } from './monthlyEventHandler.js'; +import type { NationBettingCandidate } from './types.js'; + +const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1; + +const readIntegerArg = (value: unknown, fallback: number, label: string): number => { + if (value === undefined) { + return fallback; + } + if (typeof value !== 'number' || !Number.isInteger(value)) { + throw new Error(`${label} must be an integer.`); + } + return value; +}; + +export const createOpenNationBettingHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; +}): MonthlyEventActionHandler => { + return (args, environment) => { + const world = options.getWorld(); + if (!world) { + return; + } + const nationCount = readIntegerArg(args[0], 1, 'OpenNationBetting nation count'); + const bonusPoint = readIntegerArg(args[1], 0, 'OpenNationBetting bonus point'); + if (nationCount < 1) { + throw new Error('OpenNationBetting nation count must be at least 1.'); + } + if (bonusPoint < 0) { + throw new Error('OpenNationBetting bonus point must not be negative.'); + } + + const generals = world.listGenerals(); + const cities = world.listCities(); + const candidates: NationBettingCandidate[] = world + .listNations() + .filter((nation) => nation.id > 0) + .sort((left, right) => right.power - left.power) + .map((nation) => { + const generalCount = generals.filter((general) => general.nationId === nation.id).length; + const cityCount = cities.filter((city) => city.nationId === nation.id).length; + return { + title: nation.name, + info: `국력: ${nation.power}
장수 수: ${generalCount}
도시 수: ${cityCount}`, + isHtml: true, + aux: { + nation: nation.id, + name: nation.name, + color: nation.color, + type: nation.typeCode, + level: nation.level, + capital: nation.capitalCityId, + gennum: generalCount, + power: nation.power, + city_cnt: cityCount, + }, + }; + }); + + const currentLastId = world.getState().meta.lastBettingId; + const bettingId = + (typeof currentLastId === 'number' && Number.isFinite(currentLastId) + ? Math.trunc(currentLastId) + : 0) + 1; + world.updateWorldMeta({ lastBettingId: bettingId }); + + const shortName = nationCount === 1 ? '천통국' : `최후 ${nationCount}국`; + const openYearMonth = joinYearMonth(environment.year, environment.month); + world.queueNationBettingOpen({ + id: bettingId, + name: `${shortName} 예상`, + selectCount: nationCount, + isExclusive: null, + requiresInheritancePoint: true, + openYearMonth, + closeYearMonth: openYearMonth + 24, + candidates, + bonusPoint, + }); + + const eventId = world.getNextEventId(); + if ( + !world.addEvent({ + id: eventId, + targetCode: 'DESTROY_NATION', + priority: 1_000, + condition: ['RemainNation', '<=', nationCount], + action: [ + ['FinishNationBetting', bettingId], + ['DeleteEvent'], + ], + meta: {}, + }) + ) { + throw new Error(`Failed to add FinishNationBetting event: ${eventId}`); + } + + world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + text: + nationCount > 1 + ? '【내기】중원의 강자를 점치는 내기가 진행중입니다! 호사가의 참여를 기다립니다!' + : '【내기】천하통일 후보를 점치는 내기가 진행중입니다! 호사가의 참여를 기다립니다!', + }); + + const text = `새로운 ${shortName} 내기가 열렸습니다. 천통국 베팅란을 확인해주세요.`; + const now = new Date(); + for (const general of generals.filter((entry) => entry.npcState <= 1)) { + const nation = world.getNationById(general.nationId); + world.queueMessage({ + msgType: 'private', + src: { + generalId: 0, + generalName: '', + nationId: 0, + nationName: 'System', + color: '#000000', + icon: '', + }, + dest: { + generalId: general.id, + generalName: general.name, + nationId: general.nationId, + nationName: nation?.name ?? '재야', + color: nation?.color ?? '#000000', + icon: general.picture ?? '', + }, + text, + time: now, + validUntil: new Date('9999-12-31T00:00:00.000Z'), + option: {}, + }); + } + }; +}; + +export const createFinishNationBettingHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; +}): MonthlyEventActionHandler => { + return (args, environment) => { + const world = options.getWorld(); + if (!world) { + return; + } + const bettingId = readIntegerArg(args[0], 0, 'FinishNationBetting betting ID'); + if (bettingId <= 0) { + throw new Error('FinishNationBetting betting ID must be positive.'); + } + world.queueNationBettingFinish({ + id: bettingId, + winnerNationIds: world + .listNations() + .filter((nation) => nation.level > 0) + .map((nation) => nation.id), + year: environment.year, + month: environment.month, + turnTime: environment.turnTime, + }); + }; +}; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index c54c1e4..33b4cdc 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -61,6 +61,10 @@ import { } from './monthlyInvaderAction.js'; import { createChangeCityHandler } from './monthlyChangeCityAction.js'; import { createProvideNpcTroopLeaderHandler } from './monthlyProvideNpcTroopLeaderAction.js'; +import { + createFinishNationBettingHandler, + createOpenNationBettingHandler, +} from './monthlyNationBettingAction.js'; import { buildCommandEnv } from './reservedTurnCommands.js'; import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js'; @@ -332,6 +336,18 @@ const createTurnDaemonRuntimeWithLease = async ( getWorld: () => worldRef, }) ); + eventActions.set( + 'OpenNationBetting', + createOpenNationBettingHandler({ + getWorld: () => worldRef, + }) + ); + eventActions.set( + 'FinishNationBetting', + createFinishNationBettingHandler({ + getWorld: () => worldRef, + }) + ); eventActions.set('ProcessIncome', async (_args, environment) => { await incomeHandler.onMonthChanged?.({ previousYear: environment.month === 1 ? environment.year - 1 : environment.year, diff --git a/app/game-engine/src/turn/types.ts b/app/game-engine/src/turn/types.ts index 7ad189c..87e7c74 100644 --- a/app/game-engine/src/turn/types.ts +++ b/app/game-engine/src/turn/types.ts @@ -60,6 +60,43 @@ export interface PendingNeutralAuction { closeAt: Date; } +export interface NationBettingCandidate { + title: string; + info: string; + isHtml: true; + aux: { + nation: number; + name: string; + color: string; + type: string; + level: number; + capital: number | null; + gennum: number; + power: number; + city_cnt: number; + }; +} + +export interface PendingNationBettingOpen { + id: number; + name: string; + selectCount: number; + isExclusive: boolean | null; + requiresInheritancePoint: true; + openYearMonth: number; + closeYearMonth: number; + candidates: NationBettingCandidate[]; + bonusPoint: number; +} + +export interface PendingNationBettingFinish { + id: number; + winnerNationIds: number[]; + year: number; + month: number; + turnTime: Date; +} + export interface TurnWorldSnapshot extends Omit< WorldSnapshot, 'generals' | 'cities' | 'nations' | 'troops' | 'diplomacy' diff --git a/app/game-engine/test/monthlyNationBettingAction.test.ts b/app/game-engine/test/monthlyNationBettingAction.test.ts new file mode 100644 index 0000000..ac76f46 --- /dev/null +++ b/app/game-engine/test/monthlyNationBettingAction.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest'; +import type { City, Nation } from '@sammo-ts/logic'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { + createFinishNationBettingHandler, + createOpenNationBettingHandler, +} from '../src/turn/monthlyNationBettingAction.js'; +import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const buildNation = (id: number, power: number): Nation => ({ + id, + name: `국가${id}`, + color: `#00000${id}`, + capitalCityId: id, + chiefGeneralId: id, + gold: id * 100, + rice: id * 200, + power, + level: 2, + typeCode: 'che_유가', + meta: { tech: id * 10 }, +}); + +const buildCity = (id: number): City => ({ + id, + name: `도시${id}`, + nationId: id, + level: 3, + state: 0, + population: 1_000, + populationMax: 2_000, + agriculture: 1_000, + agricultureMax: 2_000, + commerce: 1_000, + commerceMax: 2_000, + security: 1_000, + securityMax: 2_000, + supplyState: 1, + frontState: 0, + defence: 1_000, + defenceMax: 2_000, + wall: 1_000, + wallMax: 2_000, + meta: {}, +}); + +const buildGeneral = (id: number): TurnGeneral => ({ + id, + userId: `user-${id}`, + name: `장수${id}`, + nationId: id, + cityId: id, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + experience: 0, + dedication: 0, + officerLevel: 1, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 0, + rice: 0, + crew: 0, + crewTypeId: 1100, + train: 0, + atmos: 0, + age: 30, + npcState: 0, + bornYear: 170, + deadYear: 250, + affinity: 1, + picture: 'default.jpg', + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + lastTurn: { command: '휴식' }, + turnTime: new Date('0200-01-01T00:00:00.000Z'), + meta: { killturn: 1_000 }, +}); + +const sourceEvent: TurnEvent = { + id: 7, + targetCode: 'month', + priority: 1_000, + condition: true, + action: [['OpenNationBetting', 1, 500]], + meta: {}, +}; + +const buildWorld = () => { + const state: TurnWorldState = { + id: 1, + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0200-01-01T00:00:00.000Z'), + meta: { lastBettingId: 4 }, + }; + const scenarioConfig: TurnWorldSnapshot['scenarioConfig'] = { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, + iconPath: '.', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }; + return new InMemoryTurnWorld( + state, + { + scenarioConfig, + map: { id: 'test', name: 'test', cities: [] }, + generals: [buildGeneral(1), buildGeneral(2)], + cities: [buildCity(1), buildCity(2)], + nations: [buildNation(1, 100), buildNation(2, 300)], + troops: [], + diplomacy: [], + events: [sourceEvent], + initialEvents: [], + }, + { schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } } + ); +}; + +describe('nation betting monthly actions', () => { + it('opens a nation bet, adds its finish event, history and private notices', async () => { + const world = buildWorld(); + const environment = { + year: 200, + month: 1, + startyear: 190, + currentEventID: 7, + turnTime: new Date('0200-01-01T00:00:00.000Z'), + }; + await createOpenNationBettingHandler({ getWorld: () => world })([1, 500], environment, sourceEvent); + + const dirty = world.peekDirtyState(); + expect(world.getState().meta.lastBettingId).toBe(5); + expect(dirty.pendingNationBettingOpens).toHaveLength(1); + expect(dirty.pendingNationBettingOpens[0]).toMatchObject({ + id: 5, + name: '천통국 예상', + selectCount: 1, + openYearMonth: 2_400, + closeYearMonth: 2_424, + bonusPoint: 500, + }); + expect(dirty.pendingNationBettingOpens[0]?.candidates.map((candidate) => candidate.aux.nation)).toEqual([ + 2, 1, + ]); + expect(dirty.createdEvents).toEqual([ + expect.objectContaining({ + targetCode: 'DESTROY_NATION', + priority: 1_000, + condition: ['RemainNation', '<=', 1], + action: [ + ['FinishNationBetting', 5], + ['DeleteEvent'], + ], + }), + ]); + expect(dirty.logs).toHaveLength(1); + expect(dirty.messages).toHaveLength(2); + expect(dirty.messages[0]?.text).toBe( + '새로운 천통국 내기가 열렸습니다. 천통국 베팅란을 확인해주세요.' + ); + + await createFinishNationBettingHandler({ getWorld: () => world })([5], environment, sourceEvent); + expect(world.peekDirtyState().pendingNationBettingFinishes).toEqual([ + { + id: 5, + winnerNationIds: [1, 2], + year: 200, + month: 1, + turnTime: new Date('0200-01-01T00:00:00.000Z'), + }, + ]); + }); +}); diff --git a/app/game-engine/test/monthlyNationBettingPersistence.integration.test.ts b/app/game-engine/test/monthlyNationBettingPersistence.integration.test.ts new file mode 100644 index 0000000..73e0434 --- /dev/null +++ b/app/game-engine/test/monthlyNationBettingPersistence.integration.test.ts @@ -0,0 +1,335 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { City, Nation } from '@sammo-ts/logic'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; + +import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { + createFinishNationBettingHandler, + createOpenNationBettingHandler, +} from '../src/turn/monthlyNationBettingAction.js'; +import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const nationIds = [990_061, 990_062] as const; +const cityIds = [990_061, 990_062] as const; +const generalIds = [9_961, 9_962] as const; +const userIds = ['monthly-betting-user-1', 'monthly-betting-user-2'] as const; +const bettingId = 990_061; + +const buildNation = (id: number, power: number): Nation => ({ + id, + name: `베팅국${id}`, + color: '#123456', + capitalCityId: id, + chiefGeneralId: id, + gold: 1_000, + rice: 2_000, + power, + level: 2, + typeCode: 'che_유가', + meta: { tech: 100 }, +}); + +const buildCity = (id: number, nationId: number): City => ({ + id, + name: `베팅도시${id}`, + nationId, + level: 3, + state: 0, + population: 1_000, + populationMax: 2_000, + agriculture: 1_000, + agricultureMax: 2_000, + commerce: 1_000, + commerceMax: 2_000, + security: 1_000, + securityMax: 2_000, + supplyState: 1, + frontState: 0, + defence: 1_000, + defenceMax: 2_000, + wall: 1_000, + wallMax: 2_000, + meta: { region: 1, trust: 50, trade: 100 }, +}); + +const buildGeneral = (id: number, userId: string, nationId: number): TurnGeneral => ({ + id, + userId, + name: `베팅장수${id}`, + nationId, + cityId: nationId, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + experience: 0, + dedication: 0, + officerLevel: 1, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 0, + rice: 0, + crew: 0, + crewTypeId: 1100, + train: 0, + atmos: 0, + age: 30, + npcState: 0, + bornYear: 170, + deadYear: 250, + affinity: 1, + picture: 'default.jpg', + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + lastTurn: { command: '휴식' }, + turnTime: new Date('0200-01-01T00:00:00.000Z'), + meta: { killturn: 1_000 }, +}); + +const event: TurnEvent = { + id: 1, + targetCode: 'month', + priority: 1_000, + condition: true, + action: [], + meta: {}, +}; + +integration('monthly nation betting persistence', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await db.nationBetting.deleteMany({ where: { id: bettingId } }); + await db.rankData.deleteMany({ where: { generalId: { in: [...generalIds] } } }); + await db.inheritanceLog.deleteMany({ where: { userId: { in: [...userIds] } } }); + await db.inheritancePoint.deleteMany({ where: { userId: { in: [...userIds] } } }); + await db.logEntry.deleteMany({ where: { text: { contains: '천통국 예상 내기의 결과' } } }); + await db.general.deleteMany({ where: { id: { in: [...generalIds] } } }); + await db.city.deleteMany({ where: { id: { in: [...cityIds] } } }); + await db.nation.deleteMany({ where: { id: { in: [...nationIds] } } }); + }); + + afterAll(async () => { + await db.nationBetting.deleteMany({ where: { id: bettingId } }); + await db.rankData.deleteMany({ where: { generalId: { in: [...generalIds] } } }); + await db.inheritanceLog.deleteMany({ where: { userId: { in: [...userIds] } } }); + await db.inheritancePoint.deleteMany({ where: { userId: { in: [...userIds] } } }); + await db.logEntry.deleteMany({ where: { text: { contains: '천통국 예상 내기의 결과' } } }); + await db.message.deleteMany({ where: { mailbox: { in: [...generalIds] } } }); + await db.general.deleteMany({ where: { id: { in: [...generalIds] } } }); + await db.city.deleteMany({ where: { id: { in: [...cityIds] } } }); + await db.nation.deleteMany({ where: { id: { in: [...nationIds] } } }); + await closeDb?.(); + }); + + it('persists open data and settles inheritance rewards in the world flush transaction', async () => { + const nations = [buildNation(nationIds[0], 100), buildNation(nationIds[1], 300)]; + const cities = [buildCity(cityIds[0], nationIds[0]), buildCity(cityIds[1], nationIds[1])]; + const generals = [ + buildGeneral(generalIds[0], userIds[0], nationIds[0]), + buildGeneral(generalIds[1], userIds[1], nationIds[1]), + ]; + await db.nation.createMany({ + data: nations.map((nation) => ({ + id: nation.id, + name: nation.name, + color: nation.color, + capitalCityId: nation.capitalCityId, + chiefGeneralId: nation.chiefGeneralId, + gold: nation.gold, + rice: nation.rice, + tech: 100, + level: nation.level, + typeCode: nation.typeCode, + meta: {}, + })), + }); + await db.city.createMany({ + data: cities.map((city) => ({ + id: city.id, + name: city.name, + level: city.level, + nationId: city.nationId, + population: city.population, + populationMax: city.populationMax, + agriculture: city.agriculture, + agricultureMax: city.agricultureMax, + commerce: city.commerce, + commerceMax: city.commerceMax, + security: city.security, + securityMax: city.securityMax, + trust: 50, + trade: 100, + defence: city.defence, + defenceMax: city.defenceMax, + wall: city.wall, + wallMax: city.wallMax, + region: 1, + meta: {}, + })), + }); + await db.general.createMany({ + data: generals.map((general) => ({ + id: general.id, + userId: general.userId, + name: general.name, + nationId: general.nationId, + cityId: general.cityId, + npcState: general.npcState, + leadership: 50, + strength: 50, + intel: 50, + officerLevel: 1, + turnTime: general.turnTime, + meta: general.meta, + })), + }); + const row = await db.worldState.create({ + data: { + scenarioCode: 'monthly-nation-betting-persistence', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: { lastBettingId: bettingId - 1 }, + }, + }); + const state: TurnWorldState = { + id: row.id, + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('2026-07-25T00:00:00.000Z'), + meta: { lastBettingId: bettingId - 1 }, + }; + const scenarioConfig: TurnWorldSnapshot['scenarioConfig'] = { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, + iconPath: '.', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }; + const world = new InMemoryTurnWorld( + state, + { + scenarioConfig, + map: { id: 'test', name: 'test', cities: [] }, + generals, + cities, + nations, + troops: [], + diplomacy: [], + events: [event], + initialEvents: [], + }, + { schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } } + ); + const hooks = await createDatabaseTurnHooks(databaseUrl!, world); + const environment = { + year: 200, + month: 1, + startyear: 190, + currentEventID: 1, + turnTime: state.lastTurnTime, + }; + + try { + await createOpenNationBettingHandler({ getWorld: () => world })([1, 100], environment, event); + await hooks.hooks.flushChanges?.({ + lastTurnTime: state.lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 1, + durationMs: 0, + partial: false, + }); + + expect(await db.nationBetting.findUniqueOrThrow({ where: { id: bettingId } })).toMatchObject({ + name: '천통국 예상', + selectCount: 1, + openYearMonth: 2_400, + closeYearMonth: 2_424, + finished: false, + }); + expect(await db.nationBet.findMany({ where: { bettingId } })).toEqual([ + expect.objectContaining({ generalId: 0, selectionKey: '[-1]', amount: 100 }), + ]); + expect(await db.message.count({ where: { mailbox: { in: [...generalIds] } } })).toBe(2); + + await db.nationBet.createMany({ + data: [ + { + bettingId, + generalId: generalIds[0], + userId: userIds[0], + selection: [1], + selectionKey: '[1]', + amount: 100, + }, + { + bettingId, + generalId: generalIds[1], + userId: userIds[1], + selection: [0], + selectionKey: '[0]', + amount: 100, + }, + ], + }); + await db.inheritancePoint.createMany({ + data: userIds.map((userId) => ({ userId, key: 'previous', value: 900 })), + }); + world.updateNation(nationIds[0], { level: 0 }); + await createFinishNationBettingHandler({ getWorld: () => world })([bettingId], environment, event); + await hooks.hooks.flushChanges?.({ + lastTurnTime: state.lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 1, + durationMs: 0, + partial: false, + }); + + expect(await db.nationBetting.findUniqueOrThrow({ where: { id: bettingId } })).toMatchObject({ + finished: true, + winner: [0], + }); + expect( + await db.inheritancePoint.findUniqueOrThrow({ + where: { userId_key: { userId: userIds[1], key: 'previous' } }, + }) + ).toMatchObject({ value: 1_200 }); + expect( + await db.inheritancePoint.findUniqueOrThrow({ + where: { userId_key: { userId: userIds[0], key: 'previous' } }, + }) + ).toMatchObject({ value: 900 }); + expect( + await db.rankData.findUniqueOrThrow({ + where: { generalId_type: { generalId: generalIds[1], type: 'inherit_earned_act' } }, + }) + ).toMatchObject({ value: 300 }); + expect(await db.inheritanceLog.count({ where: { userId: userIds[1] } })).toBe(2); + expect( + await db.logEntry.findFirstOrThrow({ + where: { text: { contains: '천통국 예상 내기의 결과' } }, + }) + ).toMatchObject({ + year: 200, + month: 1, + text: '●200년 1월:【내기】 200년 1월에 열렸던 천통국 예상 내기의 결과가 나왔습니다!', + }); + } finally { + await hooks.close(); + await db.worldState.delete({ where: { id: row.id } }); + } + }); +}); diff --git a/app/game-engine/test/nationBettingSettlement.test.ts b/app/game-engine/test/nationBettingSettlement.test.ts new file mode 100644 index 0000000..ec7ec13 --- /dev/null +++ b/app/game-engine/test/nationBettingSettlement.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { + calculateNationBettingRewards, + purifyNationBettingSelection, +} from '../src/betting/nationBettingSettlement.js'; + +describe('nation betting settlement', () => { + it('sorts selections and rejects duplicates', () => { + expect(purifyNationBettingSelection([2, 0, 1], 3)).toEqual([0, 1, 2]); + expect(() => purifyNationBettingSelection([1, 1], 2)).toThrow('중복된 값이 있습니다.'); + }); + + it('distributes an exclusive pool proportionally including the system bonus', () => { + expect( + calculateNationBettingRewards({ + selectCount: 1, + isExclusive: null, + winner: [0], + stakes: [ + { generalId: 0, userId: null, selection: [-1], amount: 100 }, + { generalId: 1, userId: 'one', selection: [0], amount: 100 }, + { generalId: 2, userId: 'two', selection: [1], amount: 100 }, + ], + }) + ).toEqual([{ generalId: 1, userId: 'one', amount: 300, matchPoint: 1 }]); + }); + + it('preserves the legacy no-refund bug when an exclusive bet has no winner', () => { + expect( + calculateNationBettingRewards({ + selectCount: 1, + isExclusive: null, + winner: [0], + stakes: [{ generalId: 2, userId: 'two', selection: [1], amount: 100 }], + }) + ).toEqual([]); + }); + + it('rolls empty tiers down and assigns the remainder to the highest winning tier', () => { + const rewards = calculateNationBettingRewards({ + selectCount: 3, + isExclusive: false, + winner: [0, 1, 2], + stakes: [ + { generalId: 0, userId: null, selection: [-1], amount: 400 }, + { generalId: 1, userId: 'exact', selection: [0, 1, 2], amount: 100 }, + { generalId: 2, userId: 'partial-a', selection: [0, 1, 4], amount: 100 }, + { generalId: 3, userId: 'partial-b', selection: [0, 5, 6], amount: 100 }, + ], + }); + expect(rewards).toEqual([ + { generalId: 1, userId: 'exact', amount: 437.5, matchPoint: 3 }, + { generalId: 2, userId: 'partial-a', amount: 175, matchPoint: 2 }, + { generalId: 3, userId: 'partial-b', amount: 87.5, matchPoint: 1 }, + ]); + }); +}); diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index 3493f5a..35de64d 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -489,6 +489,46 @@ model InheritancePoint { @@map("inheritance_point") } +model NationBetting { + id Int @id + type String @default("bettingNation") + name String + finished Boolean @default(false) + selectCount Int @map("select_count") + isExclusive Boolean? @map("is_exclusive") + requiresInheritancePoint Boolean @default(true) @map("requires_inheritance_point") + openYearMonth Int @map("open_year_month") + closeYearMonth Int @map("close_year_month") + candidates Json + winner Json? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + bets NationBet[] + + @@index([type, id]) + @@map("nation_betting") +} + +model NationBet { + id Int @id @default(autoincrement()) + bettingId Int @map("betting_id") + generalId Int @map("general_id") + userId String? @map("user_id") + selection Json + selectionKey String @map("selection_key") + amount Float + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + betting NationBetting @relation(fields: [bettingId], references: [id], onDelete: Cascade) + + @@unique([bettingId, userId, selectionKey]) + @@index([bettingId]) + @@index([bettingId, userId]) + @@map("nation_bet") +} + model InheritanceLog { id Int @id @default(autoincrement()) userId String @map("user_id") diff --git a/packages/infra/prisma/migrations/20260725006000_add_nation_betting/migration.sql b/packages/infra/prisma/migrations/20260725006000_add_nation_betting/migration.sql new file mode 100644 index 0000000..95faade --- /dev/null +++ b/packages/infra/prisma/migrations/20260725006000_add_nation_betting/migration.sql @@ -0,0 +1,42 @@ +CREATE TABLE "nation_betting" ( + "id" INTEGER NOT NULL, + "type" TEXT NOT NULL DEFAULT 'bettingNation', + "name" TEXT NOT NULL, + "finished" BOOLEAN NOT NULL DEFAULT false, + "select_count" INTEGER NOT NULL, + "is_exclusive" BOOLEAN, + "requires_inheritance_point" BOOLEAN NOT NULL DEFAULT true, + "open_year_month" INTEGER NOT NULL, + "close_year_month" INTEGER NOT NULL, + "candidates" JSONB NOT NULL, + "winner" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "nation_betting_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "nation_bet" ( + "id" SERIAL NOT NULL, + "betting_id" INTEGER NOT NULL, + "general_id" INTEGER NOT NULL, + "user_id" TEXT, + "selection" JSONB NOT NULL, + "selection_key" TEXT NOT NULL, + "amount" DOUBLE PRECISION NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "nation_bet_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "nation_betting_type_id_idx" ON "nation_betting"("type", "id"); +CREATE INDEX "nation_bet_betting_id_idx" ON "nation_bet"("betting_id"); +CREATE INDEX "nation_bet_betting_id_user_id_idx" ON "nation_bet"("betting_id", "user_id"); +CREATE UNIQUE INDEX "nation_bet_betting_id_user_id_selection_key_key" + ON "nation_bet"("betting_id", "user_id", "selection_key"); + +ALTER TABLE "nation_bet" + ADD CONSTRAINT "nation_bet_betting_id_fkey" + FOREIGN KEY ("betting_id") REFERENCES "nation_betting"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/infra/src/db.ts b/packages/infra/src/db.ts index 80f5573..14fc1c6 100644 --- a/packages/infra/src/db.ts +++ b/packages/infra/src/db.ts @@ -27,6 +27,8 @@ export interface DatabaseClient { auction: GamePrisma.AuctionDelegate; auctionBid: GamePrisma.AuctionBidDelegate; inheritancePoint: GamePrisma.InheritancePointDelegate; + nationBetting: GamePrisma.NationBettingDelegate; + nationBet: GamePrisma.NationBetDelegate; inheritanceLog: GamePrisma.InheritanceLogDelegate; inheritanceResult: GamePrisma.InheritanceResultDelegate; inheritanceUserState: GamePrisma.InheritanceUserStateDelegate;