Merge branch 'main' into feature/auction-menu-parity

This commit is contained in:
2026-07-26 05:39:29 +00:00
27 changed files with 2955 additions and 384 deletions
+2 -1
View File
@@ -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}`,
};
};
+178 -16
View File
@@ -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,
+2
View File
@@ -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),
};
};
+336 -3
View File
@@ -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">서신전달&amp;갱신</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">&lt;{{ message.time }}&gt;</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>
+140 -22
View File
@@ -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,
+51 -33
View File
@@ -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;
+131 -60
View File
@@ -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 재조회가 완료
+2 -2
View File
@@ -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;