Merge branch 'main' into feature/auction-menu-parity
This commit is contained in:
@@ -23,13 +23,14 @@ export const resolveNationInfo = async (
|
||||
|
||||
export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise<MessageTarget> => {
|
||||
const nation = await resolveNationInfo(db, general.nationId);
|
||||
const picture = general.picture?.trim() || 'default.jpg';
|
||||
return {
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
nationId: general.nationId,
|
||||
nationName: nation.name,
|
||||
color: nation.color,
|
||||
icon: '',
|
||||
icon: general.imageServer ? `d_pic/${picture}` : `/image/icons/${picture}`,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
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 {
|
||||
@@ -26,6 +28,75 @@ import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js
|
||||
|
||||
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({
|
||||
getRecent: authedProcedure
|
||||
.input(
|
||||
@@ -85,11 +156,12 @@ export const messagesRouter = router({
|
||||
: null,
|
||||
]);
|
||||
|
||||
const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1;
|
||||
const messageBuckets: Record<MessageType, MessageView[]> = {
|
||||
private: privateMessages,
|
||||
public: publicMessages,
|
||||
national: nationalMessages,
|
||||
diplomacy: diplomacyMessages,
|
||||
diplomacy: redactDiplomacyMessages(diplomacyMessages, permission),
|
||||
};
|
||||
|
||||
let nextSequence = sequence;
|
||||
@@ -128,10 +200,8 @@ export const messagesRouter = router({
|
||||
sequence: nextSequence,
|
||||
nationId: nationId,
|
||||
generalName: general.name,
|
||||
canRespondDiplomacy:
|
||||
general.officerLevel > 4 &&
|
||||
nation !== null &&
|
||||
resolveNationPermission(general, nation.meta, false) >= 4,
|
||||
permission,
|
||||
canRespondDiplomacy: permission >= 4 && general.officerLevel > 4,
|
||||
latestRead: {
|
||||
diplomacy: readState?.latestDiplomacyMessage ?? 0,
|
||||
private: readState?.latestPrivateMessage ?? 0,
|
||||
@@ -178,6 +248,7 @@ export const messagesRouter = router({
|
||||
];
|
||||
return {
|
||||
nation: nationList.map((nation) => ({
|
||||
nationId: nation.id,
|
||||
mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + nation.id,
|
||||
name: nation.name,
|
||||
color: nation.color,
|
||||
@@ -234,14 +305,24 @@ export const messagesRouter = router({
|
||||
if (message.payload.src.generalId !== general.id) {
|
||||
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: '삭제할 수 없는 메시지입니다.' });
|
||||
}
|
||||
if (Date.now() - message.time.getTime() > 5 * 60 * 1000) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
|
||||
}
|
||||
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);
|
||||
return { ok: true, deletedIds: ids };
|
||||
}),
|
||||
@@ -291,6 +372,14 @@ export const messagesRouter = router({
|
||||
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||
|
||||
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 = {
|
||||
private: general.id,
|
||||
public: MESSAGE_MAILBOX_PUBLIC,
|
||||
@@ -312,7 +401,8 @@ export const messagesRouter = router({
|
||||
toSeq: input.to,
|
||||
limit: 15,
|
||||
});
|
||||
messageBuckets[input.type] = messages;
|
||||
messageBuckets[input.type] =
|
||||
input.type === 'diplomacy' ? redactDiplomacyMessages(messages, permission) : messages;
|
||||
|
||||
return {
|
||||
result: true,
|
||||
@@ -320,6 +410,7 @@ export const messagesRouter = router({
|
||||
sequence: 0,
|
||||
nationId,
|
||||
generalName: general.name,
|
||||
permission,
|
||||
...messageBuckets,
|
||||
};
|
||||
}),
|
||||
@@ -333,6 +424,12 @@ export const messagesRouter = router({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
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 now = new Date();
|
||||
@@ -340,28 +437,93 @@ export const messagesRouter = router({
|
||||
|
||||
let msgType: MessageType;
|
||||
let dest = src;
|
||||
let receiverMailbox = input.mailbox;
|
||||
|
||||
if (input.mailbox === MESSAGE_MAILBOX_PUBLIC) {
|
||||
msgType = 'public';
|
||||
} else if (input.mailbox >= MESSAGE_MAILBOX_NATIONAL_BASE) {
|
||||
const destNationId = input.mailbox - MESSAGE_MAILBOX_NATIONAL_BASE;
|
||||
if (destNationId <= 0) {
|
||||
if (hasPenalty(general.penalty, 'noSendPublicMsg')) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Invalid nation mailbox.',
|
||||
code: 'FORBIDDEN',
|
||||
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);
|
||||
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);
|
||||
msgType = destNationId === general.nationId ? 'national' : 'diplomacy';
|
||||
receiverMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + destNationId;
|
||||
} 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({
|
||||
where: { id: input.mailbox },
|
||||
});
|
||||
if (!destGeneral) {
|
||||
throw new TRPCError({
|
||||
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);
|
||||
@@ -394,7 +556,7 @@ export const messagesRouter = router({
|
||||
await publishRealtimeEvent(ctx.redis, ctx.profile.name, {
|
||||
type: 'messageCreated',
|
||||
at: now.toISOString(),
|
||||
mailbox: input.mailbox,
|
||||
mailbox: receiverMailbox,
|
||||
msgType,
|
||||
messageId: result.receiverId,
|
||||
senderId: general.id,
|
||||
|
||||
@@ -203,6 +203,8 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
|
||||
initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1),
|
||||
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0),
|
||||
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0),
|
||||
generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], 0),
|
||||
generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], 500),
|
||||
maxResourceActionAmount: resolveNumber(constValues, ['maxResourceActionAmount'], 0),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ const auth: GameSessionTokenPayload = {
|
||||
sanctions: {},
|
||||
};
|
||||
|
||||
const buildContext = (overrides: Record<string, unknown> = {}) => {
|
||||
const buildContext = (overrides: Record<string, unknown> = {}, contextOverrides: Record<string, unknown> = {}) => {
|
||||
const executeRaw = vi.fn(async () => 1);
|
||||
const updateMany = vi.fn(async () => ({ count: 1 }));
|
||||
const db = {
|
||||
@@ -55,11 +55,15 @@ const buildContext = (overrides: Record<string, unknown> = {}) => {
|
||||
$executeRaw: executeRaw,
|
||||
...overrides,
|
||||
};
|
||||
const redis = {
|
||||
set: vi.fn(async () => 'OK'),
|
||||
publish: vi.fn(async () => 1),
|
||||
};
|
||||
const context = {
|
||||
db,
|
||||
auth,
|
||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||
redis: {},
|
||||
redis,
|
||||
turnDaemon: {},
|
||||
battleSim: {},
|
||||
uploadDir: 'uploads',
|
||||
@@ -68,8 +72,9 @@ const buildContext = (overrides: Record<string, unknown> = {}) => {
|
||||
accessTokenStore: {},
|
||||
flushStore: {},
|
||||
gameTokenSecret: 'test-secret',
|
||||
...contextOverrides,
|
||||
} 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', () => {
|
||||
@@ -99,6 +104,291 @@ describe('messages router missing-flow compatibility', () => {
|
||||
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 () => {
|
||||
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 () => {
|
||||
const queryRaw = vi.fn(async () => [
|
||||
{
|
||||
|
||||
@@ -14,8 +14,12 @@ const integration = describe.skipIf(!databaseUrl);
|
||||
const bettingId = 990_071;
|
||||
const concurrentBettingId = 990_072;
|
||||
const generalId = 9_971;
|
||||
const otherGeneralId = 9_972;
|
||||
const nationId = 990_071;
|
||||
const otherNationId = 990_072;
|
||||
const userId = 'nation-betting-router-user';
|
||||
const otherUserId = 'nation-betting-router-other-user';
|
||||
const noGeneralUserId = 'nation-betting-router-no-general-user';
|
||||
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
@@ -32,12 +36,34 @@ const auth: GameSessionTokenPayload = {
|
||||
sanctions: {},
|
||||
};
|
||||
|
||||
const otherAuth: GameSessionTokenPayload = {
|
||||
...auth,
|
||||
sessionId: 'nation-betting-router-other-session',
|
||||
user: {
|
||||
...auth.user,
|
||||
id: otherUserId,
|
||||
username: 'other-bettor',
|
||||
displayName: 'Other Bettor',
|
||||
},
|
||||
};
|
||||
|
||||
const noGeneralAuth: GameSessionTokenPayload = {
|
||||
...auth,
|
||||
sessionId: 'nation-betting-router-no-general-session',
|
||||
user: {
|
||||
...auth.user,
|
||||
id: noGeneralUserId,
|
||||
username: 'no-general',
|
||||
displayName: 'No General',
|
||||
},
|
||||
};
|
||||
|
||||
integration('nation betting router', () => {
|
||||
let db: GamePrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
let worldStateId: number;
|
||||
|
||||
const buildContext = (requestId: string): GameApiContext => {
|
||||
const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload | null = auth): GameApiContext => {
|
||||
const redisClient = {
|
||||
get: async () => null,
|
||||
set: async () => null,
|
||||
@@ -52,7 +78,7 @@ integration('nation betting router', () => {
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
auth,
|
||||
auth: actorAuth,
|
||||
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:2'),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
@@ -64,33 +90,55 @@ integration('nation betting router', () => {
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
|
||||
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
|
||||
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
|
||||
await db.rankData.deleteMany({ where: { generalId } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId } });
|
||||
await db.general.deleteMany({ where: { id: generalId } });
|
||||
await db.nation.deleteMany({ where: { id: nationId } });
|
||||
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
|
||||
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
|
||||
|
||||
await db.nation.create({
|
||||
data: {
|
||||
id: nationId,
|
||||
name: '베팅국',
|
||||
color: '#123456',
|
||||
level: 2,
|
||||
},
|
||||
await db.nation.createMany({
|
||||
data: [
|
||||
{
|
||||
id: nationId,
|
||||
name: '베팅국',
|
||||
color: '#123456',
|
||||
level: 2,
|
||||
},
|
||||
{
|
||||
id: otherNationId,
|
||||
name: '다른베팅국',
|
||||
color: '#654321',
|
||||
level: 6,
|
||||
},
|
||||
],
|
||||
});
|
||||
await db.general.create({
|
||||
data: {
|
||||
id: generalId,
|
||||
userId,
|
||||
name: '베팅장수',
|
||||
nationId,
|
||||
cityId: 1,
|
||||
npcState: 0,
|
||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
meta: {},
|
||||
},
|
||||
await db.general.createMany({
|
||||
data: [
|
||||
{
|
||||
id: generalId,
|
||||
userId,
|
||||
name: '베팅장수',
|
||||
nationId,
|
||||
cityId: 1,
|
||||
npcState: 0,
|
||||
officerLevel: 0,
|
||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
meta: {},
|
||||
},
|
||||
{
|
||||
id: otherGeneralId,
|
||||
userId: otherUserId,
|
||||
name: '다른국가수뇌',
|
||||
nationId: otherNationId,
|
||||
cityId: 1,
|
||||
npcState: 0,
|
||||
officerLevel: 12,
|
||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
meta: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
const world = await db.worldState.create({
|
||||
data: {
|
||||
@@ -132,19 +180,22 @@ integration('nation betting router', () => {
|
||||
candidates: [{ title: '베팅국', info: '', isHtml: true, aux: { nation: nationId } }],
|
||||
},
|
||||
});
|
||||
await db.inheritancePoint.create({
|
||||
data: { userId, key: 'previous', value: 1_000 },
|
||||
await db.inheritancePoint.createMany({
|
||||
data: [
|
||||
{ userId, key: 'previous', value: 1_000 },
|
||||
{ userId: otherUserId, key: 'previous', value: 500 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
|
||||
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
|
||||
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
|
||||
await db.rankData.deleteMany({ where: { generalId } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId } });
|
||||
await db.general.deleteMany({ where: { id: generalId } });
|
||||
await db.nation.deleteMany({ where: { id: nationId } });
|
||||
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
|
||||
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
|
||||
await db.worldState.delete({ where: { id: worldStateId } });
|
||||
await closeDb?.();
|
||||
});
|
||||
@@ -229,12 +280,107 @@ integration('nation betting router', () => {
|
||||
}),
|
||||
]);
|
||||
expect(results.map((result) => result.status).sort()).toEqual(['fulfilled', 'rejected']);
|
||||
expect(await db.nationBet.aggregate({ where: { bettingId: concurrentBettingId }, _sum: { amount: true } }))
|
||||
.toMatchObject({ _sum: { amount: 600 } });
|
||||
expect(
|
||||
await db.nationBet.aggregate({ where: { bettingId: concurrentBettingId }, _sum: { amount: true } })
|
||||
).toMatchObject({ _sum: { amount: 600 } });
|
||||
expect(
|
||||
await db.inheritancePoint.findUniqueOrThrow({
|
||||
where: { userId_key: { userId, key: 'previous' } },
|
||||
})
|
||||
).toMatchObject({ value: 250 });
|
||||
});
|
||||
|
||||
it('requires authentication and an owned player general for every betting operation', async () => {
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-anonymous-list', null)).betting.getList({
|
||||
req: 'bettingNation',
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
await expect(
|
||||
appRouter
|
||||
.createCaller(buildContext('nation-betting-anonymous-detail', null))
|
||||
.betting.getDetail({ bettingId })
|
||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-anonymous-bet', null)).betting.bet({
|
||||
bettingId,
|
||||
bettingType: [0],
|
||||
amount: 10,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-no-general-list', noGeneralAuth)).betting.getList({
|
||||
req: 'bettingNation',
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' });
|
||||
await expect(
|
||||
appRouter
|
||||
.createCaller(buildContext('nation-betting-no-general-detail', noGeneralAuth))
|
||||
.betting.getDetail({ bettingId })
|
||||
).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' });
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-no-general-bet', noGeneralAuth)).betting.bet({
|
||||
bettingId,
|
||||
bettingType: [0],
|
||||
amount: 10,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' });
|
||||
});
|
||||
|
||||
it('allows generals across nation and office levels while isolating each session user bet', async () => {
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-other-list', otherAuth)).betting.getList({
|
||||
req: 'bettingNation',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
result: true,
|
||||
bettingList: {
|
||||
[bettingId]: { name: '천통국 예상' },
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-other-bet', otherAuth)).betting.bet({
|
||||
bettingId,
|
||||
bettingType: [0],
|
||||
amount: 100,
|
||||
})
|
||||
).resolves.toEqual({ result: true });
|
||||
|
||||
const [firstUserDetail, otherUserDetail] = await Promise.all([
|
||||
appRouter.createCaller(buildContext('nation-betting-first-user-detail')).betting.getDetail({ bettingId }),
|
||||
appRouter
|
||||
.createCaller(buildContext('nation-betting-other-user-detail', otherAuth))
|
||||
.betting.getDetail({ bettingId }),
|
||||
]);
|
||||
expect(firstUserDetail.myBetting).toEqual([['[0]', 150]]);
|
||||
expect(otherUserDetail.myBetting).toEqual([['[0]', 100]]);
|
||||
expect(firstUserDetail.bettingDetail).toEqual([['[0]', 250]]);
|
||||
expect(otherUserDetail.bettingDetail).toEqual([['[0]', 250]]);
|
||||
|
||||
expect(
|
||||
await db.nationBet.findUniqueOrThrow({
|
||||
where: {
|
||||
bettingId_userId_selectionKey: {
|
||||
bettingId,
|
||||
userId: otherUserId,
|
||||
selectionKey: '[0]',
|
||||
},
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
generalId: otherGeneralId,
|
||||
userId: otherUserId,
|
||||
amount: 100,
|
||||
});
|
||||
expect(
|
||||
await db.inheritancePoint.findUniqueOrThrow({
|
||||
where: { userId_key: { userId: otherUserId, key: 'previous' } },
|
||||
})
|
||||
).toMatchObject({ value: 400 });
|
||||
expect(
|
||||
await db.rankData.findUniqueOrThrow({
|
||||
where: { generalId_type: { generalId: otherGeneralId, type: 'inherit_spent_dyn' } },
|
||||
})
|
||||
).toMatchObject({ nationId: otherNationId, value: 100 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,8 @@ const DEFAULT_INITIAL_NATION_GEN_LIMIT = 10;
|
||||
const DEFAULT_MAX_TECH_LEVEL = 12;
|
||||
const DEFAULT_BASE_GOLD = 0;
|
||||
const DEFAULT_BASE_RICE = 2000;
|
||||
const DEFAULT_GENERAL_MINIMUM_GOLD = 0;
|
||||
const DEFAULT_GENERAL_MINIMUM_RICE = 500;
|
||||
const DEFAULT_MAX_RESOURCE_ACTION_AMOUNT = 10000;
|
||||
|
||||
const normalizeCode = (value: string | null | undefined): string | null => {
|
||||
@@ -132,6 +134,8 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
|
||||
initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1),
|
||||
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_BASE_GOLD),
|
||||
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_BASE_RICE),
|
||||
generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], DEFAULT_GENERAL_MINIMUM_GOLD),
|
||||
generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], DEFAULT_GENERAL_MINIMUM_RICE),
|
||||
maxResourceActionAmount: resolveNumber(
|
||||
constValues,
|
||||
['maxResourceActionAmount'],
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||
import { computed, reactive } from 'vue';
|
||||
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 {
|
||||
id: number;
|
||||
text: string;
|
||||
time: string;
|
||||
msgType: MessageType;
|
||||
src: MessageTarget;
|
||||
dest: MessageTarget | null;
|
||||
option?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
@@ -16,6 +28,22 @@ interface MessageBucket {
|
||||
public: MessageEntry[];
|
||||
national: 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<{
|
||||
@@ -23,7 +51,10 @@ const props = defineProps<{
|
||||
loading: boolean;
|
||||
targetMailbox: number;
|
||||
draftText: string;
|
||||
mailboxOptions: Array<{ label: string; value: number; disabled?: boolean }>;
|
||||
mailboxGroups: MailboxGroup[];
|
||||
generalId: number;
|
||||
generalName: string;
|
||||
nationId: number;
|
||||
canRespondDiplomacy: boolean;
|
||||
}>();
|
||||
|
||||
@@ -34,213 +65,390 @@ const emit = defineEmits<{
|
||||
(event: 'refresh'): void;
|
||||
(event: 'load-older', type: MessageType): 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 }> = [
|
||||
{ key: 'public', label: '전체' },
|
||||
{ key: 'national', label: '국가' },
|
||||
{ key: 'private', label: '개인' },
|
||||
{ key: 'diplomacy', label: '외교' },
|
||||
const sections: Array<{ type: MessageType; label: string; className: string }> = [
|
||||
{ type: 'public', label: '전체 메시지', className: 'PublicTalk' },
|
||||
{ type: 'national', label: '국가 메시지', className: 'NationalTalk' },
|
||||
{ type: 'private', label: '개인 메시지', className: 'PrivateTalk' },
|
||||
{ type: 'diplomacy', label: '외교 메시지', className: 'DiplomacyTalk' },
|
||||
];
|
||||
|
||||
const activeTab = ref<MessageType>('public');
|
||||
|
||||
const activeMessages = computed(() => {
|
||||
if (!props.messages) {
|
||||
return [] as MessageEntry[];
|
||||
}
|
||||
return props.messages[activeTab.value] ?? [];
|
||||
const visibleLimits = reactive<Record<MessageType, number>>({
|
||||
public: Number.POSITIVE_INFINITY,
|
||||
national: Number.POSITIVE_INFINITY,
|
||||
private: Number.POSITIVE_INFINITY,
|
||||
diplomacy: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
|
||||
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 parsed = Number(value);
|
||||
emit('update:targetMailbox', Number.isFinite(parsed) ? parsed : 0);
|
||||
};
|
||||
|
||||
const isDiplomacyPrompt = (message: MessageEntry): boolean =>
|
||||
message.msgType === 'diplomacy' &&
|
||||
(message.option?.action === 'noAggression' ||
|
||||
message.option?.action === 'cancelNA' ||
|
||||
message.option?.action === 'stopWar');
|
||||
|
||||
const respond = (messageId: number, response: boolean) => {
|
||||
if (!window.confirm(response ? '수락하시겠습니까?' : '거절하시겠습니까?')) {
|
||||
const submit = () => {
|
||||
if (!props.draftText.trim()) {
|
||||
emit('refresh');
|
||||
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);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message-panel">
|
||||
<div class="message-input">
|
||||
<select
|
||||
class="message-select"
|
||||
:value="targetMailbox"
|
||||
@change="setMailbox(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option
|
||||
v-for="option in mailboxOptions"
|
||||
:key="option.label"
|
||||
:value="option.value"
|
||||
:disabled="option.disabled"
|
||||
<div class="MessagePanel">
|
||||
<div class="MessageInputForm">
|
||||
<div id="mailbox_list-col">
|
||||
<select
|
||||
id="mailbox_list"
|
||||
class="message-select"
|
||||
:value="targetMailbox"
|
||||
aria-label="메시지 수신 대상"
|
||||
@change="setMailbox(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
class="message-text"
|
||||
type="text"
|
||||
maxlength="99"
|
||||
:value="draftText"
|
||||
placeholder="메시지 입력"
|
||||
@input="emit('update:draftText', ($event.target as HTMLInputElement).value)"
|
||||
@keydown.enter="emit('send')"
|
||||
/>
|
||||
<button class="message-send" @click="emit('send')">전송</button>
|
||||
</div>
|
||||
|
||||
<div class="message-tabs">
|
||||
<button
|
||||
v-for="tab in messageTabs"
|
||||
:key="tab.key"
|
||||
:class="{ active: activeTab === tab.key }"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
<button class="refresh" @click="emit('refresh')">갱신</button>
|
||||
</div>
|
||||
|
||||
<div v-if="props.loading">
|
||||
<SkeletonLines :lines="4" />
|
||||
</div>
|
||||
<div v-else-if="!props.messages" class="empty">메시지를 불러오지 못했습니다.</div>
|
||||
<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>
|
||||
<optgroup
|
||||
v-for="group in mailboxGroups"
|
||||
:key="group.label"
|
||||
:label="group.label"
|
||||
:style="{ backgroundColor: group.color ?? '#000000', color: '#ffffff' }"
|
||||
>
|
||||
<option
|
||||
v-for="option in group.options"
|
||||
:key="`${group.label}-${option.value}`"
|
||||
:value="option.value"
|
||||
:disabled="option.disabled"
|
||||
:style="{ backgroundColor: option.color ?? '#000000', color: '#ffffff' }"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div id="msg_input-col">
|
||||
<input
|
||||
class="message-text"
|
||||
type="text"
|
||||
maxlength="99"
|
||||
:value="draftText"
|
||||
aria-label="메시지 입력"
|
||||
@input="emit('update:draftText', ($event.target as HTMLInputElement).value)"
|
||||
@keydown.enter="submit"
|
||||
/>
|
||||
</div>
|
||||
<div id="msg_submit-col">
|
||||
<button class="message-send" type="button" @click="submit">서신전달&갱신</button>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
.MessagePanel {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.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;
|
||||
grid-template-columns: minmax(90px, 120px) 1fr auto;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
background: rgba(16, 16, 16, 0.8);
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
color: inherit;
|
||||
padding: 6px;
|
||||
font-size: 0.75rem;
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
padding: 4px 8px;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.message-send,
|
||||
.action-primary {
|
||||
background-color: #337ab7;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.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;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.message-tabs button.active {
|
||||
background: rgba(201, 164, 90, 0.2);
|
||||
.message-send:hover {
|
||||
background-color: #375a7f;
|
||||
}
|
||||
|
||||
.message-tabs .refresh {
|
||||
margin-left: auto;
|
||||
.message-send:focus,
|
||||
.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;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 25px;
|
||||
align-items: center;
|
||||
outline: 1px solid gray;
|
||||
background-color: #302016;
|
||||
background-image: url('/image/game/back_walnut.jpg');
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
border: 1px solid rgba(201, 164, 90, 0.2);
|
||||
padding: 6px;
|
||||
font-size: 0.75rem;
|
||||
.header-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.message-item .time {
|
||||
margin-top: 4px;
|
||||
font-size: 0.65rem;
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
}
|
||||
|
||||
.message-response {
|
||||
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;
|
||||
.btn-more-small {
|
||||
margin: 1px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-size: 11.2px;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.message-response .accept {
|
||||
color: #8fd18f;
|
||||
.action-secondary {
|
||||
border-color: #6c757d;
|
||||
background-color: #6c757d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.message-response .decline {
|
||||
color: #e09a9a;
|
||||
.btn-more-small:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.message-response button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
.empty-message {
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
border: 1px dashed rgba(201, 164, 90, 0.3);
|
||||
padding: 6px;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
background-color: #6c757d;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
@media (min-width: 940px) {
|
||||
.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>
|
||||
|
||||
@@ -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 CommandTable = Awaited<ReturnType<typeof trpc.turns.getCommandTable.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 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 commandTable = ref<CommandTable | null>(null);
|
||||
const messages = ref<MessageBundle | null>(null);
|
||||
const messageContacts = ref<MessageContacts | null>(null);
|
||||
const boardAccess = ref<BoardAccess | null>(null);
|
||||
const reservedGeneralTurns = ref<ReservedTurnView[] | null>(null);
|
||||
const reservedNationTurns = ref<ReservedTurnView[] | null>(null);
|
||||
|
||||
const messageDraftText = ref('');
|
||||
const targetMailbox = ref<number>(MESSAGE_MAILBOX_PUBLIC);
|
||||
let initializedMailboxGeneralId: number | null = null;
|
||||
|
||||
const general = computed(() => generalContext.value?.general ?? null);
|
||||
const city = computed(() => generalContext.value?.city ?? null);
|
||||
@@ -86,18 +89,85 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
} as const;
|
||||
});
|
||||
|
||||
const mailboxOptions = computed(() => {
|
||||
const options: Array<{ label: string; value: number; disabled?: boolean }> = [
|
||||
{ label: '공공', value: MESSAGE_MAILBOX_PUBLIC },
|
||||
const mailboxGroups = computed(() => {
|
||||
type MailboxOption = {
|
||||
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 });
|
||||
} else {
|
||||
options.push({ label: '국가', value: -1, disabled: true });
|
||||
|
||||
if (permission >= 4) {
|
||||
groups.push({
|
||||
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 });
|
||||
return options;
|
||||
|
||||
const sortedContacts = [...contacts].sort((left, right) => {
|
||||
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(() => {
|
||||
@@ -147,25 +217,32 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
context.general.nationId > 0 && context.general.officerLevel >= 5
|
||||
? trpc.turns.reserved.getNation.query({ generalId: id })
|
||||
: Promise.resolve(null);
|
||||
const [layout, lobby, map, commands, messageData, access, generalTurns, nationTurns] = await Promise.all([
|
||||
layoutPromise,
|
||||
trpc.lobby.info.query(),
|
||||
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
|
||||
trpc.turns.getCommandTable.query({ generalId: id }),
|
||||
trpc.messages.getRecent.query({ generalId: id }),
|
||||
trpc.board.getAccess.query(),
|
||||
generalTurnsPromise,
|
||||
nationTurnsPromise,
|
||||
]);
|
||||
const [layout, lobby, map, commands, messageData, contacts, access, generalTurns, nationTurns] =
|
||||
await Promise.all([
|
||||
layoutPromise,
|
||||
trpc.lobby.info.query(),
|
||||
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
|
||||
trpc.turns.getCommandTable.query({ generalId: id }),
|
||||
trpc.messages.getRecent.query({ generalId: id }),
|
||||
trpc.messages.getContacts.query({ generalId: id }),
|
||||
trpc.board.getAccess.query(),
|
||||
generalTurnsPromise,
|
||||
nationTurnsPromise,
|
||||
]);
|
||||
|
||||
mapLayout.value = layout;
|
||||
lobbyInfo.value = lobby;
|
||||
worldMap.value = map;
|
||||
commandTable.value = commands;
|
||||
messages.value = messageData;
|
||||
messageContacts.value = contacts;
|
||||
boardAccess.value = access;
|
||||
reservedGeneralTurns.value = generalTurns;
|
||||
reservedNationTurns.value = nationTurns;
|
||||
if (initializedMailboxGeneralId !== id) {
|
||||
targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId;
|
||||
initializedMailboxGeneralId = id;
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = resolveErrorMessage(err);
|
||||
} finally {
|
||||
@@ -200,12 +277,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
}
|
||||
|
||||
try {
|
||||
messageDraftText.value = '';
|
||||
await trpc.messages.send.mutate({
|
||||
generalId: id,
|
||||
mailbox,
|
||||
text,
|
||||
});
|
||||
messageDraftText.value = '';
|
||||
await refreshMessages();
|
||||
} catch (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 id = generalId.value;
|
||||
if (!id) {
|
||||
@@ -484,12 +599,13 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
selectedCity,
|
||||
commandTable,
|
||||
messages,
|
||||
messageContacts,
|
||||
boardAccess,
|
||||
reservedGeneralTurns,
|
||||
reservedNationTurns,
|
||||
messageDraftText,
|
||||
targetMailbox,
|
||||
mailboxOptions,
|
||||
mailboxGroups,
|
||||
statusLine,
|
||||
realtimeLabel,
|
||||
setRealtimeEnabled,
|
||||
@@ -498,6 +614,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
sendMessage,
|
||||
loadOlderMessages,
|
||||
respondToMessage,
|
||||
readLatestMessage,
|
||||
deleteMessage,
|
||||
setGeneralTurn,
|
||||
shiftGeneralTurns,
|
||||
setNationTurn,
|
||||
|
||||
@@ -50,7 +50,7 @@ const {
|
||||
reservedNationTurns,
|
||||
messageDraftText,
|
||||
targetMailbox,
|
||||
mailboxOptions,
|
||||
mailboxGroups,
|
||||
statusLine,
|
||||
realtimeLabel,
|
||||
} = storeToRefs(dashboard);
|
||||
@@ -221,22 +221,26 @@ watch(
|
||||
</div>
|
||||
|
||||
<div v-if="mobileTab === 'messages'" class="mobile-panel">
|
||||
<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>
|
||||
<MessagePanel
|
||||
class="mobile-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"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -256,22 +260,6 @@ watch(
|
||||
<div>세력 {{ lobbyInfo?.nationCnt ?? '-' }}</div>
|
||||
</div>
|
||||
</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 class="stack">
|
||||
@@ -307,6 +295,26 @@ watch(
|
||||
<div v-else class="placeholder">개인 기록 영역</div>
|
||||
</PanelCard>
|
||||
</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>
|
||||
</main>
|
||||
</template>
|
||||
@@ -401,6 +409,16 @@ button {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.desktop-message-panel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.mobile-message-panel {
|
||||
width: 100vw;
|
||||
min-width: 0;
|
||||
margin-left: -24px;
|
||||
}
|
||||
|
||||
.layout-mobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -73,9 +73,7 @@ const listYearMonth = computed(() => {
|
||||
});
|
||||
|
||||
const listItems = computed(() =>
|
||||
list.value
|
||||
? Object.values(list.value.bettingList).sort((left, right) => right.id - left.id)
|
||||
: []
|
||||
list.value ? Object.values(list.value.bettingList).sort((left, right) => right.id - left.id) : []
|
||||
);
|
||||
|
||||
const info = computed(() => detail.value?.bettingInfo ?? null);
|
||||
@@ -112,9 +110,7 @@ const detailRows = computed(() =>
|
||||
|
||||
const myBetMap = computed(() => new Map(detail.value?.myBetting ?? []));
|
||||
|
||||
const totalAmount = computed(() =>
|
||||
(detail.value?.bettingDetail ?? []).reduce((sum, [, value]) => sum + value, 0)
|
||||
);
|
||||
const totalAmount = computed(() => (detail.value?.bettingDetail ?? []).reduce((sum, [, value]) => sum + value, 0));
|
||||
|
||||
const pureAmount = computed(() =>
|
||||
(detail.value?.bettingDetail ?? []).reduce(
|
||||
@@ -137,9 +133,7 @@ const candidateAmounts = computed(() => {
|
||||
return result;
|
||||
});
|
||||
|
||||
const usedAmount = computed(() =>
|
||||
Array.from(myBetMap.value.values()).reduce((sum, value) => sum + value, 0)
|
||||
);
|
||||
const usedAmount = computed(() => Array.from(myBetMap.value.values()).reduce((sum, value) => sum + value, 0));
|
||||
|
||||
const selectedKey = computed(() => JSON.stringify([...selectedCandidates.value].sort((a, b) => a - b)));
|
||||
|
||||
@@ -150,10 +144,7 @@ const getErrorMessage = (error: unknown): string => {
|
||||
return typeof error === 'string' ? error : '요청을 처리하지 못했습니다.';
|
||||
};
|
||||
|
||||
const parseYearMonth = (yearMonth: number): [number, number] => [
|
||||
Math.floor(yearMonth / 12),
|
||||
(yearMonth % 12) + 1,
|
||||
];
|
||||
const parseYearMonth = (yearMonth: number): [number, number] => [Math.floor(yearMonth / 12), (yearMonth % 12) + 1];
|
||||
|
||||
const readSelection = (value: string): number[] => {
|
||||
try {
|
||||
@@ -171,8 +162,7 @@ const selectionLabel = (value: string): string =>
|
||||
.map((index) => candidates.value[index]?.title ?? '-')
|
||||
.join(', ');
|
||||
|
||||
const isListOpen = (item: BettingListItem): boolean =>
|
||||
!item.finished && listYearMonth.value <= item.closeYearMonth;
|
||||
const isListOpen = (item: BettingListItem): boolean => !item.finished && listYearMonth.value <= item.closeYearMonth;
|
||||
|
||||
const isDetailOpen = computed(() =>
|
||||
Boolean(info.value && !info.value.finished && currentYearMonth.value <= info.value.closeYearMonth)
|
||||
@@ -192,19 +182,72 @@ const rowColor = (key: string): string => {
|
||||
return matched === 0 ? 'red' : matched < info.value.selectCnt ? 'yellow' : 'green';
|
||||
};
|
||||
|
||||
const expectedMultiplier = (key: string, betAmount: number): string => {
|
||||
if (betAmount <= 0) {
|
||||
return '0.0';
|
||||
const rewardByMatch = computed(() => {
|
||||
const selectCount = info.value?.selectCnt ?? 0;
|
||||
const rewards = new Array<number>(selectCount + 1).fill(0);
|
||||
if (selectCount <= 0) {
|
||||
return rewards;
|
||||
}
|
||||
const amountByMatch = new Map<number, number>();
|
||||
for (const [key, betAmount] of detailRows.value) {
|
||||
const matched = matchCount(key);
|
||||
amountByMatch.set(matched, (amountByMatch.get(matched) ?? 0) + betAmount);
|
||||
}
|
||||
if (selectCount === 1 || info.value?.isExclusive) {
|
||||
rewards[selectCount] = totalAmount.value;
|
||||
return rewards;
|
||||
}
|
||||
|
||||
let remainingReward = totalAmount.value;
|
||||
let accumulatedReward = 0;
|
||||
let nextReward = totalAmount.value;
|
||||
for (let matched = selectCount; matched > 0; matched -= 1) {
|
||||
nextReward /= 2;
|
||||
accumulatedReward += nextReward;
|
||||
if (!amountByMatch.has(matched)) {
|
||||
continue;
|
||||
}
|
||||
rewards[matched] = accumulatedReward;
|
||||
remainingReward -= accumulatedReward;
|
||||
accumulatedReward = 0;
|
||||
}
|
||||
for (let matched = selectCount; matched >= 0; matched -= 1) {
|
||||
if (!amountByMatch.has(matched)) {
|
||||
continue;
|
||||
}
|
||||
rewards[matched] += remainingReward;
|
||||
break;
|
||||
}
|
||||
return rewards;
|
||||
});
|
||||
|
||||
const expectedReward = (key: string): number => {
|
||||
if (!info.value?.finished) {
|
||||
const reward = info.value?.isExclusive || info.value?.selectCnt === 1 ? totalAmount.value : totalAmount.value / 2;
|
||||
return (reward / betAmount).toFixed(1);
|
||||
return info.value?.isExclusive || info.value?.selectCnt === 1 ? totalAmount.value : totalAmount.value / 2;
|
||||
}
|
||||
const matched = matchCount(key);
|
||||
const matchedAmount = detailRows.value
|
||||
.filter(([candidateKey]) => matchCount(candidateKey) === matched)
|
||||
.reduce((sum, [, value]) => sum + value, 0);
|
||||
return matchedAmount > 0 ? (totalAmount.value / matchedAmount).toFixed(1) : '0.0';
|
||||
return rewardByMatch.value[matchCount(key)] ?? 0;
|
||||
};
|
||||
|
||||
const rewardDivisor = (key: string, betAmount: number): number =>
|
||||
info.value?.finished
|
||||
? detailRows.value
|
||||
.filter(([candidateKey]) => matchCount(candidateKey) === matchCount(key))
|
||||
.reduce((sum, [, value]) => sum + value, 0)
|
||||
: betAmount;
|
||||
|
||||
const expectedMultiplier = (key: string, betAmount: number): string => {
|
||||
const divisor = rewardDivisor(key, betAmount);
|
||||
return divisor > 0 ? (expectedReward(key) / divisor).toFixed(1) : '0.0';
|
||||
};
|
||||
|
||||
const myExpectedReward = (key: string, betAmount: number): string => {
|
||||
const myAmount = myBetMap.value.get(key);
|
||||
if (myAmount === undefined) {
|
||||
return '';
|
||||
}
|
||||
const divisor = rewardDivisor(key, betAmount);
|
||||
const reward = divisor > 0 ? (myAmount * expectedReward(key)) / divisor : 0;
|
||||
return `(${myAmount.toLocaleString('ko-KR')} -> ${reward.toFixed(1)})`;
|
||||
};
|
||||
|
||||
const loadList = async () => {
|
||||
@@ -295,7 +338,7 @@ onMounted(() => {
|
||||
<main id="nation-betting-container" class="nation-betting-page legacy-bg0">
|
||||
<header class="legacy-top-bar">
|
||||
<RouterLink class="legacy-nav-button" to="/">돌아가기</RouterLink>
|
||||
<button class="legacy-nav-button" type="button" :disabled="loadingList" @click="loadList">갱신</button>
|
||||
<div></div>
|
||||
<h1>국가 베팅장</h1>
|
||||
<div></div>
|
||||
<div></div>
|
||||
@@ -309,32 +352,37 @@ onMounted(() => {
|
||||
{{ info.name }}
|
||||
<span v-if="info.finished">(종료)</span>
|
||||
<span v-else-if="currentYearMonth <= info.closeYearMonth">
|
||||
({{ parseYearMonth(info.closeYearMonth)[0] }}년
|
||||
{{ parseYearMonth(info.closeYearMonth)[1] }}월까지)
|
||||
({{ parseYearMonth(info.closeYearMonth)[0] }}년 {{ parseYearMonth(info.closeYearMonth)[1] }}월까지)
|
||||
</span>
|
||||
<span v-else>(베팅 마감)</span>
|
||||
(총액: {{ totalAmount.toLocaleString('ko-KR') }})
|
||||
</div>
|
||||
|
||||
<div class="betting-candidates">
|
||||
<button
|
||||
<div
|
||||
v-for="(candidate, index) in candidates"
|
||||
:key="`${info.id}-${index}`"
|
||||
type="button"
|
||||
class="betting-candidate"
|
||||
:class="{ picked: selectedCandidates.includes(index) || (info.finished && winner.has(index)) }"
|
||||
:disabled="!isDetailOpen"
|
||||
@click="toggleCandidate(index)"
|
||||
class="betting-candidate-cell"
|
||||
>
|
||||
<span class="candidate-title legacy-bg1">{{ candidate.title }}</span>
|
||||
<span class="candidate-info">
|
||||
<span v-for="line in candidate.info.split('<br>')" :key="line">{{ line }}</span>
|
||||
</span>
|
||||
<span class="candidate-rate">
|
||||
선택율:
|
||||
{{ (((candidateAmounts.get(index) ?? 0) / Math.max(1, pureAmount)) * 100).toFixed(1) }}%
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="betting-candidate"
|
||||
:class="{
|
||||
picked: selectedCandidates.includes(index) || (info.finished && winner.has(index)),
|
||||
}"
|
||||
:disabled="!isDetailOpen"
|
||||
@click="toggleCandidate(index)"
|
||||
>
|
||||
<span class="candidate-title legacy-bg1">{{ candidate.title }}</span>
|
||||
<span class="candidate-info">
|
||||
<span v-for="line in candidate.info.split('<br>')" :key="line">{{ line }}</span>
|
||||
</span>
|
||||
<span class="candidate-rate">
|
||||
선택율:
|
||||
{{ (((candidateAmounts.get(index) ?? 0) / Math.max(1, pureAmount)) * 100).toFixed(1) }}%
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form v-if="isDetailOpen" class="betting-form" @submit.prevent="submitBet">
|
||||
@@ -361,7 +409,7 @@ onMounted(() => {
|
||||
{{ selectionLabel(key) }}
|
||||
</div>
|
||||
<div>{{ betAmount.toLocaleString('ko-KR') }}</div>
|
||||
<div>{{ myBetMap.get(key)?.toLocaleString('ko-KR') ?? '' }}</div>
|
||||
<div>{{ myExpectedReward(key, betAmount) }}</div>
|
||||
<div>{{ expectedMultiplier(key, betAmount) }}배</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -382,8 +430,7 @@ onMounted(() => {
|
||||
{{ item.name }}
|
||||
<span v-if="item.finished">(종료)</span>
|
||||
<span v-else-if="isListOpen(item)">
|
||||
({{ parseYearMonth(item.closeYearMonth)[0] }}년
|
||||
{{ parseYearMonth(item.closeYearMonth)[1] }}월까지)
|
||||
({{ parseYearMonth(item.closeYearMonth)[0] }}년 {{ parseYearMonth(item.closeYearMonth)[1] }}월까지)
|
||||
</span>
|
||||
<span v-else>(베팅 마감)</span>
|
||||
</button>
|
||||
@@ -400,12 +447,11 @@ onMounted(() => {
|
||||
.nation-betting-page {
|
||||
position: relative;
|
||||
width: 500px;
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
color: #fff;
|
||||
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
line-height: 1.5;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@@ -458,19 +504,32 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.section-title {
|
||||
min-height: 22px;
|
||||
min-height: 21px;
|
||||
text-align: center;
|
||||
line-height: 22px;
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
.betting-candidates {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-top: -3.5px;
|
||||
margin-right: -1.75px;
|
||||
margin-left: -1.75px;
|
||||
}
|
||||
|
||||
.betting-candidate-cell {
|
||||
flex: 0 0 auto;
|
||||
width: 33.33333333%;
|
||||
max-width: 100%;
|
||||
padding-right: 1.75px;
|
||||
padding-left: 1.75px;
|
||||
margin-top: 3.5px;
|
||||
}
|
||||
|
||||
.betting-candidate {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 143px;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 1px solid gray;
|
||||
@@ -530,7 +589,7 @@ onMounted(() => {
|
||||
.betting-form input {
|
||||
grid-column: span 4;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
height: 35.5px;
|
||||
border: 1px solid #777;
|
||||
background: #ddd;
|
||||
color: #303030;
|
||||
@@ -538,10 +597,11 @@ onMounted(() => {
|
||||
|
||||
.betting-form button {
|
||||
grid-column: span 2;
|
||||
height: 35.5px;
|
||||
}
|
||||
|
||||
.payout-table {
|
||||
margin-top: 6px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.payout-row {
|
||||
@@ -551,7 +611,7 @@ onMounted(() => {
|
||||
|
||||
.payout-row > div {
|
||||
min-width: 0;
|
||||
padding: 2px 4px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.payout-row > div:not(:first-child) {
|
||||
@@ -572,7 +632,7 @@ onMounted(() => {
|
||||
|
||||
.betting-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
width: auto;
|
||||
margin: 0.25em;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
@@ -595,6 +655,7 @@ onMounted(() => {
|
||||
|
||||
.betting-footer .legacy-nav-button {
|
||||
width: 90px;
|
||||
height: 35.5px;
|
||||
}
|
||||
|
||||
.betting-notice,
|
||||
@@ -602,6 +663,15 @@ onMounted(() => {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.betting-notice {
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 20;
|
||||
width: min(320px, calc(100vw - 16px));
|
||||
background: #303030;
|
||||
}
|
||||
|
||||
.betting-notice.error {
|
||||
border: 1px solid #9b4848;
|
||||
color: #ffd0d0;
|
||||
@@ -617,8 +687,9 @@ onMounted(() => {
|
||||
width: 1000px;
|
||||
}
|
||||
|
||||
.betting-candidates {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
.betting-candidate-cell {
|
||||
/* Legacy Bootstrap switches .col-4 to .col-lg-2 at 940px. */
|
||||
width: 16.66666667%;
|
||||
}
|
||||
|
||||
.betting-form {
|
||||
|
||||
@@ -33,6 +33,9 @@ ref `next_execute` KV와 core general meta의 공통 projection으로 비교한
|
||||
존재하지 않는 대상 경계 4개는 증여·등용의 장수 ID와 첩보·이동의 도시 ID를
|
||||
고정해 원 명령 미완료, 휴식 fallback, RNG 무소비와 semantic delta를
|
||||
비교한다.
|
||||
자원 인자·보유량 경계 13개는 증여·헌납·군량매매의 100단위 반올림과
|
||||
100..max clamp 9개, 헌납의 보유량보다 큰 요청·최소 쌀 미달 2개,
|
||||
증여의 최소 쌀 보존·자기 자신 거부 2개를 비교한다.
|
||||
필수 인자 객체 자체를 생략한 요청은 ref runner가 제한 시간 안에 종료되지
|
||||
않아 동적 호환 판정에서 제외한다.
|
||||
나머지 명령별 제약 실패·값 경계와 전체 core PostgreSQL 재조회가 완료
|
||||
|
||||
@@ -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] Implement Public 화면: 캐싱된 지도/중원정세/세력일람 + 제한된 장수일람 API/뷰.
|
||||
- [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] Implement join/빙의 UI and post-creation refresh flow.
|
||||
- [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] 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] 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.
|
||||
|
||||
## Runtime and Operations (Lower Priority)
|
||||
|
||||
@@ -214,6 +214,11 @@ Four missing-target cases cover nonexistent general IDs for gift and
|
||||
employment plus nonexistent city IDs for spying and movement. Both engines
|
||||
reject the requested command, execute rest without command RNG, and produce
|
||||
the same semantic state delta.
|
||||
Thirteen resource argument and balance cases cover 100-unit rounding and
|
||||
minimum/maximum clamps for gift, donation, and rice trade; donation against
|
||||
available and minimum resources; and gift reserve and self-target rejection.
|
||||
They compare normalized last-turn arguments, RNG, fallback, and semantic state
|
||||
deltas.
|
||||
Requests that omit the required argument object remain unverified because the
|
||||
reference runner did not terminate within the bounded comparison run.
|
||||
This is not yet a claim that every command-specific
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface TurnCommandEnv {
|
||||
initialAllowedTechLevel?: number;
|
||||
baseGold: number;
|
||||
baseRice: number;
|
||||
generalMinimumGold?: number;
|
||||
generalMinimumRice?: number;
|
||||
maxResourceActionAmount: number;
|
||||
itemCatalog?: Record<string, TurnCommandItemCatalogEntry>;
|
||||
generalActionModules?: Array<GeneralActionModule>;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action
|
||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||
import { normalizeResourceActionAmount } from './resourceAmount.js';
|
||||
|
||||
export interface TradeEnvironment {
|
||||
exchangeFee?: number;
|
||||
@@ -46,8 +47,10 @@ export class ActionDefinition<
|
||||
if (!parsed || !Number.isFinite(parsed.amount)) {
|
||||
return null;
|
||||
}
|
||||
const maxAmount = this.env.maxResourceActionAmount ?? 10_000;
|
||||
const amount = Math.max(100, Math.min(Math.round(parsed.amount / 100) * 100, maxAmount));
|
||||
const amount = normalizeResourceActionAmount(parsed.amount, this.env.maxResourceActionAmount ?? 10_000);
|
||||
if (amount === null) {
|
||||
return null;
|
||||
}
|
||||
return { buyRice: parsed.buyRice, amount };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||
import {
|
||||
denyWithReason,
|
||||
existsDestGeneral,
|
||||
friendlyDestGeneral,
|
||||
notBeNeutral,
|
||||
@@ -19,15 +20,13 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
|
||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||
import { normalizeResourceActionAmount } from './resourceAmount.js';
|
||||
|
||||
const ACTION_NAME = '증여';
|
||||
const ACTION_KEY = 'che_증여';
|
||||
const ARGS_SCHEMA = z.object({
|
||||
isGold: z.boolean(),
|
||||
amount: z.preprocess(
|
||||
(value) => (typeof value === 'number' ? Math.floor(value / 100) * 100 : value),
|
||||
z.number().int().positive()
|
||||
),
|
||||
amount: z.number(),
|
||||
destGeneralID: z.number().int().positive(),
|
||||
});
|
||||
export type GiftArgs = z.infer<typeof ARGS_SCHEMA>;
|
||||
@@ -51,10 +50,13 @@ export class ActionDefinition<
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
const maxAmount = this.env.maxResourceActionAmount > 0 ? this.env.maxResourceActionAmount : 10000;
|
||||
const amount = normalizeResourceActionAmount(parsed.amount, this.env.maxResourceActionAmount);
|
||||
if (amount === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...parsed,
|
||||
amount: Math.max(100, Math.min(parsed.amount, maxAmount)),
|
||||
amount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,9 +64,12 @@ export class ActionDefinition<
|
||||
return [notBeNeutral(), occupiedCity(), suppliedCity()];
|
||||
}
|
||||
|
||||
buildConstraints(_ctx: ConstraintContext, args: GiftArgs): Constraint[] {
|
||||
const minGold = this.env.baseGold > 0 ? this.env.baseGold : 1000;
|
||||
const minRice = this.env.baseRice > 0 ? this.env.baseRice : 1000;
|
||||
buildConstraints(ctx: ConstraintContext, args: GiftArgs): Constraint[] {
|
||||
if (ctx.actorId === args.destGeneralID) {
|
||||
return [denyWithReason('본인입니다')];
|
||||
}
|
||||
const minGold = this.env.generalMinimumGold ?? 0;
|
||||
const minRice = this.env.generalMinimumRice ?? 500;
|
||||
|
||||
return [
|
||||
notBeNeutral(),
|
||||
@@ -83,8 +88,8 @@ export class ActionDefinition<
|
||||
throw new Error('증여 대상 장수가 없습니다.');
|
||||
}
|
||||
|
||||
const minGold = this.env.baseGold > 0 ? this.env.baseGold : 1000;
|
||||
const minRice = this.env.baseRice > 0 ? this.env.baseRice : 1000;
|
||||
const minGold = this.env.generalMinimumGold ?? 0;
|
||||
const minRice = this.env.generalMinimumRice ?? 500;
|
||||
|
||||
const resKey = args.isGold ? 'gold' : 'rice';
|
||||
const resName = args.isGold ? '금' : '쌀';
|
||||
|
||||
@@ -21,6 +21,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action
|
||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||
import { normalizeResourceActionAmount } from './resourceAmount.js';
|
||||
|
||||
const ACTION_NAME = '헌납';
|
||||
const ACTION_KEY = 'che_헌납';
|
||||
@@ -96,12 +97,23 @@ export class ActionDefinition<
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly resolver: ActionResolver<TriggerState>;
|
||||
|
||||
constructor() {
|
||||
constructor(private readonly env: TurnCommandEnv) {
|
||||
this.resolver = new ActionResolver();
|
||||
}
|
||||
|
||||
parseArgs(raw: unknown): DonateArgs | null {
|
||||
return parseArgsWithSchema(ARGS_SCHEMA, raw);
|
||||
const parsed = parseArgsWithSchema(ARGS_SCHEMA, raw);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
const amount = normalizeResourceActionAmount(parsed.amount, this.env.maxResourceActionAmount);
|
||||
if (amount === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...parsed,
|
||||
amount,
|
||||
};
|
||||
}
|
||||
|
||||
buildMinConstraints(_ctx: ConstraintContext, _args: DonateArgs): Constraint[] {
|
||||
@@ -109,10 +121,12 @@ export class ActionDefinition<
|
||||
}
|
||||
|
||||
buildConstraints(_ctx: ConstraintContext, args: DonateArgs): Constraint[] {
|
||||
const minGold = this.env.generalMinimumGold ?? 0;
|
||||
const minRice = this.env.generalMinimumRice ?? 500;
|
||||
if (args.isGold) {
|
||||
return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => args.amount)];
|
||||
return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => minGold)];
|
||||
}
|
||||
return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralRice(() => args.amount)];
|
||||
return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralRice(() => minRice)];
|
||||
}
|
||||
|
||||
resolve(context: GeneralActionResolveContext<TriggerState>, args: DonateArgs): GeneralActionOutcome<TriggerState> {
|
||||
@@ -128,5 +142,5 @@ export const commandSpec: GeneralTurnCommandSpec = {
|
||||
reqArg: true,
|
||||
availabilityArgs: { isGold: true, amount: 0 },
|
||||
argsSchema: ARGS_SCHEMA,
|
||||
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
|
||||
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
const DEFAULT_MIN_RESOURCE_ACTION_AMOUNT = 100;
|
||||
const DEFAULT_MAX_RESOURCE_ACTION_AMOUNT = 10_000;
|
||||
const RESOURCE_ACTION_AMOUNT_UNIT = 100;
|
||||
|
||||
export const normalizeResourceActionAmount = (amount: number, configuredMaxAmount: number): number | null => {
|
||||
if (!Number.isFinite(amount)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxAmount = configuredMaxAmount > 0 ? configuredMaxAmount : DEFAULT_MAX_RESOURCE_ACTION_AMOUNT;
|
||||
const roundedAmount = Math.round(amount / RESOURCE_ACTION_AMOUNT_UNIT) * RESOURCE_ACTION_AMOUNT_UNIT;
|
||||
|
||||
return Math.max(DEFAULT_MIN_RESOURCE_ACTION_AMOUNT, Math.min(roundedAmount, maxAmount));
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
||||
const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102';
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
|
||||
const operationNames = (route: Route): string[] => {
|
||||
@@ -90,6 +91,7 @@ const messageBundle = (visible: boolean, canRespondDiplomacy = true) => ({
|
||||
sequence: visible ? diplomacyMessage.id : -1,
|
||||
nationId: 1,
|
||||
generalName: general.name,
|
||||
permission: canRespondDiplomacy ? 4 : 2,
|
||||
canRespondDiplomacy,
|
||||
latestRead: { diplomacy: 0, private: 0 },
|
||||
});
|
||||
@@ -131,6 +133,9 @@ const installFixture = async (
|
||||
if (operation === 'messages.getRecent') {
|
||||
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') {
|
||||
mutations.push({ operation, body: requestBody });
|
||||
if (options.acceptResponse) {
|
||||
@@ -151,9 +156,9 @@ const installFixture = async (
|
||||
};
|
||||
|
||||
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 page.getByRole('button', { name: '외교', exact: true }).last().click();
|
||||
await expect(page.locator('.DiplomacyTalk')).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[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({
|
||||
color: 'rgb(143, 209, 143)',
|
||||
fontSize: '11.2px',
|
||||
color: 'rgb(255, 255, 255)',
|
||||
fontSize: '12.5px',
|
||||
borderWidth: '1px',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
expect(geometry.buttons[1]).toMatchObject({
|
||||
color: 'rgb(224, 154, 154)',
|
||||
fontSize: '11.2px',
|
||||
color: 'rgb(255, 255, 255)',
|
||||
fontSize: '12.5px',
|
||||
borderWidth: '1px',
|
||||
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 }) => {
|
||||
const mutations = await installFixture(page, { acceptResponse: false });
|
||||
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 page.getByRole('button', { name: '메시지', exact: true }).click();
|
||||
await page.getByRole('button', { name: '외교', exact: true }).click();
|
||||
|
||||
const responseRow = page.locator('.message-response');
|
||||
await expect(responseRow).toBeVisible();
|
||||
const itemWidth = await page
|
||||
.locator('.message-item')
|
||||
.locator('.DiplomacyTalk .msg-plate')
|
||||
.evaluate((element) => element.getBoundingClientRect().width);
|
||||
expect(itemWidth).toBeGreaterThan(320);
|
||||
expect(itemWidth).toBeLessThanOrEqual(342);
|
||||
expect(itemWidth).toBeGreaterThanOrEqual(389);
|
||||
expect(itemWidth).toBeLessThanOrEqual(390);
|
||||
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.message()).toBe('거절하시겠습니까?');
|
||||
@@ -272,10 +276,9 @@ test.describe('instant diplomacy response UI', () => {
|
||||
canRespondDiplomacy: false,
|
||||
});
|
||||
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 page.getByRole('button', { name: '메시지', exact: true }).click();
|
||||
await page.getByRole('button', { name: '외교', exact: true }).click();
|
||||
|
||||
const accept = page.locator('.message-response').getByRole('button', { name: '수락' });
|
||||
await expect(accept).toBeDisabled();
|
||||
@@ -284,7 +287,7 @@ test.describe('instant diplomacy response UI', () => {
|
||||
const style = getComputedStyle(element);
|
||||
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 });
|
||||
expect(mutations).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const baseUrl = process.env.REF_NATION_BETTING_URL ?? 'https://dev-sam-ref.hided.net/sam/';
|
||||
const staticBaseUrl = process.env.REF_NATION_BETTING_STATIC_BASE_URL;
|
||||
const username = process.env.REF_NATION_BETTING_USER ?? 'refuser1';
|
||||
const passwordFile = process.env.REF_NATION_BETTING_PASSWORD_FILE;
|
||||
const artifactRoot = resolve(process.env.REF_NATION_BETTING_ARTIFACT_DIR ?? 'test-results/reference-nation-betting');
|
||||
|
||||
if (!staticBaseUrl && !passwordFile) {
|
||||
throw new Error('REF_NATION_BETTING_PASSWORD_FILE is required.');
|
||||
}
|
||||
|
||||
const password = passwordFile ? (await readFile(passwordFile, 'utf8')).trim() : '';
|
||||
|
||||
const bettingList = {
|
||||
result: true,
|
||||
bettingList: {
|
||||
7: {
|
||||
id: 7,
|
||||
type: 'bettingNation',
|
||||
name: '천통국 예상',
|
||||
finished: false,
|
||||
selectCnt: 2,
|
||||
isExclusive: false,
|
||||
reqInheritancePoint: true,
|
||||
openYearMonth: 2316,
|
||||
closeYearMonth: 2340,
|
||||
winner: null,
|
||||
totalAmount: 800,
|
||||
},
|
||||
},
|
||||
year: 193,
|
||||
month: 1,
|
||||
};
|
||||
|
||||
const bettingDetail = {
|
||||
result: true,
|
||||
bettingInfo: {
|
||||
id: 7,
|
||||
type: 'bettingNation',
|
||||
name: '천통국 예상',
|
||||
finished: false,
|
||||
selectCnt: 2,
|
||||
isExclusive: false,
|
||||
reqInheritancePoint: true,
|
||||
openYearMonth: 2316,
|
||||
closeYearMonth: 2340,
|
||||
candidates: [
|
||||
{ title: '촉', info: '국력: 1200<br>장수 수: 8<br>도시 수: 5', isHtml: true },
|
||||
{ title: '위', info: '국력: 1100<br>장수 수: 7<br>도시 수: 4', isHtml: true },
|
||||
{ title: '오', info: '국력: 900<br>장수 수: 6<br>도시 수: 3', isHtml: true },
|
||||
{ title: '연', info: '국력: 700<br>장수 수: 5<br>도시 수: 2', isHtml: true },
|
||||
{ title: '양', info: '국력: 650<br>장수 수: 4<br>도시 수: 2', isHtml: true },
|
||||
{ title: '형', info: '국력: 600<br>장수 수: 3<br>도시 수: 1', isHtml: true },
|
||||
],
|
||||
winner: null,
|
||||
},
|
||||
bettingDetail: [
|
||||
['[-1]', 500],
|
||||
['[0,1]', 200],
|
||||
['[1,2]', 100],
|
||||
],
|
||||
myBetting: [['[0,1]', 50]],
|
||||
remainPoint: 1200,
|
||||
year: 193,
|
||||
month: 1,
|
||||
};
|
||||
|
||||
const login = async (context, page) => {
|
||||
await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 });
|
||||
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||
// The reference entrance polls install status and can keep the PHP session
|
||||
// occupied. Leave it before the login request so the session lock is free.
|
||||
await page.goto('about:blank');
|
||||
await context.clearCookies();
|
||||
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 },
|
||||
timeout: 60_000,
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok() || result.result !== true) {
|
||||
throw new Error('Reference login failed.');
|
||||
}
|
||||
};
|
||||
|
||||
const installBettingFixture = async (page) => {
|
||||
await page.route('**/api.php*', async (route) => {
|
||||
const path = new URL(route.request().url()).searchParams.get('path');
|
||||
if (path === 'Betting/GetBettingList') {
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(bettingList) });
|
||||
return;
|
||||
}
|
||||
if (path === 'Betting/GetBettingDetail') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(bettingDetail),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
};
|
||||
|
||||
const mountStaticReference = async (page) => {
|
||||
const hweUrl = new URL('hwe/', staticBaseUrl);
|
||||
const assetUrl = new URL('dist_js/hwe_dynamic/vue/', staticBaseUrl);
|
||||
await page.setContent(
|
||||
`<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=500">
|
||||
<base href="${hweUrl}">
|
||||
<link rel="stylesheet" href="${new URL('d_shared/common.css', hweUrl)}">
|
||||
<link rel="stylesheet" href="${new URL('vendors.css', assetUrl)}">
|
||||
<link rel="stylesheet" href="${new URL('common_ts.css', assetUrl)}">
|
||||
<link rel="stylesheet" href="${new URL('bootstrap.css', assetUrl)}">
|
||||
<link rel="stylesheet" href="${new URL('v_nationBetting.css', assetUrl)}">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="${new URL('d_shared/common_path.js', hweUrl)}"></script>
|
||||
<script src="${new URL('vendors.js', assetUrl)}"></script>
|
||||
<script src="${new URL('common_ts.js', assetUrl)}"></script>
|
||||
<script src="${new URL('bootstrap.js', assetUrl)}"></script>
|
||||
<script src="${new URL('v_nationBetting.js', assetUrl)}"></script>
|
||||
</body>
|
||||
</html>`,
|
||||
{ waitUntil: 'networkidle' }
|
||||
);
|
||||
};
|
||||
|
||||
const measure = async (browser, viewport) => {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: viewport.width, height: viewport.height },
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'dark',
|
||||
locale: 'ko-KR',
|
||||
timezoneId: 'UTC',
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await installBettingFixture(page);
|
||||
if (staticBaseUrl) {
|
||||
await mountStaticReference(page);
|
||||
} else {
|
||||
await login(context, page);
|
||||
await page.goto(new URL('hwe/v_nationBetting.php', baseUrl).toString(), {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
await page.locator('.bettingItem').click();
|
||||
await page.locator('.bettingCandidate').first().waitFor({ state: 'visible' });
|
||||
|
||||
const geometry = await page.locator('#container').evaluate((container) => {
|
||||
const rect = (element) => {
|
||||
const value = element.getBoundingClientRect();
|
||||
return { x: value.x, y: value.y, width: value.width, height: value.height };
|
||||
};
|
||||
const cards = Array.from(container.querySelectorAll('.bettingCandidate'));
|
||||
const firstCard = cards[0];
|
||||
const cardStyle = getComputedStyle(firstCard);
|
||||
const titleStyle = getComputedStyle(firstCard.querySelector('.title'));
|
||||
const optionalRect = (selector) => {
|
||||
const element = container.querySelector(selector);
|
||||
return element ? rect(element) : null;
|
||||
};
|
||||
return {
|
||||
container: rect(container),
|
||||
topBar: rect(container.querySelector('.back_bar')),
|
||||
candidateCells: Array.from(container.querySelectorAll('.bettingCandidates > div')).map(rect),
|
||||
candidates: cards.map(rect),
|
||||
bettingForm: optionalRect('.bettingCandidates + .row'),
|
||||
payoutTable: optionalRect('.bettingCandidates + .row + div'),
|
||||
bettingList: optionalRect('.bettingList'),
|
||||
bottomBar: optionalRect('.bottom_bar, .bg0[style]'),
|
||||
cardStyle: {
|
||||
borderWidth: cardStyle.borderWidth,
|
||||
borderRadius: cardStyle.borderRadius,
|
||||
cursor: cardStyle.cursor,
|
||||
fontSize: cardStyle.fontSize,
|
||||
lineHeight: cardStyle.lineHeight,
|
||||
},
|
||||
titleStyle: {
|
||||
fontWeight: titleStyle.fontWeight,
|
||||
textAlign: titleStyle.textAlign,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await page.locator('.bettingCandidate').first().click();
|
||||
const pickedStyle = await page
|
||||
.locator('.bettingCandidate')
|
||||
.first()
|
||||
.evaluate((candidate) => {
|
||||
const style = getComputedStyle(candidate);
|
||||
return {
|
||||
borderColor: style.borderColor,
|
||||
outlineWidth: style.outlineWidth,
|
||||
titleWeight: getComputedStyle(candidate.querySelector('.title')).fontWeight,
|
||||
};
|
||||
});
|
||||
|
||||
const screenshotPath = resolve(artifactRoot, `nation-betting-ref-${viewport.name}.png`);
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true, animations: 'disabled' });
|
||||
return { geometry, pickedStyle, screenshotPath };
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
};
|
||||
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const result = {};
|
||||
for (const viewport of [
|
||||
{ name: 'desktop', width: 1280, height: 900 },
|
||||
{ name: 'mobile', width: 500, height: 900 },
|
||||
]) {
|
||||
result[viewport.name] = await measure(browser, viewport);
|
||||
}
|
||||
const outputPath = resolve(artifactRoot, 'computed-dom.json');
|
||||
await writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`);
|
||||
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, outputPath })}\n`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export default defineConfig({
|
||||
'visual-parity.spec.ts',
|
||||
'public-gaps.spec.ts',
|
||||
'instant-diplomacy-message.spec.ts',
|
||||
'ingame-message-parity.spec.ts',
|
||||
'tournament-betting.spec.ts',
|
||||
'inheritance-management.spec.ts',
|
||||
],
|
||||
|
||||
@@ -4,10 +4,7 @@ import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const imageRoots = [
|
||||
resolve(repositoryRoot, '../image/game'),
|
||||
resolve(repositoryRoot, '../../image/game'),
|
||||
];
|
||||
const imageRoots = [resolve(repositoryRoot, '../image/game'), resolve(repositoryRoot, '../../image/game')];
|
||||
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
@@ -214,12 +211,39 @@ test('nation betting matches the legacy desktop geometry and preserves a failed
|
||||
const geometry = await page.locator('#nation-betting-container').evaluate((container) => {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const bar = container.querySelector<HTMLElement>('.legacy-top-bar')!.getBoundingClientRect();
|
||||
const detail = container.querySelector<HTMLElement>('.betting-detail')!;
|
||||
const detailRect = detail.getBoundingClientRect();
|
||||
const candidateRowElement = container.querySelector<HTMLElement>('.betting-candidates')!;
|
||||
const candidateRow = candidateRowElement.getBoundingClientRect();
|
||||
const candidateCells = Array.from(container.querySelectorAll<HTMLElement>('.betting-candidate-cell'));
|
||||
const cards = Array.from(container.querySelectorAll<HTMLElement>('.betting-candidate'));
|
||||
const cardStyle = getComputedStyle(cards[0]!);
|
||||
const optionalRect = (selector: string) => {
|
||||
const element = container.querySelector<HTMLElement>(selector);
|
||||
if (!element) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
};
|
||||
return {
|
||||
container: { x: containerRect.x, width: containerRect.width },
|
||||
container: { x: containerRect.x, width: containerRect.width, height: containerRect.height },
|
||||
bar: { width: bar.width, height: bar.height },
|
||||
cardWidths: cards.map((card) => card.getBoundingClientRect().width),
|
||||
detail: { x: detailRect.x, width: detailRect.width },
|
||||
candidateRow: {
|
||||
x: candidateRow.x,
|
||||
width: candidateRow.width,
|
||||
},
|
||||
candidateCells: candidateCells.map((cell) => {
|
||||
const rect = cell.getBoundingClientRect();
|
||||
return { x: rect.x, width: rect.width };
|
||||
}),
|
||||
cards: cards.map((card) => {
|
||||
const rect = card.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
}),
|
||||
bettingForm: optionalRect('.betting-form'),
|
||||
payoutTable: optionalRect('.payout-table'),
|
||||
bettingList: optionalRect('.betting-list'),
|
||||
bottomBar: optionalRect('.betting-footer'),
|
||||
cardStyle: {
|
||||
borderWidth: cardStyle.borderWidth,
|
||||
borderRadius: cardStyle.borderRadius,
|
||||
@@ -229,24 +253,43 @@ test('nation betting matches the legacy desktop geometry and preserves a failed
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
expect(geometry.container).toEqual({ x: 140, width: 1000 });
|
||||
expect(geometry.container).toEqual({ x: 140, width: 1000, height: 435 });
|
||||
expect(geometry.bar).toEqual({ width: 1000, height: 32 });
|
||||
expect(geometry.cardWidths.every((width) => Math.abs(width - 162) < 1)).toBe(true);
|
||||
expect(geometry.detail).toEqual({ x: 140, width: 1000 });
|
||||
expect(geometry.candidateRow).toEqual({ x: 138.25, width: 1003.5 });
|
||||
expect(geometry.candidateCells.map(({ width }) => width)).toEqual(Array(6).fill(167.25));
|
||||
expect(geometry.cards.map(({ width }) => width)).toEqual(Array(6).fill(163.75));
|
||||
expect(geometry.cards.map(({ height }) => height)).toEqual(Array(6).fill(143));
|
||||
expect(geometry.cards.map(({ y }) => y)).toEqual(Array(6).fill(53));
|
||||
expect(geometry.bettingForm).toEqual({ x: 140, y: 196, width: 1000, height: 35.5 });
|
||||
expect(geometry.payoutTable).toEqual({ x: 140, y: 231.5, width: 1000, height: 85 });
|
||||
expect(geometry.bettingList).toEqual({ x: 140, y: 330.5, width: 1000, height: 45.5 });
|
||||
expect(geometry.bottomBar).toEqual({ x: 140, y: 379.5, width: 1000, height: 55.5 });
|
||||
expect(geometry.cardStyle).toEqual({
|
||||
borderWidth: '1px',
|
||||
borderRadius: '7px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
lineHeight: '18.2px',
|
||||
lineHeight: '21px',
|
||||
});
|
||||
await expect(page.locator('.legacy-top-bar .legacy-nav-button')).toHaveCount(1);
|
||||
await expect(page.locator('.payout-row:not(.payout-head)').first().locator('div').nth(2)).toHaveText(
|
||||
'(50 -> 100.0)'
|
||||
);
|
||||
|
||||
await page.locator('.betting-candidate').nth(0).click();
|
||||
await page.locator('.betting-candidate').nth(1).click();
|
||||
const pickedStyle = await page.locator('.betting-candidate').first().evaluate((candidate) => {
|
||||
const style = getComputedStyle(candidate);
|
||||
return { borderColor: style.borderColor, outlineWidth: style.outlineWidth, titleWeight: getComputedStyle(candidate.querySelector('.candidate-title')!).fontWeight };
|
||||
});
|
||||
const pickedStyle = await page
|
||||
.locator('.betting-candidate')
|
||||
.first()
|
||||
.evaluate((candidate) => {
|
||||
const style = getComputedStyle(candidate);
|
||||
return {
|
||||
borderColor: style.borderColor,
|
||||
outlineWidth: style.outlineWidth,
|
||||
titleWeight: getComputedStyle(candidate.querySelector('.candidate-title')!).fontWeight,
|
||||
};
|
||||
});
|
||||
expect(pickedStyle.borderColor).toBe('rgb(255, 255, 255)');
|
||||
// Chromium snaps the legacy 1.5px CSS outline to one device pixel at DSF 1.
|
||||
expect(pickedStyle.outlineWidth).toBe('1px');
|
||||
@@ -281,6 +324,7 @@ test('nation betting keeps the legacy 500px three-column mobile contract', async
|
||||
return {
|
||||
x: rect.x,
|
||||
width: rect.width,
|
||||
firstX: cards[0]!.getBoundingClientRect().x,
|
||||
firstWidth: cards[0]!.getBoundingClientRect().width,
|
||||
fourthY: cards[3]!.getBoundingClientRect().y,
|
||||
firstY: cards[0]!.getBoundingClientRect().y,
|
||||
@@ -288,7 +332,8 @@ test('nation betting keeps the legacy 500px three-column mobile contract', async
|
||||
});
|
||||
expect(geometry.x).toBe(0);
|
||||
expect(geometry.width).toBe(500);
|
||||
expect(geometry.firstWidth).toBeCloseTo(161.328125, 3);
|
||||
expect(geometry.firstX).toBe(0);
|
||||
expect(geometry.firstWidth).toBe(164.328125);
|
||||
expect(geometry.fourthY).toBeGreaterThan(geometry.firstY);
|
||||
|
||||
if (artifactRoot) {
|
||||
@@ -325,17 +370,7 @@ test('NPC list matches the legacy table geometry, sorting and error retention',
|
||||
expect(geometry.tableWidth).toBe(1000);
|
||||
// The legacy width attributes total 974px; Chromium proportionally expands them into the 1000px table.
|
||||
expect(geometry.headerWidths).toEqual([
|
||||
104.609375,
|
||||
104.609375,
|
||||
69.734375,
|
||||
121.015625,
|
||||
69.734375,
|
||||
90.25,
|
||||
69.734375,
|
||||
69.734375,
|
||||
69.734375,
|
||||
69.734375,
|
||||
80,
|
||||
104.609375, 104.609375, 69.734375, 121.015625, 69.734375, 90.25, 69.734375, 69.734375, 69.734375, 69.734375, 80,
|
||||
80.109375,
|
||||
]);
|
||||
expect(geometry.headerStyle).toEqual({
|
||||
@@ -346,7 +381,10 @@ test('NPC list matches the legacy table geometry, sorting and error retention',
|
||||
lineHeight: '18.2px',
|
||||
});
|
||||
await expect(page.locator('.npc-table tbody tr').first()).toContainText('관우');
|
||||
await expect(page.locator('.npc-table tbody tr').first().locator('td').first()).toHaveCSS('color', 'rgb(135, 206, 235)');
|
||||
await expect(page.locator('.npc-table tbody tr').first().locator('td').first()).toHaveCSS(
|
||||
'color',
|
||||
'rgb(135, 206, 235)'
|
||||
);
|
||||
const personality = page.locator('.npc-table tbody tr').first().locator('.trait-tooltip').first();
|
||||
await personality.hover();
|
||||
await expect(personality.getByRole('tooltip')).toBeVisible();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -348,6 +348,8 @@ const buildWorldInput = (
|
||||
maxGeneral: 500,
|
||||
baseGold: 0,
|
||||
baseRice: 2_000,
|
||||
generalMinimumGold: 0,
|
||||
generalMinimumRice: 500,
|
||||
maxResourceActionAmount: 10_000,
|
||||
maxTechLevel: 12,
|
||||
maxLevel: 255,
|
||||
|
||||
@@ -1109,6 +1109,194 @@ integration('general command missing-target fallback matrix', () => {
|
||||
);
|
||||
});
|
||||
|
||||
const resourceAmountCases: Array<{
|
||||
name: string;
|
||||
action: string;
|
||||
args: Record<string, unknown>;
|
||||
expectedAmount: number;
|
||||
}> = [
|
||||
{
|
||||
name: 'gift rounds a half unit up',
|
||||
action: 'che_증여',
|
||||
args: { isGold: true, amount: 150, destGeneralID: 3 },
|
||||
expectedAmount: 200,
|
||||
},
|
||||
{
|
||||
name: 'gift clamps below the minimum',
|
||||
action: 'che_증여',
|
||||
args: { isGold: true, amount: 1, destGeneralID: 3 },
|
||||
expectedAmount: 100,
|
||||
},
|
||||
{
|
||||
name: 'gift clamps above the maximum',
|
||||
action: 'che_증여',
|
||||
args: { isGold: true, amount: 10_050, destGeneralID: 3 },
|
||||
expectedAmount: 10_000,
|
||||
},
|
||||
{
|
||||
name: 'donation rounds a half unit up',
|
||||
action: 'che_헌납',
|
||||
args: { isGold: true, amount: 150 },
|
||||
expectedAmount: 200,
|
||||
},
|
||||
{
|
||||
name: 'donation clamps below the minimum',
|
||||
action: 'che_헌납',
|
||||
args: { isGold: true, amount: 1 },
|
||||
expectedAmount: 100,
|
||||
},
|
||||
{
|
||||
name: 'donation clamps above the maximum',
|
||||
action: 'che_헌납',
|
||||
args: { isGold: true, amount: 10_050 },
|
||||
expectedAmount: 10_000,
|
||||
},
|
||||
{
|
||||
name: 'trade rounds a half unit up',
|
||||
action: 'che_군량매매',
|
||||
args: { buyRice: true, amount: 150 },
|
||||
expectedAmount: 200,
|
||||
},
|
||||
{
|
||||
name: 'trade clamps below the minimum',
|
||||
action: 'che_군량매매',
|
||||
args: { buyRice: true, amount: 1 },
|
||||
expectedAmount: 100,
|
||||
},
|
||||
{
|
||||
name: 'trade clamps above the maximum',
|
||||
action: 'che_군량매매',
|
||||
args: { buyRice: true, amount: 10_050 },
|
||||
expectedAmount: 10_000,
|
||||
},
|
||||
];
|
||||
|
||||
integration('general command resource amount normalization matrix', () => {
|
||||
it.each(resourceAmountCases)(
|
||||
'$name matches legacy rounding and clamp semantics',
|
||||
async ({ action, args, expectedAmount }) => {
|
||||
const request = buildRequest(action, args);
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
const referenceActor = reference.after.generals.find((entry) => entry.id === 1);
|
||||
const coreActor = core.after.generals.find((entry) => entry.id === 1);
|
||||
const referenceLastTurn = referenceActor?.lastTurn as { arg?: Record<string, unknown> } | null | undefined;
|
||||
const coreLastTurn = coreActor?.lastTurn as { arg?: Record<string, unknown> } | null | undefined;
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: action,
|
||||
actionKey: action,
|
||||
usedFallback: false,
|
||||
});
|
||||
expect(referenceLastTurn?.arg).toMatchObject({ amount: expectedAmount });
|
||||
expect(coreLastTurn?.arg).toMatchObject({ amount: expectedAmount });
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
},
|
||||
120_000
|
||||
);
|
||||
});
|
||||
|
||||
integration('general command donation resource boundaries', () => {
|
||||
it('donates the available resource when the normalized request exceeds the current amount', async () => {
|
||||
const request = buildRequest('che_헌납', { isGold: true, amount: 10_000 }, { gold: 5_000 });
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: 'che_헌납',
|
||||
actionKey: 'che_헌납',
|
||||
usedFallback: false,
|
||||
});
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
it('falls back when current rice is below the legacy minimum even for a small request', async () => {
|
||||
const request = buildRequest('che_헌납', { isGold: false, amount: 100 }, { rice: 499 });
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: false });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: 'che_헌납',
|
||||
actionKey: '휴식',
|
||||
usedFallback: true,
|
||||
});
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
integration('general command gift resource and target boundaries', () => {
|
||||
it('keeps the legacy minimum rice reserve while gifting the available amount', async () => {
|
||||
const request = buildRequest('che_증여', { isGold: false, amount: 10_000, destGeneralID: 3 }, { rice: 600 });
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: 'che_증여',
|
||||
actionKey: 'che_증여',
|
||||
usedFallback: false,
|
||||
});
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
it('rejects gifting to the actor and falls back without command RNG', async () => {
|
||||
const request = buildRequest('che_증여', { isGold: true, amount: 100, destGeneralID: 1 });
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: false });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: 'che_증여',
|
||||
actionKey: '휴식',
|
||||
usedFallback: true,
|
||||
});
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
type GeneralConstraintCase = {
|
||||
name: string;
|
||||
action: string;
|
||||
|
||||
Reference in New Issue
Block a user