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
);
});