Merge branch 'main' into feature/tournament-betting-parity

This commit is contained in:
2026-07-26 04:32:29 +00:00
42 changed files with 4791 additions and 1950 deletions
+20 -12
View File
@@ -1,5 +1,6 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { loadMapDefinitionByName } from './mapDefinition.js';
export interface MapLayoutCity {
id: number;
@@ -30,8 +31,7 @@ const LEGACY_CITY_CONST = path.resolve(process.cwd(), 'legacy/hwe/sammo/CityCons
const layoutCache = new Map<string, MapLayout>();
const stripComments = (value: string): string =>
value.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
const stripComments = (value: string): string => value.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
const extractPhpArray = (source: string, marker: string): string | null => {
const idx = source.indexOf(marker);
@@ -196,11 +196,7 @@ const parseCityConstFile = async (filePath: string): Promise<ParsedCityConst> =>
const resolveScenarioFile = async (scenario: string): Promise<string> => {
const normalized = scenario.replace(/\.json$/i, '');
const candidates = [
`${normalized}.json`,
`scenario_${normalized}.json`,
'default.json',
];
const candidates = [`${normalized}.json`, `scenario_${normalized}.json`, 'default.json'];
for (const candidate of candidates) {
const fullPath = path.join(LEGACY_SCENARIO_ROOT, candidate);
@@ -272,15 +268,15 @@ const normalizeInitCity = (
typeof levelLabel === 'number'
? levelLabel
: typeof levelLabel === 'string'
? levelMap.nameToId[levelLabel] ?? Number(levelLabel)
: 0;
? (levelMap.nameToId[levelLabel] ?? Number(levelLabel))
: 0;
const regionValue =
typeof regionLabel === 'number'
? regionLabel
: typeof regionLabel === 'string'
? regionMap.nameToId[regionLabel] ?? Number(regionLabel)
: 0;
? (regionMap.nameToId[regionLabel] ?? Number(regionLabel))
: 0;
const pathNames = Array.isArray(path) ? (path as string[]) : [];
const pathIds = pathNames
@@ -324,7 +320,19 @@ export const loadMapLayout = async (scenario: string): Promise<MapLayout> => {
const levelMap = buildLookupMap(levelMapRaw);
const initCity = map.initCity ?? base.initCity ?? [];
const cityList = normalizeInitCity(initCity, levelMap, regionMap);
let cityList = normalizeInitCity(initCity, levelMap, regionMap);
if (cityList.length === 0) {
const resourceMap = await loadMapDefinitionByName(mapName);
cityList = resourceMap.cities.map((city) => ({
id: city.id,
name: city.name,
level: city.level,
region: city.region,
x: city.position.x,
y: city.position.y,
path: [...city.connections],
}));
}
const layout: MapLayout = {
mapName,
+171 -182
View File
@@ -5,31 +5,37 @@ import { promises as fs } from 'fs';
import { randomUUID } from 'crypto';
import sharp, { type WebpOptions } from 'sharp';
import { GamePrisma } from '@sammo-ts/infra';
import { authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
import { resolveSecretPermission } from '../shared/secretPermission.js';
const MAX_UPLOAD_BYTES = 1024 * 1024;
const MAX_LONG_EDGE = 2048;
const WEBP_QUALITY = 80;
const WEBP_MIN_SAVING_RATIO = 0.97;
const resolveSecretPermission = (officerLevel: number): number => {
if (officerLevel >= 5) return 2;
if (officerLevel > 1) return 1;
return 0;
};
const assertBoardAccess = (nationId: number, officerLevel: number, isSecret: boolean) => {
if (nationId <= 0 || officerLevel <= 0) {
const assertBoardAccess = (permission: number, isSecret: boolean) => {
if (permission < 0) {
throw new TRPCError({ code: 'FORBIDDEN', message: '국가에 소속되어있지 않습니다.' });
}
if (isSecret && resolveSecretPermission(officerLevel) < 2) {
if (isSecret && permission < 2) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다. 수뇌부가 아닙니다.' });
}
};
const getBoardActor = async (ctx: Parameters<typeof getMyGeneral>[0]) => {
const general = await getMyGeneral(ctx);
const nation =
general.nationId > 0
? await ctx.db.nation.findUnique({
where: { id: general.nationId },
select: { meta: true },
})
: null;
const permission = resolveSecretPermission(general, nation?.meta ?? {}, true);
return { general, permission };
};
const normalizeUploadPath = (value: string) => {
if (!value.startsWith('/')) {
return `/${value}`;
@@ -92,111 +98,96 @@ const buildAvifBuffer = async (buffer: Buffer, resize: boolean): Promise<Buffer>
return pipeline.avif({ quality: 60, effort: 4 }).toBuffer();
};
type BoardPostRow = {
id: number;
nation_id: number;
is_secret: boolean;
author_general_id: number;
author_name: string;
title: string;
content_html: string;
created_at: Date;
};
type BoardCommentRow = {
id: number;
post_id: number;
author_general_id: number;
author_name: string;
content_text: string;
created_at: Date;
};
export const boardRouter = router({
getArticles: authedProcedure
.input(z.object({ isSecret: z.boolean() }))
.query(async ({ ctx, input }) => {
const me = await getMyGeneral(ctx);
assertBoardAccess(me.nationId, me.officerLevel, input.isSecret);
getAccess: authedProcedure.query(async ({ ctx }) => {
const { permission } = await getBoardActor(ctx);
return {
permission,
canMeeting: permission >= 0,
canSecret: permission >= 2,
};
}),
getArticles: authedProcedure.input(z.object({ isSecret: z.boolean() })).query(async ({ ctx, input }) => {
const { general, permission } = await getBoardActor(ctx);
assertBoardAccess(permission, input.isSecret);
const posts = await ctx.db.$queryRaw<BoardPostRow[]>(GamePrisma.sql`
SELECT
id,
nation_id,
is_secret,
author_general_id,
author_name,
title,
content_html,
created_at
FROM board_post
WHERE nation_id = ${me.nationId} AND is_secret = ${input.isSecret}
ORDER BY created_at DESC
LIMIT 100
`);
const posts = await ctx.db.boardPost.findMany({
where: {
nationId: general.nationId,
isSecret: input.isSecret,
},
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: 100,
include: {
comments: {
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
},
},
});
if (posts.length === 0) {
return [];
}
if (posts.length === 0) {
return [];
}
const postIds = posts.map((post) => post.id);
const comments = await ctx.db.$queryRaw<BoardCommentRow[]>(GamePrisma.sql`
SELECT
id,
post_id,
author_general_id,
author_name,
content_text,
created_at
FROM board_comment
WHERE post_id IN (${GamePrisma.join(postIds)})
ORDER BY created_at ASC
`);
const authors = await ctx.db.general.findMany({
where: {
id: {
in: [...new Set(posts.map((post) => post.authorGeneralId))],
},
},
select: {
id: true,
picture: true,
imageServer: true,
},
});
const authorMap = new Map(authors.map((author) => [author.id, author]));
const commentMap = new Map<number, BoardCommentRow[]>();
for (const comment of comments) {
const list = commentMap.get(comment.post_id) ?? [];
list.push(comment);
commentMap.set(comment.post_id, list);
}
return posts.map((post) => ({
id: post.id,
title: post.title,
contentHtml: post.content_html,
authorName: post.author_name,
createdAt: post.created_at.toISOString(),
comments: (commentMap.get(post.id) ?? []).map((comment) => ({
id: comment.id,
authorName: comment.author_name,
content: comment.content_text,
createdAt: comment.created_at.toISOString(),
})),
}));
}),
return posts.map((post) => ({
id: post.id,
title: post.title,
content: post.contentHtml,
authorName: post.authorName,
authorPicture: authorMap.get(post.authorGeneralId)?.picture ?? null,
authorImageServer: authorMap.get(post.authorGeneralId)?.imageServer ?? 0,
createdAt: post.createdAt.toISOString(),
comments: post.comments.map((comment) => ({
id: comment.id,
authorName: comment.authorName,
content: comment.contentText,
createdAt: comment.createdAt.toISOString(),
})),
}));
}),
writeArticle: authedProcedure
.input(
z.object({
isSecret: z.boolean(),
title: z.string().trim().max(250),
contentHtml: z.string().trim().max(20000),
content: z.string().trim().max(20000),
})
)
.mutation(async ({ ctx, input }) => {
const me = await getMyGeneral(ctx);
assertBoardAccess(me.nationId, me.officerLevel, input.isSecret);
const { general, permission } = await getBoardActor(ctx);
assertBoardAccess(permission, input.isSecret);
if (!input.title && !input.contentHtml) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '제목 혹은 내용이 필요합니다.' });
if (!input.title && !input.content) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '제목 내용이 둘다 비어있습니다.' });
}
const rows = await ctx.db.$queryRaw<{ id: number }[]>(GamePrisma.sql`
INSERT INTO board_post (nation_id, is_secret, author_general_id, author_name, title, content_html)
VALUES (${me.nationId}, ${input.isSecret}, ${me.id}, ${me.name}, ${input.title}, ${input.contentHtml})
RETURNING id
`);
const post = await ctx.db.boardPost.create({
data: {
nationId: general.nationId,
isSecret: input.isSecret,
authorGeneralId: general.id,
authorName: general.name,
title: input.title,
contentHtml: input.content,
},
select: { id: true },
});
return { id: rows[0]?.id ?? null };
return { id: post.id };
}),
writeComment: authedProcedure
.input(
@@ -206,101 +197,99 @@ export const boardRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const me = await getMyGeneral(ctx);
const { general, permission } = await getBoardActor(ctx);
if (!input.content) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '내용이 필요합니다.' });
throw new TRPCError({ code: 'BAD_REQUEST', message: '내용이 비어있습니다.' });
}
const posts = await ctx.db.$queryRaw<{ id: number; nation_id: number; is_secret: boolean }[]>(
GamePrisma.sql`
SELECT id, nation_id, is_secret
FROM board_post
WHERE id = ${input.postId}
LIMIT 1
`
);
const post = posts[0];
const post = await ctx.db.boardPost.findFirst({
where: {
id: input.postId,
nationId: general.nationId,
},
select: {
id: true,
isSecret: true,
},
});
if (!post) {
throw new TRPCError({ code: 'NOT_FOUND', message: '게시물을 찾을 수 없습니다.' });
throw new TRPCError({ code: 'NOT_FOUND', message: '게시물 없습니다.' });
}
assertBoardAccess(me.nationId, me.officerLevel, post.is_secret);
assertBoardAccess(permission, post.isSecret);
if (post.nation_id !== me.nationId) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
}
const comment = await ctx.db.boardComment.create({
data: {
postId: post.id,
nationId: general.nationId,
isSecret: post.isSecret,
authorGeneralId: general.id,
authorName: general.name,
contentText: input.content,
},
select: { id: true },
});
const rows = await ctx.db.$queryRaw<{ id: number }[]>(GamePrisma.sql`
INSERT INTO board_comment (post_id, nation_id, is_secret, author_general_id, author_name, content_text)
VALUES (${post.id}, ${me.nationId}, ${post.is_secret}, ${me.id}, ${me.name}, ${input.content})
RETURNING id
`);
return { id: rows[0]?.id ?? null };
return { id: comment.id };
}),
uploadImage: authedProcedure
.input(z.object({ dataUrl: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const me = await getMyGeneral(ctx);
if (me.nationId <= 0 || me.officerLevel <= 0) {
throw new TRPCError({ code: 'FORBIDDEN', message: '국가에 소속되어있지 않습니다.' });
uploadImage: authedProcedure.input(z.object({ dataUrl: z.string().min(1) })).mutation(async ({ ctx, input }) => {
const { permission } = await getBoardActor(ctx);
assertBoardAccess(permission, false);
const buffer = parseDataUrl(input.dataUrl);
if (buffer.length > MAX_UPLOAD_BYTES) {
throw new TRPCError({ code: 'PAYLOAD_TOO_LARGE', message: '이미지 용량 제한(1MB)을 초과했습니다.' });
}
const metadata = await sharp(buffer, { animated: true }).metadata();
if (!metadata.format || !metadata.width || !metadata.height) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
}
const format = metadata.format;
const isAnimated = (metadata.pages ?? 1) > 1;
const needsResize = Math.max(metadata.width, metadata.height) > MAX_LONG_EDGE;
const allowed = new Set(['png', 'jpeg', 'jpg', 'gif', 'webp', 'avif', 'heif', 'tiff', 'bmp']);
if (!allowed.has(format)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' });
}
let outputBuffer = buffer;
let outputFormat = format === 'avif' ? 'avif' : 'webp';
if (format === 'avif') {
if (needsResize) {
outputBuffer = await buildAvifBuffer(buffer, true);
}
const buffer = parseDataUrl(input.dataUrl);
if (buffer.length > MAX_UPLOAD_BYTES) {
throw new TRPCError({ code: 'PAYLOAD_TOO_LARGE', message: '이미지 용량 제한(1MB)을 초과했습니다.' });
}
const metadata = await sharp(buffer, { animated: true }).metadata();
if (!metadata.format || !metadata.width || !metadata.height) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
}
const format = metadata.format;
const isAnimated = (metadata.pages ?? 1) > 1;
const needsResize = Math.max(metadata.width, metadata.height) > MAX_LONG_EDGE;
const allowed = new Set(['png', 'jpeg', 'jpg', 'gif', 'webp', 'avif', 'heif', 'tiff', 'bmp']);
if (!allowed.has(format)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' });
}
let outputBuffer = buffer;
let outputFormat = format === 'avif' ? 'avif' : 'webp';
if (format === 'avif') {
if (needsResize) {
outputBuffer = await buildAvifBuffer(buffer, true);
}
} else {
const webpBuffer = await buildWebpBuffer(buffer, {
animated: isAnimated || format === 'gif',
resize: needsResize,
});
if (format === 'webp' && !needsResize && webpBuffer.length >= buffer.length * WEBP_MIN_SAVING_RATIO) {
outputBuffer = buffer;
outputFormat = 'webp';
} else {
const webpBuffer = await buildWebpBuffer(buffer, {
animated: isAnimated || format === 'gif',
resize: needsResize,
});
if (format === 'webp' && !needsResize && webpBuffer.length >= buffer.length * WEBP_MIN_SAVING_RATIO) {
outputBuffer = buffer;
outputFormat = 'webp';
} else {
outputBuffer = webpBuffer;
outputFormat = 'webp';
}
outputBuffer = webpBuffer;
outputFormat = 'webp';
}
}
await fs.mkdir(ctx.uploadDir, { recursive: true });
const filename = `${randomUUID()}.${outputFormat}`;
await fs.writeFile(path.join(ctx.uploadDir, filename), outputBuffer);
await fs.mkdir(ctx.uploadDir, { recursive: true });
const filename = `${randomUUID()}.${outputFormat}`;
await fs.writeFile(path.join(ctx.uploadDir, filename), outputBuffer);
const outputMeta = await sharp(outputBuffer, { animated: true }).metadata();
const url = buildPublicImageUrl(ctx.uploadPublicUrl, ctx.uploadPath, filename);
const outputMeta = await sharp(outputBuffer, { animated: true }).metadata();
const url = buildPublicImageUrl(ctx.uploadPublicUrl, ctx.uploadPath, filename);
return {
url,
width: outputMeta.width ?? metadata.width,
height: outputMeta.height ?? metadata.height,
format: outputFormat,
animated: isAnimated,
size: outputBuffer.length,
};
}),
});
return {
url,
width: outputMeta.width ?? metadata.width,
height: outputMeta.height ?? metadata.height,
format: outputFormat,
animated: isAnimated,
size: outputBuffer.length,
};
}),
});
@@ -0,0 +1,127 @@
import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import { LogCategory, LogScope } from '@sammo-ts/infra';
import { getGoldIncome, getOutcome, getRiceIncome, getWallIncome, getWarGoldIncome } from '@sammo-ts/logic';
import { authedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import {
assertNationAccess,
buildNationIncomeContext,
resolveNationBill,
resolveNationRate,
resolveOfficerCity,
toIncomeCity,
} from '../shared.js';
export const getNationInfo = authedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
const [nation, cities, generals, history] = await Promise.all([
ctx.db.nation.findUnique({ where: { id: me.nationId } }),
ctx.db.city.findMany({ where: { nationId: me.nationId }, orderBy: { id: 'asc' } }),
ctx.db.general.findMany({ where: { nationId: me.nationId } }),
ctx.db.logEntry.findMany({
where: {
scope: LogScope.NATION,
category: LogCategory.HISTORY,
nationId: me.nationId,
},
select: { id: true, year: true, month: true, text: true },
orderBy: { id: 'asc' },
}),
]);
if (!nation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
}
const officerCntByCity = new Map<number, number>();
for (const general of generals) {
const officerCity = resolveOfficerCity(asRecord(general.meta));
if (
general.officerLevel >= 2 &&
general.officerLevel <= 4 &&
officerCity > 0 &&
general.cityId === officerCity
) {
officerCntByCity.set(officerCity, (officerCntByCity.get(officerCity) ?? 0) + 1);
}
}
const incomeContext = await buildNationIncomeContext(nation);
const incomeCities = cities.map(toIncomeCity);
const rate = resolveNationRate(nation);
const bill = resolveNationBill(asRecord(nation.meta));
const goldCity = getGoldIncome(
incomeContext,
incomeCities,
officerCntByCity,
nation.capitalCityId ?? 0,
nation.level
);
const goldWar = getWarGoldIncome(incomeContext, incomeCities);
const riceCity = getRiceIncome(
incomeContext,
incomeCities,
officerCntByCity,
nation.capitalCityId ?? 0,
nation.level
);
const riceWall = getWallIncome(
incomeContext,
incomeCities,
officerCntByCity,
nation.capitalCityId ?? 0,
nation.level
);
const outcome = getOutcome(
bill,
generals.filter((general) => general.npcState !== 5)
);
const population = cities.reduce((sum, city) => sum + city.population, 0);
const populationMax = cities.reduce((sum, city) => sum + city.populationMax, 0);
const crewGenerals = generals.filter((general) => general.npcState !== 5);
const crew = crewGenerals.reduce((sum, general) => sum + general.crew, 0);
const crewMax = crewGenerals.reduce((sum, general) => sum + general.leadership * 100, 0);
const meta = asRecord(nation.meta);
return {
nation: {
id: nation.id,
name: nation.name,
color: nation.color,
level: nation.level,
power: typeof meta.power === 'number' ? meta.power : 0,
gold: nation.gold,
rice: nation.rice,
tech: Math.floor(nation.tech),
rate,
bill,
capitalCityId: nation.capitalCityId ?? 0,
generalCount: generals.length,
},
population: { current: population, max: populationMax },
crew: { current: crew, max: crewMax },
income: {
goldCity,
goldWar,
goldTotal: goldCity + goldWar,
riceCity,
riceWall,
riceTotal: riceCity + riceWall,
outcome,
},
budget: {
gold: nation.gold + goldCity + goldWar - outcome,
rice: nation.rice + riceCity + riceWall - outcome,
},
cities: cities.map((city) => ({
id: city.id,
name: city.name,
capital: city.id === nation.capitalCityId,
})),
history,
};
});
+2 -1
View File
@@ -6,6 +6,7 @@ import { getChiefCenter } from './endpoints/getChiefCenter.js';
import { getCityOverview } from './endpoints/getCityOverview.js';
import { getGeneralList } from './endpoints/getGeneralList.js';
import { getGeneralLog } from './endpoints/getGeneralLog.js';
import { getNationInfo } from './endpoints/getNationInfo.js';
import { getPersonnelInfo } from './endpoints/getPersonnelInfo.js';
import { getStratFinan } from './endpoints/getStratFinan.js';
import { kick } from './endpoints/kick.js';
@@ -18,6 +19,7 @@ import { setScoutMsg } from './endpoints/setScoutMsg.js';
import { setSecretLimit } from './endpoints/setSecretLimit.js';
export const nationRouter = router({
getNationInfo,
getGeneralList,
getCityOverview,
getPersonnelInfo,
@@ -36,4 +38,3 @@ export const nationRouter = router({
kick,
appoint,
});
+37 -18
View File
@@ -5,6 +5,7 @@ import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import {
ITEM_KEYS,
addOccupiedUniqueItemKeys,
buildVoteUniqueSeed,
countOccupiedUniqueItems,
createItemModuleRegistry,
@@ -22,7 +23,12 @@ const hasAdminRole = (roles: string[], profileName: string): boolean => {
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
return true;
}
return roles.some((role) => role === 'admin.survey' || role === `admin.survey:${profileName}`);
return roles.some(
(role) =>
role === 'admin.survey.open' ||
role === 'admin.survey.open:*' ||
role === `admin.survey.open:${profileName}`
);
};
const adminProcedure = authedProcedure.use(({ ctx, next }) => {
@@ -66,9 +72,7 @@ const normalizeCode = (value: string | null | undefined): string | null => {
};
const normalizeOptions = (options: string[]): string[] =>
options
.map((option) => option.trim())
.filter((option) => option.length > 0);
options.map((option) => option.trim()).filter((option) => option.length > 0);
const readMetaNumber = (meta: Record<string, unknown>, key: string, fallback: number): number => {
const value = meta[key];
@@ -225,9 +229,7 @@ export const voteRouter = router({
const pollEnded = Boolean(row.closed_at) || (row.end_at ? row.end_at <= new Date() : false);
const userId = ctx.auth?.user.id;
const general = userId
? await ctx.db.general.findFirst({ where: { userId }, select: { id: true } })
: null;
const general = userId ? await ctx.db.general.findFirst({ where: { userId }, select: { id: true } }) : null;
const [comments, userCnt, myVoteRow] = await Promise.all([
ctx.db.$queryRaw<VoteCommentRow[]>(GamePrisma.sql`
@@ -257,9 +259,8 @@ export const voteRouter = router({
const myVote = myVoteRow[0]?.selection ? parseSelection(myVoteRow[0].selection) : null;
const canReveal = row.reveal_mode === 'after_vote'
? Boolean(myVote) || pollEnded
: pollEnded;
// 레거시는 투표 전에도 현재 집계를 보여준다. after_end만 명시적으로 숨긴다.
const canReveal = row.reveal_mode === 'after_end' ? pollEnded : true;
const voteResults = canReveal
? await ctx.db.$queryRaw<VoteResultRow[]>(GamePrisma.sql`
@@ -355,6 +356,9 @@ export const voteRouter = router({
}
const sortedSelection = [...selection].sort((a, b) => a - b);
if (new Set(sortedSelection).size !== sortedSelection.length) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '선택한 항목이 올바르지 않습니다.' });
}
const general = await getMyGeneral(ctx);
const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
@@ -394,14 +398,24 @@ export const voteRouter = router({
const itemRegistry = await getItemRegistry();
const uniqueConfig = resolveUniqueConfig(constValues);
const generalRows = await ctx.db.general.findMany({
select: {
horseCode: true,
weaponCode: true,
bookCode: true,
itemCode: true,
},
});
const [generalRows, reservedUniqueRows] = await Promise.all([
ctx.db.general.findMany({
select: {
horseCode: true,
weaponCode: true,
bookCode: true,
itemCode: true,
},
}),
ctx.db.auction.findMany({
where: {
type: 'UNIQUE_ITEM',
status: { in: ['OPEN', 'FINALIZING'] },
targetCode: { not: null },
},
select: { targetCode: true },
}),
]);
const generalItems: GeneralItemSlots[] = generalRows.map((row) => ({
horse: normalizeCode(row.horseCode),
weapon: normalizeCode(row.weaponCode),
@@ -410,6 +424,11 @@ export const voteRouter = router({
}));
const occupiedUniqueCounts = countOccupiedUniqueItems(generalItems, itemRegistry);
addOccupiedUniqueItemKeys(
occupiedUniqueCounts,
reservedUniqueRows.map((row) => row.targetCode),
itemRegistry
);
const userCount = await ctx.db.general.count({ where: { npcState: { lt: 2 } } });
const rngSeed = buildVoteUniqueSeed(
+205 -6
View File
@@ -1,15 +1,37 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import {
type WorldStateRow,
zWorldStateConfig,
zWorldStateMeta,
} from '../../context.js';
import { type WorldStateRow, zWorldStateConfig, zWorldStateMeta } from '../../context.js';
import { procedure, router } from '../../trpc.js';
import { authedProcedure } from '../../trpc.js';
import { asRecord, isRecord } from '@sammo-ts/common';
import { loadWorldMap } from '../../maps/worldMap.js';
import { loadMapLayout } from '../../maps/mapLayout.js';
import { getOwnedGeneral } from '../shared/general.js';
import { getMyGeneral, getOwnedGeneral } from '../shared/general.js';
const isWorldAdmin = (roles: readonly string[]): boolean =>
roles.some((role) => role === 'superuser' || role === 'admin' || role === 'admin.superuser');
const numberRecord = (value: unknown): Record<number, number> => {
if (!isRecord(value)) return {};
return Object.fromEntries(
Object.entries(value)
.map(([key, item]) => [Number(key), typeof item === 'number' ? item : Number.NaN] as const)
.filter(([key, item]) => Number.isFinite(key) && Number.isFinite(item))
);
};
const officerCity = (meta: unknown): number => {
const value = asRecord(meta);
const raw = value.officerCity ?? value.officer_city;
return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0;
};
const defenceTrain = (meta: unknown): number => {
const value = asRecord(meta);
const raw = value.defenceTrain ?? value.defence_train;
return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0;
};
const toWorldStateSnapshot = (row: WorldStateRow) => ({
scenarioCode: row.scenarioCode,
@@ -22,6 +44,183 @@ const toWorldStateSnapshot = (row: WorldStateRow) => ({
});
export const worldRouter = router({
getGlobalInfo: authedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
const [nations, cities, diplomacy, map] = await Promise.all([
ctx.db.nation.findMany({ where: { level: { gt: 0 } } }),
ctx.db.city.findMany({ orderBy: { id: 'asc' } }),
ctx.db.diplomacy.findMany({ where: { isDead: false, isShowing: true } }),
loadWorldMap(ctx, { generalId: me.id, neutralView: false, showMe: true }),
]);
if (!map) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' });
}
const nationRows = nations
.map((nation) => ({
id: nation.id,
name: nation.name,
color: nation.color,
capitalCityId: nation.capitalCityId ?? 0,
level: nation.level,
power: typeof asRecord(nation.meta).power === 'number' ? Number(asRecord(nation.meta).power) : 0,
cities: cities.filter((city) => city.nationId === nation.id).map((city) => city.name),
}))
.sort((left, right) => right.power - left.power || left.id - right.id);
const matrix: Record<number, Record<number, number>> = {};
for (const nation of nationRows) {
matrix[nation.id] = {};
for (const other of nationRows) matrix[nation.id]![other.id] = 2;
}
for (const relation of diplomacy) {
if (!matrix[relation.srcNationId]) continue;
const related = relation.srcNationId === me.nationId || relation.destNationId === me.nationId;
matrix[relation.srcNationId]![relation.destNationId] = related
? relation.stateCode
: [3, 4, 5, 6, 7].includes(relation.stateCode)
? 2
: relation.stateCode;
}
const conflict = cities.flatMap((city) => {
const raw = numberRecord(city.conflict);
const entries = Object.entries(raw);
if (entries.length < 2) return [];
const sum = entries.reduce((total, [, value]) => total + value, 0);
if (sum <= 0) return [];
return [
{
cityId: city.id,
cityName: city.name,
nations: Object.fromEntries(
entries.map(([id, value]) => [id, Math.round((value * 1000) / sum) / 10])
),
},
];
});
return { myNationId: me.nationId, nations: nationRows, diplomacy: matrix, conflict, map };
}),
getCurrentCity: authedProcedure
.input(z.object({ cityId: z.number().int().positive().optional() }).optional())
.query(async ({ ctx, input }) => {
const me = await getMyGeneral(ctx);
const admin = isWorldAdmin(ctx.auth?.user.roles ?? []);
const [cities, nation, nationGenerals, nations, world, layout] = await Promise.all([
ctx.db.city.findMany({ orderBy: { id: 'asc' } }),
me.nationId > 0 ? ctx.db.nation.findUnique({ where: { id: me.nationId } }) : null,
me.nationId > 0
? ctx.db.general.findMany({ where: { nationId: me.nationId }, select: { cityId: true } })
: [],
ctx.db.nation.findMany(),
ctx.db.worldState.findFirst(),
loadMapLayout(ctx.profile.scenario),
]);
const cityById = new Map(cities.map((city) => [city.id, city]));
const requested = input?.cityId && cityById.has(input.cityId) ? input.cityId : me.cityId;
const selected = cityById.get(requested);
if (!selected) throw new TRPCError({ code: 'NOT_FOUND', message: 'City not found' });
const spy = numberRecord(asRecord(nation?.meta).spyList ?? asRecord(nation?.meta).spy);
const selectable = new Set<number>([me.cityId]);
if (me.officerLevel > 0 && me.nationId > 0) {
cities.filter((city) => city.nationId === me.nationId).forEach((city) => selectable.add(city.id));
nationGenerals.forEach((general) => selectable.add(general.cityId));
Object.keys(spy).forEach((id) => selectable.add(Number(id)));
}
if (admin) cities.forEach((city) => selectable.add(city.id));
const full = admin || selectable.has(selected.id);
const ownCities = new Set(
cities.filter((city) => city.nationId === me.nationId && me.nationId > 0).map((city) => city.id)
);
const layoutCity = layout.cityList.find((city) => city.id === selected.id);
const detailed = full || Boolean(layoutCity?.path.some((id) => ownCities.has(id)));
const generals = detailed
? await ctx.db.general.findMany({ where: { cityId: selected.id }, orderBy: { turnTime: 'asc' } })
: [];
const generalIds = generals
.filter((general) => general.nationId === me.nationId && general.npcState <= 1)
.map((general) => general.id);
const turns = generalIds.length
? await ctx.db.generalTurn.findMany({
where: { generalId: { in: generalIds }, turnIdx: { lt: 5 } },
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
})
: [];
const turnMap = new Map<number, string[]>();
for (const turn of turns) {
const list = turnMap.get(turn.generalId) ?? [];
list[turn.turnIdx] = turn.actionCode;
turnMap.set(turn.generalId, list);
}
const nationMap = new Map(nations.map((item) => [item.id, item]));
const officers = await ctx.db.general.findMany({
where: { officerLevel: { in: [2, 3, 4] } },
select: { name: true, officerLevel: true, meta: true },
});
const selectedOfficers = Object.fromEntries(
officers
.filter((item) => officerCity(item.meta) === selected.id)
.map((item) => [item.officerLevel, item.name])
);
const redact = <T>(value: T): T | null => (full ? value : null);
const mappedGenerals = generals.map((general) => {
const ours = admin || (me.nationId > 0 && general.nationId === me.nationId);
return {
id: general.id,
name: general.name,
npcState: general.npcState,
picture: general.picture,
imageServer: general.imageServer,
nationId: general.nationId,
nationName: nationMap.get(general.nationId)?.name ?? '재야',
leadership: general.leadership,
strength: general.strength,
intelligence: general.intel,
injury: general.injury,
officerLevel: general.officerLevel,
defenceTrain: ours ? defenceTrain(general.meta) : null,
crewTypeId: ours ? general.crewTypeId : null,
crew: ours || full ? general.crew : null,
train: ours ? general.train : null,
atmos: ours ? general.atmos : null,
turns: ours && general.npcState <= 1 ? (turnMap.get(general.id) ?? []) : [],
};
});
return {
me: { id: me.id, nationId: me.nationId, officerLevel: me.officerLevel, admin },
options: [...selectable]
.map((id) => cityById.get(id))
.filter((city): city is NonNullable<typeof city> => Boolean(city))
.map((city) => ({ id: city.id, name: city.name, nationId: city.nationId })),
visibility: { full, detailed },
city: {
id: selected.id,
name: selected.name,
nationId: selected.nationId,
level: selected.level,
region: selected.region,
population: redact(selected.population),
populationMax: selected.populationMax,
agriculture: redact(selected.agriculture),
agricultureMax: selected.agricultureMax,
commerce: redact(selected.commerce),
commerceMax: selected.commerceMax,
security: redact(selected.security),
securityMax: selected.securityMax,
trust: redact(selected.trust),
trade: selected.trade,
defence: full || selected.nationId === 0 ? selected.defence : null,
defenceMax: selected.defenceMax,
wall: full || selected.nationId === 0 ? selected.wall : null,
wallMax: selected.wallMax,
officers: {
4: selectedOfficers[4] ?? '-',
3: selectedOfficers[3] ?? '-',
2: selectedOfficers[2] ?? '-',
},
},
generals: mappedGenerals,
lastExecute:
typeof asRecord(world?.meta).turntime === 'string' ? String(asRecord(world?.meta).turntime) : '',
};
}),
getState: procedure.query(async ({ ctx }) => {
const state = await ctx.db.worldState.findFirst();
return state ? toWorldStateSnapshot(state) : null;
+301
View File
@@ -0,0 +1,301 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
import { appRouter } from '../src/router.js';
const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
id: 1,
userId: 'user-1',
name: '테스트장수',
nationId: 1,
cityId: 1,
troopId: 0,
npcState: 0,
affinity: null,
bornYear: 180,
deadYear: 300,
picture: '22.jpg',
imageServer: 0,
leadership: 50,
strength: 50,
intel: 50,
injury: 0,
experience: 0,
dedication: 0,
officerLevel: 1,
gold: 1000,
rice: 1000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
weaponCode: 'None',
bookCode: 'None',
horseCode: 'None',
itemCode: 'None',
turnTime: new Date('2026-01-01T00:00:00Z'),
recentWarTime: null,
age: 20,
startAge: 20,
personalCode: 'None',
specialCode: 'None',
special2Code: 'None',
lastTurn: {},
meta: {},
penalty: {},
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
...overrides,
});
const auth: GameSessionTokenPayload = {
version: 1,
profile: 'che:default',
issuedAt: '2026-01-01T00:00:00.000Z',
expiresAt: '2026-01-02T00:00:00.000Z',
sessionId: 'session-1',
user: {
id: 'user-1',
username: 'tester',
displayName: 'Tester',
roles: [],
},
sanctions: {},
};
const buildContext = (options: {
me?: GeneralRow;
nationMeta?: Record<string, unknown>;
auth?: GameSessionTokenPayload | null;
posts?: Array<{
id: number;
nationId: number;
isSecret: boolean;
authorGeneralId: number;
authorName: string;
title: string;
contentHtml: string;
createdAt: Date;
updatedAt: Date;
comments: Array<{
id: number;
postId: number;
nationId: number;
isSecret: boolean;
authorGeneralId: number;
authorName: string;
contentText: string;
createdAt: Date;
}>;
}>;
targetPost?: { id: number; isSecret: boolean } | null;
}) => {
const me = options.me ?? buildGeneral();
const boardPostFindMany = vi.fn(async () => options.posts ?? []);
const boardPostFindFirst = vi.fn(async () => options.targetPost ?? null);
const boardPostCreate = vi.fn(async () => ({ id: 31 }));
const boardCommentCreate = vi.fn(async () => ({ id: 41 }));
const db = {
general: {
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
me.userId === where.userId ? me : null
),
findMany: vi.fn(async () => [{ id: me.id, picture: me.picture, imageServer: me.imageServer }]),
},
nation: {
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
where.id === me.nationId ? { meta: options.nationMeta ?? {} } : null
),
},
boardPost: {
findMany: boardPostFindMany,
findFirst: boardPostFindFirst,
create: boardPostCreate,
},
boardComment: {
create: boardCommentCreate,
},
};
const accessTokenStore = new RedisAccessTokenStore(
{
get: async () => null,
set: async () => null,
},
'che:default'
);
const context: GameApiContext = {
db: db as unknown as DatabaseClient,
redis: {} as RedisConnector['client'],
turnDaemon: {} as GameApiContext['turnDaemon'],
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth: options.auth === undefined ? auth : options.auth,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore,
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
return {
context,
boardPostFindMany,
boardPostFindFirst,
boardPostCreate,
boardCommentCreate,
};
};
describe('board router actor, nation, and secret permissions', () => {
it.each([
{
label: 'ordinary member',
me: buildGeneral({ officerLevel: 1 }),
nationMeta: {},
expected: { permission: 0, canMeeting: true, canSecret: false },
},
{
label: 'chief',
me: buildGeneral({ officerLevel: 5 }),
nationMeta: {},
expected: { permission: 2, canMeeting: true, canSecret: true },
},
{
label: 'low-rank ambassador',
me: buildGeneral({ officerLevel: 1, meta: { permission: 'ambassador' } }),
nationMeta: {},
expected: { permission: 4, canMeeting: true, canSecret: true },
},
{
label: 'auditor',
me: buildGeneral({ officerLevel: 1, meta: { permission: 'auditor' } }),
nationMeta: {},
expected: { permission: 3, canMeeting: true, canSecret: true },
},
{
label: 'penalized chief',
me: buildGeneral({ officerLevel: 5, penalty: { noChief: true } }),
nationMeta: {},
expected: { permission: 0, canMeeting: true, canSecret: false },
},
{
label: 'unaffiliated general',
me: buildGeneral({ nationId: 0, officerLevel: 0 }),
nationMeta: {},
expected: { permission: -1, canMeeting: false, canSecret: false },
},
])('reports legacy-compatible access for $label', async ({ me, nationMeta, expected }) => {
const fixture = buildContext({ me, nationMeta });
await expect(appRouter.createCaller(fixture.context).board.getAccess()).resolves.toEqual(expected);
});
it('derives the article actor from the authenticated user and scopes the list to their nation', async () => {
const createdAt = new Date('2026-07-26T10:20:00Z');
const fixture = buildContext({
me: buildGeneral({ id: 7, userId: 'user-1', nationId: 3, officerLevel: 5 }),
posts: [
{
id: 11,
nationId: 3,
isSecret: true,
authorGeneralId: 7,
authorName: '작성자',
title: '작전',
contentHtml: '내용',
createdAt,
updatedAt: createdAt,
comments: [],
},
],
});
await expect(
appRouter.createCaller(fixture.context).board.getArticles({ isSecret: true })
).resolves.toMatchObject([
{
id: 11,
title: '작전',
content: '내용',
authorName: '작성자',
authorPicture: '22.jpg',
authorImageServer: 0,
},
]);
expect(fixture.boardPostFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { nationId: 3, isSecret: true },
})
);
});
it('uses the session actor for writes and keeps empty-input wording compatible', async () => {
const fixture = buildContext({
me: buildGeneral({ id: 7, userId: 'user-1', nationId: 3, officerLevel: 1 }),
});
const caller = appRouter.createCaller(fixture.context);
await expect(caller.board.writeArticle({ isSecret: false, title: '', content: '' })).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: '제목과 내용이 둘다 비어있습니다.',
});
await expect(caller.board.writeArticle({ isSecret: false, title: '알림', content: '내용' })).resolves.toEqual({
id: 31,
});
expect(fixture.boardPostCreate).toHaveBeenCalledWith({
data: {
nationId: 3,
isSecret: false,
authorGeneralId: 7,
authorName: '테스트장수',
title: '알림',
contentHtml: '내용',
},
select: { id: true },
});
});
it('does not reveal whether another nation owns a requested comment target', async () => {
const fixture = buildContext({
me: buildGeneral({ nationId: 3, officerLevel: 5 }),
targetPost: null,
});
await expect(
appRouter.createCaller(fixture.context).board.writeComment({ postId: 99, content: '답변' })
).rejects.toMatchObject({
code: 'NOT_FOUND',
message: '게시물이 없습니다.',
});
expect(fixture.boardPostFindFirst).toHaveBeenCalledWith({
where: { id: 99, nationId: 3 },
select: { id: true, isSecret: true },
});
expect(fixture.boardCommentCreate).not.toHaveBeenCalled();
});
it('checks secret permission again when adding a comment', async () => {
const fixture = buildContext({
me: buildGeneral({ officerLevel: 2 }),
targetPost: { id: 5, isSecret: true },
});
await expect(
appRouter.createCaller(fixture.context).board.writeComment({ postId: 5, content: '답변' })
).rejects.toMatchObject({
code: 'FORBIDDEN',
message: '권한이 부족합니다. 수뇌부가 아닙니다.',
});
expect(fixture.boardCommentCreate).not.toHaveBeenCalled();
});
it('rejects unauthenticated board access', async () => {
const fixture = buildContext({ auth: null });
await expect(appRouter.createCaller(fixture.context).board.getAccess()).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
});
});
+185
View File
@@ -0,0 +1,185 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
import { appRouter } from '../src/router.js';
const now = new Date('2026-01-01T00:00:00Z');
const general = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
id: 1,
userId: 'user-1',
name: '아군',
nationId: 1,
cityId: 1,
troopId: 0,
npcState: 0,
affinity: null,
bornYear: 180,
deadYear: 300,
picture: null,
imageServer: 0,
leadership: 70,
strength: 60,
intel: 50,
injury: 0,
experience: 0,
dedication: 0,
officerLevel: 1,
gold: 1000,
rice: 1000,
crew: 500,
crewTypeId: 1,
train: 90,
atmos: 90,
weaponCode: 'None',
bookCode: 'None',
horseCode: 'None',
itemCode: 'None',
turnTime: now,
recentWarTime: null,
age: 20,
startAge: 20,
personalCode: 'None',
specialCode: 'None',
special2Code: 'None',
lastTurn: {},
meta: { defence_train: 80 },
penalty: {},
createdAt: now,
updatedAt: now,
...overrides,
});
const auth = (roles: string[] = []): GameSessionTokenPayload => ({
version: 1,
profile: 'che:default',
issuedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + 86400000).toISOString(),
sessionId: 'session',
user: { id: 'user-1', username: 'tester', displayName: 'Tester', roles },
sanctions: {},
});
const city = (id: number, nationId: number) => ({
id,
name: `도시${id}`,
level: 6,
nationId,
supplyState: 1,
frontState: 0,
population: 1000,
populationMax: 2000,
agriculture: 10,
agricultureMax: 20,
commerce: 11,
commerceMax: 21,
security: 12,
securityMax: 22,
trust: 50,
trade: 100,
defence: 13,
defenceMax: 23,
wall: 14,
wallMax: 24,
region: 2,
conflict: {},
meta: {},
});
const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Record<string, unknown> } = {}) => {
const me = options.me ?? general();
const cities = [city(1, 1), city(2, 2), city(3, 2), city(80, 1)];
const foreign = general({ id: 2, userId: 'user-2', name: '적군', nationId: 2, cityId: 2, crew: 777 });
const db = {
general: {
findFirst: vi.fn(async () => me),
findMany: vi.fn(async (args: { where?: Record<string, unknown>; select?: Record<string, boolean> }) => {
if (args.where?.nationId === 1 && args.select?.cityId) return [{ cityId: me.cityId }];
if (args.where?.cityId === 2) return [foreign];
if (args.where?.cityId === 3) return [foreign];
if (args.where?.officerLevel) return [];
return [];
}),
},
nation: {
findUnique: vi.fn(async () => ({
id: 1,
name: '아국',
color: '#008000',
level: 1,
capitalCityId: 1,
meta: options.nationMeta ?? {},
})),
findMany: vi.fn(async () => [
{ id: 1, name: '아국', color: '#008000', level: 1, capitalCityId: 1, meta: { power: 100 } },
{ id: 2, name: '적국', color: '#800000', level: 1, capitalCityId: 2, meta: { power: 90 } },
]),
},
city: { findMany: vi.fn(async () => cities) },
worldState: { findFirst: vi.fn(async () => ({ meta: { turntime: '2026-01-01' } })) },
generalTurn: { findMany: vi.fn(async () => []) },
diplomacy: { findMany: vi.fn(async () => []) },
};
const redis = { get: vi.fn(async () => null), set: vi.fn(async () => null) } as unknown as RedisConnector['client'];
const accessTokenStore = new RedisAccessTokenStore(redis, 'che:default');
return {
db: db as unknown as DatabaseClient,
redis,
turnDaemon: {} as GameApiContext['turnDaemon'],
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth: auth(options.roles),
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore,
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'secret',
} satisfies GameApiContext;
};
describe('in-game information permissions', () => {
it('does not expose nation-only pages to a wandering general', async () => {
const caller = appRouter.createCaller(context({ me: general({ nationId: 0, officerLevel: 0 }) }));
await expect(caller.nation.getNationInfo()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
await expect(caller.nation.getCityOverview()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
});
it('lets a wandering general select only the current city', async () => {
const caller = appRouter.createCaller(context({ me: general({ nationId: 0, officerLevel: 0 }) }));
const result = await caller.world.getCurrentCity({ cityId: 2 });
expect(result.city.id).toBe(2);
expect(result.options.map((entry) => entry.id)).toEqual([1]);
expect(result.visibility.full).toBe(false);
expect(result.city.population).toBeNull();
expect(result.generals).toEqual([]);
});
it('keeps adjacent foreign detail redacted and never reveals military fields', async () => {
const result = await appRouter
.createCaller(context({ me: general({ cityId: 80 }) }))
.world.getCurrentCity({ cityId: 2 });
expect(result.visibility).toEqual({ full: false, detailed: true });
expect(result.city.agriculture).toBeNull();
expect(result.city.defence).toBeNull();
expect(result.generals[0]).toMatchObject({ crew: null, train: null, atmos: null, crewTypeId: null });
});
it('allows a spied city in full but still redacts foreign-general private details', async () => {
const result = await appRouter
.createCaller(context({ nationMeta: { spy: { 2: 2 } } }))
.world.getCurrentCity({ cityId: 2 });
expect(result.visibility.full).toBe(true);
expect(result.city.population).toBe(1000);
expect(result.generals[0]).toMatchObject({ crew: 777, train: null, atmos: null, crewTypeId: null });
});
it('allows administrative roles to inspect all city and general fields', async () => {
const result = await appRouter.createCaller(context({ roles: ['admin'] })).world.getCurrentCity({ cityId: 3 });
expect(result.options).toHaveLength(4);
expect(result.visibility.full).toBe(true);
expect(result.generals[0]).toMatchObject({ crew: 777, train: 90, atmos: 90, crewTypeId: 1 });
});
});
+292
View File
@@ -0,0 +1,292 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { GamePrisma, RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
import { appRouter } from '../src/router.js';
const poll = {
id: 1,
title: '선호하는 병종',
body: '',
options: ['보병', '기병'],
multiple_options: 1,
reveal_mode: 'after_vote',
opener_general_id: 1,
opener_name: '관리자',
start_at: new Date('2026-07-26T00:00:00Z'),
end_at: null,
closed_at: null,
};
const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
id: 7,
userId: 'user-1',
name: '유비',
nationId: 1,
cityId: 1,
troopId: 0,
npcState: 0,
affinity: null,
bornYear: 180,
deadYear: 300,
picture: null,
imageServer: 0,
leadership: 50,
strength: 50,
intel: 50,
injury: 0,
experience: 0,
dedication: 0,
officerLevel: 1,
gold: 1000,
rice: 1000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
weaponCode: 'None',
bookCode: 'None',
horseCode: 'None',
itemCode: 'None',
turnTime: new Date('2026-07-26T00:00:00Z'),
recentWarTime: null,
age: 20,
startAge: 20,
personalCode: 'None',
specialCode: 'None',
special2Code: 'None',
lastTurn: {},
meta: {},
penalty: {},
createdAt: new Date('2026-07-26T00:00:00Z'),
updatedAt: new Date('2026-07-26T00:00:00Z'),
...overrides,
});
const buildAuth = (roles: string[] = [], userId = 'user-1'): GameSessionTokenPayload => ({
version: 1,
profile: 'che:default',
issuedAt: '2026-07-26T00:00:00.000Z',
expiresAt: '2026-07-27T00:00:00.000Z',
sessionId: `session-${userId}`,
user: {
id: userId,
username: userId,
displayName: userId,
roles,
},
sanctions: {},
});
const sqlText = (query: GamePrisma.Sql): string => query.strings.join(' ');
const buildContext = (options: {
auth?: GameSessionTokenPayload | null;
general?: GeneralRow | null;
myVote?: number[] | null;
voteRows?: Array<{ selection: number[]; cnt: number }>;
pollRow?: typeof poll;
configConst?: Record<string, unknown>;
auctionTargets?: string[];
}) => {
const auth = options.auth === undefined ? buildAuth() : options.auth;
const general = options.general === undefined ? buildGeneral() : options.general;
const requestCommand = vi.fn(async () => ({
type: 'voteReward' as const,
ok: true as const,
voteId: 1,
generalId: general?.id ?? 0,
awardedUnique: false,
}));
const queryRaw = vi.fn(async (query: GamePrisma.Sql) => {
const text = sqlText(query);
if (text.includes('FROM vote_poll') && text.includes('LIMIT 1')) {
return [options.pollRow ?? poll];
}
if (text.includes('INSERT INTO vote (')) {
return [{ id: 11 }];
}
if (text.includes('FROM vote_comment')) {
return [];
}
if (text.includes('SELECT selection') && text.includes('general_id')) {
return options.myVote ? [{ selection: options.myVote }] : [];
}
if (text.includes('GROUP BY selection')) {
return options.voteRows ?? [{ selection: [0], cnt: 2 }];
}
if (text.includes('INSERT INTO vote_comment')) {
return [];
}
return [];
});
const db = {
$queryRaw: queryRaw,
worldState: {
findFirst: vi.fn(async () => ({
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 3600,
config: { const: { develCost: 18, allItems: {}, ...(options.configConst ?? {}) } },
meta: {
hiddenSeed: 'seed',
scenarioId: 200,
initYear: 180,
initMonth: 1,
scenarioMeta: { startYear: 180 },
},
updatedAt: new Date(),
})),
},
general: {
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
general?.userId === where.userId ? general : null
),
findMany: vi.fn(async () => [
{
horseCode: general?.horseCode ?? 'None',
weaponCode: general?.weaponCode ?? 'None',
bookCode: general?.bookCode ?? 'None',
itemCode: general?.itemCode ?? 'None',
},
]),
count: vi.fn(async () => 2),
},
nation: {
findFirst: vi.fn(async () => ({ name: '촉' })),
},
auction: {
findMany: vi.fn(async () => (options.auctionTargets ?? []).map((targetCode) => ({ targetCode }))),
},
};
const accessTokenStore = new RedisAccessTokenStore(
{
get: async () => null,
set: async () => null,
},
'che:default'
);
const context: GameApiContext = {
db: db as unknown as DatabaseClient,
redis: {} as RedisConnector['client'],
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore,
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
return { context, requestCommand, queryRaw, db };
};
describe('vote router actor and permission boundaries', () => {
it('rejects unauthenticated survey access', async () => {
const fixture = buildContext({ auth: null });
await expect(appRouter.createCaller(fixture.context).vote.getVoteList()).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
});
it('uses only the general owned by the authenticated user for voting and reward dispatch', async () => {
const owned = buildGeneral({ id: 7, userId: 'user-1', name: '유비' });
const fixture = buildContext({ general: owned });
await expect(
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] })
).resolves.toEqual({ ok: true, wonLottery: false });
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
type: 'voteReward',
voteId: 1,
generalId: 7,
goldReward: 90,
})
);
});
it('includes active unique auctions in the API-side reward expectation', async () => {
const fixture = buildContext({
configConst: {
allItems: { weapon: { che_무기_12_칠성검: 1 } },
maxUniqueItemLimit: [[-1, 1]],
uniqueTrialCoef: 10,
maxUniqueTrialProb: 10,
minMonthToAllowInheritItem: 0,
},
auctionTargets: ['che_무기_12_칠성검'],
});
await appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] });
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
unique: { expected: false, itemKey: null },
})
);
});
it('rejects voting and comments when the authenticated user owns no general', async () => {
const fixture = buildContext({ auth: buildAuth([], 'user-2'), general: buildGeneral({ userId: 'user-1' }) });
const caller = appRouter.createCaller(fixture.context);
await expect(caller.vote.submitVote({ voteId: 1, selection: [0] })).rejects.toMatchObject({
code: 'NOT_FOUND',
message: 'General not found',
});
await expect(caller.vote.addComment({ voteId: 1, text: '댓글' })).rejects.toMatchObject({
code: 'NOT_FOUND',
message: 'General not found',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
});
it('rejects duplicate selections before persisting a vote', async () => {
const fixture = buildContext({ pollRow: { ...poll, multiple_options: 2 } });
await expect(
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0, 0] })
).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: '선택한 항목이 올바르지 않습니다.',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
});
it('shows legacy-compatible aggregate results before the current general votes', async () => {
const fixture = buildContext({ myVote: null, voteRows: [{ selection: [0], cnt: 2 }] });
const result = await appRouter.createCaller(fixture.context).vote.getVoteDetail({ voteId: 1 });
expect(result.myVote).toBeNull();
expect(result.votes).toEqual([{ selection: [0], count: 2 }]);
});
it.each([
['global survey permission', ['admin.survey.open'], true],
['wildcard survey permission', ['admin.survey.open:*'], true],
['matching profile permission', ['admin.survey.open:che:default'], true],
['different profile permission', ['admin.survey.open:hwe:default'], false],
['ordinary user', ['user'], false],
])('%s controls the administrator panel', async (_label, roles, allowed) => {
const fixture = buildContext({ auth: buildAuth(roles) });
const request = appRouter.createCaller(fixture.context).vote.getAdminStatus();
if (allowed) {
await expect(request).resolves.toEqual({ ok: true });
} else {
await expect(request).rejects.toMatchObject({ code: 'FORBIDDEN' });
}
});
});
@@ -24,6 +24,7 @@ import {
evaluateConstraints,
resolveGeneralAction,
ITEM_KEYS,
addOccupiedUniqueItemKeys,
buildGenericUniqueSeed,
countOccupiedUniqueItems,
createItemModuleRegistry,
@@ -382,6 +383,7 @@ const buildUniqueLotteryRunner = (options: {
seedBase: string;
itemRegistry: Map<string, ItemModule>;
uniqueConfig: ReturnType<typeof resolveUniqueConfig>;
getAdditionalOccupiedUniqueItemKeys?: () => Iterable<string | null | undefined>;
}): UniqueLotteryRunner => {
if (!options.worldView) {
return () => null;
@@ -408,6 +410,11 @@ const buildUniqueLotteryRunner = (options: {
entry.id === general.id ? general.role.items : entry.role.items
);
const occupiedUniqueCounts = countOccupiedUniqueItems(generalItemsList, options.itemRegistry);
addOccupiedUniqueItemKeys(
occupiedUniqueCounts,
options.getAdditionalOccupiedUniqueItemKeys?.() ?? [],
options.itemRegistry
);
const rngSeed = buildGenericUniqueSeed(
options.seedBase,
world.currentYear,
@@ -723,6 +730,7 @@ export const createReservedTurnHandler = async (options: {
commandProfile?: TurnCommandProfile;
commandEnv?: TurnCommandEnv;
commandRngFactory?: (input: { kind: 'nation' | 'general'; actionKey: string; seed: string }) => RandUtil;
getAdditionalOccupiedUniqueItemKeys?: () => Iterable<string | null | undefined>;
onActionResolved?: (payload: {
kind: 'nation' | 'general';
generalId: number;
@@ -944,6 +952,7 @@ export const createReservedTurnHandler = async (options: {
seedBase,
itemRegistry,
uniqueConfig,
getAdditionalOccupiedUniqueItemKeys: options.getAdditionalOccupiedUniqueItemKeys,
});
let baseContext: ActionContextBase = {
general: currentGeneral,
+15
View File
@@ -473,6 +473,8 @@ const createTurnDaemonRuntimeWithLease = async (
neutralAuctionRegistrar.handler,
frontStateHandler
);
let occupiedAuctionUniqueItemKeys: string[] = [];
let refreshOccupiedAuctionUniqueItemKeys = async (): Promise<void> => {};
const worldOptions: InMemoryTurnWorldOptions = {
schedule,
generalTurnHandler:
@@ -486,6 +488,7 @@ const createTurnDaemonRuntimeWithLease = async (
getWorld: () => worldRef,
commandProfile,
commandEnv: monthlyCommandEnv,
getAdditionalOccupiedUniqueItemKeys: () => occupiedAuctionUniqueItemKeys,
})),
calendarHandler: calendarHandler ?? undefined,
autoAdvanceDiplomacyMonth: false,
@@ -501,6 +504,7 @@ const createTurnDaemonRuntimeWithLease = async (
? async (general) => {
const promises: Promise<unknown>[] = [];
promises.push(reservedTurnStoreHandle.store.refreshGeneralTurns(general.id));
promises.push(refreshOccupiedAuctionUniqueItemKeys());
if (general.nationId > 0 && general.officerLevel >= 5) {
promises.push(
reservedTurnStoreHandle.store.refreshNationTurns(general.nationId, general.officerLevel)
@@ -648,6 +652,17 @@ const createTurnDaemonRuntimeWithLease = async (
if (commandConnector && databaseCommandQueue) {
await commandConnector.connect();
await databaseCommandQueue.initialize();
refreshOccupiedAuctionUniqueItemKeys = async () => {
const rows = await commandConnector.prisma.auction.findMany({
where: {
type: 'UNIQUE_ITEM',
status: { in: ['OPEN', 'FINALIZING'] },
targetCode: { not: null },
},
select: { targetCode: true },
});
occupiedAuctionUniqueItemKeys = rows.flatMap((row) => (row.targetCode ? [row.targetCode] : []));
};
}
const baseClose = close;
@@ -11,6 +11,7 @@ import {
LogFormat,
LogScope,
ITEM_KEYS,
addOccupiedUniqueItemKeys,
buildVoteUniqueSeed,
countOccupiedUniqueItems,
createItemModuleRegistry,
@@ -1163,6 +1164,21 @@ async function handleVoteReward(
generals.map((entry) => entry.role.items),
itemRegistry
);
if (ctx.commandDb) {
const reservedUniqueRows = await ctx.commandDb.auction.findMany({
where: {
type: 'UNIQUE_ITEM',
status: { in: ['OPEN', 'FINALIZING'] },
targetCode: { not: null },
},
select: { targetCode: true },
});
addOccupiedUniqueItemKeys(
occupiedUniqueCounts,
reservedUniqueRows.map((row) => row.targetCode),
itemRegistry
);
}
const userCount = generals.filter((entry) => entry.npcState < 2).length;
const rngSeed = buildVoteUniqueSeed(
typeof hiddenSeed === 'string' || typeof hiddenSeed === 'number' ? hiddenSeed : String(hiddenSeed),
@@ -173,4 +173,138 @@ describe('unique lottery on general commands', () => {
const logTexts = (result.logs ?? []).map((entry) => entry.text);
expect(logTexts.some((text) => text.includes('【아이템】'))).toBe(true);
});
it('does not award a unique item reserved by an active auction', async () => {
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const generals = [buildGeneral(1)];
const snapshot: TurnWorldSnapshot = {
generals: generals as any,
cities: [
{
id: 1,
name: 'City_1',
nationId: 1,
viewName: 'City_1',
agriculture: 100,
agricultureMax: 2000,
commerce: 100,
commerceMax: 2000,
security: 100,
securityMax: 100,
def: 100,
defMax: 100,
wall: 100,
wallMax: 100,
pop: 10000,
popMax: 50000,
trust: 50,
supplyState: 1,
frontState: 0,
tradepoint: 0,
level: 1,
meta: {},
},
] as any,
nations: [
{
id: 1,
name: 'TestNation',
color: '#FF0000',
capitalCityId: 1,
chiefGeneralId: 1,
gold: 10000,
rice: 10000,
power: 0,
level: 1,
typeCode: 'che_def',
meta: {},
},
] as any,
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
map: {
id: 'test_map',
name: 'TestMap',
cities: [
{
id: 1,
name: 'City_1',
level: 1,
region: 1,
position: { x: 0, y: 0 },
connections: [],
max: {} as any,
initial: {} as any,
},
],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
} as any,
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {
allItems: {
weapon: {
che_무기_12_칠성검: 1,
},
},
maxUniqueItemLimit: [[-1, 1]],
uniqueTrialCoef: 10,
maxUniqueTrialProb: 10,
minMonthToAllowInheritItem: 0,
},
environment: { mapName: 'test_map', unitSet: 'default' },
},
scenarioMeta: {
startYear: 180,
} as any,
unitSet: {} as any,
};
const state: TurnWorldState = {
id: 1,
currentYear: 180,
currentMonth: 1,
tickSeconds: 3600,
lastTurnTime: new Date('0180-01-01T00:00:00Z'),
meta: {
hiddenSeed: 'seed',
scenarioId: 200,
initYear: 180,
initMonth: 1,
scenarioMeta: { startYear: 180 },
},
};
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
const reservedTurns = new InMemoryReservedTurnStore(
{
generalTurn: { findMany: async () => [] },
nationTurn: { findMany: async () => [] },
} as any,
{ maxGeneralTurns: 30, maxNationTurns: 12 }
);
reservedTurns.getGeneralTurns(1)[0] = { action: 'che_훈련', args: {} };
const handler = await createReservedTurnHandler({
reservedTurns,
scenarioConfig: snapshot.scenarioConfig,
scenarioMeta: snapshot.scenarioMeta,
map: snapshot.map,
unitSet: snapshot.unitSet,
getWorld: () => world,
getAdditionalOccupiedUniqueItemKeys: () => ['che_무기_12_칠성검'],
});
const result = handler.execute({
general: world.getGeneralById(1)!,
city: world.getCityById(1)!,
nation: world.getNationById(1)!,
world: world.getState(),
schedule,
});
expect(result.general?.role.items.weapon).toBeNull();
expect((result.logs ?? []).some((entry) => entry.text.includes('【아이템】'))).toBe(false);
});
});
+76
View File
@@ -223,4 +223,80 @@ describe('voteReward command', () => {
const afterSecond = world.getGeneralById(1);
expect(afterSecond?.gold).toBe(1500);
});
it('treats an active unique auction as occupied when revalidating the lottery', async () => {
const general = buildGeneral(1);
const snapshot: TurnWorldSnapshot = {
generals: [general] as any,
cities: [] as any,
nations: [] as any,
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
map: {
id: 'test_map',
name: 'TestMap',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
} as any,
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {
allItems: { weapon: { che_무기_12_칠성검: 1 } },
maxUniqueItemLimit: [[-1, 1]],
uniqueTrialCoef: 10,
maxUniqueTrialProb: 10,
minMonthToAllowInheritItem: 0,
},
environment: { mapName: 'test_map', unitSet: 'default' },
},
scenarioMeta: { startYear: 180 } as any,
unitSet: {} as any,
};
const state: TurnWorldState = {
id: 1,
currentYear: 180,
currentMonth: 1,
tickSeconds: 3600,
lastTurnTime: new Date('0180-01-01T00:00:00Z'),
meta: {
hiddenSeed: 'seed',
scenarioId: 200,
initYear: 180,
initMonth: 1,
scenarioMeta: { startYear: 180 },
},
};
const world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
});
const handler = createTurnDaemonCommandHandler({ world });
const commandDb = {
auction: {
findMany: async () => [{ targetCode: 'che_무기_12_칠성검' }],
},
};
const result = await handler.handle(
{
type: 'voteReward',
voteId: 1,
generalId: 1,
goldReward: 500,
unique: { expected: false, itemKey: null },
},
{ db: commandDb as any }
);
expect(result).toMatchObject({
type: 'voteReward',
ok: true,
awardedUnique: false,
});
expect(world.getGeneralById(1)?.gold).toBe(1500);
expect(world.getGeneralById(1)?.role.items.weapon).toBeNull();
});
});
+445
View File
@@ -0,0 +1,445 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')];
const artifactRoot = process.env.BOARD_ARTIFACT_DIR;
type Article = {
id: number;
title: string;
content: string;
authorName: string;
authorPicture: string | null;
authorImageServer: number;
createdAt: string;
comments: Array<{
id: number;
authorName: string;
content: string;
createdAt: string;
}>;
};
type BoardFixture = {
permission: number;
canMeeting: boolean;
canSecret: boolean;
articles: Article[];
failArticle?: boolean;
failComment?: boolean;
requests: string[];
};
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 url = new URL(route.request().url());
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const initialArticles = (): Article[] => [
{
id: 11,
title: '첫 작전 회의',
content: '낙양을 지킵시다.\n병력은 서문에 배치합니다.',
authorName: '유비',
authorPicture: '22.jpg',
authorImageServer: 0,
createdAt: '2026-07-26T10:20:00.000Z',
comments: [
{
id: 21,
authorName: '관우',
content: '확인했습니다.',
createdAt: '2026-07-26T10:25:00.000Z',
},
],
},
];
const readImage = async (relative: string): Promise<Buffer> => {
for (const root of imageRoots) {
try {
return await readFile(resolve(root, relative));
} catch {
// Main checkout and feature worktrees have different image-root parents.
}
}
throw new Error(`Reference image not found: ${relative}`);
};
const installImages = async (page: Page) => {
for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) {
await page.route(`**/image/game/${filename}`, async (route) => {
await route.fulfill({
status: 200,
contentType: 'image/jpeg',
body: await readImage(`game/${filename}`),
});
});
}
await page.route('**/image/icons/**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'image/jpeg',
body: await readImage('icons/22.jpg'),
});
});
};
const generalContext = {
general: {
id: 1,
name: '유비',
npcState: 0,
nationId: 1,
cityId: 1,
troopId: 0,
picture: '22.jpg',
imageServer: 0,
officerLevel: 1,
stats: { leadership: 80, strength: 70, intelligence: 75 },
gold: 1000,
rice: 1000,
crew: 500,
train: 100,
atmos: 100,
injury: 0,
experience: 100,
dedication: 100,
items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' },
},
city: null,
nation: null,
settings: {},
penalties: {},
};
const emptyMessages = {
private: [],
national: [],
public: [],
diplomacy: [],
sequence: -1,
hasMore: { private: false, national: false, public: false, diplomacy: false },
latestRead: { private: 0, national: 0, public: 0, diplomacy: 0 },
canRespondDiplomacy: false,
};
const installApi = async (page: Page, state: BoardFixture) => {
await installImages(page);
await page.addInitScript(() => {
window.localStorage.setItem('sammo-game-token', 'ga_board_playwright');
window.localStorage.setItem('sammo-game-profile', 'che:default');
});
await page.route('**/che/api/trpc/**', async (route) => {
const operations = operationNames(route);
const results = operations.map((operation) => {
state.requests.push(operation);
if (operation === 'lobby.info') {
return response({
year: 197,
month: 7,
userCnt: 2,
maxUserCnt: 50,
npcCnt: 0,
nationCnt: 1,
turnTerm: 60,
fictionMode: '가상',
starttime: '',
opentime: '',
turntime: '',
otherTextInfo: '',
isUnited: 0,
myGeneral: { name: '유비', picture: '22.jpg' },
});
}
if (operation === 'join.getConfig') return response({});
if (operation === 'board.getAccess') {
return response({
permission: state.permission,
canMeeting: state.canMeeting,
canSecret: state.canSecret,
});
}
if (operation === 'board.getArticles') return response(state.articles);
if (operation === 'board.writeArticle') {
if (state.failArticle) {
state.failArticle = false;
return errorResponse(operation, '접속 제한입니다.');
}
state.articles.unshift({
id: 12,
title: '새 제목',
content: '새 내용',
authorName: '유비',
authorPicture: '22.jpg',
authorImageServer: 0,
createdAt: '2026-07-26T11:00:00.000Z',
comments: [],
});
return response({ id: 12 });
}
if (operation === 'board.writeComment') {
if (state.failComment) {
state.failComment = false;
return errorResponse(operation, '접속 제한입니다.');
}
state.articles[0]?.comments.push({
id: 22,
authorName: '유비',
content: '새 댓글',
createdAt: '2026-07-26T11:05:00.000Z',
});
return response({ id: 22 });
}
if (operation === 'general.me') return response(generalContext);
if (operation === 'world.getMapLayout') {
return response({ mapName: 'che', cityList: [], regionMap: {}, levelMap: {} });
}
if (operation === 'world.getMap') {
return response({
year: 197,
month: 7,
startYear: 190,
cityList: [],
nationList: [],
myCity: 1,
myNation: 1,
});
}
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
if (operation === 'messages.getRecent') return response(emptyMessages);
if (operation === 'turns.reserved.getGeneral') return response([]);
return errorResponse(operation, `Unhandled board fixture operation: ${operation}`);
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(results),
});
});
};
const gotoBoard = async (page: Page, path = 'board') => {
const articlesResponse = page.waitForResponse(
(result) => result.url().includes('/trpc/board.getArticles') && result.ok()
);
await page.goto(path);
await articlesResponse;
await expect(page.locator('.article-frame')).toBeVisible();
};
test('matches the ref meeting-room geometry, typography, textures, and controls', async ({ page }) => {
const state: BoardFixture = {
permission: 0,
canMeeting: true,
canSecret: false,
articles: initialArticles(),
requests: [],
};
await installApi(page, state);
await page.setViewportSize({ width: 1000, height: 800 });
await gotoBoard(page);
if (artifactRoot) {
await page.screenshot({
path: resolve(artifactRoot, 'board-core-desktop.png'),
fullPage: true,
animations: 'disabled',
});
}
const geometry = await page.evaluate(() => {
const rect = (selector: string) => {
const box = document.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
return { x: box.x, y: box.y, width: box.width, height: box.height };
};
const pageStyle = getComputedStyle(document.querySelector<HTMLElement>('.legacy-board-page')!);
const articleText = getComputedStyle(document.querySelector<HTMLElement>('.article-text')!);
return {
container: rect('#container'),
topBar: rect('.top-back-bar'),
titleLabel: rect('.form-label'),
submitArticle: rect('#submitArticle'),
articleAuthor: rect('.article-header .author-name'),
articleDate: rect('.article-header .date'),
icon: rect('.general-icon'),
commentAuthor: rect('.comment-row .author-name'),
submitComment: rect('.submit-comment'),
bottomButton: rect('.bottom-bar .back-button'),
font: {
family: pageStyle.fontFamily,
size: pageStyle.fontSize,
lineHeight: pageStyle.lineHeight,
},
whiteSpace: articleText.whiteSpace,
walnut: getComputedStyle(document.querySelector<HTMLElement>('#newArticle')!).backgroundImage,
green: getComputedStyle(document.querySelector<HTMLElement>('.article-header')!).backgroundImage,
blue: getComputedStyle(document.querySelector<HTMLElement>('.new-article-header')!).backgroundImage,
submitStyle: {
backgroundColor: getComputedStyle(document.querySelector<HTMLElement>('#submitArticle')!)
.backgroundColor,
borderColor: getComputedStyle(document.querySelector<HTMLElement>('#submitArticle')!).borderColor,
},
};
});
expect(geometry.container).toMatchObject({ width: 1000, height: 397.3125 });
expect(geometry.topBar.height).toBe(32);
expect(geometry.titleLabel.width).toBeCloseTo(83.33, 1);
expect(geometry.titleLabel).toMatchObject({ y: 64.1875, height: 22.1875 });
expect(geometry.submitArticle).toMatchObject({ y: 132.5625, height: 35.5 });
expect(geometry.submitArticle.width).toBeCloseTo(149.16, 1);
expect(geometry.submitComment.width).toBeCloseTo(83.33, 1);
expect(geometry.articleAuthor.width).toBe(120);
expect(geometry.articleAuthor).toMatchObject({ y: 188.0625, height: 18.1875 });
expect(geometry.articleDate.width).toBeCloseTo(83.33, 1);
expect(geometry.icon).toMatchObject({ x: 28, y: 206.25, width: 64, height: 64 });
expect(geometry.commentAuthor.width).toBe(120);
expect(geometry.commentAuthor).toMatchObject({ y: 271.25, height: 20.1875 });
expect(geometry.submitComment).toMatchObject({ y: 292.4375, height: 29.375 });
expect(geometry.bottomButton).toMatchObject({ x: 0, y: 361.8125, width: 71, height: 35.5 });
expect(geometry.font.family).toContain('Pretendard');
expect(geometry.font).toMatchObject({ size: '14px', lineHeight: '18.2px' });
expect(geometry.whiteSpace).toBe('pre');
expect(geometry.walnut).toContain('back_walnut.jpg');
expect(geometry.green).toContain('back_green.jpg');
expect(geometry.blue).toContain('back_blue.jpg');
expect(geometry.submitStyle).toEqual({
backgroundColor: 'rgb(68, 68, 68)',
borderColor: 'rgb(61, 61, 61)',
});
const button = page.locator('#submitArticle');
const before = await button.evaluate((element) => getComputedStyle(element).backgroundColor);
await button.hover();
const hover = await button.evaluate((element) => getComputedStyle(element).backgroundColor);
await button.focus();
await expect(button).toBeFocused();
const focusOutline = await button.evaluate((element) => getComputedStyle(element).outline);
expect(hover).toBe(before);
expect(focusOutline).toContain('none');
});
test('uses the ref 500px responsive form widths', async ({ page }) => {
const state: BoardFixture = {
permission: 2,
canMeeting: true,
canSecret: true,
articles: initialArticles(),
requests: [],
};
await installApi(page, state);
await page.setViewportSize({ width: 500, height: 800 });
await gotoBoard(page, 'board/secret');
if (artifactRoot) {
await page.screenshot({
path: resolve(artifactRoot, 'board-core-mobile-secret.png'),
fullPage: true,
animations: 'disabled',
});
}
const geometry = await page.evaluate(() => {
const width = (selector: string) =>
document.querySelector<HTMLElement>(selector)!.getBoundingClientRect().width;
return {
container: width('#container'),
label: width('.form-label'),
submitArticle: width('#submitArticle'),
author: width('.article-header .author-name'),
date: width('.article-header .date'),
};
});
expect(geometry.container).toBe(500);
expect(geometry.label).toBeCloseTo(83.33, 1);
expect(geometry.submitArticle).toBeCloseTo(152.66, 1);
expect(geometry.author).toBe(120);
expect(geometry.date).toBeCloseTo(83.33, 1);
await expect(page.getByRole('heading', { name: '기밀실' })).toBeVisible();
});
test('retains article and comment input after a failed mutation, then reloads after success', async ({ page }) => {
const state: BoardFixture = {
permission: 2,
canMeeting: true,
canSecret: true,
articles: initialArticles(),
failArticle: true,
failComment: true,
requests: [],
};
await installApi(page, state);
await gotoBoard(page);
await page.locator('#board-title').fill('새 제목');
await page.locator('#board-content').fill('새 내용');
page.once('dialog', async (dialog) => {
expect(dialog.message()).toBe('실패했습니다. :접속 제한입니다.');
await dialog.accept();
});
await page.locator('#submitArticle').click();
await expect(page.locator('#board-title')).toHaveValue('새 제목');
await expect(page.locator('#board-content')).toHaveValue('새 내용');
await page.locator('#submitArticle').click();
await expect(page.getByText('새 제목', { exact: true })).toBeVisible();
await expect(page.locator('#board-title')).toHaveValue('');
const commentInput = page.locator('.comment-input').first();
await commentInput.fill('새 댓글');
page.once('dialog', async (dialog) => {
expect(dialog.message()).toBe('실패했습니다: 접속 제한입니다.');
await dialog.accept();
});
await commentInput.press('Enter');
await expect(commentInput).toHaveValue('새 댓글');
await commentInput.press('Enter');
await expect(page.getByText('새 댓글', { exact: true })).toBeVisible();
});
test('denies direct secret-room rendering and disables its in-game menu for an ordinary member', async ({ page }) => {
const state: BoardFixture = {
permission: 0,
canMeeting: true,
canSecret: false,
articles: initialArticles(),
requests: [],
};
await installApi(page, state);
await page.goto('board/secret');
await expect(page.getByRole('alert')).toHaveText('권한이 부족합니다. 수뇌부가 아닙니다.');
await expect(page.locator('#newArticle')).toHaveCount(0);
expect(state.requests).not.toContain('board.getArticles');
state.requests.length = 0;
await page.goto('');
await expect(page.getByRole('link', { name: '회의실', exact: true })).toBeVisible();
await expect(page.locator('.header-actions [aria-disabled="true"]').filter({ hasText: '기밀실' })).toBeVisible();
await expect(page.getByRole('link', { name: '기밀실', exact: true })).toHaveCount(0);
});
test('enables both in-game menu entries for a chief or delegated secret role', async ({ page }) => {
const state: BoardFixture = {
permission: 3,
canMeeting: true,
canSecret: true,
articles: initialArticles(),
requests: [],
};
await installApi(page, state);
await page.goto('');
await expect(page.getByRole('link', { name: '회의실', exact: true })).toBeVisible();
await expect(page.getByRole('link', { name: '기밀실', exact: true })).toBeVisible();
});
+256
View File
@@ -0,0 +1,256 @@
import { expect, test, type Page, type Route } from '@playwright/test';
const response = (data: unknown) => ({ result: { data } });
const operationNames = (route: Route) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
const city = {
id: 1,
name: '업',
level: 8,
region: 1,
population: 150000,
populationMax: 620500,
agriculture: 1000,
agricultureMax: 12500,
commerce: 1000,
commerceMax: 11300,
security: 1000,
securityMax: 10000,
trust: 80,
trade: 100,
defence: 5000,
defenceMax: 11700,
wall: 5000,
wallMax: 12200,
supplyState: 1,
frontState: 0,
incomes: { gold: 1000, rice: 900, wall: 800 },
officers: { 2: null, 3: null, 4: { id: 1, name: '태수', npcState: 0, officerLevel: 4, cityId: 1, cityName: '업' } },
};
const map = {
result: true,
version: 0,
startYear: 180,
year: 200,
month: 1,
cityList: [[1, 8, 0, 1, 1, 1]],
nationList: [[1, '아국', '#008000', 1]],
spyList: {},
shownByGeneralList: [],
myCity: 1,
myNation: 1,
};
const layout = {
mapName: 'che',
cityList: [{ id: 1, name: '업', level: 8, region: 1, x: 345, y: 130, path: [] }],
regionMap: { 1: '하북' },
levelMap: { 8: '특' },
};
const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'member') => {
await page.addInitScript(() => {
localStorage.setItem('sammo-game-token', 'ga_info');
localStorage.setItem('sammo-game-profile', 'che:default');
});
await page.route('**/image/game/**', (route) =>
route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') })
);
await page.route('**/che/api/trpc/**', async (route) => {
const results = operationNames(route).map((operation) => {
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '장수' } });
if (operation === 'join.getConfig') return response({});
if (operation === 'nation.getNationInfo')
return response({
nation: {
id: 1,
name: '아국',
color: '#008000',
level: 1,
power: 1234,
gold: 10000,
rice: 9000,
tech: 100,
rate: 20,
bill: 100,
capitalCityId: 1,
generalCount: 2,
},
population: { current: 150000, max: 620500 },
crew: { current: 500, max: 7000 },
income: {
goldCity: 1000,
goldWar: 200,
goldTotal: 1200,
riceCity: 900,
riceWall: 800,
riceTotal: 1700,
outcome: 300,
},
budget: { gold: 10900, rice: 10400 },
cities: [{ id: 1, name: '업', capital: true }],
history: [{ id: 1, year: 200, month: 1, text: '건국했습니다.' }],
});
if (operation === 'nation.getCityOverview')
return response({
me: { id: 1, officerLevel: 1 },
nation: {
id: 1,
name: '아국',
color: '#008000',
level: 1,
typeCode: 'che_중립',
capitalCityId: 1,
rate: 20,
},
chiefStatMin: 65,
cities: [city],
generals: [
{
id: 1,
name: '장수',
npcState: 0,
officerLevel: 1,
cityId: 1,
officerCity: 0,
stats: { leadership: 70, strength: 60, intelligence: 50 },
},
],
});
if (operation === 'world.getGlobalInfo')
return response({
myNationId: 1,
nations: [
{
id: 1,
name: '아국',
color: '#008000',
capitalCityId: 1,
level: 1,
power: 1234,
cities: ['업'],
},
{
id: 2,
name: '적국',
color: '#800000',
capitalCityId: 2,
level: 1,
power: 1000,
cities: ['허창'],
},
],
diplomacy: { 1: { 1: 2, 2: 0 }, 2: { 1: 0, 2: 2 } },
conflict: [],
map,
});
if (operation === 'world.getMapLayout') return response(layout);
if (operation === 'world.getCurrentCity')
return response({
me: {
id: 1,
nationId: mode === 'wanderer' ? 0 : 1,
officerLevel: mode === 'wanderer' ? 0 : 1,
admin: mode === 'admin',
},
options: [{ id: 1, name: '업', nationId: 1 }],
visibility: { full: mode !== 'wanderer', detailed: mode !== 'wanderer' },
city: {
id: 1,
name: '업',
nationId: 1,
level: 8,
region: 1,
population: mode === 'wanderer' ? null : 150000,
populationMax: 620500,
agriculture: mode === 'wanderer' ? null : 1000,
agricultureMax: 12500,
commerce: mode === 'wanderer' ? null : 1000,
commerceMax: 11300,
security: mode === 'wanderer' ? null : 1000,
securityMax: 10000,
trust: mode === 'wanderer' ? null : 80,
trade: 100,
defence: mode === 'wanderer' ? null : 5000,
defenceMax: 11700,
wall: mode === 'wanderer' ? null : 5000,
wallMax: 12200,
officers: { 2: '-', 3: '-', 4: '태수' },
},
generals:
mode === 'wanderer'
? []
: [
{
id: 1,
name: '장수',
npcState: 0,
picture: null,
imageServer: 0,
nationId: 1,
nationName: '아국',
leadership: 70,
strength: 60,
intelligence: 50,
injury: 0,
officerLevel: 1,
defenceTrain: 80,
crewTypeId: 1,
crew: 500,
train: 90,
atmos: 90,
turns: ['징병'],
},
],
lastExecute: '2026-07-26',
});
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
});
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
});
};
const go = async (page: Page, path: string) => {
await page.goto(path);
};
test('four legacy menu pages keep the 1000px desktop table contract', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
for (const [path, selector] of [
['nation/info', '.legacy-info-page'],
['nation/cities', '.nation-cities-page'],
['global-info', '.global-page'],
['current-city', '.city-page'],
] as const) {
await go(page, path);
await expect(page.locator(selector)).toBeVisible();
const box = await page.locator(selector).evaluate((el) => {
const r = el.getBoundingClientRect();
const s = getComputedStyle(el);
return { x: r.x, width: r.width, fontSize: s.fontSize, fontFamily: s.fontFamily };
});
expect(box.width).toBe(1000);
expect(box.x).toBe(100);
expect(box.fontSize).toBe('14px');
expect(box.fontFamily).toContain('Pretendard');
expect(
await page
.locator('table')
.first()
.evaluate((el) => getComputedStyle(el).borderCollapse)
).toBe('collapse');
}
});
test('current-city hides values and general rows for a wandering user', async ({ page }) => {
await install(page, 'wanderer');
await go(page, 'current-city');
await expect(page.locator('.stats')).toContainText('?/620,500');
await expect(page.locator('.generals')).toHaveCount(0);
});
test('current-city exposes own general details to a member and admin fixture', async ({ page }) => {
await install(page, 'admin');
await go(page, 'current-city');
await expect(page.locator('.generals')).toContainText('장수');
await expect(page.locator('.generals')).toContainText('90');
});
+6 -3
View File
@@ -6,15 +6,18 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../.
export default defineConfig({
testDir: '.',
testMatch: 'troop.spec.ts',
testMatch: ['troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts'],
fullyParallel: false,
workers: 1,
timeout: 30_000,
expect: {
timeout: 5_000,
},
reporter: [['list'], ['html', { open: 'never', outputFolder: resolve(repositoryRoot, 'playwright-report') }]],
outputDir: resolve(repositoryRoot, 'test-results/troop'),
reporter: [
['list'],
['html', { open: 'never', outputFolder: resolve(repositoryRoot, 'playwright-report/game-legacy') }],
],
outputDir: resolve(repositoryRoot, 'test-results/game-legacy'),
use: {
baseURL: 'http://127.0.0.1:15120/che/',
...devices['Desktop Chrome'],
+2 -1
View File
@@ -7,7 +7,8 @@
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"test:e2e:troop": "playwright test --config e2e/playwright.config.mjs",
"test:e2e:troop": "playwright test troop.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "node -e \"console.log('test not configured')\"",
+21
View File
@@ -6,6 +6,9 @@ import JoinView from '../views/JoinView.vue';
import InheritView from '../views/InheritView.vue';
import AuctionView from '../views/AuctionView.vue';
import NationCitiesView from '../views/NationCitiesView.vue';
import NationInfoView from '../views/NationInfoView.vue';
import GlobalInfoView from '../views/GlobalInfoView.vue';
import CurrentCityView from '../views/CurrentCityView.vue';
import NationGeneralsView from '../views/NationGeneralsView.vue';
import NationPersonnelView from '../views/NationPersonnelView.vue';
import NationStratFinanView from '../views/NationStratFinanView.vue';
@@ -84,6 +87,12 @@ const routes = [
requiresGeneral: true,
},
},
{
path: '/nation/info',
name: 'nation-info',
component: NationInfoView,
meta: { requiresAuth: true, requiresGeneral: true },
},
{
path: '/nation/cities',
name: 'nation-cities',
@@ -93,6 +102,18 @@ const routes = [
requiresGeneral: true,
},
},
{
path: '/global-info',
name: 'global-info',
component: GlobalInfoView,
meta: { requiresAuth: true, requiresGeneral: true },
},
{
path: '/current-city',
name: 'current-city',
component: CurrentCityView,
meta: { requiresAuth: true, requiresGeneral: true },
},
{
path: '/nation/affairs',
name: 'nation-affairs',
@@ -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 BoardAccess = Awaited<ReturnType<typeof trpc.board.getAccess.query>>;
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>[number];
const loading = ref(false);
@@ -36,6 +37,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const mapLayout = ref<MapLayout | null>(null);
const commandTable = ref<CommandTable | null>(null);
const messages = ref<MessageBundle | null>(null);
const boardAccess = ref<BoardAccess | null>(null);
const reservedGeneralTurns = ref<ReservedTurnView[] | null>(null);
const reservedNationTurns = ref<ReservedTurnView[] | null>(null);
@@ -133,6 +135,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
if (!context) {
reservedGeneralTurns.value = null;
reservedNationTurns.value = null;
boardAccess.value = null;
loading.value = false;
return;
}
@@ -144,12 +147,13 @@ 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, generalTurns, nationTurns] = await Promise.all([
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,
]);
@@ -159,6 +163,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
worldMap.value = map;
commandTable.value = commands;
messages.value = messageData;
boardAccess.value = access;
reservedGeneralTurns.value = generalTurns;
reservedNationTurns.value = nationTurns;
} catch (err) {
@@ -479,6 +484,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
selectedCity,
commandTable,
messages,
boardAccess,
reservedGeneralTurns,
reservedNationTurns,
messageDraftText,
+433 -378
View File
@@ -1,473 +1,528 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { EditorContent, useEditor } from '@tiptap/vue-3';
import StarterKit from '@tiptap/starter-kit';
import Image from '@tiptap/extension-image';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline';
import { trpc } from '../utils/trpc';
type BoardComment = {
id: number;
authorName: string;
content: string;
createdAt: string;
};
type BoardArticle = {
id: number;
title: string;
contentHtml: string;
authorName: string;
createdAt: string;
comments: BoardComment[];
};
type BoardArticle = Awaited<ReturnType<typeof trpc.board.getArticles.query>>[number];
const route = useRoute();
const isSecretBoard = computed(() => route.name === 'board-secret');
const title = computed(() => (isSecretBoard.value ? '기밀실' : '회의실'));
const toggleBoardLabel = computed(() => (isSecretBoard.value ? '회의실로' : '기밀실로'));
const toggleBoardPath = computed(() => (isSecretBoard.value ? '/board' : '/board/secret'));
const loading = ref(false);
const errorMessage = ref<string | null>(null);
const accessChecked = ref(false);
const canAccess = ref(false);
const errorMessage = ref('');
const articles = ref<BoardArticle[]>([]);
const draftTitle = ref('');
const draftText = ref('');
const articleTextArea = ref<HTMLTextAreaElement | null>(null);
const commentDrafts = reactive<Record<number, string>>({});
const editor = useEditor({
extensions: [
StarterKit,
Underline,
Link.configure({ openOnClick: false }),
Image.configure({ inline: false }),
Placeholder.configure({ placeholder: '내용을 입력하세요.' }),
],
content: '',
editorProps: {
attributes: {
class: 'board-editor',
},
},
});
const errorText = (error: unknown, fallback: string): string => {
if (error instanceof Error) {
return error.message;
}
return typeof error === 'string' ? error : fallback;
};
const resizeTextArea = (element: HTMLTextAreaElement | null) => {
if (!element) {
return;
}
element.style.height = 'auto';
element.style.height = `${Math.max(element.scrollHeight, 42)}px`;
};
const formatDate = (value: string): string => value.slice(5, 16).replace('T', ' ');
const iconPath = (article: BoardArticle): string => {
const picture = article.authorPicture || 'default.jpg';
return article.authorImageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/icons/${picture}`;
};
const refreshArticles = async () => {
if (loading.value) return;
if (loading.value) {
return;
}
loading.value = true;
errorMessage.value = null;
errorMessage.value = '';
accessChecked.value = false;
try {
const result = await trpc.board.getArticles.query({ isSecret: isSecretBoard.value });
articles.value = result;
} catch (err) {
errorMessage.value = err instanceof Error ? err.message : '게시판을 불러오지 못했습니다.';
const access = await trpc.board.getAccess.query();
canAccess.value = isSecretBoard.value ? access.canSecret : access.canMeeting;
accessChecked.value = true;
if (!canAccess.value) {
errorMessage.value = isSecretBoard.value
? '권한이 부족합니다. 수뇌부가 아닙니다.'
: '국가에 소속되어있지 않습니다.';
articles.value = [];
return;
}
articles.value = await trpc.board.getArticles.query({ isSecret: isSecretBoard.value });
} catch (error) {
accessChecked.value = true;
canAccess.value = false;
errorMessage.value = errorText(error, '게시판을 불러오지 못했습니다.');
} finally {
loading.value = false;
}
};
const submitArticle = async () => {
const contentHtml = editor.value?.getHTML().trim() ?? '';
const titleValue = draftTitle.value.trim();
if (!titleValue && !contentHtml) return;
errorMessage.value = null;
const content = draftText.value.trim();
if (!titleValue && !content) {
return;
}
try {
await trpc.board.writeArticle.mutate({
isSecret: isSecretBoard.value,
title: titleValue,
contentHtml,
content,
});
draftTitle.value = '';
editor.value?.commands.setContent('');
draftText.value = '';
await nextTick();
resizeTextArea(articleTextArea.value);
await refreshArticles();
} catch (err) {
errorMessage.value = err instanceof Error ? err.message : '게시물 등록에 실패했습니다.';
} catch (error) {
window.alert(`실패했습니다. :${errorText(error, '게시물 등록에 실패했습니다.')}`);
}
};
const submitComment = async (postId: number) => {
const content = (commentDrafts[postId] ?? '').trim();
if (!content) return;
errorMessage.value = null;
if (!content) {
return;
}
try {
await trpc.board.writeComment.mutate({ postId, content });
commentDrafts[postId] = '';
await refreshArticles();
} catch (err) {
errorMessage.value = err instanceof Error ? err.message : '댓글 등록에 실패했습니다.';
} catch (error) {
window.alert(`실패했습니다: ${errorText(error, '댓글 등록에 실패했습니다.')}`);
}
};
const fileInputRef = ref<HTMLInputElement | null>(null);
const uploadBusy = ref(false);
const addLink = () => {
const url = window.prompt('링크 주소를 입력하세요');
if (!url) return;
editor.value?.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
};
const readFileAsDataUrl = (file: File) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject(new Error('이미지를 읽을 수 없습니다.'));
}
};
reader.onerror = () => reject(new Error('이미지를 읽는 중 오류가 발생했습니다.'));
reader.readAsDataURL(file);
});
const onSelectImage = async (event: Event) => {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file || uploadBusy.value) return;
uploadBusy.value = true;
errorMessage.value = null;
try {
const dataUrl = await readFileAsDataUrl(file);
const result = await trpc.board.uploadImage.mutate({ dataUrl });
editor.value?.chain().focus().setImage({ src: result.url, alt: file.name }).run();
} catch (err) {
errorMessage.value = err instanceof Error ? err.message : '이미지 업로드에 실패했습니다.';
} finally {
uploadBusy.value = false;
if (input) {
input.value = '';
}
}
};
const formatDate = (value: string) => new Date(value).toLocaleString('ko-KR');
watch(isSecretBoard, () => {
refreshArticles();
void refreshArticles();
});
onMounted(() => {
refreshArticles();
});
onBeforeUnmount(() => {
editor.value?.destroy();
void refreshArticles();
});
</script>
<template>
<div class="board-view">
<header class="board-header">
<div>
<h1>{{ title }}</h1>
<span class="board-subtitle"> 게시물 작성</span>
</div>
<div class="header-actions">
<RouterLink class="ghost" to="/">메인으로</RouterLink>
<RouterLink class="ghost" :to="toggleBoardPath">{{ toggleBoardLabel }}</RouterLink>
</div>
<main id="container" class="legacy-board-page">
<header class="top-back-bar bg0">
<RouterLink class="legacy-button back-button" to="/">돌아가기</RouterLink>
<div></div>
<h1>{{ title }}</h1>
<div></div>
<div></div>
</header>
<section class="board-editor-card">
<div class="field-row">
<label class="field-label">제목</label>
<input v-model="draftTitle" class="field-input" type="text" maxlength="250" placeholder="제목" />
</div>
<div v-if="loading && !accessChecked" class="board-state bg0">불러오는 중...</div>
<div v-else-if="!canAccess" class="board-state access-error" role="alert">{{ errorMessage }}</div>
<div class="editor-toolbar">
<button type="button" @click="editor?.chain().focus().toggleBold().run()" :class="{ active: editor?.isActive('bold') }">
굵게
</button>
<button
type="button"
@click="editor?.chain().focus().toggleItalic().run()"
:class="{ active: editor?.isActive('italic') }"
>
기울임
</button>
<button
type="button"
@click="editor?.chain().focus().toggleUnderline().run()"
:class="{ active: editor?.isActive('underline') }"
>
밑줄
</button>
<button type="button" @click="addLink">링크</button>
<button type="button" @click="editor?.chain().focus().toggleBulletList().run()">
목록
</button>
<button type="button" @click="editor?.chain().focus().toggleOrderedList().run()">
번호 목록
</button>
<button type="button" @click="fileInputRef?.click()" :disabled="uploadBusy">
이미지 업로드
</button>
<input ref="fileInputRef" type="file" accept="image/*" class="hidden" @change="onSelectImage" />
</div>
<EditorContent v-if="editor" :editor="editor" />
<div class="submit-row">
<button type="button" class="primary" @click="submitArticle">등록</button>
</div>
</section>
<p v-if="errorMessage" class="error-text">{{ errorMessage }}</p>
<section class="board-list">
<div v-if="loading" class="empty-text">불러오는 중...</div>
<div v-else-if="!articles.length" class="empty-text">게시물이 없습니다.</div>
<article v-else v-for="article in articles" :key="article.id" class="board-article">
<header class="article-header">
<h2>{{ article.title || '제목 없음' }}</h2>
<div class="article-meta">
<span>{{ article.authorName }}</span>
<span>{{ formatDate(article.createdAt) }}</span>
</div>
</header>
<div class="article-content" v-html="article.contentHtml" />
<section class="comment-list">
<div v-if="!article.comments.length" class="comment-empty">댓글이 없습니다.</div>
<div v-for="comment in article.comments" :key="comment.id" class="comment-item">
<div class="comment-meta">
<span>{{ comment.authorName }}</span>
<span>{{ formatDate(comment.createdAt) }}</span>
</div>
<p class="comment-content">{{ comment.content }}</p>
</div>
</section>
<div class="comment-form">
<textarea
v-model="commentDrafts[article.id]"
rows="3"
placeholder="댓글을 입력하세요."
<template v-else>
<section id="newArticle" class="bg0">
<div class="new-article-header bg2 center"> 게시물 작성</div>
<div class="form-row">
<label class="form-label bg1 center" for="board-title">제목</label>
<input
id="board-title"
v-model="draftTitle"
class="title-input"
type="text"
maxlength="250"
placeholder="제목"
/>
<button type="button" @click="submitComment(article.id)">댓글 등록</button>
</div>
</article>
</section>
</div>
<div class="form-row content-row">
<label class="form-label bg1 center" for="board-content">내용</label>
<textarea
id="board-content"
ref="articleTextArea"
v-model="draftText"
class="content-input"
placeholder="내용"
@input="resizeTextArea(articleTextArea)"
/>
</div>
<div class="article-submit-row">
<div></div>
<button id="submitArticle" class="legacy-button" type="button" @click="submitArticle">등록</button>
</div>
</section>
<section id="board">
<template v-if="articles.length">
<article v-for="article in articles" :key="article.id" class="article-frame bg0">
<header class="article-header bg1">
<div class="author-name center">{{ article.authorName }}</div>
<div class="article-title center">{{ article.title }}</div>
<time class="date center" :datetime="article.createdAt">{{
formatDate(article.createdAt)
}}</time>
</header>
<div class="article-body border-bottom">
<div class="author-icon center">
<img
class="general-icon"
width="64"
height="64"
:src="iconPath(article)"
:alt="`${article.authorName} 아이콘`"
/>
</div>
<div class="article-text">{{ article.content }}</div>
</div>
<div class="comment-list">
<div
v-for="comment in article.comments"
:key="comment.id"
class="comment-row border-bottom"
>
<div class="author-name center">{{ comment.authorName }}</div>
<div class="comment-text">{{ comment.content }}</div>
<time class="date center" :datetime="comment.createdAt">{{
formatDate(comment.createdAt)
}}</time>
</div>
</div>
<div class="comment-form">
<label class="input-comment-header bg2 center" :for="`comment-${article.id}`"
>댓글 달기</label
>
<input
:id="`comment-${article.id}`"
v-model.trim="commentDrafts[article.id]"
class="comment-input"
type="text"
maxlength="250"
placeholder="새 댓글 내용"
@keyup.enter="submitComment(article.id)"
/>
<button
class="legacy-button submit-comment"
type="button"
@click="submitComment(article.id)"
>
등록
</button>
</div>
</article>
</template>
<div v-else-if="!loading" class="empty-board">게시물이 없습니다.</div>
</section>
<footer class="bottom-bar bg0">
<RouterLink class="legacy-button back-button" to="/">돌아가기</RouterLink>
</footer>
</template>
</main>
</template>
<style scoped>
.board-view {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
color: #e6e8ef;
background: #0f1118;
min-height: 100vh;
}
.board-header h1 {
font-size: 28px;
margin: 0 0 6px;
}
.header-actions {
display: flex;
gap: 12px;
}
.header-actions .ghost {
padding: 6px 12px;
border-radius: 8px;
border: 1px solid #2b2f3f;
color: #c7d0e0;
text-decoration: none;
background: #141826;
}
.header-actions .ghost:hover {
background: #1b2233;
}
.board-subtitle {
font-size: 14px;
color: #a8afc5;
}
.board-editor-card {
background: #181b26;
padding: 20px;
border-radius: 12px;
display: flex;
flex-direction: column;
gap: 16px;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.2);
}
.field-row {
display: flex;
gap: 12px;
align-items: center;
}
.field-label {
min-width: 52px;
font-weight: 600;
color: #c9d0e5;
}
.field-input {
flex: 1;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid #2b2f3f;
background: #11131a;
color: #f2f4f8;
}
.editor-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.editor-toolbar button {
padding: 6px 10px;
border-radius: 6px;
border: 1px solid #2b2f3f;
background: #1e2232;
color: #d8dff0;
cursor: pointer;
}
.editor-toolbar button.active {
background: #3b425c;
}
.editor-toolbar button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.hidden {
display: none;
}
.board-editor {
min-height: 220px;
padding: 12px;
border-radius: 10px;
border: 1px solid #2b2f3f;
background: #0f1118;
color: #f5f6fa;
}
.board-editor :deep(img) {
max-width: 100%;
height: auto;
border-radius: 6px;
}
.submit-row {
display: flex;
justify-content: flex-end;
}
.submit-row .primary {
padding: 10px 20px;
border-radius: 8px;
border: none;
background: #3b82f6;
.legacy-board-page {
width: 500px;
margin: 0 auto;
color: #fff;
background: #000;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: 1.3;
}
.bg0 {
background-image: url('/image/game/back_walnut.jpg');
}
.bg1 {
background-image: url('/image/game/back_green.jpg');
}
.bg2 {
background-image: url('/image/game/back_blue.jpg');
}
.center {
text-align: center;
}
.top-back-bar {
width: 100%;
height: 32px;
display: grid;
grid-template-columns: 90px 90px 1fr 90px 90px;
}
.top-back-bar h1 {
margin: 0;
font-size: 24px;
font-weight: 500;
line-height: 32px;
text-align: center;
}
.legacy-button {
min-height: 31px;
box-sizing: border-box;
border: 1px solid #3d3d3d;
border-radius: 4px;
padding: 4px 12px;
color: #fff;
background: #444;
font: inherit;
font-weight: 600;
line-height: 1.5;
text-align: center;
text-decoration: none;
cursor: pointer;
}
.error-text {
color: #f87171;
.legacy-button:hover {
border-color: #3d3d3d;
background: #444;
}
.board-list {
.legacy-button:focus-visible {
outline: none;
}
.legacy-button:active {
border-color: #3d3d3d;
background: #444;
}
.back-button {
height: 32px;
margin-right: 2px;
border-color: #004f28;
background: #00582c;
}
.back-button:hover,
.back-button:focus {
border-color: #004523;
background: #004a25;
}
.board-state {
margin-top: 14px;
padding: 10px;
text-align: center;
}
.access-error {
color: #fff;
text-align: left;
}
#newArticle {
margin-top: 14px;
}
.new-article-header {
min-height: 18px;
}
.form-row {
min-height: 22.1875px;
display: flex;
flex-direction: column;
gap: 20px;
}
.board-article {
background: #161a24;
border-radius: 12px;
padding: 18px;
border: 1px solid #202638;
.form-label {
width: 16.6667%;
flex: 0 0 auto;
display: grid;
align-content: center;
}
.article-header h2 {
margin: 0 0 6px;
font-size: 20px;
.title-input,
.content-input,
.comment-input {
min-width: 0;
box-sizing: border-box;
border: 0;
color: #fff;
background: transparent;
font: inherit;
}
.article-meta {
font-size: 12px;
color: #9aa3b8;
.title-input {
width: calc(83.3333% - 10px);
margin: 1px 5px;
}
.content-row {
align-items: stretch;
min-height: 46.1875px;
}
.content-input {
width: 83.3333%;
min-height: 42px;
padding: 1px 5px;
resize: none;
overflow: hidden;
}
.title-input:focus-visible,
.content-input:focus-visible,
.comment-input:focus-visible {
outline: 2px solid #8ab4f8;
outline-offset: -2px;
}
.article-submit-row {
display: grid;
grid-template-columns: 66.6667% 33.3333%;
margin-right: -10.5px;
margin-left: -10.5px;
}
.article-submit-row .legacy-button {
width: auto;
min-height: 35.5px;
margin-right: 10.5px;
margin-left: 10.5px;
}
.article-frame {
margin: 20px auto;
}
.article-header,
.article-body,
.comment-row,
.comment-form {
display: flex;
gap: 8px;
}
.article-content {
margin-top: 12px;
line-height: 1.6;
.author-name,
.author-icon,
.input-comment-header {
width: 120px;
flex: 0 0 auto;
}
.comment-list {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 12px;
.article-header {
min-height: 18px;
align-items: stretch;
}
.comment-item {
background: #11131a;
border-radius: 8px;
padding: 10px 12px;
.article-header > *,
.comment-row > * {
display: grid;
align-content: center;
}
.comment-meta {
font-size: 11px;
color: #8e96aa;
display: flex;
gap: 8px;
margin-bottom: 6px;
.article-title,
.article-text,
.comment-text {
min-width: 0;
flex: 1 1 auto;
}
.comment-content {
margin: 0;
white-space: pre-wrap;
.date {
width: 83.333px;
flex: 0 0 auto;
font-size: 0.9em;
}
.article-body {
min-height: 64px;
}
.author-icon {
display: grid;
align-content: center;
justify-content: center;
}
.general-icon {
object-fit: contain;
}
.article-text,
.comment-text {
padding: 1px 5px;
text-align: left;
white-space: pre;
}
.border-bottom {
border-bottom: 1px solid gray;
}
.comment-row {
min-height: 21.1875px;
}
.comment-form {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 29.375px;
}
.comment-form textarea {
background: #0f1118;
border: 1px solid #2b2f3f;
border-radius: 8px;
padding: 8px 10px;
color: #e6e8ef;
.input-comment-header {
display: grid;
align-content: center;
}
.comment-form button {
align-self: flex-end;
padding: 6px 14px;
border-radius: 6px;
border: none;
background: #334155;
color: #fff;
cursor: pointer;
.comment-input {
flex: 1 1 auto;
padding: 1px 5px;
}
.empty-text {
color: #9aa3b8;
.submit-comment {
width: 83.333px;
min-height: 29.375px;
padding-top: 2px;
padding-bottom: 2px;
flex: 0 0 auto;
}
</style>
.empty-board {
min-height: 18px;
}
.bottom-bar {
padding-top: 20px;
}
.bottom-bar .back-button {
display: inline-block;
width: 71px;
height: 35.5px;
margin: 0;
padding-right: 6px;
padding-left: 6px;
white-space: nowrap;
}
@media (min-width: 940px) {
.legacy-board-page {
width: 1000px;
}
.form-label {
width: 8.3333%;
}
.title-input {
width: calc(91.6667% - 10px);
}
.content-input {
width: 91.6667%;
}
.article-submit-row {
grid-template-columns: 83.3333% 16.6667%;
}
}
</style>
@@ -0,0 +1,240 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { formatOfficerLevelText, cityLevelMap, regionMap } from '../utils/nationFormat';
import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.world.getCurrentCity.query>>;
const data = ref<Result | null>(null);
const error = ref('');
const selected = ref<number>();
const show = (value: number | null) => (value === null ? '?' : value.toLocaleString('ko-KR'));
const load = async (cityId?: number) => {
try {
data.value = await trpc.world.getCurrentCity.query(cityId ? { cityId } : undefined);
selected.value = data.value.city.id;
error.value = '';
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '도시 정보를 불러오지 못했습니다.';
}
};
const city = computed(() => data.value?.city);
onMounted(() => void load());
</script>
<template>
<main class="city-page">
<table class="legacy-table legacy-bg0 center">
<tbody>
<tr>
<td> <br /><RouterLink to="/">돌아가기</RouterLink></td>
</tr>
</tbody>
</table>
<table class="legacy-table legacy-bg0 selector">
<tbody>
<tr>
<td>
도시선택 :
<select v-model.number="selected" @change="load(selected)">
<option v-for="option in data?.options ?? []" :key="option.id" :value="option.id">
{{ option.name.padEnd(4, '_') }}{{
option.nationId === data?.me.nationId
? '본국'
: option.nationId === 0
? '공백지'
: '타국'
}}
</option>
</select>
<p>명령 화면에서 도시를 클릭하셔도 됩니다.</p>
</td>
</tr>
</tbody>
</table>
<p v-if="error" class="error">{{ error }}</p>
<template v-if="data && city">
<table class="legacy-table legacy-bg2 stats">
<tbody>
<tr>
<td colspan="11" class="city-title">
{{ regionMap[city.region] }} | {{ cityLevelMap[city.level] }} {{ city.name }}
</td>
<td class="city-title">{{ data.lastExecute }}</td>
</tr>
<tr>
<th>주민</th>
<td>{{ show(city.population) }}/{{ show(city.populationMax) }}</td>
<th>농업</th>
<td>{{ show(city.agriculture) }}/{{ show(city.agricultureMax) }}</td>
<th>상업</th>
<td>{{ show(city.commerce) }}/{{ show(city.commerceMax) }}</td>
<th>치안</th>
<td>{{ show(city.security) }}/{{ show(city.securityMax) }}</td>
<th>수비</th>
<td>{{ show(city.defence) }}/{{ show(city.defenceMax) }}</td>
<th>성벽</th>
<td>{{ show(city.wall) }}/{{ show(city.wallMax) }}</td>
</tr>
<tr>
<th>민심</th>
<td>{{ show(city.trust) }}</td>
<th>시세</th>
<td>{{ city.trade ?? '-' }}%</td>
<th>인구</th>
<td>
{{
city.population === null
? '?'
: ((city.population / city.populationMax) * 100).toFixed(2)
}}%
</td>
<th>태수</th>
<td>{{ city.officers[4] }}</td>
<th>군사</th>
<td>{{ city.officers[3] }}</td>
<th>종사</th>
<td>{{ city.officers[2] }}</td>
</tr>
<tr>
<th>장수</th>
<td colspan="11">
{{
data.visibility.detailed
? data.generals.map((g) => g.name).join(', ') || '-'
: '알 수 없음'
}}
</td>
</tr>
</tbody>
</table>
<table v-if="data.visibility.detailed" class="legacy-table legacy-bg0 generals">
<thead>
<tr>
<th> </th>
<th> </th>
<th>통솔</th>
<th>무력</th>
<th>지력</th>
<th> </th>
<th></th>
<th> </th>
<th> </th>
<th>훈련</th>
<th>사기</th>
<th> </th>
</tr>
</thead>
<tbody>
<tr v-for="general in data.generals" :key="general.id">
<td>
<img
v-if="general.picture"
width="64"
height="64"
:src="`/image/general/${general.picture}`"
/>
</td>
<td>{{ general.name }}</td>
<td>{{ general.leadership }}</td>
<td>{{ general.strength }}</td>
<td>{{ general.intelligence }}</td>
<td>{{ formatOfficerLevelText(general.officerLevel) }}</td>
<td>{{ general.defenceTrain ?? '?' }}</td>
<td>{{ general.crewTypeId ?? '?' }}</td>
<td>{{ general.crew ?? '?' }}</td>
<td>{{ general.train ?? '?' }}</td>
<td>{{ general.atmos ?? '?' }}</td>
<td class="turns">
{{
general.turns.length
? general.turns.map((turn, index) => `${index + 1} : ${turn}`).join(' / ')
: general.npcState > 1
? 'NPC 장수'
: `${general.nationName}】 장수`
}}
</td>
</tr>
</tbody>
</table>
</template>
<table class="legacy-table legacy-bg0 center footer">
<tbody>
<tr>
<td><RouterLink to="/">돌아가기</RouterLink></td>
</tr>
</tbody>
</table>
</main>
</template>
<style scoped>
.city-page {
width: 1000px;
margin: 0 auto;
font-size: 14px;
}
.legacy-table {
width: 100%;
border-collapse: collapse;
}
.legacy-table td,
.legacy-table th {
border: 1px solid #777;
padding: 3px;
font-weight: 400;
}
.center {
text-align: center;
}
.selector {
text-align: center;
margin-top: 0;
}
.selector select {
display: inline-block;
min-width: 400px;
}
.stats {
margin-top: 14px;
}
.stats th,
.generals th {
background-image: url('/image/game/back_green.jpg');
text-align: center;
}
.stats td {
text-align: center;
}
.city-title {
text-align: center;
}
.generals {
margin-top: 14px;
}
.generals td {
text-align: center;
}
.generals td:last-child {
text-align: left;
padding-left: 1em;
}
.turns {
font-size: x-small;
}
.footer {
margin-top: 14px;
}
.error {
text-align: center;
color: #ff7373;
}
@media (max-width: 700px) {
.city-page {
width: 1000px;
transform-origin: top left;
}
.selector select {
min-width: 300px;
}
}
</style>
@@ -0,0 +1,243 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import MapViewer from '../components/main/MapViewer.vue';
import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.world.getGlobalInfo.query>>;
type Layout = Awaited<ReturnType<typeof trpc.world.getMapLayout.query>>;
const data = ref<Result | null>(null);
const layout = ref<Layout | null>(null);
const error = ref('');
const state = (value: number) => ({ 0: '★', 1: '▲', 2: '', 7: '@' })[value] ?? 'ㆍ';
const stateClass = (value: number) => `state-${value}`;
const nationMap = computed(() => new Map(data.value?.nations.map((nation) => [nation.id, nation]) ?? []));
onMounted(async () => {
try {
[data.value, layout.value] = await Promise.all([
trpc.world.getGlobalInfo.query(),
trpc.world.getMapLayout.query(),
]);
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '중원 정보를 불러오지 못했습니다.';
}
});
</script>
<template>
<main class="global-page legacy-bg0">
<table class="legacy-title">
<tbody>
<tr>
<td> <br /><RouterLink to="/">돌아가기</RouterLink></td>
</tr>
</tbody>
</table>
<p v-if="error" class="error">{{ error }}</p>
<section v-if="data" class="section">
<h2 class="blue">외교 현황</h2>
<div class="matrix-wrap">
<table class="matrix">
<thead>
<tr>
<th></th>
<th
v-for="nation in data.nations"
:key="nation.id"
class="vertical"
:style="{ backgroundColor: nation.color }"
>
{{ nation.name }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="me in data.nations" :key="me.id">
<th :style="{ backgroundColor: me.color }">{{ me.name }}</th>
<td
v-for="you in data.nations"
:key="you.id"
:class="[
stateClass(data.diplomacy[me.id]?.[you.id] ?? 2),
{ mine: me.id === data.myNationId || you.id === data.myNationId },
]"
>
{{ me.id === you.id ? '' : state(data.diplomacy[me.id]?.[you.id] ?? 2) }}
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td :colspan="data.nations.length + 1">
불가침 : <b class="state-7">@</b>, 통상 : , 선포 : <b class="state-1"></b>, 교전 :
<b class="state-0"></b>
</td>
</tr>
</tfoot>
</table>
</div>
</section>
<section v-if="data?.conflict.length" class="section">
<h2 class="magenta">분쟁 현황</h2>
<div v-for="conflict in data.conflict" :key="conflict.cityId" class="conflict">
<strong>{{ conflict.cityName }}</strong>
<div>
<div v-for="(percent, id) in conflict.nations" :key="id" class="conflict-row">
<span :style="{ backgroundColor: nationMap.get(Number(id))?.color }">{{
nationMap.get(Number(id))?.name
}}</span
><em>{{ percent.toFixed(1) }}%</em
><i :style="{ width: `${percent}%`, backgroundColor: nationMap.get(Number(id))?.color }" />
</div>
</div>
</div>
</section>
<section v-if="data && layout" class="section map-section">
<h2 class="green">중원 지도</h2>
<div class="map-grid">
<MapViewer :map-data="data.map" :map-layout="layout" :loading="false" />
<div class="nation-list">
<div v-for="nation in data.nations" :key="nation.id">
<b :style="{ color: nation.color }">{{ nation.name }}</b> {{ nation.power.toLocaleString()
}}<br /><small>{{ nation.cities.join(', ') }}</small>
</div>
</div>
</div>
</section>
<table class="legacy-title footer">
<tbody>
<tr>
<td><RouterLink to="/">돌아가기</RouterLink></td>
</tr>
</tbody>
</table>
</main>
</template>
<style scoped>
.global-page {
width: 1000px;
margin: 0 auto;
font-size: 14px;
}
.legacy-title {
width: 100%;
border-collapse: collapse;
text-align: center;
}
.legacy-title td {
border: 1px solid #777;
padding: 4px;
}
.section {
margin-top: 21px;
}
.section h2 {
font-size: 16.8px;
font-weight: 400;
text-align: center;
margin: 0;
border-block: 1px solid #777;
padding: 2px;
}
.blue {
background: #00f;
}
.magenta {
background: #f0f;
}
.green {
background: green;
}
.matrix-wrap {
overflow-x: auto;
}
.matrix {
margin: auto;
min-width: 400px;
border-collapse: collapse;
text-align: center;
}
.matrix th {
font-weight: 400;
}
.matrix .vertical {
writing-mode: vertical-rl;
text-align: end;
padding: 1ch 0;
min-width: 1ch;
max-width: 3ch;
}
.matrix tbody th {
padding: 2px 1ch;
text-align: right;
min-width: 10ch;
}
.matrix td {
border-left: 1px solid gray;
border-top: 1px solid gray;
padding: 0;
}
.matrix td.mine {
background: #600;
}
.state-0 {
color: red;
}
.state-1 {
color: #f0f;
}
.state-7 {
color: #32cd32;
}
.conflict {
display: grid;
grid-template-columns: 16ch 1fr;
}
.conflict > strong {
text-align: right;
padding-right: 1ch;
align-self: center;
}
.conflict-row {
display: grid;
grid-template-columns: 16ch 6ch 1fr;
align-items: center;
}
.conflict-row span {
padding-left: 1ch;
}
.conflict-row em {
text-align: right;
padding-right: 0.5ch;
font-style: normal;
}
.conflict-row i {
height: 1.2em;
}
.map-grid {
display: grid;
grid-template-columns: 700px 300px;
}
.nation-list > div {
padding: 6px;
border-bottom: 1px solid #666;
}
.footer {
margin-top: 20px;
}
.error {
text-align: center;
color: #ff7373;
}
@media (max-width: 700px) {
.global-page {
width: 500px;
}
.map-grid {
grid-template-columns: 500px;
}
.nation-list {
width: 500px;
}
}
</style>
+13
View File
@@ -45,6 +45,7 @@ const {
selectedCity,
commandTable,
messages,
boardAccess,
reservedGeneralTurns,
reservedNationTurns,
messageDraftText,
@@ -94,7 +95,14 @@ watch(
<p class="page-subtitle">{{ statusLine }}</p>
</div>
<div class="header-actions">
<RouterLink v-if="boardAccess?.canMeeting" class="ghost" to="/board">회의실</RouterLink>
<span v-else class="ghost disabled" aria-disabled="true">회의실</span>
<RouterLink v-if="boardAccess?.canSecret" class="ghost" to="/board/secret">기밀실</RouterLink>
<span v-else class="ghost disabled" aria-disabled="true">기밀실</span>
<RouterLink class="ghost" to="/nation/info">세력 정보</RouterLink>
<RouterLink class="ghost" to="/nation/cities">세력 도시</RouterLink>
<RouterLink class="ghost" to="/global-info">중원 정보</RouterLink>
<RouterLink class="ghost" to="/current-city">현재 도시</RouterLink>
<RouterLink class="ghost" to="/nation/generals">세력 장수</RouterLink>
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
<RouterLink class="ghost" to="/troop">부대 편성</RouterLink>
@@ -361,6 +369,11 @@ button {
color: #fff;
}
.ghost.disabled {
cursor: not-allowed;
opacity: 0.5;
}
.error {
color: #f5b7b1;
font-size: 0.85rem;
+155 -552
View File
@@ -1,582 +1,185 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { trpc } from '../utils/trpc';
import { computed, onMounted, ref } from 'vue';
import { cityLevelMap, regionMap } from '../utils/nationFormat';
import { trpc } from '../utils/trpc';
type CityOverviewResponse = Awaited<ReturnType<typeof trpc.nation.getCityOverview.query>>;
type CityEntry = CityOverviewResponse['cities'][number];
type GeneralEntry = CityOverviewResponse['generals'][number];
type CitySortKey = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
type OfficerLevel = 2 | 3 | 4;
const officerLabels: Record<OfficerLevel, string> = {
4: '태수',
3: '군사',
2: '종사',
};
const officerLevels: OfficerLevel[] = [4, 3, 2];
const sortOptions: Array<{ key: CitySortKey; label: string }> = [
{ key: 1, label: '기본' },
{ key: 2, label: '인구' },
{ key: 3, label: '인구율' },
{ key: 4, label: '민심' },
{ key: 5, label: '농업' },
{ key: 6, label: '상업' },
{ key: 7, label: '치안' },
{ key: 8, label: '수비' },
{ key: 9, label: '성벽' },
{ key: 10, label: '시세' },
{ key: 11, label: '지역' },
{ key: 12, label: '규모' },
];
const loading = ref(false);
const error = ref<string | null>(null);
const data = ref<CityOverviewResponse | null>(null);
const sortKey = ref<CitySortKey>(1);
const filterText = ref('');
const showAppointment = ref(false);
const appointmentDraft = reactive<Record<number, Record<number, number>>>({});
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
}
if (typeof value === 'string') {
return value;
}
return 'unknown_error';
};
const loadCities = async () => {
if (loading.value) {
return;
}
loading.value = true;
error.value = null;
type Result = Awaited<ReturnType<typeof trpc.nation.getCityOverview.query>>;
type City = Result['cities'][number];
type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
const data = ref<Result | null>(null);
const error = ref('');
const sort = ref<Sort>(10);
const options = ['기본', '인구', '인구율', '민심', '농업', '상업', '치안', '수비', '성벽', '시세', '지역', '규모'];
const generalNames = (cityId: number) =>
data.value?.generals
.filter((g) => g.cityId === cityId)
.map((g) => g.name)
.join(', ') || '-';
const cities = computed(() =>
[...(data.value?.cities ?? [])].sort((a, b) => {
const key = sort.value;
if (key === 1) return a.id - b.id;
if (key === 2) return b.population - a.population;
if (key === 3) return b.population / b.populationMax - a.population / a.populationMax;
if (key === 4) return b.trust - a.trust;
if (key === 5) return b.agriculture - a.agriculture;
if (key === 6) return b.commerce - a.commerce;
if (key === 7) return b.security - a.security;
if (key === 8) return b.defence - a.defence;
if (key === 9) return b.wall - a.wall;
if (key === 10) return (b.trade ?? -1) - (a.trade ?? -1);
if (key === 11) return a.region - b.region || b.level - a.level;
return b.level - a.level || a.region - b.region;
})
);
const officer = (city: City, level: 2 | 3 | 4) => city.officers[level]?.name ?? '-';
onMounted(async () => {
try {
data.value = await trpc.nation.getCityOverview.query();
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
loading.value = false;
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '세력 도시를 불러오지 못했습니다.';
}
};
const cityNameMap = computed(() => {
const map = new Map<number, string>();
if (data.value) {
for (const city of data.value.cities) {
map.set(city.id, city.name);
}
}
return map;
});
const generalMap = computed(() => {
const map = new Map<number, GeneralEntry>();
for (const general of data.value?.generals ?? []) {
map.set(general.id, general);
}
return map;
});
const generalsByCity = computed(() => {
const map = new Map<number, GeneralEntry[]>();
for (const general of data.value?.generals ?? []) {
if (!map.has(general.cityId)) {
map.set(general.cityId, []);
}
map.get(general.cityId)?.push(general);
}
return map;
});
const canAppoint = computed(() => (data.value?.me.officerLevel ?? 0) >= 5);
const candidatesByLevel = computed(() => {
const minStat = data.value?.chiefStatMin ?? 0;
const base = (data.value?.generals ?? []).filter((general) => general.officerLevel !== 12);
return {
4: base.filter((general) => general.stats.strength >= minStat),
3: base.filter((general) => general.stats.intelligence >= minStat),
2: base,
} as Record<OfficerLevel, GeneralEntry[]>;
});
const formatCandidateLabel = (general: GeneralEntry): string => {
const cityName = cityNameMap.value.get(general.cityId);
const role = general.officerLevel >= 5 ? '수뇌' : general.officerLevel >= 2 ? '관직' : '일반';
return `${general.name}${cityName ? ` (${cityName})` : ''} · ${role}`;
};
const ensureDraft = (cityId: number, level: OfficerLevel, defaultValue: number) => {
if (!appointmentDraft[cityId]) {
appointmentDraft[cityId] = { 2: 0, 3: 0, 4: 0 };
}
if (appointmentDraft[cityId][level] === undefined) {
appointmentDraft[cityId][level] = defaultValue;
}
};
const resetDrafts = (cities: CityEntry[]) => {
for (const key of Object.keys(appointmentDraft)) {
delete appointmentDraft[Number(key)];
}
for (const city of cities) {
appointmentDraft[city.id] = {
2: city.officers[2]?.id ?? 0,
3: city.officers[3]?.id ?? 0,
4: city.officers[4]?.id ?? 0,
};
}
};
watch(
() => data.value,
(value) => {
if (value) {
resetDrafts(value.cities);
}
}
);
const sortedCities = computed(() => {
const list = data.value?.cities ?? [];
const keyword = filterText.value.trim();
const filtered = keyword
? list.filter((city) => city.name.includes(keyword))
: list;
return [...filtered].sort((lhs, rhs) => {
switch (sortKey.value) {
case 2:
return rhs.population - lhs.population;
case 3:
return rhs.population / rhs.populationMax - lhs.population / lhs.populationMax;
case 4:
return rhs.trust - lhs.trust;
case 5:
return rhs.agriculture - lhs.agriculture;
case 6:
return rhs.commerce - lhs.commerce;
case 7:
return rhs.security - lhs.security;
case 8:
return rhs.defence - lhs.defence;
case 9:
return rhs.wall - lhs.wall;
case 10:
return (rhs.trade ?? -1) - (lhs.trade ?? -1);
case 11: {
const regionCmp = lhs.region - rhs.region;
if (regionCmp !== 0) {
return regionCmp;
}
return rhs.level - lhs.level;
}
case 12: {
const levelCmp = rhs.level - lhs.level;
if (levelCmp !== 0) {
return levelCmp;
}
return lhs.region - rhs.region;
}
default:
return lhs.id - rhs.id;
}
});
});
const formatPercent = (value: number, max: number): string => {
if (!max) {
return '-';
}
return `${((value / max) * 100).toFixed(1)}%`;
};
const formatNumber = (value: number): string => new Intl.NumberFormat('ko-KR').format(value);
const resolveOfficerName = (officer: CityEntry['officers'][OfficerLevel]): string => {
if (!officer) {
return '-';
}
if (officer.cityName) {
return `${officer.name} (${officer.cityName})`;
}
return officer.name;
};
const appointOfficer = async (cityId: number, officerLevel: OfficerLevel) => {
if (!data.value) {
return;
}
const city = data.value.cities.find((entry) => entry.id === cityId);
if (!city) {
return;
}
const destGeneralId = appointmentDraft[cityId]?.[officerLevel] ?? 0;
const general = generalMap.value.get(destGeneralId);
const officerLabel = officerLabels[officerLevel];
const targetName = destGeneralId === 0 ? '공석' : general?.name ?? '알 수 없음';
const message =
destGeneralId === 0
? `${city.name} ${officerLabel} 자리를 비우시겠습니까?`
: `${targetName}을(를) ${city.name} ${officerLabel}로 임명하시겠습니까?`;
if (!window.confirm(message)) {
return;
}
try {
await trpc.nation.appoint.mutate({
destGeneralId,
destCityId: cityId,
officerLevel,
});
await loadCities();
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
onMounted(() => {
void loadCities();
});
</script>
<template>
<main class="nation-page">
<header class="page-header">
<div>
<h1 class="page-title">세력 도시</h1>
<p class="page-subtitle"> 도시 현황과 관직 배치를 확인합니다.</p>
</div>
<div class="header-actions">
<RouterLink class="ghost" to="/">메인</RouterLink>
<RouterLink class="ghost" to="/nation/generals">세력 장수</RouterLink>
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
<button class="ghost" @click="loadCities">새로고침</button>
</div>
</header>
<div v-if="error" class="error">{{ error }}</div>
<PanelCard title="세력 도시 목록" subtitle="도시별 개발 상태 및 관직 현황">
<template #actions>
<div class="toolbar-actions">
<select v-model.number="sortKey" class="select-input">
<option v-for="option in sortOptions" :key="option.key" :value="option.key">
{{ option.label }}
</option>
</select>
<input v-model="filterText" class="filter-input" placeholder="도시 검색" />
<button
v-if="canAppoint"
class="ghost"
:class="{ active: showAppointment }"
@click="showAppointment = !showAppointment"
>
관직 임명 모드
</button>
</div>
</template>
<div class="list-meta">
{{ sortedCities.length }} 도시 · 관직 최소 능력 {{ data?.chiefStatMin ?? '-' }}
</div>
<SkeletonLines v-if="loading" :lines="8" />
<section v-else class="city-grid">
<article v-for="city in sortedCities" :key="city.id" class="city-card">
<div class="city-header">
<div>
<div class="city-title">
{{ city.name }}
<span v-if="data?.nation.capitalCityId === city.id" class="capital-tag">수도</span>
</div>
<div class="city-meta">
{{ regionMap[city.region] ?? '미지' }} · {{ cityLevelMap[city.level] ?? '-' }} 규모
</div>
</div>
<div class="city-meta">시세 {{ city.trade === null ? '- ' : city.trade }}%</div>
</div>
<div class="city-stats">
<div>
주민 {{ city.population }}/{{ city.populationMax }}
<span class="muted">({{ formatPercent(city.population, city.populationMax) }})</span>
</div>
<div>농업 {{ city.agriculture }}/{{ city.agricultureMax }}</div>
<div>상업 {{ city.commerce }}/{{ city.commerceMax }}</div>
<div>치안 {{ city.security }}/{{ city.securityMax }}</div>
<div>수비 {{ city.defence }}/{{ city.defenceMax }}</div>
<div>성벽 {{ city.wall }}/{{ city.wallMax }}</div>
<div>민심 {{ city.trust }}</div>
</div>
<div class="city-incomes">
<div>자금 수입 {{ formatNumber(city.incomes.gold) }}</div>
<div>군량 수입 {{ formatNumber(city.incomes.rice) }}</div>
<div>둔전 수입 {{ formatNumber(city.incomes.wall) }}</div>
</div>
<div class="city-officers">
<div>
<span class="officer-label">태수</span>
{{ resolveOfficerName(city.officers[4]) }}
</div>
<div>
<span class="officer-label">군사</span>
{{ resolveOfficerName(city.officers[3]) }}
</div>
<div>
<span class="officer-label">종사</span>
{{ resolveOfficerName(city.officers[2]) }}
</div>
</div>
<div v-if="showAppointment && canAppoint" class="city-appoint">
<div v-for="level in officerLevels" :key="level" class="appoint-row">
<span class="officer-label">{{ officerLabels[level] }}</span>
<select
v-model.number="appointmentDraft[city.id][level]"
class="select-input"
@focus="ensureDraft(city.id, level, city.officers[level]?.id ?? 0)"
>
<option :value="0">공석</option>
<option
v-for="candidate in candidatesByLevel[level]"
:key="candidate.id"
:value="candidate.id"
>
{{ formatCandidateLabel(candidate) }}
</option>
</select>
<button class="ghost" @click="appointOfficer(city.id, level)">임명</button>
</div>
</div>
<div class="city-generals">
<div class="muted">장수</div>
<div class="general-tags">
<span
v-for="general in generalsByCity.get(city.id) ?? []"
:key="general.id"
class="general-tag"
>
<span v-if="general.npcState > 0" class="npc-tag">NPC</span>
{{ general.name }}
</span>
<span v-if="(generalsByCity.get(city.id) ?? []).length === 0" class="muted">-</span>
</div>
</div>
</article>
</section>
</PanelCard>
<main class="nation-cities-page">
<table class="legacy-table legacy-bg0 title">
<tbody>
<tr>
<td> <br /><RouterLink to="/">돌아가기</RouterLink></td>
</tr>
<tr>
<td>
정렬순서 :
<select v-model.number="sort">
<option v-for="(label, index) in options" :key="label" :value="index + 1">
{{ label }}
</option>
</select>
<button class="legacy-button">정렬하기</button>
</td>
</tr>
</tbody>
</table>
<p v-if="error" class="error">{{ error }}</p>
<table v-for="city in cities" :key="city.id" class="legacy-table city legacy-bg2">
<tbody>
<tr>
<td colspan="10" class="city-title" :style="{ backgroundColor: data?.nation.color }">
{{ regionMap[city.region] }} | {{ cityLevelMap[city.level] }}
<span :class="{ capital: city.id === data?.nation.capitalCityId }">{{
city.id === data?.nation.capitalCityId ? `[${city.name}]` : city.name
}}</span>
</td>
</tr>
<tr>
<th>주민</th>
<td>{{ city.population }}/{{ city.populationMax }}</td>
<th>인구율</th>
<td>{{ ((city.population / city.populationMax) * 100).toFixed(2) }}%</td>
<th>자금 수입</th>
<td>{{ city.incomes.gold.toLocaleString() }}</td>
<th>군량 수입</th>
<td>{{ city.incomes.rice.toLocaleString() }}</td>
<th>둔전 수입</th>
<td>{{ city.incomes.wall.toLocaleString() }}</td>
</tr>
<tr>
<th>농업</th>
<td>{{ city.agriculture }}/{{ city.agricultureMax }}</td>
<th>상업</th>
<td>{{ city.commerce }}/{{ city.commerceMax }}</td>
<th>치안</th>
<td>{{ city.security }}/{{ city.securityMax }}</td>
<th>수비</th>
<td>{{ city.defence }}/{{ city.defenceMax }}</td>
<th>성벽</th>
<td>{{ city.wall }}/{{ city.wallMax }}</td>
</tr>
<tr>
<th>민심</th>
<td>{{ city.trust.toFixed(1) }}</td>
<th>시세</th>
<td>{{ city.trade ?? '-' }}%</td>
<th>태수</th>
<td>{{ officer(city, 4) }}</td>
<th>군사</th>
<td>{{ officer(city, 3) }}</td>
<th>종사</th>
<td>{{ officer(city, 2) }}</td>
</tr>
<tr>
<th>장수</th>
<td colspan="9" class="general-list">{{ generalNames(city.id) }}</td>
</tr>
</tbody>
</table>
<table class="legacy-table legacy-bg0 title footer">
<tbody>
<tr>
<td><RouterLink to="/">돌아가기</RouterLink></td>
</tr>
</tbody>
</table>
</main>
</template>
<style scoped>
.nation-page {
min-height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
.nation-cities-page {
width: 1000px;
margin: 0 auto;
font-size: 14px;
}
.page-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
padding-bottom: 12px;
.legacy-table {
width: 100%;
border-collapse: collapse;
}
.page-title {
font-size: 1.6rem;
font-weight: 600;
.legacy-table td,
.legacy-table th {
border: 1px solid #777;
padding: 3px;
font-weight: 400;
}
.page-subtitle {
font-size: 0.85rem;
color: rgba(232, 221, 196, 0.7);
.title {
text-align: center;
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
.city {
margin-top: 14px;
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 6px 12px;
font-size: 0.8rem;
cursor: pointer;
text-decoration: none;
color: inherit;
background: rgba(16, 16, 16, 0.6);
.city th {
width: 60px;
text-align: center;
background-image: url('/image/game/back_green.jpg');
}
.ghost.active {
background: rgba(201, 164, 90, 0.2);
.city td {
width: 140px;
text-align: center;
}
.error {
color: #f5b7b1;
font-size: 0.85rem;
}
.toolbar-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.select-input {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(16, 16, 16, 0.8);
color: rgba(232, 221, 196, 0.9);
padding: 6px 8px;
font-size: 0.75rem;
}
.filter-input {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(16, 16, 16, 0.8);
color: rgba(232, 221, 196, 0.9);
padding: 6px 8px;
font-size: 0.75rem;
}
.list-meta {
margin-bottom: 12px;
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.6);
}
.city-grid {
display: grid;
gap: 12px;
}
.city-card {
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 12px;
background: rgba(12, 12, 12, 0.75);
display: flex;
flex-direction: column;
gap: 10px;
}
.city-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.city-title {
font-size: 1rem;
font-weight: 600;
text-align: left !important;
}
.city-meta {
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.6);
.general-list {
text-align: left !important;
}
.capital-tag {
margin-left: 6px;
font-size: 0.65rem;
padding: 2px 6px;
border: 1px solid rgba(201, 164, 90, 0.4);
color: rgba(232, 221, 196, 0.8);
.capital {
color: #0ff;
}
.city-stats,
.city-incomes,
.city-officers {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 6px;
font-size: 0.8rem;
.footer {
margin-top: 14px;
}
.city-incomes {
padding-top: 6px;
border-top: 1px solid rgba(201, 164, 90, 0.2);
.legacy-button {
padding: 1px 6px;
font-weight: 400;
}
.city-officers {
border-top: 1px solid rgba(201, 164, 90, 0.2);
padding-top: 6px;
.error {
text-align: center;
color: #ff7373;
}
.officer-label {
font-weight: 600;
margin-right: 6px;
}
.city-appoint {
display: flex;
flex-direction: column;
gap: 6px;
border-top: 1px dashed rgba(201, 164, 90, 0.2);
padding-top: 8px;
}
.appoint-row {
display: grid;
grid-template-columns: 60px minmax(0, 1fr) auto;
gap: 6px;
align-items: center;
}
.city-generals {
display: flex;
flex-direction: column;
gap: 6px;
}
.general-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.general-tag {
padding: 3px 6px;
border: 1px solid rgba(201, 164, 90, 0.3);
font-size: 0.75rem;
display: inline-flex;
align-items: center;
gap: 4px;
}
.npc-tag {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.6rem;
padding: 1px 3px;
border: 1px solid rgba(201, 164, 90, 0.4);
color: rgba(232, 221, 196, 0.8);
}
.muted {
color: rgba(232, 221, 196, 0.6);
@media (max-width: 700px) {
.nation-cities-page {
width: 1000px;
transform-origin: top left;
}
}
</style>
@@ -0,0 +1,177 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.nation.getNationInfo.query>>;
const data = ref<Result | null>(null);
const error = ref('');
const number = (value: number) => value.toLocaleString('ko-KR');
const diff = (value: number) => `${value > 0 ? '+' : ''}${number(value)}`;
const nationLevel = computed(
() => ['두목', '영주', '군벌', '주자사', '주목', '공', '왕', '황제'][data.value?.nation.level ?? 0] ?? '-'
);
onMounted(async () => {
try {
data.value = await trpc.nation.getNationInfo.query();
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '세력 정보를 불러오지 못했습니다.';
}
});
</script>
<template>
<main class="legacy-info-page">
<table class="legacy-table title-table legacy-bg0">
<tbody>
<tr>
<td> <br /><RouterLink to="/">돌아가기</RouterLink></td>
</tr>
</tbody>
</table>
<p v-if="error" class="error">{{ error }}</p>
<table v-if="data" class="legacy-table info-table legacy-bg2">
<tbody>
<tr>
<td colspan="8" class="nation-title" :style="{ backgroundColor: data.nation.color }">
{{ data.nation.name }}
</td>
</tr>
<tr>
<th>총주민</th>
<td>{{ number(data.population.current) }}/{{ number(data.population.max) }}</td>
<th>총병사</th>
<td>{{ number(data.crew.current) }}/{{ number(data.crew.max) }}</td>
<th> </th>
<td colspan="3">{{ data.nation.power }}</td>
</tr>
<tr>
<th> </th>
<td>{{ number(data.nation.gold) }}</td>
<th> </th>
<td>{{ number(data.nation.rice) }}</td>
<th> </th>
<td colspan="3">{{ data.nation.rate }} %</td>
</tr>
<tr>
<th>세금/단기</th>
<td>+{{ number(data.income.goldCity) }} / +{{ number(data.income.goldWar) }}</td>
<th>세곡/둔전</th>
<td>+{{ number(data.income.riceCity) }} / +{{ number(data.income.riceWall) }}</td>
<th>지급률</th>
<td colspan="3">{{ data.nation.bill }} %</td>
</tr>
<tr>
<th>수입/지출</th>
<td>+{{ number(data.income.goldTotal) }} / -{{ number(data.income.outcome) }}</td>
<th>수입/지출</th>
<td>+{{ number(data.income.riceTotal) }} / -{{ number(data.income.outcome) }}</td>
<th> </th>
<td>{{ data.cities.length }}</td>
<th> </th>
<td>{{ data.nation.generalCount }}</td>
</tr>
<tr>
<th>국고 예산</th>
<td>{{ number(data.budget.gold) }} ({{ diff(data.income.goldTotal - data.income.outcome) }})</td>
<th>병량 예산</th>
<td>{{ number(data.budget.rice) }} ({{ diff(data.income.riceTotal - data.income.outcome) }})</td>
<th>기술력</th>
<td>{{ number(data.nation.tech) }}</td>
<th> </th>
<td>{{ nationLevel }}</td>
</tr>
<tr>
<th>속령일람 :</th>
<td colspan="7">
<template v-for="(city, index) in data.cities" :key="city.id"
><span v-if="city.capital" class="capital">{{ city.name }}</span
><span v-else>{{ city.name }}</span
><span v-if="index + 1 < data.cities.length">, </span></template
>
</td>
</tr>
<tr>
<th>국가열전</th>
<td colspan="7" class="history legacy-bg0">
<div v-for="entry in data.history" :key="entry.id">
{{ entry.year }} {{ entry.month }}: {{ entry.text }}
</div>
<span v-if="!data.history.length">-</span>
</td>
</tr>
</tbody>
</table>
<table class="legacy-table footer-table legacy-bg0">
<tbody>
<tr>
<td><RouterLink to="/">돌아가기</RouterLink></td>
</tr>
</tbody>
</table>
</main>
</template>
<style scoped>
.legacy-info-page {
width: 1000px;
margin: 0 auto;
font-size: 14px;
}
.legacy-table {
width: 100%;
border-collapse: collapse;
}
.legacy-table td,
.legacy-table th {
border: 1px solid #777;
padding: 4px;
font-weight: 400;
}
.title-table,
.footer-table {
text-align: center;
}
.title-table {
margin-bottom: 14px;
}
.footer-table {
margin-top: 14px;
}
.info-table th {
width: 98px;
text-align: center;
background-image: url('/image/game/back_green.jpg');
}
.info-table td {
text-align: center;
}
.info-table td:nth-child(2),
.info-table td:nth-child(4) {
width: 198px;
}
.nation-title {
text-align: center;
}
.capital {
color: #0ff;
}
.history {
text-align: left !important;
}
.error {
color: #ff7373;
text-align: center;
}
@media (max-width: 700px) {
.legacy-info-page {
width: 500px;
}
.info-table {
font-size: 12px;
}
.info-table td,
.info-table th {
padding: 3px;
}
}
</style>
+579 -762
View File
@@ -1,865 +1,682 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { computed, onMounted, ref } from 'vue';
import { trpc } from '../utils/trpc';
type VoteListResponse = Awaited<ReturnType<typeof trpc.vote.getVoteList.query>>;
type VoteDetailResponse = Awaited<ReturnType<typeof trpc.vote.getVoteDetail.query>>;
type RevealMode = 'after_vote' | 'after_end';
type VoteDetail = Awaited<ReturnType<typeof trpc.vote.getVoteDetail.query>>;
type PollSummary = VoteListResponse['polls'][number];
type VoteDetail = VoteDetailResponse;
type VoteResultEntry = VoteDetail['votes'][number];
const loading = ref(false);
const detailLoading = ref(false);
const error = ref<string | null>(null);
const polls = ref<PollSummary[]>([]);
const voteReward = ref(0);
const activeVoteId = ref<number | null>(null);
const voteDetail = ref<VoteDetail | null>(null);
const adminEnabled = ref(false);
const currentVoteId = ref<number | null>(null);
const currentVote = ref<VoteDetail | null>(null);
const loading = ref(false);
const detailLoading = ref(false);
const isVoteAdmin = ref(false);
const showNewVote = ref(false);
const message = ref('');
const messageKind = ref<'success' | 'error'>('success');
const mySinglePick = ref(0);
const myMultiPick = ref<number[]>([]);
const myComment = ref('');
const newVoteTitle = ref('');
const newVoteOptionsText = ref('');
const newVoteMultipleOptions = ref(1);
const selectionSingle = ref<number | null>(null);
const selectionMulti = ref<number[]>([]);
const commentDraft = ref('');
const actionMessage = ref<string | null>(null);
const newPollTitle = ref('');
const newPollBody = ref('');
const newPollOptionsText = ref('');
const newPollMultipleOptions = ref(1);
const newPollEndAt = ref('');
const newPollRevealMode = ref<RevealMode>('after_vote');
const newPollClosePrevious = ref(true);
const updateTitle = ref('');
const updateBody = ref('');
const updateOptionsText = ref('');
const updateMultipleOptions = ref<number | null>(null);
const updateEndAt = ref('');
const updateRevealMode = ref<RevealMode>('after_vote');
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
const getErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
return error.message;
}
if (typeof value === 'string') {
return value;
}
return 'unknown_error';
return typeof error === 'string' ? error : '요청을 처리하지 못했습니다.';
};
const formatDate = (value: string | null): string => {
if (!value) {
return '-';
const showMessage = (text: string, kind: 'success' | 'error') => {
message.value = text;
messageKind.value = kind;
};
const isEnded = (poll: { endAt: string | null; closedAt: string | null }): boolean => {
if (poll.closedAt) {
return true;
}
return poll.endAt ? new Date(poll.endAt).getTime() < Date.now() : false;
};
const canVote = computed(
() => Boolean(currentVote.value) && !currentVote.value?.myVote && !isEnded(currentVote.value!.voteInfo)
);
const voteTotal = computed(() => (currentVote.value?.votes ?? []).reduce((total, vote) => total + vote.count, 0));
const voteDistribution = computed(() => {
const result = Array.from({ length: currentVote.value?.voteInfo.options.length ?? 0 }, () => 0);
for (const vote of currentVote.value?.votes ?? []) {
for (const selection of vote.selection) {
if (selection >= 0 && selection < result.length) {
result[selection] += vote.count;
}
}
}
return result;
});
const newVoteOptions = computed(() => newVoteOptionsText.value.split('\n').filter((option) => option.length > 0));
const percentage = (count: number, total: number): string => ((count / Math.max(1, total)) * 100).toFixed(1);
const formatStartDate = (value: string): string => value.slice(0, 10);
const formatCommentDate = (value: string): string => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString('ko-KR');
const pad = (part: number) => String(part).padStart(2, '0');
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
const parseOptionsText = (text: string): string[] =>
text
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
const voteColor = (index: number): string =>
['#ff0000', '#ffa500', '#ffff00', '#008000', '#0000ff', '#000080', '#800080'][index % 7]!;
const resolveDateInput = (value: string): string | undefined => {
if (!value) {
return undefined;
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return undefined;
}
return parsed.toISOString();
};
const isPollEnded = (poll: { endAt: string | null; closedAt: string | null }): boolean => {
if (poll.closedAt) {
return true;
}
if (!poll.endAt) {
return false;
}
const endDate = new Date(poll.endAt);
if (Number.isNaN(endDate.getTime())) {
return false;
}
return endDate <= new Date();
};
const currentPoll = computed(() => {
if (!polls.value.length) {
return null;
}
const selected = polls.value.find((poll) => poll.id === activeVoteId.value);
return selected ?? polls.value[0] ?? null;
});
const revealLabel = computed(() => {
if (!voteDetail.value) {
return '';
}
return voteDetail.value.voteInfo.revealMode === 'after_vote' ? '투표 후 공개' : '종료 후 공개';
});
const pollEnded = computed(() => {
if (!voteDetail.value) {
return false;
}
return isPollEnded({
endAt: voteDetail.value.voteInfo.endAt,
closedAt: voteDetail.value.voteInfo.closedAt,
});
});
const canVote = computed(() => {
if (!voteDetail.value) {
return false;
}
if (voteDetail.value.myVote) {
return false;
}
return !pollEnded.value;
});
const canReveal = computed(() => {
if (!voteDetail.value) {
return false;
}
if (voteDetail.value.voteInfo.revealMode === 'after_vote') {
return Boolean(voteDetail.value.myVote) || pollEnded.value;
}
return pollEnded.value;
});
const isSingleChoice = computed(() => voteDetail.value?.voteInfo.multipleOptions === 1);
const voteDistribution = computed(() => {
if (!voteDetail.value) {
return [] as number[];
}
const optionCount = voteDetail.value.voteInfo.options.length;
const counts = Array.from({ length: optionCount }, () => 0);
for (const entry of voteDetail.value.votes as VoteResultEntry[]) {
for (const index of entry.selection) {
if (index >= 0 && index < counts.length) {
counts[index] += entry.count;
}
}
}
return counts;
});
const voteTotal = computed(() =>
(voteDetail.value?.votes ?? []).reduce((sum, entry) => sum + entry.count, 0)
);
const selectPoll = (pollId: number) => {
if (activeVoteId.value === pollId) {
return;
}
activeVoteId.value = pollId;
};
const loadVoteList = async () => {
if (loading.value) {
return;
}
loading.value = true;
error.value = null;
try {
const result = await trpc.vote.getVoteList.query();
polls.value = result.polls;
voteReward.value = result.voteReward ?? 0;
if (!activeVoteId.value || !result.polls.some((poll) => poll.id === activeVoteId.value)) {
const openPoll = result.polls.find((poll) => !isPollEnded(poll));
activeVoteId.value = openPoll?.id ?? result.polls[0]?.id ?? null;
}
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
loading.value = false;
}
};
const voteColorText = (index: number): string => ([1, 2].includes(index % 7) ? '#000' : '#fff');
const loadVoteDetail = async (voteId: number) => {
if (detailLoading.value) {
return;
}
detailLoading.value = true;
error.value = null;
actionMessage.value = null;
try {
const result = await trpc.vote.getVoteDetail.query({ voteId });
voteDetail.value = result;
if (result.myVote && result.myVote.length > 0) {
if (result.voteInfo.multipleOptions === 1) {
selectionSingle.value = result.myVote[0] ?? null;
selectionMulti.value = [...result.myVote];
} else {
selectionSingle.value = null;
selectionMulti.value = [...result.myVote];
}
} else {
selectionSingle.value = null;
selectionMulti.value = [];
}
commentDraft.value = '';
updateTitle.value = result.voteInfo.title;
updateBody.value = result.voteInfo.body;
updateMultipleOptions.value = result.voteInfo.multipleOptions;
updateRevealMode.value = result.voteInfo.revealMode;
updateEndAt.value = result.voteInfo.endAt ? result.voteInfo.endAt.slice(0, 16) : '';
} catch (err) {
error.value = resolveErrorMessage(err);
const detail = await trpc.vote.getVoteDetail.query({ voteId });
currentVote.value = detail;
currentVoteId.value = voteId;
mySinglePick.value = detail.myVote?.[0] ?? 0;
myMultiPick.value = detail.myVote ? [...detail.myVote] : [];
myComment.value = '';
} catch (error) {
showMessage(getErrorMessage(error), 'error');
} finally {
detailLoading.value = false;
}
};
const refreshAll = async () => {
await loadVoteList();
if (activeVoteId.value) {
await loadVoteDetail(activeVoteId.value);
const reloadVote = async () => {
if (loading.value) {
return;
}
loading.value = true;
message.value = '';
try {
const result = await trpc.vote.getVoteList.query();
polls.value = result.polls;
voteReward.value = result.voteReward;
const nextVoteId =
(currentVoteId.value && result.polls.some((poll) => poll.id === currentVoteId.value)
? currentVoteId.value
: result.polls[0]?.id) ?? null;
if (nextVoteId) {
await loadVoteDetail(nextVoteId);
} else {
currentVoteId.value = null;
currentVote.value = null;
}
} catch (error) {
showMessage(getErrorMessage(error), 'error');
} finally {
loading.value = false;
}
};
const selectVote = (voteId: number) => {
if (voteId !== currentVoteId.value) {
void loadVoteDetail(voteId);
}
};
const changeMultiPick = (index: number, checked: boolean) => {
const limit = currentVote.value?.voteInfo.multipleOptions ?? 0;
if (checked && limit > 0 && myMultiPick.value.length > limit) {
myMultiPick.value = myMultiPick.value.filter((value) => value !== index);
showMessage(`${limit}개까지만 선택할 수 있습니다.`, 'error');
}
};
const submitVote = async () => {
if (!voteDetail.value) {
if (!currentVote.value) {
return;
}
const optionLimit = voteDetail.value.voteInfo.multipleOptions;
const selected = isSingleChoice.value
? selectionSingle.value !== null
? [selectionSingle.value]
: []
: [...selectionMulti.value];
if (selected.length === 0) {
actionMessage.value = '선택한 항목이 없습니다.';
const selection = currentVote.value.voteInfo.multipleOptions === 1 ? [mySinglePick.value] : [...myMultiPick.value];
if (selection.length === 0) {
showMessage('선택한 항목이 없습니다.', 'error');
return;
}
if (optionLimit >= 1 && selected.length > optionLimit) {
actionMessage.value = '선택한 항목이 너무 많습니다.';
return;
}
actionMessage.value = null;
try {
const result = await trpc.vote.submitVote.mutate({
voteId: voteDetail.value.voteInfo.id,
selection: selected,
voteId: currentVote.value.voteInfo.id,
selection,
});
actionMessage.value = result.wonLottery
? '투표 완료! 유니크 추첨에 당첨되었습니다.'
: '투표가 완료되었습니다.';
await refreshAll();
} catch (err) {
actionMessage.value = resolveErrorMessage(err);
showMessage(result.wonLottery ? '특별한 설문 보상이 제공되었습니다!' : '설문을 마쳤습니다.', 'success');
await loadVoteDetail(currentVote.value.voteInfo.id);
} catch (error) {
showMessage(getErrorMessage(error), 'error');
}
};
const submitComment = async () => {
if (!voteDetail.value) {
if (!currentVote.value || myComment.value.length === 0) {
return;
}
const text = commentDraft.value.trim();
if (!text) {
return;
}
actionMessage.value = null;
try {
await trpc.vote.addComment.mutate({
voteId: voteDetail.value.voteInfo.id,
text,
voteId: currentVote.value.voteInfo.id,
text: myComment.value,
});
commentDraft.value = '';
await loadVoteDetail(voteDetail.value.voteInfo.id);
} catch (err) {
actionMessage.value = resolveErrorMessage(err);
myComment.value = '';
showMessage('댓글을 달았습니다.', 'success');
await loadVoteDetail(currentVote.value.voteInfo.id);
} catch (error) {
showMessage(getErrorMessage(error), 'error');
}
};
const createPoll = async () => {
const options = parseOptionsText(newPollOptionsText.value);
const endAt = resolveDateInput(newPollEndAt.value);
actionMessage.value = null;
const submitNewVote = async () => {
try {
await trpc.vote.createPoll.mutate({
title: newPollTitle.value.trim(),
body: newPollBody.value.trim(),
options,
multipleOptions: newPollMultipleOptions.value,
endAt,
revealMode: newPollRevealMode.value,
closePrevious: newPollClosePrevious.value,
title: newVoteTitle.value,
body: '',
options: newVoteOptions.value,
multipleOptions: newVoteMultipleOptions.value,
revealMode: 'after_vote',
closePrevious: true,
});
newPollTitle.value = '';
newPollBody.value = '';
newPollOptionsText.value = '';
newPollMultipleOptions.value = 1;
newPollEndAt.value = '';
newPollRevealMode.value = 'after_vote';
newPollClosePrevious.value = true;
await refreshAll();
} catch (err) {
actionMessage.value = resolveErrorMessage(err);
}
};
const updatePoll = async () => {
if (!voteDetail.value) {
return;
}
const appendOptions = parseOptionsText(updateOptionsText.value);
const endAt = resolveDateInput(updateEndAt.value);
actionMessage.value = null;
try {
await trpc.vote.updatePoll.mutate({
voteId: voteDetail.value.voteInfo.id,
title: updateTitle.value.trim() || undefined,
body: updateBody.value.trim() || undefined,
appendOptions: appendOptions.length > 0 ? appendOptions : undefined,
multipleOptions: updateMultipleOptions.value ?? undefined,
endAt,
revealMode: updateRevealMode.value,
});
updateOptionsText.value = '';
await refreshAll();
} catch (err) {
actionMessage.value = resolveErrorMessage(err);
}
};
const closePoll = async () => {
if (!voteDetail.value) {
return;
}
actionMessage.value = null;
try {
await trpc.vote.closePoll.mutate({ voteId: voteDetail.value.voteInfo.id });
await refreshAll();
} catch (err) {
actionMessage.value = resolveErrorMessage(err);
showMessage('설문 조사가 생성되었습니다.', 'success');
newVoteTitle.value = '';
newVoteOptionsText.value = '';
newVoteMultipleOptions.value = 1;
showNewVote.value = false;
await reloadVote();
} catch (error) {
showMessage(getErrorMessage(error), 'error');
}
};
onMounted(() => {
void refreshAll();
void trpc.vote.getAdminStatus.query().then((result) => {
adminEnabled.value = Boolean(result?.ok);
}).catch(() => {
adminEnabled.value = false;
});
});
watch(activeVoteId, (voteId) => {
if (voteId) {
void loadVoteDetail(voteId);
}
void reloadVote();
void trpc.vote.getAdminStatus
.query()
.then((result) => {
isVoteAdmin.value = result.ok === true;
})
.catch(() => {
isVoteAdmin.value = false;
});
});
</script>
<template>
<main class="survey-view">
<header class="page-header">
<div>
<h1 class="page-title">설문조사</h1>
<p class="page-subtitle">투표 참여 보상과 유니크 추첨이 함께 진행됩니다.</p>
</div>
<div class="header-actions">
<RouterLink class="ghost" to="/">메인으로</RouterLink>
<button class="ghost" @click="refreshAll" :disabled="loading">새로고침</button>
</div>
<main id="container" class="pageVote bg0">
<header class="back_bar bg0">
<RouterLink class="btn btn-sammo-base2 back_btn" to="/"> 닫기</RouterLink>
<button class="btn btn-sammo-base2 reload_btn" type="button" :disabled="loading" @click="reloadVote">
갱신
</button>
<h2 class="title"></h2>
<div>&nbsp;</div>
<div></div>
</header>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="actionMessage" class="notice">{{ actionMessage }}</div>
<div
v-if="message"
class="vote-notice"
:class="messageKind"
:role="messageKind === 'error' ? 'alert' : 'status'"
>
{{ message }}
</div>
<div id="vote-title" class="bg2">설문 조사({{ voteReward }}금과 추첨으로 유니크템 증정!)</div>
<section class="survey-grid">
<div class="survey-main">
<PanelCard title="보상 안내">
<div class="reward-block">
<div>투표 참여 보상: <strong>{{ voteReward }}</strong> </div>
<div>추첨 보상: 유니크 장비 1</div>
</div>
</PanelCard>
<div v-if="detailLoading && !currentVote" class="loading">불러오는 중...</div>
<table v-if="currentVote" id="vote-result">
<colgroup>
<col class="vote-idx" />
<col class="vote-count" />
<col class="vote-percent" />
<col class="vote-option" />
</colgroup>
<thead>
<tr>
<th colspan="3" class="text-end bg1">설문 제목</th>
<th id="vote-detail-title">
{{ currentVote.voteInfo.title }}
<template v-if="currentVote.voteInfo.multipleOptions !== 1">
({{
currentVote.voteInfo.multipleOptions === 0
? currentVote.voteInfo.options.length
: currentVote.voteInfo.multipleOptions
}} 선택 가능 )
</template>
</th>
</tr>
<tr>
<th colspan="3" class="text-end bg1">게시자</th>
<th id="vote-detail-opener">{{ currentVote.voteInfo.openerName || '[SYSTEM]' }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(option, index) in currentVote.voteInfo.options" :key="index">
<td v-if="canVote" class="text-center">
<input
v-if="currentVote.voteInfo.multipleOptions === 1"
:id="`v-vote-${index}`"
v-model="mySinglePick"
class="form-check-input"
type="radio"
:value="index"
/>
<input
v-else
:id="`v-vote-${index}`"
v-model="myMultiPick"
class="form-check-input"
type="checkbox"
:value="index"
@change="changeMultiPick(index, ($event.target as HTMLInputElement).checked)"
/>
</td>
<td
v-else
class="text-end f_tnum"
:style="{ backgroundColor: voteColor(index), color: voteColorText(index) }"
>
{{ index + 1 }}.
</td>
<td class="text-end f_tnum vote-count">
<label :for="`v-vote-${index}`">{{ voteDistribution[index] }}</label>
</td>
<td class="text-end f_tnum vote-percent">
<label :for="`v-vote-${index}`">
({{ percentage(voteDistribution[index] ?? 0, voteTotal) }}%)
</label>
</td>
<td>
<label :for="`v-vote-${index}`">{{ option }}</label>
</td>
</tr>
</tbody>
<tfoot>
<tr>
<template v-if="canVote">
<td class="text-center">투표</td>
<td colspan="2">
<button class="btn btn-primary vote-submit" @click="submitVote">투표</button>
</td>
</template>
<td v-else colspan="3" class="text-center">결산</td>
<td>
투표율: {{ voteTotal }} / {{ currentVote.userCnt }} ({{
percentage(voteTotal, currentVote.userCnt)
}}%)
</td>
</tr>
</tfoot>
</table>
<PanelCard title="현재 설문" :subtitle="currentPoll?.title ?? '설문 정보 없음'">
<SkeletonLines v-if="loading || detailLoading" :lines="6" />
<div v-else-if="!voteDetail" class="placeholder">설문 정보가 없습니다.</div>
<div v-else class="poll-detail">
<div class="poll-meta">
<div><strong>제목</strong> {{ voteDetail.voteInfo.title }}</div>
<div v-if="voteDetail.voteInfo.body"><strong>본문</strong> {{ voteDetail.voteInfo.body }}</div>
<div><strong>작성자</strong> {{ voteDetail.voteInfo.openerName }}</div>
<div>
<strong>선택 제한</strong>
{{
voteDetail.voteInfo.multipleOptions === 0
? '제한 없음'
: voteDetail.voteInfo.multipleOptions === 1
? '1개 선택'
: `${voteDetail.voteInfo.multipleOptions}개 선택`
}}
</div>
<div><strong>공개 정책</strong> {{ revealLabel }}</div>
<div><strong>시작</strong> {{ formatDate(voteDetail.voteInfo.startAt) }}</div>
<div><strong>종료</strong> {{ formatDate(voteDetail.voteInfo.endAt) }}</div>
<div><strong>닫힘</strong> {{ formatDate(voteDetail.voteInfo.closedAt) }}</div>
</div>
<form v-if="currentVote" @submit.prevent="submitComment">
<table id="vote-comment">
<colgroup>
<col class="comment-idx" />
<col class="comment-name" />
<col class="comment-text" />
<col class="comment-date" />
</colgroup>
<thead>
<tr class="bg1 text-center">
<th>#</th>
<th><span>국가명</span><span>장수명</span></th>
<th>댓글</th>
<th>일시</th>
</tr>
</thead>
<tbody>
<tr v-for="(comment, index) in currentVote.comments" :key="comment.id">
<td class="comment-idx f_tnum">{{ index + 1 }}.</td>
<td class="comment-name">
<span>{{ comment.nationName }}</span
><span>{{ comment.generalName }}</span>
</td>
<td>{{ comment.text }}</td>
<td class="comment-date f_tnum">{{ formatCommentDate(comment.createdAt) }}</td>
</tr>
</tbody>
<tfoot>
<tr>
<td></td>
<td><button class="btn btn-primary comment-submit" type="submit">댓글 달기</button></td>
<td colspan="2">
<input v-model="myComment" class="form-control" maxlength="200" aria-label="댓글" />
</td>
</tr>
</tfoot>
</table>
</form>
<div class="poll-options">
<div
v-for="(option, idx) in voteDetail.voteInfo.options"
:key="`${voteDetail.voteInfo.id}-${idx}`"
class="poll-option"
>
<label>
<input
v-if="isSingleChoice"
type="radio"
:value="idx"
v-model="selectionSingle"
:disabled="!canVote"
/>
<input
v-else
type="checkbox"
:value="idx"
v-model="selectionMulti"
:disabled="!canVote"
/>
<span>{{ option }}</span>
</label>
</div>
</div>
<div class="poll-actions">
<button class="ghost" @click="submitVote" :disabled="!canVote">투표하기</button>
<span v-if="voteDetail.myVote" class="muted">이미 투표했습니다.</span>
<span v-else-if="pollEnded" class="muted">설문이 종료되었습니다.</span>
</div>
</div>
</PanelCard>
<PanelCard title="결과">
<SkeletonLines v-if="detailLoading" :lines="4" />
<div v-else-if="!voteDetail" class="placeholder">설문 결과가 없습니다.</div>
<div v-else-if="!canReveal" class="placeholder">
결과는 {{ revealLabel }}됩니다.
</div>
<div v-else class="poll-results">
<div class="result-summary">
참여 인원 {{ voteTotal }} / {{ voteDetail.userCnt }}
</div>
<div v-for="(option, idx) in voteDetail.voteInfo.options" :key="`result-${idx}`" class="result-row">
<div class="result-option">{{ option }}</div>
<div class="result-count">{{ voteDistribution[idx] ?? 0 }}</div>
<div class="result-percent">
{{
voteTotal > 0
? ((voteDistribution[idx] ?? 0) / voteTotal * 100).toFixed(1)
: '0.0'
}}%
</div>
</div>
</div>
</PanelCard>
<PanelCard title="댓글">
<SkeletonLines v-if="detailLoading" :lines="4" />
<div v-else-if="!voteDetail" class="placeholder">댓글을 불러오는 중입니다.</div>
<div v-else>
<div v-if="voteDetail.comments.length === 0" class="placeholder">아직 댓글이 없습니다.</div>
<div v-else class="comment-list">
<div v-for="comment in voteDetail.comments" :key="comment.id" class="comment-item">
<div class="comment-header">
<span>{{ comment.nationName }}</span>
<span>{{ comment.generalName }}</span>
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
</div>
<div class="comment-text">{{ comment.text }}</div>
</div>
</div>
<div class="comment-form">
<input
v-model="commentDraft"
type="text"
maxlength="200"
placeholder="댓글을 입력하세요"
/>
<button class="ghost" @click="submitComment">댓글 등록</button>
</div>
</div>
</PanelCard>
<div id="vote-old-title" class="bg2">이전 설문 조사</div>
<div id="vote-old-list">
<div v-for="poll in polls" :key="poll.id" class="vote-old-item">
<a href="#" @click.prevent="selectVote(poll.id)">{{ poll.title }}</a>
({{ formatStartDate(poll.startAt) }})
</div>
</div>
<div class="survey-side">
<PanelCard title="설문 목록">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else class="poll-list">
<div v-if="polls.length === 0" class="placeholder">등록된 설문이 없습니다.</div>
<button
v-for="poll in polls"
:key="poll.id"
class="poll-list-item"
:class="{ active: poll.id === currentPoll?.id }"
@click="selectPoll(poll.id)"
>
<div class="poll-title">{{ poll.title }}</div>
<div class="poll-meta-row">
<span>{{ formatDate(poll.startAt) }}</span>
<span>{{ isPollEnded(poll) ? '종료됨' : '진행중' }}</span>
</div>
</button>
<div v-if="isVoteAdmin" id="vote-new-panel">
<div><a href="#" @click.prevent="showNewVote = !showNewVote"> 설문 조사 열기</a></div>
<template v-if="showNewVote">
<div class="admin-row">
<div>설문 제목</div>
<div><input v-model="newVoteTitle" class="form-control" type="text" /></div>
</div>
<div class="admin-row">
<div>설문 대상(엔터로 구분) ({{ newVoteOptions.length }})</div>
<div>
<textarea
v-model="newVoteOptionsText"
class="form-control"
:rows="newVoteOptions.length + 1"
></textarea>
</div>
</PanelCard>
</div>
<div class="admin-row">
<div>동시 응답 (0=모두)</div>
<div>
<input
v-model.number="newVoteMultipleOptions"
class="form-control"
type="number"
min="0"
:max="newVoteOptions.length"
/>
</div>
</div>
<div class="admin-submit">
<button class="btn btn-primary" type="button" @click="submitNewVote">제출</button>
</div>
</template>
</div>
<PanelCard v-if="adminEnabled" title="관리자 패널">
<div class="admin-section">
<h3> 설문 생성</h3>
<label>
제목
<input v-model="newPollTitle" type="text" />
</label>
<label>
본문
<textarea v-model="newPollBody" rows="3" />
</label>
<label>
항목 (줄바꿈 구분)
<textarea v-model="newPollOptionsText" rows="4" />
</label>
<label>
동시 응답 (0=제한 없음)
<input v-model.number="newPollMultipleOptions" type="number" min="0" />
</label>
<label>
종료 시각
<input v-model="newPollEndAt" type="datetime-local" />
</label>
<label>
공개 정책
<select v-model="newPollRevealMode">
<option value="after_vote">투표 공개</option>
<option value="after_end">종료 공개</option>
</select>
</label>
<label class="checkbox">
<input v-model="newPollClosePrevious" type="checkbox" />
기존 설문 종료
</label>
<button class="ghost" @click="createPoll">설문 생성</button>
</div>
<div class="admin-section" v-if="voteDetail">
<h3>설문 수정</h3>
<p class="muted">응답 0건일 때만 수정 가능합니다.</p>
<label>
제목
<input v-model="updateTitle" type="text" />
</label>
<label>
본문
<textarea v-model="updateBody" rows="3" />
</label>
<label>
항목 추가 (줄바꿈 구분)
<textarea v-model="updateOptionsText" rows="3" />
</label>
<label>
동시 응답
<input v-model.number="updateMultipleOptions" type="number" min="0" />
</label>
<label>
종료 시각
<input v-model="updateEndAt" type="datetime-local" />
</label>
<label>
공개 정책
<select v-model="updateRevealMode">
<option value="after_vote">투표 공개</option>
<option value="after_end">종료 공개</option>
</select>
</label>
<div class="admin-actions">
<button class="ghost" @click="updatePoll">수정 적용</button>
<button class="ghost" @click="closePoll">설문 종료</button>
</div>
</div>
</PanelCard>
</div>
</section>
<footer class="bottom_bar bg0">
<RouterLink class="btn btn-sammo-base2 back_btn" to="/"> 닫기</RouterLink>
</footer>
</main>
</template>
<style scoped>
.survey-view {
min-height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
.pageVote {
margin: 0 auto;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
font-size: 14px;
line-height: 1.5;
}
.page-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
padding-bottom: 12px;
.bg0 {
background-image: url('/image/game/back_walnut.jpg');
}
.page-title {
font-size: 1.6rem;
font-weight: 600;
.bg1 {
background-image: url('/image/game/back_green.jpg');
}
.page-subtitle {
font-size: 0.85rem;
color: rgba(232, 221, 196, 0.7);
.bg2 {
background-image: url('/image/game/back_blue.jpg');
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 6px 12px;
font-size: 0.8rem;
cursor: pointer;
background: rgba(16, 16, 16, 0.6);
color: inherit;
}
.ghost:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error {
color: #ff8080;
}
.notice {
color: rgba(232, 221, 196, 0.8);
}
.survey-grid {
.back_bar {
width: 100%;
height: 32px;
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 16px;
grid-template-columns: 90px 90px 1fr 90px 90px;
}
.survey-main,
.survey-side {
display: flex;
flex-direction: column;
gap: 16px;
}
.reward-block {
display: flex;
flex-direction: column;
gap: 6px;
}
.poll-detail {
display: flex;
flex-direction: column;
gap: 12px;
}
.poll-meta {
display: grid;
gap: 6px;
font-size: 0.9rem;
}
.poll-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.poll-option {
display: flex;
gap: 8px;
align-items: center;
}
.poll-actions {
display: flex;
align-items: center;
gap: 12px;
}
.poll-results {
display: flex;
flex-direction: column;
gap: 8px;
}
.result-summary {
font-size: 0.9rem;
color: rgba(232, 221, 196, 0.8);
}
.result-row {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 12px;
align-items: center;
font-size: 0.9rem;
}
.result-count,
.result-percent {
text-align: right;
white-space: nowrap;
}
.comment-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.comment-item {
padding: 10px 12px;
border: 1px solid rgba(201, 164, 90, 0.3);
border-radius: 8px;
background: rgba(16, 16, 16, 0.5);
}
.comment-header {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: rgba(232, 221, 196, 0.8);
}
.comment-date {
opacity: 0.7;
}
.comment-text {
margin-top: 6px;
}
.comment-form {
display: flex;
gap: 8px;
margin-top: 12px;
}
.comment-form input {
flex: 1;
padding: 6px 8px;
background: rgba(16, 16, 16, 0.6);
border: 1px solid rgba(201, 164, 90, 0.4);
color: inherit;
}
.poll-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.poll-list-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: 1px solid rgba(201, 164, 90, 0.3);
background: rgba(16, 16, 16, 0.5);
text-align: left;
cursor: pointer;
color: inherit;
}
.poll-list-item.active {
border-color: rgba(255, 214, 140, 0.8);
background: rgba(32, 24, 12, 0.8);
}
.poll-meta-row {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.7);
}
.placeholder {
color: rgba(232, 221, 196, 0.7);
}
.muted {
color: rgba(232, 221, 196, 0.6);
}
.admin-section {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.admin-section h3 {
.back_bar .title {
margin: 0;
font-size: 1rem;
}
.admin-section label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.85rem;
.btn {
min-height: 35.5px;
padding: 5.25px 10.5px;
border: 1px solid transparent;
border-radius: 5.25px;
color: #fff;
font: inherit;
cursor: pointer;
}
.admin-section input,
.admin-section textarea,
.admin-section select {
padding: 6px 8px;
background: rgba(16, 16, 16, 0.6);
border: 1px solid rgba(201, 164, 90, 0.4);
color: inherit;
.btn:hover {
filter: brightness(1.15);
}
.admin-section .checkbox {
flex-direction: row;
align-items: center;
gap: 8px;
.btn:focus-visible {
outline: 2px solid #8ab4f8;
outline-offset: -2px;
}
.admin-actions {
display: flex;
gap: 8px;
.btn:active {
transform: translateY(1px);
}
@media (max-width: 1024px) {
.survey-grid {
grid-template-columns: 1fr;
.btn:disabled {
opacity: 0.65;
cursor: default;
}
.btn-sammo-base2 {
height: 32px;
min-height: 32px;
margin-right: 2px;
border-color: #004f28;
background: #00582c;
font-weight: 600;
text-align: center;
text-decoration: none;
}
.back_bar .btn-sammo-base2 {
width: 88px;
}
.btn-primary {
border-color: #0d6efd;
background: #0d6efd;
}
#vote-title {
font-size: 1.8em;
line-height: 1.5;
text-align: center;
}
#vote-old-title {
font-size: 1.5em;
line-height: 1.5;
text-align: center;
}
#vote-result,
#vote-comment {
width: 100%;
border-collapse: collapse;
}
#vote-result th,
#vote-result td,
#vote-comment th,
#vote-comment td {
padding-right: 1ch;
padding-left: 1ch;
}
#vote-result label {
display: block;
}
#vote-result .vote-idx {
width: 5ch;
}
#vote-result .vote-count {
width: 55px;
padding-right: 0;
}
#vote-result .vote-percent {
width: 70px;
padding-left: 0;
}
.vote-submit {
width: 100%;
}
#vote-comment .comment-idx {
width: 5ch;
text-align: end;
}
#vote-comment .comment-name {
width: 110px;
text-align: center;
}
#vote-comment .comment-name span,
#vote-comment thead th:nth-child(2) span {
display: inline-block;
width: 50%;
}
#vote-comment tbody tr {
border-top: 1px solid gray;
}
.comment-submit {
width: 50%;
margin-left: 50%;
}
.form-control {
width: 100%;
min-height: 35.5px;
box-sizing: border-box;
padding: 5.25px 10.5px;
border: 1px solid #6c757d;
border-radius: 5.25px;
color: #fff;
background: #212529;
font: inherit;
}
.form-check-input {
width: 1em;
height: 1em;
margin: 0;
accent-color: #0d6efd;
}
.text-end {
text-align: end;
}
.text-center {
text-align: center;
}
.f_tnum {
font-variant-numeric: tabular-nums;
}
#vote-old-list,
#vote-new-panel {
padding: 0 7px;
}
.vote-old-item a,
#vote-new-panel a {
color: #6ea8fe;
}
.admin-row {
display: grid;
grid-template-columns: 25% 75%;
}
.admin-row > div {
padding: 2px 0;
}
.admin-submit {
width: 20%;
margin-left: 80%;
display: grid;
}
.bottom_bar {
height: 55.5px;
padding-top: 20px;
box-sizing: border-box;
}
.bottom_bar .back_btn {
display: inline-block;
width: auto;
}
.vote-notice {
padding: 5px 10px;
border: 1px solid #477a47;
color: #d8f5d8;
}
.vote-notice.error {
border-color: #9b4848;
color: #ffd0d0;
}
.loading {
padding: 12px;
text-align: center;
}
@media (min-width: 501px) {
.pageVote {
width: 1000px;
}
.comment-form {
flex-direction: column;
#vote-comment .comment-name {
width: 260px;
}
#vote-comment .comment-date {
width: 98px;
padding-right: 0.5ch;
padding-left: 0.5ch;
text-align: center;
}
}
@media (max-width: 500px) {
.pageVote {
width: 500px;
}
#vote-comment .comment-name {
width: 130px;
}
#vote-comment .comment-date {
width: 50px;
padding-right: 0.5ch;
padding-left: 0.5ch;
text-align: center;
}
.admin-row {
grid-template-columns: 100%;
}
}
</style>
@@ -8,16 +8,22 @@
- 범위: 명령 결과, RNG 소비, 로그, 예약 턴 lifecycle, DB 영속화
현재 ref MariaDB와 core memory를 잇는 공통 runner, 성공 경로 55개,
실행 중 확률 실패 9개, full constraint fallback 7개와 모략 확률 clamp
8개가 구현됐다.
실행 중 확률 실패 9개, full constraint fallback 12개와 모략 확률 clamp
8개, 모략 결과값 경계 5개, 부상 경계 3개가 구현됐다.
실패 9개는 내정 critical
`주민선정/정착장려/상업투자/기술연구/물자조달`과 모략
`화계/선동/파괴/탈취`이며 RNG 전체 trace, semantic state delta와 실패
로그 본문을 비교한다. 제약 7개는 무소속, 방랑국, 타국 도시, 보급 단절,
금·쌀 부족과 민심 상한을 대표하며 휴식 fallback과 RNG/state delta를
비교한다. clamp 8개는 `화계/선동/파괴/탈취` 각각의 계산 확률 0과
로그 본문을 비교한다. 공통 제약 7개는 무소속, 방랑국, 타국 도시, 보급
단절, 금·쌀 부족과 민심 상한을 대표한다. 모략 제약 5개는 동일 도시,
중립 도시, 불가침국, 계략 비용 금·쌀 부족을 고정한다. 모두 휴식
fallback과 RNG/state delta를 비교한다. clamp 8개는
`화계/선동/파괴/탈취` 각각의 계산 확률 0과
0.5 경계에서 성공 판정 RNG의 무소비 또는 `nextBits(1)` 소비와 전체
state delta를 비교한다. 나머지 명령별 제약 실패·값 경계·alternative와 전체 core
state delta를 비교한다. 결과값 5개는 화계 농업·상업, 선동 치안·민심,
파괴 수비·성벽의 0 바닥과 탈취의 대상 국가 자원 상한·미보급 도시 최종
저장 상태를 비교한다. 부상 3개는 화계·선동·파괴의 대상 장수별 판정,
부상도 80 상한, 병력·훈련·사기 0.98 정수 저장과 대상 장수 로그를
비교한다. 나머지 명령별 제약 실패·값 경계·alternative와 전체 core
PostgreSQL 재조회가 완료 기준을 통과하기 전까지 55개 명령 전체의 동적
호환 상태를 `확인`으로 올리지 않는다.
@@ -588,6 +594,17 @@ fixture가 명시한 필드만 비교하면 예상하지 못한 side effect를
`city.trust FLOAT` 재조회 정밀도 차이도 발견해 부분 patch와 6자리
유효숫자 저장 경계로 바로잡았다.
결과값 경계 fixture에서는 파괴의 전체 city spread도 선동과 같이
불필요한 front 재계산을 일으키는 문제를 발견해 수비·성벽·state 부분
patch로 좁혔다. 미보급 탈취는 레거시가 자원 감소 update 뒤 원본
`destCity.agri/comm`을 다시 저장하므로 관찰 가능한 최종 자원은 변하지
않는다. 버그로 보이지만 호환 계약으로 보존하고 `state=32`만 남긴다.
부상 경계 fixture에서는 화계의 `계략로 인해` 조사를 레거시
`계략으로 인해`로 수정하고, 선동·파괴에 누락된 대상 장수 부상 로그를
추가했다. 병력·훈련·사기의 0.98 감소는 ref 정수 DB 저장처럼 내림이 아닌
반올림을 적용한다.
## 55개 명령 coverage manifest
`manifest.json`은 PHP command inventory와 TS registry의 합집합을 기준으로
+4 -1
View File
@@ -106,7 +106,10 @@ Move items into the main docs once they are finalized.
- [AI suggestion] Replace smoke/placeholder tests with concrete assertions for success/failure outcomes (e.g., 등용, NPC능동 invalid args, 출병 troop creation).
- [AI suggestion] Document che*출병 parity gaps vs legacy (city state/term=43, fallback to che*이동 when friendly, nation.war/AllowWar check, post-war static events/unique-item lottery, missing route data when map/diplomacy not provided).
- [AI suggestion] Verify that "이호경식" followed by "출병" is correctly blocked with the new reserved-turn overlay (diplomacy state sync).
- [AI suggestion] Survey/unique lottery should account for auction/storage-held unique items once the inventory and auction models are finalized.
- [AI suggestion] Migrate the legacy `storage` namespaces used to reserve
unequipped unique items. Survey, monthly nation-level, and general-command
lotteries now count equipped items plus active/finalizing unique auctions,
but core2026 has no equivalent persistent storage namespace to include yet.
## Trigger System
@@ -179,14 +179,26 @@ responses pass a separate reference trace and core resolver/API boundary.
Nine general in-action failure cases now also pass the common differential:
five domestic critical failures and four sabotage failures. They compare the
full command RNG trace, semantic state delta and the exact action-log body.
Seven full-constraint cases cover neutral status, wandering nation, city
ownership, supply, gold, rice and trust-cap rejection. Both engines replace
the denied command with rest, consume the same RNG and produce the same
semantic delta. Eight sabotage clamp cases cover probability zero and 0.5 for
Seven common full-constraint cases cover neutral status, wandering nation,
city ownership, supply, gold, rice and trust-cap rejection. Five sabotage
constraint cases add the occupied-city target, neutral target, non-aggression
status and insufficient sabotage gold or rice. Both engines replace the denied
command with rest, consume the same RNG and produce the same semantic delta.
Eight sabotage clamp cases cover probability zero and 0.5 for
fire attack, agitation, destruction and seizure. The zero boundary skips the
success RNG primitive, while exactly 0.5 consumes `nextBits(1)`; the complete
RNG trace and semantic delta match. These cases also fixed fire-attack city
state persistence and agitation front/trust persistence differences.
Five sabotage value-boundary cases cover zero floors for fire-attack,
agitation and destruction city values, the supplied-nation resource cap for
seizure, and the legacy final state of unsupplied seizure. They also remove
destruction's unintended front recalculation and preserve the legacy
unsupplied-seizure overwrite that leaves city resources unchanged.
Three sabotage injury-boundary cases cover per-defender rolls, the injury cap
of 80, integer persistence after multiplying crew/train/atmosphere by 0.98,
and the target-general injury log for fire attack, agitation and destruction.
They restore the legacy Korean particle in the log, add the two missing logs,
and round rather than floor the integer fields.
This is not yet a claim that every command-specific
constraint, clamp, alternative and persistence boundary has been dynamically
compared.
+20 -13
View File
@@ -43,19 +43,20 @@ storage, route guards, and image loading.
## Enforced contracts
| Screen | Ref entry point | Current automated contract |
| ------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset |
| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows |
| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus |
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
| hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus |
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error |
| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error |
| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus |
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
| Screen | Ref entry point | Current automated contract |
| -------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset |
| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows |
| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus |
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
| hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus |
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error |
| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error |
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus |
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
The global game baseline is black, white, Pretendard 14px. Legacy texture
helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is
@@ -63,6 +64,12 @@ green, and `bg2` is blue. Shared `PanelCard` uses the same walnut body and green
header so screens that still use the common component no longer inherit the
discarded Galmuri/parchment visual system.
The survey fixture covers both the poll list and an open detail. Its result
rows are visible before the current general votes when the poll uses the
legacy-compatible `after_vote` mode. The mutation fixture covers voting and
comment submission, while the error fixture confirms that a failed vote keeps
the selected radio option so the user can retry.
## Route coverage rule
Adding or changing a frontend route requires:
+2
View File
@@ -32,6 +32,8 @@ export interface DatabaseClient {
inheritanceLog: GamePrisma.InheritanceLogDelegate;
inheritanceResult: GamePrisma.InheritanceResultDelegate;
inheritanceUserState: GamePrisma.InheritanceUserStateDelegate;
boardPost: GamePrisma.BoardPostDelegate;
boardComment: GamePrisma.BoardCommentDelegate;
inputEvent: GamePrisma.InputEventDelegate;
turnDaemonLease: GamePrisma.TurnDaemonLeaseDelegate;
}
@@ -136,6 +136,10 @@ export class ActionResolver<
consumeSuccessfulStrategyItem(this.pipeline, context);
for (const injured of result.injuredGenerals) {
effects.push(createGeneralPatchEffect(injured.patch, injured.id));
ctx.addLog('<M>계략</>으로 인해 <R>부상</>을 당했습니다.', {
generalId: injured.id,
format: LogFormat.MONTH,
});
}
return { effects };
@@ -140,14 +140,12 @@ export class ActionResolver<
)
);
} else {
const commDmg = stolenGold / 12;
const agriDmg = stolenRice / 12;
// 레거시는 미보급 도시 자원을 먼저 감소시키지만 같은 명령 끝의
// 원본 destCity 전체 저장이 이를 덮어쓴다. 관찰 가능한 최종
// 상태는 자원 변화 없이 state 32만 남는다.
effects.push(
createCityPatchEffect(
{
commerce: Math.round(Math.max(0, destCity.commerce - commDmg)),
agriculture: Math.round(Math.max(0, destCity.agriculture - agriDmg)),
state: 32,
},
args.destCityId
@@ -106,7 +106,6 @@ export class ActionResolver<
effects.push(
createCityPatchEffect(
{
...destCity,
defence: newDef,
wall: newWall,
state: 32, // Legacy sabotage state
@@ -118,6 +117,10 @@ export class ActionResolver<
consumeSuccessfulStrategyItem(this.pipeline, context);
for (const injured of result.injuredGenerals) {
effects.push(createGeneralPatchEffect(injured.patch, injured.id));
ctx.addLog('<M>계략</>으로 인해 <R>부상</>을 당했습니다.', {
generalId: injured.id,
format: LogFormat.MONTH,
});
}
return { effects };
@@ -212,9 +212,9 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
id: defender.id,
patch: {
injury: clamp(defender.injury + injuryAmount, 0, INJURY_MAX),
crew: Math.floor(defender.crew * 0.98),
atmos: Math.floor(defender.atmos * 0.98),
train: Math.floor(defender.train * 0.98),
crew: Math.round(defender.crew * 0.98),
atmos: Math.round(defender.atmos * 0.98),
train: Math.round(defender.train * 0.98),
},
});
}
@@ -386,7 +386,7 @@ export class ActionResolver<
for (const injured of result.injuredGenerals) {
// 타겟 장수는 Draft가 아니므로 Effect 반환
effects.push(createGeneralPatchEffect(injured.patch, injured.id));
context.addLog(`<M>${ACTION_KEY}</>로 인해 <R>부상</>을 당했습니다.`, {
context.addLog('<M>계략</>로 인해 <R>부상</>을 당했습니다.', {
generalId: injured.id,
format: LogFormat.MONTH,
});
@@ -144,6 +144,24 @@ export const countOccupiedUniqueItems = (
return counts;
};
export const addOccupiedUniqueItemKeys = (
counts: Map<string, number>,
itemKeys: Iterable<string | null | undefined>,
itemRegistry: Map<string, ItemModule>
): Map<string, number> => {
for (const itemKey of itemKeys) {
if (!itemKey) {
continue;
}
const module = itemRegistry.get(itemKey);
if (!module || module.buyable) {
continue;
}
counts.set(itemKey, (counts.get(itemKey) ?? 0) + 1);
}
return counts;
};
const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1;
const serializeSeed = (...values: Array<string | number>): string =>
+17
View File
@@ -5,6 +5,7 @@ import type { GeneralItemSlots } from '../src/domain/entities.js';
import type { ItemModule } from '../src/items/types.js';
import {
type UniqueAcquireType,
addOccupiedUniqueItemKeys,
buildVoteUniqueSeed,
countOccupiedUniqueItems,
resolveUniqueConfig,
@@ -131,4 +132,20 @@ describe('unique lottery', () => {
expect(counts.get('uniqueItem')).toBe(1);
expect(counts.get('buyableItem')).toBeUndefined();
});
it('adds active auction and storage reservations without counting buyable items', () => {
const itemRegistry = new Map<string, ItemModule>([
['uniqueItem', buildItem('uniqueItem', 'weapon', false)],
['buyableItem', buildItem('buyableItem', 'book', true)],
]);
const counts = addOccupiedUniqueItemKeys(
new Map([['uniqueItem', 1]]),
['uniqueItem', 'uniqueItem', 'buyableItem', null],
itemRegistry
);
expect(counts.get('uniqueItem')).toBe(3);
expect(counts.get('buyableItem')).toBeUndefined();
});
});
@@ -0,0 +1,119 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { chromium } from '@playwright/test';
const targetUrl = process.env.REF_BOARD_URL ?? 'https://dev-sam-ref.hided.net/sam/';
const username = process.env.REF_BOARD_USER ?? 'refuser1';
const passwordFile = process.env.REF_BOARD_PASSWORD_FILE;
const artifactRoot = process.env.REF_BOARD_ARTIFACT_DIR;
if (!passwordFile) {
throw new Error('REF_BOARD_PASSWORD_FILE is required');
}
const password = (await readFile(passwordFile, 'utf8')).trim();
const login = async (context, page) => {
await page.goto(targetUrl, { 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', targetUrl).toString(), {
data: { username, password: passwordHash },
});
const result = await response.json();
if (!response.ok() || result.result !== true) {
throw new Error('Reference login failed');
}
};
const measure = async (browser, name, viewport, isSecret) => {
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);
const boardUrl = new URL('hwe/v_board.php', targetUrl);
if (isSecret) {
boardUrl.searchParams.set('isSecret', 'true');
}
await page.goto(boardUrl.toString(), { waitUntil: 'networkidle', timeout: 60_000 });
await page.locator('.articleFrame').first().waitFor({ state: 'visible' });
if (artifactRoot) {
const path = resolve(artifactRoot, `board-ref-${name}.png`);
await mkdir(dirname(path), { recursive: true });
await page.screenshot({ path, fullPage: true, animations: 'disabled' });
}
const geometry = await page.evaluate(() => {
const rect = (selector) => {
const box = document.querySelector(selector).getBoundingClientRect();
return { x: box.x, y: box.y, width: box.width, height: box.height };
};
const pageStyle = getComputedStyle(document.querySelector('#container'));
const articleText = getComputedStyle(document.querySelector('.articleFrame .text'));
return {
title: document.title,
container: rect('#container'),
topBar: rect('.back_bar'),
titleLabel: rect('#newArticle .articleTitle'),
submitArticle: rect('#submitArticle'),
articleAuthor: rect('.articleFrame .authorName'),
articleDate: rect('.articleFrame .date'),
icon: rect('.articleFrame .generalIcon'),
commentAuthor: rect('.articleFrame .comment .authorName'),
submitComment: rect('.articleFrame .submitComment'),
bottomButton: rect('.bg0[style] .back_btn'),
font: {
family: pageStyle.fontFamily,
size: pageStyle.fontSize,
lineHeight: pageStyle.lineHeight,
},
whiteSpace: articleText.whiteSpace,
walnut: getComputedStyle(document.querySelector('#newArticle')).backgroundImage,
green: getComputedStyle(document.querySelector('.articleFrame > .bg1')).backgroundImage,
blue: getComputedStyle(document.querySelector('.newArticleHeader')).backgroundImage,
submitStyle: {
backgroundColor: getComputedStyle(document.querySelector('#submitArticle')).backgroundColor,
borderColor: getComputedStyle(document.querySelector('#submitArticle')).borderColor,
color: getComputedStyle(document.querySelector('#submitArticle')).color,
},
};
});
const submit = page.locator('#submitArticle');
await submit.hover();
const hoverBackground = await submit.evaluate((element) => getComputedStyle(element).backgroundColor);
await submit.focus();
const focusOutline = await submit.evaluate((element) => getComputedStyle(element).outline);
return {
...geometry,
submitInteraction: {
hoverBackground,
focusOutline,
},
};
} finally {
await context.close();
}
};
const browser = await chromium.launch({ headless: true });
try {
const measurements = {
desktop: await measure(browser, 'desktop', { width: 1000, height: 800 }, false),
mobile: await measure(browser, 'mobile', { width: 500, height: 800 }, true),
};
process.stdout.write(`${JSON.stringify(measurements, null, 2)}\n`);
} finally {
await browser.close();
}
@@ -163,5 +163,63 @@ export const canonicalFrontendFixture = {
globalAction: ['<L>●</> 유비가 내정을 수행했습니다.'],
},
},
surveyList: {
polls: [
{
id: 2,
title: '선호하는 병종',
startAt: '2026-07-26T00:00:00.000Z',
endAt: null,
closedAt: null,
revealMode: 'after_vote',
optionsCount: 3,
multipleOptions: 1,
},
{
id: 1,
title: '지난 설문',
startAt: '2026-07-20T00:00:00.000Z',
endAt: '2026-07-21T00:00:00.000Z',
closedAt: '2026-07-21T00:00:00.000Z',
revealMode: 'after_vote',
optionsCount: 2,
multipleOptions: 1,
},
],
voteReward: 90,
},
surveyDetail: {
voteInfo: {
id: 2,
title: '선호하는 병종',
body: '',
options: ['보병', '기병', '궁병'],
multipleOptions: 1,
revealMode: 'after_vote',
openerGeneralId: 9,
openerName: '설문관리자',
startAt: '2026-07-26T00:00:00.000Z',
endAt: null,
closedAt: null,
},
votes: [
{ selection: [0], count: 2 },
{ selection: [1], count: 1 },
],
comments: [
{
id: 1,
voteId: 2,
generalId: 3,
nationId: 1,
generalName: '관우',
nationName: '촉',
text: '기병이 좋습니다.',
createdAt: '2026-07-26T01:23:00.000Z',
},
],
myVote: null,
userCnt: 17,
},
},
} as const;
@@ -111,6 +111,8 @@ const installHallFixture = async (page: Page): Promise<void> => {
};
const installAuthenticatedGameFixture = async (page: Page): Promise<void> => {
let surveyVoted = false;
const surveyComments = fixture.game.surveyDetail.comments.map((comment) => ({ ...comment }));
await installImages(page);
await page.addInitScript(
({ gameToken, profile }) => {
@@ -139,6 +141,38 @@ const installAuthenticatedGameFixture = async (page: Page): Promise<void> => {
if (operation === 'public.getMapLayout') return fixture.game.mapLayout;
if (operation === 'yearbook.getRange') return fixture.game.yearbookRange;
if (operation === 'yearbook.getHistory') return fixture.game.yearbook;
if (operation === 'vote.getVoteList') return fixture.game.surveyList;
if (operation === 'vote.getVoteDetail') {
return {
...fixture.game.surveyDetail,
votes: surveyVoted
? [
{ selection: [0], count: 3 },
{ selection: [1], count: 1 },
]
: fixture.game.surveyDetail.votes,
comments: surveyComments,
myVote: surveyVoted ? [0] : null,
};
}
if (operation === 'vote.submitVote') {
surveyVoted = true;
return { ok: true, wonLottery: false };
}
if (operation === 'vote.addComment') {
surveyComments.push({
id: surveyComments.length + 1,
voteId: 2,
generalId: 1,
nationId: 1,
generalName: '유비',
nationName: '촉',
text: '새 댓글',
createdAt: '2026-07-26T02:34:00.000Z',
});
return { ok: true };
}
if (operation === 'vote.getAdminStatus') return { ok: false };
throw new Error(`Unhandled authenticated game fixture operation: ${operation}`);
});
});
@@ -494,3 +528,104 @@ test.describe('yearbook legacy parity', () => {
await expect(page.getByLabel('연월 선택')).toBeVisible();
});
});
test.describe('survey legacy parity', () => {
test.beforeEach(async ({ page }) => {
await installAuthenticatedGameFixture(page);
});
for (const viewport of [
{ name: 'desktop', width: 1365, height: 768, containerWidth: 1000, commentNameWidth: 260 },
{ name: 'mobile', width: 390, height: 844, containerWidth: 500, commentNameWidth: 130 },
]) {
test(`renders the ref vote tables on ${viewport.name}`, async ({ page }) => {
await page.setViewportSize(viewport);
await page.goto('http://127.0.0.1:15102/che/survey');
await expect(page.getByText('설문 조사(90금과 추첨으로 유니크템 증정!)')).toBeVisible();
await expect(page.getByText('기병이 좋습니다.')).toBeVisible();
await expect(page.locator('#vote-new-panel')).toHaveCount(0);
if (artifactRoot) {
await page.screenshot({
path: resolve(artifactRoot, `survey-core-${viewport.name}.png`),
fullPage: true,
animations: 'disabled',
});
}
const geometry = await page.evaluate(() => {
const rect = (selector: string) =>
document.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
const container = document.querySelector<HTMLElement>('#container')!;
const title = document.querySelector<HTMLElement>('#vote-title')!;
return {
containerWidth: rect('#container').width,
containerHeight: rect('#container').height,
resultWidth: rect('#vote-result').width,
commentNameWidth: rect('#vote-comment .comment-name').width,
fontFamily: getComputedStyle(container).fontFamily,
fontSize: getComputedStyle(container).fontSize,
backgroundImage: getComputedStyle(container).backgroundImage,
title: {
height: rect('#vote-title').height,
fontSize: getComputedStyle(title).fontSize,
backgroundImage: getComputedStyle(title).backgroundImage,
},
};
});
expect(geometry.containerWidth).toBe(viewport.containerWidth);
expect(geometry.containerHeight).toBeLessThan(viewport.height);
expect(geometry.resultWidth).toBe(viewport.containerWidth);
expect(geometry.commentNameWidth).toBe(viewport.commentNameWidth);
expect(geometry.fontFamily).toContain('Pretendard');
expect(geometry.fontSize).toBe('14px');
expect(geometry.backgroundImage).toContain('back_walnut.jpg');
expect(geometry.title.height).toBeCloseTo(37.8, 0);
expect(geometry.title.fontSize).toBe('25.2px');
expect(geometry.title.backgroundImage).toContain('back_blue.jpg');
const secondOption = page.locator('#v-vote-1');
await secondOption.check();
await expect(secondOption).toBeChecked();
await secondOption.focus();
await expect(secondOption).toBeFocused();
const voteButton = page.getByRole('button', { name: '투표', exact: true });
const beforeHover = await voteButton.evaluate((element) => getComputedStyle(element).filter);
await voteButton.hover();
const afterHover = await voteButton.evaluate((element) => getComputedStyle(element).filter);
expect(afterHover).not.toBe(beforeHover);
});
}
test('submits a vote and comment through the real screen controls', async ({ page }) => {
await page.goto('http://127.0.0.1:15102/che/survey');
await page.getByRole('button', { name: '투표', exact: true }).click();
await expect(page.getByRole('status')).toHaveText('설문을 마쳤습니다.');
await expect(page.getByText('결산', { exact: true })).toBeVisible();
await page.getByLabel('댓글').fill('새 댓글');
await page.getByRole('button', { name: '댓글 달기' }).click();
await expect(page.getByText('새 댓글')).toBeVisible();
});
test('keeps the selected option after a vote API error', async ({ page }) => {
await page.route('**/che/api/trpc/**', async (route) => {
if (operationNames(route).includes('vote.submitVote')) {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: { message: '이미 설문조사를 완료하였습니다.' } }),
});
return;
}
await route.fallback();
});
await page.goto('http://127.0.0.1:15102/che/survey');
const secondOption = page.locator('#v-vote-1');
await secondOption.check();
await page.getByRole('button', { name: '투표', exact: true }).click();
await expect(page.getByRole('alert')).toBeVisible();
await expect(secondOption).toBeChecked();
});
});
@@ -554,6 +554,169 @@ integration('general sabotage probability clamp matrix', () => {
);
});
type SabotageValueBoundaryCase = {
name: string;
action: 'che_화계' | 'che_선동' | 'che_파괴' | 'che_탈취';
stat: 'leadership' | 'strength' | 'intelligence';
fixturePatches: FixturePatches;
};
const sabotageValueBoundaryCases: SabotageValueBoundaryCase[] = [
{
name: 'fire attack does not reduce agriculture or commerce below zero',
action: 'che_화계',
stat: 'intelligence',
fixturePatches: { cities: { 70: { agriculture: 1, commerce: 1 } } },
},
{
name: 'agitation does not reduce security or trust below zero',
action: 'che_선동',
stat: 'leadership',
fixturePatches: { cities: { 70: { security: 1, trust: 1 } } },
},
{
name: 'destruction does not reduce defence or wall below zero',
action: 'che_파괴',
stat: 'strength',
fixturePatches: { cities: { 70: { defence: 1, wall: 1 } } },
},
{
name: 'seizure does not take more than supplied nation resources',
action: 'che_탈취',
stat: 'strength',
fixturePatches: { nations: { 2: { gold: 1, rice: 1 } } },
},
{
name: 'seizure does not reduce unsupplied city resources below zero',
action: 'che_탈취',
stat: 'strength',
fixturePatches: {
cities: { 70: { agriculture: 1, commerce: 1, supplyState: 0 } },
},
},
];
const hasSuccessfulSabotageLog = (logs: Array<Record<string, unknown>>): boolean =>
logs.some((entry) => typeof entry.text === 'string' && entry.text.includes('성공했습니다.'));
integration('general sabotage value boundary matrix', () => {
it.each(sabotageValueBoundaryCases)(
'$name matches the legacy clamped state delta',
async ({ action, stat, fixturePatches }) => {
const request = buildRequest(
action,
{ destCityID: 70 },
{ [stat]: 100 },
{
...fixturePatches,
generals: {
...fixturePatches.generals,
2: { ...fixturePatches.generals?.[2], [stat]: 10 },
},
cities: {
...fixturePatches.cities,
70: {
...fixturePatches.cities?.[70],
security: 0,
securityMax: 2_000,
},
},
}
);
request.setup!.world!.hiddenSeed = 'general-value-0';
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: action,
actionKey: action,
usedFallback: false,
});
expect(hasSuccessfulSabotageLog(reference.after.logs)).toBe(true);
expect(hasSuccessfulSabotageLog(core.after.logs)).toBe(true);
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
},
120_000
);
});
type SabotageInjuryBoundaryCase = {
action: 'che_화계' | 'che_선동' | 'che_파괴';
stat: 'leadership' | 'strength' | 'intelligence';
hiddenSeed: string;
};
const sabotageInjuryBoundaryCases: SabotageInjuryBoundaryCase[] = [
{ action: 'che_화계', stat: 'intelligence', hiddenSeed: 'general-injury-4' },
{ action: 'che_선동', stat: 'leadership', hiddenSeed: 'general-injury-13' },
{ action: 'che_파괴', stat: 'strength', hiddenSeed: 'general-injury-4' },
];
const injuryLogTexts = (logs: Array<Record<string, unknown>>): string[] =>
logs
.map((entry) => entry.text)
.filter((text): text is string => typeof text === 'string' && text.includes('부상</>을 당했습니다.'));
const legacyInjuryLogBody = (text: string): string => text.replace(/^<C>●<\/>\d+월:/, '');
integration('general sabotage injury boundary matrix', () => {
it.each(sabotageInjuryBoundaryCases)(
'$action matches legacy injury cap, integer persistence, and log',
async ({ action, stat, hiddenSeed }) => {
const request = buildRequest(
action,
{ destCityID: 70 },
{ [stat]: 100 },
{
generals: {
2: {
[stat]: 10,
injury: 79,
crew: 101,
atmos: 51,
train: 51,
},
},
cities: { 70: { security: 0, securityMax: 2_000 } },
}
);
request.setup!.world!.hiddenSeed = hiddenSeed;
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: action,
actionKey: action,
usedFallback: false,
});
expect(injuryLogTexts(reference.after.logs)).toHaveLength(1);
expect(injuryLogTexts(core.after.logs)).toEqual(
injuryLogTexts(reference.after.logs).map(legacyInjuryLogBody)
);
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;
@@ -600,6 +763,40 @@ const constraintCases: GeneralConstraintCase[] = [
action: 'che_주민선정',
fixturePatches: { cities: { 3: { trust: 100 } } },
},
{
name: 'sabotage targets the occupied city',
action: 'che_화계',
args: { destCityID: 3 },
},
{
name: 'sabotage targets a neutral city',
action: 'che_화계',
args: { destCityID: 70 },
fixturePatches: { cities: { 70: { nationId: 0 } } },
},
{
name: 'sabotage targets a non-aggression nation',
action: 'che_화계',
args: { destCityID: 70 },
fixturePatches: {
diplomacy: {
'1:2': { state: 7 },
'2:1': { state: 7 },
},
},
},
{
name: 'insufficient sabotage gold',
action: 'che_화계',
args: { destCityID: 70 },
actorPatch: { gold: 0 },
},
{
name: 'insufficient sabotage rice',
action: 'che_화계',
args: { destCityID: 70 },
actorPatch: { rice: 0 },
},
];
integration('general command full-constraint fallback matrix', () => {