feat: 정지·종료 기수의 장수 참여를 허용하고 확정 기록 보호
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user