fix: 외교 메시지의 시계 fallback과 fixture 경계를 복원한다
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import { enqueuePrivateMessageWebPush, GamePrisma } from '@sammo-ts/infra';
|
||||
import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common';
|
||||
import { enqueuePrivateMessageWebPush, GamePrisma } from '@sammo-ts/infra';
|
||||
import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic';
|
||||
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
import { loadCurrentGameTime } from '../services/gameClock.js';
|
||||
import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js';
|
||||
|
||||
export interface MessageView {
|
||||
id: number;
|
||||
@@ -48,6 +48,19 @@ const formatMessageTime = (value: Date): string => {
|
||||
)} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`;
|
||||
};
|
||||
|
||||
const messageValidityPredicate = (gameTime: CurrentGameTime) => {
|
||||
if (gameTime.tick === null) {
|
||||
// A legacy or partially migrated profile has no authoritative logical
|
||||
// tick. Rows that already carry a tick still need the wall-time
|
||||
// fallback used by the clock migration.
|
||||
return GamePrisma.sql`valid_until > ${gameTime.now}`;
|
||||
}
|
||||
return GamePrisma.sql`(
|
||||
(valid_until_tick IS NOT NULL AND valid_until_tick > ${BigInt(gameTime.tick)})
|
||||
OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now})
|
||||
)`;
|
||||
};
|
||||
|
||||
const toMessageView = (row: MessageRow): MessageView => {
|
||||
const payload = parsePayload(row.message);
|
||||
return {
|
||||
@@ -113,10 +126,7 @@ export const fetchMessagesFromMailbox = async (params: {
|
||||
FROM message
|
||||
WHERE mailbox = ${params.mailbox}
|
||||
AND type = ${params.msgType}
|
||||
AND (
|
||||
(valid_until_tick IS NOT NULL AND valid_until_tick > ${gameTime.tick === null ? null : BigInt(gameTime.tick)})
|
||||
OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now})
|
||||
)
|
||||
AND ${messageValidityPredicate(gameTime)}
|
||||
AND id >= ${fromSeq}
|
||||
ORDER BY id DESC
|
||||
LIMIT ${params.limit}
|
||||
@@ -138,10 +148,7 @@ export const fetchOldMessagesFromMailbox = async (params: {
|
||||
FROM message
|
||||
WHERE mailbox = ${params.mailbox}
|
||||
AND type = ${params.msgType}
|
||||
AND (
|
||||
(valid_until_tick IS NOT NULL AND valid_until_tick > ${gameTime.tick === null ? null : BigInt(gameTime.tick)})
|
||||
OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now})
|
||||
)
|
||||
AND ${messageValidityPredicate(gameTime)}
|
||||
AND id < ${params.toSeq}
|
||||
ORDER BY id DESC
|
||||
LIMIT ${params.limit}
|
||||
@@ -156,10 +163,7 @@ export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<
|
||||
SELECT id, mailbox, type, src, dest, time, valid_until, message
|
||||
FROM message
|
||||
WHERE id = ${id}
|
||||
AND (
|
||||
(valid_until_tick IS NOT NULL AND valid_until_tick > ${gameTime.tick === null ? null : BigInt(gameTime.tick)})
|
||||
OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now})
|
||||
)
|
||||
AND ${messageValidityPredicate(gameTime)}
|
||||
LIMIT 1
|
||||
`;
|
||||
const row = rows[0];
|
||||
@@ -179,10 +183,7 @@ export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number):
|
||||
SELECT id, mailbox, type, src, dest, time, valid_until, message
|
||||
FROM message
|
||||
WHERE id = ${id}
|
||||
AND (
|
||||
(valid_until_tick IS NOT NULL AND valid_until_tick > ${gameTime.tick === null ? null : BigInt(gameTime.tick)})
|
||||
OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now})
|
||||
)
|
||||
AND ${messageValidityPredicate(gameTime)}
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
import { JosaUtil, MAX_SAFE_GAME_TICK } from '@sammo-ts/common';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import { createGamePostgresConnector, type GamePrismaClient, type RedisConnector } from '@sammo-ts/infra';
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
|
||||
import type { GameApiContext } from '../src/context.js';
|
||||
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
||||
import { fetchMessagesFromMailbox } from '../src/messages/store.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
@@ -251,14 +252,22 @@ integration('diplomacy document message persistence', () => {
|
||||
|
||||
const buildRollbackDatabase = (failure: Error): GameApiContext['db'] => {
|
||||
const database = db as unknown as GameApiContext['db'];
|
||||
let failNextTransaction = true;
|
||||
return new Proxy(database, {
|
||||
get(target, property) {
|
||||
if (property === '$transaction') {
|
||||
return async (callback: (transaction: GameApiContext['db']) => Promise<unknown>) =>
|
||||
db.$transaction(async (transaction) => {
|
||||
return async (callback: (transaction: GameApiContext['db']) => Promise<unknown>) => {
|
||||
if (!failNextTransaction) {
|
||||
return db.$transaction((transaction) =>
|
||||
callback(transaction as unknown as GameApiContext['db'])
|
||||
);
|
||||
}
|
||||
failNextTransaction = false;
|
||||
return db.$transaction(async (transaction) => {
|
||||
await callback(transaction as unknown as GameApiContext['db']);
|
||||
throw failure;
|
||||
});
|
||||
};
|
||||
}
|
||||
return Reflect.get(target, property, target);
|
||||
},
|
||||
@@ -370,6 +379,51 @@ integration('diplomacy document message persistence', () => {
|
||||
await expectInputEvent(chainedRequestId, 'sendLetter', fixtureUserId);
|
||||
});
|
||||
|
||||
it('keeps permanent messages readable while a profile has no logical clock', async () => {
|
||||
const created = await appRouter
|
||||
.createCaller(buildContext('legacy-clock-fallback', fixtureAuth))
|
||||
.diplomacy.sendLetter({
|
||||
destNationId: foreignNationId,
|
||||
brief: '시계 이관 중 외교문서',
|
||||
detail: '시계 이관 중에도 보여야 합니다.',
|
||||
});
|
||||
const receiverMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + foreignNationId;
|
||||
const receiver = await db.message.findFirstOrThrow({
|
||||
where: { mailbox: receiverMailbox, type: 'diplomacy' },
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
expect(receiver.validUntilTick).toBe(BigInt(MAX_SAFE_GAME_TICK));
|
||||
|
||||
await db.worldState.update({
|
||||
where: { id: fixtureWorldStateId },
|
||||
data: { clockBaseTime: null, clockTick: null, clockWallAnchor: null },
|
||||
});
|
||||
try {
|
||||
const messages = await fetchMessagesFromMailbox({
|
||||
db,
|
||||
mailbox: receiverMailbox,
|
||||
msgType: 'diplomacy',
|
||||
limit: 15,
|
||||
fromSeq: 0,
|
||||
});
|
||||
expect(messages).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: receiver.id,
|
||||
text: expect.stringContaining(`#${created.id}`),
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
await db.worldState.update({
|
||||
where: { id: fixtureWorldStateId },
|
||||
data: {
|
||||
clockBaseTime,
|
||||
clockTick: logicalGameTick,
|
||||
clockWallAnchor: new Date('2026-08-24T00:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('stores diplomacy and national copies for both approval and rejection responses', async () => {
|
||||
const approved = await createLetter();
|
||||
const approveRequestId = 'respond-approve';
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type GamePrismaClient,
|
||||
type RedisConnector,
|
||||
} from '@sammo-ts/infra';
|
||||
import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic';
|
||||
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import { createGameApiServer } from '../src/server.js';
|
||||
@@ -21,8 +22,14 @@ const integration = describe.skipIf(!databaseUrl || !process.env.REDIS_URL);
|
||||
const profileId = process.env.POSTGRES_SCHEMA ?? 'public';
|
||||
const profileName = `che:diplomacy-html-${process.pid}`;
|
||||
const userId = `diplomacy-html-user-${process.pid}`;
|
||||
const fixtureId = 920_000 + (process.pid % 50_000);
|
||||
// National and diplomacy mailboxes use the Ref-compatible 9000 + nation id
|
||||
// address space, so a real nation fixture must stay in 1..998; 999 is public.
|
||||
const fixtureId = 861;
|
||||
const foreignNationId = fixtureId + 1;
|
||||
const fixtureMailboxes = [
|
||||
MESSAGE_MAILBOX_NATIONAL_BASE + fixtureId,
|
||||
MESSAGE_MAILBOX_NATIONAL_BASE + foreignNationId,
|
||||
] as const;
|
||||
const secret = 'diplomacy-html-http-secret';
|
||||
const redisPrefix = `sammo:diplomacy-html:${process.pid}`;
|
||||
const envKeys = [
|
||||
@@ -66,7 +73,22 @@ const deleteProfileRedisKeys = async (): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
const isFixtureOutboxPayload = (payload: unknown): boolean => {
|
||||
if (!payload || typeof payload !== 'object' || !('changes' in payload)) return false;
|
||||
const changes = (payload as { changes?: unknown }).changes;
|
||||
return (
|
||||
Array.isArray(changes) &&
|
||||
changes.some(
|
||||
(change) =>
|
||||
Array.isArray(change) &&
|
||||
change[0] === 'messages.mailbox' &&
|
||||
fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number])
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const cleanup = async (): Promise<void> => {
|
||||
await db.message.deleteMany({ where: { mailbox: { in: [...fixtureMailboxes] } } });
|
||||
await db.diplomacyLetter.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
@@ -77,6 +99,15 @@ const cleanup = async (): Promise<void> => {
|
||||
],
|
||||
},
|
||||
});
|
||||
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
|
||||
await db.readModelRevision.deleteMany({
|
||||
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } },
|
||||
});
|
||||
const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } });
|
||||
const outboxIds = outboxes.filter(({ payload }) => isFixtureOutboxPayload(payload)).map(({ id }) => id);
|
||||
if (outboxIds.length > 0) {
|
||||
await db.readModelOutbox.deleteMany({ where: { id: { in: outboxIds } } });
|
||||
}
|
||||
await db.generalAccessLog.deleteMany({ where: { generalId: fixtureId } });
|
||||
await db.general.deleteMany({ where: { id: fixtureId } });
|
||||
await db.nation.deleteMany({ where: { id: { in: [fixtureId, foreignNationId] } } });
|
||||
|
||||
Reference in New Issue
Block a user