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