feat: 정지·종료 기수의 장수 참여를 허용하고 확정 기록 보호

This commit is contained in:
2026-09-06 13:39:30 +00:00
parent 4d64978cb6
commit b14d8d52f6
19 changed files with 458 additions and 22 deletions
+9 -7
View File
@@ -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();
@@ -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<string, GamePrisma.InputJsonValue>), 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<string, GamePrisma.InputJsonValue>),
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
);
});
@@ -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<string, GamePrisma.InputJsonValue>), 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
);
});
@@ -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
);
});
@@ -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')
+3 -1
View File
@@ -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' },
+8 -5
View File
@@ -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<void> => {
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<void> => {
if (entries.length === 0) {
if (recordsFinalized || entries.length === 0) {
return;
}
const grouped = new Map<string, { userId: string; key: string; amount: number }>();
@@ -1490,7 +1493,7 @@ export const createDatabaseTurnHooks = async (
}
};
const persistInheritanceLogs = async (entries: typeof pendingInheritanceLogs): Promise<void> => {
if (entries.length === 0) {
if (recordsFinalized || entries.length === 0) {
return;
}
await prisma.inheritanceLog.createMany({
@@ -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;
}
@@ -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에서도 사용자가 가오픈 중 두 턴을 기다리면
+14
View File
@@ -0,0 +1,14 @@
import type { DatabaseClient } from '@sammo-ts/infra';
/** 통일 대기/이민족전으로 시계가 재개되어도 확정된 기수의 기록은 다시 열지 않는다. */
export const areSeasonRecordsFinalized = async (
db: Pick<DatabaseClient, 'gameHistory'>,
serverId: unknown
): Promise<boolean> => {
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';
};
@@ -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,
@@ -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
@@ -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);
}
});
});
@@ -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: {
@@ -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();
@@ -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,
+40
View File
@@ -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<void> =>
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);
});
}
+4
View File
@@ -808,6 +808,10 @@ onUnmounted(() => {
</span>
</summary>
<div v-if="!inheritConfig" class="advanced-body muted">유산 포인트 정보를 불러오지 못했습니다.</div>
<div v-else-if="inheritConfig.enabled === false" class="advanced-body muted">
통일 이후에는 유산 포인트를 사용하거나 생성 보너스를 받지 않습니다. 기본 옵션으로 장수를 생성할
있습니다.
</div>
<div v-else class="advanced-body inherit-panel">
<div class="inherit-summary">
<div>보유 포인트: {{ inheritTotalPoint }}</div>
+25
View File
@@ -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은