feat: port pre-start general deletion lifecycle

This commit is contained in:
2026-07-31 05:44:36 +00:00
parent f1929be3fe
commit 71ec02d091
24 changed files with 1257 additions and 160 deletions
+260
View File
@@ -0,0 +1,260 @@
import { describe, expect, it, vi } from 'vitest';
import type { GamePrisma } from '@sammo-ts/infra';
import type { TurnSchedule } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter } from '../src/turn/prestartDeletion.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const acceptedAt = new Date('2026-07-31T00:00:00.000Z');
const buildGeneral = (overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
id: 7,
userId: 'owner-7',
name: '테스트장수',
nationId: 0,
cityId: 1,
troopId: 0,
stats: { leadership: 70, strength: 60, intelligence: 50 },
turnTime: new Date('0185-01-01T00:00:00.000Z'),
recentWarTime: null,
role: {
items: { horse: null, weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
penalty: {},
officerLevel: 0,
experience: 0,
dedication: 0,
injury: 0,
gold: 1_000,
rice: 1_000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 20,
npcState: 0,
...overrides,
meta: { killturn: 6, ...overrides.meta },
});
const buildFixture = (options: {
generals?: TurnGeneral[];
minTurns?: number;
lastTurnTime?: Date;
troops?: TurnWorldSnapshot['troops'];
lastRefresh?: Date | null;
eventActor?: string;
}) => {
const state: TurnWorldState = {
id: 1,
currentYear: 185,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: options.lastTurnTime ?? new Date('2026-07-30T00:00:00.000Z'),
meta: { opentime: '2026-08-01T00:00:00.000Z' },
};
const snapshot: TurnWorldSnapshot = {
generals: options.generals ?? [buildGeneral()],
cities: [],
nations: [],
troops: options.troops ?? [],
diplomacy: [],
events: [],
initialEvents: [],
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 },
iconPath: '',
map: {},
const: {
...(options.minTurns === undefined ? {} : { minTurnDieOnPrestart: options.minTurns }),
},
environment: { mapName: 'test', unitSet: 'test' },
},
scenarioMeta: {
title: 'test',
startYear: 180,
life: null,
fiction: null,
history: [],
ignoreDefaultEvents: false,
},
map: {
id: 'test',
name: 'test',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
},
};
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
const inputEvent = {
findUnique: vi.fn(async ({ where }: { where: { requestId: string } }) => ({
createdAt: acceptedAt,
actorUserId: options.eventActor ?? 'owner-7',
target: 'ENGINE',
eventType: where.requestId.startsWith('status-') ? 'ensureDieOnPrestartStatus' : 'dieOnPrestart',
})),
};
const generalAccessLog = {
findUnique: vi.fn(async () =>
options.lastRefresh === null ? null : { lastRefresh: options.lastRefresh ?? acceptedAt }
),
};
const db = { inputEvent, generalAccessLog } as unknown as GamePrisma.TransactionClient;
return {
world,
db,
handler: createTurnDaemonCommandHandler({ world }),
inputEvent,
generalAccessLog,
};
};
describe('pre-start general deletion', () => {
it('uses the default two turns, scenario override, and Ref Seoul error timestamp', () => {
expect(buildPrestartDeleteAfter(acceptedAt, 600, { const: {} }).toISOString()).toBe('2026-07-31T00:20:00.000Z');
expect(
buildPrestartDeleteAfter(acceptedAt, 600, {
const: { minTurnDieOnPrestart: 1 },
}).toISOString()
).toBe('2026-07-31T00:10:00.000Z');
expect(formatPrestartDeleteAfter(new Date('2026-07-31T00:20:00.000Z'))).toBe('2026-07-31 09:20:00');
});
it('fixes a legacy missing cutoff once from lastRefresh and returns it on later status requests', async () => {
const lastRefresh = new Date('2026-07-30T23:55:00.000Z');
const fixture = buildFixture({ lastRefresh });
const first = await fixture.handler.handle(
{
type: 'ensureDieOnPrestartStatus',
requestId: 'status-first',
userId: 'owner-7',
generalId: 7,
},
{ db: fixture.db }
);
expect(first).toEqual({
type: 'ensureDieOnPrestartStatus',
generalId: 7,
show: true,
available: false,
availableAt: '2026-07-31T00:15:00.000Z',
});
expect(fixture.world.getGeneralById(7)?.meta.prestart_delete_after).toBe('2026-07-31T00:15:00.000Z');
const second = await fixture.handler.handle(
{
type: 'ensureDieOnPrestartStatus',
requestId: 'status-second',
userId: 'owner-7',
generalId: 7,
},
{ db: fixture.db }
);
expect(second).toEqual(first);
expect(fixture.generalAccessLog.findUnique).toHaveBeenCalledTimes(1);
});
it('preserves the Ref error order and validates the durable event actor', async () => {
const started = buildFixture({
generals: [buildGeneral({ nationId: 1 })],
lastTurnTime: new Date('2026-08-02T00:00:00.000Z'),
});
await expect(
started.handler.handle(
{ type: 'dieOnPrestart', requestId: 'die-started', userId: 'owner-7', generalId: 7 },
{ db: started.db }
)
).resolves.toMatchObject({ ok: false, reason: '게임이 시작되었습니다.' });
const nation = buildFixture({ generals: [buildGeneral({ nationId: 1 })] });
await expect(
nation.handler.handle(
{ type: 'dieOnPrestart', requestId: 'die-nation', userId: 'owner-7', generalId: 7 },
{ db: nation.db }
)
).resolves.toMatchObject({ ok: false, reason: '이미 국가에 소속되어있습니다.' });
const actorMismatch = buildFixture({ eventActor: 'foreign-user' });
await expect(
actorMismatch.handler.handle(
{ type: 'dieOnPrestart', requestId: 'die-actor', userId: 'owner-7', generalId: 7 },
{ db: actorMismatch.db }
)
).rejects.toThrow('input event actor does not match dieOnPrestart user');
const ownerMismatch = buildFixture({});
await expect(
ownerMismatch.handler.handle(
{ type: 'dieOnPrestart', requestId: 'die-owner', userId: 'foreign-user', generalId: 7 },
{ db: ownerMismatch.db }
)
).resolves.toMatchObject({ ok: false, reason: '장수가 없습니다' });
});
it('allows the equality boundary and queues troop cleanup, lifecycle, and exact global log together', async () => {
const leader = buildGeneral({
troopId: 7,
meta: { killturn: 3, prestart_delete_after: acceptedAt.toISOString() },
});
const member = buildGeneral({ id: 8, userId: 'owner-8', name: '부대원', troopId: 7 });
const fixture = buildFixture({
generals: [leader, member],
troops: [{ id: 7, nationId: 0, name: '테스트부대' }],
});
await expect(
fixture.handler.handle(
{ type: 'dieOnPrestart', requestId: 'die-success', userId: 'owner-7', generalId: 7 },
{ db: fixture.db }
)
).resolves.toEqual({ type: 'dieOnPrestart', ok: true, generalId: 7 });
expect(fixture.world.getGeneralById(7)).toBeNull();
expect(fixture.world.getGeneralById(8)?.troopId).toBe(0);
const changes = fixture.world.peekDirtyState();
expect(changes.deletedTroops).toEqual([7]);
expect(changes.deletedGenerals).toEqual([7]);
expect(changes.lifecycleEvents).toEqual([
expect.objectContaining({
generalId: 7,
outcome: 'deleted',
before: expect.objectContaining({ troopId: 0 }),
year: 185,
month: 1,
}),
]);
expect(changes.logs).toEqual([
expect.objectContaining({
scope: 'SYSTEM',
category: 'SUMMARY',
text: '<Y>테스트장수</>가 홀연히 모습을 <R>감추었습니다</>',
}),
]);
});
it('persists a legacy cutoff on early failure without queuing deletion or a log', async () => {
const fixture = buildFixture({ lastRefresh: acceptedAt, minTurns: 1 });
await expect(
fixture.handler.handle(
{ type: 'dieOnPrestart', requestId: 'die-early', userId: 'owner-7', generalId: 7 },
{ db: fixture.db }
)
).resolves.toMatchObject({
ok: false,
reason: '아직 삭제할 수 없습니다. 2026-07-31 09:10:00 부터 가능합니다.',
});
expect(fixture.world.getGeneralById(7)?.meta.prestart_delete_after).toBe('2026-07-31T00:10:00.000Z');
expect(fixture.world.peekDirtyState()).toMatchObject({
deletedGenerals: [],
lifecycleEvents: [],
logs: [],
});
});
});
@@ -10,12 +10,8 @@ import type { TurnGeneral } from '../src/turn/types.js';
const databaseUrl = process.env.GENERAL_LIFECYCLE_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const generalIds = [990_001, 990_002, 990_003];
const userIds = [
'integration-lifecycle-dead',
'integration-lifecycle-retired',
'integration-lifecycle-possessed',
];
const serverId = 'integration-lifecycle';
const userIds = ['integration-lifecycle-dead', 'integration-lifecycle-retired', 'integration-lifecycle-possessed'];
const serverId = 'lifecycle-int';
const makeGeneral = (id: number, userId: string, patch: Partial<TurnGeneral> = {}): TurnGeneral => ({
id,
@@ -57,10 +53,7 @@ const makeGeneral = (id: number, userId: string, patch: Partial<TurnGeneral> = {
...patch,
});
const event = (
general: TurnGeneral,
outcome: GeneralLifecycleEvent['outcome']
): GeneralLifecycleEvent => ({
const event = (general: TurnGeneral, outcome: GeneralLifecycleEvent['outcome']): GeneralLifecycleEvent => ({
generalId: general.id,
outcome,
before: general,
@@ -153,12 +146,24 @@ integration('general turn lifecycle persistence', () => {
const archived = await db.oldGeneral.findUniqueOrThrow({
where: { by_no: { serverId, generalNo: general.id } },
});
expect(asRecord(archived.data).history).toEqual(['<Y>●</>둘째 기록', '<C>●</>첫 기록']);
const archivedData = asRecord(archived.data);
expect(archivedData.history).toEqual(['<Y>●</>둘째 기록', '<C>●</>첫 기록']);
expect(asRecord(archivedData.meta)).not.toHaveProperty('inheritRandomUnique');
expect(asRecord(archivedData.meta)).not.toHaveProperty('inheritSpecificSpecialWar');
expect(
await db.inheritancePoint.findUnique({
where: { userId_key: { userId: general.userId!, key: 'previous' } },
})
).toMatchObject({ value: 3_147 });
expect(
(
await db.inheritanceLog.findMany({
where: { userId: general.userId! },
orderBy: { id: 'asc' },
select: { text: true },
})
).map(({ text }) => text)
).toEqual(['사망으로 랜덤 유니크 구입 3000 포인트 반환', '사망 정산: 3,147 포인트']);
});
it('resets access/ranks and records pre-rebirth hall and inheritance values', async () => {
@@ -185,8 +190,9 @@ integration('general turn lifecycle persistence', () => {
expect(await db.generalAccessLog.findUnique({ where: { generalId: general.id } })).toMatchObject({
refreshScore: 0,
});
expect(await db.rankData.findUnique({ where: { generalId_type: { generalId: general.id, type: 'warnum' } } }))
.toMatchObject({ value: 0 });
expect(
await db.rankData.findUnique({ where: { generalId_type: { generalId: general.id, type: 'warnum' } } })
).toMatchObject({ value: 0 });
expect(
await db.hallOfFame.findUnique({
where: {
@@ -220,12 +226,7 @@ integration('general turn lifecycle persistence', () => {
});
await db.$transaction((tx) =>
persistGeneralLifecycleEvents(
tx,
[event(general, 'deleted')],
{ serverId, startYear: 180 },
{}
)
persistGeneralLifecycleEvents(tx, [event(general, 'deleted')], { serverId, startYear: 180 }, {})
);
expect(
@@ -37,7 +37,11 @@ const archivedGeneral = (): TurnGeneral => ({
deadYear: 240,
affinity: 50,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 0 },
meta: {
killturn: 0,
inheritRandomUnique: true,
inheritSpecificSpecialWar: true,
},
turnTime: new Date('0200-01-01T00:00:00.000Z'),
});
@@ -79,6 +83,7 @@ describe('general lifecycle archive history', () => {
create: expect.objectContaining({
data: expect.objectContaining({
history: ['<Y>●</>둘째 기록', '<C>●</>첫 기록'],
meta: { killturn: 0 },
}),
}),
})