merge: complete in-game message parity
This commit is contained in:
@@ -23,13 +23,14 @@ export const resolveNationInfo = async (
|
|||||||
|
|
||||||
export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise<MessageTarget> => {
|
export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise<MessageTarget> => {
|
||||||
const nation = await resolveNationInfo(db, general.nationId);
|
const nation = await resolveNationInfo(db, general.nationId);
|
||||||
|
const picture = general.picture?.trim() || 'default.jpg';
|
||||||
return {
|
return {
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
generalName: general.name,
|
generalName: general.name,
|
||||||
nationId: general.nationId,
|
nationId: general.nationId,
|
||||||
nationName: nation.name,
|
nationName: nation.name,
|
||||||
color: nation.color,
|
color: nation.color,
|
||||||
icon: '',
|
icon: general.imageServer ? `d_pic/${picture}` : `/image/icons/${picture}`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { asRecord } from '@sammo-ts/common';
|
||||||
|
import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
|
||||||
|
|
||||||
import { authedProcedure, router } from '../../trpc.js';
|
import { authedProcedure, router } from '../../trpc.js';
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +28,75 @@ import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js
|
|||||||
|
|
||||||
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
||||||
|
|
||||||
|
const redactDiplomacyMessages = (messages: MessageView[], permission: number): MessageView[] => {
|
||||||
|
if (permission >= 3) {
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
return messages.map((message) => {
|
||||||
|
if (!message.dest || message.dest.nationId === 0) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
text: '(외교 메시지입니다)',
|
||||||
|
option: {
|
||||||
|
...(message.option ?? {}),
|
||||||
|
invalid: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFutureDate = (value: string | undefined, now = Date.now()): boolean => {
|
||||||
|
if (!value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const parsed = Date.parse(value);
|
||||||
|
return Number.isFinite(parsed) && parsed > now;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMessageFeatureBlocked = (sanctions: UserSanctions, profileNames: string[]): boolean => {
|
||||||
|
if (
|
||||||
|
isFutureDate(sanctions.mutedUntil) ||
|
||||||
|
isFutureDate(sanctions.suspendedUntil) ||
|
||||||
|
isFutureDate(sanctions.bannedUntil)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (const profileName of profileNames) {
|
||||||
|
const restriction = sanctions.serverRestrictions?.[profileName];
|
||||||
|
if (!restriction) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (restriction.until && !isFutureDate(restriction.until)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (restriction.blockedFeatures?.includes('messages')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readPenaltyNumber = (penalty: unknown, key: string, fallback: number): number => {
|
||||||
|
const value = asRecord(penalty)[key];
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasPenalty = (penalty: unknown, key: string): boolean => {
|
||||||
|
const value = asRecord(penalty)[key];
|
||||||
|
return value === true || value === 1 || value === '1';
|
||||||
|
};
|
||||||
|
|
||||||
export const messagesRouter = router({
|
export const messagesRouter = router({
|
||||||
getRecent: authedProcedure
|
getRecent: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -85,11 +156,12 @@ export const messagesRouter = router({
|
|||||||
: null,
|
: null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1;
|
||||||
const messageBuckets: Record<MessageType, MessageView[]> = {
|
const messageBuckets: Record<MessageType, MessageView[]> = {
|
||||||
private: privateMessages,
|
private: privateMessages,
|
||||||
public: publicMessages,
|
public: publicMessages,
|
||||||
national: nationalMessages,
|
national: nationalMessages,
|
||||||
diplomacy: diplomacyMessages,
|
diplomacy: redactDiplomacyMessages(diplomacyMessages, permission),
|
||||||
};
|
};
|
||||||
|
|
||||||
let nextSequence = sequence;
|
let nextSequence = sequence;
|
||||||
@@ -128,10 +200,8 @@ export const messagesRouter = router({
|
|||||||
sequence: nextSequence,
|
sequence: nextSequence,
|
||||||
nationId: nationId,
|
nationId: nationId,
|
||||||
generalName: general.name,
|
generalName: general.name,
|
||||||
canRespondDiplomacy:
|
permission,
|
||||||
general.officerLevel > 4 &&
|
canRespondDiplomacy: permission >= 4 && general.officerLevel > 4,
|
||||||
nation !== null &&
|
|
||||||
resolveNationPermission(general, nation.meta, false) >= 4,
|
|
||||||
latestRead: {
|
latestRead: {
|
||||||
diplomacy: readState?.latestDiplomacyMessage ?? 0,
|
diplomacy: readState?.latestDiplomacyMessage ?? 0,
|
||||||
private: readState?.latestPrivateMessage ?? 0,
|
private: readState?.latestPrivateMessage ?? 0,
|
||||||
@@ -178,6 +248,7 @@ export const messagesRouter = router({
|
|||||||
];
|
];
|
||||||
return {
|
return {
|
||||||
nation: nationList.map((nation) => ({
|
nation: nationList.map((nation) => ({
|
||||||
|
nationId: nation.id,
|
||||||
mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + nation.id,
|
mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + nation.id,
|
||||||
name: nation.name,
|
name: nation.name,
|
||||||
color: nation.color,
|
color: nation.color,
|
||||||
@@ -234,14 +305,24 @@ export const messagesRouter = router({
|
|||||||
if (message.payload.src.generalId !== general.id) {
|
if (message.payload.src.generalId !== general.id) {
|
||||||
throw new TRPCError({ code: 'FORBIDDEN', message: '본인의 메시지만 삭제할 수 있습니다.' });
|
throw new TRPCError({ code: 'FORBIDDEN', message: '본인의 메시지만 삭제할 수 있습니다.' });
|
||||||
}
|
}
|
||||||
if (message.msgType === 'diplomacy' || message.payload.option?.deletable === false) {
|
if (message.msgType === 'diplomacy' && message.payload.option?.action) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: '시스템 외교 메시지는 삭제할 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (message.payload.option?.deletable === false) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' });
|
||||||
}
|
}
|
||||||
if (Date.now() - message.time.getTime() > 5 * 60 * 1000) {
|
if (Date.now() - message.time.getTime() > 5 * 60 * 1000) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
|
||||||
}
|
}
|
||||||
const receiverMessageId = message.payload.option?.receiverMessageID;
|
const receiverMessageId = message.payload.option?.receiverMessageID;
|
||||||
const ids = [message.id, ...(typeof receiverMessageId === 'number' ? [receiverMessageId] : [])];
|
const shouldDeleteReceiverCopy = message.msgType === 'private' || message.msgType === 'national';
|
||||||
|
const ids = [
|
||||||
|
message.id,
|
||||||
|
...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []),
|
||||||
|
];
|
||||||
await invalidateMessages(ctx.db, ids);
|
await invalidateMessages(ctx.db, ids);
|
||||||
return { ok: true, deletedIds: ids };
|
return { ok: true, deletedIds: ids };
|
||||||
}),
|
}),
|
||||||
@@ -291,6 +372,14 @@ export const messagesRouter = router({
|
|||||||
const general = await getOwnedGeneral(ctx, input.generalId);
|
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||||
|
|
||||||
const nationId = general.nationId;
|
const nationId = general.nationId;
|
||||||
|
const nation =
|
||||||
|
nationId > 0
|
||||||
|
? await ctx.db.nation.findUnique({
|
||||||
|
where: { id: nationId },
|
||||||
|
select: { meta: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1;
|
||||||
const mailboxes = {
|
const mailboxes = {
|
||||||
private: general.id,
|
private: general.id,
|
||||||
public: MESSAGE_MAILBOX_PUBLIC,
|
public: MESSAGE_MAILBOX_PUBLIC,
|
||||||
@@ -312,7 +401,8 @@ export const messagesRouter = router({
|
|||||||
toSeq: input.to,
|
toSeq: input.to,
|
||||||
limit: 15,
|
limit: 15,
|
||||||
});
|
});
|
||||||
messageBuckets[input.type] = messages;
|
messageBuckets[input.type] =
|
||||||
|
input.type === 'diplomacy' ? redactDiplomacyMessages(messages, permission) : messages;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
result: true,
|
result: true,
|
||||||
@@ -320,6 +410,7 @@ export const messagesRouter = router({
|
|||||||
sequence: 0,
|
sequence: 0,
|
||||||
nationId,
|
nationId,
|
||||||
generalName: general.name,
|
generalName: general.name,
|
||||||
|
permission,
|
||||||
...messageBuckets,
|
...messageBuckets,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -333,6 +424,12 @@ export const messagesRouter = router({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const general = await getOwnedGeneral(ctx, input.generalId);
|
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||||
|
if (!ctx.auth || isMessageFeatureBlocked(ctx.auth.sanctions, [ctx.profile.name, ctx.profile.id])) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: '메시지 전송이 제한된 계정입니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const src = await buildTargetFromGeneral(ctx.db, general);
|
const src = await buildTargetFromGeneral(ctx.db, general);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -340,28 +437,93 @@ export const messagesRouter = router({
|
|||||||
|
|
||||||
let msgType: MessageType;
|
let msgType: MessageType;
|
||||||
let dest = src;
|
let dest = src;
|
||||||
|
let receiverMailbox = input.mailbox;
|
||||||
|
|
||||||
if (input.mailbox === MESSAGE_MAILBOX_PUBLIC) {
|
if (input.mailbox === MESSAGE_MAILBOX_PUBLIC) {
|
||||||
msgType = 'public';
|
if (hasPenalty(general.penalty, 'noSendPublicMsg')) {
|
||||||
} else if (input.mailbox >= MESSAGE_MAILBOX_NATIONAL_BASE) {
|
|
||||||
const destNationId = input.mailbox - MESSAGE_MAILBOX_NATIONAL_BASE;
|
|
||||||
if (destNationId <= 0) {
|
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'FORBIDDEN',
|
||||||
message: 'Invalid nation mailbox.',
|
message: '공개 메세지를 보낼 수 없습니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
msgType = 'public';
|
||||||
|
} else if (input.mailbox >= MESSAGE_MAILBOX_NATIONAL_BASE) {
|
||||||
|
const sourceNation =
|
||||||
|
general.nationId > 0
|
||||||
|
? await ctx.db.nation.findUnique({
|
||||||
|
where: { id: general.nationId },
|
||||||
|
select: { meta: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const permission =
|
||||||
|
general.nationId > 0 && sourceNation ? resolveNationPermission(general, sourceNation.meta) : -1;
|
||||||
|
const destNationId = permission < 4 ? general.nationId : input.mailbox - MESSAGE_MAILBOX_NATIONAL_BASE;
|
||||||
const nationInfo = await resolveNationInfo(ctx.db, destNationId);
|
const nationInfo = await resolveNationInfo(ctx.db, destNationId);
|
||||||
|
if (destNationId > 0) {
|
||||||
|
const destNation = await ctx.db.nation.findUnique({ where: { id: destNationId } });
|
||||||
|
if (!destNation) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: '존재하지 않는 국가입니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
dest = buildNationTarget(destNationId, nationInfo.name, nationInfo.color);
|
dest = buildNationTarget(destNationId, nationInfo.name, nationInfo.color);
|
||||||
msgType = destNationId === general.nationId ? 'national' : 'diplomacy';
|
msgType = destNationId === general.nationId ? 'national' : 'diplomacy';
|
||||||
|
receiverMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + destNationId;
|
||||||
} else if (input.mailbox > 0) {
|
} else if (input.mailbox > 0) {
|
||||||
|
if (hasPenalty(general.penalty, 'noSendPrivateMsg')) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: '개인 메세지를 보낼 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const intervalSeconds = Math.max(
|
||||||
|
0,
|
||||||
|
Math.ceil(readPenaltyNumber(general.penalty, 'sendPrivateMsgDelay', 2))
|
||||||
|
);
|
||||||
|
if (intervalSeconds > 0) {
|
||||||
|
const rateLimitKey = `game:${ctx.profile.name}:message:private:${ctx.auth.sessionId}`;
|
||||||
|
const acquired = await ctx.redis.set(rateLimitKey, '1', {
|
||||||
|
NX: true,
|
||||||
|
PX: intervalSeconds * 1000,
|
||||||
|
});
|
||||||
|
if (acquired === null) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'TOO_MANY_REQUESTS',
|
||||||
|
message: `개인메세지는 ${intervalSeconds}초당 1건만 보낼 수 있습니다!`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
const destGeneral = await ctx.db.general.findUnique({
|
const destGeneral = await ctx.db.general.findUnique({
|
||||||
where: { id: input.mailbox },
|
where: { id: input.mailbox },
|
||||||
});
|
});
|
||||||
if (!destGeneral) {
|
if (!destGeneral) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'NOT_FOUND',
|
code: 'NOT_FOUND',
|
||||||
message: 'Destination general not found.',
|
message: '존재하지 않는 유저입니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const [sourceNation, destNation] = await Promise.all([
|
||||||
|
general.nationId > 0
|
||||||
|
? ctx.db.nation.findUnique({ where: { id: general.nationId }, select: { meta: true } })
|
||||||
|
: null,
|
||||||
|
destGeneral.nationId > 0
|
||||||
|
? ctx.db.nation.findUnique({ where: { id: destGeneral.nationId }, select: { meta: true } })
|
||||||
|
: null,
|
||||||
|
]);
|
||||||
|
const sourcePermission =
|
||||||
|
sourceNation && general.nationId > 0
|
||||||
|
? resolveNationPermission(general, sourceNation.meta, false)
|
||||||
|
: -1;
|
||||||
|
const destPermission =
|
||||||
|
destNation && destGeneral.nationId > 0
|
||||||
|
? resolveNationPermission(destGeneral, destNation.meta, false)
|
||||||
|
: -1;
|
||||||
|
if (sourcePermission === 4 && destPermission === 4 && destGeneral.nationId !== general.nationId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: '외교권자끼리는 메시지를 보낼 수 없습니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
dest = await buildTargetFromGeneral(ctx.db, destGeneral);
|
dest = await buildTargetFromGeneral(ctx.db, destGeneral);
|
||||||
@@ -394,7 +556,7 @@ export const messagesRouter = router({
|
|||||||
await publishRealtimeEvent(ctx.redis, ctx.profile.name, {
|
await publishRealtimeEvent(ctx.redis, ctx.profile.name, {
|
||||||
type: 'messageCreated',
|
type: 'messageCreated',
|
||||||
at: now.toISOString(),
|
at: now.toISOString(),
|
||||||
mailbox: input.mailbox,
|
mailbox: receiverMailbox,
|
||||||
msgType,
|
msgType,
|
||||||
messageId: result.receiverId,
|
messageId: result.receiverId,
|
||||||
senderId: general.id,
|
senderId: general.id,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const auth: GameSessionTokenPayload = {
|
|||||||
sanctions: {},
|
sanctions: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildContext = (overrides: Record<string, unknown> = {}) => {
|
const buildContext = (overrides: Record<string, unknown> = {}, contextOverrides: Record<string, unknown> = {}) => {
|
||||||
const executeRaw = vi.fn(async () => 1);
|
const executeRaw = vi.fn(async () => 1);
|
||||||
const updateMany = vi.fn(async () => ({ count: 1 }));
|
const updateMany = vi.fn(async () => ({ count: 1 }));
|
||||||
const db = {
|
const db = {
|
||||||
@@ -55,11 +55,15 @@ const buildContext = (overrides: Record<string, unknown> = {}) => {
|
|||||||
$executeRaw: executeRaw,
|
$executeRaw: executeRaw,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
const redis = {
|
||||||
|
set: vi.fn(async () => 'OK'),
|
||||||
|
publish: vi.fn(async () => 1),
|
||||||
|
};
|
||||||
const context = {
|
const context = {
|
||||||
db,
|
db,
|
||||||
auth,
|
auth,
|
||||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||||
redis: {},
|
redis,
|
||||||
turnDaemon: {},
|
turnDaemon: {},
|
||||||
battleSim: {},
|
battleSim: {},
|
||||||
uploadDir: 'uploads',
|
uploadDir: 'uploads',
|
||||||
@@ -68,8 +72,9 @@ const buildContext = (overrides: Record<string, unknown> = {}) => {
|
|||||||
accessTokenStore: {},
|
accessTokenStore: {},
|
||||||
flushStore: {},
|
flushStore: {},
|
||||||
gameTokenSecret: 'test-secret',
|
gameTokenSecret: 'test-secret',
|
||||||
|
...contextOverrides,
|
||||||
} as unknown as GameApiContext;
|
} as unknown as GameApiContext;
|
||||||
return { caller: appRouter.createCaller(context), db, executeRaw, updateMany };
|
return { caller: appRouter.createCaller(context), db, executeRaw, updateMany, redis };
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('messages router missing-flow compatibility', () => {
|
describe('messages router missing-flow compatibility', () => {
|
||||||
@@ -99,6 +104,291 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
expect(result.canRespondDiplomacy).toBe(true);
|
expect(result.canRespondDiplomacy).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lists an appointed ambassador as permission 4 but keeps responses limited to officers', async () => {
|
||||||
|
const ambassador = {
|
||||||
|
...general,
|
||||||
|
officerLevel: 1,
|
||||||
|
meta: { permission: 'ambassador' },
|
||||||
|
} as GeneralRow;
|
||||||
|
const { caller } = buildContext({
|
||||||
|
general: {
|
||||||
|
findUnique: vi.fn(async () => ambassador),
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
},
|
||||||
|
nation: {
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
findUnique: vi.fn(async () => ({ meta: {} })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await caller.messages.getRecent({ generalId: ambassador.id });
|
||||||
|
|
||||||
|
expect(result.permission).toBe(4);
|
||||||
|
expect(result.canRespondDiplomacy).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts recent and old diplomacy content below secret permission 3', async () => {
|
||||||
|
const diplomacyRow = {
|
||||||
|
id: 19,
|
||||||
|
mailbox: 9001,
|
||||||
|
type: 'diplomacy',
|
||||||
|
src: 9002,
|
||||||
|
dest: 9001,
|
||||||
|
time: new Date(),
|
||||||
|
valid_until: new Date('9999-12-31T00:00:00Z'),
|
||||||
|
message: {
|
||||||
|
src: {
|
||||||
|
generalId: 8,
|
||||||
|
generalName: '외교관',
|
||||||
|
nationId: 2,
|
||||||
|
nationName: '촉',
|
||||||
|
color: '#000000',
|
||||||
|
icon: '',
|
||||||
|
},
|
||||||
|
dest: {
|
||||||
|
generalId: 0,
|
||||||
|
generalName: '',
|
||||||
|
nationId: 1,
|
||||||
|
nationName: '위',
|
||||||
|
color: '#ffffff',
|
||||||
|
icon: '',
|
||||||
|
},
|
||||||
|
text: '보이면 안 되는 외교 본문',
|
||||||
|
option: { action: 'noAggression' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const queryRaw = vi.fn(async () => [diplomacyRow]);
|
||||||
|
const { caller } = buildContext({
|
||||||
|
$queryRaw: queryRaw,
|
||||||
|
nation: {
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
findUnique: vi.fn(async () => ({ meta: {} })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const recent = await caller.messages.getRecent({ generalId: general.id });
|
||||||
|
const old = await caller.messages.getOld({
|
||||||
|
generalId: general.id,
|
||||||
|
type: 'diplomacy',
|
||||||
|
to: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recent.permission).toBe(2);
|
||||||
|
expect(recent.diplomacy[0]).toMatchObject({
|
||||||
|
text: '(외교 메시지입니다)',
|
||||||
|
option: { action: 'noAggression', invalid: true },
|
||||||
|
});
|
||||||
|
expect(old.diplomacy[0]).toMatchObject({
|
||||||
|
text: '(외교 메시지입니다)',
|
||||||
|
option: { action: 'noAggression', invalid: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => {
|
||||||
|
const queryRaw = vi.fn(async () => [{ id: 51 }]);
|
||||||
|
const findNation = vi.fn(async ({ where }: { where: { id: number } }) => ({
|
||||||
|
id: where.id,
|
||||||
|
name: where.id === 1 ? '위' : '촉',
|
||||||
|
color: '#112233',
|
||||||
|
meta: {},
|
||||||
|
}));
|
||||||
|
const { caller } = buildContext({
|
||||||
|
$queryRaw: queryRaw,
|
||||||
|
nation: {
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
findUnique: findNation,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await caller.messages.send({
|
||||||
|
generalId: general.id,
|
||||||
|
mailbox: 9002,
|
||||||
|
text: '국가 메시지',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.msgType).toBe('national');
|
||||||
|
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows an ambassador to target a foreign nation mailbox as diplomacy', async () => {
|
||||||
|
const ambassador = {
|
||||||
|
...general,
|
||||||
|
officerLevel: 1,
|
||||||
|
meta: { permission: 'ambassador' },
|
||||||
|
} as GeneralRow;
|
||||||
|
const queryRaw = vi.fn(async () => [{ id: 52 }]);
|
||||||
|
const { caller } = buildContext({
|
||||||
|
$queryRaw: queryRaw,
|
||||||
|
general: {
|
||||||
|
findUnique: vi.fn(async () => ambassador),
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
},
|
||||||
|
nation: {
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
findUnique: vi.fn(async ({ where }: { where: { id: number } }) => ({
|
||||||
|
id: where.id,
|
||||||
|
name: where.id === 1 ? '위' : '촉',
|
||||||
|
color: '#112233',
|
||||||
|
meta: {},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await caller.messages.send({
|
||||||
|
generalId: ambassador.id,
|
||||||
|
mailbox: 9002,
|
||||||
|
text: '외교 메시지',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.msgType).toBe('diplomacy');
|
||||||
|
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9002, 'diplomacy']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks private messages between foreign ambassadors', async () => {
|
||||||
|
const ambassador = {
|
||||||
|
...general,
|
||||||
|
officerLevel: 1,
|
||||||
|
meta: { permission: 'ambassador' },
|
||||||
|
} as GeneralRow;
|
||||||
|
const foreignAmbassador = {
|
||||||
|
...ambassador,
|
||||||
|
id: 8,
|
||||||
|
userId: 'user-8',
|
||||||
|
name: '상대 외교관',
|
||||||
|
nationId: 2,
|
||||||
|
} as GeneralRow;
|
||||||
|
const { caller } = buildContext({
|
||||||
|
general: {
|
||||||
|
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||||
|
where.id === ambassador.id ? ambassador : foreignAmbassador
|
||||||
|
),
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
},
|
||||||
|
nation: {
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
findUnique: vi.fn(async ({ where }: { where: { id: number } }) => ({
|
||||||
|
id: where.id,
|
||||||
|
name: where.id === 1 ? '위' : '촉',
|
||||||
|
color: '#112233',
|
||||||
|
meta: {},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
caller.messages.send({
|
||||||
|
generalId: ambassador.id,
|
||||||
|
mailbox: foreignAmbassador.id,
|
||||||
|
text: '개인 메시지',
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: '외교권자끼리는 메시지를 보낼 수 없습니다.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['public', { noSendPublicMsg: 1 }, 9999, '공개 메세지를 보낼 수 없습니다.'],
|
||||||
|
['private', { noSendPrivateMsg: 1 }, 8, '개인 메세지를 보낼 수 없습니다.'],
|
||||||
|
])('enforces the general %s-message penalty', async (_type, penalty, mailbox, message) => {
|
||||||
|
const penalized = { ...general, penalty } as GeneralRow;
|
||||||
|
const { caller } = buildContext({
|
||||||
|
general: {
|
||||||
|
findUnique: vi.fn(async () => penalized),
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
},
|
||||||
|
nation: {
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#fff', meta: {} })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
caller.messages.send({
|
||||||
|
generalId: penalized.id,
|
||||||
|
mailbox,
|
||||||
|
text: '차단 메시지',
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'FORBIDDEN', message });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces the legacy private-message interval through Redis without touching lifecycle', async () => {
|
||||||
|
const redis = {
|
||||||
|
set: vi.fn(async () => null),
|
||||||
|
publish: vi.fn(async () => 1),
|
||||||
|
};
|
||||||
|
const { caller } = buildContext(
|
||||||
|
{
|
||||||
|
nation: {
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#fff', meta: {} })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ redis }
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
caller.messages.send({
|
||||||
|
generalId: general.id,
|
||||||
|
mailbox: 8,
|
||||||
|
text: '너무 빠른 메시지',
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'TOO_MANY_REQUESTS',
|
||||||
|
message: '개인메세지는 2초당 1건만 보낼 수 있습니다!',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks sends for a muted authenticated user independently of general permission', async () => {
|
||||||
|
const mutedAuth = {
|
||||||
|
...auth,
|
||||||
|
sanctions: { mutedUntil: '2099-01-01T00:00:00.000Z' },
|
||||||
|
};
|
||||||
|
const { caller } = buildContext({}, { auth: mutedAuth });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
caller.messages.send({
|
||||||
|
generalId: general.id,
|
||||||
|
mailbox: 9999,
|
||||||
|
text: '사용자 mute',
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: '메시지 전송이 제한된 계정입니다.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects every remaining general-scoped message mutation for another user general', async () => {
|
||||||
|
const foreignGeneral = { ...general, userId: 'user-8' } as GeneralRow;
|
||||||
|
const { caller } = buildContext({
|
||||||
|
general: {
|
||||||
|
findUnique: vi.fn(async () => foreignGeneral),
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(caller.messages.getContacts({ generalId: foreignGeneral.id })).rejects.toMatchObject({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
caller.messages.readLatest({
|
||||||
|
generalId: foreignGeneral.id,
|
||||||
|
type: 'private',
|
||||||
|
messageId: 1,
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||||
|
await expect(caller.messages.delete({ generalId: foreignGeneral.id, messageId: 1 })).rejects.toMatchObject({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
caller.messages.respond({
|
||||||
|
generalId: foreignGeneral.id,
|
||||||
|
messageId: 1,
|
||||||
|
response: true,
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||||
|
});
|
||||||
|
|
||||||
it('persists latest-read updates through the monotonic upsert', async () => {
|
it('persists latest-read updates through the monotonic upsert', async () => {
|
||||||
const { caller, executeRaw } = buildContext();
|
const { caller, executeRaw } = buildContext();
|
||||||
|
|
||||||
@@ -154,6 +444,49 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => {
|
||||||
|
const queryRaw = vi.fn(async () => [
|
||||||
|
{
|
||||||
|
id: 25,
|
||||||
|
mailbox: 9001,
|
||||||
|
type: 'diplomacy',
|
||||||
|
src: 9001,
|
||||||
|
dest: 9002,
|
||||||
|
time: new Date(),
|
||||||
|
valid_until: new Date('9999-12-31T00:00:00Z'),
|
||||||
|
message: {
|
||||||
|
src: {
|
||||||
|
generalId: general.id,
|
||||||
|
generalName: general.name,
|
||||||
|
nationId: 1,
|
||||||
|
nationName: '위',
|
||||||
|
color: '#fff',
|
||||||
|
icon: '',
|
||||||
|
},
|
||||||
|
dest: {
|
||||||
|
generalId: 0,
|
||||||
|
generalName: '',
|
||||||
|
nationId: 2,
|
||||||
|
nationName: '촉',
|
||||||
|
color: '#000',
|
||||||
|
icon: '',
|
||||||
|
},
|
||||||
|
text: '일반 외교 메시지',
|
||||||
|
option: { receiverMessageID: 26 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { caller, updateMany } = buildContext({ $queryRaw: queryRaw });
|
||||||
|
|
||||||
|
const result = await caller.messages.delete({ generalId: general.id, messageId: 25 });
|
||||||
|
|
||||||
|
expect(result.deletedIds).toEqual([25]);
|
||||||
|
expect(updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { id: { in: [25] } },
|
||||||
|
data: { validUntil: expect.any(Date) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects deleting another general message', async () => {
|
it('rejects deleting another general message', async () => {
|
||||||
const queryRaw = vi.fn(async () => [
|
const queryRaw = vi.fn(async () => [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, reactive } from 'vue';
|
||||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
|
||||||
import type { MessageType } from '@sammo-ts/logic';
|
import type { MessageType } from '@sammo-ts/logic';
|
||||||
|
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||||
|
import MessagePlate from './MessagePlate.vue';
|
||||||
|
|
||||||
|
interface MessageTarget {
|
||||||
|
generalId: number;
|
||||||
|
generalName: string;
|
||||||
|
nationId: number;
|
||||||
|
nationName: string;
|
||||||
|
color: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface MessageEntry {
|
interface MessageEntry {
|
||||||
id: number;
|
id: number;
|
||||||
text: string;
|
text: string;
|
||||||
time: string;
|
time: string;
|
||||||
msgType: MessageType;
|
msgType: MessageType;
|
||||||
|
src: MessageTarget;
|
||||||
|
dest: MessageTarget | null;
|
||||||
option?: Record<string, unknown> | null;
|
option?: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,6 +28,22 @@ interface MessageBucket {
|
|||||||
public: MessageEntry[];
|
public: MessageEntry[];
|
||||||
national: MessageEntry[];
|
national: MessageEntry[];
|
||||||
diplomacy: MessageEntry[];
|
diplomacy: MessageEntry[];
|
||||||
|
permission: number;
|
||||||
|
latestRead: {
|
||||||
|
private: number;
|
||||||
|
diplomacy: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MailboxGroup {
|
||||||
|
label: string;
|
||||||
|
color?: string;
|
||||||
|
options: Array<{
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
color?: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -23,7 +51,10 @@ const props = defineProps<{
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
targetMailbox: number;
|
targetMailbox: number;
|
||||||
draftText: string;
|
draftText: string;
|
||||||
mailboxOptions: Array<{ label: string; value: number; disabled?: boolean }>;
|
mailboxGroups: MailboxGroup[];
|
||||||
|
generalId: number;
|
||||||
|
generalName: string;
|
||||||
|
nationId: number;
|
||||||
canRespondDiplomacy: boolean;
|
canRespondDiplomacy: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -34,213 +65,390 @@ const emit = defineEmits<{
|
|||||||
(event: 'refresh'): void;
|
(event: 'refresh'): void;
|
||||||
(event: 'load-older', type: MessageType): void;
|
(event: 'load-older', type: MessageType): void;
|
||||||
(event: 'respond', messageId: number, response: boolean): void;
|
(event: 'respond', messageId: number, response: boolean): void;
|
||||||
|
(event: 'read-latest', type: 'private' | 'diplomacy', messageId: number): void;
|
||||||
|
(event: 'delete', messageId: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const messageTabs: Array<{ key: MessageType; label: string }> = [
|
const sections: Array<{ type: MessageType; label: string; className: string }> = [
|
||||||
{ key: 'public', label: '전체' },
|
{ type: 'public', label: '전체 메시지', className: 'PublicTalk' },
|
||||||
{ key: 'national', label: '국가' },
|
{ type: 'national', label: '국가 메시지', className: 'NationalTalk' },
|
||||||
{ key: 'private', label: '개인' },
|
{ type: 'private', label: '개인 메시지', className: 'PrivateTalk' },
|
||||||
{ key: 'diplomacy', label: '외교' },
|
{ type: 'diplomacy', label: '외교 메시지', className: 'DiplomacyTalk' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const activeTab = ref<MessageType>('public');
|
const visibleLimits = reactive<Record<MessageType, number>>({
|
||||||
|
public: Number.POSITIVE_INFINITY,
|
||||||
const activeMessages = computed(() => {
|
national: Number.POSITIVE_INFINITY,
|
||||||
if (!props.messages) {
|
private: Number.POSITIVE_INFINITY,
|
||||||
return [] as MessageEntry[];
|
diplomacy: Number.POSITIVE_INFINITY,
|
||||||
}
|
|
||||||
return props.messages[activeTab.value] ?? [];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const bucket = (type: MessageType): MessageEntry[] => props.messages?.[type] ?? [];
|
||||||
|
const visibleMessages = (type: MessageType): MessageEntry[] => bucket(type).slice(0, visibleLimits[type]);
|
||||||
|
|
||||||
|
const permission = computed(() => props.messages?.permission ?? -1);
|
||||||
|
|
||||||
const setMailbox = (value: string) => {
|
const setMailbox = (value: string) => {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
emit('update:targetMailbox', Number.isFinite(parsed) ? parsed : 0);
|
emit('update:targetMailbox', Number.isFinite(parsed) ? parsed : 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isDiplomacyPrompt = (message: MessageEntry): boolean =>
|
const submit = () => {
|
||||||
message.msgType === 'diplomacy' &&
|
if (!props.draftText.trim()) {
|
||||||
(message.option?.action === 'noAggression' ||
|
emit('refresh');
|
||||||
message.option?.action === 'cancelNA' ||
|
|
||||||
message.option?.action === 'stopWar');
|
|
||||||
|
|
||||||
const respond = (messageId: number, response: boolean) => {
|
|
||||||
if (!window.confirm(response ? '수락하시겠습니까?' : '거절하시겠습니까?')) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
emit('send');
|
||||||
|
};
|
||||||
|
|
||||||
|
const newestIncomingId = (type: 'private' | 'diplomacy'): number =>
|
||||||
|
bucket(type)
|
||||||
|
.filter((message) => message.src.generalId !== props.generalId)
|
||||||
|
.reduce((latest, message) => Math.max(latest, message.id), 0);
|
||||||
|
|
||||||
|
const canMarkRead = (type: 'private' | 'diplomacy'): boolean => {
|
||||||
|
if (!props.messages) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const newest = newestIncomingId(type);
|
||||||
|
return newest > props.messages.latestRead[type];
|
||||||
|
};
|
||||||
|
|
||||||
|
const markRead = (type: 'private' | 'diplomacy') => {
|
||||||
|
const messageId = newestIncomingId(type);
|
||||||
|
if (messageId > 0) {
|
||||||
|
emit('read-latest', type, messageId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSectionMailbox = (type: MessageType) => {
|
||||||
|
if (type === 'public') {
|
||||||
|
emit('update:targetMailbox', 9999);
|
||||||
|
} else if (type === 'national') {
|
||||||
|
emit('update:targetMailbox', 9000 + props.nationId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setReplyTarget = (type: MessageType, target: MessageTarget) => {
|
||||||
|
const mailbox =
|
||||||
|
(type === 'diplomacy' || type === 'national') && target.nationId !== props.nationId
|
||||||
|
? 9000 + target.nationId
|
||||||
|
: target.generalId;
|
||||||
|
if (mailbox > 0) {
|
||||||
|
emit('update:targetMailbox', mailbox);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fold = (type: MessageType) => {
|
||||||
|
if (bucket(type).length >= 10) {
|
||||||
|
visibleLimits[type] = 10;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const forwardResponse = (messageId: number, response: boolean) => {
|
||||||
emit('respond', messageId, response);
|
emit('respond', messageId, response);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="message-panel">
|
<div class="MessagePanel">
|
||||||
<div class="message-input">
|
<div class="MessageInputForm">
|
||||||
<select
|
<div id="mailbox_list-col">
|
||||||
class="message-select"
|
<select
|
||||||
:value="targetMailbox"
|
id="mailbox_list"
|
||||||
@change="setMailbox(($event.target as HTMLSelectElement).value)"
|
class="message-select"
|
||||||
>
|
:value="targetMailbox"
|
||||||
<option
|
aria-label="메시지 수신 대상"
|
||||||
v-for="option in mailboxOptions"
|
@change="setMailbox(($event.target as HTMLSelectElement).value)"
|
||||||
:key="option.label"
|
|
||||||
:value="option.value"
|
|
||||||
:disabled="option.disabled"
|
|
||||||
>
|
>
|
||||||
{{ option.label }}
|
<optgroup
|
||||||
</option>
|
v-for="group in mailboxGroups"
|
||||||
</select>
|
:key="group.label"
|
||||||
<input
|
:label="group.label"
|
||||||
class="message-text"
|
:style="{ backgroundColor: group.color ?? '#000000', color: '#ffffff' }"
|
||||||
type="text"
|
>
|
||||||
maxlength="99"
|
<option
|
||||||
:value="draftText"
|
v-for="option in group.options"
|
||||||
placeholder="메시지 입력"
|
:key="`${group.label}-${option.value}`"
|
||||||
@input="emit('update:draftText', ($event.target as HTMLInputElement).value)"
|
:value="option.value"
|
||||||
@keydown.enter="emit('send')"
|
:disabled="option.disabled"
|
||||||
/>
|
:style="{ backgroundColor: option.color ?? '#000000', color: '#ffffff' }"
|
||||||
<button class="message-send" @click="emit('send')">전송</button>
|
>
|
||||||
</div>
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
<div class="message-tabs">
|
</optgroup>
|
||||||
<button
|
</select>
|
||||||
v-for="tab in messageTabs"
|
</div>
|
||||||
:key="tab.key"
|
<div id="msg_input-col">
|
||||||
:class="{ active: activeTab === tab.key }"
|
<input
|
||||||
@click="activeTab = tab.key"
|
class="message-text"
|
||||||
>
|
type="text"
|
||||||
{{ tab.label }}
|
maxlength="99"
|
||||||
</button>
|
:value="draftText"
|
||||||
<button class="refresh" @click="emit('refresh')">갱신</button>
|
aria-label="메시지 입력"
|
||||||
</div>
|
@input="emit('update:draftText', ($event.target as HTMLInputElement).value)"
|
||||||
|
@keydown.enter="submit"
|
||||||
<div v-if="props.loading">
|
/>
|
||||||
<SkeletonLines :lines="4" />
|
</div>
|
||||||
</div>
|
<div id="msg_submit-col">
|
||||||
<div v-else-if="!props.messages" class="empty">메시지를 불러오지 못했습니다.</div>
|
<button class="message-send" type="button" @click="submit">서신전달&갱신</button>
|
||||||
<div v-else class="message-list">
|
|
||||||
<div v-if="activeMessages.length === 0" class="empty">메시지가 없습니다.</div>
|
|
||||||
<div v-else>
|
|
||||||
<div v-for="message in activeMessages" :key="message.id" class="message-item">
|
|
||||||
<div class="text">{{ message.text }}</div>
|
|
||||||
<div v-if="isDiplomacyPrompt(message)" class="message-response">
|
|
||||||
<button class="accept" :disabled="!canRespondDiplomacy" @click="respond(message.id, true)">
|
|
||||||
수락
|
|
||||||
</button>
|
|
||||||
<button class="decline" :disabled="!canRespondDiplomacy" @click="respond(message.id, false)">
|
|
||||||
거절
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="time">{{ message.time }}</div>
|
|
||||||
</div>
|
|
||||||
<button class="load-older" @click="emit('load-older', activeTab)">이전 메시지</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading && !messages" class="message-loading">
|
||||||
|
<SkeletonLines :lines="4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<section
|
||||||
|
v-for="section in sections"
|
||||||
|
:key="section.type"
|
||||||
|
:class="['message-section', section.className]"
|
||||||
|
:data-message-type="section.type"
|
||||||
|
>
|
||||||
|
<div class="stickyAnchor"></div>
|
||||||
|
<header class="BoardHeader">
|
||||||
|
<div class="header-label">{{ section.label }}</div>
|
||||||
|
<button
|
||||||
|
v-if="section.type === 'public' || section.type === 'national'"
|
||||||
|
class="btn-more-small action-primary"
|
||||||
|
type="button"
|
||||||
|
@click="setSectionMailbox(section.type)"
|
||||||
|
>
|
||||||
|
↩ 여기로
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="btn-more-small action-secondary"
|
||||||
|
type="button"
|
||||||
|
:disabled="!canMarkRead(section.type)"
|
||||||
|
@click="markRead(section.type)"
|
||||||
|
>
|
||||||
|
모두 읽음
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="bucket(section.type).length === 0" class="empty-message">메시지가 없습니다.</div>
|
||||||
|
<div v-else class="MessageList">
|
||||||
|
<MessagePlate
|
||||||
|
v-for="message in visibleMessages(section.type)"
|
||||||
|
:key="message.id"
|
||||||
|
:message="message"
|
||||||
|
:general-id="generalId"
|
||||||
|
:general-name="generalName"
|
||||||
|
:nation-id="nationId"
|
||||||
|
:permission="permission"
|
||||||
|
:can-respond-diplomacy="canRespondDiplomacy"
|
||||||
|
@set-target="setReplyTarget"
|
||||||
|
@delete="emit('delete', $event)"
|
||||||
|
@respond="forwardResponse"
|
||||||
|
/>
|
||||||
|
<div class="Actions">
|
||||||
|
<button class="fold-message" type="button" @click="fold(section.type)">접기</button>
|
||||||
|
<button class="load-older" type="button" @click="emit('load-older', section.type)">
|
||||||
|
이전 메시지 불러오기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.message-panel {
|
.MessagePanel {
|
||||||
display: flex;
|
color: #fff;
|
||||||
flex-direction: column;
|
font-size: 14px;
|
||||||
gap: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-input {
|
.MessageInputForm {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 4fr) minmax(0, 1fr);
|
||||||
|
grid-template-areas: 'mailbox input submit';
|
||||||
|
background-color: #302016;
|
||||||
|
background-image: url('/image/game/back_walnut.jpg');
|
||||||
|
}
|
||||||
|
|
||||||
|
#mailbox_list-col {
|
||||||
|
grid-area: mailbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
#msg_input-col {
|
||||||
|
grid-area: input;
|
||||||
|
}
|
||||||
|
|
||||||
|
#msg_submit-col {
|
||||||
|
grid-area: submit;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mailbox_list-col,
|
||||||
|
#msg_input-col,
|
||||||
|
#msg_submit-col {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(90px, 120px) 1fr auto;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-select,
|
.message-select,
|
||||||
|
.message-text,
|
||||||
|
.message-send {
|
||||||
|
height: 35.5px;
|
||||||
|
border: 1px solid #6c757d;
|
||||||
|
border-radius: 4px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-select {
|
||||||
|
width: 100%;
|
||||||
|
background-color: #212529;
|
||||||
|
padding: 4px 30px 4px 12px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.message-text {
|
.message-text {
|
||||||
background: rgba(16, 16, 16, 0.8);
|
width: 100%;
|
||||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
background-color: #fff;
|
||||||
color: inherit;
|
padding: 4px 8px;
|
||||||
padding: 6px;
|
color: #212529;
|
||||||
font-size: 0.75rem;
|
}
|
||||||
|
|
||||||
|
.message-send,
|
||||||
|
.action-primary {
|
||||||
|
background-color: #337ab7;
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-send {
|
.message-send {
|
||||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-tabs {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-tabs button {
|
|
||||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
font-size: 0.7rem;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-tabs button.active {
|
.message-send:hover {
|
||||||
background: rgba(201, 164, 90, 0.2);
|
background-color: #375a7f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-tabs .refresh {
|
.message-send:focus,
|
||||||
margin-left: auto;
|
.message-send:focus-visible {
|
||||||
|
outline: none !important;
|
||||||
|
outline-width: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-list {
|
.message-loading {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-section {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.BoardHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
min-height: 25px;
|
||||||
gap: 8px;
|
align-items: center;
|
||||||
|
outline: 1px solid gray;
|
||||||
|
background-color: #302016;
|
||||||
|
background-image: url('/image/game/back_walnut.jpg');
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-item {
|
.header-label {
|
||||||
border: 1px solid rgba(201, 164, 90, 0.2);
|
flex: 1;
|
||||||
padding: 6px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-item .time {
|
.btn-more-small {
|
||||||
margin-top: 4px;
|
margin: 1px;
|
||||||
font-size: 0.65rem;
|
border: 1px solid transparent;
|
||||||
color: rgba(232, 221, 196, 0.6);
|
border-radius: 3px;
|
||||||
}
|
padding: 2px 6px;
|
||||||
|
font-size: 11.2px;
|
||||||
.message-response {
|
line-height: 1.5;
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 4px;
|
|
||||||
margin-top: 5px;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-response button {
|
|
||||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
|
||||||
padding: 3px 10px;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-response .accept {
|
.action-secondary {
|
||||||
color: #8fd18f;
|
border-color: #6c757d;
|
||||||
|
background-color: #6c757d;
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-response .decline {
|
.btn-more-small:disabled {
|
||||||
color: #e09a9a;
|
cursor: default;
|
||||||
|
opacity: 0.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-response button:disabled {
|
.empty-message {
|
||||||
cursor: not-allowed;
|
min-height: 22px;
|
||||||
opacity: 0.5;
|
}
|
||||||
|
|
||||||
|
.MessageList {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Actions {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-message,
|
||||||
|
.load-older {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 6px 12px;
|
||||||
|
color: #fff;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-message {
|
||||||
|
background-color: #212529;
|
||||||
}
|
}
|
||||||
|
|
||||||
.load-older {
|
.load-older {
|
||||||
border: 1px dashed rgba(201, 164, 90, 0.3);
|
background-color: #6c757d;
|
||||||
padding: 6px;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
@media (min-width: 940px) {
|
||||||
color: rgba(232, 221, 196, 0.6);
|
.MessagePanel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.MessageInputForm,
|
||||||
|
.message-loading {
|
||||||
|
grid-column: 1 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.PublicTalk,
|
||||||
|
.PrivateTalk {
|
||||||
|
border-right: 1px solid gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-message {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.MessageList {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 939.98px) {
|
||||||
|
.MessageInputForm {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 5;
|
||||||
|
top: 0;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-template-areas:
|
||||||
|
'mailbox submit'
|
||||||
|
'input input';
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text {
|
||||||
|
height: 33.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.BoardHeader {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 4;
|
||||||
|
top: 62px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,431 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
import type { MessageType } from '@sammo-ts/logic';
|
||||||
|
|
||||||
|
interface MessageTarget {
|
||||||
|
generalId: number;
|
||||||
|
generalName: string;
|
||||||
|
nationId: number;
|
||||||
|
nationName: string;
|
||||||
|
color: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageEntry {
|
||||||
|
id: number;
|
||||||
|
text: string;
|
||||||
|
time: string;
|
||||||
|
msgType: MessageType;
|
||||||
|
src: MessageTarget;
|
||||||
|
dest: MessageTarget | null;
|
||||||
|
option?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
message: MessageEntry;
|
||||||
|
generalId: number;
|
||||||
|
generalName: string;
|
||||||
|
nationId: number;
|
||||||
|
permission: number;
|
||||||
|
canRespondDiplomacy: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'set-target', type: MessageType, target: MessageTarget): void;
|
||||||
|
(event: 'delete', messageId: number): void;
|
||||||
|
(event: 'respond', messageId: number, response: boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const now = ref(Date.now());
|
||||||
|
let deleteTimer: number | null = null;
|
||||||
|
|
||||||
|
const destination = computed<MessageTarget>(
|
||||||
|
() =>
|
||||||
|
props.message.dest ?? {
|
||||||
|
generalId: 0,
|
||||||
|
generalName: '',
|
||||||
|
nationId: 0,
|
||||||
|
nationName: '재야',
|
||||||
|
color: '#000000',
|
||||||
|
icon: '/image/icons/default.jpg',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalid = computed(() => props.message.option?.invalid === true);
|
||||||
|
const hasAction = computed(() => typeof props.message.option?.action === 'string');
|
||||||
|
const nationDirection = computed(() => {
|
||||||
|
if (props.message.src.nationId === destination.value.nationId) {
|
||||||
|
return 'local';
|
||||||
|
}
|
||||||
|
return props.message.src.nationId === props.nationId ? 'src' : 'dest';
|
||||||
|
});
|
||||||
|
|
||||||
|
const parseMessageTime = (): number => {
|
||||||
|
const normalized = props.message.time.includes('T')
|
||||||
|
? props.message.time
|
||||||
|
: `${props.message.time.replace(' ', 'T')}Z`;
|
||||||
|
return Date.parse(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deletable = computed(() => {
|
||||||
|
if (invalid.value || hasAction.value || props.message.src.generalId !== props.generalId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (props.message.option?.deletable === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const sentAt = parseMessageTime();
|
||||||
|
return Number.isFinite(sentAt) && sentAt + 5 * 60 * 1000 > now.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const scheduleDeleteExpiry = () => {
|
||||||
|
const sentAt = parseMessageTime();
|
||||||
|
if (!Number.isFinite(sentAt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delay = sentAt + 5 * 60 * 1000 - Date.now();
|
||||||
|
if (delay <= 0) {
|
||||||
|
now.value = Date.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteTimer = window.setTimeout(() => {
|
||||||
|
now.value = Date.now();
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isBright = (color: string): boolean => {
|
||||||
|
const match = /^#([0-9a-f]{6})$/i.exec(color);
|
||||||
|
if (!match) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const value = Number.parseInt(match[1]!, 16);
|
||||||
|
const red = (value >> 16) & 0xff;
|
||||||
|
const green = (value >> 8) & 0xff;
|
||||||
|
const blue = value & 0xff;
|
||||||
|
return red * 0.299 + green * 0.587 + blue * 0.114 > 160;
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconUrl = computed(() => {
|
||||||
|
const icon = props.message.src.icon?.trim();
|
||||||
|
if (!icon) {
|
||||||
|
return '/image/icons/default.jpg';
|
||||||
|
}
|
||||||
|
if (icon.startsWith('/') || /^https?:\/\//i.test(icon)) {
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
|
return `${import.meta.env.BASE_URL}${icon.replace(/^\/+/, '')}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetClass = (target: MessageTarget) => ({
|
||||||
|
'msg-target': true,
|
||||||
|
'msg-bright': isBright(target.color),
|
||||||
|
'msg-dark': !isBright(target.color),
|
||||||
|
});
|
||||||
|
|
||||||
|
const setTarget = (target: MessageTarget) => {
|
||||||
|
emit('set-target', props.message.msgType, target);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestDelete = () => {
|
||||||
|
if (!window.confirm('삭제하시겠습니까?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('delete', props.message.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const respond = (response: boolean) => {
|
||||||
|
if (!window.confirm(response ? '수락하시겠습니까?' : '거절하시겠습니까?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('respond', props.message.id, response);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(scheduleDeleteExpiry);
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (deleteTimer !== null) {
|
||||||
|
window.clearTimeout(deleteTimer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article
|
||||||
|
:id="`msg_${message.id}`"
|
||||||
|
:class="['msg-plate', `msg-plate-${message.msgType}`, `msg-plate-${nationDirection}`]"
|
||||||
|
:data-id="message.id"
|
||||||
|
>
|
||||||
|
<div class="msg-icon">
|
||||||
|
<img class="general-icon" width="64" height="64" :src="iconUrl" :alt="message.src.generalName" />
|
||||||
|
</div>
|
||||||
|
<div class="msg-body">
|
||||||
|
<div class="msg-header">
|
||||||
|
<button v-if="deletable" class="delete-message" type="button" @click="requestDelete">❌</button>
|
||||||
|
|
||||||
|
<template v-if="message.msgType === 'private'">
|
||||||
|
<template v-if="message.src.generalId === generalId">
|
||||||
|
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }"
|
||||||
|
>나</span
|
||||||
|
>
|
||||||
|
<span class="msg-from-to">▶</span>
|
||||||
|
<button
|
||||||
|
:class="targetClass(destination)"
|
||||||
|
:style="{ backgroundColor: destination.color }"
|
||||||
|
type="button"
|
||||||
|
@click="setTarget(destination)"
|
||||||
|
>
|
||||||
|
{{ destination.generalName }}:{{ destination.nationName }} | ↩
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button
|
||||||
|
:class="targetClass(message.src)"
|
||||||
|
:style="{ backgroundColor: message.src.color }"
|
||||||
|
type="button"
|
||||||
|
@click="setTarget(message.src)"
|
||||||
|
>
|
||||||
|
{{ message.src.generalName }}:{{ message.src.nationName }} | ↩
|
||||||
|
</button>
|
||||||
|
<span class="msg-from-to">▶</span>
|
||||||
|
<span :class="targetClass(destination)" :style="{ backgroundColor: destination.color }"
|
||||||
|
>나</span
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="message.msgType === 'national' && message.src.nationId === destination.nationId">
|
||||||
|
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
|
||||||
|
{{ message.src.generalName }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template
|
||||||
|
v-else-if="(message.msgType === 'national' || message.msgType === 'diplomacy') && permission >= 4"
|
||||||
|
>
|
||||||
|
<template v-if="message.src.nationId === nationId">
|
||||||
|
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
|
||||||
|
{{ message.src.generalName }}
|
||||||
|
</span>
|
||||||
|
<span class="msg-from-to">▶</span>
|
||||||
|
<button
|
||||||
|
:class="targetClass(destination)"
|
||||||
|
:style="{ backgroundColor: destination.color }"
|
||||||
|
type="button"
|
||||||
|
@click="setTarget(destination)"
|
||||||
|
>
|
||||||
|
{{ destination.nationName }} | ↩
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
:class="targetClass(message.src)"
|
||||||
|
:style="{ backgroundColor: message.src.color }"
|
||||||
|
type="button"
|
||||||
|
@click="setTarget(message.src)"
|
||||||
|
>
|
||||||
|
{{ message.src.generalName }}:{{ message.src.nationName }} | ↩
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="message.msgType === 'national' || message.msgType === 'diplomacy'">
|
||||||
|
<template v-if="message.src.nationId === nationId">
|
||||||
|
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
|
||||||
|
{{ message.src.generalName }}
|
||||||
|
</span>
|
||||||
|
<span class="msg-from-to">▶</span>
|
||||||
|
<span :class="targetClass(destination)" :style="{ backgroundColor: destination.color }">
|
||||||
|
{{ destination.nationName }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span v-else :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
|
||||||
|
{{ message.src.generalName }}:{{ message.src.nationName }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-else-if="message.src.generalId !== generalId"
|
||||||
|
:class="targetClass(message.src)"
|
||||||
|
:style="{ backgroundColor: message.src.color }"
|
||||||
|
type="button"
|
||||||
|
@click="setTarget(message.src)"
|
||||||
|
>
|
||||||
|
{{ message.src.generalName }}:{{ message.src.nationName }} | ↩
|
||||||
|
</button>
|
||||||
|
<span v-else :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
|
||||||
|
{{ message.src.generalName }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="msg-time"><{{ message.time }}></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="['msg-content', invalid ? 'msg-invalid' : 'msg-valid']">
|
||||||
|
{{ invalid ? '삭제된 메시지입니다' : message.text }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasAction" class="message-response">
|
||||||
|
<button
|
||||||
|
class="prompt-yes"
|
||||||
|
type="button"
|
||||||
|
:disabled="message.msgType === 'diplomacy' && !canRespondDiplomacy"
|
||||||
|
@click="respond(true)"
|
||||||
|
>
|
||||||
|
수락
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="prompt-no"
|
||||||
|
type="button"
|
||||||
|
:disabled="message.msgType === 'diplomacy' && !canRespondDiplomacy"
|
||||||
|
@click="respond(false)"
|
||||||
|
>
|
||||||
|
거절
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.msg-plate {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 64px minmax(0, 1fr);
|
||||||
|
width: 100%;
|
||||||
|
min-height: 64px;
|
||||||
|
outline: 1px solid gray;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12.5px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-plate-private {
|
||||||
|
background-color: #5d1e1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-plate-private.msg-plate-dest {
|
||||||
|
background-color: #5d461a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-plate-public {
|
||||||
|
background-color: #141c65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-plate-national,
|
||||||
|
.msg-plate-diplomacy {
|
||||||
|
background-color: #00582c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-plate-national.msg-plate-dest,
|
||||||
|
.msg-plate-diplomacy.msg-plate-dest {
|
||||||
|
background-color: #704615;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-plate-national.msg-plate-src,
|
||||||
|
.msg-plate-diplomacy.msg-plate-src {
|
||||||
|
background-color: #70153b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-right: 1px solid gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.general-icon {
|
||||||
|
display: block;
|
||||||
|
width: 64px;
|
||||||
|
max-width: none;
|
||||||
|
height: 64px;
|
||||||
|
object-fit: fill;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-body {
|
||||||
|
min-width: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-header {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-target {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 2px 2px 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 2px 3px;
|
||||||
|
box-shadow: 2px 2px #000;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.msg-target {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-bright {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-dark {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-from-to {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-time {
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-message {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
margin: 2px 2px 0;
|
||||||
|
border: 1px solid #ffc107;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: transparent;
|
||||||
|
padding: 2px 4px;
|
||||||
|
color: #ffc107;
|
||||||
|
font-size: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-content {
|
||||||
|
overflow: hidden;
|
||||||
|
margin-right: 5px;
|
||||||
|
margin-left: 10px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-invalid {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-response {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0;
|
||||||
|
margin-top: 5px;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-response button {
|
||||||
|
min-width: 42px;
|
||||||
|
border: 1px outset buttonborder;
|
||||||
|
background: buttonface;
|
||||||
|
padding: 1px 6px;
|
||||||
|
color: buttontext;
|
||||||
|
font-size: 12.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-response button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -23,6 +23,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
type MapLayout = Awaited<ReturnType<typeof trpc.world.getMapLayout.query>>;
|
type MapLayout = Awaited<ReturnType<typeof trpc.world.getMapLayout.query>>;
|
||||||
type CommandTable = Awaited<ReturnType<typeof trpc.turns.getCommandTable.query>>;
|
type CommandTable = Awaited<ReturnType<typeof trpc.turns.getCommandTable.query>>;
|
||||||
type MessageBundle = Awaited<ReturnType<typeof trpc.messages.getRecent.query>>;
|
type MessageBundle = Awaited<ReturnType<typeof trpc.messages.getRecent.query>>;
|
||||||
|
type MessageContacts = Awaited<ReturnType<typeof trpc.messages.getContacts.query>>;
|
||||||
type BoardAccess = Awaited<ReturnType<typeof trpc.board.getAccess.query>>;
|
type BoardAccess = Awaited<ReturnType<typeof trpc.board.getAccess.query>>;
|
||||||
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>[number];
|
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>[number];
|
||||||
|
|
||||||
@@ -37,12 +38,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
const mapLayout = ref<MapLayout | null>(null);
|
const mapLayout = ref<MapLayout | null>(null);
|
||||||
const commandTable = ref<CommandTable | null>(null);
|
const commandTable = ref<CommandTable | null>(null);
|
||||||
const messages = ref<MessageBundle | null>(null);
|
const messages = ref<MessageBundle | null>(null);
|
||||||
|
const messageContacts = ref<MessageContacts | null>(null);
|
||||||
const boardAccess = ref<BoardAccess | null>(null);
|
const boardAccess = ref<BoardAccess | null>(null);
|
||||||
const reservedGeneralTurns = ref<ReservedTurnView[] | null>(null);
|
const reservedGeneralTurns = ref<ReservedTurnView[] | null>(null);
|
||||||
const reservedNationTurns = ref<ReservedTurnView[] | null>(null);
|
const reservedNationTurns = ref<ReservedTurnView[] | null>(null);
|
||||||
|
|
||||||
const messageDraftText = ref('');
|
const messageDraftText = ref('');
|
||||||
const targetMailbox = ref<number>(MESSAGE_MAILBOX_PUBLIC);
|
const targetMailbox = ref<number>(MESSAGE_MAILBOX_PUBLIC);
|
||||||
|
let initializedMailboxGeneralId: number | null = null;
|
||||||
|
|
||||||
const general = computed(() => generalContext.value?.general ?? null);
|
const general = computed(() => generalContext.value?.general ?? null);
|
||||||
const city = computed(() => generalContext.value?.city ?? null);
|
const city = computed(() => generalContext.value?.city ?? null);
|
||||||
@@ -86,18 +89,85 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
} as const;
|
} as const;
|
||||||
});
|
});
|
||||||
|
|
||||||
const mailboxOptions = computed(() => {
|
const mailboxGroups = computed(() => {
|
||||||
const options: Array<{ label: string; value: number; disabled?: boolean }> = [
|
type MailboxOption = {
|
||||||
{ label: '공공', value: MESSAGE_MAILBOX_PUBLIC },
|
label: string;
|
||||||
|
value: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
color?: string;
|
||||||
|
};
|
||||||
|
type MailboxGroup = {
|
||||||
|
label: string;
|
||||||
|
color?: string;
|
||||||
|
options: MailboxOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const ownNationId = general.value?.nationId ?? 0;
|
||||||
|
const ownMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + ownNationId;
|
||||||
|
const permission = messages.value?.permission ?? -1;
|
||||||
|
const contacts = messageContacts.value?.nation ?? [];
|
||||||
|
const ownNation = contacts.find((nation) => nation.mailbox === ownMailbox);
|
||||||
|
const groups: MailboxGroup[] = [
|
||||||
|
{
|
||||||
|
label: '즐겨찾기',
|
||||||
|
color: '#000000',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '【 아국 메세지 】',
|
||||||
|
value: ownMailbox,
|
||||||
|
color: ownNation?.color ?? '#000000',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '【 전체 메세지 】',
|
||||||
|
value: MESSAGE_MAILBOX_PUBLIC,
|
||||||
|
color: '#000000',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
if (nationId.value) {
|
|
||||||
options.push({ label: '국가', value: MESSAGE_MAILBOX_NATIONAL_BASE + nationId.value });
|
if (permission >= 4) {
|
||||||
} else {
|
groups.push({
|
||||||
options.push({ label: '국가', value: -1, disabled: true });
|
label: '외교메시지',
|
||||||
|
color: '#000000',
|
||||||
|
options: contacts
|
||||||
|
.filter((nation) => nation.mailbox !== ownMailbox && nation.nationId > 0)
|
||||||
|
.map((nation) => ({
|
||||||
|
label: nation.name,
|
||||||
|
value: nation.mailbox,
|
||||||
|
color: nation.color,
|
||||||
|
})),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
options.push({ label: '외교', value: -2, disabled: true });
|
|
||||||
options.push({ label: '개인', value: -3, disabled: true });
|
const sortedContacts = [...contacts].sort((left, right) => {
|
||||||
return options;
|
if (left.mailbox === ownMailbox) return -1;
|
||||||
|
if (right.mailbox === ownMailbox) return 1;
|
||||||
|
return left.mailbox - right.mailbox;
|
||||||
|
});
|
||||||
|
for (const nation of sortedContacts) {
|
||||||
|
const options = [...nation.general]
|
||||||
|
.filter(([id]) => id !== generalId.value)
|
||||||
|
.sort((left, right) => left[1].localeCompare(right[1], 'ko'))
|
||||||
|
.map(([id, name, flags]) => {
|
||||||
|
const ruler = Boolean(flags & 1);
|
||||||
|
const ambassador = Boolean(flags & 4);
|
||||||
|
return {
|
||||||
|
label: ruler ? `*${name}*` : ambassador ? `#${name}#` : name,
|
||||||
|
value: id,
|
||||||
|
disabled: permission === 4 && ambassador && nation.mailbox !== ownMailbox,
|
||||||
|
color: nation.color,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (options.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
label: nation.name,
|
||||||
|
color: nation.color,
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusLine = computed(() => {
|
const statusLine = computed(() => {
|
||||||
@@ -147,25 +217,32 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
context.general.nationId > 0 && context.general.officerLevel >= 5
|
context.general.nationId > 0 && context.general.officerLevel >= 5
|
||||||
? trpc.turns.reserved.getNation.query({ generalId: id })
|
? trpc.turns.reserved.getNation.query({ generalId: id })
|
||||||
: Promise.resolve(null);
|
: Promise.resolve(null);
|
||||||
const [layout, lobby, map, commands, messageData, access, generalTurns, nationTurns] = await Promise.all([
|
const [layout, lobby, map, commands, messageData, contacts, access, generalTurns, nationTurns] =
|
||||||
layoutPromise,
|
await Promise.all([
|
||||||
trpc.lobby.info.query(),
|
layoutPromise,
|
||||||
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
|
trpc.lobby.info.query(),
|
||||||
trpc.turns.getCommandTable.query({ generalId: id }),
|
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
|
||||||
trpc.messages.getRecent.query({ generalId: id }),
|
trpc.turns.getCommandTable.query({ generalId: id }),
|
||||||
trpc.board.getAccess.query(),
|
trpc.messages.getRecent.query({ generalId: id }),
|
||||||
generalTurnsPromise,
|
trpc.messages.getContacts.query({ generalId: id }),
|
||||||
nationTurnsPromise,
|
trpc.board.getAccess.query(),
|
||||||
]);
|
generalTurnsPromise,
|
||||||
|
nationTurnsPromise,
|
||||||
|
]);
|
||||||
|
|
||||||
mapLayout.value = layout;
|
mapLayout.value = layout;
|
||||||
lobbyInfo.value = lobby;
|
lobbyInfo.value = lobby;
|
||||||
worldMap.value = map;
|
worldMap.value = map;
|
||||||
commandTable.value = commands;
|
commandTable.value = commands;
|
||||||
messages.value = messageData;
|
messages.value = messageData;
|
||||||
|
messageContacts.value = contacts;
|
||||||
boardAccess.value = access;
|
boardAccess.value = access;
|
||||||
reservedGeneralTurns.value = generalTurns;
|
reservedGeneralTurns.value = generalTurns;
|
||||||
reservedNationTurns.value = nationTurns;
|
reservedNationTurns.value = nationTurns;
|
||||||
|
if (initializedMailboxGeneralId !== id) {
|
||||||
|
targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId;
|
||||||
|
initializedMailboxGeneralId = id;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = resolveErrorMessage(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -200,12 +277,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
messageDraftText.value = '';
|
||||||
await trpc.messages.send.mutate({
|
await trpc.messages.send.mutate({
|
||||||
generalId: id,
|
generalId: id,
|
||||||
mailbox,
|
mailbox,
|
||||||
text,
|
text,
|
||||||
});
|
});
|
||||||
messageDraftText.value = '';
|
|
||||||
await refreshMessages();
|
await refreshMessages();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = resolveErrorMessage(err);
|
||||||
@@ -260,6 +337,44 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readLatestMessage = async (type: 'private' | 'diplomacy', messageId: number) => {
|
||||||
|
const id = generalId.value;
|
||||||
|
if (!id || messageId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await trpc.messages.readLatest.mutate({
|
||||||
|
generalId: id,
|
||||||
|
type,
|
||||||
|
messageId,
|
||||||
|
});
|
||||||
|
if (messages.value) {
|
||||||
|
messages.value = {
|
||||||
|
...messages.value,
|
||||||
|
latestRead: {
|
||||||
|
...messages.value.latestRead,
|
||||||
|
[type]: Math.max(messages.value.latestRead[type], messageId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = resolveErrorMessage(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteMessage = async (messageId: number) => {
|
||||||
|
const id = generalId.value;
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await trpc.messages.delete.mutate({ generalId: id, messageId });
|
||||||
|
await refreshMessages();
|
||||||
|
} catch (err) {
|
||||||
|
error.value = resolveErrorMessage(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const setGeneralTurn = async (turnIndex: number, action: string) => {
|
const setGeneralTurn = async (turnIndex: number, action: string) => {
|
||||||
const id = generalId.value;
|
const id = generalId.value;
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -484,12 +599,13 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
selectedCity,
|
selectedCity,
|
||||||
commandTable,
|
commandTable,
|
||||||
messages,
|
messages,
|
||||||
|
messageContacts,
|
||||||
boardAccess,
|
boardAccess,
|
||||||
reservedGeneralTurns,
|
reservedGeneralTurns,
|
||||||
reservedNationTurns,
|
reservedNationTurns,
|
||||||
messageDraftText,
|
messageDraftText,
|
||||||
targetMailbox,
|
targetMailbox,
|
||||||
mailboxOptions,
|
mailboxGroups,
|
||||||
statusLine,
|
statusLine,
|
||||||
realtimeLabel,
|
realtimeLabel,
|
||||||
setRealtimeEnabled,
|
setRealtimeEnabled,
|
||||||
@@ -498,6 +614,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
loadOlderMessages,
|
loadOlderMessages,
|
||||||
respondToMessage,
|
respondToMessage,
|
||||||
|
readLatestMessage,
|
||||||
|
deleteMessage,
|
||||||
setGeneralTurn,
|
setGeneralTurn,
|
||||||
shiftGeneralTurns,
|
shiftGeneralTurns,
|
||||||
setNationTurn,
|
setNationTurn,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const {
|
|||||||
reservedNationTurns,
|
reservedNationTurns,
|
||||||
messageDraftText,
|
messageDraftText,
|
||||||
targetMailbox,
|
targetMailbox,
|
||||||
mailboxOptions,
|
mailboxGroups,
|
||||||
statusLine,
|
statusLine,
|
||||||
realtimeLabel,
|
realtimeLabel,
|
||||||
} = storeToRefs(dashboard);
|
} = storeToRefs(dashboard);
|
||||||
@@ -221,22 +221,26 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="mobileTab === 'messages'" class="mobile-panel">
|
<div v-if="mobileTab === 'messages'" class="mobile-panel">
|
||||||
<PanelCard title="메시지함">
|
<MessagePanel
|
||||||
<MessagePanel
|
class="mobile-message-panel"
|
||||||
:messages="messages"
|
:messages="messages"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:target-mailbox="targetMailbox"
|
:target-mailbox="targetMailbox"
|
||||||
:draft-text="messageDraftText"
|
:draft-text="messageDraftText"
|
||||||
:mailbox-options="mailboxOptions"
|
:mailbox-groups="mailboxGroups"
|
||||||
:can-respond-diplomacy="messages?.canRespondDiplomacy ?? false"
|
:general-id="general?.id ?? 0"
|
||||||
@update:target-mailbox="targetMailbox = $event"
|
:general-name="general?.name ?? ''"
|
||||||
@update:draft-text="messageDraftText = $event"
|
:nation-id="general?.nationId ?? 0"
|
||||||
@send="dashboard.sendMessage"
|
:can-respond-diplomacy="messages?.canRespondDiplomacy ?? false"
|
||||||
@load-older="dashboard.loadOlderMessages"
|
@update:target-mailbox="targetMailbox = $event"
|
||||||
@refresh="dashboard.refreshMessages"
|
@update:draft-text="messageDraftText = $event"
|
||||||
@respond="dashboard.respondToMessage"
|
@send="dashboard.sendMessage"
|
||||||
/>
|
@load-older="dashboard.loadOlderMessages"
|
||||||
</PanelCard>
|
@refresh="dashboard.refreshMessages"
|
||||||
|
@respond="dashboard.respondToMessage"
|
||||||
|
@read-latest="dashboard.readLatestMessage"
|
||||||
|
@delete="dashboard.deleteMessage"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -256,22 +260,6 @@ watch(
|
|||||||
<div>세력 {{ lobbyInfo?.nationCnt ?? '-' }}</div>
|
<div>세력 {{ lobbyInfo?.nationCnt ?? '-' }}</div>
|
||||||
</div>
|
</div>
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="메시지함">
|
|
||||||
<MessagePanel
|
|
||||||
:messages="messages"
|
|
||||||
:loading="loading"
|
|
||||||
:target-mailbox="targetMailbox"
|
|
||||||
:draft-text="messageDraftText"
|
|
||||||
:mailbox-options="mailboxOptions"
|
|
||||||
:can-respond-diplomacy="messages?.canRespondDiplomacy ?? false"
|
|
||||||
@update:target-mailbox="targetMailbox = $event"
|
|
||||||
@update:draft-text="messageDraftText = $event"
|
|
||||||
@send="dashboard.sendMessage"
|
|
||||||
@load-older="dashboard.loadOlderMessages"
|
|
||||||
@refresh="dashboard.refreshMessages"
|
|
||||||
@respond="dashboard.respondToMessage"
|
|
||||||
/>
|
|
||||||
</PanelCard>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
@@ -307,6 +295,26 @@ watch(
|
|||||||
<div v-else class="placeholder">개인 기록 영역</div>
|
<div v-else class="placeholder">개인 기록 영역</div>
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
</div>
|
</div>
|
||||||
|
<MessagePanel
|
||||||
|
class="desktop-message-panel"
|
||||||
|
:messages="messages"
|
||||||
|
:loading="loading"
|
||||||
|
:target-mailbox="targetMailbox"
|
||||||
|
:draft-text="messageDraftText"
|
||||||
|
:mailbox-groups="mailboxGroups"
|
||||||
|
:general-id="general?.id ?? 0"
|
||||||
|
:general-name="general?.name ?? ''"
|
||||||
|
:nation-id="general?.nationId ?? 0"
|
||||||
|
:can-respond-diplomacy="messages?.canRespondDiplomacy ?? false"
|
||||||
|
@update:target-mailbox="targetMailbox = $event"
|
||||||
|
@update:draft-text="messageDraftText = $event"
|
||||||
|
@send="dashboard.sendMessage"
|
||||||
|
@load-older="dashboard.loadOlderMessages"
|
||||||
|
@refresh="dashboard.refreshMessages"
|
||||||
|
@respond="dashboard.respondToMessage"
|
||||||
|
@read-latest="dashboard.readLatestMessage"
|
||||||
|
@delete="dashboard.deleteMessage"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
@@ -401,6 +409,16 @@ button {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.desktop-message-panel {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-message-panel {
|
||||||
|
width: 100vw;
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: -24px;
|
||||||
|
}
|
||||||
|
|
||||||
.layout-mobile {
|
.layout-mobile {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ Move items into the main docs once they are finalized.
|
|||||||
- [AI suggestion] Define gateway login handoff + profile selection flow for the game frontend (token delivery, auto-login, cookie vs localStorage policy).
|
- [AI suggestion] Define gateway login handoff + profile selection flow for the game frontend (token delivery, auto-login, cookie vs localStorage policy).
|
||||||
- [AI suggestion] Implement Public 화면: 캐싱된 지도/중원정세/세력일람 + 제한된 장수일람 API/뷰.
|
- [AI suggestion] Implement Public 화면: 캐싱된 지도/중원정세/세력일람 + 제한된 장수일람 API/뷰.
|
||||||
- [AI suggestion] Define main screen SSE contract + 실시간 동기화 토글 연동 (지도/명령/도시/국가/장수/메시지/동향/기록).
|
- [AI suggestion] Define main screen SSE contract + 실시간 동기화 토글 연동 (지도/명령/도시/국가/장수/메시지/동향/기록).
|
||||||
- [AI suggestion] Port legacy main UI components into `app/game-frontend` (MapViewer, CommandSelectForm, MessagePanel 등).
|
- [AI suggestion] Port remaining legacy main UI components into `app/game-frontend` (MessagePanel 이관 완료; 나머지 패널 추적).
|
||||||
- [AI suggestion] Provide map city name/position data for MapViewer (API or scenario export) and replace placeholder layout.
|
- [AI suggestion] Provide map city name/position data for MapViewer (API or scenario export) and replace placeholder layout.
|
||||||
- [AI suggestion] Implement join/빙의 UI and post-creation refresh flow.
|
- [AI suggestion] Implement join/빙의 UI and post-creation refresh flow.
|
||||||
- [AI suggestion] Build and maintain a legacy-to-SPA route mapping table with data requirements.
|
- [AI suggestion] Build and maintain a legacy-to-SPA route mapping table with data requirements.
|
||||||
@@ -57,7 +57,7 @@ Move items into the main docs once they are finalized.
|
|||||||
- [AI suggestion] Wire `realtimeEnabled` to an SSE or polling channel and update main dashboard data buckets (map/lobby/messages/commands).
|
- [AI suggestion] Wire `realtimeEnabled` to an SSE or polling channel and update main dashboard data buckets (map/lobby/messages/commands).
|
||||||
- [AI suggestion] Finalize static asset and web base URLs (`VITE_GAME_WEB_URL`, `VITE_GAME_ASSET_URL`) and document deployment mapping for legacy images.
|
- [AI suggestion] Finalize static asset and web base URLs (`VITE_GAME_WEB_URL`, `VITE_GAME_ASSET_URL`) and document deployment mapping for legacy images.
|
||||||
- [AI suggestion] Expand Join UI to cover inherit options (특기/도시/턴타임/보너스 스탯) using `join.getConfig` and `join.createGeneral` inputs.
|
- [AI suggestion] Expand Join UI to cover inherit options (특기/도시/턴타임/보너스 스탯) using `join.getConfig` and `join.createGeneral` inputs.
|
||||||
- [AI suggestion] Extend MessagePanel to support private/diplomacy targets and surface sender/receiver metadata from message payloads.
|
- [x] Extend MessagePanel to support private/diplomacy targets and surface sender/receiver metadata from message payloads.
|
||||||
- [AI suggestion] Port legacy TipTap-based editors (국가 방침/임관 권유) into game-frontend and reuse the new board image upload policy.
|
- [AI suggestion] Port legacy TipTap-based editors (국가 방침/임관 권유) into game-frontend and reuse the new board image upload policy.
|
||||||
|
|
||||||
## Runtime and Operations (Lower Priority)
|
## Runtime and Operations (Lower Priority)
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
|
||||||
|
import { canonicalFrontendFixture as fixture } from './fixtures/canonical';
|
||||||
|
|
||||||
|
const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102';
|
||||||
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
|
const errorResponse = (path: string, message: string) => ({
|
||||||
|
error: {
|
||||||
|
message,
|
||||||
|
code: -32000,
|
||||||
|
data: { code: 'BAD_REQUEST', httpStatus: 400, path },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const operationNames = (route: Route): string[] => {
|
||||||
|
const pathname = new URL(route.request().url()).pathname;
|
||||||
|
return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||||
|
};
|
||||||
|
|
||||||
|
const general = {
|
||||||
|
id: 1,
|
||||||
|
name: '테스트장수',
|
||||||
|
npcState: 0,
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
picture: 'default.jpg',
|
||||||
|
imageServer: 0,
|
||||||
|
officerLevel: 1,
|
||||||
|
stats: { leadership: 80, strength: 70, intelligence: 90 },
|
||||||
|
gold: 1000,
|
||||||
|
rice: 1000,
|
||||||
|
crew: 500,
|
||||||
|
train: 100,
|
||||||
|
atmos: 100,
|
||||||
|
injury: 0,
|
||||||
|
experience: 1200,
|
||||||
|
dedication: 900,
|
||||||
|
items: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
const generalContext = {
|
||||||
|
general,
|
||||||
|
city: {
|
||||||
|
id: 1,
|
||||||
|
name: '낙양',
|
||||||
|
level: 7,
|
||||||
|
nationId: 1,
|
||||||
|
population: 50000,
|
||||||
|
agriculture: 5000,
|
||||||
|
commerce: 5000,
|
||||||
|
security: 5000,
|
||||||
|
defence: 5000,
|
||||||
|
wall: 5000,
|
||||||
|
supplyState: 1,
|
||||||
|
frontState: 2,
|
||||||
|
},
|
||||||
|
nation: {
|
||||||
|
id: 1,
|
||||||
|
name: '테스트국',
|
||||||
|
color: '#d32f2f',
|
||||||
|
level: 5,
|
||||||
|
gold: 10000,
|
||||||
|
rice: 10000,
|
||||||
|
tech: 1200,
|
||||||
|
typeCode: 'che_군벌',
|
||||||
|
capitalCityId: 1,
|
||||||
|
},
|
||||||
|
settings: {},
|
||||||
|
penalties: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const target = (generalId: number, generalName: string, nationId: number, nationName: string, color: string) => ({
|
||||||
|
generalId,
|
||||||
|
generalName,
|
||||||
|
nationId,
|
||||||
|
nationName,
|
||||||
|
color,
|
||||||
|
icon: '/image/icons/default.jpg',
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownTarget = target(1, '테스트장수', 1, '테스트국', '#d32f2f');
|
||||||
|
const foreignTarget = target(8, '상대장수', 2, '상대국', '#2457a6');
|
||||||
|
const messageTime = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
|
||||||
|
const buildMessages = (permission: number) => ({
|
||||||
|
result: true,
|
||||||
|
public: [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
msgType: 'public',
|
||||||
|
src: ownTarget,
|
||||||
|
dest: null,
|
||||||
|
text: '전체 메시지 본문',
|
||||||
|
option: {},
|
||||||
|
time: messageTime,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
national: [
|
||||||
|
{
|
||||||
|
id: 102,
|
||||||
|
msgType: 'national',
|
||||||
|
src: ownTarget,
|
||||||
|
dest: target(0, '', 1, '테스트국', '#d32f2f'),
|
||||||
|
text: '국가 메시지 본문',
|
||||||
|
option: {},
|
||||||
|
time: messageTime,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
private: [
|
||||||
|
{
|
||||||
|
id: 103,
|
||||||
|
msgType: 'private',
|
||||||
|
src: foreignTarget,
|
||||||
|
dest: ownTarget,
|
||||||
|
text: '개인 메시지 본문',
|
||||||
|
option: {},
|
||||||
|
time: messageTime,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
diplomacy: [
|
||||||
|
{
|
||||||
|
id: 104,
|
||||||
|
msgType: 'diplomacy',
|
||||||
|
src: foreignTarget,
|
||||||
|
dest: target(0, '', 1, '테스트국', '#d32f2f'),
|
||||||
|
text: permission >= 3 ? '외교 메시지 본문' : '(외교 메시지입니다)',
|
||||||
|
option:
|
||||||
|
permission >= 3
|
||||||
|
? { action: 'noAggression', deletable: false }
|
||||||
|
: { action: 'noAggression', deletable: false, invalid: true },
|
||||||
|
time: messageTime,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sequence: 104,
|
||||||
|
nationId: 1,
|
||||||
|
generalName: general.name,
|
||||||
|
permission,
|
||||||
|
canRespondDiplomacy: permission >= 4 && general.officerLevel > 4,
|
||||||
|
latestRead: { private: 0, diplomacy: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const contacts = {
|
||||||
|
nation: [
|
||||||
|
{
|
||||||
|
nationId: 0,
|
||||||
|
mailbox: 9000,
|
||||||
|
name: '재야',
|
||||||
|
color: '#000000',
|
||||||
|
general: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nationId: 1,
|
||||||
|
mailbox: 9001,
|
||||||
|
name: '테스트국',
|
||||||
|
color: '#d32f2f',
|
||||||
|
general: [
|
||||||
|
[1, '테스트장수', 4],
|
||||||
|
[2, '아군군주', 1],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nationId: 2,
|
||||||
|
mailbox: 9002,
|
||||||
|
name: '상대국',
|
||||||
|
color: '#2457a6',
|
||||||
|
general: [
|
||||||
|
[8, '상대외교관', 4],
|
||||||
|
[9, '상대일반', 0],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const installFixture = async (
|
||||||
|
page: Page,
|
||||||
|
options: { permission: number; sendError?: string }
|
||||||
|
): Promise<Array<{ operation: string; body: unknown }>> => {
|
||||||
|
const mutations: Array<{ operation: string; body: unknown }> = [];
|
||||||
|
await page.addInitScript(
|
||||||
|
({ gameToken, profile }) => {
|
||||||
|
window.localStorage.setItem('sammo-game-token', gameToken);
|
||||||
|
window.localStorage.setItem('sammo-game-profile', profile);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
gameToken: fixture.game.session.gameToken,
|
||||||
|
profile: fixture.game.session.profile,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await page.route('**/image/**', (route) => route.fulfill({ status: 204, body: '' }));
|
||||||
|
await page.route('**/che/api/events**', (route) => route.abort());
|
||||||
|
await page.route('**/che/api/trpc/**', async (route) => {
|
||||||
|
const body = route.request().postDataJSON();
|
||||||
|
const results = operationNames(route).map((operation) => {
|
||||||
|
if (operation === 'lobby.info') {
|
||||||
|
return response({ ...fixture.game.lobby, myGeneral: general });
|
||||||
|
}
|
||||||
|
if (operation === 'general.me') return response(generalContext);
|
||||||
|
if (operation === 'world.getMapLayout') return response(fixture.game.mapLayout);
|
||||||
|
if (operation === 'world.getMap') {
|
||||||
|
return response({ ...fixture.game.map, myCity: 1, myNation: 1 });
|
||||||
|
}
|
||||||
|
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
|
||||||
|
if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') {
|
||||||
|
return response([]);
|
||||||
|
}
|
||||||
|
if (operation === 'messages.getRecent') return response(buildMessages(options.permission));
|
||||||
|
if (operation === 'messages.getContacts') return response(contacts);
|
||||||
|
if (operation === 'board.getAccess') return response({ canMeeting: true, canSecret: true });
|
||||||
|
if (operation === 'tournament.getState') return response({ stage: 0 });
|
||||||
|
if (
|
||||||
|
operation === 'messages.send' ||
|
||||||
|
operation === 'messages.readLatest' ||
|
||||||
|
operation === 'messages.delete' ||
|
||||||
|
operation === 'messages.respond'
|
||||||
|
) {
|
||||||
|
mutations.push({ operation, body });
|
||||||
|
if (operation === 'messages.send' && options.sendError) {
|
||||||
|
return errorResponse(operation, options.sendError);
|
||||||
|
}
|
||||||
|
return response(operation === 'messages.respond' ? { result: true, reason: 'success' } : { ok: true });
|
||||||
|
}
|
||||||
|
return errorResponse(operation, `Unhandled message fixture operation: ${operation}`);
|
||||||
|
});
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(results),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return mutations;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMessages = async (page: Page, viewport: { width: number; height: number }) => {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
await page.goto(`http://127.0.0.1:${gamePort}/che/`);
|
||||||
|
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||||
|
if (viewport.width <= 1024) {
|
||||||
|
await page.getByRole('button', { name: '메시지', exact: true }).click();
|
||||||
|
}
|
||||||
|
await expect(page.locator('.MessagePanel')).toBeVisible();
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const viewport of [
|
||||||
|
{ width: 1000, height: 900 },
|
||||||
|
{ width: 500, height: 900 },
|
||||||
|
]) {
|
||||||
|
test(`matches the reference message computed DOM at ${viewport.width}px Chromium viewport`, async ({ page }) => {
|
||||||
|
await installFixture(page, { permission: 4 });
|
||||||
|
await openMessages(page, viewport);
|
||||||
|
const geometry = await page.locator('.MessagePanel').evaluate((panel) => {
|
||||||
|
const required = (selector: string) => panel.querySelector<HTMLElement>(selector)!;
|
||||||
|
const rect = (element: Element) => {
|
||||||
|
const box = element.getBoundingClientRect();
|
||||||
|
return { x: box.x, y: box.y, width: box.width, height: box.height };
|
||||||
|
};
|
||||||
|
const panelStyle = getComputedStyle(panel);
|
||||||
|
const header = required('.BoardHeader');
|
||||||
|
const plate = required('.msg-plate');
|
||||||
|
const icon = required('.general-icon');
|
||||||
|
return {
|
||||||
|
panel: rect(panel),
|
||||||
|
inputForm: rect(required('.MessageInputForm')),
|
||||||
|
select: rect(required('.message-select')),
|
||||||
|
input: rect(required('.message-text')),
|
||||||
|
submit: rect(required('.message-send')),
|
||||||
|
publicSection: rect(required('.PublicTalk')),
|
||||||
|
nationalSection: rect(required('.NationalTalk')),
|
||||||
|
firstHeader: rect(header),
|
||||||
|
firstPlate: rect(plate),
|
||||||
|
firstIcon: rect(icon),
|
||||||
|
computed: {
|
||||||
|
panelDisplay: panelStyle.display,
|
||||||
|
panelColumns: panelStyle.gridTemplateColumns,
|
||||||
|
panelFontSize: panelStyle.fontSize,
|
||||||
|
headerColor: getComputedStyle(header).color,
|
||||||
|
headerOutlineWidth: getComputedStyle(header).outlineWidth,
|
||||||
|
plateBackgroundColor: getComputedStyle(plate).backgroundColor,
|
||||||
|
plateFontSize: getComputedStyle(plate).fontSize,
|
||||||
|
plateMinHeight: getComputedStyle(plate).minHeight,
|
||||||
|
iconObjectFit: getComputedStyle(icon).objectFit,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(geometry.panel.x).toBeCloseTo(0, 0);
|
||||||
|
expect(geometry.panel.width).toBeCloseTo(viewport.width, 0);
|
||||||
|
expect(geometry.inputForm.width).toBeCloseTo(viewport.width, 0);
|
||||||
|
expect(geometry.select.height).toBeCloseTo(35.5, 0);
|
||||||
|
expect(geometry.submit.height).toBeCloseTo(35.5, 0);
|
||||||
|
expect(geometry.firstHeader.height).toBeCloseTo(25, 0);
|
||||||
|
expect(geometry.firstPlate.height).toBeGreaterThanOrEqual(64);
|
||||||
|
expect(geometry.firstIcon).toMatchObject({ width: 64, height: 64 });
|
||||||
|
expect(geometry.computed).toMatchObject({
|
||||||
|
panelFontSize: '14px',
|
||||||
|
headerColor: 'rgb(255, 255, 255)',
|
||||||
|
headerOutlineWidth: '1px',
|
||||||
|
plateBackgroundColor: 'rgb(20, 28, 101)',
|
||||||
|
plateFontSize: '12.5px',
|
||||||
|
plateMinHeight: '64px',
|
||||||
|
iconObjectFit: 'fill',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (viewport.width === 1000) {
|
||||||
|
expect(geometry.computed.panelDisplay).toBe('grid');
|
||||||
|
expect(geometry.computed.panelColumns).toBe('500px 500px');
|
||||||
|
expect(geometry.select.width).toBeCloseTo(166.66, 0);
|
||||||
|
expect(geometry.input.width).toBeCloseTo(666.66, 0);
|
||||||
|
expect(geometry.submit.width).toBeCloseTo(166.66, 0);
|
||||||
|
expect(geometry.publicSection.width).toBeCloseTo(500, 0);
|
||||||
|
expect(geometry.nationalSection.x).toBeCloseTo(500, 0);
|
||||||
|
} else {
|
||||||
|
expect(geometry.computed.panelDisplay).toBe('block');
|
||||||
|
expect(geometry.select.width).toBeCloseTo(250, 0);
|
||||||
|
expect(geometry.input.width).toBeCloseTo(500, 0);
|
||||||
|
expect(geometry.input.height).toBeCloseTo(33.5, 0);
|
||||||
|
expect(geometry.submit.width).toBeCloseTo(250, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = page.locator('.message-send');
|
||||||
|
await submit.hover();
|
||||||
|
expect(
|
||||||
|
await submit.evaluate((element) => ({
|
||||||
|
cursor: getComputedStyle(element).cursor,
|
||||||
|
backgroundColor: getComputedStyle(element).backgroundColor,
|
||||||
|
}))
|
||||||
|
).toEqual({ cursor: 'pointer', backgroundColor: 'rgb(55, 90, 127)' });
|
||||||
|
await submit.focus();
|
||||||
|
expect(
|
||||||
|
await submit.evaluate((element) => ({
|
||||||
|
outlineWidth: getComputedStyle(element).outlineWidth,
|
||||||
|
boxShadow: getComputedStyle(element).boxShadow,
|
||||||
|
}))
|
||||||
|
).toEqual({ outlineWidth: '0px', boxShadow: 'none' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('exposes ambassador targets, reply, read, delete, and successful send interactions', async ({ page }) => {
|
||||||
|
const mutations = await installFixture(page, { permission: 4 });
|
||||||
|
await openMessages(page, { width: 500, height: 900 });
|
||||||
|
|
||||||
|
const select = page.getByLabel('메시지 수신 대상');
|
||||||
|
await expect(select.locator('option[value="9002"]')).toHaveCount(1);
|
||||||
|
await expect(select.locator('option[value="8"]')).toBeDisabled();
|
||||||
|
await expect(select.locator('option[value="9"]')).toBeEnabled();
|
||||||
|
|
||||||
|
await page.locator('.PrivateTalk .msg-target').filter({ hasText: '상대장수' }).click();
|
||||||
|
await expect(select).toHaveValue('8');
|
||||||
|
|
||||||
|
await page.locator('.PrivateTalk').getByRole('button', { name: '모두 읽음' }).click();
|
||||||
|
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.readLatest').length).toBe(1);
|
||||||
|
|
||||||
|
const deleteButton = page.locator('.PublicTalk .delete-message');
|
||||||
|
page.once('dialog', (dialog) => dialog.accept());
|
||||||
|
await deleteButton.click();
|
||||||
|
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.delete').length).toBe(1);
|
||||||
|
|
||||||
|
await select.selectOption('9999');
|
||||||
|
await page.getByLabel('메시지 입력').fill('전송 성공');
|
||||||
|
await page.getByRole('button', { name: '서신전달&갱신' }).click();
|
||||||
|
await expect(page.getByLabel('메시지 입력')).toHaveValue('');
|
||||||
|
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redacts diplomacy for a low-permission general and preserves the failed-send error flow', async ({ page }) => {
|
||||||
|
const mutations = await installFixture(page, {
|
||||||
|
permission: 2,
|
||||||
|
sendError: '공개 메세지를 보낼 수 없습니다.',
|
||||||
|
});
|
||||||
|
await openMessages(page, { width: 500, height: 900 });
|
||||||
|
|
||||||
|
const select = page.getByLabel('메시지 수신 대상');
|
||||||
|
await expect(select.locator('option[value="9002"]')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.DiplomacyTalk')).toContainText('삭제된 메시지입니다');
|
||||||
|
await expect(page.locator('.DiplomacyTalk')).not.toContainText('외교 메시지 본문');
|
||||||
|
await expect(page.locator('.DiplomacyTalk .message-response button').first()).toBeDisabled();
|
||||||
|
|
||||||
|
await select.selectOption('9999');
|
||||||
|
await page.getByLabel('메시지 입력').fill('차단될 메시지');
|
||||||
|
await page.getByRole('button', { name: '서신전달&갱신' }).click();
|
||||||
|
await expect(page.getByLabel('메시지 입력')).toHaveValue('');
|
||||||
|
await expect(page.locator('.error')).toHaveText('공개 메세지를 보낼 수 없습니다.');
|
||||||
|
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1);
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import { resolve } from 'node:path';
|
|||||||
import { canonicalFrontendFixture as fixture } from './fixtures/canonical';
|
import { canonicalFrontendFixture as fixture } from './fixtures/canonical';
|
||||||
|
|
||||||
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
||||||
|
const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102';
|
||||||
const response = (data: unknown) => ({ result: { data } });
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
|
|
||||||
const operationNames = (route: Route): string[] => {
|
const operationNames = (route: Route): string[] => {
|
||||||
@@ -90,6 +91,7 @@ const messageBundle = (visible: boolean, canRespondDiplomacy = true) => ({
|
|||||||
sequence: visible ? diplomacyMessage.id : -1,
|
sequence: visible ? diplomacyMessage.id : -1,
|
||||||
nationId: 1,
|
nationId: 1,
|
||||||
generalName: general.name,
|
generalName: general.name,
|
||||||
|
permission: canRespondDiplomacy ? 4 : 2,
|
||||||
canRespondDiplomacy,
|
canRespondDiplomacy,
|
||||||
latestRead: { diplomacy: 0, private: 0 },
|
latestRead: { diplomacy: 0, private: 0 },
|
||||||
});
|
});
|
||||||
@@ -131,6 +133,9 @@ const installFixture = async (
|
|||||||
if (operation === 'messages.getRecent') {
|
if (operation === 'messages.getRecent') {
|
||||||
return response(messageBundle(visible, options.canRespondDiplomacy));
|
return response(messageBundle(visible, options.canRespondDiplomacy));
|
||||||
}
|
}
|
||||||
|
if (operation === 'messages.getContacts') return response({ nation: [] });
|
||||||
|
if (operation === 'board.getAccess') return response({ canMeeting: true, canSecret: true });
|
||||||
|
if (operation === 'tournament.getState') return response({ stage: 0 });
|
||||||
if (operation === 'messages.respond') {
|
if (operation === 'messages.respond') {
|
||||||
mutations.push({ operation, body: requestBody });
|
mutations.push({ operation, body: requestBody });
|
||||||
if (options.acceptResponse) {
|
if (options.acceptResponse) {
|
||||||
@@ -151,9 +156,9 @@ const installFixture = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openDiplomacyTab = async (page: Page) => {
|
const openDiplomacyTab = async (page: Page) => {
|
||||||
await page.goto('http://127.0.0.1:15102/che/');
|
await page.goto(`http://127.0.0.1:${gamePort}/che/`);
|
||||||
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||||
await page.getByRole('button', { name: '외교', exact: true }).last().click();
|
await expect(page.locator('.DiplomacyTalk')).toBeVisible();
|
||||||
await expect(page.getByText(diplomacyMessage.text)).toBeVisible();
|
await expect(page.getByText(diplomacyMessage.text)).toBeVisible();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -186,16 +191,16 @@ 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(4, 0);
|
expect(geometry.buttons[1]!.x - (geometry.buttons[0]!.x + geometry.buttons[0]!.width)).toBeCloseTo(0, 0);
|
||||||
expect(geometry.buttons[0]).toMatchObject({
|
expect(geometry.buttons[0]).toMatchObject({
|
||||||
color: 'rgb(143, 209, 143)',
|
color: 'rgb(255, 255, 255)',
|
||||||
fontSize: '11.2px',
|
fontSize: '12.5px',
|
||||||
borderWidth: '1px',
|
borderWidth: '1px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
});
|
});
|
||||||
expect(geometry.buttons[1]).toMatchObject({
|
expect(geometry.buttons[1]).toMatchObject({
|
||||||
color: 'rgb(224, 154, 154)',
|
color: 'rgb(255, 255, 255)',
|
||||||
fontSize: '11.2px',
|
fontSize: '12.5px',
|
||||||
borderWidth: '1px',
|
borderWidth: '1px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
});
|
});
|
||||||
@@ -234,18 +239,17 @@ test.describe('instant diplomacy response UI', () => {
|
|||||||
test('keeps the message and exposes a rejected response on mobile Chromium', async ({ page }) => {
|
test('keeps the message and exposes a rejected response on mobile Chromium', async ({ page }) => {
|
||||||
const mutations = await installFixture(page, { acceptResponse: false });
|
const mutations = await installFixture(page, { acceptResponse: false });
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await page.goto('http://127.0.0.1:15102/che/');
|
await page.goto(`http://127.0.0.1:${gamePort}/che/`);
|
||||||
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||||
await page.getByRole('button', { name: '메시지', exact: true }).click();
|
await page.getByRole('button', { name: '메시지', exact: true }).click();
|
||||||
await page.getByRole('button', { name: '외교', exact: true }).click();
|
|
||||||
|
|
||||||
const responseRow = page.locator('.message-response');
|
const responseRow = page.locator('.message-response');
|
||||||
await expect(responseRow).toBeVisible();
|
await expect(responseRow).toBeVisible();
|
||||||
const itemWidth = await page
|
const itemWidth = await page
|
||||||
.locator('.message-item')
|
.locator('.DiplomacyTalk .msg-plate')
|
||||||
.evaluate((element) => element.getBoundingClientRect().width);
|
.evaluate((element) => element.getBoundingClientRect().width);
|
||||||
expect(itemWidth).toBeGreaterThan(320);
|
expect(itemWidth).toBeGreaterThanOrEqual(389);
|
||||||
expect(itemWidth).toBeLessThanOrEqual(342);
|
expect(itemWidth).toBeLessThanOrEqual(390);
|
||||||
|
|
||||||
page.once('dialog', async (dialog) => {
|
page.once('dialog', async (dialog) => {
|
||||||
expect(dialog.message()).toBe('거절하시겠습니까?');
|
expect(dialog.message()).toBe('거절하시겠습니까?');
|
||||||
@@ -272,10 +276,9 @@ test.describe('instant diplomacy response UI', () => {
|
|||||||
canRespondDiplomacy: false,
|
canRespondDiplomacy: false,
|
||||||
});
|
});
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await page.goto('http://127.0.0.1:15102/che/');
|
await page.goto(`http://127.0.0.1:${gamePort}/che/`);
|
||||||
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||||
await page.getByRole('button', { name: '메시지', exact: true }).click();
|
await page.getByRole('button', { name: '메시지', exact: true }).click();
|
||||||
await page.getByRole('button', { name: '외교', exact: true }).click();
|
|
||||||
|
|
||||||
const accept = page.locator('.message-response').getByRole('button', { name: '수락' });
|
const accept = page.locator('.message-response').getByRole('button', { name: '수락' });
|
||||||
await expect(accept).toBeDisabled();
|
await expect(accept).toBeDisabled();
|
||||||
@@ -284,7 +287,7 @@ test.describe('instant diplomacy response UI', () => {
|
|||||||
const style = getComputedStyle(element);
|
const style = getComputedStyle(element);
|
||||||
return { cursor: style.cursor, opacity: style.opacity };
|
return { cursor: style.cursor, opacity: style.opacity };
|
||||||
})
|
})
|
||||||
).toEqual({ cursor: 'not-allowed', opacity: '0.5' });
|
).toEqual({ cursor: 'not-allowed', opacity: '0.65' });
|
||||||
await accept.click({ force: true });
|
await accept.click({ force: true });
|
||||||
expect(mutations).toHaveLength(0);
|
expect(mutations).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export default defineConfig({
|
|||||||
'visual-parity.spec.ts',
|
'visual-parity.spec.ts',
|
||||||
'public-gaps.spec.ts',
|
'public-gaps.spec.ts',
|
||||||
'instant-diplomacy-message.spec.ts',
|
'instant-diplomacy-message.spec.ts',
|
||||||
|
'ingame-message-parity.spec.ts',
|
||||||
'tournament-betting.spec.ts',
|
'tournament-betting.spec.ts',
|
||||||
'inheritance-management.spec.ts',
|
'inheritance-management.spec.ts',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { mkdir, readFile } from 'node:fs/promises';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
|
||||||
|
const baseUrl = process.env.REF_MESSAGE_URL ?? 'http://127.0.0.1:3400/sam/';
|
||||||
|
const username = process.env.REF_MESSAGE_USER ?? 'refuser1';
|
||||||
|
const passwordFile = process.env.REF_MESSAGE_PASSWORD_FILE;
|
||||||
|
const artifactRoot = process.env.REF_MESSAGE_ARTIFACT_DIR;
|
||||||
|
|
||||||
|
if (!passwordFile) {
|
||||||
|
throw new Error('REF_MESSAGE_PASSWORD_FILE is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = (await readFile(passwordFile, 'utf8')).trim();
|
||||||
|
|
||||||
|
const login = async (context, page) => {
|
||||||
|
await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 });
|
||||||
|
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||||
|
const passwordHash = createHash('sha512')
|
||||||
|
.update(globalSalt + password + globalSalt)
|
||||||
|
.digest('hex');
|
||||||
|
const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||||
|
data: { username, password: passwordHash },
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok() || result.result !== true) {
|
||||||
|
throw new Error('Reference login failed.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureGeneral = async (page) => {
|
||||||
|
await page.goto(new URL('hwe/index.php', baseUrl).toString(), {
|
||||||
|
waitUntil: 'networkidle',
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
if (await page.locator('.MessagePanel').isVisible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto(new URL('hwe/v_join.php', baseUrl).toString(), {
|
||||||
|
waitUntil: 'networkidle',
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
const create = page.getByRole('button', { name: '장수 생성', exact: true });
|
||||||
|
await create.waitFor({ state: 'visible', timeout: 30_000 });
|
||||||
|
page.once('dialog', (dialog) => dialog.accept());
|
||||||
|
await create.click();
|
||||||
|
await page.locator('.MessagePanel').waitFor({ state: 'visible', timeout: 60_000 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const measure = async (browser, name, viewport) => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport,
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
colorScheme: 'dark',
|
||||||
|
locale: 'ko-KR',
|
||||||
|
timezoneId: 'UTC',
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const page = await context.newPage();
|
||||||
|
await login(context, page);
|
||||||
|
await ensureGeneral(page);
|
||||||
|
await page.locator('.MessagePanel').waitFor({ state: 'visible', timeout: 30_000 });
|
||||||
|
await page.locator('.BoardHeader').first().waitFor({ state: 'visible' });
|
||||||
|
const marker = `computed-dom-${name}-${Date.now()}`;
|
||||||
|
await page.locator('.MessageInputForm select').selectOption('9999');
|
||||||
|
await page.locator('.MessageInputForm input').fill(marker);
|
||||||
|
await page.getByRole('button', { name: '서신전달&갱신' }).click();
|
||||||
|
await page.getByText(marker, { exact: true }).waitFor({ state: 'visible', timeout: 30_000 });
|
||||||
|
|
||||||
|
if (artifactRoot) {
|
||||||
|
const path = resolve(artifactRoot, `message-ref-${name}.png`);
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
await page.locator('.MessagePanel').screenshot({
|
||||||
|
path,
|
||||||
|
animations: 'disabled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const rect = (element) => {
|
||||||
|
const box = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: box.x,
|
||||||
|
y: box.y,
|
||||||
|
width: box.width,
|
||||||
|
height: box.height,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const required = (selector) => {
|
||||||
|
const element = document.querySelector(selector);
|
||||||
|
if (!element) throw new Error(`Missing reference selector: ${selector}`);
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
const optionalRect = (selector) => {
|
||||||
|
const element = document.querySelector(selector);
|
||||||
|
return element ? rect(element) : null;
|
||||||
|
};
|
||||||
|
const style = (selector) => getComputedStyle(required(selector));
|
||||||
|
const input = required('.MessageInputForm input');
|
||||||
|
const select = required('.MessageInputForm select');
|
||||||
|
const submit = required('#msg_submit-col button');
|
||||||
|
const firstPlate = document.querySelector('.msg_plate');
|
||||||
|
const firstIcon = document.querySelector('.msg_plate .generalIcon');
|
||||||
|
const panelStyle = style('.MessagePanel');
|
||||||
|
const headerStyle = style('.BoardHeader');
|
||||||
|
const plateStyle = firstPlate ? getComputedStyle(firstPlate) : null;
|
||||||
|
const iconStyle = firstIcon ? getComputedStyle(firstIcon) : null;
|
||||||
|
return {
|
||||||
|
panel: rect(required('.MessagePanel')),
|
||||||
|
inputForm: rect(required('.MessageInputForm')),
|
||||||
|
select: rect(select),
|
||||||
|
input: rect(input),
|
||||||
|
submit: rect(submit),
|
||||||
|
publicSection: rect(required('.PublicTalk')),
|
||||||
|
nationalSection: rect(required('.NationalTalk')),
|
||||||
|
privateSection: rect(required('.PrivateTalk')),
|
||||||
|
diplomacySection: rect(required('.DiplomacyTalk')),
|
||||||
|
firstHeader: rect(required('.BoardHeader')),
|
||||||
|
firstPlate: optionalRect('.msg_plate'),
|
||||||
|
firstIcon: optionalRect('.msg_plate .generalIcon'),
|
||||||
|
computed: {
|
||||||
|
panelDisplay: panelStyle.display,
|
||||||
|
panelColumns: panelStyle.gridTemplateColumns,
|
||||||
|
panelFontSize: panelStyle.fontSize,
|
||||||
|
headerColor: headerStyle.color,
|
||||||
|
headerOutlineWidth: headerStyle.outlineWidth,
|
||||||
|
headerBackgroundImage: headerStyle.backgroundImage,
|
||||||
|
plateBackgroundColor: plateStyle?.backgroundColor ?? null,
|
||||||
|
plateFontSize: plateStyle?.fontSize ?? null,
|
||||||
|
plateMinHeight: plateStyle?.minHeight ?? null,
|
||||||
|
iconObjectFit: iconStyle?.objectFit ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = page.locator('#msg_submit-col button');
|
||||||
|
await submit.hover();
|
||||||
|
const hover = await submit.evaluate((element) => {
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
cursor: style.cursor,
|
||||||
|
backgroundColor: style.backgroundColor,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await submit.focus();
|
||||||
|
const focus = await submit.evaluate((element) => {
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
outline: style.outline,
|
||||||
|
boxShadow: style.boxShadow,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const markerPlate = page.locator('.msg_plate').filter({ hasText: marker });
|
||||||
|
const deleteButton = markerPlate.locator('.btn-delete-msg');
|
||||||
|
if (await deleteButton.isVisible()) {
|
||||||
|
page.once('dialog', (dialog) => dialog.accept());
|
||||||
|
await deleteButton.click();
|
||||||
|
}
|
||||||
|
return { ...result, interaction: { hover, focus } };
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
try {
|
||||||
|
const measurements = {
|
||||||
|
desktop: await measure(browser, 'desktop', { width: 1000, height: 900 }),
|
||||||
|
mobile: await measure(browser, 'mobile', { width: 500, height: 900 }),
|
||||||
|
};
|
||||||
|
process.stdout.write(`${JSON.stringify(measurements, null, 2)}\n`);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user