diff --git a/app/game-api/src/router/auction/index.ts b/app/game-api/src/router/auction/index.ts index 8f1ac28..bc1b476 100644 --- a/app/game-api/src/router/auction/index.ts +++ b/app/game-api/src/router/auction/index.ts @@ -6,7 +6,7 @@ import type { DatabaseClient, GameApiContext, GeneralRow } from '../../context.j import { buildAuctionTimerKeys } from '../../auction/keys.js'; import { GamePrisma } from '@sammo-ts/infra'; import { ItemLoader, isItemKey } from '@sammo-ts/logic'; -import { asRecord } from '@sammo-ts/common'; +import { asNumber, asRecord } from '@sammo-ts/common'; import { buildAuctionAlias } from '@sammo-ts/logic'; import { openAuctionWithDaemon } from '../../auction/open.js'; @@ -91,6 +91,14 @@ const ensureGeneral = async (db: DatabaseClient, userId: string): Promise => { + const worldState = await db.worldState.findFirst({ select: { meta: true } }); + const meta = asRecord(worldState?.meta); + if (asNumber(meta.isUnited, 0) !== 0 || asNumber(meta.isunited, 0) !== 0) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '천하통일 후에는 경매를 이용할 수 없습니다.' }); + } +}; + const loadAuction = async ( db: DatabaseClient, auctionId: number @@ -323,16 +331,19 @@ export const auctionRouter = router({ openBuyRice: authedProcedure.input(zOpenResourceInput).mutation(async ({ ctx, input }) => { const auth = requireAuth(ctx); const general = await ensureGeneral(ctx.db, auth.user.id); + await ensureAuctionSeasonActive(ctx.db); return openAuctionWithDaemon(ctx, general.id, { auctionType: 'BUY_RICE', ...input }); }), openSellRice: authedProcedure.input(zOpenResourceInput).mutation(async ({ ctx, input }) => { const auth = requireAuth(ctx); const general = await ensureGeneral(ctx.db, auth.user.id); + await ensureAuctionSeasonActive(ctx.db); return openAuctionWithDaemon(ctx, general.id, { auctionType: 'SELL_RICE', ...input }); }), openUnique: authedProcedure.input(zOpenUniqueInput).mutation(async ({ ctx, input }) => { const auth = requireAuth(ctx); const general = await ensureGeneral(ctx.db, auth.user.id); + await ensureAuctionSeasonActive(ctx.db); return openAuctionWithDaemon(ctx, general.id, { auctionType: 'UNIQUE_ITEM', itemKey: input.itemKey, @@ -342,6 +353,7 @@ export const auctionRouter = router({ bidBuyRice: authedProcedure.input(zBidInput).mutation(async ({ ctx, input }) => { const auth = requireAuth(ctx); const general = await ensureGeneral(ctx.db, auth.user.id); + await ensureAuctionSeasonActive(ctx.db); const auction = await loadAuction(ctx.db, input.auctionId); if (!auction || auction.type !== 'BUY_RICE') { throw new TRPCError({ code: 'NOT_FOUND', message: 'Auction not found.' }); @@ -413,6 +425,7 @@ export const auctionRouter = router({ bidSellRice: authedProcedure.input(zBidInput).mutation(async ({ ctx, input }) => { const auth = requireAuth(ctx); const general = await ensureGeneral(ctx.db, auth.user.id); + await ensureAuctionSeasonActive(ctx.db); const auction = await loadAuction(ctx.db, input.auctionId); if (!auction || auction.type !== 'SELL_RICE') { throw new TRPCError({ code: 'NOT_FOUND', message: 'Auction not found.' }); @@ -484,6 +497,7 @@ export const auctionRouter = router({ bidUnique: authedProcedure.input(zUniqueBidInput).mutation(async ({ ctx, input }) => { const auth = requireAuth(ctx); const general = await ensureGeneral(ctx.db, auth.user.id); + await ensureAuctionSeasonActive(ctx.db); const auction = await loadAuction(ctx.db, input.auctionId); if (!auction || auction.type !== 'UNIQUE_ITEM') { throw new TRPCError({ code: 'NOT_FOUND', message: 'Auction not found.' }); diff --git a/app/game-api/test/auctionRouter.test.ts b/app/game-api/test/auctionRouter.test.ts index cf570d6..74e7cce 100644 --- a/app/game-api/test/auctionRouter.test.ts +++ b/app/game-api/test/auctionRouter.test.ts @@ -76,6 +76,8 @@ const buildContext = (options: { general?: GeneralRow | null; auctions?: Array>; queryRaw?: (query: GamePrisma.Sql) => Promise; + isUnited?: number; + isunited?: number; }) => { const auth = options.auth === undefined ? buildAuth() : options.auth; const general = options.general === undefined ? buildGeneral() : options.general; @@ -108,7 +110,7 @@ const buildContext = (options: { allItems: { weapon: { che_무기_12_칠성검: 1 } }, }, }, - meta: { hiddenSeed: 'auction-hidden-seed' }, + meta: { hiddenSeed: 'auction-hidden-seed', isUnited: options.isUnited ?? 0, isunited: options.isunited ?? 0 }, updatedAt: new Date('2026-07-26T00:00:00Z'), }; const db = { @@ -219,6 +221,28 @@ describe('auction router actor and permission boundaries', () => { }); }); + it('rejects auction mutations after unification before sending a daemon command', async () => { + const fixture = buildContext({ isUnited: 0, isunited: 2 }); + const caller = appRouter.createCaller(fixture.context); + + await expect( + caller.auction.openBuyRice({ + amount: 1000, + closeTurnCnt: 3, + startBidAmount: 500, + finishBidAmount: 2000, + }) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: '천하통일 후에는 경매를 이용할 수 없습니다.', + }); + await expect(caller.auction.bidUnique({ auctionId: 31, amount: 110 })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: '천하통일 후에는 경매를 이용할 수 없습니다.', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); + it('redacts real unique-auction identities while preserving caller markers', async () => { const openedAt = new Date('2026-07-26T01:00:00Z'); const fixture = buildContext({ diff --git a/app/game-engine/src/auction/bidder.ts b/app/game-engine/src/auction/bidder.ts index ecec430..9893586 100644 --- a/app/game-engine/src/auction/bidder.ts +++ b/app/game-engine/src/auction/bidder.ts @@ -33,6 +33,7 @@ interface AuctionBidRow { id: number; generalId: number; amount: number; + meta: unknown; } interface AuctionDetail { @@ -49,6 +50,16 @@ const parseDetail = (detail: unknown): AuctionDetail => { return detail as AuctionDetail; }; +const toFiniteNumber = (value: unknown): number => { + const parsed = typeof value === 'string' ? Number(value) : value; + return typeof parsed === 'number' && Number.isFinite(parsed) ? parsed : 0; +}; + +const readRankTrackedAmount = (bid: AuctionBidRow | null): number => { + if (!bid?.meta || typeof bid.meta !== 'object' || Array.isArray(bid.meta)) return 0; + return Math.max(0, toFiniteNumber((bid.meta as Record).inheritSpentTrackedAmount)); +}; + const extendCloseDate = (options: { now: Date; closeAt: Date; @@ -107,14 +118,14 @@ const loadHighestBid = async ( const rows = await prisma.$queryRaw( isReverse ? GamePrisma.sql` - SELECT id, general_id as "generalId", amount + SELECT id, general_id as "generalId", amount, meta FROM auction_bid WHERE auction_id = ${auctionId} ORDER BY amount ASC, id ASC LIMIT 1 ` : GamePrisma.sql` - SELECT id, general_id as "generalId", amount + SELECT id, general_id as "generalId", amount, meta FROM auction_bid WHERE auction_id = ${auctionId} ORDER BY amount DESC, id ASC @@ -133,14 +144,14 @@ const loadMyPrevBid = async ( const rows = await prisma.$queryRaw( isReverse ? GamePrisma.sql` - SELECT id, general_id as "generalId", amount + SELECT id, general_id as "generalId", amount, meta FROM auction_bid WHERE auction_id = ${auctionId} AND general_id = ${generalId} ORDER BY amount ASC, id ASC LIMIT 1 ` : GamePrisma.sql` - SELECT id, general_id as "generalId", amount + SELECT id, general_id as "generalId", amount, meta FROM auction_bid WHERE auction_id = ${auctionId} AND general_id = ${generalId} ORDER BY amount DESC, id ASC @@ -340,6 +351,8 @@ export const createAuctionBidder = async (options: { const eventId = randomUUID(); const eventAt = now; + const rankTrackedAmount = auction.type === 'UNIQUE_ITEM' ? readRankTrackedAmount(myPrevBid) + morePoint : 0; + const previousRankTrackedAmount = readRankTrackedAmount(highestBid); try { const persistBid = async (tx: GamePrisma.TransactionClient): Promise => { @@ -352,7 +365,12 @@ export const createAuctionBidder = async (options: { ${command.amount}, ${eventId}, ${eventAt}, - ${JSON.stringify({ tryExtendCloseDate: command.tryExtendCloseDate ?? true })}::jsonb + ${JSON.stringify({ + tryExtendCloseDate: command.tryExtendCloseDate ?? true, + ...(auction.type === 'UNIQUE_ITEM' + ? { inheritSpentTrackedAmount: rankTrackedAmount } + : {}), + })}::jsonb ) ` ); @@ -396,21 +414,42 @@ export const createAuctionBidder = async (options: { if (deductedRows.length === 0) { throw new Error('INSUFFICIENT_POINT'); } + await tx.$executeRaw( + GamePrisma.sql` + INSERT INTO rank_data (nation_id, general_id, type, value) + SELECT nation_id, id, 'inherit_spent_dyn', ${morePoint} + FROM general + WHERE id = ${command.generalId} + ON CONFLICT (general_id, type) + DO UPDATE SET + nation_id = EXCLUDED.nation_id, + value = rank_data.value + EXCLUDED.value + ` + ); if (highestBid && highestBid.generalId !== command.generalId && !myPrevBid) { const prevUserId = await resolveUserId(tx, highestBid.generalId); - if (prevUserId) { - await tx.$executeRaw( - GamePrisma.sql` - INSERT INTO inheritance_point (user_id, key, value, updated_at) - VALUES (${prevUserId}, 'previous', ${highestBid.amount}, ${eventAt}) - ON CONFLICT (user_id, key) - DO UPDATE SET - value = inheritance_point.value + EXCLUDED.value, - updated_at = EXCLUDED.updated_at - ` - ); + if (!prevUserId) { + throw new Error('USER_NOT_FOUND'); } + await tx.$executeRaw( + GamePrisma.sql` + INSERT INTO inheritance_point (user_id, key, value, updated_at) + VALUES (${prevUserId}, 'previous', ${highestBid.amount}, ${eventAt}) + ON CONFLICT (user_id, key) + DO UPDATE SET + value = inheritance_point.value + EXCLUDED.value, + updated_at = EXCLUDED.updated_at + ` + ); + await tx.$executeRaw( + GamePrisma.sql` + UPDATE rank_data + SET value = GREATEST(0, value - ${previousRankTrackedAmount}) + WHERE general_id = ${highestBid.generalId} + AND type = 'inherit_spent_dyn' + ` + ); } } }; @@ -453,7 +492,36 @@ export const createAuctionBidder = async (options: { }; } - if (auction.type !== 'UNIQUE_ITEM') { + if (auction.type === 'UNIQUE_ITEM') { + world.updateGeneral(command.generalId, { + inheritancePoints: { + ...general.inheritancePoints, + previous: toFiniteNumber(general.inheritancePoints?.previous) - morePoint, + }, + meta: { + ...general.meta, + inherit_spent_dyn: toFiniteNumber(general.meta.inherit_spent_dyn) + morePoint, + }, + }); + if (highestBid && highestBid.generalId !== command.generalId && !myPrevBid) { + const prev = world.getGeneralById(highestBid.generalId); + if (prev) { + world.updateGeneral(prev.id, { + inheritancePoints: { + ...prev.inheritancePoints, + previous: toFiniteNumber(prev.inheritancePoints?.previous) + highestBid.amount, + }, + meta: { + ...prev.meta, + inherit_spent_dyn: Math.max( + 0, + toFiniteNumber(prev.meta.inherit_spent_dyn) - previousRankTrackedAmount + ), + }, + }); + } + } + } else { const resourceType = auction.type === 'BUY_RICE' ? 'gold' : 'rice'; world.updateGeneral(command.generalId, { gold: resourceType === 'gold' ? general.gold - morePoint : general.gold, diff --git a/app/game-engine/src/turn/inheritancePointCalculation.ts b/app/game-engine/src/turn/inheritancePointCalculation.ts new file mode 100644 index 0000000..ca2dbaa --- /dev/null +++ b/app/game-engine/src/turn/inheritancePointCalculation.ts @@ -0,0 +1,94 @@ +import type { TurnGeneral } from './types.js'; + +const DEX_LIMIT = 1_275_975; + +export const STORED_INHERITANCE_KEYS = [ + 'lived_month', + 'max_domestic_critical', + 'active_action', + 'unifier', + 'tournament', +] as const; + +export const ALL_MERGED_INHERITANCE_KEYS = [ + ...STORED_INHERITANCE_KEYS, + 'max_belong', + 'combat', + 'sabotage', + 'dex', + 'betting', +] as const; + +export type MergedInheritanceKey = (typeof ALL_MERGED_INHERITANCE_KEYS)[number]; + +const readNumber = (source: Record, key: string): number => { + const value = source[key]; + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return 0; +}; + +const computeDexPoint = (general: TurnGeneral): number => { + let totalDexterity = 0; + for (let index = 1; index <= 5; index += 1) { + let dexterity = readNumber(general.meta, `dex${index}`); + if (dexterity > DEX_LIMIT) { + totalDexterity += (dexterity - DEX_LIMIT) / 3; + dexterity = DEX_LIMIT; + } + totalDexterity += dexterity; + } + return totalDexterity * 0.001; +}; + +const computeBettingPoint = (general: TurnGeneral): number => { + const wins = readNumber(general.meta, 'betwin'); + const gold = readNumber(general.meta, 'betgold'); + const wonGold = readNumber(general.meta, 'betwingold'); + const winRate = wonGold / Math.max(1000, gold); + return wins * 10 * winRate ** 2; +}; + +export const computeActiveInheritancePoint = ( + general: TurnGeneral, + key: MergedInheritanceKey, + storedOverride?: number +): number => { + const stored = storedOverride ?? general.inheritancePoints?.[key] ?? 0; + switch (key) { + case 'lived_month': { + const value = readNumber(general.meta, 'inherit_lived_month'); + return value !== 0 ? value : stored; + } + case 'max_domestic_critical': { + const value = readNumber(general.meta, 'max_domestic_critical'); + return value !== 0 ? value : stored; + } + case 'active_action': { + const value = readNumber(general.meta, 'inherit_active_action'); + return value !== 0 ? value * 3 : stored; + } + case 'unifier': + case 'tournament': + return stored; + case 'max_belong': + return ( + Math.max( + readNumber(general.meta, 'belong'), + readNumber(general.meta, 'max_belong'), + readNumber(general.meta, 'inherit_max_belong') + ) * 10 + ); + case 'combat': + return readNumber(general.meta, 'rank_warnum') * 5; + case 'sabotage': + return readNumber(general.meta, 'firenum') * 20; + case 'dex': + return computeDexPoint(general); + case 'betting': + return computeBettingPoint(general); + } +}; diff --git a/app/game-engine/src/turn/monthlyEventHandler.ts b/app/game-engine/src/turn/monthlyEventHandler.ts index deb12b7..5fa4916 100644 --- a/app/game-engine/src/turn/monthlyEventHandler.ts +++ b/app/game-engine/src/turn/monthlyEventHandler.ts @@ -209,12 +209,16 @@ const parseActions = (raw: unknown): Array<{ name: string; args: readonly unknow }); }; +export interface ScenarioEventCalendarHandler extends TurnCalendarHandler { + dispatchTarget(targetCode: string, context: TurnCalendarContext): Promise; +} + export const createMonthlyEventHandler = (options: { getWorld: () => InMemoryTurnWorld | null; startYear: number; actions?: MonthlyEventActionRegistry; -}): TurnCalendarHandler => { - const dispatch = async (targetCode: 'pre_month' | 'month', context: TurnCalendarContext): Promise => { +}): ScenarioEventCalendarHandler => { + const dispatchTarget = async (targetCode: string, context: TurnCalendarContext): Promise => { const world = options.getWorld(); if (!world) { return; @@ -249,7 +253,8 @@ export const createMonthlyEventHandler = (options: { }; return { - beforeMonthChanged: (context) => dispatch('pre_month', context), - onMonthChanged: (context) => dispatch('month', context), + beforeMonthChanged: (context) => dispatchTarget('pre_month', context), + onMonthChanged: (context) => dispatchTarget('month', context), + dispatchTarget, }; }; diff --git a/app/game-engine/src/turn/monthlyUniqueInheritAction.ts b/app/game-engine/src/turn/monthlyUniqueInheritAction.ts index b22292b..579b6e8 100644 --- a/app/game-engine/src/turn/monthlyUniqueInheritAction.ts +++ b/app/game-engine/src/turn/monthlyUniqueInheritAction.ts @@ -13,26 +13,11 @@ import { import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import { ALL_MERGED_INHERITANCE_KEYS, computeActiveInheritancePoint } from './inheritancePointCalculation.js'; import type { MonthlyEventActionHandler } from './monthlyEventHandler.js'; import type { TurnGeneral } from './types.js'; const LEGACY_ITEM_SLOTS: readonly ItemSlot[] = ['horse', 'weapon', 'book', 'item']; -const DEX_LIMIT = 1_275_975; -const STORED_INHERITANCE_KEYS = [ - 'lived_month', - 'max_domestic_critical', - 'active_action', - 'unifier', - 'tournament', -] as const; -const ALL_MERGED_INHERITANCE_KEYS = [ - ...STORED_INHERITANCE_KEYS, - 'max_belong', - 'combat', - 'sabotage', - 'dex', - 'betting', -] as const; const readNumber = (source: Record, key: string): number => { const value = source[key]; @@ -163,60 +148,6 @@ export const createLostUniqueItemHandler = (options: { }; }; -const computeDexPoint = (general: TurnGeneral): number => { - let totalDexterity = 0; - for (let index = 1; index <= 5; index += 1) { - let dexterity = readNumber(general.meta, `dex${index}`); - if (dexterity > DEX_LIMIT) { - totalDexterity += (dexterity - DEX_LIMIT) / 3; - dexterity = DEX_LIMIT; - } - totalDexterity += dexterity; - } - return totalDexterity * 0.001; -}; - -const computeBettingPoint = (general: TurnGeneral): number => { - const wins = readNumber(general.meta, 'betwin'); - const gold = readNumber(general.meta, 'betgold'); - const wonGold = readNumber(general.meta, 'betwingold'); - const winRate = wonGold / Math.max(1000, gold); - return wins * 10 * winRate ** 2; -}; - -const computeActiveInheritancePoint = (general: TurnGeneral, key: string): number => { - const stored = general.inheritancePoints?.[key] ?? 0; - switch (key) { - case 'lived_month': { - const value = readNumber(general.meta, 'inherit_lived_month'); - return value !== 0 ? value : stored; - } - case 'max_domestic_critical': { - const value = readNumber(general.meta, 'max_domestic_critical'); - return value !== 0 ? value : stored; - } - case 'active_action': { - const value = readNumber(general.meta, 'inherit_active_action'); - return value !== 0 ? value * 3 : stored; - } - case 'unifier': - case 'tournament': - return stored; - case 'max_belong': - return Math.max(readNumber(general.meta, 'belong'), readNumber(general.meta, 'inherit_max_belong')) * 10; - case 'combat': - return readNumber(general.meta, 'rank_warnum') * 5; - case 'sabotage': - return readNumber(general.meta, 'firenum') * 20; - case 'dex': - return computeDexPoint(general); - case 'betting': - return computeBettingPoint(general); - default: - return stored; - } -}; - export const createMergeInheritPointRankHandler = (options: { getWorld: () => InMemoryTurnWorld | null; }): MonthlyEventActionHandler => { diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index fc6837e..03d90f8 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -36,6 +36,7 @@ import { loadTurnCommandProfile } from './turnCommandProfile.js'; import { loadTurnWorldFromDatabase } from './worldLoader.js'; import { shouldUseAi } from './ai/generalAi.js'; import { createUnificationHandler } from './unificationHandler.js'; +import { loadPendingUnificationAuctionCancellations } from './unificationAuctionCancellation.js'; import { createAuctionFinalizer } from '../auction/finalizer.js'; import { createAuctionBidder } from '../auction/bidder.js'; import { createNeutralAuctionRegistrar } from '../auction/neutralRegistrar.js'; @@ -224,12 +225,6 @@ const createTurnDaemonRuntimeWithLease = async ( snapshot.scenarioConfig.environment.scenarioEffect ); const monthlyCommandEnv = buildCommandEnv(snapshot.scenarioConfig, snapshot.unitSet); - const unification = options.calendarHandler - ? null - : createUnificationHandler({ - profileName: options.profileName ?? options.profile, - getWorld: () => worldRef, - }); const incomeHandler = createIncomeHandler({ getWorld: () => worldRef, scenarioConfig: snapshot.scenarioConfig, @@ -404,6 +399,16 @@ const createTurnDaemonRuntimeWithLease = async ( startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear, actions: eventActions, }); + const unification = options.calendarHandler + ? null + : createUnificationHandler({ + profileName: options.profileName ?? options.profile, + getWorld: () => worldRef, + loadPendingUniqueAuctions: databaseFlushEnabled + ? () => loadPendingUnificationAuctionCancellations(options.databaseUrl) + : undefined, + dispatchUnitedEvents: (context) => monthlyEventHandler.dispatchTarget('united', context), + }); const nationTurnMonthlyHandler = createNationTurnMonthlyHandler({ getWorld: () => worldRef, }); diff --git a/app/game-engine/src/turn/types.ts b/app/game-engine/src/turn/types.ts index a4d3492..a5673dc 100644 --- a/app/game-engine/src/turn/types.ts +++ b/app/game-engine/src/turn/types.ts @@ -118,6 +118,18 @@ export interface PendingUnificationFinalization { year: number; month: number; completedAt: Date; + auctionCancellations: PendingUnificationAuctionCancellation[]; +} + +export interface PendingUnificationAuctionCancellation { + auctionId: number; + status: 'OPEN' | 'FINALIZING'; + closeAt: Date; + title: string; + highestBidId: number | null; + bidderGeneralId: number | null; + amount: number | null; + rankTrackedAmount: number; } export interface TurnWorldSnapshot extends Omit< diff --git a/app/game-engine/src/turn/unificationAuctionCancellation.ts b/app/game-engine/src/turn/unificationAuctionCancellation.ts new file mode 100644 index 0000000..33067ff --- /dev/null +++ b/app/game-engine/src/turn/unificationAuctionCancellation.ts @@ -0,0 +1,68 @@ +import { asRecord } from '@sammo-ts/common'; +import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra'; + +import type { PendingUnificationAuctionCancellation } from './types.js'; + +interface PendingAuctionRow { + auctionId: number; + status: 'OPEN' | 'FINALIZING'; + closeAt: Date; + detail: unknown; + highestBidId: number | null; + bidderGeneralId: number | null; + amount: number | null; + highestBidMeta: unknown; +} + +export const loadPendingUnificationAuctionCancellations = async ( + databaseUrl: string +): Promise => { + const connector = createGamePostgresConnector({ url: databaseUrl }); + await connector.connect(); + try { + const rows = await connector.prisma.$queryRaw(GamePrisma.sql` + SELECT + auction.id AS "auctionId", + auction.status, + auction.close_at AS "closeAt", + auction.detail, + highest.id AS "highestBidId", + highest.general_id AS "bidderGeneralId", + highest.amount, + highest.meta AS "highestBidMeta" + FROM auction + LEFT JOIN LATERAL ( + SELECT bid.id, bid.general_id, bid.amount, bid.meta + FROM auction_bid bid + WHERE bid.auction_id = auction.id + ORDER BY bid.amount DESC, bid.id ASC + LIMIT 1 + ) highest ON TRUE + WHERE auction.type = 'UNIQUE_ITEM' + AND auction.status IN ('OPEN', 'FINALIZING') + ORDER BY auction.close_at ASC, auction.id ASC + `); + return rows.map((row) => { + const title = asRecord(row.detail).title; + if (typeof title !== 'string' || !title.trim()) { + throw new Error(`Unification auction ${row.auctionId} has no title.`); + } + const hasBid = row.highestBidId !== null; + if (hasBid && (row.bidderGeneralId === null || row.amount === null || row.amount <= 0)) { + throw new Error(`Unification auction ${row.auctionId} has an invalid highest bid.`); + } + return { + auctionId: row.auctionId, + status: row.status, + closeAt: new Date(row.closeAt.getTime()), + title, + highestBidId: row.highestBidId, + bidderGeneralId: row.bidderGeneralId, + amount: row.amount, + rankTrackedAmount: Math.max(0, Number(asRecord(row.highestBidMeta).inheritSpentTrackedAmount) || 0), + }; + }); + } finally { + await connector.disconnect(); + } +}; diff --git a/app/game-engine/src/turn/unificationHandler.ts b/app/game-engine/src/turn/unificationHandler.ts index 9aa0951..6372ab0 100644 --- a/app/game-engine/src/turn/unificationHandler.ts +++ b/app/game-engine/src/turn/unificationHandler.ts @@ -1,9 +1,12 @@ import { asNumber, asRecord, JosaUtil } from '@sammo-ts/common'; import { LogCategory, LogFormat, LogScope, type LogEntryDraft } from '@sammo-ts/logic'; -import type { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js'; +import type { InMemoryTurnWorld, TurnCalendarContext, TurnCalendarHandler } from './inMemoryWorld.js'; +import type { PendingUnificationAuctionCancellation } from './types.js'; import { queueYearbookSnapshot } from './yearbookHandler.js'; +const UNIFIER_POINT = 2000; + const buildUnificationLog = (nationName: string): LogEntryDraft => ({ scope: LogScope.SYSTEM, category: LogCategory.HISTORY, @@ -39,9 +42,11 @@ const resolveServerId = (world: InMemoryTurnWorld, fallback: string): string => export const createUnificationHandler = (options: { profileName: string; getWorld: () => InMemoryTurnWorld | null; + loadPendingUniqueAuctions?: () => Promise; + dispatchUnitedEvents: (context: TurnCalendarContext) => Promise; }): { handler: TurnCalendarHandler } => ({ handler: { - onMonthChanged: (context) => { + onMonthChanged: async (context) => { const world = options.getWorld(); if (!world) return; @@ -57,12 +62,57 @@ export const createUnificationHandler = (options: { if (cities.length === 0 || cities.some((city) => city.nationId !== winner.id)) return; const serverId = resolveServerId(world, options.profileName); + world.pushLog(buildNationHistoryLog(winner.id, winner.name)); + + const auctionCancellations = (await options.loadPendingUniqueAuctions?.()) ?? []; + for (const cancellation of auctionCancellations) { + if (cancellation.highestBidId === null) continue; + const bidderId = cancellation.bidderGeneralId; + const amount = cancellation.amount; + if (bidderId === null || amount === null || amount <= 0) { + throw new Error(`Unification auction ${cancellation.auctionId} has an invalid refund plan.`); + } + const bidder = world.getGeneralById(bidderId); + if (!bidder?.userId) { + throw new Error(`Unification auction ${cancellation.auctionId} bidder is unavailable.`); + } + const spentDynamic = asNumber(bidder.meta.inherit_spent_dyn, 0); + if (cancellation.rankTrackedAmount > spentDynamic) { + throw new Error(`Unification auction ${cancellation.auctionId} rank refund exceeds tracked spend.`); + } + world.updateGeneral(bidder.id, { + inheritancePoints: { + ...bidder.inheritancePoints, + previous: asNumber(bidder.inheritancePoints?.previous, 0) + amount, + }, + meta: { + ...bidder.meta, + inherit_spent_dyn: spentDynamic - cancellation.rankTrackedAmount, + }, + }); + } + + for (const general of world + .listGenerals() + .filter( + (entry) => + entry.userId && entry.npcState < 2 && entry.nationId === winner.id && entry.officerLevel > 4 + )) { + world.updateGeneral(general.id, { + inheritancePoints: { + ...general.inheritancePoints, + unifier: asNumber(general.inheritancePoints?.unifier, 0) + UNIFIER_POINT, + }, + }); + } + + await options.dispatchUnitedEvents(context); + world.updateWorldMeta({ isUnited: 2, isunited: 2, refreshLimit: asNumber(meta.refreshLimit, 0) * 100, }); - world.pushLog(buildNationHistoryLog(winner.id, winner.name)); for (const general of world.listGenerals().filter((entry) => entry.nationId === winner.id)) { world.pushLog(buildGeneralActionLog(general.id, winner.id, winner.name)); } @@ -77,6 +127,7 @@ export const createUnificationHandler = (options: { year: context.currentYear, month: context.currentMonth, completedAt: new Date(state.lastTurnTime.getTime()), + auctionCancellations, }); }, }, diff --git a/app/game-engine/src/turn/unificationPersistence.ts b/app/game-engine/src/turn/unificationPersistence.ts index 46dd33f..05fc5bf 100644 --- a/app/game-engine/src/turn/unificationPersistence.ts +++ b/app/game-engine/src/turn/unificationPersistence.ts @@ -1,8 +1,10 @@ import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common'; import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra'; -import { LogCategory, LogScope } from '@sammo-ts/logic'; +import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageRecordDraft } from '@sammo-ts/logic'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import { ALL_MERGED_INHERITANCE_KEYS, computeActiveInheritancePoint } from './inheritancePointCalculation.js'; +import type { PendingUnificationAuctionCancellation, TurnGeneral } from './types.js'; const UNIFIER_POINT = 2000; const asJson = (value: unknown): InputJsonValue => value as InputJsonValue; @@ -15,6 +17,7 @@ export interface UnificationFinalizationInput { readonly year: number; readonly month: number; readonly completedAt: Date; + readonly auctionCancellations: readonly PendingUnificationAuctionCancellation[]; } export interface UnificationFinalizationResult { @@ -41,6 +44,17 @@ const ownerDisplayName = (meta: Record): string | null => { return null; }; +export const resolveStoredInheritancePoint = ( + currentPoints: ReadonlyMap, + general: Pick, + key: (typeof ALL_MERGED_INHERITANCE_KEYS)[number], + unifierAward: number +): number => + currentPoints.get(key) ?? + (key === 'unifier' + ? Math.max(0, (general.inheritancePoints?.[key] ?? 0) - unifierAward) + : (general.inheritancePoints?.[key] ?? 0)); + const formatHistogram = (value: unknown): string => Object.entries(asRecord(value)) .filter((entry): entry is [string, number] => typeof entry[1] === 'number' && Number.isFinite(entry[1])) @@ -88,6 +102,160 @@ const claimGeneration = async ( return 'CLAIMED'; }; +interface LockedUnificationAuctionRow { + auctionId: number; + status: 'OPEN' | 'FINALIZING'; + closeAt: Date; + detail: unknown; +} + +interface HighestUnificationBidRow { + bidId: number; + bidderGeneralId: number; + amount: number; + meta: unknown; +} + +const insertMessage = async (transaction: GamePrisma.TransactionClient, draft: MessageRecordDraft): Promise => { + const rows = await transaction.$queryRaw>` + INSERT INTO message (mailbox, type, src, dest, time, valid_until, message) + VALUES ( + ${draft.mailbox}, + ${draft.msgType}, + ${draft.srcId}, + ${draft.destId}, + ${draft.time}, + ${draft.validUntil}, + CAST(${JSON.stringify(draft.payload)} AS jsonb) + ) + RETURNING id + `; + const id = rows[0]?.id; + if (!id) throw new Error('Failed to persist unification auction cancellation message.'); + return id; +}; + +const cancelPendingUniqueAuctions = async ( + transaction: GamePrisma.TransactionClient, + input: UnificationFinalizationInput, + world: InMemoryTurnWorld +): Promise => { + const lockedRows = await transaction.$queryRaw` + SELECT + auction.id AS "auctionId", + auction.status, + auction.close_at AS "closeAt", + auction.detail + FROM auction + WHERE auction.type = 'UNIQUE_ITEM' + AND auction.status IN ('OPEN', 'FINALIZING') + ORDER BY auction.close_at ASC, auction.id ASC + FOR UPDATE OF auction + `; + if (lockedRows.length !== input.auctionCancellations.length) { + throw new Error( + `Unification auction set changed: planned=${input.auctionCancellations.length}, actual=${lockedRows.length}.` + ); + } + + for (const [index, row] of lockedRows.entries()) { + const planned = input.auctionCancellations[index]!; + const title = asRecord(row.detail).title; + if ( + row.auctionId !== planned.auctionId || + row.status !== planned.status || + row.closeAt.getTime() !== planned.closeAt.getTime() || + title !== planned.title + ) { + throw new Error(`Unification auction plan mismatch: ${planned.auctionId}.`); + } + + const highestRows = await transaction.$queryRaw` + SELECT id AS "bidId", general_id AS "bidderGeneralId", amount, meta + FROM auction_bid + WHERE auction_id = ${row.auctionId} + ORDER BY amount DESC, id ASC + LIMIT 1 + `; + const highest = highestRows[0] ?? null; + const highestRankTrackedAmount = Math.max( + 0, + readNumber(highest ? asRecord(highest.meta).inheritSpentTrackedAmount : 0) + ); + if ( + (highest?.bidId ?? null) !== planned.highestBidId || + (highest?.bidderGeneralId ?? null) !== planned.bidderGeneralId || + (highest?.amount ?? null) !== planned.amount || + highestRankTrackedAmount !== planned.rankTrackedAmount + ) { + throw new Error(`Unification auction highest bid changed: ${planned.auctionId}.`); + } + + if (highest) { + const bidder = world.getGeneralById(highest.bidderGeneralId); + const dbBidder = await transaction.general.findUnique({ + where: { id: highest.bidderGeneralId }, + select: { userId: true }, + }); + if (!bidder?.userId || bidder.userId !== dbBidder?.userId) { + throw new Error(`Unification auction refund owner is unavailable: ${planned.auctionId}.`); + } + await transaction.inheritancePoint.upsert({ + where: { userId_key: { userId: bidder.userId, key: 'previous' } }, + update: { value: { increment: highest.amount } }, + create: { userId: bidder.userId, key: 'previous', value: highest.amount }, + }); + await transaction.rankData.upsert({ + where: { + generalId_type: { generalId: bidder.id, type: 'inherit_spent_dyn' }, + }, + update: { + nationId: bidder.nationId, + value: readNumber(bidder.meta.inherit_spent_dyn), + }, + create: { + generalId: bidder.id, + nationId: bidder.nationId, + type: 'inherit_spent_dyn', + value: readNumber(bidder.meta.inherit_spent_dyn), + }, + }); + + const nation = world.getNationById(bidder.nationId); + const message: MessageDraft = { + msgType: 'private', + src: { + generalId: 0, + generalName: '', + nationId: 0, + nationName: 'System', + color: '#000000', + icon: '', + }, + dest: { + generalId: bidder.id, + generalName: bidder.name, + nationId: bidder.nationId, + nationName: nation?.name ?? '재야', + color: nation?.color ?? '#000000', + icon: bidder.picture ?? '', + }, + text: `${planned.auctionId}번 ${planned.title}가 취소되었습니다.`, + time: input.completedAt, + validUntil: new Date('9999-12-31T00:00:00.000Z'), + }; + await sendMessage({ insertMessage: (draft) => insertMessage(transaction, draft) }, message, { + sendDestOnly: true, + }); + } + + await transaction.auction.update({ + where: { id: row.auctionId }, + data: { status: 'CANCELED', finishedAt: input.completedAt }, + }); + } +}; + export const persistUnificationFinalization = async ( transaction: GamePrisma.TransactionClient, input: UnificationFinalizationInput, @@ -127,6 +295,10 @@ export const persistUnificationFinalization = async ( const nations = world.listNations(); const eligibleGenerals = generals.filter((general) => general.userId && general.npcState < 2); + // Ref cancels and refunds every unfinished unique auction before it merges + // inheritance. Keep that order inside the generation transaction. + await cancelPendingUniqueAuctions(transaction, input, world); + const pointRows = eligibleGenerals.length ? await transaction.inheritancePoint.findMany({ where: { userId: { in: eligibleGenerals.map((general) => general.userId!) } }, @@ -142,24 +314,18 @@ export const persistUnificationFinalization = async ( for (const general of eligibleGenerals) { const userId = general.userId!; - const generalMeta = asRecord(general.meta); const currentPoints = pointsByUser.get(userId) ?? new Map(); const previous = currentPoints.get('previous') ?? 0; - const livedMonth = readNumber(generalMeta.inherit_lived_month); - const maxDomestic = readNumber(generalMeta.max_domestic_critical); - const activeAction = readNumber(generalMeta.inherit_active_action); - const combat = readNumber(generalMeta.rank_warnum) * 5; - const sabotage = readNumber(generalMeta.firenum) * 20; - const dex = - Object.entries(generalMeta).reduce( - (sum, [key, value]) => (key.startsWith('dex') ? sum + readNumber(value) : sum), - 0 - ) * 0.001; const unifier = currentPoints.get('unifier') ?? 0; const unifierAward = general.nationId === input.winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0; - const total = Math.floor( - previous + livedMonth + maxDomestic + activeAction * 3 + combat + sabotage + dex + unifier + unifierAward - ); + const mergedPoints = Object.fromEntries( + ALL_MERGED_INHERITANCE_KEYS.map((key) => { + const stored = resolveStoredInheritancePoint(currentPoints, general, key, unifierAward); + const effectiveStored = key === 'unifier' ? stored + unifierAward : stored; + return [key, computeActiveInheritancePoint(general, key, effectiveStored)]; + }) + ) as Record<(typeof ALL_MERGED_INHERITANCE_KEYS)[number], number>; + const total = Math.floor(previous + Object.values(mergedPoints).reduce((sum, value) => sum + value, 0)); await transaction.inheritancePoint.upsert({ where: { userId_key: { userId, key: 'previous' } }, @@ -176,13 +342,8 @@ export const persistUnificationFinalization = async ( month: input.month, value: { previous, - lived_month: livedMonth, - max_domestic_critical: maxDomestic, - active_action: activeAction, - combat, - sabotage, - dex, - unifier, + ...mergedPoints, + unifierBeforeAward: unifier, unifierAward, total, generationKey: input.generationKey, diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index b409074..f0ffcd6 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -1506,6 +1506,10 @@ async function handleAuctionOpen( ctx: CommandHandlerContext, command: Extract ): Promise { + const worldMeta = asRecord(ctx.world.getState().meta); + if (Number(worldMeta.isUnited ?? 0) !== 0 || Number(worldMeta.isunited ?? 0) !== 0) { + return { type: 'auctionOpen', ok: false, reason: '천하통일 후에는 경매를 이용할 수 없습니다.' }; + } return openAuction(command, ctx.world, ctx.commandDb); } @@ -1513,6 +1517,15 @@ async function handleAuctionBid( ctx: CommandHandlerContext, command: Extract ): Promise { + const worldMeta = asRecord(ctx.world.getState().meta); + if (Number(worldMeta.isUnited ?? 0) !== 0 || Number(worldMeta.isunited ?? 0) !== 0) { + return { + type: 'auctionBid', + ok: false, + auctionId: command.auctionId, + reason: '천하통일 후에는 경매를 이용할 수 없습니다.', + }; + } if (!ctx.auctionBidder) { return { type: 'auctionBid', diff --git a/app/game-engine/test/unificationFinalization.integration.test.ts b/app/game-engine/test/unificationFinalization.integration.test.ts index 29b853a..6799cd9 100644 --- a/app/game-engine/test/unificationFinalization.integration.test.ts +++ b/app/game-engine/test/unificationFinalization.integration.test.ts @@ -2,14 +2,20 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; +import { createAuctionBidder } from '../src/auction/bidder.js'; import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { composeCalendarHandlers } from '../src/turn/calendarHandlers.js'; +import { EngineStateManager } from '../src/turn/engineStateManager.js'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createMonthlyEventHandler, type MonthlyEventActionHandler } from '../src/turn/monthlyEventHandler.js'; +import { createMergeInheritPointRankHandler } from '../src/turn/monthlyUniqueInheritAction.js'; +import { loadPendingUnificationAuctionCancellations } from '../src/turn/unificationAuctionCancellation.js'; import { createUnificationHandler } from '../src/turn/unificationHandler.js'; import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js'; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); -const fixtureId = 992_001; +const fixtureId = 8_901; const serverId = 'che_unification_atomicity_fixture'; const profileName = 'che'; const userId = 'unification-atomicity-user'; @@ -19,6 +25,9 @@ integration('unification finalization transaction', () => { let closeDb: (() => Promise) | undefined; const cleanup = async (): Promise => { + await db.message.deleteMany({ where: { mailbox: fixtureId } }); + await db.auction.deleteMany({ where: { hostGeneralId: fixtureId } }); + await db.event.deleteMany({ where: { id: fixtureId } }); await db.unificationFinalization.deleteMany({ where: { serverId } }); await db.yearbookHistory.deleteMany({ where: { profileName: serverId } }); await db.emperor.deleteMany({ where: { serverId } }); @@ -120,15 +129,54 @@ integration('unification finalization transaction', () => { rank_warnum: 4, firenum: 2, dex1: 100, + max_belong: 4, + betwin: 2, + betgold: 1_000, + betwingold: 500, + inherit_earned_act: 5, + inherit_spent_dyn: 30, }, }, }); await db.inheritancePoint.createMany({ data: [ { userId, key: 'previous', value: 100 }, - { userId, key: 'unifier', value: 7 }, + { userId, key: 'tournament', value: 11 }, ], }); + const futureCloseAt = new Date(Date.now() + 86_400_000); + const uniqueAuction = await db.auction.create({ + data: { + type: 'UNIQUE_ITEM', + targetCode: 'che_서적_07_논어', + hostGeneralId: fixtureId, + hostName: '(상인)', + detail: { title: '논어 경매', isReverse: false }, + status: 'OPEN', + closeAt: futureCloseAt, + }, + }); + const resourceAuction = await db.auction.create({ + data: { + type: 'BUY_RICE', + targetCode: '100', + hostGeneralId: fixtureId, + hostName: '원자장수', + detail: { title: '쌀 구매 경매', amount: 100, isReverse: false }, + status: 'OPEN', + closeAt: futureCloseAt, + }, + }); + await db.event.create({ + data: { + id: fixtureId, + targetCode: 'united', + priority: 5_000, + condition: true, + action: [['MergeInheritPointRank']], + meta: { fixture: 'unification-atomicity' }, + }, + }); const worldRow = await db.worldState.create({ data: { scenarioCode: 'unification-atomicity-fixture', @@ -160,14 +208,71 @@ integration('unification finalization transaction', () => { }, }); + const beforeBid = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + const bidWorld = new InMemoryTurnWorld(beforeBid.state, beforeBid.snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); + const bidder = await createAuctionBidder({ databaseUrl: databaseUrl!, world: bidWorld }); + try { + await expect( + bidder.bid({ + type: 'auctionBid', + auctionId: uniqueAuction.id, + generalId: fixtureId, + amount: 30, + tryExtendCloseDate: false, + }) + ).resolves.toMatchObject({ ok: true, auctionId: uniqueAuction.id }); + await expect( + bidder.bid({ + type: 'auctionBid', + auctionId: uniqueAuction.id, + generalId: fixtureId, + amount: 50, + tryExtendCloseDate: false, + }) + ).resolves.toMatchObject({ ok: true, auctionId: uniqueAuction.id }); + } finally { + await bidder.close(); + } + expect( + (await db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId, key: 'previous' } } })).value + ).toBe(50); + expect( + await db.rankData.findUniqueOrThrow({ + where: { generalId_type: { generalId: fixtureId, type: 'inherit_spent_dyn' } }, + }) + ).toMatchObject({ value: 50 }); + expect( + (await db.auctionBid.findMany({ where: { auctionId: uniqueAuction.id }, orderBy: { id: 'asc' } })).map( + (bid) => bid.meta + ) + ).toEqual([ + expect.objectContaining({ inheritSpentTrackedAmount: 30 }), + expect.objectContaining({ inheritSpentTrackedAmount: 50 }), + ]); + const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); let world: InMemoryTurnWorld | null = null; - const unification = createUnificationHandler({ profileName, getWorld: () => world }); + const actions = new Map(); + actions.set('MergeInheritPointRank', createMergeInheritPointRankHandler({ getWorld: () => world })); + const events = createMonthlyEventHandler({ getWorld: () => world, startYear: 190, actions }); + const unification = createUnificationHandler({ + profileName, + getWorld: () => world, + loadPendingUniqueAuctions: () => loadPendingUnificationAuctionCancellations(databaseUrl!), + dispatchUnitedEvents: (context) => events.dispatchTarget('united', context), + }); world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, { schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, - calendarHandler: unification.handler, + calendarHandler: composeCalendarHandlers(events, unification.handler), }); const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { profileName }); + const stateManager = new EngineStateManager(); + stateManager.register('world', { + capture: () => world!.captureState(), + restore: (captured) => world!.restoreState(captured), + }); const runResult = { lastTurnTime: '0190-07-01T00:00:00.000Z', processedGenerals: 0, @@ -176,12 +281,21 @@ integration('unification finalization transaction', () => { partial: false, }; try { - await world.advanceMonth(new Date('0190-07-01T00:00:00.000Z')); - expect(world.getState().meta).toMatchObject({ isUnited: 2, isunited: 2, refreshLimit: 200 }); - expect(world.peekDirtyState().pendingUnificationFinalizations).toHaveLength(1); - expect(world.peekDirtyState().pendingYearbookSnapshots).toHaveLength(1); - - await expect(hooks.hooks.flushChanges?.(runResult)).rejects.toThrow(); + const beforeFailedTurn = world.captureState(); + await expect( + stateManager.transaction(async () => { + await world!.advanceMonth(new Date('0190-07-01T00:00:00.000Z')); + expect(world!.getState().meta).toMatchObject({ isUnited: 2, isunited: 2, refreshLimit: 200 }); + expect(world!.peekDirtyState().pendingUnificationFinalizations).toHaveLength(1); + expect(world!.peekDirtyState().pendingYearbookSnapshots).toHaveLength(1); + expect(world!.getGeneralById(fixtureId)).toMatchObject({ + inheritancePoints: { previous: 100, unifier: 2_000, tournament: 11 }, + meta: { inherit_earned_dyn: 2_155.1, inherit_earned: 2_160.1, inherit_spent: 0 }, + }); + await hooks.hooks.flushChanges?.(runResult); + }) + ).rejects.toThrow(); + expect(world.captureState()).toEqual(beforeFailedTurn); expect(await db.unificationFinalization.count({ where: { serverId } })).toBe(0); expect(await db.yearbookHistory.count({ where: { profileName: serverId } })).toBe(0); @@ -192,8 +306,11 @@ integration('unification finalization transaction', () => { expect( (await db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId, key: 'previous' } } })) .value - ).toBe(100); - expect(world.peekDirtyState().pendingUnificationFinalizations).toHaveLength(1); + ).toBe(50); + expect((await db.auction.findUniqueOrThrow({ where: { id: uniqueAuction.id } })).status).toBe('OPEN'); + expect((await db.auction.findUniqueOrThrow({ where: { id: resourceAuction.id } })).status).toBe('OPEN'); + expect(await db.message.count({ where: { mailbox: fixtureId } })).toBe(0); + expect(world.peekDirtyState().pendingUnificationFinalizations).toHaveLength(0); await db.gameHistory.create({ data: { @@ -204,13 +321,70 @@ integration('unification finalization transaction', () => { scenarioName: '원자성 시나리오', }, }); - await hooks.hooks.flushChanges?.(runResult); + await stateManager.transaction(async () => { + await world!.advanceMonth(new Date('0190-07-01T00:00:00.000Z')); + await hooks.hooks.flushChanges?.(runResult); + }); expect(await db.unificationFinalization.count({ where: { serverId } })).toBe(1); expect(await db.inheritanceResult.count({ where: { serverId } })).toBe(1); expect(await db.oldGeneral.count({ where: { serverId } })).toBe(1); expect(await db.oldNation.count({ where: { serverId } })).toBe(2); expect(await db.emperor.count({ where: { serverId } })).toBe(1); + expect( + (await db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId, key: 'previous' } } })) + .value + ).toBe(2_255); + expect(await db.inheritancePoint.count({ where: { userId, key: { not: 'previous' } } })).toBe(0); + expect(await db.inheritanceResult.findFirstOrThrow({ where: { serverId } })).toMatchObject({ + value: expect.objectContaining({ + previous: 100, + max_belong: 40, + tournament: 11, + betting: 5, + unifier: 2_000, + unifierBeforeAward: 0, + unifierAward: 2_000, + total: 2_255, + }), + }); + expect(await db.auction.findUniqueOrThrow({ where: { id: uniqueAuction.id } })).toMatchObject({ + status: 'CANCELED', + finishedAt: new Date('0190-07-01T00:00:00.000Z'), + }); + expect((await db.auction.findUniqueOrThrow({ where: { id: resourceAuction.id } })).status).toBe('OPEN'); + expect(await db.auctionBid.count({ where: { auctionId: uniqueAuction.id } })).toBe(2); + const cancellationMessage = await db.message.findFirstOrThrow({ where: { mailbox: fixtureId } }); + expect(cancellationMessage).toMatchObject({ + mailbox: fixtureId, + src: 0, + dest: fixtureId, + time: new Date('0190-07-01T00:00:00.000Z'), + }); + expect(cancellationMessage.message).toMatchObject({ + text: `${uniqueAuction.id}번 논어 경매가 취소되었습니다.`, + }); + expect(await db.event.count({ where: { id: fixtureId } })).toBe(1); + expect( + await db.rankData.findUniqueOrThrow({ + where: { generalId_type: { generalId: fixtureId, type: 'inherit_spent' } }, + }) + ).toMatchObject({ value: 0 }); + await expect( + db.rankData.findUniqueOrThrow({ + where: { generalId_type: { generalId: fixtureId, type: 'inherit_spent_dyn' } }, + }) + ).resolves.toMatchObject({ value: 0 }); + await expect( + db.rankData.findUniqueOrThrow({ + where: { generalId_type: { generalId: fixtureId, type: 'inherit_earned_dyn' } }, + }) + ).resolves.toMatchObject({ value: 2_155 }); + await expect( + db.rankData.findUniqueOrThrow({ + where: { generalId_type: { generalId: fixtureId, type: 'inherit_earned' } }, + }) + ).resolves.toMatchObject({ value: 2_160 }); expect((await db.gameHistory.findUniqueOrThrow({ where: { serverId } })).winnerNation).toBe(fixtureId); const yearbook = await db.yearbookHistory.findUniqueOrThrow({ where: { diff --git a/app/game-engine/test/unificationHandler.test.ts b/app/game-engine/test/unificationHandler.test.ts new file mode 100644 index 0000000..84b6fdc --- /dev/null +++ b/app/game-engine/test/unificationHandler.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { City, MapDefinition, Nation } from '@sammo-ts/logic'; + +import { composeCalendarHandlers } from '../src/turn/calendarHandlers.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createMonthlyEventHandler, type MonthlyEventActionHandler } from '../src/turn/monthlyEventHandler.js'; +import { createMergeInheritPointRankHandler } from '../src/turn/monthlyUniqueInheritAction.js'; +import { createUnificationHandler } from '../src/turn/unificationHandler.js'; +import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const map: MapDefinition = { + id: 'united-test', + name: 'united-test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, +}; + +const general: TurnGeneral = { + id: 1, + userId: 'user-1', + name: '통일장수', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 80, strength: 70, intelligence: 60 }, + turnTime: new Date('0190-07-01T00:00:00.000Z'), + role: { + items: { horse: null, weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + inheritancePoints: { previous: 100, unifier: 7, tournament: 11 }, + meta: { + killturn: 24, + inherit_lived_month: 10, + max_belong: 4, + max_domestic_critical: 20, + inherit_active_action: 3, + rank_warnum: 4, + firenum: 2, + dex1: 100, + betwin: 2, + betgold: 1000, + betwingold: 500, + inherit_earned_act: 5, + inherit_spent_dyn: 50, + }, + officerLevel: 12, + experience: 10, + dedication: 5, + injury: 0, + gold: 100, + rice: 100, + crew: 100, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 40, + npcState: 0, + picture: '1.png', + imageServer: 0, +}; + +const nation: Nation = { + id: 1, + name: '통일국', + color: '#ffffff', + capitalCityId: 1, + chiefGeneralId: 1, + gold: 1000, + rice: 2000, + power: 3000, + level: 1, + typeCode: 'test', + meta: {}, +}; + +const city: City = { + id: 1, + name: '통일도시', + nationId: 1, + level: 1, + state: 0, + population: 1000, + populationMax: 2000, + agriculture: 0, + agricultureMax: 0, + commerce: 0, + commerceMax: 0, + security: 0, + securityMax: 0, + supplyState: 1, + frontState: 0, + defence: 0, + defenceMax: 0, + wall: 0, + wallMax: 0, + meta: {}, +}; + +describe('unification handler', () => { + it('refunds pending unique bids and runs UNITED before setting the united flag', async () => { + const observed: Array<{ isUnited: unknown; unifier: number; previous: number; spent: unknown }> = []; + const actions = new Map(); + let world: InMemoryTurnWorld | null = null; + actions.set('MergeInheritPointRank', createMergeInheritPointRankHandler({ getWorld: () => world })); + actions.set('ObserveUnited', () => { + const current = world!.getGeneralById(1)!; + observed.push({ + isUnited: world!.getState().meta.isUnited, + unifier: current.inheritancePoints?.unifier ?? 0, + previous: current.inheritancePoints?.previous ?? 0, + spent: current.meta.inherit_spent_dyn, + }); + }); + const events = createMonthlyEventHandler({ getWorld: () => world, startYear: 190, actions }); + const auctionCancellation = { + auctionId: 77, + status: 'OPEN' as const, + closeAt: new Date('0190-08-01T00:00:00.000Z'), + title: '보물 경매', + highestBidId: 5, + bidderGeneralId: 1, + amount: 30, + rankTrackedAmount: 30, + }; + const legacyAuctionCancellation = { + auctionId: 78, + status: 'FINALIZING' as const, + closeAt: new Date('0190-08-02T00:00:00.000Z'), + title: '기존 보물 경매', + highestBidId: 6, + bidderGeneralId: 1, + amount: 20, + rankTrackedAmount: 0, + }; + const unification = createUnificationHandler({ + profileName: 'che', + getWorld: () => world, + loadPendingUniqueAuctions: async () => [auctionCancellation, legacyAuctionCancellation], + dispatchUnitedEvents: (context) => events.dispatchTarget('UNITED', context), + }); + const state: TurnWorldState = { + id: 1, + currentYear: 190, + currentMonth: 6, + tickSeconds: 600, + lastTurnTime: new Date('0190-06-01T00:00:00.000Z'), + meta: { serverId: 'server-1', refreshLimit: 2 }, + }; + const snapshot: TurnWorldSnapshot = { + generals: [general], + cities: [city], + nations: [nation], + troops: [], + diplomacy: [], + events: [ + { + id: 10, + targetCode: 'united', + priority: 5000, + condition: true, + action: [['ObserveUnited'], ['MergeInheritPointRank']], + meta: {}, + }, + ], + initialEvents: [], + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: map.id, unitSet: 'test' }, + }, + scenarioMeta: { + title: 'united test', + startYear: 190, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, + map, + }; + world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + calendarHandler: composeCalendarHandlers(events, unification.handler), + }); + + await world.advanceMonth(new Date('0190-07-01T00:00:00.000Z')); + + expect(observed).toEqual([{ isUnited: undefined, unifier: 2007, previous: 150, spent: 20 }]); + expect(world.getState().meta).toMatchObject({ isUnited: 2, isunited: 2, refreshLimit: 200 }); + expect(world.getGeneralById(1)).toMatchObject({ + inheritancePoints: { previous: 150, unifier: 2007, tournament: 11 }, + meta: { inherit_earned_dyn: 2162.1, inherit_earned: 2167.1, inherit_spent: 20 }, + }); + expect(world.listEvents('united')).toHaveLength(1); + expect(world.peekDirtyState().pendingUnificationFinalizations).toEqual([ + expect.objectContaining({ auctionCancellations: [auctionCancellation, legacyAuctionCancellation] }), + ]); + + const bid = vi.fn(); + const commands = createTurnDaemonCommandHandler({ + world, + auctionBidder: { bid }, + }); + await expect( + commands.handle({ type: 'auctionBid', auctionId: 77, generalId: 1, amount: 100 }) + ).resolves.toMatchObject({ ok: false, reason: '천하통일 후에는 경매를 이용할 수 없습니다.' }); + await expect( + commands.handle({ type: 'auctionOpen', auctionType: 'UNIQUE_ITEM', generalId: 1, amount: 100 }) + ).resolves.toMatchObject({ ok: false, reason: '천하통일 후에는 경매를 이용할 수 없습니다.' }); + expect(bid).not.toHaveBeenCalled(); + }); +}); diff --git a/app/game-engine/test/unificationPersistence.test.ts b/app/game-engine/test/unificationPersistence.test.ts index ad2bcbc..7fd79b6 100644 --- a/app/game-engine/test/unificationPersistence.test.ts +++ b/app/game-engine/test/unificationPersistence.test.ts @@ -4,7 +4,7 @@ import type { GamePrisma } from '@sammo-ts/infra'; import type { City, Nation } from '@sammo-ts/logic'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; -import { persistUnificationFinalization } from '../src/turn/unificationPersistence.js'; +import { persistUnificationFinalization, resolveStoredInheritancePoint } from '../src/turn/unificationPersistence.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; const buildWorld = (): InMemoryTurnWorld => { @@ -135,12 +135,29 @@ const input = { year: 190, month: 7, completedAt: new Date('0190-07-01T00:00:00.000Z'), + auctionCancellations: [], } as const; describe('persistUnificationFinalization', () => { + it.each([ + { label: 'missing row', rows: [], memoryValue: 2_000, expected: 0 }, + { label: 'zero row', rows: [['unifier', 0] as const], memoryValue: 2_000, expected: 0 }, + { label: 'positive row', rows: [['unifier', 7] as const], memoryValue: 2_007, expected: 7 }, + ])('resolves the pre-award unifier value for $label', ({ rows, memoryValue, expected }) => { + expect( + resolveStoredInheritancePoint( + new Map(rows), + { inheritancePoints: { unifier: memoryValue } }, + 'unifier', + 2_000 + ) + ).toBe(expected); + }); + it('does not write when the transaction-scoped generation was already applied', async () => { const transaction = Object.assign({} as GamePrisma.TransactionClient, { $executeRaw: vi.fn().mockResolvedValue(1), + $queryRaw: vi.fn().mockResolvedValue([]), unificationFinalization: { findUnique: vi.fn().mockResolvedValue({ generationKey: input.generationKey, @@ -171,6 +188,7 @@ describe('persistUnificationFinalization', () => { const emperorCreate = vi.fn().mockResolvedValue({}); const transaction = Object.assign({} as GamePrisma.TransactionClient, { $executeRaw: vi.fn().mockResolvedValue(1), + $queryRaw: vi.fn().mockResolvedValue([]), unificationFinalization: { findUnique: vi.fn().mockResolvedValue(null), create: vi.fn().mockResolvedValue({}),