fix(game-api): 장수 소유자 확인 메시지를 복구

Ref와 같이 확인자와 확인 대상에게 시스템 개인 메시지를 저장하고, 두 mailbox 변경을 durable journal에 기록한다. 실패 경계와 실제 PostgreSQL 저장 회귀 테스트를 포함한다.
This commit is contained in:
2026-08-21 05:45:51 +00:00
parent fb65400906
commit e241af0ba3
5 changed files with 393 additions and 14 deletions
+43 -7
View File
@@ -7,12 +7,12 @@ import {
ItemLoader,
isItemKey,
loadWarTraitModules,
sendMessage,
WarTraitLoader,
WAR_TRAIT_KEYS,
isWarTraitKey,
} from '@sammo-ts/logic';
import type { InheritBuffType } from '@sammo-ts/logic';
import type { ItemSlot } from '@sammo-ts/logic';
import type { InheritBuffType, ItemSlot, MessageDraft, MessageRecordDraft } from '@sammo-ts/logic';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import {
@@ -28,6 +28,9 @@ import {
} from '../../services/inheritance.js';
import type { GameApiContext, WorldStateRow } from '../../context.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[] = [
'warAvoidRatio',
@@ -881,11 +884,8 @@ export const inheritRouter = router({
}
const [general, target] = await Promise.all([
ctx.db.general.findFirst({ where: { userId }, select: { id: true } }),
ctx.db.general.findUnique({
where: { id: input.targetGeneralId },
select: { id: true, name: true, userId: true, meta: true },
}),
ctx.db.general.findFirst({ where: { userId } }),
ctx.db.general.findUnique({ where: { id: input.targetGeneralId } }),
]);
if (!general) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
@@ -909,6 +909,42 @@ export const inheritRouter = router({
worldState.currentMonth,
`${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 };
}),
});
@@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest';
const classifications = {
durableJournal: [
'betting.bet',
'inherit.checkOwner',
'messages.delete',
'messages.respond',
'messages.send',
@@ -36,7 +37,6 @@ const classifications = {
'diplomacy.respondLetter',
'diplomacy.rollbackLetter',
'diplomacy.sendLetter',
'inherit.checkOwner',
'join.getSelectionPool',
'join.listPossessCandidates',
'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 }),
]);
});
});
+116 -2
View File
@@ -1,7 +1,9 @@
import { describe, expect, it, vi } from 'vitest';
import { ChangeJournal } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import type { MessagePayload } from '@sammo-ts/logic';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
@@ -91,6 +93,14 @@ const worldState = {
updatedAt: new Date('2026-07-26T00:00:00Z'),
};
interface CapturedMessage {
mailbox: number;
type: string;
src: number;
dest: number;
payload: MessagePayload;
}
const buildContext = (options: {
auth?: GameSessionTokenPayload | null;
general?: GeneralRow | null;
@@ -123,8 +133,30 @@ const buildContext = (options: {
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 = {
$queryRaw: vi.fn(async () => [{ value: options.inheritancePoint ?? 10_000 }]),
$queryRaw: queryRaw,
worldState: {
findFirst: vi.fn(async () => activeWorldState),
},
@@ -137,6 +169,11 @@ const buildContext = (options: {
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: {
upsert: pointUpsert,
},
@@ -156,8 +193,10 @@ const buildContext = (options: {
},
'che:default'
);
const changeJournal = new ChangeJournal();
const context: GameApiContext = {
db: db as unknown as DatabaseClient,
changeJournal,
redis: {} as RedisConnector['client'],
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
battleSim: {} as GameApiContext['battleSim'],
@@ -170,7 +209,16 @@ const buildContext = (options: {
flushStore: new InMemoryFlushStore(),
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', () => {
@@ -182,6 +230,10 @@ describe('inherit router actor and permission boundaries', () => {
await expect(caller.inherit.buyHiddenBuff({ type: 'warAvoidRatio', level: 1 })).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
await expect(caller.inherit.checkOwner({ targetGeneralId: 8 })).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
expect(fixture.messageRows).toHaveLength(0);
expect(fixture.requestCommand).not.toHaveBeenCalled();
});
@@ -476,6 +528,68 @@ describe('inherit router actor and permission boundaries', () => {
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();
});
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 |
| --- | ---: | --- |
| 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` |
| 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` |
| 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` |
@@ -51,6 +51,7 @@ writer reconciliation을 포함한다. rolling deployment가 끝난 뒤에만
| writer | durable key | public wake-up | 근거와 경계 |
| --- | --- | --- | --- |
| `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.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를 표시한다. |
@@ -72,8 +73,8 @@ public dashboard event로 내보내지 않는다. browser wake-up은 정밀 enti
전쟁/불가침 상태를 실제 변경하는 `messages.respond`와 구분한다.
- `messages.readLatest`는 본인의 읽음 cursor다. 요청한 tab이 이미 최신 cursor를 알고
있으므로 자기 자신에게 다시 wake-up을 보내지 않는다.
- nation reserved turn, selection-pool reservation, possession 후보, inheritance owner
확인은 각각 전용 화면/request response가 최신 상태를 소유한다.
- nation reserved turn, selection-pool reservation possession 후보는 각각 전용
화면/request response가 최신 상태를 소유한다.
- image upload는 외부 content store write이며 game PostgreSQL read model이 아니다.
- battle simulation 준비와 서버 fallback은 호환상 mutation transport를 쓰지만
read-only 계산이며 input event transaction을 열지 않는다.