From 55a9c87610d63009318e95f7c368fa46075a1cb0 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 08:04:14 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(vote):=20=EC=84=A4=EB=AC=B8=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=EC=9E=90=EB=A5=BC=20=EC=9E=A5=EC=88=98=EB=AA=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=80=EC=9E=A5=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 인증 계정 ID나 계정 표시명 대신 getMyGeneral로 확정한 현재 장수명을 opener_name에 저장한다. 서로 다른 세 identity를 둔 회귀 테스트로 저장 경계를 고정한다. --- app/game-api/src/router/vote/index.ts | 3 +-- app/game-api/test/voteRouter.test.ts | 7 ++++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/game-api/src/router/vote/index.ts b/app/game-api/src/router/vote/index.ts index 0eb358af..b52cf230 100644 --- a/app/game-api/src/router/vote/index.ts +++ b/app/game-api/src/router/vote/index.ts @@ -416,7 +416,6 @@ export const voteRouter = router({ ) .mutation(async ({ ctx, input }) => { const general = await getMyGeneral(ctx); - const openerName = ctx.auth?.user.username ?? general.name; const options = normalizeOptions(input.options); if (options.length === 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: '항목이 없습니다.' }); @@ -468,7 +467,7 @@ export const voteRouter = router({ ${multipleOptions}, ${input.revealMode}, ${general.id}, - ${openerName}, + ${general.name}, ${gameTime.now}, ${gameTime.tick === null ? null : BigInt(gameTime.tick)}, ${endAt}, diff --git a/app/game-api/test/voteRouter.test.ts b/app/game-api/test/voteRouter.test.ts index a55ce09c..b169e63f 100644 --- a/app/game-api/test/voteRouter.test.ts +++ b/app/game-api/test/voteRouter.test.ts @@ -264,7 +264,7 @@ describe('vote router actor and permission boundaries', () => { expect(fixture.redisPublish).not.toHaveBeenCalled(); }); - it('publishes a global front-status projection after creating a survey', async () => { + it('stores the authenticated general name and publishes a global projection after creating a survey', async () => { const auth = buildAuth(['admin.survey.open']); auth.user.username = 'admin-account'; auth.user.displayName = '관리자 표시명'; @@ -285,8 +285,9 @@ describe('vote router actor and permission boundaries', () => { const insert = fixture.queryRaw.mock.calls .map(([query]) => query) .find((query) => sqlText(query).includes('INSERT INTO vote_poll')); - expect(insert?.values).toContain('admin-account'); - expect(insert?.values).not.toContain('관리자 장수'); + expect(insert?.values).toContain('관리자 장수'); + expect(insert?.values).not.toContain('admin-account'); + expect(insert?.values).not.toContain('관리자 표시명'); }); it('reports the current world develcost as the legacy five-times survey reward', async () => { From 907b7db1c6d160b35d94c2cfd341cee4361de88c Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 08:13:05 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(game):=20=EA=B0=80=EC=98=A4=ED=94=88=20?= =?UTF-8?q?=EC=9E=A5=EC=88=98=20=EC=82=AD=EC=A0=9C=20=EB=8C=80=EA=B8=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=20=EC=A0=91=EC=88=98=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=EC=97=90=20=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 미래 정식 오픈에 고정된 게임 시각과 durable input event 접수 시각을 분리한다. 일반 생성과 선택 풀 생성의 삭제 가능 시각 및 최초 접속 시각만 operational 시각을 사용하고 RNG와 턴 배치는 기존 논리 시각을 보존한다. --- app/game-api/test/createGeneral.integration.test.ts | 8 ++++++-- app/game-api/test/selectPool.integration.test.ts | 7 ++++++- app/game-engine/src/turn/joinCreateGeneralService.ts | 12 ++++++++---- app/game-engine/src/turn/selectPoolService.ts | 7 ++++--- app/game-engine/src/turn/worldCommandHandler.ts | 10 +++++++++- 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/app/game-api/test/createGeneral.integration.test.ts b/app/game-api/test/createGeneral.integration.test.ts index ad0c613f..52ff8068 100644 --- a/app/game-api/test/createGeneral.integration.test.ts +++ b/app/game-api/test/createGeneral.integration.test.ts @@ -287,12 +287,16 @@ integration('generic general creation through the durable turn daemon', () => { accountIconUpdatedAt: '2026-07-30T00:00:00.000Z', }); const createdAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: created.id } }); + const acceptedEvent = await db.inputEvent.findUniqueOrThrow({ + where: { requestId: `join-create:${userId}:${clientRequestId}` }, + }); if (!createdAccess.lastRefresh) { throw new Error('created general must have an initial access timestamp'); } + expect(createdAccess.lastRefresh).toEqual(acceptedEvent.createdAt); expect( new Date((created.meta as Record).prestart_delete_after as string).getTime() - - createdAccess.lastRefresh.getTime() + acceptedEvent.createdAt.getTime() ).toBe(2 * 5 * 60 * 1_000); expect(runtime!.world.getGeneralById(created.id)).toMatchObject({ id: created.id, @@ -354,7 +358,7 @@ integration('generic general creation through the durable turn daemon', () => { attempts: 1, actorUserId: userId, }); - expect(access.lastRefresh?.getTime()).toBe(runtime!.world.getGameNow(event.createdAt).getTime()); + expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime()); const turnGridOffsetSeconds = ((created.turnTime.getTime() - runtime!.world.getState().lastTurnTime.getTime()) / 1000 + 300) % 300; expect(turnGridOffsetSeconds).toBeGreaterThanOrEqual(35); diff --git a/app/game-api/test/selectPool.integration.test.ts b/app/game-api/test/selectPool.integration.test.ts index 11df30cd..920600e4 100644 --- a/app/game-api/test/selectPool.integration.test.ts +++ b/app/game-api/test/selectPool.integration.test.ts @@ -257,12 +257,17 @@ integration('scenario 903 select pool through the durable turn daemon', () => { const initial = await db.general.findFirstOrThrow({ where: { userId } }); const initialRuntime = runtime!.world.getGeneralById(initial.id); const initialAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: initial.id } }); + const acceptedEvent = await db.inputEvent.findFirstOrThrow({ + where: { actorUserId: userId, eventType: 'selectPoolCreate', status: 'SUCCEEDED' }, + orderBy: { sequence: 'desc' }, + }); if (!initialAccess.lastRefresh) { throw new Error('selected general must have an initial access timestamp'); } + expect(initialAccess.lastRefresh).toEqual(acceptedEvent.createdAt); expect( new Date((initial.meta as Record).prestart_delete_after as string).getTime() - - initialAccess.lastRefresh.getTime() + acceptedEvent.createdAt.getTime() ).toBe(2 * 5 * 60 * 1_000); expect(initialRuntime).toMatchObject({ id: initial.id, diff --git a/app/game-engine/src/turn/joinCreateGeneralService.ts b/app/game-engine/src/turn/joinCreateGeneralService.ts index 99c8697d..a10488fa 100644 --- a/app/game-engine/src/turn/joinCreateGeneralService.ts +++ b/app/game-engine/src/turn/joinCreateGeneralService.ts @@ -522,8 +522,9 @@ export const createGeneralFromJoin = async (options: { worldState: WorldStateRow; input: JoinCreateGeneralInput; acceptedAt: Date; + operationalAcceptedAt: Date; }): Promise<{ ok: true; generalId: number }> => { - const { db, world, worldState, input, acceptedAt } = options; + const { db, world, worldState, input, acceptedAt, operationalAcceptedAt } = options; await lockJoinMutation(db, input.userId); await assertGeneralIdSnapshotMatches(db, world); @@ -734,7 +735,10 @@ export const createGeneralFromJoin = async (options: { const nextInheritancePoint = currentInheritancePoint - inheritRequiredPoint; const restInheritanceBonus = await resolveRestInheritanceBonus(db, worldState, input.userId); const finalInheritancePoint = nextInheritancePoint + restInheritanceBonus; - const prestartDeleteAfter = buildPrestartDeleteAfter(acceptedAt, worldState.tickSeconds, config); + // Ref의 가오픈 삭제 대기는 정지된 게임 clock이 아니라 실제 요청 접수 시각부터 흐른다. + // 미래 정식 오픈에 clock을 고정한 PREOPEN에서도 사용자가 가오픈 중 두 턴을 기다리면 + // 삭제할 수 있어야 하므로 RNG/턴 배치용 acceptedAt과 이 벽시계 경계를 분리한다. + const prestartDeleteAfter = buildPrestartDeleteAfter(operationalAcceptedAt, worldState.tickSeconds, config); const general: TurnGeneral = { id: generalId, userId: input.userId, @@ -839,11 +843,11 @@ export const createGeneralFromJoin = async (options: { }); await db.generalAccessLog.upsert({ where: { generalId }, - update: { userId: input.userId, lastRefresh: acceptedAt }, + update: { userId: input.userId, lastRefresh: operationalAcceptedAt }, create: { generalId, userId: input.userId, - lastRefresh: acceptedAt, + lastRefresh: operationalAcceptedAt, }, }); if (inheritRequiredPoint > 0) { diff --git a/app/game-engine/src/turn/selectPoolService.ts b/app/game-engine/src/turn/selectPoolService.ts index ceb255ba..ba448e96 100644 --- a/app/game-engine/src/turn/selectPoolService.ts +++ b/app/game-engine/src/turn/selectPoolService.ts @@ -690,6 +690,7 @@ export const createGeneralFromSelectionPool = async (options: { uniqueName: string; personality: string; now?: Date; + operationalAcceptedAt: Date; seedOwnerIdentity?: string | number; ownerPicture?: string; ownerImageServer?: number; @@ -754,7 +755,7 @@ export const createGeneralFromSelectionPool = async (options: { const nextChangeAt = new Date( now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000 ); - const prestartDeleteAfter = buildPrestartDeleteAfter(now, worldState.tickSeconds, config); + const prestartDeleteAfter = buildPrestartDeleteAfter(options.operationalAcceptedAt, worldState.tickSeconds, config); const showImgLevel = asNumber(config.showImgLevel, 0); const useOwnerPicture = showImgLevel >= 1 && typeof options.ownerPicture === 'string' && options.ownerPicture !== 'default.jpg'; @@ -893,8 +894,8 @@ export const createGeneralFromSelectionPool = async (options: { } await db.generalAccessLog.upsert({ where: { generalId }, - update: { userId, lastRefresh: now }, - create: { generalId, userId, lastRefresh: now }, + update: { userId, lastRefresh: options.operationalAcceptedAt }, + create: { generalId, userId, lastRefresh: options.operationalAcceptedAt }, }); await clearUnusedReservations(db, userId, now, nowTick); await synchronizeSelectionPoolWorld(db, world); diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index e1e69a84..50747b02 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -296,6 +296,7 @@ async function handleJoinCreateGeneral( ...(command.inheritBonusStat !== undefined ? { inheritBonusStat: command.inheritBonusStat } : {}), }, acceptedAt, + operationalAcceptedAt, })), }; } catch (error) { @@ -366,7 +367,13 @@ async function handleSelectPoolCreate( if (!worldState) { throw new Error('Selection-pool world state is missing.'); } - const acceptedAt = await resolveSelectionCommandAcceptedAt(db, ctx.world, command); + const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command); + const acceptedAt = + command.acceptedGameTick !== undefined + ? ctx.world.gameTickToDate(command.acceptedGameTick) + : command.acceptedGameAt !== undefined + ? new Date(command.acceptedGameAt) + : ctx.world.getGameNow(operationalAcceptedAt); try { return { type: 'selectPoolCreate', @@ -383,6 +390,7 @@ async function handleSelectPoolCreate( ...(command.ownerImageServer !== undefined ? { ownerImageServer: command.ownerImageServer } : {}), ...(command.ownerIconRevision ? { ownerIconRevision: command.ownerIconRevision } : {}), now: acceptedAt, + operationalAcceptedAt, })), }; } catch (error) {