feat: 응답 가능한 서신과 턴 시간 호환 이관
등용장과 이민족 선택 응답을 turn daemon transaction으로 연결하고 Ref의 통일 이후 상태 전이와 수신자별 메시지 저장 규칙을 보존한다.\n\n유산 턴 시간 변경을 nextTurnTimeBase 기반 결정적 계산으로 바로잡고 API, 엔진, Chromium 회귀를 추가한다.
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
isWarTraitKey,
|
||||
} from '@sammo-ts/logic';
|
||||
import type { InheritBuffType } from '@sammo-ts/logic';
|
||||
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
||||
import {
|
||||
appendInheritanceLog,
|
||||
buildResetCost,
|
||||
@@ -153,17 +154,24 @@ const buildTurnTimeZoneList = (tickMinutes: number): string[] => {
|
||||
return zones;
|
||||
};
|
||||
|
||||
const alignToTurnBase = (time: Date, tickMinutes: number): Date => {
|
||||
const base = new Date(time.getFullYear(), time.getMonth(), time.getDate() - 1, 1, 0, 0, 0);
|
||||
const elapsedMinutes = Math.floor((time.getTime() - base.getTime()) / 60000);
|
||||
const alignedMinutes = elapsedMinutes - (elapsedMinutes % tickMinutes);
|
||||
return new Date(base.getTime() + alignedMinutes * 60000);
|
||||
const formatTurnTimeBaseLabel = (value: number): string => {
|
||||
const wholeSeconds = Math.trunc(value);
|
||||
const hours = String(Math.trunc(wholeSeconds / 3600)).padStart(2, '0');
|
||||
const minutes = String(Math.trunc((wholeSeconds % 3600) / 60)).padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
const formatTimeLabel = (value: Date): string => {
|
||||
const hours = String(value.getHours()).padStart(2, '0');
|
||||
const minutes = String(value.getMinutes()).padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
export const resolveResetTurnTimeBase = (options: {
|
||||
hiddenSeed: string | number;
|
||||
userId: string;
|
||||
previousTurnTimeBase: string | number;
|
||||
tickSeconds: number;
|
||||
}): { nextTurnTimeBase: number; nextTurnTimeLabel: string } => {
|
||||
const rng = new LiteHashDRBG(
|
||||
simpleSerialize(options.hiddenSeed, 'ResetTurnTime', options.userId, options.previousTurnTimeBase)
|
||||
);
|
||||
const nextTurnTimeBase = rng.nextFloat1() * Math.max(60, options.tickSeconds);
|
||||
return { nextTurnTimeBase, nextTurnTimeLabel: formatTurnTimeBaseLabel(nextTurnTimeBase) };
|
||||
};
|
||||
|
||||
const resolveSeasonValue = (meta: Record<string, unknown>): number | null => {
|
||||
@@ -491,7 +499,7 @@ export const inheritRouter = router({
|
||||
|
||||
const worldState = await resolveWorld(ctx);
|
||||
const worldMeta = asRecord(worldState.meta);
|
||||
if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) {
|
||||
if (asNumber(worldMeta.isunited ?? worldMeta.isUnited, 0) !== 0) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' });
|
||||
}
|
||||
|
||||
@@ -553,7 +561,7 @@ export const inheritRouter = router({
|
||||
|
||||
const general = await ctx.db.general.findFirst({
|
||||
where: { userId },
|
||||
select: { id: true, meta: true, turnTime: true },
|
||||
select: { id: true, meta: true, turnTick: true },
|
||||
});
|
||||
if (!general) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
|
||||
@@ -568,21 +576,30 @@ export const inheritRouter = router({
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' });
|
||||
}
|
||||
|
||||
const tickMinutes = Math.max(1, Math.round(worldState.tickSeconds / 60));
|
||||
const baseTime = alignToTurnBase(general.turnTime ?? new Date(), tickMinutes);
|
||||
const seedBase = `${asRecord(worldState.meta).hiddenSeed ?? 'inherit'}:ResetTurnTime:${userId}:${general.id}`;
|
||||
const rng = new LiteHashDRBG(seedBase);
|
||||
const offsetMinutes = rng.nextFloat1() * tickMinutes;
|
||||
let nextTurnTime = new Date(baseTime.getTime() + offsetMinutes * 60000);
|
||||
if (nextTurnTime.getTime() <= Date.now()) {
|
||||
nextTurnTime = new Date(nextTurnTime.getTime() + tickMinutes * 60000);
|
||||
}
|
||||
const generalMeta = asRecord(general.meta);
|
||||
const rawSeedTurnTime = generalMeta.nextTurnTimeBase ?? general.turnTick ?? 0;
|
||||
const seedTurnTime =
|
||||
typeof rawSeedTurnTime === 'string' || typeof rawSeedTurnTime === 'number'
|
||||
? rawSeedTurnTime
|
||||
: typeof rawSeedTurnTime === 'bigint'
|
||||
? Number(rawSeedTurnTime)
|
||||
: 0;
|
||||
const hiddenSeed =
|
||||
typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number'
|
||||
? worldMeta.hiddenSeed
|
||||
: 'inherit';
|
||||
const { nextTurnTimeBase, nextTurnTimeLabel } = resolveResetTurnTimeBase({
|
||||
hiddenSeed,
|
||||
userId,
|
||||
previousTurnTimeBase: seedTurnTime,
|
||||
tickSeconds: worldState.tickSeconds,
|
||||
});
|
||||
|
||||
await patchGeneral(ctx, general.id, {
|
||||
turnTime: nextTurnTime.toISOString(),
|
||||
meta: {
|
||||
...asRecord(general.meta),
|
||||
...generalMeta,
|
||||
inheritResetTurnTime: nextLevel,
|
||||
nextTurnTimeBase,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -592,9 +609,9 @@ export const inheritRouter = router({
|
||||
userId,
|
||||
worldState.currentYear,
|
||||
worldState.currentMonth,
|
||||
`${cost} 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${formatTimeLabel(nextTurnTime)} 적용`
|
||||
`${cost} 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${nextTurnTimeLabel} 적용`
|
||||
);
|
||||
return { ok: true, nextTurnTime: nextTurnTime.toISOString() };
|
||||
return { ok: true, nextTurnTimeBase, nextTurnTimeLabel };
|
||||
}),
|
||||
resetStat: authedProcedure
|
||||
.input(
|
||||
|
||||
@@ -72,10 +72,7 @@ const hasPenalty = (penalty: unknown, key: string): boolean => {
|
||||
return value === true || value === 1 || value === '1';
|
||||
};
|
||||
|
||||
const markMessageMailboxes = (
|
||||
ctx: Pick<GameApiContext, 'changeJournal'>,
|
||||
mailboxes: Iterable<number>
|
||||
): void => {
|
||||
const markMessageMailboxes = (ctx: Pick<GameApiContext, 'changeJournal'>, mailboxes: Iterable<number>): void => {
|
||||
for (const mailbox of mailboxes) {
|
||||
ctx.changeJournal?.mark('messages.mailbox', mailbox);
|
||||
}
|
||||
@@ -328,6 +325,27 @@ export const messagesRouter = router({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||
const message = await fetchMessageById(ctx.db, input.messageId);
|
||||
if (!message) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '메시지가 없습니다.' });
|
||||
}
|
||||
const action = message.payload.option?.action;
|
||||
if (action === 'scout' || action === 'raiseInvader') {
|
||||
if (!ctx.auth) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
const commandResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'messageRespond',
|
||||
userId: ctx.auth.user.id,
|
||||
generalId: general.id,
|
||||
messageId: input.messageId,
|
||||
response: input.response,
|
||||
});
|
||||
if (!commandResult || commandResult.type !== 'messageRespond') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '메시지 응답 처리에 실패했습니다.' });
|
||||
}
|
||||
return { result: commandResult.ok, reason: commandResult.reason };
|
||||
}
|
||||
const result = await respondToDiplomaticMessage({
|
||||
db: ctx.db,
|
||||
actor: general,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveResetTurnTimeBase } from '../src/router/inherit/index.js';
|
||||
|
||||
describe('inherit reset turn time Ref compatibility', () => {
|
||||
it('matches the Ref PHP deterministic seed, offset, and displayed minute', () => {
|
||||
const result = resolveResetTurnTimeBase({
|
||||
hiddenSeed: 'hidden-seed',
|
||||
userId: 'user-7',
|
||||
previousTurnTimeBase: 123_456,
|
||||
tickSeconds: 600,
|
||||
});
|
||||
|
||||
expect(result.nextTurnTimeBase).toBeCloseTo(302.5143852464758, 12);
|
||||
expect(result.nextTurnTimeLabel).toBe('00:05');
|
||||
});
|
||||
|
||||
it('uses the prior pending base as the next deterministic seed input', () => {
|
||||
const first = resolveResetTurnTimeBase({
|
||||
hiddenSeed: 'hidden-seed',
|
||||
userId: 'user-7',
|
||||
previousTurnTimeBase: 123_456,
|
||||
tickSeconds: 600,
|
||||
});
|
||||
const second = resolveResetTurnTimeBase({
|
||||
hiddenSeed: 'hidden-seed',
|
||||
userId: 'user-7',
|
||||
previousTurnTimeBase: first.nextTurnTimeBase,
|
||||
tickSeconds: 600,
|
||||
});
|
||||
|
||||
expect(second.nextTurnTimeBase).not.toBe(first.nextTurnTimeBase);
|
||||
expect(second.nextTurnTimeBase).toBeGreaterThanOrEqual(0);
|
||||
expect(second.nextTurnTimeBase).toBeLessThan(600);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
|
||||
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
import { resolveResetTurnTimeBase } from '../src/router/inherit/index.js';
|
||||
|
||||
const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
|
||||
id: 7,
|
||||
@@ -278,6 +279,43 @@ describe('inherit router actor and permission boundaries', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('queues Ref-compatible nextTurnTimeBase without moving the current scheduled turn', async () => {
|
||||
const fixture = buildContext({
|
||||
inheritancePoint: 2_000,
|
||||
general: buildGeneral({ meta: { nextTurnTimeBase: 123_456 } }),
|
||||
});
|
||||
const expected = resolveResetTurnTimeBase({
|
||||
hiddenSeed: 'test-seed',
|
||||
userId: 'user-1',
|
||||
previousTurnTimeBase: 123_456,
|
||||
tickSeconds: worldState.tickSeconds,
|
||||
});
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).inherit.resetTurnTime()).resolves.toEqual({
|
||||
ok: true,
|
||||
...expected,
|
||||
});
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'patchGeneral',
|
||||
generalId: 7,
|
||||
patch: {
|
||||
meta: {
|
||||
nextTurnTimeBase: expected.nextTurnTimeBase,
|
||||
inheritResetTurnTime: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } }));
|
||||
expect(fixture.logCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: 'user-1',
|
||||
year: 200,
|
||||
month: 4,
|
||||
text: `1000 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${expected.nextTurnTimeLabel} 적용`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('reveals a target owner to the caller without using the caller general id from input', async () => {
|
||||
const fixture = buildContext({ inheritancePoint: 1500 });
|
||||
|
||||
|
||||
@@ -565,6 +565,70 @@ describe('messages router missing-flow compatibility', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['scout', 'raiseInvader'] as const)(
|
||||
'dispatches a private %s response through the durable engine command',
|
||||
async (action) => {
|
||||
const messageRow = {
|
||||
id: 29,
|
||||
mailbox: general.id,
|
||||
type: 'private',
|
||||
src: 8,
|
||||
dest: general.id,
|
||||
time: new Date(),
|
||||
valid_until: new Date('9999-12-31T00:00:00Z'),
|
||||
message: {
|
||||
src: {
|
||||
generalId: 8,
|
||||
generalName: '제안자',
|
||||
nationId: 2,
|
||||
nationName: '촉',
|
||||
color: '#000',
|
||||
icon: '',
|
||||
},
|
||||
dest: {
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
nationId: general.nationId,
|
||||
nationName: '위',
|
||||
color: '#fff',
|
||||
icon: '',
|
||||
},
|
||||
text: '응답할 메시지',
|
||||
option: { action },
|
||||
},
|
||||
};
|
||||
const requestCommand = vi.fn(async () => ({
|
||||
type: 'messageRespond' as const,
|
||||
ok: true,
|
||||
generalId: general.id,
|
||||
messageId: messageRow.id,
|
||||
action,
|
||||
reason: 'success',
|
||||
}));
|
||||
const { caller } = buildContext(
|
||||
{ $queryRaw: vi.fn(async () => [messageRow]) },
|
||||
{ turnDaemon: { requestCommand } }
|
||||
);
|
||||
|
||||
const result = await caller.messages.respond({
|
||||
generalId: general.id,
|
||||
messageId: messageRow.id,
|
||||
response: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ result: true, reason: 'success' });
|
||||
expect(requestCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'messageRespond',
|
||||
userId: auth.user.id,
|
||||
generalId: general.id,
|
||||
messageId: messageRow.id,
|
||||
response: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const buildDiplomaticContext = (options?: {
|
||||
action?: 'noAggression' | 'cancelNA' | 'stopWar';
|
||||
actorNationId?: number;
|
||||
@@ -634,10 +698,10 @@ describe('messages router missing-flow compatibility', () => {
|
||||
let insertedId = 100;
|
||||
const queryRaw = vi.fn(async () => {
|
||||
rawCall += 1;
|
||||
if (rawCall === 1) {
|
||||
if (rawCall <= 2) {
|
||||
return [messageRow];
|
||||
}
|
||||
if (rawCall >= 4) {
|
||||
if (rawCall >= 5) {
|
||||
insertedId += 1;
|
||||
return [{ id: insertedId }];
|
||||
}
|
||||
@@ -693,84 +757,87 @@ describe('messages router missing-flow compatibility', () => {
|
||||
const messageUpdateMany = vi.fn(async () => ({ count: 1 }));
|
||||
const cityUpdate = vi.fn(async () => ({}));
|
||||
const changeJournal = new ChangeJournal();
|
||||
const { caller } = buildContext({
|
||||
general: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||
where.id === actor.id ? actor : where.id === proposer.id ? proposer : null
|
||||
),
|
||||
findMany: vi.fn(async () => []),
|
||||
const { caller } = buildContext(
|
||||
{
|
||||
general: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||
where.id === actor.id ? actor : where.id === proposer.id ? proposer : null
|
||||
),
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
nation: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||
where.id === actorNationId
|
||||
? {
|
||||
id: actorNationId,
|
||||
name: '위',
|
||||
color: '#fff',
|
||||
capitalCityId: 10,
|
||||
chiefGeneralId: actor.id,
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
tech: 0,
|
||||
level: 1,
|
||||
typeCode: 'test',
|
||||
meta: {},
|
||||
}
|
||||
: where.id === proposerNationId
|
||||
? {
|
||||
id: proposerNationId,
|
||||
name: '촉',
|
||||
color: '#000',
|
||||
capitalCityId: 20,
|
||||
chiefGeneralId: proposer.id,
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
tech: 0,
|
||||
level: 1,
|
||||
typeCode: 'test',
|
||||
meta: { recv_assist: { [`n${actorNationId}`]: [actorNationId, 50] } },
|
||||
}
|
||||
: null
|
||||
),
|
||||
findMany: vi.fn(async () => []),
|
||||
update: nationUpdate,
|
||||
},
|
||||
city: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: 10,
|
||||
nationId: actorNationId,
|
||||
supplyState: 1,
|
||||
})),
|
||||
findMany: vi.fn(async () => options?.cities ?? []),
|
||||
update: cityUpdate,
|
||||
},
|
||||
diplomacy: {
|
||||
findUnique: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
}: {
|
||||
where: { srcNationId_destNationId: { srcNationId: number; destNationId: number } };
|
||||
}) =>
|
||||
diplomacyRows.find(
|
||||
(row) =>
|
||||
row.srcNationId === where.srcNationId_destNationId.srcNationId &&
|
||||
row.destNationId === where.srcNationId_destNationId.destNationId
|
||||
) ?? null
|
||||
),
|
||||
findMany: vi.fn(async () => diplomacyRows),
|
||||
update: diplomacyUpdate,
|
||||
},
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
currentYear: 200,
|
||||
currentMonth: 3,
|
||||
config: { environment: { mapName: 'che' } },
|
||||
})),
|
||||
},
|
||||
logEntry: { createMany: logCreateMany },
|
||||
message: { updateMany: messageUpdateMany },
|
||||
$queryRaw: queryRaw,
|
||||
},
|
||||
nation: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||
where.id === actorNationId
|
||||
? {
|
||||
id: actorNationId,
|
||||
name: '위',
|
||||
color: '#fff',
|
||||
capitalCityId: 10,
|
||||
chiefGeneralId: actor.id,
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
tech: 0,
|
||||
level: 1,
|
||||
typeCode: 'test',
|
||||
meta: {},
|
||||
}
|
||||
: where.id === proposerNationId
|
||||
? {
|
||||
id: proposerNationId,
|
||||
name: '촉',
|
||||
color: '#000',
|
||||
capitalCityId: 20,
|
||||
chiefGeneralId: proposer.id,
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
tech: 0,
|
||||
level: 1,
|
||||
typeCode: 'test',
|
||||
meta: { recv_assist: { [`n${actorNationId}`]: [actorNationId, 50] } },
|
||||
}
|
||||
: null
|
||||
),
|
||||
findMany: vi.fn(async () => []),
|
||||
update: nationUpdate,
|
||||
},
|
||||
city: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: 10,
|
||||
nationId: actorNationId,
|
||||
supplyState: 1,
|
||||
})),
|
||||
findMany: vi.fn(async () => options?.cities ?? []),
|
||||
update: cityUpdate,
|
||||
},
|
||||
diplomacy: {
|
||||
findUnique: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
}: {
|
||||
where: { srcNationId_destNationId: { srcNationId: number; destNationId: number } };
|
||||
}) =>
|
||||
diplomacyRows.find(
|
||||
(row) =>
|
||||
row.srcNationId === where.srcNationId_destNationId.srcNationId &&
|
||||
row.destNationId === where.srcNationId_destNationId.destNationId
|
||||
) ?? null
|
||||
),
|
||||
findMany: vi.fn(async () => diplomacyRows),
|
||||
update: diplomacyUpdate,
|
||||
},
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
currentYear: 200,
|
||||
currentMonth: 3,
|
||||
config: { environment: { mapName: 'che' } },
|
||||
})),
|
||||
},
|
||||
logEntry: { createMany: logCreateMany },
|
||||
message: { updateMany: messageUpdateMany },
|
||||
$queryRaw: queryRaw,
|
||||
}, { changeJournal });
|
||||
{ changeJournal }
|
||||
);
|
||||
return {
|
||||
caller,
|
||||
actor,
|
||||
@@ -811,7 +878,7 @@ describe('messages router missing-flow compatibility', () => {
|
||||
where: { id: { in: [31] } },
|
||||
data: { validUntil: expect.any(Date) },
|
||||
});
|
||||
expect(setup.queryRaw).toHaveBeenCalledTimes(8);
|
||||
expect(setup.queryRaw).toHaveBeenCalledTimes(9);
|
||||
});
|
||||
|
||||
it('declines a diplomatic prompt without changing diplomacy', async () => {
|
||||
|
||||
Reference in New Issue
Block a user