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
@@ -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,