외교 메시지 응답과 알림 권한 정리
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import {
|
import {
|
||||||
|
readModelOutboxPayloadToDiplomacyMailboxes,
|
||||||
readModelOutboxPayloadToChanges,
|
readModelOutboxPayloadToChanges,
|
||||||
readModelOutboxPayloadToMessageMailboxes,
|
readModelOutboxPayloadToMessageMailboxes,
|
||||||
type ReadModelDomain,
|
type ReadModelDomain,
|
||||||
@@ -20,6 +21,7 @@ const NON_DASHBOARD_DOMAINS: ReadonlySet<ReadModelDomain> = new Set([
|
|||||||
'access.general',
|
'access.general',
|
||||||
'dashboard.global',
|
'dashboard.global',
|
||||||
'messages.mailbox',
|
'messages.mailbox',
|
||||||
|
'messages.diplomacyMailbox',
|
||||||
'tournament',
|
'tournament',
|
||||||
'betting',
|
'betting',
|
||||||
]);
|
]);
|
||||||
@@ -86,8 +88,9 @@ export class ReadModelOutboxWorker implements ReadModelOutboxWakeup {
|
|||||||
this.db,
|
this.db,
|
||||||
async (payload) => {
|
async (payload) => {
|
||||||
const mailboxes = readModelOutboxPayloadToMessageMailboxes(payload);
|
const mailboxes = readModelOutboxPayloadToMessageMailboxes(payload);
|
||||||
if (mailboxes.length > 0) {
|
const diplomacyMailboxes = readModelOutboxPayloadToDiplomacyMailboxes(payload);
|
||||||
await publishRealtimeMessageChanges(this.redis, this.profileName, mailboxes);
|
if (mailboxes.length > 0 || diplomacyMailboxes.length > 0) {
|
||||||
|
await publishRealtimeMessageChanges(this.redis, this.profileName, mailboxes, diplomacyMailboxes);
|
||||||
}
|
}
|
||||||
if (payload.changes.some(([domain]) => !NON_DASHBOARD_DOMAINS.has(domain))) {
|
if (payload.changes.some(([domain]) => !NON_DASHBOARD_DOMAINS.has(domain))) {
|
||||||
const changes = readModelOutboxPayloadToChanges(payload);
|
const changes = readModelOutboxPayloadToChanges(payload);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC } from '@sammo-ts
|
|||||||
const uniqueIdentities = (identities: readonly RealtimeViewerIdentity[]): RealtimeViewerIdentity[] => {
|
const uniqueIdentities = (identities: readonly RealtimeViewerIdentity[]): RealtimeViewerIdentity[] => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return identities.filter((identity) => {
|
return identities.filter((identity) => {
|
||||||
const key = `${identity.generalId ?? ''}:${identity.cityId ?? ''}:${identity.nationId ?? ''}`;
|
const key = `${identity.generalId ?? ''}:${identity.cityId ?? ''}:${identity.nationId ?? ''}:${identity.canReadDiplomacy}`;
|
||||||
if (seen.has(key)) return false;
|
if (seen.has(key)) return false;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
return true;
|
return true;
|
||||||
@@ -44,6 +44,7 @@ export const shouldReloadRealtimeViewerIdentity = (event: RealtimeEvent, identit
|
|||||||
const changes = eventChanges(event);
|
const changes = eventChanges(event);
|
||||||
if (!changes) return false;
|
if (!changes) return false;
|
||||||
const generalId = identity.generalId;
|
const generalId = identity.generalId;
|
||||||
|
if (identity.nationId !== null && changes.nationIds.includes(identity.nationId)) return true;
|
||||||
return [
|
return [
|
||||||
changes.generalIds,
|
changes.generalIds,
|
||||||
changes.mapGeneralIds ?? changes.generalIds,
|
changes.mapGeneralIds ?? changes.generalIds,
|
||||||
@@ -69,8 +70,20 @@ export const toPublicRealtimeEvent = (
|
|||||||
identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }]
|
identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }]
|
||||||
);
|
);
|
||||||
if (event.type === 'messageCreated' || event.type === 'messagesChanged') {
|
if (event.type === 'messageCreated' || event.type === 'messagesChanged') {
|
||||||
const mailboxes = event.type === 'messageCreated' ? [event.mailbox] : event.mailboxes;
|
const mailboxes =
|
||||||
return viewers.some((identity) => mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)))
|
event.type === 'messageCreated' ? (event.msgType === 'diplomacy' ? [] : [event.mailbox]) : event.mailboxes;
|
||||||
|
const diplomacyMailboxes =
|
||||||
|
event.type === 'messageCreated'
|
||||||
|
? event.msgType === 'diplomacy'
|
||||||
|
? [event.mailbox]
|
||||||
|
: []
|
||||||
|
: (event.diplomacyMailboxes ?? []);
|
||||||
|
return viewers.some(
|
||||||
|
(identity) =>
|
||||||
|
mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)) ||
|
||||||
|
(identity.canReadDiplomacy &&
|
||||||
|
diplomacyMailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)))
|
||||||
|
)
|
||||||
? { type: 'messagesInvalidated', refreshGrant: createRefreshGrant() }
|
? { type: 'messagesInvalidated', refreshGrant: createRefreshGrant() }
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,11 +34,15 @@ export const publishRealtimeReadModelChanges = async (
|
|||||||
export const publishRealtimeMessageChanges = async (
|
export const publishRealtimeMessageChanges = async (
|
||||||
redis: RedisConnector['client'],
|
redis: RedisConnector['client'],
|
||||||
profileName: string,
|
profileName: string,
|
||||||
mailboxes: readonly number[]
|
mailboxes: readonly number[],
|
||||||
|
diplomacyMailboxes: readonly number[] = []
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (mailboxes.length === 0) return;
|
if (mailboxes.length === 0 && diplomacyMailboxes.length === 0) return;
|
||||||
await publishRealtimeEvent(redis, profileName, {
|
await publishRealtimeEvent(redis, profileName, {
|
||||||
type: 'messagesChanged',
|
type: 'messagesChanged',
|
||||||
mailboxes: [...new Set(mailboxes)].sort((left, right) => left - right),
|
mailboxes: [...new Set(mailboxes)].sort((left, right) => left - right),
|
||||||
|
...(diplomacyMailboxes.length > 0
|
||||||
|
? { diplomacyMailboxes: [...new Set(diplomacyMailboxes)].sort((left, right) => left - right) }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -86,8 +86,9 @@ const sendDocumentNotice = async (options: {
|
|||||||
await sendMessage(store, { ...draft, msgType: 'national' });
|
await sendMessage(store, { ...draft, msgType: 'national' });
|
||||||
}
|
}
|
||||||
|
|
||||||
options.ctx.changeJournal?.mark('messages.mailbox', MESSAGE_MAILBOX_NATIONAL_BASE + options.src.nationId);
|
const messageDomain = options.includeNational ? 'messages.mailbox' : 'messages.diplomacyMailbox';
|
||||||
options.ctx.changeJournal?.mark('messages.mailbox', MESSAGE_MAILBOX_NATIONAL_BASE + options.dest.nationId);
|
options.ctx.changeJournal?.mark(messageDomain, MESSAGE_MAILBOX_NATIONAL_BASE + options.src.nationId);
|
||||||
|
options.ctx.changeJournal?.mark(messageDomain, MESSAGE_MAILBOX_NATIONAL_BASE + options.dest.nationId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => {
|
const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => {
|
||||||
|
|||||||
@@ -76,9 +76,14 @@ const hasPenalty = (penalty: unknown, key: string): boolean => {
|
|||||||
return value === true || value === 1 || value === '1';
|
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>,
|
||||||
|
msgType?: MessageType
|
||||||
|
): void => {
|
||||||
|
const domain = msgType === 'diplomacy' ? 'messages.diplomacyMailbox' : 'messages.mailbox';
|
||||||
for (const mailbox of mailboxes) {
|
for (const mailbox of mailboxes) {
|
||||||
ctx.changeJournal?.mark('messages.mailbox', mailbox);
|
ctx.changeJournal?.mark(domain, mailbox);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -315,7 +320,11 @@ export const messagesRouter = router({
|
|||||||
message.msgType === 'national'
|
message.msgType === 'national'
|
||||||
? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId
|
? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId
|
||||||
: null;
|
: null;
|
||||||
markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]);
|
markMessageMailboxes(
|
||||||
|
ctx,
|
||||||
|
[message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])],
|
||||||
|
message.msgType
|
||||||
|
);
|
||||||
return { ok: true, deletedIds };
|
return { ok: true, deletedIds };
|
||||||
}),
|
}),
|
||||||
respond: engineAuthedProcedure
|
respond: engineAuthedProcedure
|
||||||
@@ -333,6 +342,9 @@ export const messagesRouter = router({
|
|||||||
throw new TRPCError({ code: 'NOT_FOUND', message: '메시지가 없습니다.' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: '메시지가 없습니다.' });
|
||||||
}
|
}
|
||||||
const action = message.payload.option?.action;
|
const action = message.payload.option?.action;
|
||||||
|
if (message.payload.option?.invalid === true) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제된 메시지에는 응답할 수 없습니다.' });
|
||||||
|
}
|
||||||
if (action === 'scout' || action === 'raiseInvader') {
|
if (action === 'scout' || action === 'raiseInvader') {
|
||||||
if (!ctx.auth) {
|
if (!ctx.auth) {
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
@@ -620,7 +632,7 @@ export const messagesRouter = router({
|
|||||||
: msgType === 'private'
|
: msgType === 'private'
|
||||||
? general.id
|
? general.id
|
||||||
: MESSAGE_MAILBOX_NATIONAL_BASE + general.nationId;
|
: MESSAGE_MAILBOX_NATIONAL_BASE + general.nationId;
|
||||||
markMessageMailboxes(ctx, [receiverMailbox, ...(senderMailbox === null ? [] : [senderMailbox])]);
|
markMessageMailboxes(ctx, [receiverMailbox, ...(senderMailbox === null ? [] : [senderMailbox])], msgType);
|
||||||
|
|
||||||
return { msgType, msgId: result.receiverId };
|
return { msgType, msgId: result.receiverId };
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
shouldReloadRealtimeViewerIdentity,
|
shouldReloadRealtimeViewerIdentity,
|
||||||
toPublicRealtimeEvent,
|
toPublicRealtimeEvent,
|
||||||
} from './realtime/publicEvent.js';
|
} from './realtime/publicEvent.js';
|
||||||
|
import { resolveNationPermission } from './router/nation/shared.js';
|
||||||
import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js';
|
import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js';
|
||||||
import { GatewayHttpProfileStatusSource } from './auth/profileStatusSource.js';
|
import { GatewayHttpProfileStatusSource } from './auth/profileStatusSource.js';
|
||||||
import { CachedTurnEngineStatus } from './services/turnEngineStatus.js';
|
import { CachedTurnEngineStatus } from './services/turnEngineStatus.js';
|
||||||
@@ -313,11 +314,24 @@ export const createGameApiServer = async () => {
|
|||||||
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
|
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
|
||||||
const general = await postgres.prisma.general.findFirst({
|
const general = await postgres.prisma.general.findFirst({
|
||||||
where: { userId: auth.user.id, npcState: 0 },
|
where: { userId: auth.user.id, npcState: 0 },
|
||||||
select: { id: true, cityId: true, nationId: true },
|
select: { id: true, cityId: true, nationId: true, officerLevel: true, meta: true, penalty: true },
|
||||||
});
|
});
|
||||||
return general
|
if (!general) {
|
||||||
? { generalId: general.id, cityId: general.cityId, nationId: general.nationId }
|
return { generalId: null, cityId: null, nationId: null, canReadDiplomacy: false };
|
||||||
: { generalId: null, cityId: null, nationId: null };
|
}
|
||||||
|
const nation =
|
||||||
|
general.nationId > 0
|
||||||
|
? await postgres.prisma.nation.findUnique({
|
||||||
|
where: { id: general.nationId },
|
||||||
|
select: { meta: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
generalId: general.id,
|
||||||
|
cityId: general.cityId,
|
||||||
|
nationId: general.nationId,
|
||||||
|
canReadDiplomacy: Boolean(nation && resolveNationPermission(general, nation.meta, false) >= 3),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
let viewerIdentity = await loadViewerIdentity();
|
let viewerIdentity = await loadViewerIdentity();
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const isFixtureOutboxPayload = (payload: unknown): boolean => {
|
|||||||
changes.some(
|
changes.some(
|
||||||
(change) =>
|
(change) =>
|
||||||
Array.isArray(change) &&
|
Array.isArray(change) &&
|
||||||
change[0] === 'messages.mailbox' &&
|
(change[0] === 'messages.mailbox' || change[0] === 'messages.diplomacyMailbox') &&
|
||||||
fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number])
|
fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number])
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -94,7 +94,10 @@ integration('diplomacy document message persistence', () => {
|
|||||||
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
|
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
|
||||||
await deleteFixtureOutboxes();
|
await deleteFixtureOutboxes();
|
||||||
await db.readModelRevision.deleteMany({
|
await db.readModelRevision.deleteMany({
|
||||||
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } },
|
where: {
|
||||||
|
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
|
||||||
|
entityId: { in: [...fixtureMailboxes] },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -575,7 +578,10 @@ integration('diplomacy document message persistence', () => {
|
|||||||
await expect(db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).resolves.toBe(0);
|
await expect(db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).resolves.toBe(0);
|
||||||
await expect(
|
await expect(
|
||||||
db.readModelRevision.count({
|
db.readModelRevision.count({
|
||||||
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } },
|
where: {
|
||||||
|
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
|
||||||
|
entityId: { in: [...fixtureMailboxes] },
|
||||||
|
},
|
||||||
})
|
})
|
||||||
).resolves.toBe(0);
|
).resolves.toBe(0);
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ const isFixtureOutboxPayload = (payload: unknown): boolean => {
|
|||||||
changes.some(
|
changes.some(
|
||||||
(change) =>
|
(change) =>
|
||||||
Array.isArray(change) &&
|
Array.isArray(change) &&
|
||||||
change[0] === 'messages.mailbox' &&
|
(change[0] === 'messages.mailbox' || change[0] === 'messages.diplomacyMailbox') &&
|
||||||
fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number])
|
fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number])
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -101,7 +101,10 @@ const cleanup = async (): Promise<void> => {
|
|||||||
});
|
});
|
||||||
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
|
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
|
||||||
await db.readModelRevision.deleteMany({
|
await db.readModelRevision.deleteMany({
|
||||||
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } },
|
where: {
|
||||||
|
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
|
||||||
|
entityId: { in: [...fixtureMailboxes] },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } });
|
const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } });
|
||||||
const outboxIds = outboxes.filter(({ payload }) => isFixtureOutboxPayload(payload)).map(({ id }) => id);
|
const outboxIds = outboxes.filter(({ payload }) => isFixtureOutboxPayload(payload)).map(({ id }) => id);
|
||||||
|
|||||||
@@ -368,8 +368,8 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
expect(result.msgType).toBe('diplomacy');
|
expect(result.msgType).toBe('diplomacy');
|
||||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
expect(queryRaw).toHaveBeenCalledTimes(2);
|
||||||
expect(changeJournal.snapshot()).toEqual([
|
expect(changeJournal.snapshot()).toEqual([
|
||||||
{ domain: 'messages.mailbox', entityId: 9000 },
|
{ domain: 'messages.diplomacyMailbox', entityId: 9000 },
|
||||||
{ domain: 'messages.mailbox', entityId: 9001 },
|
{ domain: 'messages.diplomacyMailbox', entityId: 9001 },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -837,6 +837,7 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
proposerCurrentNationId?: number;
|
proposerCurrentNationId?: number;
|
||||||
proposerNationMeta?: Record<string, unknown>;
|
proposerNationMeta?: Record<string, unknown>;
|
||||||
diplomacyState?: number;
|
diplomacyState?: number;
|
||||||
|
invalid?: boolean;
|
||||||
response?: boolean;
|
response?: boolean;
|
||||||
cities?: Array<{ id: number; nationId: number; frontState: number }>;
|
cities?: Array<{ id: number; nationId: number; frontState: number }>;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -891,6 +892,7 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
text: '외교 제안',
|
text: '외교 제안',
|
||||||
option: {
|
option: {
|
||||||
action,
|
action,
|
||||||
|
...(options?.invalid ? { invalid: true } : {}),
|
||||||
...(action === 'noAggression' ? { year: 201, month: 2 } : {}),
|
...(action === 'noAggression' ? { year: 201, month: 2 } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1075,6 +1077,21 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
it('rejects a response to a tombstoned diplomatic prompt before any state mutation', async () => {
|
||||||
|
const setup = buildDiplomaticContext({ invalid: true });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
setup.caller.messages.respond({
|
||||||
|
generalId: setup.actor.id,
|
||||||
|
messageId: 31,
|
||||||
|
response: true,
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '삭제된 메시지에는 응답할 수 없습니다.' });
|
||||||
|
expect(setup.diplomacyUpdate).not.toHaveBeenCalled();
|
||||||
|
expect(setup.messageUpdateMany).not.toHaveBeenCalled();
|
||||||
|
expect(setup.requestCommand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts a diplomatic prompt atomically and applies the legacy non-aggression effects', async () => {
|
it('accepts a diplomatic prompt atomically and applies the legacy non-aggression effects', async () => {
|
||||||
const setup = buildDiplomaticContext();
|
const setup = buildDiplomaticContext();
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
toPublicRealtimeEvent as convertPublicRealtimeEvent,
|
toPublicRealtimeEvent as convertPublicRealtimeEvent,
|
||||||
} from '../src/realtime/publicEvent.js';
|
} from '../src/realtime/publicEvent.js';
|
||||||
|
|
||||||
const viewer = { generalId: 7, cityId: 3, nationId: 2 } as const;
|
const viewer = { generalId: 7, cityId: 3, nationId: 2, canReadDiplomacy: true } as const;
|
||||||
const refreshGrant = 'opaque-grant';
|
const refreshGrant = 'opaque-grant';
|
||||||
const toPublicRealtimeEvent = (event: RealtimeEvent, identities: Parameters<typeof convertPublicRealtimeEvent>[1]) =>
|
const toPublicRealtimeEvent = (event: RealtimeEvent, identities: Parameters<typeof convertPublicRealtimeEvent>[1]) =>
|
||||||
convertPublicRealtimeEvent(event, identities, () => refreshGrant);
|
convertPublicRealtimeEvent(event, identities, () => refreshGrant);
|
||||||
@@ -198,6 +198,37 @@ describe('public realtime event privacy boundary', () => {
|
|||||||
expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull();
|
expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('suppresses diplomacy-only wake-ups for viewers without secret-message access', () => {
|
||||||
|
const mailbox = MESSAGE_MAILBOX_NATIONAL_BASE + viewer.nationId;
|
||||||
|
const blockedViewer = { ...viewer, canReadDiplomacy: false };
|
||||||
|
expect(
|
||||||
|
toPublicRealtimeEvent(
|
||||||
|
{
|
||||||
|
type: 'messageCreated',
|
||||||
|
at: '2026-09-04T00:00:00Z',
|
||||||
|
mailbox,
|
||||||
|
msgType: 'diplomacy',
|
||||||
|
messageId: 1,
|
||||||
|
senderId: 2,
|
||||||
|
},
|
||||||
|
[blockedViewer]
|
||||||
|
)
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [mailbox] }, [
|
||||||
|
blockedViewer,
|
||||||
|
])
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [mailbox], diplomacyMailboxes: [mailbox] }, [
|
||||||
|
blockedViewer,
|
||||||
|
])
|
||||||
|
).toEqual({ type: 'messagesInvalidated', refreshGrant });
|
||||||
|
expect(
|
||||||
|
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [mailbox] }, [viewer])
|
||||||
|
).toEqual({ type: 'messagesInvalidated', refreshGrant });
|
||||||
|
});
|
||||||
|
|
||||||
it('redacts durable mailbox wake-ups to one viewer-safe boolean event', () => {
|
it('redacts durable mailbox wake-ups to one viewer-safe boolean event', () => {
|
||||||
const event: RealtimeEvent = {
|
const event: RealtimeEvent = {
|
||||||
type: 'messagesChanged',
|
type: 'messagesChanged',
|
||||||
@@ -225,6 +256,12 @@ describe('public realtime event privacy boundary', () => {
|
|||||||
viewer
|
viewer
|
||||||
)
|
)
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldReloadRealtimeViewerIdentity(
|
||||||
|
turnEvent({ ...createEmptyRealtimeReadModelChanges(), nationIds: [viewer.nationId] }),
|
||||||
|
viewer
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('merges previous and committed identities across an ownership transition', () => {
|
it('merges previous and committed identities across an ownership transition', () => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const payload = (
|
|||||||
| 'access.general'
|
| 'access.general'
|
||||||
| 'dashboard.global'
|
| 'dashboard.global'
|
||||||
| 'messages.mailbox'
|
| 'messages.mailbox'
|
||||||
|
| 'messages.diplomacyMailbox'
|
||||||
| 'tournament'
|
| 'tournament'
|
||||||
| 'betting'
|
| 'betting'
|
||||||
) => ({
|
) => ({
|
||||||
@@ -19,7 +20,11 @@ const payload = (
|
|||||||
changes: [
|
changes: [
|
||||||
[
|
[
|
||||||
domain,
|
domain,
|
||||||
domain === 'front.general' || domain === 'access.general' ? 7 : domain === 'messages.mailbox' ? 9999 : 0,
|
domain === 'front.general' || domain === 'access.general'
|
||||||
|
? 7
|
||||||
|
: domain === 'messages.mailbox' || domain === 'messages.diplomacyMailbox'
|
||||||
|
? 9999
|
||||||
|
: 0,
|
||||||
'1',
|
'1',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -114,6 +119,25 @@ describe('ReadModelOutboxWorker', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('labels diplomacy-only mailbox wake-ups for viewer permission filtering', async () => {
|
||||||
|
const fixture = createFixture([{ id: 15n, payload: payload('messages.diplomacyMailbox'), attempts: 1 }]);
|
||||||
|
const worker = new ReadModelOutboxWorker(fixture.db, fixture.redis, 'che:default', {
|
||||||
|
owner: 'worker-test',
|
||||||
|
intervalMs: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.start();
|
||||||
|
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
|
||||||
|
await worker.stop();
|
||||||
|
|
||||||
|
expect(fixture.incr).not.toHaveBeenCalled();
|
||||||
|
expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toEqual({
|
||||||
|
type: 'messagesChanged',
|
||||||
|
mailboxes: [],
|
||||||
|
diplomacyMailboxes: [9999],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('coalesces repeated wakeups into one trailing batch and waits for it on shutdown', async () => {
|
it('coalesces repeated wakeups into one trailing batch and waits for it on shutdown', async () => {
|
||||||
let releaseFirst: (() => void) | undefined;
|
let releaseFirst: (() => void) | undefined;
|
||||||
const first = new Promise<readonly object[]>((resolve) => {
|
const first = new Promise<readonly object[]>((resolve) => {
|
||||||
|
|||||||
@@ -1852,6 +1852,7 @@ export const createDatabaseTurnHooks = async (
|
|||||||
await persistYearbookSnapshot(prisma, snapshot);
|
await persistYearbookSnapshot(prisma, snapshot);
|
||||||
}
|
}
|
||||||
const persistedMessageMailboxes: number[] = [];
|
const persistedMessageMailboxes: number[] = [];
|
||||||
|
const persistedDiplomacyMailboxes: number[] = [];
|
||||||
for (const finalization of pendingUnificationFinalizations) {
|
for (const finalization of pendingUnificationFinalizations) {
|
||||||
if (options?.profileName && finalization.profileName !== options.profileName) {
|
if (options?.profileName && finalization.profileName !== options.profileName) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -1878,7 +1879,11 @@ export const createDatabaseTurnHooks = async (
|
|||||||
expiresGameTick,
|
expiresGameTick,
|
||||||
});
|
});
|
||||||
await enqueuePrivateMessageWebPush(prisma, draft, id);
|
await enqueuePrivateMessageWebPush(prisma, draft, id);
|
||||||
persistedMessageMailboxes.push(draft.mailbox);
|
if (draft.msgType === 'diplomacy') {
|
||||||
|
persistedDiplomacyMailboxes.push(draft.mailbox);
|
||||||
|
} else {
|
||||||
|
persistedMessageMailboxes.push(draft.mailbox);
|
||||||
|
}
|
||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1968,6 +1973,7 @@ export const createDatabaseTurnHooks = async (
|
|||||||
journal.mark('dashboard.global');
|
journal.mark('dashboard.global');
|
||||||
}
|
}
|
||||||
markIds(journal, 'messages.mailbox', uniqueSortedIds(persistedMessageMailboxes));
|
markIds(journal, 'messages.mailbox', uniqueSortedIds(persistedMessageMailboxes));
|
||||||
|
markIds(journal, 'messages.diplomacyMailbox', uniqueSortedIds(persistedDiplomacyMailboxes));
|
||||||
markIds(journal, 'access.general', accessScoreResetGeneralIds);
|
markIds(journal, 'access.general', accessScoreResetGeneralIds);
|
||||||
if (pendingNationBettingOpens.length > 0 || pendingNationBettingFinishes.length > 0) {
|
if (pendingNationBettingOpens.length > 0 || pendingNationBettingFinishes.length > 0) {
|
||||||
journal.mark('betting');
|
journal.mark('betting');
|
||||||
|
|||||||
@@ -266,9 +266,9 @@ onBeforeUnmount(() => {
|
|||||||
{{ invalid ? '삭제된 메시지입니다' : message.text }}
|
{{ invalid ? '삭제된 메시지입니다' : message.text }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="hasAction" class="message-response">
|
<div v-if="hasAction && !invalid" class="message-response">
|
||||||
<button
|
<button
|
||||||
class="prompt-yes"
|
class="prompt-yes legacy-button legacy-button--primary"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="message.msgType === 'diplomacy' && !canRespondDiplomacy"
|
:disabled="message.msgType === 'diplomacy' && !canRespondDiplomacy"
|
||||||
@click="respond(true)"
|
@click="respond(true)"
|
||||||
@@ -276,7 +276,7 @@ onBeforeUnmount(() => {
|
|||||||
수락
|
수락
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="prompt-no"
|
class="prompt-no legacy-button legacy-button--danger"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="message.msgType === 'diplomacy' && !canRespondDiplomacy"
|
:disabled="message.msgType === 'diplomacy' && !canRespondDiplomacy"
|
||||||
@click="respond(false)"
|
@click="respond(false)"
|
||||||
@@ -414,17 +414,14 @@ button.msg-target {
|
|||||||
.message-response {
|
.message-response {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 0;
|
gap: 4px;
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-response button {
|
.message-response .legacy-button {
|
||||||
min-width: 42px;
|
min-width: 42px;
|
||||||
border: 1px outset buttonborder;
|
padding: 2px 8px;
|
||||||
background: buttonface;
|
|
||||||
padding: 1px 6px;
|
|
||||||
color: buttontext;
|
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const READ_MODEL_DOMAINS = [
|
|||||||
'contacts.world',
|
'contacts.world',
|
||||||
'reserved.general',
|
'reserved.general',
|
||||||
'messages.mailbox',
|
'messages.mailbox',
|
||||||
|
'messages.diplomacyMailbox',
|
||||||
'tournament',
|
'tournament',
|
||||||
'betting',
|
'betting',
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ export const readModelOutboxPayloadToMessageMailboxes = (
|
|||||||
payload.changes.flatMap(([domain, entityId]) => (domain === 'messages.mailbox' ? [entityId] : []))
|
payload.changes.flatMap(([domain, entityId]) => (domain === 'messages.mailbox' ? [entityId] : []))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const readModelOutboxPayloadToDiplomacyMailboxes = (payload: ReadModelOutboxPayloadV1): readonly number[] =>
|
||||||
|
uniqueSortedIds(
|
||||||
|
payload.changes.flatMap(([domain, entityId]) => (domain === 'messages.diplomacyMailbox' ? [entityId] : []))
|
||||||
|
);
|
||||||
|
|
||||||
export const parseReadModelOutboxPayload = (value: unknown): ReadModelOutboxPayloadV1 | null => {
|
export const parseReadModelOutboxPayload = (value: unknown): ReadModelOutboxPayloadV1 | null => {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -123,6 +128,7 @@ export const readModelOutboxPayloadToChanges = (
|
|||||||
break;
|
break;
|
||||||
case 'access.general':
|
case 'access.general':
|
||||||
case 'messages.mailbox':
|
case 'messages.mailbox':
|
||||||
|
case 'messages.diplomacyMailbox':
|
||||||
case 'tournament':
|
case 'tournament':
|
||||||
case 'betting':
|
case 'betting':
|
||||||
// These domains have no browser-wide dashboard invalidation.
|
// These domains have no browser-wide dashboard invalidation.
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export interface RealtimeViewerIdentity {
|
|||||||
generalId: number | null;
|
generalId: number | null;
|
||||||
cityId: number | null;
|
cityId: number | null;
|
||||||
nationId: number | null;
|
nationId: number | null;
|
||||||
|
/** Omitted by older internal producers; absence is treated as no access. */
|
||||||
|
canReadDiplomacy?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createEmptyRealtimeReadModelInvalidation = (): RealtimeReadModelInvalidation => ({
|
export const createEmptyRealtimeReadModelInvalidation = (): RealtimeReadModelInvalidation => ({
|
||||||
@@ -216,6 +218,8 @@ export interface MessageCreatedEvent {
|
|||||||
export interface MessagesChangedEvent {
|
export interface MessagesChangedEvent {
|
||||||
type: 'messagesChanged';
|
type: 'messagesChanged';
|
||||||
mailboxes: number[];
|
mailboxes: number[];
|
||||||
|
/** Nation mailboxes whose committed changes contain only diplomacy messages. */
|
||||||
|
diplomacyMailboxes?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Redis-owned tournament stage changed after its atomic source revision commit. */
|
/** Redis-owned tournament stage changed after its atomic source revision commit. */
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ describe('ChangeJournal', () => {
|
|||||||
'contacts.world',
|
'contacts.world',
|
||||||
'reserved.general',
|
'reserved.general',
|
||||||
'messages.mailbox',
|
'messages.mailbox',
|
||||||
|
'messages.diplomacyMailbox',
|
||||||
'dashboard.global',
|
'dashboard.global',
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
hasRealtimeReadModelChanges,
|
hasRealtimeReadModelChanges,
|
||||||
parseReadModelOutboxPayload,
|
parseReadModelOutboxPayload,
|
||||||
|
readModelOutboxPayloadToDiplomacyMailboxes,
|
||||||
readModelOutboxPayloadToMessageMailboxes,
|
readModelOutboxPayloadToMessageMailboxes,
|
||||||
readModelOutboxPayloadToChanges,
|
readModelOutboxPayloadToChanges,
|
||||||
resolveRealtimeReadModelInvalidation,
|
resolveRealtimeReadModelInvalidation,
|
||||||
@@ -93,11 +94,13 @@ describe('read-model outbox payload', () => {
|
|||||||
['messages.mailbox', 9999, '3'],
|
['messages.mailbox', 9999, '3'],
|
||||||
['messages.mailbox', 7, '2'],
|
['messages.mailbox', 7, '2'],
|
||||||
['messages.mailbox', 7, '2'],
|
['messages.mailbox', 7, '2'],
|
||||||
|
['messages.diplomacyMailbox', 9007, '4'],
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (!payload) throw new Error('valid payload rejected');
|
if (!payload) throw new Error('valid payload rejected');
|
||||||
|
|
||||||
expect(readModelOutboxPayloadToMessageMailboxes(payload)).toEqual([7, 9999]);
|
expect(readModelOutboxPayloadToMessageMailboxes(payload)).toEqual([7, 9999]);
|
||||||
|
expect(readModelOutboxPayloadToDiplomacyMailboxes(payload)).toEqual([9007]);
|
||||||
expect(hasRealtimeReadModelChanges(readModelOutboxPayloadToChanges(payload))).toBe(false);
|
expect(hasRealtimeReadModelChanges(readModelOutboxPayloadToChanges(payload))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,12 +73,22 @@ const diplomacyMessage = {
|
|||||||
time: '0190-03-01 00:00:00',
|
time: '0190-03-01 00:00:00',
|
||||||
};
|
};
|
||||||
|
|
||||||
const messageBundle = (visible: boolean, canRespondDiplomacy = true) => ({
|
const messageBundle = (visible: boolean, canRespondDiplomacy = true, deleted = false) => ({
|
||||||
result: true,
|
result: true,
|
||||||
private: [],
|
private: [],
|
||||||
public: [],
|
public: [],
|
||||||
national: [],
|
national: [],
|
||||||
diplomacy: visible ? [diplomacyMessage] : [],
|
diplomacy: visible
|
||||||
|
? [
|
||||||
|
deleted
|
||||||
|
? {
|
||||||
|
...diplomacyMessage,
|
||||||
|
text: '삭제된 메시지입니다.',
|
||||||
|
option: { ...diplomacyMessage.option, invalid: true },
|
||||||
|
}
|
||||||
|
: diplomacyMessage,
|
||||||
|
]
|
||||||
|
: [],
|
||||||
sequence: visible ? diplomacyMessage.id : -1,
|
sequence: visible ? diplomacyMessage.id : -1,
|
||||||
nationId: 1,
|
nationId: 1,
|
||||||
generalName: general.name,
|
generalName: general.name,
|
||||||
@@ -89,7 +99,7 @@ const messageBundle = (visible: boolean, canRespondDiplomacy = true) => ({
|
|||||||
|
|
||||||
const installFixture = async (
|
const installFixture = async (
|
||||||
page: Page,
|
page: Page,
|
||||||
options: { acceptResponse: boolean; canRespondDiplomacy?: boolean }
|
options: { acceptResponse: boolean; canRespondDiplomacy?: boolean; deleted?: boolean }
|
||||||
): Promise<Array<{ operation: string; body: unknown }>> => {
|
): Promise<Array<{ operation: string; body: unknown }>> => {
|
||||||
let visible = true;
|
let visible = true;
|
||||||
const mutations: Array<{ operation: string; body: unknown }> = [];
|
const mutations: Array<{ operation: string; body: unknown }> = [];
|
||||||
@@ -168,7 +178,7 @@ const installFixture = async (
|
|||||||
return response({ global: [], general: [], history: [] });
|
return response({ global: [], general: [], history: [] });
|
||||||
}
|
}
|
||||||
if (operation === 'messages.getRecent') {
|
if (operation === 'messages.getRecent') {
|
||||||
return response(messageBundle(visible, options.canRespondDiplomacy));
|
return response(messageBundle(visible, options.canRespondDiplomacy, options.deleted));
|
||||||
}
|
}
|
||||||
if (operation === 'messages.getContacts') return response({ nation: [] });
|
if (operation === 'messages.getContacts') return response({ nation: [] });
|
||||||
if (operation === 'board.getAccess') return response({ canMeeting: true, canSecret: true });
|
if (operation === 'board.getAccess') return response({ canMeeting: true, canSecret: true });
|
||||||
@@ -229,20 +239,20 @@ test.describe('instant diplomacy response UI', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(geometry.buttons).toHaveLength(2);
|
expect(geometry.buttons).toHaveLength(2);
|
||||||
expect(geometry.buttons[1]!.x - (geometry.buttons[0]!.x + geometry.buttons[0]!.width)).toBeCloseTo(0, 0);
|
expect(geometry.buttons[1]!.x - (geometry.buttons[0]!.x + geometry.buttons[0]!.width)).toBeCloseTo(4, 0);
|
||||||
expect(geometry.buttons[0]).toMatchObject({
|
expect(geometry.buttons[0]).toMatchObject({
|
||||||
color: 'rgb(255, 255, 255)',
|
color: 'rgb(255, 255, 255)',
|
||||||
fontSize: '12.5px',
|
fontSize: '12.5px',
|
||||||
borderWidth: '1px',
|
borderWidth: '0px 1px 4px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
});
|
});
|
||||||
expect(geometry.buttons[1]).toMatchObject({
|
expect(geometry.buttons[1]).toMatchObject({
|
||||||
color: 'rgb(255, 255, 255)',
|
color: 'rgb(255, 255, 255)',
|
||||||
fontSize: '12.5px',
|
fontSize: '12.5px',
|
||||||
borderWidth: '1px',
|
borderWidth: '0px 1px 4px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
});
|
});
|
||||||
expect(geometry.buttons.every((button) => button.height >= 22 && button.height <= 26)).toBe(true);
|
expect(geometry.buttons.every((button) => button.height >= 28 && button.height <= 34)).toBe(true);
|
||||||
|
|
||||||
await decline.hover();
|
await decline.hover();
|
||||||
expect(await decline.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer');
|
expect(await decline.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer');
|
||||||
@@ -303,7 +313,7 @@ test.describe('instant diplomacy response UI', () => {
|
|||||||
|
|
||||||
if (artifactRoot) {
|
if (artifactRoot) {
|
||||||
await mkdir(artifactRoot, { recursive: true });
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
await page.locator('.mobile-panel').screenshot({
|
await page.locator('.mobile-panel[data-mobile-panel-id="messages"]').screenshot({
|
||||||
path: resolve(artifactRoot, 'instant-diplomacy-response-error-core-mobile.png'),
|
path: resolve(artifactRoot, 'instant-diplomacy-response-error-core-mobile.png'),
|
||||||
animations: 'disabled',
|
animations: 'disabled',
|
||||||
});
|
});
|
||||||
@@ -332,4 +342,22 @@ test.describe('instant diplomacy response UI', () => {
|
|||||||
await accept.click({ force: true });
|
await accept.click({ force: true });
|
||||||
expect(mutations).toHaveLength(0);
|
expect(mutations).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('does not render response controls for a deleted diplomatic prompt', async ({ page }) => {
|
||||||
|
const mutations = await installFixture(page, { acceptResponse: true, deleted: true });
|
||||||
|
await page.setViewportSize({ width: 1365, height: 900 });
|
||||||
|
await page.goto(`http://127.0.0.1:${gamePort}/che/`);
|
||||||
|
await expect(page.getByText('삭제된 메시지입니다', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.locator('.message-response')).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: '수락' })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: '거절' })).toHaveCount(0);
|
||||||
|
expect(mutations).toHaveLength(0);
|
||||||
|
if (artifactRoot) {
|
||||||
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
|
await page.locator('.DiplomacyTalk .msg-plate').screenshot({
|
||||||
|
path: resolve(artifactRoot, 'deleted-diplomacy-message-core-desktop.png'),
|
||||||
|
animations: 'disabled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user