From b14d8d52f69a7648c04c9cd2e3bfd912c781b071 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 6 Sep 2026 13:39:30 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A0=95=EC=A7=80=C2=B7=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20=EA=B8=B0=EC=88=98=EC=9D=98=20=EC=9E=A5=EC=88=98=20?= =?UTF-8?q?=EC=B0=B8=EC=97=AC=EB=A5=BC=20=ED=97=88=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=ED=99=95=EC=A0=95=20=EA=B8=B0=EB=A1=9D=20=EB=B3=B4?= =?UTF-8?q?=ED=98=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router/join/index.ts | 16 ++-- .../test/createGeneral.integration.test.ts | 95 +++++++++++++++++++ .../test/npcPossession.integration.test.ts | 55 +++++++++++ .../test/selectPool.integration.test.ts | 46 +++++++++ .../src/lifecycle/databaseCommandQueue.ts | 7 ++ app/game-engine/src/tournament/finalizer.ts | 4 +- app/game-engine/src/turn/databaseHooks.ts | 13 ++- .../turn/generalTurnLifecyclePersistence.ts | 6 ++ .../src/turn/joinCreateGeneralService.ts | 23 +++-- app/game-engine/src/turn/seasonRecords.ts | 14 +++ .../src/turn/unificationPersistence.ts | 3 + .../databaseCommandQueue.integration.test.ts | 63 ++++++++++++ ...rnLifecyclePersistence.integration.test.ts | 29 ++++++ .../generalTurnLifecyclePersistence.test.ts | 2 + ...nificationFinalization.integration.test.ts | 16 ++++ .../test/unificationPersistence.test.ts | 19 +++- app/game-frontend/e2e/joinLayout.spec.ts | 40 ++++++++ app/game-frontend/src/views/JoinView.vue | 4 + docs/architecture/game-clock.md | 25 +++++ 19 files changed, 458 insertions(+), 22 deletions(-) create mode 100644 app/game-engine/src/turn/seasonRecords.ts diff --git a/app/game-api/src/router/join/index.ts b/app/game-api/src/router/join/index.ts index cfffefdc..f3076252 100644 --- a/app/game-api/src/router/join/index.ts +++ b/app/game-api/src/router/join/index.ts @@ -4,11 +4,7 @@ import { z } from 'zod'; import type { GameApiContext, WorldStateRow } from '../../context.js'; import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js'; import { asNumber, asRecord, asStringArray } from '@sammo-ts/common'; -import { - CLOCK_OPERATION_PERSISTENCE_LOCK, - GamePrisma, - acquireGameSchemaAdvisoryXactLock, -} from '@sammo-ts/infra'; +import { CLOCK_OPERATION_PERSISTENCE_LOCK, GamePrisma, acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra'; import { isWarTraitKey, JOIN_PERSONALITY_TRAIT_KEYS, @@ -339,6 +335,11 @@ export const joinRouter = router({ }; }); + const serverId = asRecord(worldState.meta).serverId; + const history = + typeof serverId === 'string' + ? await ctx.db.gameHistory.findUnique({ where: { serverId }, select: { status: true } }) + : null; const inheritConst = resolveInheritConstants(worldState); const inheritTotalPoint = ctx.auth?.user.id ? await readInheritancePoint(ctx.db, ctx.auth.user.id, 'previous') @@ -376,6 +377,7 @@ export const joinRouter = router({ npcGeneralCount, }, inherit: { + enabled: history?.status !== 'COMPLETED', totalPoint: inheritTotalPoint, costs: { inheritBornSpecialPoint: inheritConst.inheritBornSpecialPoint, @@ -595,10 +597,10 @@ export const joinRouter = router({ message: 'World state is not initialized.', }); } - if (!['PREOPEN', 'RUNNING', 'MANUAL'].includes(clockRows[0].clockPhase)) { + if (!['PREOPEN', 'RUNNING', 'MANUAL', 'SUSPENDED', 'COMPLETED'].includes(clockRows[0].clockPhase)) { throw new TRPCError({ code: 'PRECONDITION_FAILED', - message: '게임 시계가 중단된 동안은 NPC 빙의 후보를 갱신할 수 없습니다.', + message: '게임 시계를 조정하는 동안은 NPC 빙의 후보를 갱신할 수 없습니다.', }); } const worldState = await transaction.worldState.findFirst(); diff --git a/app/game-api/test/createGeneral.integration.test.ts b/app/game-api/test/createGeneral.integration.test.ts index 3ee87f94..2b9f74dc 100644 --- a/app/game-api/test/createGeneral.integration.test.ts +++ b/app/game-api/test/createGeneral.integration.test.ts @@ -229,6 +229,14 @@ integration('generic general creation through the durable turn daemon', () => { create: { userId, key, value }, }); } + // 이 fixture는 특기 없는 일반 생성/reload를 검증한다. 1% 천재 추첨은 별도 계약이다. + const seededWorld = await db.worldState.findFirstOrThrow(); + await db.worldState.update({ + where: { id: seededWorld.id }, + data: { + meta: { ...(seededWorld.meta as Record), genius: 0 }, + }, + }); await startRuntime('create-general-integration-daemon'); }, 60_000); @@ -559,4 +567,91 @@ integration('generic general creation through the durable turn daemon', () => { expect.arrayContaining([expect.objectContaining({ userId: 'forged-owner' })]) ); }, 10_000); + it.each([ + { phase: 'SUSPENDED', united: 0, finalized: false }, + { phase: 'SUSPENDED', united: 2, finalized: true }, + { phase: 'COMPLETED', united: 3, finalized: true }, + ])( + 'creates a visitor in $phase/$united without reopening season records', + async ({ phase, united, finalized }) => { + await stopRuntime('prepare frozen participation'); + const original = await db.worldState.findFirstOrThrow(); + const history = await db.gameHistory.findUniqueOrThrow({ where: { serverId: profile } }); + const visitorId = `visitor-${phase}-${united}`; + const auth = buildAuth(visitorId, '방문자', 7000 + united); + await db.inheritancePoint.create({ data: { userId: visitorId, key: 'previous', value: 5000 } }); + await db.worldState.update({ + where: { id: original.id }, + data: { + clockPhase: phase, + meta: { + ...(original.meta as Record), + isUnited: united, + isunited: united, + }, + }, + }); + await db.gameHistory.update({ + where: { serverId: profile }, + data: { status: finalized ? 'COMPLETED' : 'OPEN' }, + }); + const readRecords = async () => ({ + points: await db.inheritancePoint.findMany({ where: { userId: visitorId }, orderBy: { id: 'asc' } }), + logs: await db.inheritanceLog.count({ where: { userId: visitorId } }), + baseline: await db.gameInheritanceBaseline.count({ where: { serverId: profile, userId: visitorId } }), + hall: await db.hallOfFame.findMany({ where: { serverId: profile }, orderBy: { id: 'asc' } }), + results: await db.inheritanceResult.count({ where: { serverId: profile } }), + oldGenerals: await db.oldGeneral.count({ where: { serverId: profile } }), + }); + const before = await readRecords(); + try { + await startRuntime(`visitor-${phase}-${united}`); + const caller = appRouter.createCaller(buildContext(`visitor-${phase}-${united}`, auth)); + expect((await caller.join.getConfig()).inherit.enabled).toBe(!finalized); + const input = { + name: `방문${united}`, + pic: false, + leadership: 55, + strength: 55, + intel: 55, + character: 'che_안전' as const, + }; + if (finalized) { + await expect( + appRouter + .createCaller(buildContext(`visitor-paid-${united}`, auth)) + .join.createGeneral({ ...input, inheritTurntimeZone: 7 }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(await readRecords()).toEqual(before); + } + const result = await caller.join.createGeneral(input); + expect(await db.general.findUniqueOrThrow({ where: { id: result.generalId } })).toMatchObject({ + userId: visitorId, + npcState: 0, + }); + expect(await db.worldState.findUniqueOrThrow({ where: { id: original.id } })).toMatchObject({ + clockPhase: phase, + clockTick: original.clockTick, + lastTurnTick: original.lastTurnTick, + }); + if (finalized) expect(await readRecords()).toEqual(before); + else + expect( + await db.gameInheritanceBaseline.count({ where: { serverId: profile, userId: visitorId } }) + ).toBe(1); + await stopRuntime('verify visitor persisted'); + await startRuntime(`visitor-reload-${phase}-${united}`); + expect(runtime?.world.getGeneralById(result.generalId)?.userId).toBe(visitorId); + } finally { + await stopRuntime('restore participation fixture'); + await db.worldState.update({ + where: { id: original.id }, + data: { clockPhase: original.clockPhase, meta: original.meta! }, + }); + await db.gameHistory.update({ where: { serverId: profile }, data: { status: history.status } }); + await startRuntime('participation-fixture-restored'); + } + }, + 30_000 + ); }); diff --git a/app/game-api/test/npcPossession.integration.test.ts b/app/game-api/test/npcPossession.integration.test.ts index bbb807ba..ba31772b 100644 --- a/app/game-api/test/npcPossession.integration.test.ts +++ b/app/game-api/test/npcPossession.integration.test.ts @@ -716,4 +716,59 @@ integration('mode 1 NPC possession through token reservation and the durable dae expect(await db.general.count({ where: { userId: rejectedUserId } })).toBe(0); expect(runtime!.world.listGenerals().some(({ userId: owner }) => owner === rejectedUserId)).toBe(false); }, 45_000); + it.each(['SUSPENDED', 'COMPLETED'])( + 'reserves and possesses an NPC in %s without changing finalized records', + async (phase) => { + await stopRuntime('prepare frozen possession'); + const original = await db.worldState.findFirstOrThrow(); + const history = await db.gameHistory.findUniqueOrThrow({ where: { serverId: profile } }); + const visitorId = `npc-visitor-${phase}`; + const visitor = buildAuth(visitorId, '축하방문자', phase === 'SUSPENDED' ? 801 : 802); + await db.worldState.update({ + where: { id: original.id }, + data: { + clockPhase: phase, + meta: { ...(original.meta as Record), isUnited: 2, isunited: 2 }, + }, + }); + await db.gameHistory.update({ where: { serverId: profile }, data: { status: 'COMPLETED' } }); + const records = async () => ({ + hall: await db.hallOfFame.findMany({ where: { serverId: profile }, orderBy: { id: 'asc' } }), + old: await db.oldGeneral.findMany({ where: { serverId: profile }, orderBy: { id: 'asc' } }), + points: await db.inheritancePoint.findMany({ where: { userId: visitorId } }), + results: await db.inheritanceResult.count({ where: { serverId: profile } }), + }); + const before = await records(); + try { + await startRuntime(`npc-visitor-${phase}`); + const caller = appRouter.createCaller(buildContext(`npc-visitor-${phase}`, visitor)); + const candidates = await caller.join.listPossessCandidates({}); + const picked = candidates.candidates[0]!; + expect(picked).toBeDefined(); + await caller.join.possessGeneral({ generalId: picked.id, tokenNonce: candidates.tokenNonce }); + expect(await db.general.findUniqueOrThrow({ where: { id: picked.id } })).toMatchObject({ + userId: visitorId, + npcState: 1, + }); + expect(await records()).toEqual(before); + expect(await db.worldState.findUniqueOrThrow({ where: { id: original.id } })).toMatchObject({ + clockPhase: phase, + clockTick: original.clockTick, + lastTurnTick: original.lastTurnTick, + }); + await stopRuntime('verify frozen possession reload'); + await startRuntime(`npc-visitor-reload-${phase}`); + expect(runtime!.world.getGeneralById(picked.id)?.userId).toBe(visitorId); + } finally { + await stopRuntime('restore frozen possession'); + await db.worldState.update({ + where: { id: original.id }, + data: { clockPhase: original.clockPhase, meta: original.meta! }, + }); + await db.gameHistory.update({ where: { serverId: profile }, data: { status: history.status } }); + await startRuntime('frozen-possession-restored'); + } + }, + 30_000 + ); }); diff --git a/app/game-api/test/selectPool.integration.test.ts b/app/game-api/test/selectPool.integration.test.ts index a99d45b0..99bc3fde 100644 --- a/app/game-api/test/selectPool.integration.test.ts +++ b/app/game-api/test/selectPool.integration.test.ts @@ -664,4 +664,50 @@ integration('scenario 903 select pool through the durable turn daemon', () => { error: null, }); }, 30_000); + it.each(['SUSPENDED', 'COMPLETED'])( + 'creates from the selection pool while %s', + async (phase) => { + await runtime!.lifecycle.stop('prepare frozen pool creation'); + await daemonLoop; + await runtime!.close(); + const original = await db.worldState.findFirstOrThrow(); + await db.worldState.update({ where: { id: original.id }, data: { clockPhase: phase } }); + await db.gameHistory.update({ where: { serverId: profile }, data: { status: 'COMPLETED' } }); + runtime = await createTurnDaemonRuntime({ + profile, + databaseUrl: databaseUrl!, + enableDatabaseFlush: true, + enableLeaseHeartbeat: false, + leaseOwnerId: `frozen-pool-${phase}`, + }); + turnDaemon = new DatabaseTurnDaemonTransport(db, 10_000); + daemonLoop = runtime.lifecycle.start(); + await turnDaemon.requestStatus(10_000); + const visitorId = `pool-visitor-${phase}`; + const visitorAuth = { ...auth, user: { ...auth.user, id: visitorId } }; + const readRecords = async () => ({ + hall: await db.hallOfFame.count({ where: { serverId: profile } }), + old: await db.oldGeneral.count({ where: { serverId: profile } }), + points: await db.inheritancePoint.findMany({ where: { userId: visitorId } }), + results: await db.inheritanceResult.count({ where: { serverId: profile } }), + }); + const before = await readRecords(); + const candidates = await appRouter + .createCaller(buildContext(`pool-reserve-${phase}`, visitorAuth)) + .join.getSelectionPool(); + const result = await appRouter + .createCaller(buildContext(`pool-create-${phase}`, visitorAuth)) + .join.selectPoolGeneral({ uniqueName: candidates.candidates[0]!.uniqueName, personality: 'che_안전' }); + expect(await db.general.findUniqueOrThrow({ where: { id: result.generalId } })).toMatchObject({ + userId: visitorId, + }); + expect(await readRecords()).toEqual(before); + expect(await db.worldState.findUniqueOrThrow({ where: { id: original.id } })).toMatchObject({ + clockPhase: phase, + clockTick: original.clockTick, + lastTurnTick: original.lastTurnTick, + }); + }, + 30_000 + ); }); diff --git a/app/game-engine/src/lifecycle/databaseCommandQueue.ts b/app/game-engine/src/lifecycle/databaseCommandQueue.ts index c1e216ac..887abfbe 100644 --- a/app/game-engine/src/lifecycle/databaseCommandQueue.ts +++ b/app/game-engine/src/lifecycle/databaseCommandQueue.ts @@ -169,6 +169,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T AND ( ${gameplayAllowed} OR "event_type" = 'getStatus' + OR ( + ${world?.clockPhase === 'SUSPENDED' || world?.clockPhase === 'COMPLETED'} + AND "event_type" IN ( + 'joinCreateGeneral', 'npcPossessGeneral', + 'selectPoolReserve', 'selectPoolCreate', 'selectPoolReselect' + ) + ) OR ( ${suspendedTournamentBetCommand} AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta') diff --git a/app/game-engine/src/tournament/finalizer.ts b/app/game-engine/src/tournament/finalizer.ts index ba4d07d8..c8bbda02 100644 --- a/app/game-engine/src/tournament/finalizer.ts +++ b/app/game-engine/src/tournament/finalizer.ts @@ -1,3 +1,4 @@ +import { areSeasonRecordsFinalized } from '../turn/seasonRecords.js'; import { JosaUtil, asRecord } from '@sammo-ts/common'; import { createGamePostgresConnector, type GamePrisma } from '@sammo-ts/infra'; import { ActionLogger, LogFormat, type TournamentType, type TriggerValue } from '@sammo-ts/logic'; @@ -253,7 +254,8 @@ export const createTournamentRewardFinalizer = async (options: { })) .filter((entry) => !!entry.userId); - for (const entry of pointUpdates) { + const recordsFinalized = await areSeasonRecordsFinalized(db, world.getState().meta.serverId); + for (const entry of recordsFinalized ? [] : pointUpdates) { await db.inheritancePoint.upsert({ where: { userId_key: { userId: entry.userId!, key: 'tournament' }, diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 15eab787..331ed78f 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -1,3 +1,4 @@ +import { areSeasonRecordsFinalized } from './seasonRecords.js'; import { acquireGameSchemaAdvisoryXactLock, CLOCK_OPERATION_PERSISTENCE_LOCK, @@ -602,7 +603,8 @@ const persistNationBettingOpen = async ( const persistNationBettingFinish = async ( prisma: GamePrisma.TransactionClient, - finish: PendingNationBettingFinish + finish: PendingNationBettingFinish, + recordsFinalized: boolean ): Promise => { await prisma.$queryRaw` SELECT id @@ -652,7 +654,7 @@ const persistNationBettingFinish = async ( })), }); - for (const reward of rewards) { + for (const reward of recordsFinalized ? [] : rewards) { if (!reward.userId) { continue; } @@ -1451,11 +1453,12 @@ export const createDatabaseTurnHooks = async ( `); } + const recordsFinalized = await areSeasonRecordsFinalized(prisma, asRecord(state.meta).serverId); for (const betting of pendingNationBettingOpens) { await persistNationBettingOpen(prisma, betting); } for (const finish of pendingNationBettingFinishes) { - await persistNationBettingFinish(prisma, finish); + await persistNationBettingFinish(prisma, finish, recordsFinalized); } const meta = asRecord(state.meta); @@ -1464,7 +1467,7 @@ export const createDatabaseTurnHooks = async ( const persistInheritancePointAdjustments = async ( entries: typeof inheritancePointAdjustments ): Promise => { - if (entries.length === 0) { + if (recordsFinalized || entries.length === 0) { return; } const grouped = new Map(); @@ -1490,7 +1493,7 @@ export const createDatabaseTurnHooks = async ( } }; const persistInheritanceLogs = async (entries: typeof pendingInheritanceLogs): Promise => { - if (entries.length === 0) { + if (recordsFinalized || entries.length === 0) { return; } await prisma.inheritanceLog.createMany({ diff --git a/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts b/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts index 485518a6..8461fa52 100644 --- a/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts +++ b/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts @@ -1,3 +1,4 @@ +import { areSeasonRecordsFinalized } from './seasonRecords.js'; import { asRecord, HALL_OF_FAME_TYPES, @@ -403,10 +404,15 @@ export const persistGeneralLifecycleEvents = async ( data: { refreshScore: 0 }, }); + const recordsFinalized = await areSeasonRecordsFinalized(prisma, worldMeta.serverId); for (const event of events) { if (event.outcome === 'detached' || event.outcome === 'deleted') { await prisma.generalAccessLog.deleteMany({ where: { generalId: event.generalId } }); } + if (recordsFinalized) { + if (event.outcome === 'retired') await persistPostRetirementRankValues(prisma, event); + continue; + } if (event.outcome !== 'deleted' && event.outcome !== 'retired') { continue; } diff --git a/app/game-engine/src/turn/joinCreateGeneralService.ts b/app/game-engine/src/turn/joinCreateGeneralService.ts index 491e3a60..c8a2bb1f 100644 --- a/app/game-engine/src/turn/joinCreateGeneralService.ts +++ b/app/game-engine/src/turn/joinCreateGeneralService.ts @@ -1,3 +1,4 @@ +import { areSeasonRecordsFinalized } from './seasonRecords.js'; import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import { acquireGameSchemaAdvisoryXactLock, GamePrisma } from '@sammo-ts/infra'; import { @@ -613,14 +614,20 @@ export const createGeneralFromJoin = async (options: { const inheritBonus = validateAndNormalizeBonus(input.inheritBonusStat); const inheritConstants = resolveInheritConstants(worldState); const worldMeta = asRecord(worldState.meta); + const recordsFinalized = await areSeasonRecordsFinalized(db, worldMeta.serverId); const inheritRequiredPoint = calculateInheritanceCost(input, inheritConstants, inheritBonus); - const currentInheritancePoint = await applyInheritanceUser( - db, - input.userId, - worldState.currentYear, - worldState.currentMonth - ); - await ensureGameInheritanceBaseline(db, worldMeta, input.userId, currentInheritancePoint); + if (recordsFinalized && inheritRequiredPoint > 0) { + fail('BAD_REQUEST', '통일 이후에는 유산 포인트를 사용하는 생성 옵션을 적용할 수 없습니다.'); + } + const currentInheritancePoint = recordsFinalized + ? (await db.inheritancePoint.findMany({ where: { userId: input.userId }, select: { value: true } })).reduce( + (sum, row) => sum + row.value, + 0 + ) + : await applyInheritanceUser(db, input.userId, worldState.currentYear, worldState.currentMonth); + if (!recordsFinalized) { + await ensureGameInheritanceBaseline(db, worldMeta, input.userId, currentInheritancePoint); + } if (currentInheritancePoint < inheritRequiredPoint) { fail('BAD_REQUEST', '유산 포인트가 부족합니다. 다시 가입해주세요!'); } @@ -743,7 +750,7 @@ export const createGeneralFromJoin = async (options: { ? input.ownerIconRevision : undefined; const nextInheritancePoint = currentInheritancePoint - inheritRequiredPoint; - const restInheritanceBonus = await resolveRestInheritanceBonus(db, worldState, input.userId); + const restInheritanceBonus = recordsFinalized ? 0 : await resolveRestInheritanceBonus(db, worldState, input.userId); const finalInheritancePoint = nextInheritancePoint + restInheritanceBonus; // Ref의 가오픈 삭제 대기는 정지된 게임 clock이 아니라 실제 요청 접수 시각부터 흐른다. // 미래 정식 오픈에 clock을 고정한 PREOPEN에서도 사용자가 가오픈 중 두 턴을 기다리면 diff --git a/app/game-engine/src/turn/seasonRecords.ts b/app/game-engine/src/turn/seasonRecords.ts new file mode 100644 index 00000000..c24322a9 --- /dev/null +++ b/app/game-engine/src/turn/seasonRecords.ts @@ -0,0 +1,14 @@ +import type { DatabaseClient } from '@sammo-ts/infra'; + +/** 통일 대기/이민족전으로 시계가 재개되어도 확정된 기수의 기록은 다시 열지 않는다. */ +export const areSeasonRecordsFinalized = async ( + db: Pick, + serverId: unknown +): Promise => { + if (typeof serverId !== 'string' || !serverId.trim()) return false; + const history = await db.gameHistory.findUnique({ + where: { serverId: serverId.trim() }, + select: { status: true }, + }); + return history?.status === 'COMPLETED'; +}; diff --git a/app/game-engine/src/turn/unificationPersistence.ts b/app/game-engine/src/turn/unificationPersistence.ts index 62ea23be..d4cfbc8e 100644 --- a/app/game-engine/src/turn/unificationPersistence.ts +++ b/app/game-engine/src/turn/unificationPersistence.ts @@ -1,3 +1,4 @@ +import { areSeasonRecordsFinalized } from './seasonRecords.js'; import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common'; import { acquireGameSchemaAdvisoryXactLock, @@ -95,6 +96,8 @@ const claimGeneration = async ( } return 'ALREADY_APPLIED'; } + // 이전 버전/이관 기수에 generation row가 없어도 통일 기록을 다시 정산하지 않는다. + if (await areSeasonRecordsFinalized(transaction, input.serverId)) return 'ALREADY_APPLIED'; await transaction.unificationFinalization.create({ data: { generationKey: input.generationKey, diff --git a/app/game-engine/test/databaseCommandQueue.integration.test.ts b/app/game-engine/test/databaseCommandQueue.integration.test.ts index c9fa9060..3f474184 100644 --- a/app/game-engine/test/databaseCommandQueue.integration.test.ts +++ b/app/game-engine/test/databaseCommandQueue.integration.test.ts @@ -387,6 +387,69 @@ integration('database command queue', () => { } ); + it.each(['SUSPENDED', 'COMPLETED', 'RECONCILING'])( + 'admits only participation commands while the clock is %s', + async (phase) => { + await db.worldState.updateMany({ data: { clockPhase: phase } }); + const payloads: TurnDaemonCommand[] = [ + { + type: 'joinCreateGeneral', + userId: 'visitor', + ownerDisplayName: '방문자', + seedOwnerIdentity: 'visitor', + name: '방문', + leadership: 55, + strength: 55, + intel: 55, + pic: false, + character: 'che_안전', + profileId: 'che', + }, + { + type: 'npcPossessGeneral', + userId: 'visitor', + ownerDisplayName: '방문자', + profileId: 'che', + generalId: 7, + tokenNonce: 1, + }, + { type: 'selectPoolReserve', userId: 'visitor', seedOwnerIdentity: 'visitor' }, + { + type: 'selectPoolCreate', + userId: 'visitor', + ownerDisplayName: '방문자', + uniqueName: '후보', + personality: 'che_안전', + seedOwnerIdentity: 'visitor', + }, + { type: 'selectPoolReselect', userId: 'visitor', ownerDisplayName: '방문자', uniqueName: '후보' }, + { type: 'vacation', userId: 'visitor', generalId: 7 }, + ]; + const types = payloads.map((payload) => payload.type); + await db.inputEvent.createMany({ + data: payloads.map((payload) => ({ + requestId: `integration:engine:participation:${payload.type}`, + target: 'ENGINE', + eventType: payload.type, + actorUserId: 'visitor', + payload: payload as GamePrisma.InputJsonValue, + })), + }); + const commands = await new DatabaseTurnDaemonCommandQueue(db).drain(); + expect(commands.map((command) => command.type)).toEqual(phase === 'RECONCILING' ? [] : types.slice(0, -1)); + const pending = await db.inputEvent.findUniqueOrThrow({ + where: { requestId: 'integration:engine:participation:vacation' }, + }); + expect(pending).toMatchObject({ status: 'PENDING', attempts: 0 }); + expect(await db.worldState.findFirst()).toMatchObject({ + clockPhase: phase, + clockTick: 123n, + clockRevision: 1n, + deadlineGeneration: 1n, + }); + } + ); + it('dequeues gameplay only in an executable phase and records the processing clock generation', async () => { const existingWorld = await db.worldState.findFirst({ orderBy: { id: 'asc' } }); const world = existingWorld diff --git a/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts b/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts index 581f8db7..bc669f40 100644 --- a/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts +++ b/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts @@ -1360,4 +1360,33 @@ integration('general turn lifecycle persistence', () => { await expect(db.oldGeneral.count({ where: { serverId, generalNo: general.id } })).resolves.toBe(0); await expect(db.oldGeneral.count({ where: { serverId, generalNo: automaticGeneral.id } })).resolves.toBe(0); }); + it('preserves all finalized settlements and archives even after an invader resume or NPC ownership change', async () => { + await db.gameHistory.update({ where: { serverId }, data: { status: 'COMPLETED' } }); + const readRecords = async () => ({ + hall: await db.hallOfFame.findMany({ where: { serverId }, orderBy: { id: 'asc' } }), + points: await db.inheritancePoint.findMany({ where: { userId: { in: userIds } }, orderBy: { id: 'asc' } }), + results: await db.inheritanceResult.findMany({ where: { serverId }, orderBy: { id: 'asc' } }), + logs: await db.inheritanceLog.findMany({ where: { userId: { in: userIds } }, orderBy: { id: 'asc' } }), + archives: await db.oldGeneral.findMany({ where: { serverId }, orderBy: { id: 'asc' } }), + }); + const before = await readRecords(); + for (const united of [1, 2, 3, 0]) { + const general = makeGeneral(generalIds[0]!, userIds[1]!, { + experience: 999999, + meta: { killturn: 0, inheritRandomUnique: true, inherit_active_action: 999999 }, + }); + await db.$transaction(async (transaction) => { + await persistGeneralLifecycleEvents( + transaction, + [ + { ...event(general, 'retired'), isUnitedAtEvent: united }, + { ...event(general, 'deleted'), isUnitedAtEvent: united }, + ], + { serverId, isUnited: united }, + {} + ); + }); + expect(await readRecords()).toEqual(before); + } + }); }); diff --git a/app/game-engine/test/generalTurnLifecyclePersistence.test.ts b/app/game-engine/test/generalTurnLifecyclePersistence.test.ts index 23ad215c..a7ad2ff0 100644 --- a/app/game-engine/test/generalTurnLifecyclePersistence.test.ts +++ b/app/game-engine/test/generalTurnLifecyclePersistence.test.ts @@ -51,6 +51,7 @@ describe('general lifecycle archive history', () => { const general = archivedGeneral(); const upsert = vi.fn(async () => undefined); const prisma = { + gameHistory: { findUnique: vi.fn(async () => ({ status: 'OPEN' })) }, generalAccessLog: { updateMany: vi.fn(async () => ({ count: 1 })), deleteMany: vi.fn(async () => ({ count: 1 })), @@ -146,6 +147,7 @@ describe('general lifecycle archive history', () => { findUnique: vi.fn(async () => null), }, gameHistory: { + findUnique: vi.fn(async () => ({ status: 'OPEN' })), count: vi.fn(async () => 99), }, hallOfFame: { diff --git a/app/game-engine/test/unificationFinalization.integration.test.ts b/app/game-engine/test/unificationFinalization.integration.test.ts index 36663bf7..5b123aba 100644 --- a/app/game-engine/test/unificationFinalization.integration.test.ts +++ b/app/game-engine/test/unificationFinalization.integration.test.ts @@ -464,6 +464,19 @@ integration('unification finalization transaction', () => { expect(await db.message.count({ where: { mailbox: fixtureId } })).toBe(0); expect(world.peekDirtyState().pendingUnificationFinalizations).toHaveLength(0); + const assertLateInheritanceIgnored = async () => { + const beforePoints = await db.inheritancePoint.findMany({ where: { userId }, orderBy: { id: 'asc' } }); + const beforeLogs = await db.inheritanceLog.count({ where: { userId } }); + world.queueInheritancePointAdjustment(userId, 'previous', 999999); + world.queueInheritancePointAdjustment(userId, 'tournament', 100); + world.queueInheritanceLog({ userId, year: 190, month: 7, text: '확정 후 보상은 기록하지 않음' }); + await hooks.hooks.flushChanges?.(runResult); + expect(await db.inheritancePoint.findMany({ where: { userId }, orderBy: { id: 'asc' } })).toEqual( + beforePoints + ); + expect(await db.inheritanceLog.count({ where: { userId } })).toBe(beforeLogs); + }; + await db.gameHistory.create({ data: { serverId, @@ -597,6 +610,7 @@ integration('unification finalization transaction', () => { }, }); expect(yearbook.globalHistory).toEqual(expect.arrayContaining([expect.stringContaining('【통일】')])); + await assertLateInheritanceIgnored(); expect(world.peekDirtyState().pendingUnificationFinalizations).toHaveLength(0); await hooks.hooks.flushChanges?.(runResult); @@ -772,6 +786,8 @@ integration('unification finalization transaction', () => { await db.clockProjectionOutbox.findFirstOrThrow({ where: { suspensionId: suspension.id } }) ).toMatchObject({ status: 'PENDING', targetRevision: 2n }); + await assertLateInheritanceIgnored(); + if (process.env.REDIS_URL) { const redis = createRedisConnector({ url: process.env.REDIS_URL }); await redis.connect(); diff --git a/app/game-engine/test/unificationPersistence.test.ts b/app/game-engine/test/unificationPersistence.test.ts index 17c95a1f..b1e19111 100644 --- a/app/game-engine/test/unificationPersistence.test.ts +++ b/app/game-engine/test/unificationPersistence.test.ts @@ -176,6 +176,19 @@ describe('persistUnificationFinalization', () => { expect(transaction.unificationFinalization.create).not.toHaveBeenCalled(); }); + it('does not reopen a completed imported season without a finalization generation', async () => { + const transaction = Object.assign({} as GamePrisma.TransactionClient, { + $executeRaw: vi.fn().mockResolvedValue(1), + $queryRaw: vi.fn().mockResolvedValue([]), + gameHistory: { findUnique: vi.fn().mockResolvedValue({ status: 'COMPLETED' }) }, + unificationFinalization: { findUnique: vi.fn().mockResolvedValue(null), create: vi.fn() }, + }); + await expect(persistUnificationFinalization(transaction, input, buildWorld())).resolves.toMatchObject({ + status: 'ALREADY_APPLIED', + }); + expect(transaction.unificationFinalization.create).not.toHaveBeenCalled(); + }); + it('uses one supplied transaction for absolute inheritance and archive writes', async () => { const inheritanceUpsert = vi.fn().mockResolvedValue({}); const inheritanceResultCreate = vi.fn().mockResolvedValue({}); @@ -216,7 +229,11 @@ describe('persistUnificationFinalization', () => { { generalId: 1, type: 'ttl', value: 1 }, ]), }, - gameHistory: { count: vi.fn().mockResolvedValue(1), update: gameHistoryUpdate }, + gameHistory: { + findUnique: vi.fn().mockResolvedValue({ status: 'OPEN' }), + count: vi.fn().mockResolvedValue(1), + update: gameHistoryUpdate, + }, hallOfFame: { findMany: vi.fn().mockResolvedValue([]), create: hallCreate, diff --git a/app/game-frontend/e2e/joinLayout.spec.ts b/app/game-frontend/e2e/joinLayout.spec.ts index d4031b96..bbdfd76f 100644 --- a/app/game-frontend/e2e/joinLayout.spec.ts +++ b/app/game-frontend/e2e/joinLayout.spec.ts @@ -6,6 +6,7 @@ const operationNames = (route: Route) => decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); type FixtureState = { + finalized?: boolean; mapRequests: number; generalRequests: number; created?: boolean; @@ -69,6 +70,7 @@ const installFixture = async (page: Page, state: FixtureState): Promise => npcGeneralCount: 1, }, inherit: { + enabled: !state.finalized, totalPoint: 30, costs: { inheritBornSpecialPoint: 10, @@ -453,3 +455,41 @@ test('shows the creation success dialog exactly once before navigating home', as await expect(page).toHaveURL(new RegExp(`${gameBasePath}/?$`)); expect(state.createRequests).toBe(1); }); + +for (const width of [1280, 390]) { + test(`allows completed-season creation without inheritance options at ${width}px`, async ({ page }, testInfo) => { + const state: FixtureState = { mapRequests: 0, generalRequests: 0, finalized: true }; + await installFixture(page, state); + await page.setViewportSize({ width, height: 900 }); + await page.goto('join'); + await page.locator('.advanced-options summary').click(); + await expect(page.locator('.advanced-options')).toContainText('기본 옵션으로 장수를 생성할 수 있습니다.'); + await expect(page.locator('.inherit-options')).toHaveCount(0); + await expect( + page.locator('.create-form').getByRole('button', { name: '장수 생성', exact: true }) + ).toBeEnabled(); + await page.evaluate(() => document.fonts.ready); + const geometry = await page.locator('.advanced-options').evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + color: getComputedStyle(element).color, + documentWidth: document.documentElement.scrollWidth, + viewportWidth: window.innerWidth, + html: element.outerHTML, + }; + }); + expect(geometry.documentWidth).toBe(geometry.viewportWidth); + await testInfo.attach('completed-join-geometry', { + body: JSON.stringify(geometry), + contentType: 'application/json', + }); + await page.screenshot({ path: testInfo.outputPath('completed-join.png'), fullPage: true }); + await page.locator('.create-form').getByRole('button', { name: '장수 생성', exact: true }).click(); + await expect(page.getByRole('alertdialog', { name: '완료' })).toBeVisible(); + expect(state.createRequests).toBe(1); + }); +} diff --git a/app/game-frontend/src/views/JoinView.vue b/app/game-frontend/src/views/JoinView.vue index 540a42a1..2bfd61da 100644 --- a/app/game-frontend/src/views/JoinView.vue +++ b/app/game-frontend/src/views/JoinView.vue @@ -808,6 +808,10 @@ onUnmounted(() => {
유산 포인트 정보를 불러오지 못했습니다.
+
+ 통일 이후에는 유산 포인트를 사용하거나 생성 보너스를 받지 않습니다. 기본 옵션으로 장수를 생성할 수 + 있습니다. +
보유 포인트: {{ inheritTotalPoint }}
diff --git a/docs/architecture/game-clock.md b/docs/architecture/game-clock.md index 7450ed2c..0c341a54 100644 --- a/docs/architecture/game-clock.md +++ b/docs/architecture/game-clock.md @@ -92,6 +92,31 @@ GAME 표시 좌표를 옮기는 명령이 별도로 예약된 정식 WALL 오픈 이민족전이 끝나 `isUnited=3`이 되면 clock phase도 같은 월 transaction에서 `COMPLETED`가 되어 이후 GAME_TIME이 진행하지 않습니다. +### 정지·종료 중 참여와 기수 기록 확정 + +`SUSPENDED`와 `COMPLETED`에서도 일반 장수 생성, NPC 빙의, 선택형 장수의 +후보 예약·생성·재선택은 기존 ENGINE input event/lease/clock fence를 거쳐 +처리합니다. NPC 빙의 후보도 같은 phase에서 예약할 수 있습니다. 예약 만료와 +재선택 cooldown은 기존 GAME_TIME 규칙을 유지하며, `RECONCILING`에서는 +처리하지 않습니다. 이 허용은 턴 실행이나 다른 ENGINE 명령을 열지 않습니다. + +기수 기록의 `gameHistory.status=COMPLETED`와 게임 시계 phase는 다릅니다. +통일 직후 기록은 확정되지만 시계는 이민족 선택 대기(`SUSPENDED`), 이민족전 +진행(`RUNNING`), 이벤트 종료(`COMPLETED`)를 거칠 수 있습니다. 기록 보호는 +현재 serverId의 DB 기수 상태를 기준으로 합니다. + +첫 통일 transaction은 종전처럼 사망·은퇴 정산과 통일 유산/명예의 전당/ +왕조 archive를 저장한 뒤 기수를 확정합니다. 이후에는 유산 보상·사망·은퇴 +정산과 명예의 전당 추가/갱신, 장수 archive의 추가/소유자 덮어쓰기를 막습니다. +따라서 통일 뒤 NPC에 빙의해도 기존 통일 기록의 소유자를 가져오거나 다음 기수의 +복귀 보너스용 참가 이력을 만들지 않습니다. 현재 장수·rank read model, 메시지, +게임 운영 기록은 계속 갱신합니다. + +통일 이후 일반 생성은 유산 bucket 정리, 신규/복귀 보너스, 현재 기수 유산 +원금 기록을 하지 않습니다. 유산 포인트가 필요한 생성 옵션은 거부하고 생성 +화면에도 안내합니다. 통일 전 운영 일시정지는 기존 유산 생성 규칙을 유지합니다. +이는 2026-09-06 승인된 Core 제품 정책입니다. + DB migration은 GAME 규칙의 기존 DateTime 투영에서 tick을 채웁니다. 새 설치와 migration 재실행은 `prisma:migrate:deploy:game`으로 수행합니다. 메시지의 연도 9999 같은 무기한 호환값은 일반 메시지의 투영일 뿐입니다. actionable deadline은