fix(game-api): 장수 소유자 확인 메시지를 복구
Ref와 같이 확인자와 확인 대상에게 시스템 개인 메시지를 저장하고, 두 mailbox 변경을 durable journal에 기록한다. 실패 경계와 실제 PostgreSQL 저장 회귀 테스트를 포함한다.
This commit is contained in:
@@ -7,12 +7,12 @@ import {
|
|||||||
ItemLoader,
|
ItemLoader,
|
||||||
isItemKey,
|
isItemKey,
|
||||||
loadWarTraitModules,
|
loadWarTraitModules,
|
||||||
|
sendMessage,
|
||||||
WarTraitLoader,
|
WarTraitLoader,
|
||||||
WAR_TRAIT_KEYS,
|
WAR_TRAIT_KEYS,
|
||||||
isWarTraitKey,
|
isWarTraitKey,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import type { InheritBuffType } from '@sammo-ts/logic';
|
import type { InheritBuffType, ItemSlot, MessageDraft, MessageRecordDraft } from '@sammo-ts/logic';
|
||||||
import type { ItemSlot } from '@sammo-ts/logic';
|
|
||||||
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
||||||
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +28,9 @@ import {
|
|||||||
} from '../../services/inheritance.js';
|
} from '../../services/inheritance.js';
|
||||||
import type { GameApiContext, WorldStateRow } from '../../context.js';
|
import type { GameApiContext, WorldStateRow } from '../../context.js';
|
||||||
import { openAuctionWithDaemon } from '../../auction/open.js';
|
import { openAuctionWithDaemon } from '../../auction/open.js';
|
||||||
|
import { buildTargetFromGeneral } from '../../messages/targets.js';
|
||||||
|
import { insertMessage } from '../../messages/store.js';
|
||||||
|
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||||
|
|
||||||
const BUFF_KEYS: InheritBuffType[] = [
|
const BUFF_KEYS: InheritBuffType[] = [
|
||||||
'warAvoidRatio',
|
'warAvoidRatio',
|
||||||
@@ -881,11 +884,8 @@ export const inheritRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [general, target] = await Promise.all([
|
const [general, target] = await Promise.all([
|
||||||
ctx.db.general.findFirst({ where: { userId }, select: { id: true } }),
|
ctx.db.general.findFirst({ where: { userId } }),
|
||||||
ctx.db.general.findUnique({
|
ctx.db.general.findUnique({ where: { id: input.targetGeneralId } }),
|
||||||
where: { id: input.targetGeneralId },
|
|
||||||
select: { id: true, name: true, userId: true, meta: true },
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
if (!general) {
|
if (!general) {
|
||||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
|
||||||
@@ -909,6 +909,42 @@ export const inheritRouter = router({
|
|||||||
worldState.currentMonth,
|
worldState.currentMonth,
|
||||||
`${inheritConst.inheritCheckOwnerPoint} 포인트로 장수 소유자 확인`
|
`${inheritConst.inheritCheckOwnerPoint} 포인트로 장수 소유자 확인`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [generalTarget, checkedTarget, gameTime] = await Promise.all([
|
||||||
|
buildTargetFromGeneral(ctx.db, general),
|
||||||
|
buildTargetFromGeneral(ctx.db, target),
|
||||||
|
loadCurrentGameTime(ctx.db),
|
||||||
|
]);
|
||||||
|
const systemTarget: MessageDraft['src'] = {
|
||||||
|
generalId: 0,
|
||||||
|
generalName: '',
|
||||||
|
nationId: 0,
|
||||||
|
nationName: 'System',
|
||||||
|
color: '#000000',
|
||||||
|
icon: '',
|
||||||
|
};
|
||||||
|
const validUntil = new Date('9999-12-31T00:00:00.000Z');
|
||||||
|
const sendSystemPrivateMessage = async (dest: MessageDraft['dest'], text: string): Promise<void> => {
|
||||||
|
await sendMessage(
|
||||||
|
{
|
||||||
|
insertMessage: (draft: MessageRecordDraft) => insertMessage(ctx.db, draft),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
msgType: 'private',
|
||||||
|
src: systemTarget,
|
||||||
|
dest,
|
||||||
|
text,
|
||||||
|
time: gameTime.now,
|
||||||
|
validUntil,
|
||||||
|
option: {},
|
||||||
|
},
|
||||||
|
{ sendDestOnly: true }
|
||||||
|
);
|
||||||
|
ctx.changeJournal?.mark('messages.mailbox', dest.generalId);
|
||||||
|
};
|
||||||
|
|
||||||
|
await sendSystemPrivateMessage(generalTarget, `${target.name}의 소유자는 ${ownerName} 입니다.`);
|
||||||
|
await sendSystemPrivateMessage(checkedTarget, '소유자명이 누군가에 의해 확인되었습니다.');
|
||||||
return { ok: true, ownerName, targetName: target.name };
|
return { ok: true, ownerName, targetName: target.name };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
const classifications = {
|
const classifications = {
|
||||||
durableJournal: [
|
durableJournal: [
|
||||||
'betting.bet',
|
'betting.bet',
|
||||||
|
'inherit.checkOwner',
|
||||||
'messages.delete',
|
'messages.delete',
|
||||||
'messages.respond',
|
'messages.respond',
|
||||||
'messages.send',
|
'messages.send',
|
||||||
@@ -36,7 +37,6 @@ const classifications = {
|
|||||||
'diplomacy.respondLetter',
|
'diplomacy.respondLetter',
|
||||||
'diplomacy.rollbackLetter',
|
'diplomacy.rollbackLetter',
|
||||||
'diplomacy.sendLetter',
|
'diplomacy.sendLetter',
|
||||||
'inherit.checkOwner',
|
|
||||||
'join.getSelectionPool',
|
'join.getSelectionPool',
|
||||||
'join.listPossessCandidates',
|
'join.listPossessCandidates',
|
||||||
'messages.readLatest',
|
'messages.readLatest',
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
|
import { createGamePostgresConnector, type GamePrismaClient, type RedisConnector } from '@sammo-ts/infra';
|
||||||
|
|
||||||
|
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||||
|
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
|
||||||
|
import type { GameApiContext } from '../src/context.js';
|
||||||
|
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
||||||
|
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||||
|
import { appRouter } from '../src/router.js';
|
||||||
|
|
||||||
|
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||||
|
const integration = describe.skipIf(!databaseUrl);
|
||||||
|
const actorGeneralId = 8_701;
|
||||||
|
const checkedGeneralId = 8_702;
|
||||||
|
const actorNationId = 871;
|
||||||
|
const checkedNationId = 872;
|
||||||
|
const actorUserId = 'inherit-owner-message-actor';
|
||||||
|
const checkedUserId = 'inherit-owner-message-checked';
|
||||||
|
|
||||||
|
const auth: GameSessionTokenPayload = {
|
||||||
|
version: 1,
|
||||||
|
profile: 'che:inherit-owner-message',
|
||||||
|
issuedAt: '2026-08-19T00:00:00.000Z',
|
||||||
|
expiresAt: '2026-08-20T00:00:00.000Z',
|
||||||
|
sessionId: 'inherit-owner-message-session',
|
||||||
|
user: {
|
||||||
|
id: actorUserId,
|
||||||
|
username: actorUserId,
|
||||||
|
displayName: '확인자 계정',
|
||||||
|
roles: ['user'],
|
||||||
|
},
|
||||||
|
sanctions: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasMailboxChange = (payload: unknown): boolean => {
|
||||||
|
if (!payload || typeof payload !== 'object' || !('changes' in payload)) return false;
|
||||||
|
const changes = (payload as { changes?: unknown }).changes;
|
||||||
|
if (!Array.isArray(changes)) return false;
|
||||||
|
const mailboxes = new Set([actorGeneralId, checkedGeneralId]);
|
||||||
|
return changes.some(
|
||||||
|
(change) =>
|
||||||
|
Array.isArray(change) &&
|
||||||
|
change[0] === 'messages.mailbox' &&
|
||||||
|
typeof change[1] === 'number' &&
|
||||||
|
mailboxes.has(change[1])
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
integration('inherit owner lookup private messages', () => {
|
||||||
|
let db: GamePrismaClient;
|
||||||
|
let closeDb: (() => Promise<void>) | undefined;
|
||||||
|
let worldStateId: number;
|
||||||
|
|
||||||
|
const buildContext = (requestId: string): GameApiContext => {
|
||||||
|
const redisClient = {
|
||||||
|
get: async () => null,
|
||||||
|
set: async () => null,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
requestId,
|
||||||
|
db,
|
||||||
|
redis: redisClient as unknown as RedisConnector['client'],
|
||||||
|
turnDaemon: new InMemoryTurnDaemonTransport(),
|
||||||
|
battleSim: new InMemoryBattleSimTransport(),
|
||||||
|
profile: { id: 'che', scenario: 'inherit-owner-message', name: 'che:inherit-owner-message' },
|
||||||
|
uploadDir: 'uploads',
|
||||||
|
uploadPath: '/uploads',
|
||||||
|
uploadPublicUrl: null,
|
||||||
|
auth,
|
||||||
|
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:inherit-owner-message'),
|
||||||
|
flushStore: new InMemoryFlushStore(),
|
||||||
|
gameTokenSecret: 'test-secret',
|
||||||
|
readModelOutbox: { wake: vi.fn() },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||||
|
await connector.connect();
|
||||||
|
db = connector.prisma;
|
||||||
|
closeDb = () => connector.disconnect();
|
||||||
|
|
||||||
|
await db.inputEvent.deleteMany({ where: { actorUserId } });
|
||||||
|
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, checkedGeneralId] } } });
|
||||||
|
await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } });
|
||||||
|
await db.inheritancePoint.deleteMany({ where: { userId: actorUserId } });
|
||||||
|
await db.general.deleteMany({ where: { id: { in: [actorGeneralId, checkedGeneralId] } } });
|
||||||
|
await db.nation.deleteMany({ where: { id: { in: [actorNationId, checkedNationId] } } });
|
||||||
|
await db.readModelRevision.deleteMany({
|
||||||
|
where: { domain: 'messages.mailbox', entityId: { in: [actorGeneralId, checkedGeneralId] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.nation.createMany({
|
||||||
|
data: [
|
||||||
|
{ id: actorNationId, name: '확인국', color: '#123456', level: 2 },
|
||||||
|
{ id: checkedNationId, name: '피확인국', color: '#654321', level: 3 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await db.general.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: actorGeneralId,
|
||||||
|
userId: actorUserId,
|
||||||
|
name: '확인장수',
|
||||||
|
nationId: actorNationId,
|
||||||
|
cityId: 1,
|
||||||
|
npcState: 0,
|
||||||
|
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||||
|
meta: { ownerName: '확인자 계정' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: checkedGeneralId,
|
||||||
|
userId: checkedUserId,
|
||||||
|
name: '피확인장수',
|
||||||
|
nationId: checkedNationId,
|
||||||
|
cityId: 1,
|
||||||
|
npcState: 0,
|
||||||
|
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||||
|
meta: { ownerName: '피확인 계정' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await db.inheritancePoint.create({
|
||||||
|
data: { userId: actorUserId, key: 'previous', value: 1_500 },
|
||||||
|
});
|
||||||
|
const world = await db.worldState.create({
|
||||||
|
data: {
|
||||||
|
scenarioCode: 'inherit-owner-message',
|
||||||
|
currentYear: 200,
|
||||||
|
currentMonth: 4,
|
||||||
|
tickSeconds: 600,
|
||||||
|
config: { const: { inheritCheckOwnerPoint: 1_000 } },
|
||||||
|
meta: { isUnited: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
worldStateId = world.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } });
|
||||||
|
const outboxIds = outboxes.filter(({ payload }) => hasMailboxChange(payload)).map(({ id }) => id);
|
||||||
|
if (outboxIds.length > 0) {
|
||||||
|
await db.readModelOutbox.deleteMany({ where: { id: { in: outboxIds } } });
|
||||||
|
}
|
||||||
|
await db.inputEvent.deleteMany({ where: { actorUserId } });
|
||||||
|
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, checkedGeneralId] } } });
|
||||||
|
await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } });
|
||||||
|
await db.inheritancePoint.deleteMany({ where: { userId: actorUserId } });
|
||||||
|
await db.general.deleteMany({ where: { id: { in: [actorGeneralId, checkedGeneralId] } } });
|
||||||
|
await db.nation.deleteMany({ where: { id: { in: [actorNationId, checkedNationId] } } });
|
||||||
|
await db.readModelRevision.deleteMany({
|
||||||
|
where: { domain: 'messages.mailbox', entityId: { in: [actorGeneralId, checkedGeneralId] } },
|
||||||
|
});
|
||||||
|
await db.worldState.delete({ where: { id: worldStateId } });
|
||||||
|
await closeDb?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('commits the point charge, log, and both Ref-compatible private messages', async () => {
|
||||||
|
const requestId = 'integration:inherit-owner-message:success';
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(buildContext(requestId)).inherit.checkOwner({ targetGeneralId: checkedGeneralId })
|
||||||
|
).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
ownerName: '피확인 계정',
|
||||||
|
targetName: '피확인장수',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
db.inheritancePoint.findUniqueOrThrow({
|
||||||
|
where: { userId_key: { userId: actorUserId, key: 'previous' } },
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ value: 500 });
|
||||||
|
await expect(db.inheritanceLog.findMany({ where: { userId: actorUserId } })).resolves.toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
year: 200,
|
||||||
|
month: 4,
|
||||||
|
text: '1000 포인트로 장수 소유자 확인',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const messages = await db.message.findMany({
|
||||||
|
where: { mailbox: { in: [actorGeneralId, checkedGeneralId] } },
|
||||||
|
orderBy: { mailbox: 'asc' },
|
||||||
|
});
|
||||||
|
expect(messages).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
messages.map(({ mailbox, type, src, dest, message }) => ({ mailbox, type, src, dest, message }))
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
mailbox: actorGeneralId,
|
||||||
|
type: 'private',
|
||||||
|
src: 0,
|
||||||
|
dest: actorGeneralId,
|
||||||
|
message: expect.objectContaining({
|
||||||
|
src: expect.objectContaining({ generalId: 0, nationName: 'System' }),
|
||||||
|
dest: expect.objectContaining({ generalId: actorGeneralId, generalName: '확인장수' }),
|
||||||
|
text: '피확인장수의 소유자는 피확인 계정 입니다.',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mailbox: checkedGeneralId,
|
||||||
|
type: 'private',
|
||||||
|
src: 0,
|
||||||
|
dest: checkedGeneralId,
|
||||||
|
message: expect.objectContaining({
|
||||||
|
src: expect.objectContaining({ generalId: 0, nationName: 'System' }),
|
||||||
|
dest: expect.objectContaining({ generalId: checkedGeneralId, generalName: '피확인장수' }),
|
||||||
|
text: '소유자명이 누군가에 의해 확인되었습니다.',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
db.inputEvent.findUniqueOrThrow({ where: { requestId: `${requestId}:inherit.checkOwner` } })
|
||||||
|
).resolves.toMatchObject({ status: 'SUCCEEDED', actorUserId });
|
||||||
|
await expect(
|
||||||
|
db.readModelRevision.findMany({
|
||||||
|
where: { domain: 'messages.mailbox', entityId: { in: [actorGeneralId, checkedGeneralId] } },
|
||||||
|
orderBy: { entityId: 'asc' },
|
||||||
|
})
|
||||||
|
).resolves.toEqual([
|
||||||
|
expect.objectContaining({ domain: 'messages.mailbox', entityId: actorGeneralId, revision: 1n }),
|
||||||
|
expect.objectContaining({ domain: 'messages.mailbox', entityId: checkedGeneralId, revision: 1n }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ChangeJournal } from '@sammo-ts/common';
|
||||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
import type { RedisConnector } from '@sammo-ts/infra';
|
import type { RedisConnector } from '@sammo-ts/infra';
|
||||||
|
import type { MessagePayload } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||||
@@ -91,6 +93,14 @@ const worldState = {
|
|||||||
updatedAt: new Date('2026-07-26T00:00:00Z'),
|
updatedAt: new Date('2026-07-26T00:00:00Z'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface CapturedMessage {
|
||||||
|
mailbox: number;
|
||||||
|
type: string;
|
||||||
|
src: number;
|
||||||
|
dest: number;
|
||||||
|
payload: MessagePayload;
|
||||||
|
}
|
||||||
|
|
||||||
const buildContext = (options: {
|
const buildContext = (options: {
|
||||||
auth?: GameSessionTokenPayload | null;
|
auth?: GameSessionTokenPayload | null;
|
||||||
general?: GeneralRow | null;
|
general?: GeneralRow | null;
|
||||||
@@ -123,8 +133,30 @@ const buildContext = (options: {
|
|||||||
const: options.configConst,
|
const: options.configConst,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const messageRows: CapturedMessage[] = [];
|
||||||
|
const queryRaw = vi.fn(async (query: unknown, ...values: unknown[]) => {
|
||||||
|
const queryStrings = Array.isArray(query)
|
||||||
|
? query.map(String)
|
||||||
|
: ((query as { strings?: readonly string[] } | null)?.strings ?? []);
|
||||||
|
const sql = queryStrings.join(' ');
|
||||||
|
if (sql.includes('INSERT INTO message')) {
|
||||||
|
const payload = JSON.parse(String(values[8])) as MessagePayload;
|
||||||
|
messageRows.push({
|
||||||
|
mailbox: Number(values[0]),
|
||||||
|
type: String(values[1]),
|
||||||
|
src: Number(values[2]),
|
||||||
|
dest: Number(values[3]),
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
return [{ id: 100 + messageRows.length }];
|
||||||
|
}
|
||||||
|
if (sql.includes('FROM inheritance_point')) {
|
||||||
|
return [{ value: options.inheritancePoint ?? 10_000 }];
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected raw query in inherit router fixture: ${sql}`);
|
||||||
|
});
|
||||||
const db = {
|
const db = {
|
||||||
$queryRaw: vi.fn(async () => [{ value: options.inheritancePoint ?? 10_000 }]),
|
$queryRaw: queryRaw,
|
||||||
worldState: {
|
worldState: {
|
||||||
findFirst: vi.fn(async () => activeWorldState),
|
findFirst: vi.fn(async () => activeWorldState),
|
||||||
},
|
},
|
||||||
@@ -137,6 +169,11 @@ const buildContext = (options: {
|
|||||||
target?.id === where.id ? target : null
|
target?.id === where.id ? target : null
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
nation: {
|
||||||
|
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||||
|
where.id === 1 ? { id: 1, name: '촉', color: '#ff0000' } : null
|
||||||
|
),
|
||||||
|
},
|
||||||
inheritancePoint: {
|
inheritancePoint: {
|
||||||
upsert: pointUpsert,
|
upsert: pointUpsert,
|
||||||
},
|
},
|
||||||
@@ -156,8 +193,10 @@ const buildContext = (options: {
|
|||||||
},
|
},
|
||||||
'che:default'
|
'che:default'
|
||||||
);
|
);
|
||||||
|
const changeJournal = new ChangeJournal();
|
||||||
const context: GameApiContext = {
|
const context: GameApiContext = {
|
||||||
db: db as unknown as DatabaseClient,
|
db: db as unknown as DatabaseClient,
|
||||||
|
changeJournal,
|
||||||
redis: {} as RedisConnector['client'],
|
redis: {} as RedisConnector['client'],
|
||||||
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
|
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
|
||||||
battleSim: {} as GameApiContext['battleSim'],
|
battleSim: {} as GameApiContext['battleSim'],
|
||||||
@@ -170,7 +209,16 @@ const buildContext = (options: {
|
|||||||
flushStore: new InMemoryFlushStore(),
|
flushStore: new InMemoryFlushStore(),
|
||||||
gameTokenSecret: 'test-secret',
|
gameTokenSecret: 'test-secret',
|
||||||
};
|
};
|
||||||
return { context, requestCommand, pointUpsert, logCreate, findMany, inheritanceLogFindMany };
|
return {
|
||||||
|
context,
|
||||||
|
requestCommand,
|
||||||
|
pointUpsert,
|
||||||
|
logCreate,
|
||||||
|
findMany,
|
||||||
|
inheritanceLogFindMany,
|
||||||
|
messageRows,
|
||||||
|
changeJournal,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('inherit router actor and permission boundaries', () => {
|
describe('inherit router actor and permission boundaries', () => {
|
||||||
@@ -182,6 +230,10 @@ describe('inherit router actor and permission boundaries', () => {
|
|||||||
await expect(caller.inherit.buyHiddenBuff({ type: 'warAvoidRatio', level: 1 })).rejects.toMatchObject({
|
await expect(caller.inherit.buyHiddenBuff({ type: 'warAvoidRatio', level: 1 })).rejects.toMatchObject({
|
||||||
code: 'UNAUTHORIZED',
|
code: 'UNAUTHORIZED',
|
||||||
});
|
});
|
||||||
|
await expect(caller.inherit.checkOwner({ targetGeneralId: 8 })).rejects.toMatchObject({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
});
|
||||||
|
expect(fixture.messageRows).toHaveLength(0);
|
||||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -476,6 +528,68 @@ describe('inherit router actor and permission boundaries', () => {
|
|||||||
text: '1000 포인트로 장수 소유자 확인',
|
text: '1000 포인트로 장수 소유자 확인',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(fixture.messageRows).toHaveLength(2);
|
||||||
|
expect(fixture.messageRows).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
mailbox: 7,
|
||||||
|
type: 'private',
|
||||||
|
src: 0,
|
||||||
|
dest: 7,
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
src: expect.objectContaining({ generalId: 0, nationName: 'System' }),
|
||||||
|
dest: expect.objectContaining({ generalId: 7, generalName: '유비' }),
|
||||||
|
text: '조조의 소유자는 위유저 입니다.',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
mailbox: 8,
|
||||||
|
type: 'private',
|
||||||
|
src: 0,
|
||||||
|
dest: 8,
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
src: expect.objectContaining({ generalId: 0, nationName: 'System' }),
|
||||||
|
dest: expect.objectContaining({ generalId: 8, generalName: '조조' }),
|
||||||
|
text: '소유자명이 누군가에 의해 확인되었습니다.',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(fixture.changeJournal.snapshot()).toEqual([
|
||||||
|
{ domain: 'messages.mailbox', entityId: 7 },
|
||||||
|
{ domain: 'messages.mailbox', entityId: 8 },
|
||||||
|
]);
|
||||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not charge or send messages when the owner lookup target is the actor', async () => {
|
||||||
|
const fixture = buildContext({
|
||||||
|
inheritancePoint: 1_500,
|
||||||
|
target: buildGeneral({ id: 7, userId: 'user-1', name: '유비' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 7 })
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: '자신의 정보는 확인할 수 없습니다.',
|
||||||
|
});
|
||||||
|
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.messageRows).toHaveLength(0);
|
||||||
|
expect(fixture.changeJournal.snapshot()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not charge or send messages when inheritance points are insufficient', async () => {
|
||||||
|
const fixture = buildContext({ inheritancePoint: 999 });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 })
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: '유산 포인트가 부족합니다.',
|
||||||
|
});
|
||||||
|
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.messageRows).toHaveLength(0);
|
||||||
|
expect(fixture.changeJournal.snapshot()).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ writer reconciliation을 포함한다. rolling deployment가 끝난 뒤에만
|
|||||||
|
|
||||||
| 분류 | 수 | route |
|
| 분류 | 수 | route |
|
||||||
| --- | ---: | --- |
|
| --- | ---: | --- |
|
||||||
| durable journal | 22 | `betting.bet`; `messages.delete`, `messages.respond`, `messages.send`; `nation.setBill`, `nation.setBlockScout`, `nation.setBlockWar`, `nation.setNotice`, `nation.setRate`, `nation.setScoutMsg`, `nation.setSecretLimit`; `npc.setGeneralPriority`, `npc.setNationPolicy`, `npc.setNationPriority`; `turns.repeatGeneral`, `turns.setGeneral`, `turns.setGeneralBulk`, `turns.shiftGeneral`; `vote.closePoll`, `vote.createPoll`, `vote.submitVote`, `vote.updatePoll` |
|
| durable journal | 23 | `betting.bet`; `inherit.checkOwner`; `messages.delete`, `messages.respond`, `messages.send`; `nation.setBill`, `nation.setBlockScout`, `nation.setBlockWar`, `nation.setNotice`, `nation.setRate`, `nation.setScoutMsg`, `nation.setSecretLimit`; `npc.setGeneralPriority`, `npc.setNationPolicy`, `npc.setNationPriority`; `turns.repeatGeneral`, `turns.setGeneral`, `turns.setGeneralBulk`, `turns.shiftGeneral`; `vote.closePoll`, `vote.createPoll`, `vote.submitVote`, `vote.updatePoll` |
|
||||||
| separate access journal | 1 | `public.recordAccess` |
|
| separate access journal | 1 | `public.recordAccess` |
|
||||||
| explicit no realtime consumer | 15 | `board.writeArticle`, `board.writeComment`; `diplomacy.destroyLetter`, `diplomacy.respondLetter`, `diplomacy.rollbackLetter`, `diplomacy.sendLetter`; `inherit.checkOwner`; `join.getSelectionPool`, `join.listPossessCandidates`; `messages.readLatest`; `turns.repeatNation`, `turns.setNation`, `turns.setNationBulk`, `turns.shiftNation`; `vote.addComment` |
|
| explicit no realtime consumer | 14 | `board.writeArticle`, `board.writeComment`; `diplomacy.destroyLetter`, `diplomacy.respondLetter`, `diplomacy.rollbackLetter`, `diplomacy.sendLetter`; `join.getSelectionPool`, `join.listPossessCandidates`; `messages.readLatest`; `turns.repeatNation`, `turns.setNation`, `turns.setNationBulk`, `turns.shiftNation`; `vote.addComment` |
|
||||||
| engine owned | 27 | `auction.bidBuyRice`, `auction.bidSellRice`, `auction.bidUnique`, `auction.openBuyRice`, `auction.openSellRice`, `auction.openUnique`; `general.adjustIcon`, `general.buildNationCandidate`, `general.dieOnPrestart`, `general.dropItem`, `general.ensureDieOnPrestartStatus`, `general.instantRetreat`, `general.setMySetting`, `general.vacation`; `inherit.openUniqueAuction`; `join.createGeneral`, `join.possessGeneral`, `join.reselectPoolGeneral`, `join.selectPoolGeneral`; `nation.appoint`, `nation.changePermission`, `nation.kick`; `troop.create`, `troop.exit`, `troop.join`, `troop.kick`, `troop.rename` |
|
| engine owned | 27 | `auction.bidBuyRice`, `auction.bidSellRice`, `auction.bidUnique`, `auction.openBuyRice`, `auction.openSellRice`, `auction.openUnique`; `general.adjustIcon`, `general.buildNationCandidate`, `general.dieOnPrestart`, `general.dropItem`, `general.ensureDieOnPrestartStatus`, `general.instantRetreat`, `general.setMySetting`, `general.vacation`; `inherit.openUniqueAuction`; `join.createGeneral`, `join.possessGeneral`, `join.reselectPoolGeneral`, `join.selectPoolGeneral`; `nation.appoint`, `nation.changePermission`, `nation.kick`; `troop.create`, `troop.exit`, `troop.join`, `troop.kick`, `troop.rename` |
|
||||||
| mixed saga | 9 | `inherit.buyHiddenBuff`, `inherit.buyRandomUnique`, `inherit.resetSpecialWar`, `inherit.resetStat`, `inherit.resetTurnTime`, `inherit.setNextSpecialWar`; `tournament.cancel`, `tournament.join`, `tournament.placeBet` |
|
| mixed saga | 9 | `inherit.buyHiddenBuff`, `inherit.buyRandomUnique`, `inherit.resetSpecialWar`, `inherit.resetStat`, `inherit.resetTurnTime`, `inherit.setNextSpecialWar`; `tournament.cancel`, `tournament.join`, `tournament.placeBet` |
|
||||||
| Redis projection | 6 | `tournament.patchState`, `tournament.seedParticipants`, `tournament.setBettingEntries`, `tournament.setMatches`, `tournament.setParticipants`, `tournament.setState` |
|
| Redis projection | 6 | `tournament.patchState`, `tournament.seedParticipants`, `tournament.setBettingEntries`, `tournament.setMatches`, `tournament.setParticipants`, `tournament.setState` |
|
||||||
@@ -51,6 +51,7 @@ writer reconciliation을 포함한다. rolling deployment가 끝난 뒤에만
|
|||||||
| writer | durable key | public wake-up | 근거와 경계 |
|
| writer | durable key | public wake-up | 근거와 경계 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `betting.bet` | `general.content:<actor>`, `betting:0` | 없음 | 본인 베팅/유산 지출과 베팅 aggregate source가 바뀐다. `betting`은 현재 별도 화면 source이고 main dashboard fan-out을 만들지 않는다. |
|
| `betting.bet` | `general.content:<actor>`, `betting:0` | 없음 | 본인 베팅/유산 지출과 베팅 aggregate source가 바뀐다. `betting`은 현재 별도 화면 source이고 main dashboard fan-out을 만들지 않는다. |
|
||||||
|
| `inherit.checkOwner` | 확인자·확인 대상의 `messages.mailbox:<general>` | 두 장수 mailbox viewer에게 ID 없는 `messagesInvalidated` | Ref처럼 확인 결과와 피확인 알림을 시스템 개인 메시지로 저장하며 포인트 차감·유산 로그·두 메시지·journal을 한 API input-event transaction에서 commit한다. |
|
||||||
| `messages.send` | 생성된 수신/송신 복사본의 `messages.mailbox:<mailbox>` | 해당 mailbox viewer에게 ID 없는 `messagesInvalidated` | 기존 pre-commit Redis `messageCreated`를 제거했다. outbox publish 뒤에도 browser에는 mailbox/message/sender/time/revision이 노출되지 않는다. |
|
| `messages.send` | 생성된 수신/송신 복사본의 `messages.mailbox:<mailbox>` | 해당 mailbox viewer에게 ID 없는 `messagesInvalidated` | 기존 pre-commit Redis `messageCreated`를 제거했다. outbox publish 뒤에도 browser에는 mailbox/message/sender/time/revision이 노출되지 않는다. |
|
||||||
| `messages.delete` | 실제로 만료한 송신/수신 mailbox | 동일 | sender copy만 지우는 수동 외교 메시지는 그 mailbox만 표시한다. |
|
| `messages.delete` | 실제로 만료한 송신/수신 mailbox | 동일 | sender copy만 지우는 수동 외교 메시지는 그 mailbox만 표시한다. |
|
||||||
| `messages.respond` | 영향 mailbox, `records.general`, 실제 외교 변경 국가의 `nation.content`, front-state patch 도시의 `city.content`, 필요 시 `map.world`, transitive aggregate용 `dashboard.global` | mailbox boolean 및 해당 dashboard slice | 실패 로그도 commit되면 actor 개인 기록을 표시한다. 외교 수락이 실제 diplomacy/city/nation dependency를 바꿀 때만 broad source key를 표시한다. |
|
| `messages.respond` | 영향 mailbox, `records.general`, 실제 외교 변경 국가의 `nation.content`, front-state patch 도시의 `city.content`, 필요 시 `map.world`, transitive aggregate용 `dashboard.global` | mailbox boolean 및 해당 dashboard slice | 실패 로그도 commit되면 actor 개인 기록을 표시한다. 외교 수락이 실제 diplomacy/city/nation dependency를 바꿀 때만 broad source key를 표시한다. |
|
||||||
@@ -72,8 +73,8 @@ public dashboard event로 내보내지 않는다. browser wake-up은 정밀 enti
|
|||||||
전쟁/불가침 상태를 실제 변경하는 `messages.respond`와 구분한다.
|
전쟁/불가침 상태를 실제 변경하는 `messages.respond`와 구분한다.
|
||||||
- `messages.readLatest`는 본인의 읽음 cursor다. 요청한 tab이 이미 최신 cursor를 알고
|
- `messages.readLatest`는 본인의 읽음 cursor다. 요청한 tab이 이미 최신 cursor를 알고
|
||||||
있으므로 자기 자신에게 다시 wake-up을 보내지 않는다.
|
있으므로 자기 자신에게 다시 wake-up을 보내지 않는다.
|
||||||
- nation reserved turn, selection-pool reservation, possession 후보, inheritance owner
|
- nation reserved turn, selection-pool reservation과 possession 후보는 각각 전용
|
||||||
확인은 각각 전용 화면/request response가 최신 상태를 소유한다.
|
화면/request response가 최신 상태를 소유한다.
|
||||||
- image upload는 외부 content store write이며 game PostgreSQL read model이 아니다.
|
- image upload는 외부 content store write이며 game PostgreSQL read model이 아니다.
|
||||||
- battle simulation 준비와 서버 fallback은 호환상 mutation transport를 쓰지만
|
- battle simulation 준비와 서버 fallback은 호환상 mutation transport를 쓰지만
|
||||||
read-only 계산이며 input event transaction을 열지 않는다.
|
read-only 계산이며 input event transaction을 열지 않는다.
|
||||||
|
|||||||
Reference in New Issue
Block a user