diff --git a/app/game-api/src/maps/mapLayout.ts b/app/game-api/src/maps/mapLayout.ts index c33bdda..a867703 100644 --- a/app/game-api/src/maps/mapLayout.ts +++ b/app/game-api/src/maps/mapLayout.ts @@ -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(); -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 => const resolveScenarioFile = async (scenario: string): Promise => { 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 => { 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, diff --git a/app/game-api/src/router/board/index.ts b/app/game-api/src/router/board/index.ts index b046748..7f8b3f3 100644 --- a/app/game-api/src/router/board/index.ts +++ b/app/game-api/src/router/board/index.ts @@ -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[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 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(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(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(); - 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, - }; - }), -}); \ No newline at end of file + return { + url, + width: outputMeta.width ?? metadata.width, + height: outputMeta.height ?? metadata.height, + format: outputFormat, + animated: isAnimated, + size: outputBuffer.length, + }; + }), +}); diff --git a/app/game-api/src/router/nation/endpoints/getNationInfo.ts b/app/game-api/src/router/nation/endpoints/getNationInfo.ts new file mode 100644 index 0000000..bc3aa93 --- /dev/null +++ b/app/game-api/src/router/nation/endpoints/getNationInfo.ts @@ -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(); + 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, + }; +}); diff --git a/app/game-api/src/router/nation/index.ts b/app/game-api/src/router/nation/index.ts index c2c2d35..e810b96 100644 --- a/app/game-api/src/router/nation/index.ts +++ b/app/game-api/src/router/nation/index.ts @@ -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, }); - diff --git a/app/game-api/src/router/vote/index.ts b/app/game-api/src/router/vote/index.ts index 15d59dc..249b9f5 100644 --- a/app/game-api/src/router/vote/index.ts +++ b/app/game-api/src/router/vote/index.ts @@ -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, 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(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(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>(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( diff --git a/app/game-api/src/router/world/index.ts b/app/game-api/src/router/world/index.ts index 7d4540d..5719247 100644 --- a/app/game-api/src/router/world/index.ts +++ b/app/game-api/src/router/world/index.ts @@ -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 => { + 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> = {}; + 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([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(); + 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 = (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 => 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; diff --git a/app/game-api/test/boardRouter.test.ts b/app/game-api/test/boardRouter.test.ts new file mode 100644 index 0000000..135b0ec --- /dev/null +++ b/app/game-api/test/boardRouter.test.ts @@ -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 => ({ + 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; + 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', + }); + }); +}); diff --git a/app/game-api/test/inGameInfoRouter.test.ts b/app/game-api/test/inGameInfoRouter.test.ts new file mode 100644 index 0000000..8a788cd --- /dev/null +++ b/app/game-api/test/inGameInfoRouter.test.ts @@ -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 => ({ + 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 } = {}) => { + 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; select?: Record }) => { + 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 }); + }); +}); diff --git a/app/game-api/test/voteRouter.test.ts b/app/game-api/test/voteRouter.test.ts new file mode 100644 index 0000000..69ba616 --- /dev/null +++ b/app/game-api/test/voteRouter.test.ts @@ -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 => ({ + 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; + 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' }); + } + }); +}); diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 5fe0a1b..5a0a976 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -24,6 +24,7 @@ import { evaluateConstraints, resolveGeneralAction, ITEM_KEYS, + addOccupiedUniqueItemKeys, buildGenericUniqueSeed, countOccupiedUniqueItems, createItemModuleRegistry, @@ -382,6 +383,7 @@ const buildUniqueLotteryRunner = (options: { seedBase: string; itemRegistry: Map; uniqueConfig: ReturnType; + getAdditionalOccupiedUniqueItemKeys?: () => Iterable; }): 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; 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, diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 6517527..2362c52 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -473,6 +473,8 @@ const createTurnDaemonRuntimeWithLease = async ( neutralAuctionRegistrar.handler, frontStateHandler ); + let occupiedAuctionUniqueItemKeys: string[] = []; + let refreshOccupiedAuctionUniqueItemKeys = async (): Promise => {}; 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[] = []; 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; diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index ea4594f..4c9d872 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -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), diff --git a/app/game-engine/test/uniqueLotteryCommand.test.ts b/app/game-engine/test/uniqueLotteryCommand.test.ts index c09664e..e293455 100644 --- a/app/game-engine/test/uniqueLotteryCommand.test.ts +++ b/app/game-engine/test/uniqueLotteryCommand.test.ts @@ -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); + }); }); diff --git a/app/game-engine/test/voteReward.test.ts b/app/game-engine/test/voteReward.test.ts index e713b6f..402f9d0 100644 --- a/app/game-engine/test/voteReward.test.ts +++ b/app/game-engine/test/voteReward.test.ts @@ -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(); + }); }); diff --git a/app/game-frontend/e2e/board.spec.ts b/app/game-frontend/e2e/board.spec.ts new file mode 100644 index 0000000..599d2e0 --- /dev/null +++ b/app/game-frontend/e2e/board.spec.ts @@ -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 => { + 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(selector)!.getBoundingClientRect(); + return { x: box.x, y: box.y, width: box.width, height: box.height }; + }; + const pageStyle = getComputedStyle(document.querySelector('.legacy-board-page')!); + const articleText = getComputedStyle(document.querySelector('.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('#newArticle')!).backgroundImage, + green: getComputedStyle(document.querySelector('.article-header')!).backgroundImage, + blue: getComputedStyle(document.querySelector('.new-article-header')!).backgroundImage, + submitStyle: { + backgroundColor: getComputedStyle(document.querySelector('#submitArticle')!) + .backgroundColor, + borderColor: getComputedStyle(document.querySelector('#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(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(); +}); diff --git a/app/game-frontend/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts new file mode 100644 index 0000000..98e9421 --- /dev/null +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -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'); +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index eddbfbd..933badb 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -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'], diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index 4d24888..5350e81 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -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')\"", diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index 8efb797..708b6f1 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -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', diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index ca49b20..042482c 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -23,6 +23,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { type MapLayout = Awaited>; type CommandTable = Awaited>; type MessageBundle = Awaited>; + type BoardAccess = Awaited>; type ReservedTurnView = Awaited>[number]; const loading = ref(false); @@ -36,6 +37,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const mapLayout = ref(null); const commandTable = ref(null); const messages = ref(null); + const boardAccess = ref(null); const reservedGeneralTurns = ref(null); const reservedNationTurns = ref(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, diff --git a/app/game-frontend/src/views/BoardView.vue b/app/game-frontend/src/views/BoardView.vue index a78b9f6..9cd663d 100644 --- a/app/game-frontend/src/views/BoardView.vue +++ b/app/game-frontend/src/views/BoardView.vue @@ -1,473 +1,528 @@ + - -
-

새 설문 생성

- -