feat: port pre-start general deletion lifecycle
This commit is contained in:
@@ -30,7 +30,7 @@ const resolveImmediateActionRequestId = (
|
|||||||
contextRequestId: string | undefined,
|
contextRequestId: string | undefined,
|
||||||
userId: string,
|
userId: string,
|
||||||
clientRequestId: string | undefined,
|
clientRequestId: string | undefined,
|
||||||
action: 'buildNationCandidate' | 'instantRetreat'
|
action: 'buildNationCandidate' | 'dieOnPrestart' | 'instantRetreat'
|
||||||
): string | undefined => {
|
): string | undefined => {
|
||||||
if (clientRequestId) {
|
if (clientRequestId) {
|
||||||
return `general:${action}:${userId}:${clientRequestId}`;
|
return `general:${action}:${userId}:${clientRequestId}`;
|
||||||
@@ -41,13 +41,19 @@ const resolveImmediateActionRequestId = (
|
|||||||
const requestImmediateAction = async (
|
const requestImmediateAction = async (
|
||||||
ctx: GameApiContext,
|
ctx: GameApiContext,
|
||||||
input: { clientRequestId?: string } | undefined,
|
input: { clientRequestId?: string } | undefined,
|
||||||
action: 'buildNationCandidate' | 'instantRetreat'
|
action: 'buildNationCandidate' | 'dieOnPrestart' | 'instantRetreat'
|
||||||
): Promise<{ ok: true }> => {
|
): Promise<{ ok: true }> => {
|
||||||
const userId = ctx.auth?.user.id;
|
const userId = ctx.auth?.user.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
const general = await getMyGeneral(ctx);
|
const general =
|
||||||
|
action === 'dieOnPrestart'
|
||||||
|
? await ctx.db.general.findFirst({ where: { userId, npcState: 0 } })
|
||||||
|
: await getMyGeneral(ctx);
|
||||||
|
if (!general) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: '장수가 없습니다' });
|
||||||
|
}
|
||||||
const requestId = resolveImmediateActionRequestId(ctx.requestId, userId, input?.clientRequestId, action);
|
const requestId = resolveImmediateActionRequestId(ctx.requestId, userId, input?.clientRequestId, action);
|
||||||
try {
|
try {
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
@@ -277,20 +283,45 @@ export const generalRouter = router({
|
|||||||
penalties,
|
penalties,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
dieOnPrestart: authedProcedure.mutation(async ({ ctx }) => {
|
ensureDieOnPrestartStatus: engineAuthedProcedure.mutation(async ({ ctx }) => {
|
||||||
const general = await getMyGeneral(ctx);
|
const userId = ctx.auth?.user.id;
|
||||||
|
if (!userId) {
|
||||||
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
|
}
|
||||||
|
const general = await ctx.db.general.findFirst({
|
||||||
|
where: { userId, npcState: 0 },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!general) {
|
||||||
|
return { show: false, available: false, availableAt: null };
|
||||||
|
}
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'dieOnPrestart',
|
type: 'ensureDieOnPrestartStatus',
|
||||||
|
...(ctx.requestId ? { requestId: `${ctx.requestId}:general.ensureDieOnPrestartStatus` } : {}),
|
||||||
|
userId,
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
});
|
});
|
||||||
if (!result || result.type !== 'dieOnPrestart') {
|
if (!result) {
|
||||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
throw new TRPCError({
|
||||||
|
code: 'TIMEOUT',
|
||||||
|
message: '삭제 가능 시각을 아직 확인하지 못했습니다. 다시 시도해 주세요.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (!result.ok) {
|
if (result.type !== 'ensureDieOnPrestartStatus') {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
throw new TRPCError({
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: '턴 데몬이 올바르지 않은 삭제 상태를 반환했습니다.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return {
|
||||||
|
show: result.show,
|
||||||
|
available: result.available,
|
||||||
|
availableAt: result.availableAt ?? null,
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
|
dieOnPrestart: engineAuthedProcedure
|
||||||
|
.input(zImmediateActionInput)
|
||||||
|
.mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'dieOnPrestart')),
|
||||||
buildNationCandidate: engineAuthedProcedure
|
buildNationCandidate: engineAuthedProcedure
|
||||||
.input(zImmediateActionInput)
|
.input(zImmediateActionInput)
|
||||||
.mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'buildNationCandidate')),
|
.mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'buildNationCandidate')),
|
||||||
|
|||||||
@@ -475,8 +475,9 @@ export const settleTournamentOutcome = async (options: {
|
|||||||
if (result.type !== expectedType) {
|
if (result.type !== expectedType) {
|
||||||
throw new Error(`${expectedType} 명령에 잘못된 응답(${result.type})을 받았습니다.`);
|
throw new Error(`${expectedType} 명령에 잘못된 응답(${result.type})을 받았습니다.`);
|
||||||
}
|
}
|
||||||
if (!result.ok) {
|
if (!('ok' in result) || !result.ok) {
|
||||||
throw new Error(`${expectedType} 명령이 실패했습니다: ${result.reason}`);
|
const reason = 'reason' in result ? result.reason : '성공 여부가 없는 응답';
|
||||||
|
throw new Error(`${expectedType} 명령이 실패했습니다: ${reason}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -274,6 +274,14 @@ integration('generic general creation through the durable turn daemon', () => {
|
|||||||
killturn: 6,
|
killturn: 6,
|
||||||
inherit_spent_dyn: 4500,
|
inherit_spent_dyn: 4500,
|
||||||
});
|
});
|
||||||
|
const createdAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: created.id } });
|
||||||
|
if (!createdAccess.lastRefresh) {
|
||||||
|
throw new Error('created general must have an initial access timestamp');
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
new Date((created.meta as Record<string, unknown>).prestart_delete_after as string).getTime() -
|
||||||
|
createdAccess.lastRefresh.getTime()
|
||||||
|
).toBe(2 * 5 * 60 * 1_000);
|
||||||
expect(runtime!.world.getGeneralById(created.id)).toMatchObject({
|
expect(runtime!.world.getGeneralById(created.id)).toMatchObject({
|
||||||
id: created.id,
|
id: created.id,
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -74,15 +74,15 @@ const auth: GameSessionTokenPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createContext = (options: {
|
const createContext = (options: {
|
||||||
me?: GeneralRow;
|
me?: GeneralRow | null;
|
||||||
targets?: GeneralRow[];
|
targets?: GeneralRow[];
|
||||||
nationMeta?: Record<string, unknown>;
|
nationMeta?: Record<string, unknown>;
|
||||||
requestCommand?: ReturnType<typeof vi.fn>;
|
requestCommand?: ReturnType<typeof vi.fn>;
|
||||||
}) => {
|
}) => {
|
||||||
const me = options.me ?? buildGeneral();
|
const me = options.me === undefined ? buildGeneral() : options.me;
|
||||||
const targets = options.targets ?? [me];
|
const targets = options.targets ?? (me ? [me] : []);
|
||||||
const requestCommand =
|
const requestCommand =
|
||||||
options.requestCommand ?? vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: me.id }));
|
options.requestCommand ?? vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: me?.id ?? 0 }));
|
||||||
const generalFindUnique = vi.fn(
|
const generalFindUnique = vi.fn(
|
||||||
async ({ where }: { where: { id: number } }) => targets.find((general) => general.id === where.id) ?? null
|
async ({ where }: { where: { id: number } }) => targets.find((general) => general.id === where.id) ?? null
|
||||||
);
|
);
|
||||||
@@ -90,7 +90,7 @@ const createContext = (options: {
|
|||||||
general: {
|
general: {
|
||||||
findFirst: vi.fn(async () => me),
|
findFirst: vi.fn(async () => me),
|
||||||
findUnique: generalFindUnique,
|
findUnique: generalFindUnique,
|
||||||
findMany: vi.fn(async () => targets.filter((general) => general.nationId === me.nationId)),
|
findMany: vi.fn(async () => targets.filter((general) => general.nationId === (me?.nationId ?? 0))),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
},
|
},
|
||||||
city: { findUnique: vi.fn(async () => null) },
|
city: { findUnique: vi.fn(async () => null) },
|
||||||
@@ -232,6 +232,7 @@ describe('in-game my information ownership', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
['dieOnPrestart', 'dieOnPrestart'],
|
||||||
['buildNationCandidate', 'buildNationCandidate'],
|
['buildNationCandidate', 'buildNationCandidate'],
|
||||||
['instantRetreat', 'instantRetreat'],
|
['instantRetreat', 'instantRetreat'],
|
||||||
] as const)('dispatches %s only for the session-owned general', async (procedure, commandType) => {
|
] as const)('dispatches %s only for the session-owned general', async (procedure, commandType) => {
|
||||||
@@ -253,6 +254,32 @@ describe('in-game my information ownership', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('gets the server-owned pre-start deletion status without accepting a general id', async () => {
|
||||||
|
const requestCommand = vi.fn(async () => ({
|
||||||
|
type: 'ensureDieOnPrestartStatus' as const,
|
||||||
|
generalId: 7,
|
||||||
|
show: true,
|
||||||
|
available: false,
|
||||||
|
availableAt: '2026-01-01T00:20:00.000Z',
|
||||||
|
}));
|
||||||
|
const fixture = createContext({ requestCommand });
|
||||||
|
|
||||||
|
await expect(appRouter.createCaller(fixture.context).general.ensureDieOnPrestartStatus()).resolves.toEqual({
|
||||||
|
show: true,
|
||||||
|
available: false,
|
||||||
|
availableAt: '2026-01-01T00:20:00.000Z',
|
||||||
|
});
|
||||||
|
expect(requestCommand).toHaveBeenCalledWith({
|
||||||
|
type: 'ensureDieOnPrestartStatus',
|
||||||
|
userId: 'user-7',
|
||||||
|
generalId: 7,
|
||||||
|
});
|
||||||
|
expect(fixture.db.general.findFirst).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'user-7', npcState: 0 },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('returns the daemon compatibility failure without performing an API-side mutation', async () => {
|
it('returns the daemon compatibility failure without performing an API-side mutation', async () => {
|
||||||
const requestCommand = vi.fn(async () => ({
|
const requestCommand = vi.fn(async () => ({
|
||||||
type: 'instantRetreat' as const,
|
type: 'instantRetreat' as const,
|
||||||
@@ -287,6 +314,38 @@ describe('in-game my information ownership', () => {
|
|||||||
generalId: 7,
|
generalId: 7,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the die-on-prestart request identity when its destructive result times out', async () => {
|
||||||
|
const requestCommand = vi.fn(async () => null);
|
||||||
|
const fixture = createContext({ requestCommand });
|
||||||
|
const caller = appRouter.createCaller(fixture.context);
|
||||||
|
const clientRequestId = '33333333-3333-4333-8333-333333333333';
|
||||||
|
|
||||||
|
await expect(caller.general.dieOnPrestart({ clientRequestId })).rejects.toMatchObject({
|
||||||
|
code: 'TIMEOUT',
|
||||||
|
message: expect.stringContaining('같은 요청으로 다시 시도'),
|
||||||
|
});
|
||||||
|
expect(requestCommand).toHaveBeenCalledWith({
|
||||||
|
type: 'dieOnPrestart',
|
||||||
|
requestId: `general:dieOnPrestart:user-7:${clientRequestId}`,
|
||||||
|
userId: 'user-7',
|
||||||
|
generalId: 7,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects deletion with the legacy no-general message before dispatching a daemon command', async () => {
|
||||||
|
const requestCommand = vi.fn();
|
||||||
|
const fixture = createContext({ me: null, requestCommand });
|
||||||
|
|
||||||
|
await expect(appRouter.createCaller(fixture.context).general.dieOnPrestart()).rejects.toMatchObject({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: '장수가 없습니다',
|
||||||
|
});
|
||||||
|
expect(fixture.db.general.findFirst).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'user-7', npcState: 0 },
|
||||||
|
});
|
||||||
|
expect(requestCommand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('battle-center general and user permissions', () => {
|
describe('battle-center general and user permissions', () => {
|
||||||
|
|||||||
@@ -209,6 +209,14 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
|
|
||||||
const initial = await db.general.findFirstOrThrow({ where: { userId } });
|
const initial = await db.general.findFirstOrThrow({ where: { userId } });
|
||||||
const initialRuntime = runtime!.world.getGeneralById(initial.id);
|
const initialRuntime = runtime!.world.getGeneralById(initial.id);
|
||||||
|
const initialAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: initial.id } });
|
||||||
|
if (!initialAccess.lastRefresh) {
|
||||||
|
throw new Error('selected general must have an initial access timestamp');
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
new Date((initial.meta as Record<string, unknown>).prestart_delete_after as string).getTime() -
|
||||||
|
initialAccess.lastRefresh.getTime()
|
||||||
|
).toBe(2 * 5 * 60 * 1_000);
|
||||||
expect(initialRuntime).toMatchObject({
|
expect(initialRuntime).toMatchObject({
|
||||||
id: initial.id,
|
id: initial.id,
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -88,6 +88,13 @@ const zTroopRename = z.object({
|
|||||||
|
|
||||||
const zDieOnPrestart = z.object({
|
const zDieOnPrestart = z.object({
|
||||||
type: z.literal('dieOnPrestart'),
|
type: z.literal('dieOnPrestart'),
|
||||||
|
userId: z.string().min(1),
|
||||||
|
generalId: zFiniteNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
const zEnsureDieOnPrestartStatus = z.object({
|
||||||
|
type: z.literal('ensureDieOnPrestartStatus'),
|
||||||
|
userId: z.string().min(1),
|
||||||
generalId: zFiniteNumber,
|
generalId: zFiniteNumber,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -415,6 +422,14 @@ const normalizeDieOnPrestart: CommandNormalizer<'dieOnPrestart'> = (envelope) =>
|
|||||||
return { ...command, requestId: envelope.requestId };
|
return { ...command, requestId: envelope.requestId };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeEnsureDieOnPrestartStatus: CommandNormalizer<'ensureDieOnPrestartStatus'> = (envelope) => {
|
||||||
|
const command = parseWith(zEnsureDieOnPrestartStatus, envelope.command);
|
||||||
|
if (!command) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { ...command, requestId: envelope.requestId };
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeBuildNationCandidate: CommandNormalizer<'buildNationCandidate'> = (envelope) => {
|
const normalizeBuildNationCandidate: CommandNormalizer<'buildNationCandidate'> = (envelope) => {
|
||||||
const command = parseWith(zBuildNationCandidate, envelope.command);
|
const command = parseWith(zBuildNationCandidate, envelope.command);
|
||||||
if (!command) {
|
if (!command) {
|
||||||
@@ -629,6 +644,7 @@ const normalizers: CommandNormalizerMap = {
|
|||||||
troopKick: normalizeTroopKick,
|
troopKick: normalizeTroopKick,
|
||||||
troopRename: normalizeTroopRename,
|
troopRename: normalizeTroopRename,
|
||||||
dieOnPrestart: normalizeDieOnPrestart,
|
dieOnPrestart: normalizeDieOnPrestart,
|
||||||
|
ensureDieOnPrestartStatus: normalizeEnsureDieOnPrestartStatus,
|
||||||
buildNationCandidate: normalizeBuildNationCandidate,
|
buildNationCandidate: normalizeBuildNationCandidate,
|
||||||
instantRetreat: normalizeInstantRetreat,
|
instantRetreat: normalizeInstantRetreat,
|
||||||
vacation: normalizeVacation,
|
vacation: normalizeVacation,
|
||||||
|
|||||||
@@ -74,13 +74,13 @@ const settleInheritance = async (
|
|||||||
const ranks = new Map(rankRows.map((row) => [row.type, row.value]));
|
const ranks = new Map(rankRows.map((row) => [row.type, row.value]));
|
||||||
const rank = (key: string): number => ranks.get(key) ?? readNumber(meta, `rank_${key}`);
|
const rank = (key: string): number => ranks.get(key) ?? readNumber(meta, `rank_${key}`);
|
||||||
const previous = points.get('previous') ?? 0;
|
const previous = points.get('previous') ?? 0;
|
||||||
const refund =
|
const randomUniqueRefund = meta.inheritRandomUnique
|
||||||
(meta.inheritRandomUnique
|
? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000)
|
||||||
? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000)
|
: 0;
|
||||||
: 0) +
|
const specificSpecialRefund = meta.inheritSpecificSpecialWar
|
||||||
(meta.inheritSpecificSpecialWar
|
? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000)
|
||||||
? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000)
|
: 0;
|
||||||
: 0);
|
const refund = randomUniqueRefund + specificSpecialRefund;
|
||||||
const lived = readNumber(meta, 'inherit_lived_month');
|
const lived = readNumber(meta, 'inherit_lived_month');
|
||||||
const maxBelong = readNumber(meta, 'inherit_max_belong') * 10;
|
const maxBelong = readNumber(meta, 'inherit_max_belong') * 10;
|
||||||
const maxDomestic = readNumber(meta, 'max_domestic_critical');
|
const maxDomestic = readNumber(meta, 'max_domestic_critical');
|
||||||
@@ -129,6 +129,19 @@ const settleInheritance = async (
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
for (const text of [
|
||||||
|
...(randomUniqueRefund > 0 ? [`사망으로 랜덤 유니크 구입 ${randomUniqueRefund} 포인트 반환`] : []),
|
||||||
|
...(specificSpecialRefund > 0 ? [`사망으로 전투 특기 지정 ${specificSpecialRefund} 포인트 반환`] : []),
|
||||||
|
]) {
|
||||||
|
await prisma.inheritanceLog.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
year: event.year,
|
||||||
|
month: event.month,
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
await prisma.inheritanceLog.create({
|
await prisma.inheritanceLog.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
@@ -139,8 +152,7 @@ const settleInheritance = async (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const computeRate = (numerator: number, denominator: number): number =>
|
const computeRate = (numerator: number, denominator: number): number => (denominator > 0 ? numerator / denominator : 0);
|
||||||
denominator > 0 ? numerator / denominator : 0;
|
|
||||||
|
|
||||||
const settleHall = async (
|
const settleHall = async (
|
||||||
prisma: GamePrisma.TransactionClient,
|
prisma: GamePrisma.TransactionClient,
|
||||||
@@ -186,7 +198,9 @@ const settleHall = async (
|
|||||||
const season = readWorldNumber(worldMeta, 'season', 1);
|
const season = readWorldNumber(worldMeta, 'season', 1);
|
||||||
const scenario = readWorldNumber(worldMeta, 'scenarioId', 0);
|
const scenario = readWorldNumber(worldMeta, 'scenarioId', 0);
|
||||||
const scenarioName =
|
const scenarioName =
|
||||||
typeof asRecord(worldMeta.scenarioMeta).title === 'string' ? String(asRecord(worldMeta.scenarioMeta).title) : '';
|
typeof asRecord(worldMeta.scenarioMeta).title === 'string'
|
||||||
|
? String(asRecord(worldMeta.scenarioMeta).title)
|
||||||
|
: '';
|
||||||
const aux = {
|
const aux = {
|
||||||
name: event.before.name,
|
name: event.before.name,
|
||||||
nationName: nation?.name ?? '재야',
|
nationName: nation?.name ?? '재야',
|
||||||
@@ -270,8 +284,12 @@ const archiveDeletedGeneral = async (
|
|||||||
orderBy: { id: 'desc' },
|
orderBy: { id: 'desc' },
|
||||||
select: { text: true },
|
select: { text: true },
|
||||||
});
|
});
|
||||||
|
const archivedMeta = { ...asRecord(event.before.meta) };
|
||||||
|
delete archivedMeta.inheritRandomUnique;
|
||||||
|
delete archivedMeta.inheritSpecificSpecialWar;
|
||||||
const data = {
|
const data = {
|
||||||
...event.before,
|
...event.before,
|
||||||
|
meta: archivedMeta,
|
||||||
turnTime: event.before.turnTime.toISOString(),
|
turnTime: event.before.turnTime.toISOString(),
|
||||||
recentWarTime: event.before.recentWarTime?.toISOString() ?? null,
|
recentWarTime: event.before.recentWarTime?.toISOString() ?? null,
|
||||||
history: history.map((entry) => entry.text),
|
history: history.map((entry) => entry.text),
|
||||||
|
|||||||
@@ -699,6 +699,21 @@ export class InMemoryTurnWorld {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deleteGeneralWithLifecycle(id: number, year: number, month: number): boolean {
|
||||||
|
const general = this.generals.get(id);
|
||||||
|
if (!general) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.lifecycleEvents.push({
|
||||||
|
generalId: id,
|
||||||
|
outcome: 'deleted',
|
||||||
|
before: structuredClone(general),
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
});
|
||||||
|
return this.removeGeneral(id);
|
||||||
|
}
|
||||||
|
|
||||||
updateCity(id: number, patch: Partial<City>): City | null {
|
updateCity(id: number, patch: Partial<City>): City | null {
|
||||||
const target = this.cities.get(id);
|
const target = this.cities.get(id);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type { WarTraitKey } from '@sammo-ts/logic';
|
|||||||
|
|
||||||
import type { DatabaseClient, GamePrisma as GamePrismaTypes } from '@sammo-ts/infra';
|
import type { DatabaseClient, GamePrisma as GamePrismaTypes } from '@sammo-ts/infra';
|
||||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||||
|
import { buildPrestartDeleteAfter } from './prestartDeletion.js';
|
||||||
import type { TurnGeneral } from './types.js';
|
import type { TurnGeneral } from './types.js';
|
||||||
|
|
||||||
type WorldStateRow = GamePrismaTypes.WorldStateGetPayload<Record<string, never>>;
|
type WorldStateRow = GamePrismaTypes.WorldStateGetPayload<Record<string, never>>;
|
||||||
@@ -702,6 +703,7 @@ export const createGeneralFromJoin = async (options: {
|
|||||||
const nextInheritancePoint = currentInheritancePoint - inheritRequiredPoint;
|
const nextInheritancePoint = currentInheritancePoint - inheritRequiredPoint;
|
||||||
const restInheritanceBonus = await resolveRestInheritanceBonus(db, worldState, input.userId);
|
const restInheritanceBonus = await resolveRestInheritanceBonus(db, worldState, input.userId);
|
||||||
const finalInheritancePoint = nextInheritancePoint + restInheritanceBonus;
|
const finalInheritancePoint = nextInheritancePoint + restInheritanceBonus;
|
||||||
|
const prestartDeleteAfter = buildPrestartDeleteAfter(acceptedAt, worldState.tickSeconds, config);
|
||||||
const general: TurnGeneral = {
|
const general: TurnGeneral = {
|
||||||
id: generalId,
|
id: generalId,
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
@@ -778,6 +780,7 @@ export const createGeneralFromJoin = async (options: {
|
|||||||
tournament: 0,
|
tournament: 0,
|
||||||
newvote: 0,
|
newvote: 0,
|
||||||
inherit_spent_dyn: inheritRequiredPoint,
|
inherit_spent_dyn: inheritRequiredPoint,
|
||||||
|
prestart_delete_after: prestartDeleteAfter.toISOString(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (!world.addGeneral(general)) {
|
if (!world.addGeneral(general)) {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { asNumber, asRecord } from '@sammo-ts/common';
|
||||||
|
|
||||||
|
const DEFAULT_MIN_TURNS = 2;
|
||||||
|
|
||||||
|
export const readPrestartDeleteAfter = (meta: Record<string, unknown>): Date | null => {
|
||||||
|
const value = meta.prestart_delete_after;
|
||||||
|
if (typeof value !== 'string' || !value.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildPrestartDeleteAfter = (base: Date, tickSeconds: number, config: Record<string, unknown>): Date => {
|
||||||
|
const configConst = asRecord(config.const);
|
||||||
|
const minTurns = Math.max(0, Math.floor(asNumber(configConst.minTurnDieOnPrestart, DEFAULT_MIN_TURNS)));
|
||||||
|
return new Date(base.getTime() + Math.max(1, Math.floor(tickSeconds)) * minTurns * 1_000);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatPrestartDeleteAfter = (value: Date): string => {
|
||||||
|
const seoul = new Date(value.getTime() + 9 * 60 * 60 * 1_000);
|
||||||
|
const pad = (part: number): string => String(part).padStart(2, '0');
|
||||||
|
return [
|
||||||
|
`${seoul.getUTCFullYear()}-${pad(seoul.getUTCMonth() + 1)}-${pad(seoul.getUTCDate())}`,
|
||||||
|
`${pad(seoul.getUTCHours())}:${pad(seoul.getUTCMinutes())}:${pad(seoul.getUTCSeconds())}`,
|
||||||
|
].join(' ');
|
||||||
|
};
|
||||||
@@ -1,12 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import {
|
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||||
asNumber,
|
|
||||||
asRecord,
|
|
||||||
JosaUtil,
|
|
||||||
LiteHashDRBG,
|
|
||||||
RandUtil,
|
|
||||||
} from '@sammo-ts/common';
|
|
||||||
import { GamePrisma, LogCategory, LogScope } from '@sammo-ts/infra';
|
import { GamePrisma, LogCategory, LogScope } from '@sammo-ts/infra';
|
||||||
import {
|
import {
|
||||||
EventDomesticTraitLoader,
|
EventDomesticTraitLoader,
|
||||||
@@ -18,15 +12,12 @@ import {
|
|||||||
|
|
||||||
import type { DatabaseClient, GamePrisma as GamePrismaTypes } from '@sammo-ts/infra';
|
import type { DatabaseClient, GamePrisma as GamePrismaTypes } from '@sammo-ts/infra';
|
||||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||||
|
import { buildPrestartDeleteAfter } from './prestartDeletion.js';
|
||||||
import type { TurnGeneral } from './types.js';
|
import type { TurnGeneral } from './types.js';
|
||||||
|
|
||||||
type WorldStateRow = GamePrismaTypes.WorldStateGetPayload<Record<string, never>>;
|
type WorldStateRow = GamePrismaTypes.WorldStateGetPayload<Record<string, never>>;
|
||||||
|
|
||||||
export type SelectPoolErrorCode =
|
export type SelectPoolErrorCode = 'BAD_REQUEST' | 'PRECONDITION_FAILED' | 'CONFLICT' | 'INTERNAL_SERVER_ERROR';
|
||||||
| 'BAD_REQUEST'
|
|
||||||
| 'PRECONDITION_FAILED'
|
|
||||||
| 'CONFLICT'
|
|
||||||
| 'INTERNAL_SERVER_ERROR';
|
|
||||||
|
|
||||||
export class SelectPoolError extends Error {
|
export class SelectPoolError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -98,10 +89,7 @@ export interface SelectPoolReservationDto {
|
|||||||
candidates: SelectPoolCandidateDto[];
|
candidates: SelectPoolCandidateDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const fail = (
|
const fail = (code: SelectPoolErrorCode, message: string): never => {
|
||||||
code: SelectPoolErrorCode,
|
|
||||||
message: string
|
|
||||||
): never => {
|
|
||||||
throw new SelectPoolError(code, message);
|
throw new SelectPoolError(code, message);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,12 +123,7 @@ export const resolveSelectionMaxGeneral = (worldState: WorldStateRow): number =>
|
|||||||
return Math.max(
|
return Math.max(
|
||||||
0,
|
0,
|
||||||
Math.floor(
|
Math.floor(
|
||||||
asNumber(
|
asNumber(config.maxGeneral ?? configConst.defaultMaxGeneral ?? configConst.maxGeneral, DEFAULT_MAX_GENERAL)
|
||||||
config.maxGeneral ??
|
|
||||||
configConst.defaultMaxGeneral ??
|
|
||||||
configConst.maxGeneral,
|
|
||||||
DEFAULT_MAX_GENERAL
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -174,9 +157,7 @@ const candidateWeight = (candidate: SelectPoolCandidateInfo): number =>
|
|||||||
|
|
||||||
const eventDomesticTraitLoader = new EventDomesticTraitLoader();
|
const eventDomesticTraitLoader = new EventDomesticTraitLoader();
|
||||||
|
|
||||||
const toCandidateDto = async (
|
const toCandidateDto = async (candidate: SelectPoolCandidateInfo): Promise<SelectPoolCandidateDto> => {
|
||||||
candidate: SelectPoolCandidateInfo
|
|
||||||
): Promise<SelectPoolCandidateDto> => {
|
|
||||||
const trait = isEventDomesticTraitKey(candidate.specialDomestic)
|
const trait = isEventDomesticTraitKey(candidate.specialDomestic)
|
||||||
? await eventDomesticTraitLoader.load(candidate.specialDomestic)
|
? await eventDomesticTraitLoader.load(candidate.specialDomestic)
|
||||||
: null;
|
: null;
|
||||||
@@ -187,8 +168,7 @@ const toCandidateDto = async (
|
|||||||
strength: candidate.strength,
|
strength: candidate.strength,
|
||||||
intel: candidate.intel,
|
intel: candidate.intel,
|
||||||
specialDomestic: candidate.specialDomestic,
|
specialDomestic: candidate.specialDomestic,
|
||||||
specialDomesticName:
|
specialDomesticName: trait?.name ?? candidate.specialDomestic.replace(/^che_event_/, ''),
|
||||||
trait?.name ?? candidate.specialDomestic.replace(/^che_event_/, ''),
|
|
||||||
specialDomesticInfo: trait?.info ?? '',
|
specialDomesticInfo: trait?.info ?? '',
|
||||||
specialWar: candidate.specialWar ?? null,
|
specialWar: candidate.specialWar ?? null,
|
||||||
ego: candidate.ego ?? null,
|
ego: candidate.ego ?? null,
|
||||||
@@ -204,18 +184,12 @@ const toReservationDto = (
|
|||||||
): Promise<SelectPoolReservationDto> => {
|
): Promise<SelectPoolReservationDto> => {
|
||||||
const validUntil = rows[0]?.reservedUntil;
|
const validUntil = rows[0]?.reservedUntil;
|
||||||
if (!validUntil) {
|
if (!validUntil) {
|
||||||
throw new SelectPoolError(
|
throw new SelectPoolError('INTERNAL_SERVER_ERROR', '장수 선택 후보의 유효기간이 없습니다.');
|
||||||
'INTERNAL_SERVER_ERROR',
|
|
||||||
'장수 선택 후보의 유효기간이 없습니다.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const expiresAt = validUntil;
|
const expiresAt = validUntil;
|
||||||
const sorted = rows
|
const sorted = rows
|
||||||
.map((row) => ({ id: row.id, info: parseCandidate(row) }))
|
.map((row) => ({ id: row.id, info: parseCandidate(row) }))
|
||||||
.sort(
|
.sort((left, right) => candidateWeight(left.info) - candidateWeight(right.info) || left.id - right.id);
|
||||||
(left, right) =>
|
|
||||||
candidateWeight(left.info) - candidateWeight(right.info) || left.id - right.id
|
|
||||||
);
|
|
||||||
return Promise.all(sorted.map((entry) => toCandidateDto(entry.info))).then((candidates) => ({
|
return Promise.all(sorted.map((entry) => toCandidateDto(entry.info))).then((candidates) => ({
|
||||||
poolName: SUPPORTED_POOL,
|
poolName: SUPPORTED_POOL,
|
||||||
hasGeneral,
|
hasGeneral,
|
||||||
@@ -229,16 +203,11 @@ const formatLegacySeedTime = (value: Date): string => {
|
|||||||
const koreaTime = new Date(value.getTime() + LEGACY_TIMEZONE_OFFSET_MS);
|
const koreaTime = new Date(value.getTime() + LEGACY_TIMEZONE_OFFSET_MS);
|
||||||
return `${koreaTime.getUTCFullYear()}-${pad(koreaTime.getUTCMonth() + 1)}-${pad(
|
return `${koreaTime.getUTCFullYear()}-${pad(koreaTime.getUTCMonth() + 1)}-${pad(
|
||||||
koreaTime.getUTCDate()
|
koreaTime.getUTCDate()
|
||||||
)} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(
|
)} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(koreaTime.getUTCSeconds())}`;
|
||||||
koreaTime.getUTCSeconds()
|
|
||||||
)}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildSelectPoolSeed = (
|
export const buildSelectPoolSeed = (hiddenSeed: string | number, ownerIdentity: string | number, now: Date): string =>
|
||||||
hiddenSeed: string | number,
|
simpleSerialize(hiddenSeed, 'selectPool', ownerIdentity, formatLegacySeedTime(now));
|
||||||
ownerIdentity: string | number,
|
|
||||||
now: Date
|
|
||||||
): string => simpleSerialize(hiddenSeed, 'selectPool', ownerIdentity, formatLegacySeedTime(now));
|
|
||||||
|
|
||||||
export const claimWeightedSelectionCandidates = async <T extends { id: number }>(options: {
|
export const claimWeightedSelectionCandidates = async <T extends { id: number }>(options: {
|
||||||
weighted: [T, number][];
|
weighted: [T, number][];
|
||||||
@@ -365,13 +334,7 @@ export const reserveSelectionPool = async (options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rng = new RandUtil(
|
const rng = new RandUtil(
|
||||||
new LiteHashDRBG(
|
new LiteHashDRBG(buildSelectPoolSeed(getWorldHiddenSeed(worldState), options.seedOwnerIdentity ?? userId, now))
|
||||||
buildSelectPoolSeed(
|
|
||||||
getWorldHiddenSeed(worldState),
|
|
||||||
options.seedOwnerIdentity ?? userId,
|
|
||||||
now
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
const weighted = available.map((row) => [row, candidateWeight(parseCandidate(row))] as [SelectPoolRow, number]);
|
const weighted = available.map((row) => [row, candidateWeight(parseCandidate(row))] as [SelectPoolRow, number]);
|
||||||
const reservedUntil = new Date(
|
const reservedUntil = new Date(
|
||||||
@@ -398,10 +361,10 @@ export const reserveSelectionPool = async (options: {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const reserved = selected.map((candidate) => ({
|
const reserved = selected.map((candidate) => ({
|
||||||
...candidate,
|
...candidate,
|
||||||
ownerUserId: userId,
|
ownerUserId: userId,
|
||||||
reservedUntil,
|
reservedUntil,
|
||||||
}));
|
}));
|
||||||
if (reserved.length !== RESERVATION_COUNT) {
|
if (reserved.length !== RESERVATION_COUNT) {
|
||||||
fail('CONFLICT', '장수 선택 후보를 예약하지 못했습니다. 다시 시도해 주세요.');
|
fail('CONFLICT', '장수 선택 후보를 예약하지 못했습니다. 다시 시도해 주세요.');
|
||||||
}
|
}
|
||||||
@@ -413,10 +376,7 @@ const lockSelectionMutationTables = async (db: DatabaseClient): Promise<void> =>
|
|||||||
await db.$executeRaw(GamePrisma.sql`LOCK TABLE "select_pool" IN SHARE ROW EXCLUSIVE MODE`);
|
await db.$executeRaw(GamePrisma.sql`LOCK TABLE "select_pool" IN SHARE ROW EXCLUSIVE MODE`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const assertGeneralIdSnapshotMatches = async (
|
const assertGeneralIdSnapshotMatches = async (db: DatabaseClient, world: InMemoryTurnWorld): Promise<void> => {
|
||||||
db: DatabaseClient,
|
|
||||||
world: InMemoryTurnWorld
|
|
||||||
): Promise<void> => {
|
|
||||||
const persistedIds = (
|
const persistedIds = (
|
||||||
await db.general.findMany({
|
await db.general.findMany({
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
@@ -427,13 +387,8 @@ const assertGeneralIdSnapshotMatches = async (
|
|||||||
.listGenerals()
|
.listGenerals()
|
||||||
.map(({ id }) => id)
|
.map(({ id }) => id)
|
||||||
.sort((left, right) => left - right);
|
.sort((left, right) => left - right);
|
||||||
if (
|
if (persistedIds.length !== runtimeIds.length || persistedIds.some((id, index) => id !== runtimeIds[index])) {
|
||||||
persistedIds.length !== runtimeIds.length ||
|
throw new Error('DB와 턴 데몬의 장수 번호 목록이 일치하지 않아 장수를 생성할 수 없습니다.');
|
||||||
persistedIds.some((id, index) => id !== runtimeIds[index])
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
'DB와 턴 데몬의 장수 번호 목록이 일치하지 않아 장수를 생성할 수 없습니다.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -468,12 +423,7 @@ const resolveRandomPersonality = (
|
|||||||
): string =>
|
): string =>
|
||||||
new RandUtil(
|
new RandUtil(
|
||||||
new LiteHashDRBG(
|
new LiteHashDRBG(
|
||||||
simpleSerialize(
|
simpleSerialize(getWorldHiddenSeed(worldState), 'selectPickedGeneralPersonality', ownerIdentity, uniqueName)
|
||||||
getWorldHiddenSeed(worldState),
|
|
||||||
'selectPickedGeneralPersonality',
|
|
||||||
ownerIdentity,
|
|
||||||
uniqueName
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
).choice([...PERSONALITY_TRAIT_KEYS]);
|
).choice([...PERSONALITY_TRAIT_KEYS]);
|
||||||
|
|
||||||
@@ -495,19 +445,10 @@ const resolveSelectedPersonality = (
|
|||||||
return requested;
|
return requested;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolvePoolRng = (
|
const resolvePoolRng = (worldState: WorldStateRow, ownerIdentity: string | number, uniqueName: string): RandUtil =>
|
||||||
worldState: WorldStateRow,
|
|
||||||
ownerIdentity: string | number,
|
|
||||||
uniqueName: string
|
|
||||||
): RandUtil =>
|
|
||||||
new RandUtil(
|
new RandUtil(
|
||||||
new LiteHashDRBG(
|
new LiteHashDRBG(
|
||||||
simpleSerialize(
|
simpleSerialize(getWorldHiddenSeed(worldState), 'selectPickedGeneral', ownerIdentity, uniqueName)
|
||||||
getWorldHiddenSeed(worldState),
|
|
||||||
'selectPickedGeneral',
|
|
||||||
ownerIdentity,
|
|
||||||
uniqueName
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -612,16 +553,12 @@ export const createGeneralFromSelectionPool = async (options: {
|
|||||||
const nextChangeAt = new Date(
|
const nextChangeAt = new Date(
|
||||||
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
|
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
|
||||||
);
|
);
|
||||||
|
const prestartDeleteAfter = buildPrestartDeleteAfter(now, worldState.tickSeconds, config);
|
||||||
const showImgLevel = asNumber(config.showImgLevel, 0);
|
const showImgLevel = asNumber(config.showImgLevel, 0);
|
||||||
const picture = showImgLevel >= 3 ? info.picture : 'default.jpg';
|
const picture = showImgLevel >= 3 ? info.picture : 'default.jpg';
|
||||||
const defaultSpecialWar =
|
const defaultSpecialWar =
|
||||||
typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None';
|
typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None';
|
||||||
const personality = resolveSelectedPersonality(
|
const personality = resolveSelectedPersonality(worldState, seedOwnerIdentity, uniqueName, options.personality);
|
||||||
worldState,
|
|
||||||
seedOwnerIdentity,
|
|
||||||
uniqueName,
|
|
||||||
options.personality
|
|
||||||
);
|
|
||||||
// 모든 사용자 입력과 DB 선조건을 검증한 뒤에만 allocator를 변경한다.
|
// 모든 사용자 입력과 DB 선조건을 검증한 뒤에만 allocator를 변경한다.
|
||||||
// SelectPoolError는 정상 command 결과로 commit되므로 이보다 먼저
|
// SelectPoolError는 정상 command 결과로 commit되므로 이보다 먼저
|
||||||
// getNextGeneralId()를 호출하면 실패한 요청도 lastGeneralId를 소비한다.
|
// getNextGeneralId()를 호출하면 실패한 요청도 lastGeneralId를 소비한다.
|
||||||
@@ -692,6 +629,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
|||||||
dex5: info.dex[4],
|
dex5: info.dex[4],
|
||||||
next_change: nextChangeAt.toISOString(),
|
next_change: nextChangeAt.toISOString(),
|
||||||
nextChangeAt: nextChangeAt.toISOString(),
|
nextChangeAt: nextChangeAt.toISOString(),
|
||||||
|
prestart_delete_after: prestartDeleteAfter.toISOString(),
|
||||||
npc_org: 0,
|
npc_org: 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -884,6 +822,6 @@ export const getSelectionPoolStatus = async (
|
|||||||
poolName,
|
poolName,
|
||||||
allowOptions: resolvePoolAllowOptions(worldState),
|
allowOptions: resolvePoolAllowOptions(worldState),
|
||||||
hasGeneral: Boolean(general),
|
hasGeneral: Boolean(general),
|
||||||
nextChangeAt: general ? readNextChangeAt(general.meta)?.toISOString() ?? null : null,
|
nextChangeAt: general ? (readNextChangeAt(general.meta)?.toISOString() ?? null) : null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
} from './selectPoolService.js';
|
} from './selectPoolService.js';
|
||||||
import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.js';
|
import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.js';
|
||||||
import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js';
|
import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js';
|
||||||
|
import { buildPrestartDeleteAfter, formatPrestartDeleteAfter, readPrestartDeleteAfter } from './prestartDeletion.js';
|
||||||
|
|
||||||
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
|
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
|
||||||
|
|
||||||
@@ -141,7 +142,15 @@ const resolveCommandAcceptedAt = async (
|
|||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
command: Extract<
|
command: Extract<
|
||||||
TurnDaemonCommand,
|
TurnDaemonCommand,
|
||||||
{ type: 'joinCreateGeneral' | 'npcPossessGeneral' | 'selectPoolCreate' | 'selectPoolReselect' }
|
{
|
||||||
|
type:
|
||||||
|
| 'dieOnPrestart'
|
||||||
|
| 'ensureDieOnPrestartStatus'
|
||||||
|
| 'joinCreateGeneral'
|
||||||
|
| 'npcPossessGeneral'
|
||||||
|
| 'selectPoolCreate'
|
||||||
|
| 'selectPoolReselect';
|
||||||
|
}
|
||||||
>
|
>
|
||||||
): Promise<Date> => {
|
): Promise<Date> => {
|
||||||
if (!command.requestId) {
|
if (!command.requestId) {
|
||||||
@@ -1029,31 +1038,136 @@ async function handleTroopRename(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ensurePrestartDeleteAfter = async (
|
||||||
|
ctx: CommandHandlerContext,
|
||||||
|
command: Extract<TurnDaemonCommand, { type: 'dieOnPrestart' | 'ensureDieOnPrestartStatus' }>,
|
||||||
|
general: TurnGeneral,
|
||||||
|
acceptedAt: Date
|
||||||
|
): Promise<Date> => {
|
||||||
|
const current = readPrestartDeleteAfter(asRecord(general.meta));
|
||||||
|
if (current) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
const db = requireCommandDatabase(ctx);
|
||||||
|
const access = await db.generalAccessLog.findUnique({
|
||||||
|
where: { generalId: general.id },
|
||||||
|
select: { lastRefresh: true },
|
||||||
|
});
|
||||||
|
const deleteAfter = buildPrestartDeleteAfter(
|
||||||
|
access?.lastRefresh ?? acceptedAt,
|
||||||
|
ctx.world.getState().tickSeconds,
|
||||||
|
asRecord(ctx.world.getScenarioConfig())
|
||||||
|
);
|
||||||
|
const updated = ctx.world.updateGeneral(general.id, {
|
||||||
|
meta: {
|
||||||
|
...general.meta,
|
||||||
|
prestart_delete_after: deleteAfter.toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!updated) {
|
||||||
|
throw new Error(`${command.type} general disappeared while fixing the pre-start deletion time.`);
|
||||||
|
}
|
||||||
|
return deleteAfter;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleEnsureDieOnPrestartStatus(
|
||||||
|
ctx: CommandHandlerContext,
|
||||||
|
command: Extract<TurnDaemonCommand, { type: 'ensureDieOnPrestartStatus' }>
|
||||||
|
): Promise<TurnDaemonCommandResult> {
|
||||||
|
const general = ctx.world.getGeneralById(command.generalId);
|
||||||
|
if (!general || general.npcState !== 0 || general.userId !== command.userId) {
|
||||||
|
return {
|
||||||
|
type: 'ensureDieOnPrestartStatus',
|
||||||
|
generalId: command.generalId,
|
||||||
|
show: false,
|
||||||
|
available: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const db = requireCommandDatabase(ctx);
|
||||||
|
const acceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||||
|
const worldState = ctx.world.getState();
|
||||||
|
const opentime = typeof worldState.meta.opentime === 'string' ? worldState.meta.opentime : null;
|
||||||
|
if ((opentime && worldState.lastTurnTime.getTime() > new Date(opentime).getTime()) || general.nationId !== 0) {
|
||||||
|
return {
|
||||||
|
type: 'ensureDieOnPrestartStatus',
|
||||||
|
generalId: command.generalId,
|
||||||
|
show: false,
|
||||||
|
available: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const availableAt = await ensurePrestartDeleteAfter(ctx, command, general, acceptedAt);
|
||||||
|
return {
|
||||||
|
type: 'ensureDieOnPrestartStatus',
|
||||||
|
generalId: command.generalId,
|
||||||
|
show: true,
|
||||||
|
available: availableAt.getTime() <= acceptedAt.getTime(),
|
||||||
|
availableAt: availableAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDieOnPrestart(
|
async function handleDieOnPrestart(
|
||||||
ctx: CommandHandlerContext,
|
ctx: CommandHandlerContext,
|
||||||
command: Extract<TurnDaemonCommand, { type: 'dieOnPrestart' }>
|
command: Extract<TurnDaemonCommand, { type: 'dieOnPrestart' }>
|
||||||
): Promise<TurnDaemonCommandResult> {
|
): Promise<TurnDaemonCommandResult> {
|
||||||
const { world } = ctx;
|
const { world } = ctx;
|
||||||
|
const db = requireCommandDatabase(ctx);
|
||||||
const general = world.getGeneralById(command.generalId);
|
const general = world.getGeneralById(command.generalId);
|
||||||
if (!general) {
|
if (!general || general.npcState !== 0 || general.userId !== command.userId) {
|
||||||
return {
|
return {
|
||||||
type: 'dieOnPrestart',
|
type: 'dieOnPrestart',
|
||||||
ok: false,
|
ok: false,
|
||||||
generalId: command.generalId,
|
generalId: command.generalId,
|
||||||
reason: '장수 정보를 찾을 수 없습니다.',
|
reason: '장수가 없습니다',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const acceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||||
const worldState = world.getState();
|
const worldState = world.getState();
|
||||||
const opentime = worldState.meta.opentime as string | undefined;
|
const opentime = worldState.meta.opentime as string | undefined;
|
||||||
if (opentime && new Date(worldState.lastTurnTime) > new Date(opentime)) {
|
if (opentime && new Date(worldState.lastTurnTime) > new Date(opentime)) {
|
||||||
return { type: 'dieOnPrestart', ok: false, generalId: command.generalId, reason: '가오픈 기간이 아닙니다.' };
|
return {
|
||||||
|
type: 'dieOnPrestart',
|
||||||
|
ok: false,
|
||||||
|
generalId: command.generalId,
|
||||||
|
reason: '게임이 시작되었습니다.',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (general.npcState !== 0 || general.nationId !== 0) {
|
if (general.nationId !== 0) {
|
||||||
return { type: 'dieOnPrestart', ok: false, generalId: command.generalId, reason: '삭제할 수 없는 상태입니다.' };
|
return {
|
||||||
|
type: 'dieOnPrestart',
|
||||||
|
ok: false,
|
||||||
|
generalId: command.generalId,
|
||||||
|
reason: '이미 국가에 소속되어있습니다.',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
world.removeGeneral(command.generalId);
|
const deleteAfter = await ensurePrestartDeleteAfter(ctx, command, general, acceptedAt);
|
||||||
|
if (deleteAfter.getTime() > acceptedAt.getTime()) {
|
||||||
|
return {
|
||||||
|
type: 'dieOnPrestart',
|
||||||
|
ok: false,
|
||||||
|
generalId: command.generalId,
|
||||||
|
reason: `아직 삭제할 수 없습니다. ${formatPrestartDeleteAfter(deleteAfter)} 부터 가능합니다.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (general.troopId === general.id) {
|
||||||
|
for (const member of world.listGenerals()) {
|
||||||
|
if (member.troopId === general.id) {
|
||||||
|
world.updateGeneral(member.id, { troopId: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
world.removeTroop(general.id);
|
||||||
|
}
|
||||||
|
const josaYi = JosaUtil.pick(general.name, '이');
|
||||||
|
world.pushLog({
|
||||||
|
scope: LogScope.SYSTEM,
|
||||||
|
category: LogCategory.SUMMARY,
|
||||||
|
format: LogFormat.MONTH,
|
||||||
|
text: `<Y>${general.name}</>${josaYi} 홀연히 모습을 <R>감추었습니다</>`,
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
world.deleteGeneralWithLifecycle(command.generalId, worldState.currentYear, worldState.currentMonth);
|
||||||
return { type: 'dieOnPrestart', ok: true, generalId: command.generalId };
|
return { type: 'dieOnPrestart', ok: true, generalId: command.generalId };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2135,6 +2249,11 @@ export const createTurnDaemonCommandHandler = (options: {
|
|||||||
troopKick: (command) => handleTroopKick(ctx, command as Extract<TurnDaemonCommand, { type: 'troopKick' }>),
|
troopKick: (command) => handleTroopKick(ctx, command as Extract<TurnDaemonCommand, { type: 'troopKick' }>),
|
||||||
troopRename: (command) =>
|
troopRename: (command) =>
|
||||||
handleTroopRename(ctx, command as Extract<TurnDaemonCommand, { type: 'troopRename' }>),
|
handleTroopRename(ctx, command as Extract<TurnDaemonCommand, { type: 'troopRename' }>),
|
||||||
|
ensureDieOnPrestartStatus: (command) =>
|
||||||
|
handleEnsureDieOnPrestartStatus(
|
||||||
|
ctx,
|
||||||
|
command as Extract<TurnDaemonCommand, { type: 'ensureDieOnPrestartStatus' }>
|
||||||
|
),
|
||||||
dieOnPrestart: (command) =>
|
dieOnPrestart: (command) =>
|
||||||
handleDieOnPrestart(ctx, command as Extract<TurnDaemonCommand, { type: 'dieOnPrestart' }>),
|
handleDieOnPrestart(ctx, command as Extract<TurnDaemonCommand, { type: 'dieOnPrestart' }>),
|
||||||
buildNationCandidate: (command) =>
|
buildNationCandidate: (command) =>
|
||||||
|
|||||||
@@ -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 databaseUrl = process.env.GENERAL_LIFECYCLE_DATABASE_URL;
|
||||||
const integration = describe.skipIf(!databaseUrl);
|
const integration = describe.skipIf(!databaseUrl);
|
||||||
const generalIds = [990_001, 990_002, 990_003];
|
const generalIds = [990_001, 990_002, 990_003];
|
||||||
const userIds = [
|
const userIds = ['integration-lifecycle-dead', 'integration-lifecycle-retired', 'integration-lifecycle-possessed'];
|
||||||
'integration-lifecycle-dead',
|
const serverId = 'lifecycle-int';
|
||||||
'integration-lifecycle-retired',
|
|
||||||
'integration-lifecycle-possessed',
|
|
||||||
];
|
|
||||||
const serverId = 'integration-lifecycle';
|
|
||||||
|
|
||||||
const makeGeneral = (id: number, userId: string, patch: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
const makeGeneral = (id: number, userId: string, patch: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
||||||
id,
|
id,
|
||||||
@@ -57,10 +53,7 @@ const makeGeneral = (id: number, userId: string, patch: Partial<TurnGeneral> = {
|
|||||||
...patch,
|
...patch,
|
||||||
});
|
});
|
||||||
|
|
||||||
const event = (
|
const event = (general: TurnGeneral, outcome: GeneralLifecycleEvent['outcome']): GeneralLifecycleEvent => ({
|
||||||
general: TurnGeneral,
|
|
||||||
outcome: GeneralLifecycleEvent['outcome']
|
|
||||||
): GeneralLifecycleEvent => ({
|
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
outcome,
|
outcome,
|
||||||
before: general,
|
before: general,
|
||||||
@@ -153,12 +146,24 @@ integration('general turn lifecycle persistence', () => {
|
|||||||
const archived = await db.oldGeneral.findUniqueOrThrow({
|
const archived = await db.oldGeneral.findUniqueOrThrow({
|
||||||
where: { by_no: { serverId, generalNo: general.id } },
|
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(
|
expect(
|
||||||
await db.inheritancePoint.findUnique({
|
await db.inheritancePoint.findUnique({
|
||||||
where: { userId_key: { userId: general.userId!, key: 'previous' } },
|
where: { userId_key: { userId: general.userId!, key: 'previous' } },
|
||||||
})
|
})
|
||||||
).toMatchObject({ value: 3_147 });
|
).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 () => {
|
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({
|
expect(await db.generalAccessLog.findUnique({ where: { generalId: general.id } })).toMatchObject({
|
||||||
refreshScore: 0,
|
refreshScore: 0,
|
||||||
});
|
});
|
||||||
expect(await db.rankData.findUnique({ where: { generalId_type: { generalId: general.id, type: 'warnum' } } }))
|
expect(
|
||||||
.toMatchObject({ value: 0 });
|
await db.rankData.findUnique({ where: { generalId_type: { generalId: general.id, type: 'warnum' } } })
|
||||||
|
).toMatchObject({ value: 0 });
|
||||||
expect(
|
expect(
|
||||||
await db.hallOfFame.findUnique({
|
await db.hallOfFame.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@@ -220,12 +226,7 @@ integration('general turn lifecycle persistence', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await db.$transaction((tx) =>
|
await db.$transaction((tx) =>
|
||||||
persistGeneralLifecycleEvents(
|
persistGeneralLifecycleEvents(tx, [event(general, 'deleted')], { serverId, startYear: 180 }, {})
|
||||||
tx,
|
|
||||||
[event(general, 'deleted')],
|
|
||||||
{ serverId, startYear: 180 },
|
|
||||||
{}
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ const archivedGeneral = (): TurnGeneral => ({
|
|||||||
deadYear: 240,
|
deadYear: 240,
|
||||||
affinity: 50,
|
affinity: 50,
|
||||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||||
meta: { killturn: 0 },
|
meta: {
|
||||||
|
killturn: 0,
|
||||||
|
inheritRandomUnique: true,
|
||||||
|
inheritSpecificSpecialWar: true,
|
||||||
|
},
|
||||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,6 +83,7 @@ describe('general lifecycle archive history', () => {
|
|||||||
create: expect.objectContaining({
|
create: expect.objectContaining({
|
||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
history: ['<Y>●</>둘째 기록', '<C>●</>첫 기록'],
|
history: ['<Y>●</>둘째 기록', '<C>●</>첫 기록'],
|
||||||
|
meta: { killturn: 0 },
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||||
|
const frontendPort = Number(process.env.PLAYWRIGHT_FRONTEND_PORT ?? 15144);
|
||||||
|
const apiPort = Number(process.env.PLAYWRIGHT_GAME_API_PORT ?? 15145);
|
||||||
|
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`;
|
||||||
|
const profileId = process.env.PLAYWRIGHT_PROFILE_ID ?? 'die_on_prestart_live_integration';
|
||||||
|
const scenario = process.env.PLAYWRIGHT_SCENARIO ?? '2';
|
||||||
|
const expectedGameProfile = `${profileId}:${scenario}`;
|
||||||
|
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? expectedGameProfile;
|
||||||
|
const baseURL = `http://127.0.0.1:${frontendPort}${basePath}/`;
|
||||||
|
const gameApiUrl = `http://127.0.0.1:${apiPort}/trpc`;
|
||||||
|
const databaseUrl = process.env.DIE_ON_PRESTART_LIVE_DATABASE_URL ?? '';
|
||||||
|
const redisUrl = process.env.DIE_ON_PRESTART_LIVE_REDIS_URL ?? '';
|
||||||
|
const gameSecret = process.env.DIE_ON_PRESTART_LIVE_GAME_SECRET ?? '';
|
||||||
|
const databaseSchema = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null;
|
||||||
|
|
||||||
|
if (databaseUrl && databaseSchema !== profileId) {
|
||||||
|
throw new Error(
|
||||||
|
`Die-on-prestart live schema must match its profile ID: ${databaseSchema ?? '(missing)'} != ${profileId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (gameProfile !== expectedGameProfile) {
|
||||||
|
throw new Error(
|
||||||
|
`PLAYWRIGHT_GAME_PROFILE must match profile and scenario: ${gameProfile} != ${expectedGameProfile}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: '.',
|
||||||
|
testMatch: ['dieOnPrestartLive.spec.ts'],
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
timeout: 60_000,
|
||||||
|
expect: {
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
reporter: [['list']],
|
||||||
|
outputDir: resolve(repositoryRoot, 'test-results/die-on-prestart-live'),
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
locale: 'ko-KR',
|
||||||
|
timezoneId: 'Asia/Seoul',
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
webServer: [
|
||||||
|
{
|
||||||
|
command: 'node app/game-api/dist/index.js',
|
||||||
|
cwd: repositoryRoot,
|
||||||
|
url: `http://127.0.0.1:${apiPort}/healthz`,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 120_000,
|
||||||
|
env: {
|
||||||
|
DATABASE_URL: databaseUrl,
|
||||||
|
REDIS_URL: redisUrl,
|
||||||
|
GAME_TOKEN_SECRET: gameSecret,
|
||||||
|
GAME_API_HOST: '127.0.0.1',
|
||||||
|
GAME_API_PORT: String(apiPort),
|
||||||
|
PROFILE: profileId,
|
||||||
|
SCENARIO: scenario,
|
||||||
|
GAME_PROFILE_NAME: gameProfile,
|
||||||
|
DAEMON_REQUEST_TIMEOUT_MS: '10000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: `VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=/gateway/ pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${frontendPort}`,
|
||||||
|
cwd: repositoryRoot,
|
||||||
|
url: baseURL,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
import { encryptGameSessionToken } from '../../../packages/common/dist/auth/gameToken.js';
|
||||||
|
import { createTurnDaemonRuntime, seedScenarioToDatabase } from '../../game-engine/dist/index.js';
|
||||||
|
import { createGamePostgresConnector } from '../../../packages/infra/dist/index.js';
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DIE_ON_PRESTART_LIVE_DATABASE_URL;
|
||||||
|
const redisUrl = process.env.DIE_ON_PRESTART_LIVE_REDIS_URL;
|
||||||
|
const gameTokenSecret = process.env.DIE_ON_PRESTART_LIVE_GAME_SECRET;
|
||||||
|
const profile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'die_on_prestart_live_integration:2';
|
||||||
|
const archiveServerId = 'die_prestart:2';
|
||||||
|
const scenarioId = Number(process.env.PLAYWRIGHT_SCENARIO ?? '2');
|
||||||
|
const userId = 'die-prestart-live-user';
|
||||||
|
const generalId = 991_741;
|
||||||
|
const memberId = 991_742;
|
||||||
|
const hasLiveFixture = Boolean(databaseUrl && redisUrl && gameTokenSecret);
|
||||||
|
|
||||||
|
const installSession = async (page: Page): Promise<void> => {
|
||||||
|
const now = new Date();
|
||||||
|
const gameToken = encryptGameSessionToken(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
profile,
|
||||||
|
issuedAt: now.toISOString(),
|
||||||
|
expiresAt: new Date(now.getTime() + 3_600_000).toISOString(),
|
||||||
|
sessionId: `die-prestart-live-${randomUUID()}`,
|
||||||
|
user: {
|
||||||
|
id: userId,
|
||||||
|
username: 'die-prestart-live',
|
||||||
|
displayName: '실삭제사용자',
|
||||||
|
roles: ['user'],
|
||||||
|
legacyMemberNo: 7_741,
|
||||||
|
canUseGeneralPicture: false,
|
||||||
|
},
|
||||||
|
sanctions: {},
|
||||||
|
identity: {
|
||||||
|
kakaoVerified: true,
|
||||||
|
canCreateGeneral: true,
|
||||||
|
requiresKakaoVerification: false,
|
||||||
|
graceEndsAt: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
gameTokenSecret!
|
||||||
|
);
|
||||||
|
await page.addInitScript(
|
||||||
|
({ token, gameProfile }) => {
|
||||||
|
if (location.pathname.startsWith('/che/')) {
|
||||||
|
window.localStorage.setItem('sammo-game-token', token);
|
||||||
|
window.localStorage.setItem('sammo-game-profile', gameProfile);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ token: gameToken, gameProfile: profile }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('pre-start deletion through live PostgreSQL, Redis, API, daemon, and Chromium', () => {
|
||||||
|
test.skip(!hasLiveFixture, 'live pre-start deletion token, Redis, and database are required');
|
||||||
|
|
||||||
|
let db: ReturnType<typeof createGamePostgresConnector>['prisma'];
|
||||||
|
let closeDb: (() => Promise<void>) | undefined;
|
||||||
|
let runtime: Awaited<ReturnType<typeof createTurnDaemonRuntime>> | undefined;
|
||||||
|
let daemonLoop: Promise<void> | undefined;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
const schema = new URL(databaseUrl!).searchParams.get('schema');
|
||||||
|
if (!schema?.endsWith('die_on_prestart_live_integration')) {
|
||||||
|
throw new Error(`Refusing non-dedicated schema: ${schema ?? '(missing)'}`);
|
||||||
|
}
|
||||||
|
const previousSeed = process.env.INTEGRATION_WORLD_SEED;
|
||||||
|
process.env.INTEGRATION_WORLD_SEED = 'die-on-prestart-live-seed';
|
||||||
|
try {
|
||||||
|
await seedScenarioToDatabase({
|
||||||
|
scenarioId,
|
||||||
|
databaseUrl: databaseUrl!,
|
||||||
|
now: new Date('2099-07-31T12:00:00.000Z'),
|
||||||
|
installOptions: {
|
||||||
|
turnTermMinutes: 5,
|
||||||
|
npcMode: 0,
|
||||||
|
showImgLevel: 3,
|
||||||
|
serverId: profile,
|
||||||
|
season: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (previousSeed === undefined) {
|
||||||
|
delete process.env.INTEGRATION_WORLD_SEED;
|
||||||
|
} else {
|
||||||
|
process.env.INTEGRATION_WORLD_SEED = previousSeed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||||
|
await connector.connect();
|
||||||
|
db = connector.prisma;
|
||||||
|
closeDb = () => connector.disconnect();
|
||||||
|
await db.inputEvent.deleteMany();
|
||||||
|
await db.logEntry.deleteMany();
|
||||||
|
const worldState = await db.worldState.findFirstOrThrow();
|
||||||
|
await db.worldState.update({
|
||||||
|
where: { id: worldState.id },
|
||||||
|
data: {
|
||||||
|
meta: {
|
||||||
|
...(worldState.meta as Record<string, unknown>),
|
||||||
|
serverId: archiveServerId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.oldGeneral.deleteMany({ where: { serverId: archiveServerId, generalNo: generalId } });
|
||||||
|
await db.inheritanceResult.deleteMany({ where: { serverId: archiveServerId, owner: userId } });
|
||||||
|
await db.inheritanceLog.deleteMany({ where: { userId } });
|
||||||
|
await db.inheritancePoint.deleteMany({ where: { userId } });
|
||||||
|
await db.selectPoolEntry.deleteMany({ where: { uniqueName: '실삭제풀' } });
|
||||||
|
await db.general.deleteMany({ where: { id: { in: [generalId, memberId] } } });
|
||||||
|
|
||||||
|
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
||||||
|
const availableAt = new Date(Date.now() - 60_000);
|
||||||
|
await db.general.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: generalId,
|
||||||
|
userId,
|
||||||
|
name: '실삭제장수',
|
||||||
|
nationId: 0,
|
||||||
|
cityId: city.id,
|
||||||
|
troopId: generalId,
|
||||||
|
npcState: 0,
|
||||||
|
leadership: 70,
|
||||||
|
strength: 60,
|
||||||
|
intel: 50,
|
||||||
|
turnTime: new Date('2099-07-31T12:05:00.000Z'),
|
||||||
|
meta: {
|
||||||
|
killturn: 6,
|
||||||
|
prestart_delete_after: availableAt.toISOString(),
|
||||||
|
inheritRandomUnique: true,
|
||||||
|
inheritSpecificSpecialWar: true,
|
||||||
|
},
|
||||||
|
penalty: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: memberId,
|
||||||
|
userId: null,
|
||||||
|
name: '실삭제부대원',
|
||||||
|
nationId: 0,
|
||||||
|
cityId: city.id,
|
||||||
|
troopId: generalId,
|
||||||
|
npcState: 2,
|
||||||
|
leadership: 50,
|
||||||
|
strength: 50,
|
||||||
|
intel: 50,
|
||||||
|
turnTime: new Date('2099-07-31T12:05:00.000Z'),
|
||||||
|
meta: { killturn: 6 },
|
||||||
|
penalty: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await db.troop.create({
|
||||||
|
data: { troopLeaderId: generalId, nationId: 0, name: '실삭제부대' },
|
||||||
|
});
|
||||||
|
await db.generalAccessLog.create({
|
||||||
|
data: { generalId, userId, lastRefresh: new Date(availableAt.getTime() - 10 * 60_000) },
|
||||||
|
});
|
||||||
|
await db.generalTurn.create({
|
||||||
|
data: { generalId, turnIdx: 0, actionCode: '휴식', arg: {} },
|
||||||
|
});
|
||||||
|
await db.generalTurnRevision.create({
|
||||||
|
data: { generalId, revision: 1 },
|
||||||
|
});
|
||||||
|
await db.rankData.create({
|
||||||
|
data: { generalId, nationId: 0, type: 'warnum', value: 1 },
|
||||||
|
});
|
||||||
|
await db.selectPoolEntry.create({
|
||||||
|
data: {
|
||||||
|
uniqueName: '실삭제풀',
|
||||||
|
ownerUserId: userId,
|
||||||
|
generalId,
|
||||||
|
reservedUntil: new Date(Date.now() + 3_600_000),
|
||||||
|
info: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.inheritancePoint.create({
|
||||||
|
data: { userId, key: 'previous', value: 100 },
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime = await createTurnDaemonRuntime({
|
||||||
|
profile,
|
||||||
|
databaseUrl: databaseUrl!,
|
||||||
|
enableDatabaseFlush: true,
|
||||||
|
enableLeaseHeartbeat: false,
|
||||||
|
leaseOwnerId: 'die-on-prestart-live-daemon',
|
||||||
|
});
|
||||||
|
daemonLoop = runtime.lifecycle.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
if (runtime) {
|
||||||
|
await runtime.lifecycle.stop('pre-start deletion live complete');
|
||||||
|
await daemonLoop;
|
||||||
|
await runtime.close();
|
||||||
|
}
|
||||||
|
await closeDb?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletes the owned general and its lifecycle state from the actual UI', async ({ page }, testInfo) => {
|
||||||
|
const dialogs: string[] = [];
|
||||||
|
await installSession(page);
|
||||||
|
await page.route('**/image/game/**', (route) =>
|
||||||
|
route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') })
|
||||||
|
);
|
||||||
|
await page.route('**/gateway/**', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'text/html',
|
||||||
|
body: '<!doctype html><html><body>gateway</body></html>',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
page.on('dialog', async (dialog) => {
|
||||||
|
dialogs.push(`${dialog.type()}:${dialog.message()}`);
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1000, height: 900 });
|
||||||
|
await page.goto('my-page');
|
||||||
|
const actionLine = page.locator('.action-line').filter({ hasText: '가오픈 기간 내 장수 삭제' });
|
||||||
|
const deleteButton = actionLine.getByRole('button', { name: '장수 삭제' });
|
||||||
|
await expect(actionLine).toContainText('가오픈 기간 내 장수 삭제');
|
||||||
|
await expect(deleteButton).toBeVisible();
|
||||||
|
await expect(deleteButton).toHaveCSS('width', '160px');
|
||||||
|
await expect(deleteButton).toHaveCSS('height', '30px');
|
||||||
|
await expect(deleteButton).toHaveCSS('background-color', 'rgb(34, 85, 0)');
|
||||||
|
await deleteButton.click();
|
||||||
|
|
||||||
|
await page.waitForURL(/\/gateway\/$/u);
|
||||||
|
await expect(page.locator('body')).toHaveText('gateway');
|
||||||
|
expect(dialogs).toEqual(['confirm:정말로 삭제하시겠습니까?']);
|
||||||
|
const event = await db.inputEvent.findFirstOrThrow({
|
||||||
|
where: { actorUserId: userId, eventType: 'dieOnPrestart' },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
const requestPrefix = `general:dieOnPrestart:${userId}:`;
|
||||||
|
expect(event.requestId.startsWith(requestPrefix)).toBe(true);
|
||||||
|
const clientRequestId = event.requestId.slice(requestPrefix.length);
|
||||||
|
expect(clientRequestId).toMatch(/^[0-9a-f-]{36}$/iu);
|
||||||
|
|
||||||
|
await expect(db.general.findUnique({ where: { id: generalId } })).resolves.toBeNull();
|
||||||
|
await expect(db.general.findUniqueOrThrow({ where: { id: memberId } })).resolves.toMatchObject({
|
||||||
|
troopId: 0,
|
||||||
|
});
|
||||||
|
await expect(db.troop.findUnique({ where: { troopLeaderId: generalId } })).resolves.toBeNull();
|
||||||
|
await expect(db.generalAccessLog.findUnique({ where: { generalId } })).resolves.toBeNull();
|
||||||
|
await expect(db.generalTurn.count({ where: { generalId } })).resolves.toBe(0);
|
||||||
|
await expect(db.generalTurnRevision.findUnique({ where: { generalId } })).resolves.toBeNull();
|
||||||
|
await expect(db.rankData.count({ where: { generalId } })).resolves.toBe(0);
|
||||||
|
await expect(
|
||||||
|
db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: '실삭제풀' } })
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
ownerUserId: null,
|
||||||
|
generalId: null,
|
||||||
|
reservedUntil: null,
|
||||||
|
});
|
||||||
|
const archived = await db.oldGeneral.findUniqueOrThrow({
|
||||||
|
where: { by_no: { serverId: archiveServerId, generalNo: generalId } },
|
||||||
|
});
|
||||||
|
const archivedData = archived.data as { troopId?: number; meta?: Record<string, unknown> };
|
||||||
|
expect(archivedData.troopId).toBe(0);
|
||||||
|
expect(archivedData.meta).not.toHaveProperty('inheritRandomUnique');
|
||||||
|
expect(archivedData.meta).not.toHaveProperty('inheritSpecificSpecialWar');
|
||||||
|
await expect(
|
||||||
|
db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId, key: 'previous' } } })
|
||||||
|
).resolves.toMatchObject({ value: 7_105 });
|
||||||
|
const inheritanceResult = await db.inheritanceResult.findFirstOrThrow({
|
||||||
|
where: { serverId: archiveServerId, owner: userId },
|
||||||
|
});
|
||||||
|
expect(inheritanceResult).toMatchObject({ generalId });
|
||||||
|
expect(inheritanceResult.value).toMatchObject({
|
||||||
|
previous: 100,
|
||||||
|
refund: 7_000,
|
||||||
|
combat: 5,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await db.inheritanceLog.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
select: { text: true },
|
||||||
|
})
|
||||||
|
).map((entry: { text: string }) => entry.text)
|
||||||
|
).toEqual([
|
||||||
|
'사망으로 랜덤 유니크 구입 3000 포인트 반환',
|
||||||
|
'사망으로 전투 특기 지정 4000 포인트 반환',
|
||||||
|
'사망 정산: 7,105 포인트',
|
||||||
|
]);
|
||||||
|
await expect(
|
||||||
|
db.logEntry.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
scope: 'SYSTEM',
|
||||||
|
category: 'SUMMARY',
|
||||||
|
text: { contains: '<Y>실삭제장수</>가 홀연히 모습을 <R>감추었습니다</>' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
expect(event).toMatchObject({
|
||||||
|
status: 'SUCCEEDED',
|
||||||
|
attempts: 1,
|
||||||
|
actorUserId: userId,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => ({
|
||||||
|
gameToken: localStorage.getItem('sammo-game-token'),
|
||||||
|
gameProfile: localStorage.getItem('sammo-game-profile'),
|
||||||
|
}))
|
||||||
|
).toEqual({
|
||||||
|
gameToken: null,
|
||||||
|
gameProfile: profile,
|
||||||
|
});
|
||||||
|
await page.screenshot({
|
||||||
|
path: testInfo.outputPath('die-on-prestart-live-success.png'),
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,6 +26,10 @@ type FixtureState = {
|
|||||||
instantRetreatEnabled?: boolean;
|
instantRetreatEnabled?: boolean;
|
||||||
instantRetreatAttempts?: number;
|
instantRetreatAttempts?: number;
|
||||||
instantRetreatInputs?: Array<Record<string, unknown>>;
|
instantRetreatInputs?: Array<Record<string, unknown>>;
|
||||||
|
dieOnPrestartShow?: boolean;
|
||||||
|
dieOnPrestartAvailableAt?: string;
|
||||||
|
dieOnPrestartAttempts?: number;
|
||||||
|
dieOnPrestartInputs?: Array<Record<string, unknown>>;
|
||||||
generalMeQueries?: number;
|
generalMeQueries?: number;
|
||||||
generalLogQueries?: number;
|
generalLogQueries?: number;
|
||||||
settingMutations: Array<Record<string, unknown>>;
|
settingMutations: Array<Record<string, unknown>>;
|
||||||
@@ -125,8 +129,10 @@ const battleCenter = (state: FixtureState) => ({
|
|||||||
|
|
||||||
const install = async (page: Page, state: FixtureState) => {
|
const install = async (page: Page, state: FixtureState) => {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
localStorage.setItem('sammo-game-token', 'ga_menu-token');
|
if (location.pathname.startsWith('/che/')) {
|
||||||
localStorage.setItem('sammo-game-profile', 'che:default');
|
localStorage.setItem('sammo-game-token', 'ga_menu-token');
|
||||||
|
localStorage.setItem('sammo-game-profile', 'che:default');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
await page.route('**/image/game/**', async (route) => {
|
await page.route('**/image/game/**', async (route) => {
|
||||||
const filename = basename(new URL(route.request().url()).pathname);
|
const filename = basename(new URL(route.request().url()).pathname);
|
||||||
@@ -158,6 +164,12 @@ const install = async (page: Page, state: FixtureState) => {
|
|||||||
state.generalMeQueries = (state.generalMeQueries ?? 0) + 1;
|
state.generalMeQueries = (state.generalMeQueries ?? 0) + 1;
|
||||||
return response(myGeneral(state));
|
return response(myGeneral(state));
|
||||||
}
|
}
|
||||||
|
if (operation === 'general.ensureDieOnPrestartStatus')
|
||||||
|
return response({
|
||||||
|
show: state.dieOnPrestartShow ?? false,
|
||||||
|
available: false,
|
||||||
|
availableAt: state.dieOnPrestartAvailableAt ?? null,
|
||||||
|
});
|
||||||
if (operation === 'world.getState')
|
if (operation === 'world.getState')
|
||||||
return response({
|
return response({
|
||||||
currentYear: 185,
|
currentYear: 185,
|
||||||
@@ -209,6 +221,20 @@ const install = async (page: Page, state: FixtureState) => {
|
|||||||
}
|
}
|
||||||
return response({ ok: true });
|
return response({ ok: true });
|
||||||
}
|
}
|
||||||
|
if (operation === 'general.dieOnPrestart') {
|
||||||
|
state.dieOnPrestartInputs?.push(jsonInput);
|
||||||
|
state.dieOnPrestartAttempts = (state.dieOnPrestartAttempts ?? 0) + 1;
|
||||||
|
if (state.dieOnPrestartAttempts === 1) {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
message: '요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다.',
|
||||||
|
code: -32000,
|
||||||
|
data: { code: 'TIMEOUT', httpStatus: 408, path: operation },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return response({ ok: true });
|
||||||
|
}
|
||||||
if (operation === 'general.setMySetting') {
|
if (operation === 'general.setMySetting') {
|
||||||
state.settingMutations.push(jsonInput);
|
state.settingMutations.push(jsonInput);
|
||||||
state.myset = Math.max(0, state.myset - 1);
|
state.myset = Math.max(0, state.myset - 1);
|
||||||
@@ -480,6 +506,124 @@ test('내 정보 즉시행동은 timeout 재시도 ID를 유지하고 성공 후
|
|||||||
expect(state.instantRetreatInputs?.every((input) => !('generalId' in input))).toBe(true);
|
expect(state.instantRetreatInputs?.every((input) => !('generalId' in input))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('가오픈 장수 삭제는 레거시 표시와 확인을 보존하고 timeout을 같은 ID로 재시도한다', async ({ page }) => {
|
||||||
|
const state: FixtureState = {
|
||||||
|
permission: 'head',
|
||||||
|
myset: 3,
|
||||||
|
dieOnPrestartShow: true,
|
||||||
|
dieOnPrestartAvailableAt: '2026-01-01T00:20:00.000Z',
|
||||||
|
dieOnPrestartAttempts: 0,
|
||||||
|
dieOnPrestartInputs: [],
|
||||||
|
settingMutations: [],
|
||||||
|
accessPages: [],
|
||||||
|
};
|
||||||
|
const dialogs: string[] = [];
|
||||||
|
let confirmCount = 0;
|
||||||
|
page.on('dialog', async (dialog) => {
|
||||||
|
dialogs.push(`${dialog.type()}:${dialog.message()}`);
|
||||||
|
if (dialog.type() === 'confirm') {
|
||||||
|
confirmCount += 1;
|
||||||
|
if (confirmCount === 1) {
|
||||||
|
await dialog.dismiss();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
await install(page, state);
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('sammo-session-token', 'gateway-session-token');
|
||||||
|
});
|
||||||
|
await page.route(/^http:\/\/127\.0\.0\.1:\d+\/api\/trpc\/me(?:\?|$)/u, async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(
|
||||||
|
response({
|
||||||
|
id: 'gateway-user',
|
||||||
|
username: 'gateway-user',
|
||||||
|
displayName: '게이트웨이 사용자',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route('**/gateway/**', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'text/html',
|
||||||
|
body: '<!doctype html><html><body>gateway</body></html>',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.setViewportSize({ width: 1000, height: 900 });
|
||||||
|
await page.goto('my-page');
|
||||||
|
|
||||||
|
const actionLine = page.locator('.action-line').filter({ hasText: '가오픈 기간 내 장수 삭제' });
|
||||||
|
const deleteButton = actionLine.getByRole('button', { name: '장수 삭제' });
|
||||||
|
await expect(actionLine).toContainText('가오픈 기간 내 장수 삭제 (2026-01-01 09:20:00 부터)');
|
||||||
|
await expect(deleteButton).toBeVisible();
|
||||||
|
await expect(deleteButton).toBeEnabled();
|
||||||
|
const geometry = await deleteButton.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
color: style.color,
|
||||||
|
backgroundColor: style.backgroundColor,
|
||||||
|
cursor: style.cursor,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry).toEqual({
|
||||||
|
width: 160,
|
||||||
|
height: 30,
|
||||||
|
fontSize: '14px',
|
||||||
|
color: 'rgb(255, 255, 255)',
|
||||||
|
backgroundColor: 'rgb(34, 85, 0)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
});
|
||||||
|
await persistParityArtifact(page, 'core-my-page-die-on-prestart', geometry);
|
||||||
|
|
||||||
|
await deleteButton.click();
|
||||||
|
await expect.poll(() => confirmCount).toBe(1);
|
||||||
|
expect(dialogs[0]).toBe('confirm:정말로 삭제하시겠습니까?');
|
||||||
|
expect(state.dieOnPrestartInputs).toHaveLength(0);
|
||||||
|
|
||||||
|
await deleteButton.click();
|
||||||
|
await expect.poll(() => state.dieOnPrestartInputs?.length).toBe(1);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
dialogs.some((message) =>
|
||||||
|
message.includes('alert:실패했습니다: 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다.')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
await expect(deleteButton).toBeVisible();
|
||||||
|
const pendingRequestId = await page.evaluate(() => sessionStorage.getItem('sam.pending.dieOnPrestart'));
|
||||||
|
expect(pendingRequestId).toEqual(expect.stringMatching(/^[0-9a-f-]{36}$/i));
|
||||||
|
|
||||||
|
await deleteButton.click();
|
||||||
|
await expect.poll(() => state.dieOnPrestartInputs?.length).toBe(2);
|
||||||
|
await page.waitForURL(/\/gateway\/$/u);
|
||||||
|
await expect(page.locator('body')).toHaveText('gateway');
|
||||||
|
|
||||||
|
const requestIds = state.dieOnPrestartInputs?.map((input) => input.clientRequestId);
|
||||||
|
expect(requestIds).toEqual([pendingRequestId, pendingRequestId]);
|
||||||
|
expect(state.dieOnPrestartInputs?.every((input) => !('generalId' in input) && !('userId' in input))).toBe(true);
|
||||||
|
const storage = await page.evaluate(() => ({
|
||||||
|
gameToken: localStorage.getItem('sammo-game-token'),
|
||||||
|
gameProfile: localStorage.getItem('sammo-game-profile'),
|
||||||
|
sessionToken: localStorage.getItem('sammo-session-token'),
|
||||||
|
pendingRequestId: sessionStorage.getItem('sam.pending.dieOnPrestart'),
|
||||||
|
}));
|
||||||
|
expect(storage).toEqual({
|
||||||
|
gameToken: null,
|
||||||
|
gameProfile: 'che:default',
|
||||||
|
sessionToken: 'gateway-session-token',
|
||||||
|
pendingRequestId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('감찰부 keeps the selector interaction and shows the permission error path', async ({ page }) => {
|
test('감찰부 keeps the selector interaction and shows the permission error path', async ({ page }) => {
|
||||||
const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
||||||
await install(page, head);
|
await install(page, head);
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
"./joinGeneralLive.spec.ts",
|
"./joinGeneralLive.spec.ts",
|
||||||
"./joinGeneral.live.playwright.config.mjs",
|
"./joinGeneral.live.playwright.config.mjs",
|
||||||
"./npcPossessionLive.spec.ts",
|
"./npcPossessionLive.spec.ts",
|
||||||
"./npcPossession.live.playwright.config.mjs"
|
"./npcPossession.live.playwright.config.mjs",
|
||||||
|
"./dieOnPrestartLive.spec.ts",
|
||||||
|
"./dieOnPrestart.live.playwright.config.mjs"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"test:e2e:join-live": "playwright test --config e2e/joinGeneral.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
|
"test:e2e:join-live": "playwright test --config e2e/joinGeneral.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
|
||||||
"test:e2e:npc-possession": "playwright test npcPossession.spec.ts --config e2e/playwright.config.mjs",
|
"test:e2e:npc-possession": "playwright test npcPossession.spec.ts --config e2e/playwright.config.mjs",
|
||||||
"test:e2e:npc-possession-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/npcPossession.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
|
"test:e2e:npc-possession-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/npcPossession.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
|
||||||
|
"test:e2e:die-on-prestart-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/dieOnPrestart.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"test": "node -e \"console.log('test not configured')\"",
|
"test": "node -e \"console.log('test not configured')\"",
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.setGameToken(null);
|
this.setGameToken(null);
|
||||||
this.status = 'public';
|
this.status = 'public';
|
||||||
},
|
},
|
||||||
|
leaveGame() {
|
||||||
|
this.setGameToken(null);
|
||||||
|
this.status = this.sessionToken ? 'authed' : 'public';
|
||||||
|
},
|
||||||
async refreshGeneralStatus() {
|
async refreshGeneralStatus() {
|
||||||
if (!this.gameToken) {
|
if (!this.gameToken) {
|
||||||
this.status = this.sessionToken ? 'authed' : 'public';
|
this.status = this.sessionToken ? 'authed' : 'public';
|
||||||
|
|||||||
@@ -4,14 +4,17 @@ import { trpc } from '../utils/trpc';
|
|||||||
import { formatLog } from '../utils/formatLog';
|
import { formatLog } from '../utils/formatLog';
|
||||||
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
||||||
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic';
|
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic';
|
||||||
|
import { useSessionStore } from '../stores/session';
|
||||||
|
|
||||||
const SCREEN_MODE_KEY = 'sam.screenMode';
|
const SCREEN_MODE_KEY = 'sam.screenMode';
|
||||||
const CUSTOM_CSS_KEY = 'sam_customCSS';
|
const CUSTOM_CSS_KEY = 'sam_customCSS';
|
||||||
|
const PENDING_DIE_ON_PRESTART_KEY = 'sam.pending.dieOnPrestart';
|
||||||
type ScreenMode = 'auto' | '500px' | '1000px';
|
type ScreenMode = 'auto' | '500px' | '1000px';
|
||||||
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
|
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
|
||||||
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
|
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
|
||||||
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
|
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
|
||||||
type SelectionPoolStatus = Awaited<ReturnType<typeof trpc.join.getConfig.query>>['selectionPool'];
|
type SelectionPoolStatus = Awaited<ReturnType<typeof trpc.join.getConfig.query>>['selectionPool'];
|
||||||
|
type DieOnPrestartStatus = Awaited<ReturnType<typeof trpc.general.ensureDieOnPrestartStatus.mutate>>;
|
||||||
|
|
||||||
type WorldSnapshot = {
|
type WorldSnapshot = {
|
||||||
currentYear: number;
|
currentYear: number;
|
||||||
@@ -31,13 +34,20 @@ type SettingForm = {
|
|||||||
const data = ref<MyGeneralResponse | null>(null);
|
const data = ref<MyGeneralResponse | null>(null);
|
||||||
const world = ref<WorldSnapshot>(null);
|
const world = ref<WorldSnapshot>(null);
|
||||||
const selectionPoolStatus = ref<SelectionPoolStatus | null>(null);
|
const selectionPoolStatus = ref<SelectionPoolStatus | null>(null);
|
||||||
|
const dieOnPrestartStatus = ref<DieOnPrestartStatus | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const screenMode = ref<ScreenMode>('auto');
|
const screenMode = ref<ScreenMode>('auto');
|
||||||
const customCss = ref('');
|
const customCss = ref('');
|
||||||
const cssSaving = ref(false);
|
const cssSaving = ref(false);
|
||||||
|
const session = useSessionStore();
|
||||||
let cssTimer: number | null = null;
|
let cssTimer: number | null = null;
|
||||||
|
const readPendingDieOnPrestartId = (): string => {
|
||||||
|
const stored = window.sessionStorage.getItem(PENDING_DIE_ON_PRESTART_KEY);
|
||||||
|
return stored && /^[0-9a-f-]{36}$/iu.test(stored) ? stored : crypto.randomUUID();
|
||||||
|
};
|
||||||
const immediateActionRequestIds = reactive({
|
const immediateActionRequestIds = reactive({
|
||||||
|
dieOnPrestart: readPendingDieOnPrestartId(),
|
||||||
buildNationCandidate: crypto.randomUUID(),
|
buildNationCandidate: crypto.randomUUID(),
|
||||||
instantRetreat: crypto.randomUUID(),
|
instantRetreat: crypto.randomUUID(),
|
||||||
});
|
});
|
||||||
@@ -136,12 +146,16 @@ const actionAvailability = computed(() => {
|
|||||||
const preopen = Boolean(turnTime && openTime && turnTime.getTime() <= openTime.getTime());
|
const preopen = Boolean(turnTime && openTime && turnTime.getTime() <= openTime.getTime());
|
||||||
const npcMode = numberValue(config.npcMode ?? config.npcmode, 0);
|
const npcMode = numberValue(config.npcMode ?? config.npcmode, 0);
|
||||||
return {
|
return {
|
||||||
dieOnPrestart: Boolean(preopen && general?.npcState === 0 && general.nationId === 0),
|
dieOnPrestart: Boolean(dieOnPrestartStatus.value?.show),
|
||||||
buildNationCandidate: Boolean(preopen && general?.nationId === 0),
|
buildNationCandidate: Boolean(preopen && general?.nationId === 0),
|
||||||
instantRetreat: Boolean(availableInstantAction.instantRetreat),
|
instantRetreat: Boolean(availableInstantAction.instantRetreat),
|
||||||
selectOtherGeneral: Boolean(npcMode === 2 && general?.npcState === 0),
|
selectOtherGeneral: Boolean(npcMode === 2 && general?.npcState === 0),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
const formatDieOnPrestartAvailableAt = computed(() => {
|
||||||
|
const value = dieOnPrestartStatus.value?.availableAt;
|
||||||
|
return value ? formatSeoulDateTime(value) : '';
|
||||||
|
});
|
||||||
const formatSelectionAvailableAt = computed(() => {
|
const formatSelectionAvailableAt = computed(() => {
|
||||||
const value = selectionPoolStatus.value?.nextChangeAt;
|
const value = selectionPoolStatus.value?.nextChangeAt;
|
||||||
if (!value) return '';
|
if (!value) return '';
|
||||||
@@ -178,14 +192,16 @@ const loadPage = async () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
const [general, state, joinConfig] = await Promise.all([
|
const [general, state, joinConfig, prestartStatus] = await Promise.all([
|
||||||
trpc.general.me.query(),
|
trpc.general.me.query(),
|
||||||
trpc.world.getState.query() as Promise<WorldSnapshot>,
|
trpc.world.getState.query() as Promise<WorldSnapshot>,
|
||||||
trpc.join.getConfig.query(),
|
trpc.join.getConfig.query(),
|
||||||
|
trpc.general.ensureDieOnPrestartStatus.mutate(),
|
||||||
]);
|
]);
|
||||||
data.value = general;
|
data.value = general;
|
||||||
world.value = state;
|
world.value = state;
|
||||||
selectionPoolStatus.value = joinConfig.selectionPool;
|
selectionPoolStatus.value = joinConfig.selectionPool;
|
||||||
|
dieOnPrestartStatus.value = prestartStatus;
|
||||||
if (general) {
|
if (general) {
|
||||||
Object.assign(form, general.settings);
|
Object.assign(form, general.settings);
|
||||||
}
|
}
|
||||||
@@ -218,6 +234,25 @@ const confirmMutation = async (message: string, mutation: () => Promise<unknown>
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dieOnPrestart = async () => {
|
||||||
|
if (!confirm('정말로 삭제하시겠습니까?')) return;
|
||||||
|
const clientRequestId = immediateActionRequestIds.dieOnPrestart;
|
||||||
|
window.sessionStorage.setItem(PENDING_DIE_ON_PRESTART_KEY, clientRequestId);
|
||||||
|
try {
|
||||||
|
await trpc.general.dieOnPrestart.mutate({ clientRequestId });
|
||||||
|
window.sessionStorage.removeItem(PENDING_DIE_ON_PRESTART_KEY);
|
||||||
|
session.leaveGame();
|
||||||
|
window.location.replace(import.meta.env.VITE_GATEWAY_WEB_URL?.trim() || '/gateway/');
|
||||||
|
} catch (cause) {
|
||||||
|
const code = asRecord(asRecord(cause).data).code;
|
||||||
|
if (code !== 'TIMEOUT') {
|
||||||
|
window.sessionStorage.removeItem(PENDING_DIE_ON_PRESTART_KEY);
|
||||||
|
}
|
||||||
|
alert(`실패했습니다: ${errorText(cause)}`);
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const dropItem = (item: { key: ItemSlotKey; name: string; code: string | null }) =>
|
const dropItem = (item: { key: ItemSlotKey; name: string; code: string | null }) =>
|
||||||
confirmMutation(`${item.code ?? item.name}을(를) 버리시겠습니까?`, () =>
|
confirmMutation(`${item.code ?? item.name}을(를) 버리시겠습니까?`, () =>
|
||||||
trpc.general.dropItem.mutate({ itemType: item.key })
|
trpc.general.dropItem.mutate({ itemType: item.key })
|
||||||
@@ -387,13 +422,8 @@ onMounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="actionAvailability.dieOnPrestart" class="action-line">
|
<div v-if="actionAvailability.dieOnPrestart" class="action-line">
|
||||||
가오픈 기간 내 장수 삭제<br />
|
가오픈 기간 내 장수 삭제 ({{ formatDieOnPrestartAvailableAt }} 부터)<br />
|
||||||
<button
|
<button class="action-button" @click="dieOnPrestart">장수 삭제</button>
|
||||||
class="action-button"
|
|
||||||
@click="confirmMutation('정말로 삭제하시겠습니까?', () => trpc.general.dieOnPrestart.mutate())"
|
|
||||||
>
|
|
||||||
장수 삭제
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="actionAvailability.buildNationCandidate" class="action-line">
|
<div v-if="actionAvailability.buildNationCandidate" class="action-line">
|
||||||
서버 개시 이전 거병(2턴부터 건국 가능)<br />
|
서버 개시 이전 거병(2턴부터 건국 가능)<br />
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ export type TurnDaemonCommand =
|
|||||||
targetGeneralId: number;
|
targetGeneralId: number;
|
||||||
}
|
}
|
||||||
| { type: 'troopRename'; requestId?: string; generalId: number; troopId: number; troopName: string }
|
| { type: 'troopRename'; requestId?: string; generalId: number; troopId: number; troopName: string }
|
||||||
| { type: 'dieOnPrestart'; requestId?: string; generalId: number }
|
| { type: 'ensureDieOnPrestartStatus'; requestId?: string; userId: string; generalId: number }
|
||||||
|
| { type: 'dieOnPrestart'; requestId?: string; userId: string; generalId: number }
|
||||||
| { type: 'buildNationCandidate'; requestId?: string; userId: string; generalId: number }
|
| { type: 'buildNationCandidate'; requestId?: string; userId: string; generalId: number }
|
||||||
| { type: 'instantRetreat'; requestId?: string; userId: string; generalId: number }
|
| { type: 'instantRetreat'; requestId?: string; userId: string; generalId: number }
|
||||||
| { type: 'vacation'; requestId?: string; generalId: number }
|
| { type: 'vacation'; requestId?: string; generalId: number }
|
||||||
@@ -373,6 +374,13 @@ export type TurnDaemonCommandResult =
|
|||||||
troopId: number;
|
troopId: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'ensureDieOnPrestartStatus';
|
||||||
|
generalId: number;
|
||||||
|
show: boolean;
|
||||||
|
available: boolean;
|
||||||
|
availableAt?: string;
|
||||||
|
}
|
||||||
| { type: 'dieOnPrestart'; ok: boolean; generalId: number; reason?: string }
|
| { type: 'dieOnPrestart'; ok: boolean; generalId: number; reason?: string }
|
||||||
| { type: 'buildNationCandidate'; ok: boolean; generalId: number; reason?: string }
|
| { type: 'buildNationCandidate'; ok: boolean; generalId: number; reason?: string }
|
||||||
| { type: 'instantRetreat'; ok: boolean; generalId: number; reason?: string }
|
| { type: 'instantRetreat'; ok: boolean; generalId: number; reason?: string }
|
||||||
|
|||||||
@@ -506,7 +506,7 @@ describe('auction integration flow', () => {
|
|||||||
`
|
`
|
||||||
);
|
);
|
||||||
const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000);
|
const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000);
|
||||||
expect(result?.ok).toBe(true);
|
expect(result).toMatchObject({ type: 'auctionFinalize', ok: true });
|
||||||
|
|
||||||
const finished = await prisma.auction.findUnique({
|
const finished = await prisma.auction.findUnique({
|
||||||
where: { id: auction.id },
|
where: { id: auction.id },
|
||||||
@@ -652,7 +652,7 @@ describe('auction integration flow', () => {
|
|||||||
`
|
`
|
||||||
);
|
);
|
||||||
const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000);
|
const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000);
|
||||||
expect(result?.ok).toBe(false);
|
expect(result).toMatchObject({ type: 'auctionFinalize', ok: false });
|
||||||
|
|
||||||
const reopened = await prisma.auction.findUnique({
|
const reopened = await prisma.auction.findUnique({
|
||||||
where: { id: auction.id },
|
where: { id: auction.id },
|
||||||
@@ -794,7 +794,7 @@ describe('auction integration flow', () => {
|
|||||||
`
|
`
|
||||||
);
|
);
|
||||||
const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000);
|
const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000);
|
||||||
expect(result?.ok).toBe(true);
|
expect(result).toMatchObject({ type: 'auctionFinalize', ok: true });
|
||||||
|
|
||||||
const winner = await prisma.general.findUnique({
|
const winner = await prisma.general.findUnique({
|
||||||
where: { id: bidderB.generalId },
|
where: { id: bidderB.generalId },
|
||||||
|
|||||||
Reference in New Issue
Block a user