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
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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에서도 사용자가 가오픈 중 두 턴을 기다리면
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,7 @@ const operationNames = (route: Route) =>
|
||||
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
||||
|
||||
type FixtureState = {
|
||||
finalized?: boolean;
|
||||
mapRequests: number;
|
||||
generalRequests: number;
|
||||
created?: boolean;
|
||||
@@ -69,6 +70,7 @@ const installFixture = async (page: Page, state: FixtureState): Promise<void> =>
|
||||
npcGeneralCount: 1,
|
||||
},
|
||||
inherit: {
|
||||
enabled: !state.finalized,
|
||||
totalPoint: 30,
|
||||
costs: {
|
||||
inheritBornSpecialPoint: 10,
|
||||
@@ -453,3 +455,41 @@ test('shows the creation success dialog exactly once before navigating home', as
|
||||
await expect(page).toHaveURL(new RegExp(`${gameBasePath}/?$`));
|
||||
expect(state.createRequests).toBe(1);
|
||||
});
|
||||
|
||||
for (const width of [1280, 390]) {
|
||||
test(`allows completed-season creation without inheritance options at ${width}px`, async ({ page }, testInfo) => {
|
||||
const state: FixtureState = { mapRequests: 0, generalRequests: 0, finalized: true };
|
||||
await installFixture(page, state);
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto('join');
|
||||
await page.locator('.advanced-options summary').click();
|
||||
await expect(page.locator('.advanced-options')).toContainText('기본 옵션으로 장수를 생성할 수 있습니다.');
|
||||
await expect(page.locator('.inherit-options')).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('.create-form').getByRole('button', { name: '장수 생성', exact: true })
|
||||
).toBeEnabled();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const geometry = await page.locator('.advanced-options').evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
color: getComputedStyle(element).color,
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
viewportWidth: window.innerWidth,
|
||||
html: element.outerHTML,
|
||||
};
|
||||
});
|
||||
expect(geometry.documentWidth).toBe(geometry.viewportWidth);
|
||||
await testInfo.attach('completed-join-geometry', {
|
||||
body: JSON.stringify(geometry),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
await page.screenshot({ path: testInfo.outputPath('completed-join.png'), fullPage: true });
|
||||
await page.locator('.create-form').getByRole('button', { name: '장수 생성', exact: true }).click();
|
||||
await expect(page.getByRole('alertdialog', { name: '완료' })).toBeVisible();
|
||||
expect(state.createRequests).toBe(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -808,6 +808,10 @@ onUnmounted(() => {
|
||||
</span>
|
||||
</summary>
|
||||
<div v-if="!inheritConfig" class="advanced-body muted">유산 포인트 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else-if="inheritConfig.enabled === false" class="advanced-body muted">
|
||||
통일 이후에는 유산 포인트를 사용하거나 생성 보너스를 받지 않습니다. 기본 옵션으로 장수를 생성할 수
|
||||
있습니다.
|
||||
</div>
|
||||
<div v-else class="advanced-body inherit-panel">
|
||||
<div class="inherit-summary">
|
||||
<div>보유 포인트: {{ inheritTotalPoint }}</div>
|
||||
|
||||
@@ -92,6 +92,31 @@ GAME 표시 좌표를 옮기는 명령이 별도로 예약된 정식 WALL 오픈
|
||||
이민족전이 끝나 `isUnited=3`이 되면 clock phase도 같은 월 transaction에서
|
||||
`COMPLETED`가 되어 이후 GAME_TIME이 진행하지 않습니다.
|
||||
|
||||
### 정지·종료 중 참여와 기수 기록 확정
|
||||
|
||||
`SUSPENDED`와 `COMPLETED`에서도 일반 장수 생성, NPC 빙의, 선택형 장수의
|
||||
후보 예약·생성·재선택은 기존 ENGINE input event/lease/clock fence를 거쳐
|
||||
처리합니다. NPC 빙의 후보도 같은 phase에서 예약할 수 있습니다. 예약 만료와
|
||||
재선택 cooldown은 기존 GAME_TIME 규칙을 유지하며, `RECONCILING`에서는
|
||||
처리하지 않습니다. 이 허용은 턴 실행이나 다른 ENGINE 명령을 열지 않습니다.
|
||||
|
||||
기수 기록의 `gameHistory.status=COMPLETED`와 게임 시계 phase는 다릅니다.
|
||||
통일 직후 기록은 확정되지만 시계는 이민족 선택 대기(`SUSPENDED`), 이민족전
|
||||
진행(`RUNNING`), 이벤트 종료(`COMPLETED`)를 거칠 수 있습니다. 기록 보호는
|
||||
현재 serverId의 DB 기수 상태를 기준으로 합니다.
|
||||
|
||||
첫 통일 transaction은 종전처럼 사망·은퇴 정산과 통일 유산/명예의 전당/
|
||||
왕조 archive를 저장한 뒤 기수를 확정합니다. 이후에는 유산 보상·사망·은퇴
|
||||
정산과 명예의 전당 추가/갱신, 장수 archive의 추가/소유자 덮어쓰기를 막습니다.
|
||||
따라서 통일 뒤 NPC에 빙의해도 기존 통일 기록의 소유자를 가져오거나 다음 기수의
|
||||
복귀 보너스용 참가 이력을 만들지 않습니다. 현재 장수·rank read model, 메시지,
|
||||
게임 운영 기록은 계속 갱신합니다.
|
||||
|
||||
통일 이후 일반 생성은 유산 bucket 정리, 신규/복귀 보너스, 현재 기수 유산
|
||||
원금 기록을 하지 않습니다. 유산 포인트가 필요한 생성 옵션은 거부하고 생성
|
||||
화면에도 안내합니다. 통일 전 운영 일시정지는 기존 유산 생성 규칙을 유지합니다.
|
||||
이는 2026-09-06 승인된 Core 제품 정책입니다.
|
||||
|
||||
DB migration은 GAME 규칙의 기존 DateTime 투영에서 tick을 채웁니다. 새 설치와 migration
|
||||
재실행은 `prisma:migrate:deploy:game`으로 수행합니다. 메시지의 연도 9999 같은
|
||||
무기한 호환값은 일반 메시지의 투영일 뿐입니다. actionable deadline은
|
||||
|
||||
Reference in New Issue
Block a user