From ae4cc31a16af27542d85b7f9395739151d78795b Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 25 Jul 2026 08:44:44 +0000 Subject: [PATCH] feat: add secure troop management --- .gitignore | 2 + app/game-api/src/router/troop/index.ts | 285 ++++++-- app/game-api/test/troopRouter.test.ts | 243 +++++++ app/game-engine/src/turn/commandRegistry.ts | 47 ++ app/game-engine/src/turn/inMemoryWorld.ts | 12 + app/game-engine/src/turn/types.ts | 1 + .../src/turn/worldCommandHandler.ts | 172 +++++ app/game-engine/src/turn/worldLoader.ts | 1 + app/game-engine/test/troopManagement.test.ts | 224 ++++++ app/game-frontend/e2e/playwright.config.mjs | 34 + app/game-frontend/e2e/troop.spec.ts | 347 +++++++++ app/game-frontend/package.json | 1 + app/game-frontend/src/main.ts | 4 - app/game-frontend/src/router/index.ts | 10 + app/game-frontend/src/stores/session.ts | 5 + app/game-frontend/src/views/MainView.vue | 1 + app/game-frontend/src/views/TroopView.vue | 678 ++++++++++++++++++ app/game-frontend/tsconfig.json | 3 +- package.json | 1 + packages/common/src/turnDaemon/types.ts | 51 ++ packages/infra/src/turnEngineDb.ts | 1 + packages/logic/src/index.ts | 1 + packages/logic/src/troop/management.ts | 115 +++ pnpm-lock.yaml | 38 + pnpm-workspace.yaml | 3 + 25 files changed, 2232 insertions(+), 48 deletions(-) create mode 100644 app/game-api/test/troopRouter.test.ts create mode 100644 app/game-engine/test/troopManagement.test.ts create mode 100644 app/game-frontend/e2e/playwright.config.mjs create mode 100644 app/game-frontend/e2e/troop.spec.ts create mode 100644 app/game-frontend/src/views/TroopView.vue create mode 100644 packages/logic/src/troop/management.ts diff --git a/.gitignore b/.gitignore index 869af95..3beadec 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,5 @@ docker-compose.override.yml .turbo/ *_errors.txt docs/image-storage.md +playwright-report/ +test-results/ diff --git a/app/game-api/src/router/troop/index.ts b/app/game-api/src/router/troop/index.ts index af5420d..3942c23 100644 --- a/app/game-api/src/router/troop/index.ts +++ b/app/game-api/src/router/troop/index.ts @@ -1,73 +1,272 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; +import type { TurnDaemonCommandResult } from '@sammo-ts/common'; +import { isValidTroopNameWidth, normalizeTroopName, resolveTroopSecretPermission } from '@sammo-ts/logic'; + import { authedProcedure, router } from '../../trpc.js'; +import { getMyGeneral } from '../shared/general.js'; + +const troopNameSchema = z + .string() + .refine(isValidTroopNameWidth, '부대 이름은 전각 9자 또는 반각 18자 이하여야 합니다.'); + +const normalizeRequiredTroopName = (value: string): string => { + const name = normalizeTroopName(value); + if (!name) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '부대 이름이 없습니다.' }); + } + return name; +}; + +const assertCommandResult = ( + result: TurnDaemonCommandResult | null, + expectedType: T +): never => { + if (!result) { + throw new TRPCError({ code: 'TIMEOUT', message: 'Turn daemon did not respond.' }); + } + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Unexpected turn daemon response for ${expectedType}.`, + }); +}; export const troopRouter = router({ - join: authedProcedure + getList: authedProcedure.query(async ({ ctx }) => { + const me = await getMyGeneral(ctx); + if (me.nationId <= 0) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어 있지 않습니다.' }); + } + + const [nation, troops, generals, cities] = await Promise.all([ + ctx.db.nation.findUnique({ + where: { id: me.nationId }, + select: { id: true, name: true, meta: true }, + }), + ctx.db.troop.findMany({ + where: { nationId: me.nationId }, + select: { troopLeaderId: true, nationId: true, name: true }, + }), + ctx.db.general.findMany({ + where: { nationId: me.nationId }, + select: { + id: true, + name: true, + cityId: true, + troopId: true, + picture: true, + imageServer: true, + turnTime: true, + }, + }), + ctx.db.city.findMany({ + select: { id: true, name: true }, + }), + ]); + if (!nation) { + throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' }); + } + + const troopLeaderIds = troops.map((troop) => troop.troopLeaderId); + const turns = + troopLeaderIds.length === 0 + ? [] + : await ctx.db.generalTurn.findMany({ + where: { generalId: { in: troopLeaderIds } }, + select: { generalId: true, turnIdx: true, actionCode: true }, + orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }], + }); + const cityNames = new Map(cities.map((city) => [city.id, city.name])); + const generalMap = new Map(generals.map((general) => [general.id, general])); + const reservedByLeader = new Map(); + for (const turn of turns) { + const list = reservedByLeader.get(turn.generalId) ?? []; + list.push(turn.actionCode); + reservedByLeader.set(turn.generalId, list); + } + + const mappedTroops = troops + .map((troop) => { + const leader = generalMap.get(troop.troopLeaderId); + return { + id: troop.troopLeaderId, + name: troop.name, + nationId: troop.nationId, + turnTime: leader?.turnTime.toISOString() ?? null, + reservedCommands: reservedByLeader.get(troop.troopLeaderId) ?? [], + leader: leader + ? { + id: leader.id, + name: leader.name, + cityId: leader.cityId, + cityName: cityNames.get(leader.cityId) ?? '알 수 없음', + picture: leader.picture, + imageServer: leader.imageServer, + } + : null, + members: generals + .filter((general) => general.troopId === troop.troopLeaderId) + .map((general) => ({ + id: general.id, + name: general.name, + cityId: general.cityId, + cityName: cityNames.get(general.cityId) ?? '알 수 없음', + })), + }; + }) + .sort((left, right) => { + const timeOrder = (left.turnTime ?? '').localeCompare(right.turnTime ?? ''); + return timeOrder || left.id - right.id; + }); + + return { + nation: { id: nation.id, name: nation.name }, + me: { id: me.id, troopId: me.troopId }, + permission: resolveTroopSecretPermission(me, nation.meta, false), + troops: mappedTroops, + }; + }), + create: authedProcedure.input(z.object({ troopName: troopNameSchema })).mutation(async ({ ctx, input }) => { + const me = await getMyGeneral(ctx); + const troopName = normalizeRequiredTroopName(input.troopName); + if (me.troopId !== 0) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: '이미 부대에 소속되어 있습니다.', + }); + } + if (me.nationId <= 0) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: '국가에 소속되어 있지 않습니다.', + }); + } + const result = await ctx.turnDaemon.requestCommand({ + type: 'troopCreate', + generalId: me.id, + troopName, + }); + if (!result || result.type !== 'troopCreate') { + return assertCommandResult(result, 'troopCreate'); + } + if (!result.ok) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: result.reason }); + } + return { ok: true, troopId: result.troopId, troopName: result.troopName }; + }), + join: authedProcedure.input(z.object({ troopId: z.number().int().positive() })).mutation(async ({ ctx, input }) => { + const me = await getMyGeneral(ctx); + const result = await ctx.turnDaemon.requestCommand({ + type: 'troopJoin', + generalId: me.id, + troopId: input.troopId, + }); + if (!result || result.type !== 'troopJoin') { + return assertCommandResult(result, 'troopJoin'); + } + if (!result.ok) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: result.reason }); + } + return { ok: true }; + }), + exit: authedProcedure.mutation(async ({ ctx }) => { + const me = await getMyGeneral(ctx); + const result = await ctx.turnDaemon.requestCommand({ + type: 'troopExit', + generalId: me.id, + }); + if (!result || result.type !== 'troopExit') { + return assertCommandResult(result, 'troopExit'); + } + if (!result.ok) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: result.reason }); + } + return { ok: true, wasLeader: result.wasLeader }; + }), + kick: authedProcedure .input( z.object({ - generalId: z.number().int().positive(), troopId: z.number().int().positive(), + targetGeneralId: z.number().int().positive(), }) ) .mutation(async ({ ctx, input }) => { - const result = await ctx.turnDaemon.requestCommand({ - type: 'troopJoin', - generalId: input.generalId, - troopId: input.troopId, + const me = await getMyGeneral(ctx); + if (me.id !== input.troopId || me.troopId !== me.id) { + throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); + } + const target = await ctx.db.general.findUnique({ + where: { id: input.targetGeneralId }, + select: { id: true, troopId: true }, }); - if (!result) { - throw new TRPCError({ - code: 'TIMEOUT', - message: 'Turn daemon did not respond.', - }); + if (!target) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '장수 정보를 찾을 수 없습니다.' }); } - if (result.type !== 'troopJoin') { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Unexpected turn daemon response.', - }); + if (target.troopId === 0) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '부대에 소속되어 있지 않습니다.' }); } - if (!result.ok) { - throw new TRPCError({ - code: 'PRECONDITION_FAILED', - message: result.reason, - }); + if (target.troopId !== input.troopId) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '다른 부대에 소속되어 있습니다.' }); + } + if (target.id === input.troopId) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '부대장을 추방할 수 없습니다.' }); } + const result = await ctx.turnDaemon.requestCommand({ + type: 'troopKick', + generalId: me.id, + troopId: input.troopId, + targetGeneralId: input.targetGeneralId, + }); + if (!result || result.type !== 'troopKick') { + return assertCommandResult(result, 'troopKick'); + } + if (!result.ok) { + const code = result.reason === '권한이 부족합니다.' ? 'FORBIDDEN' : 'PRECONDITION_FAILED'; + throw new TRPCError({ code, message: result.reason }); + } return { ok: true }; }), - exit: authedProcedure + rename: authedProcedure .input( z.object({ - generalId: z.number().int().positive(), + troopId: z.number().int().positive(), + troopName: troopNameSchema, }) ) .mutation(async ({ ctx, input }) => { - const result = await ctx.turnDaemon.requestCommand({ - type: 'troopExit', - generalId: input.generalId, + const me = await getMyGeneral(ctx); + const troopName = normalizeRequiredTroopName(input.troopName); + const nation = await ctx.db.nation.findUnique({ + where: { id: me.nationId }, + select: { meta: true }, }); - if (!result) { - throw new TRPCError({ - code: 'TIMEOUT', - message: 'Turn daemon did not respond.', - }); + const permission = resolveTroopSecretPermission(me, nation?.meta ?? {}, false); + if (me.id !== input.troopId && permission < 4) { + throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); } - if (result.type !== 'troopExit') { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Unexpected turn daemon response.', - }); - } - if (!result.ok) { - throw new TRPCError({ - code: 'PRECONDITION_FAILED', - message: result.reason, - }); + const troop = await ctx.db.troop.findUnique({ + where: { troopLeaderId: input.troopId }, + select: { nationId: true }, + }); + if (!troop || me.nationId <= 0 || troop.nationId !== me.nationId) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '부대가 없습니다.' }); } - return { ok: true, wasLeader: result.wasLeader }; + const result = await ctx.turnDaemon.requestCommand({ + type: 'troopRename', + generalId: me.id, + troopId: input.troopId, + troopName, + }); + if (!result || result.type !== 'troopRename') { + return assertCommandResult(result, 'troopRename'); + } + if (!result.ok) { + const code = result.reason === '권한이 부족합니다.' ? 'FORBIDDEN' : 'PRECONDITION_FAILED'; + throw new TRPCError({ code, message: result.reason }); + } + return { ok: true, troopName: result.troopName }; }), }); diff --git a/app/game-api/test/troopRouter.test.ts b/app/game-api/test/troopRouter.test.ts new file mode 100644 index 0000000..1bf0a4a --- /dev/null +++ b/app/game-api/test/troopRouter.test.ts @@ -0,0 +1,243 @@ +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 type { TurnDaemonTransport } from '../src/daemon/transport.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: 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-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; + target?: GeneralRow | null; + troop?: { troopLeaderId: number; nationId: number; name: string } | null; + nationMeta?: Record; + auth?: GameSessionTokenPayload | null; + result: Awaited>; +}) => { + const me = options.me ?? buildGeneral(); + const requestCommand = vi.fn(async () => options.result); + const db = { + general: { + findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => + me.userId === where.userId ? me : null + ), + findUnique: vi.fn(async ({ where }: { where: { id: number } }) => { + if (where.id === me.id) { + return me; + } + return options.target?.id === where.id ? options.target : null; + }), + }, + nation: { + findUnique: vi.fn(async ({ where }: { where: { id: number } }) => + where.id === me.nationId ? { id: me.nationId, meta: options.nationMeta ?? {} } : null + ), + }, + troop: { + findUnique: vi.fn(async ({ where }: { where: { troopLeaderId: number } }) => + options.troop?.troopLeaderId === where.troopLeaderId ? options.troop : null + ), + }, + }; + 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: options.auth === undefined ? auth : options.auth, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore, + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; + return { context, requestCommand }; +}; + +describe('troop router permissions and mutations', () => { + it('creates a troop only for the general owned by the authenticated user', async () => { + const { context, requestCommand } = buildContext({ + result: { type: 'troopCreate', ok: true, generalId: 1, troopId: 1, troopName: '백마대' }, + }); + const caller = appRouter.createCaller(context); + + await expect(caller.troop.create({ troopName: '백마대' })).resolves.toEqual({ + ok: true, + troopId: 1, + troopName: '백마대', + }); + expect(requestCommand).toHaveBeenCalledWith({ + type: 'troopCreate', + generalId: 1, + troopName: '백마대', + }); + }); + + it('rejects troop creation before daemon dispatch when already assigned or the name is blank', async () => { + const assigned = buildContext({ + me: buildGeneral({ troopId: 9 }), + result: null, + }); + await expect( + appRouter.createCaller(assigned.context).troop.create({ troopName: '신규대' }) + ).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + message: '이미 부대에 소속되어 있습니다.', + }); + expect(assigned.requestCommand).not.toHaveBeenCalled(); + + const blank = buildContext({ result: null }); + await expect(appRouter.createCaller(blank.context).troop.create({ troopName: ' ' })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: '부대 이름이 없습니다.', + }); + expect(blank.requestCommand).not.toHaveBeenCalled(); + }); + + it('rejects an over-width legacy troop name', async () => { + const fixture = buildContext({ result: null }); + await expect( + appRouter.createCaller(fixture.context).troop.create({ troopName: '가나다라마바사아자차' }) + ).rejects.toThrow('부대 이름은 전각 9자 또는 반각 18자 이하여야 합니다.'); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); + + it('allows only the current troop leader to kick a member', async () => { + const unauthorized = buildContext({ + me: buildGeneral({ id: 2, troopId: 1 }), + target: buildGeneral({ id: 3, userId: null, troopId: 1 }), + result: null, + }); + await expect( + appRouter.createCaller(unauthorized.context).troop.kick({ troopId: 1, targetGeneralId: 3 }) + ).rejects.toMatchObject({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); + expect(unauthorized.requestCommand).not.toHaveBeenCalled(); + + const authorized = buildContext({ + me: buildGeneral({ id: 1, troopId: 1 }), + target: buildGeneral({ id: 3, userId: null, troopId: 1 }), + result: { type: 'troopKick', ok: true, generalId: 1, troopId: 1, targetGeneralId: 3 }, + }); + await expect( + appRouter.createCaller(authorized.context).troop.kick({ troopId: 1, targetGeneralId: 3 }) + ).resolves.toEqual({ ok: true }); + expect(authorized.requestCommand).toHaveBeenCalledWith({ + type: 'troopKick', + generalId: 1, + troopId: 1, + targetGeneralId: 3, + }); + }); + + it('checks same-nation top-secret permission before renaming another troop', async () => { + const forbidden = buildContext({ + me: buildGeneral({ id: 2, officerLevel: 1, meta: {} }), + troop: { troopLeaderId: 1, nationId: 1, name: '구대' }, + result: null, + }); + await expect( + appRouter.createCaller(forbidden.context).troop.rename({ troopId: 1, troopName: '신대' }) + ).rejects.toMatchObject({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); + + const ambassador = buildContext({ + me: buildGeneral({ id: 2, officerLevel: 1, meta: { permission: 'ambassador' } }), + troop: { troopLeaderId: 1, nationId: 1, name: '구대' }, + result: { type: 'troopRename', ok: true, generalId: 2, troopId: 1, troopName: '신대' }, + }); + await expect( + appRouter.createCaller(ambassador.context).troop.rename({ troopId: 1, troopName: '신대' }) + ).resolves.toEqual({ ok: true, troopName: '신대' }); + + const crossNation = buildContext({ + me: buildGeneral({ id: 2, officerLevel: 1, meta: { permission: 'ambassador' } }), + troop: { troopLeaderId: 1, nationId: 9, name: '타국대' }, + result: null, + }); + await expect( + appRouter.createCaller(crossNation.context).troop.rename({ troopId: 1, troopName: '신대' }) + ).rejects.toMatchObject({ code: 'PRECONDITION_FAILED', message: '부대가 없습니다.' }); + expect(crossNation.requestCommand).not.toHaveBeenCalled(); + }); + + it('does not accept troop mutations without authentication', async () => { + const fixture = buildContext({ auth: null, result: null }); + await expect( + appRouter.createCaller(fixture.context).troop.create({ troopName: '백마대' }) + ).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index 3f80ae8..9e8a951 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -50,11 +50,31 @@ const zTroopJoin = z.object({ troopId: zFiniteNumber, }); +const zTroopCreate = z.object({ + type: z.literal('troopCreate'), + generalId: zFiniteNumber, + troopName: z.string(), +}); + const zTroopExit = z.object({ type: z.literal('troopExit'), generalId: zFiniteNumber, }); +const zTroopKick = z.object({ + type: z.literal('troopKick'), + generalId: zFiniteNumber, + troopId: zFiniteNumber, + targetGeneralId: zFiniteNumber, +}); + +const zTroopRename = z.object({ + type: z.literal('troopRename'), + generalId: zFiniteNumber, + troopId: zFiniteNumber, + troopName: z.string(), +}); + const zDieOnPrestart = z.object({ type: z.literal('dieOnPrestart'), generalId: zFiniteNumber, @@ -262,6 +282,14 @@ const normalizeTroopJoin: CommandNormalizer<'troopJoin'> = (envelope) => { return { ...command, requestId: envelope.requestId }; }; +const normalizeTroopCreate: CommandNormalizer<'troopCreate'> = (envelope) => { + const command = parseWith(zTroopCreate, envelope.command); + if (!command) { + return null; + } + return { ...command, requestId: envelope.requestId }; +}; + const normalizeTroopExit: CommandNormalizer<'troopExit'> = (envelope) => { const command = parseWith(zTroopExit, envelope.command); if (!command) { @@ -270,6 +298,22 @@ const normalizeTroopExit: CommandNormalizer<'troopExit'> = (envelope) => { return { ...command, requestId: envelope.requestId }; }; +const normalizeTroopKick: CommandNormalizer<'troopKick'> = (envelope) => { + const command = parseWith(zTroopKick, envelope.command); + if (!command) { + return null; + } + return { ...command, requestId: envelope.requestId }; +}; + +const normalizeTroopRename: CommandNormalizer<'troopRename'> = (envelope) => { + const command = parseWith(zTroopRename, envelope.command); + if (!command) { + return null; + } + return { ...command, requestId: envelope.requestId }; +}; + const normalizeDieOnPrestart: CommandNormalizer<'dieOnPrestart'> = (envelope) => { const command = parseWith(zDieOnPrestart, envelope.command); if (!command) { @@ -445,8 +489,11 @@ const normalizeShutdown: CommandNormalizer<'shutdown'> = (envelope) => { const normalizers: CommandNormalizerMap = { auctionFinalize: normalizeAuctionFinalize, auctionBid: normalizeAuctionBid, + troopCreate: normalizeTroopCreate, troopJoin: normalizeTroopJoin, troopExit: normalizeTroopExit, + troopKick: normalizeTroopKick, + troopRename: normalizeTroopRename, dieOnPrestart: normalizeDieOnPrestart, buildNationCandidate: normalizeBuildNationCandidate, instantRetreat: normalizeInstantRetreat, diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index bdf02a3..6c11cb4 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -434,6 +434,18 @@ export class InMemoryTurnWorld { return next; } + createTroop(troop: Troop): Troop | null { + if (this.troops.has(troop.id)) { + return null; + } + const next = { ...troop }; + this.troops.set(troop.id, next); + this.dirtyTroopIds.add(troop.id); + this.createdTroopIds.add(troop.id); + this.deletedTroopIds.delete(troop.id); + return next; + } + removeTroop(id: number): boolean { if (!this.troops.has(id)) { return false; diff --git a/app/game-engine/src/turn/types.ts b/app/game-engine/src/turn/types.ts index 499e80f..cc53d8b 100644 --- a/app/game-engine/src/turn/types.ts +++ b/app/game-engine/src/turn/types.ts @@ -22,6 +22,7 @@ export interface TurnWorldState { export interface TurnGeneral extends General { turnTime: Date; recentWarTime?: Date | null; + penalty?: unknown; } export interface TurnDiplomacy { diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 5e9d525..920a4dc 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -14,7 +14,10 @@ import { buildVoteUniqueSeed, countOccupiedUniqueItems, createItemModuleRegistry, + isValidTroopNameWidth, loadItemModules, + normalizeTroopName, + resolveTroopSecretPermission, resolveUniqueConfig, rollUniqueLottery, type ItemModule, @@ -441,6 +444,73 @@ async function handleTroopJoin( }; } +async function handleTroopCreate( + ctx: CommandHandlerContext, + command: Extract +): Promise { + const { world } = ctx; + const general = world.getGeneralById(command.generalId); + if (!general) { + return { + type: 'troopCreate', + ok: false, + generalId: command.generalId, + reason: '장수 정보를 찾을 수 없습니다.', + }; + } + + const troopName = normalizeTroopName(command.troopName); + if (!troopName) { + return { type: 'troopCreate', ok: false, generalId: command.generalId, reason: '부대 이름이 없습니다.' }; + } + if (!isValidTroopNameWidth(troopName)) { + return { + type: 'troopCreate', + ok: false, + generalId: command.generalId, + reason: '부대 이름은 전각 9자 또는 반각 18자 이하여야 합니다.', + }; + } + if (general.troopId !== 0 || world.getTroopById(general.id)) { + return { + type: 'troopCreate', + ok: false, + generalId: command.generalId, + reason: '이미 부대에 소속되어 있습니다.', + }; + } + if (general.nationId <= 0 || !world.getNationById(general.nationId)) { + return { + type: 'troopCreate', + ok: false, + generalId: command.generalId, + reason: '국가에 소속되어 있지 않습니다.', + }; + } + + const troop = world.createTroop({ + id: general.id, + nationId: general.nationId, + name: troopName, + }); + if (!troop) { + return { + type: 'troopCreate', + ok: false, + generalId: command.generalId, + reason: '부대가 생성되지 않았습니다. 버그일 수 있습니다.', + }; + } + world.updateGeneral(general.id, { troopId: general.id }); + return { + type: 'troopCreate', + ok: true, + generalId: general.id, + troopId: troop.id, + troopName: troop.name, + }; +} + async function handleTroopExit( ctx: CommandHandlerContext, command: Extract @@ -490,6 +560,103 @@ async function handleTroopExit( }; } +async function handleTroopKick( + ctx: CommandHandlerContext, + command: Extract +): Promise { + const fail = (reason: string): TurnDaemonCommandResult => ({ + type: 'troopKick', + ok: false, + generalId: command.generalId, + troopId: command.troopId, + targetGeneralId: command.targetGeneralId, + reason, + }); + const { world } = ctx; + const actor = world.getGeneralById(command.generalId); + if (!actor) { + return fail('장수 정보를 찾을 수 없습니다.'); + } + const troop = world.getTroopById(command.troopId); + if ( + command.generalId !== command.troopId || + actor.troopId !== actor.id || + !troop || + troop.id !== actor.id || + troop.nationId !== actor.nationId + ) { + return fail('권한이 부족합니다.'); + } + + const target = world.getGeneralById(command.targetGeneralId); + if (!target) { + return fail('장수 정보를 찾을 수 없습니다.'); + } + if (target.troopId === 0) { + return fail('부대에 소속되어 있지 않습니다.'); + } + if (target.troopId !== command.troopId) { + return fail('다른 부대에 소속되어 있습니다.'); + } + if (target.id === command.troopId) { + return fail('부대장을 추방할 수 없습니다.'); + } + + world.updateGeneral(target.id, { troopId: 0 }); + return { + type: 'troopKick', + ok: true, + generalId: actor.id, + troopId: troop.id, + targetGeneralId: target.id, + }; +} + +async function handleTroopRename( + ctx: CommandHandlerContext, + command: Extract +): Promise { + const fail = (reason: string): TurnDaemonCommandResult => ({ + type: 'troopRename', + ok: false, + generalId: command.generalId, + troopId: command.troopId, + reason, + }); + const { world } = ctx; + const actor = world.getGeneralById(command.generalId); + if (!actor) { + return fail('장수 정보를 찾을 수 없습니다.'); + } + + const nation = world.getNationById(actor.nationId); + const permission = resolveTroopSecretPermission(actor, nation?.meta ?? {}, false); + if (actor.id !== command.troopId && permission < 4) { + return fail('권한이 부족합니다.'); + } + + const troopName = normalizeTroopName(command.troopName); + if (!troopName) { + return fail('부대 이름이 없습니다.'); + } + if (!isValidTroopNameWidth(troopName)) { + return fail('부대 이름은 전각 9자 또는 반각 18자 이하여야 합니다.'); + } + + const troop = world.getTroopById(command.troopId); + if (!troop || actor.nationId <= 0 || troop.nationId !== actor.nationId) { + return fail('부대가 없습니다.'); + } + world.updateTroop(troop.id, { name: troopName }); + return { + type: 'troopRename', + ok: true, + generalId: actor.id, + troopId: troop.id, + troopName, + }; +} + async function handleDieOnPrestart( ctx: CommandHandlerContext, command: Extract @@ -1154,8 +1321,13 @@ export const createTurnDaemonCommandHandler = (options: { >; const handlers: HandlerMap = { + troopCreate: (command) => + handleTroopCreate(ctx, command as Extract), troopJoin: (command) => handleTroopJoin(ctx, command as Extract), troopExit: (command) => handleTroopExit(ctx, command as Extract), + troopKick: (command) => handleTroopKick(ctx, command as Extract), + troopRename: (command) => + handleTroopRename(ctx, command as Extract), dieOnPrestart: (command) => handleDieOnPrestart(ctx, command as Extract), buildNationCandidate: (command) => diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index 5fa9177..245cdc9 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -195,6 +195,7 @@ const mapGeneralRow = (row: TurnEngineGeneralRow): TurnGeneral => { meta: {}, }, itemInventory, + penalty: row.penalty, // meta는 상단에서 보장 처리됨. turnTime: row.turnTime, recentWarTime: row.recentWarTime ?? null, diff --git a/app/game-engine/test/troopManagement.test.ts b/app/game-engine/test/troopManagement.test.ts new file mode 100644 index 0000000..154df6e --- /dev/null +++ b/app/game-engine/test/troopManagement.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from 'vitest'; + +import type { TriggerValue, TurnSchedule } from '@sammo-ts/logic'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; +import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; + +const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; + +const buildGeneral = (id: number, overrides: Partial = {}): TurnGeneral => ({ + id, + name: `장수${id}`, + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + turnTime: new Date('0180-01-01T00:00:00Z'), + recentWarTime: null, + role: { + items: { horse: null, weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, + penalty: {}, + officerLevel: 1, + experience: 0, + dedication: 0, + injury: 0, + gold: 1000, + rice: 1000, + crew: 100, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 30, + npcState: 0, + ...overrides, +}); + +const buildWorld = (options: { + generals?: TurnGeneral[]; + troops?: Array<{ id: number; nationId: number; name: string }>; + nationMeta?: Record; +}) => { + const state: TurnWorldState = { + id: 1, + currentYear: 180, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0180-01-01T00:00:00Z'), + meta: { killturn: 24 }, + }; + const snapshot: TurnWorldSnapshot = { + generals: options.generals ?? [buildGeneral(1)], + cities: [], + nations: [ + { + id: 1, + name: '테스트국', + color: '#ff0000', + capitalCityId: null, + chiefGeneralId: 1, + gold: 1000, + rice: 1000, + power: 0, + level: 1, + typeCode: 'che_def', + meta: options.nationMeta ?? {}, + }, + ], + troops: options.troops ?? [], + diplomacy: [], + events: [], + initialEvents: [], + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'test' }, + }, + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + }; + return new InMemoryTurnWorld(state, snapshot, { schedule }); +}; + +describe('troop management world commands', () => { + it('creates a troop and assigns the authenticated general atomically in dirty state', async () => { + const world = buildWorld({}); + const handler = createTurnDaemonCommandHandler({ world }); + + await expect(handler.handle({ type: 'troopCreate', generalId: 1, troopName: ' 백마대 ' })).resolves.toEqual({ + type: 'troopCreate', + ok: true, + generalId: 1, + troopId: 1, + troopName: '백마대', + }); + expect(world.getGeneralById(1)?.troopId).toBe(1); + expect(world.getTroopById(1)).toEqual({ id: 1, nationId: 1, name: '백마대' }); + expect(world.peekDirtyState().createdTroops).toEqual([{ id: 1, nationId: 1, name: '백마대' }]); + + const escapedWorld = buildWorld({ generals: [buildGeneral(2)] }); + const escapedHandler = createTurnDaemonCommandHandler({ world: escapedWorld }); + await expect( + escapedHandler.handle({ type: 'troopCreate', generalId: 2, troopName: '<백마대>' }) + ).resolves.toMatchObject({ ok: true, troopName: '<백마대>' }); + }); + + it('preserves legacy creation failures without mutating state', async () => { + const assigned = buildWorld({ + generals: [buildGeneral(1, { troopId: 1 })], + troops: [{ id: 1, nationId: 1, name: '기존대' }], + }); + const assignedHandler = createTurnDaemonCommandHandler({ world: assigned }); + await expect( + assignedHandler.handle({ type: 'troopCreate', generalId: 1, troopName: '신규대' }) + ).resolves.toMatchObject({ ok: false, reason: '이미 부대에 소속되어 있습니다.' }); + + const blank = buildWorld({}); + const blankHandler = createTurnDaemonCommandHandler({ world: blank }); + await expect( + blankHandler.handle({ type: 'troopCreate', generalId: 1, troopName: ' ' }) + ).resolves.toMatchObject({ + ok: false, + reason: '부대 이름이 없습니다.', + }); + expect(blank.getGeneralById(1)?.troopId).toBe(0); + expect(blank.peekDirtyState().createdTroops).toEqual([]); + }); + + it('allows only the troop leader to kick a current non-leader member', async () => { + const buildFixture = () => + buildWorld({ + generals: [ + buildGeneral(1, { troopId: 1 }), + buildGeneral(2, { troopId: 1 }), + buildGeneral(3, { troopId: 1 }), + ], + troops: [{ id: 1, nationId: 1, name: '백마대' }], + }); + + const forbidden = buildFixture(); + const forbiddenHandler = createTurnDaemonCommandHandler({ world: forbidden }); + await expect( + forbiddenHandler.handle({ + type: 'troopKick', + generalId: 2, + troopId: 1, + targetGeneralId: 3, + }) + ).resolves.toMatchObject({ ok: false, reason: '권한이 부족합니다.' }); + expect(forbidden.getGeneralById(3)?.troopId).toBe(1); + + const allowed = buildFixture(); + const allowedHandler = createTurnDaemonCommandHandler({ world: allowed }); + await expect( + allowedHandler.handle({ + type: 'troopKick', + generalId: 1, + troopId: 1, + targetGeneralId: 3, + }) + ).resolves.toMatchObject({ ok: true, targetGeneralId: 3 }); + expect(allowed.getGeneralById(3)?.troopId).toBe(0); + + await expect( + allowedHandler.handle({ + type: 'troopKick', + generalId: 1, + troopId: 1, + targetGeneralId: 1, + }) + ).resolves.toMatchObject({ ok: false, reason: '부대장을 추방할 수 없습니다.' }); + }); + + it('renames for the leader or a same-nation top-secret actor and honors penalties', async () => { + const leaderWorld = buildWorld({ + generals: [buildGeneral(1, { troopId: 1, penalty: { noTopSecret: true } })], + troops: [{ id: 1, nationId: 1, name: '구대' }], + }); + const leaderHandler = createTurnDaemonCommandHandler({ world: leaderWorld }); + await expect( + leaderHandler.handle({ type: 'troopRename', generalId: 1, troopId: 1, troopName: '신대' }) + ).resolves.toMatchObject({ ok: true, troopName: '신대' }); + + const managerWorld = buildWorld({ + generals: [ + buildGeneral(1, { troopId: 1 }), + buildGeneral(2, { meta: { killturn: 24, permission: 'ambassador' } }), + ], + troops: [{ id: 1, nationId: 1, name: '구대' }], + }); + const managerHandler = createTurnDaemonCommandHandler({ world: managerWorld }); + await expect( + managerHandler.handle({ type: 'troopRename', generalId: 2, troopId: 1, troopName: '신대' }) + ).resolves.toMatchObject({ ok: true, troopName: '신대' }); + + const penalizedWorld = buildWorld({ + generals: [ + buildGeneral(1, { troopId: 1 }), + buildGeneral(2, { + meta: { killturn: 24, permission: 'ambassador' }, + penalty: { noTopSecret: true }, + }), + ], + troops: [{ id: 1, nationId: 1, name: '구대' }], + }); + const penalizedHandler = createTurnDaemonCommandHandler({ world: penalizedWorld }); + await expect( + penalizedHandler.handle({ type: 'troopRename', generalId: 2, troopId: 1, troopName: '신대' }) + ).resolves.toMatchObject({ ok: false, reason: '권한이 부족합니다.' }); + expect(penalizedWorld.getTroopById(1)?.name).toBe('구대'); + }); +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs new file mode 100644 index 0000000..eddbfbd --- /dev/null +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -0,0 +1,34 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, devices } from '@playwright/test'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); + +export default defineConfig({ + testDir: '.', + testMatch: 'troop.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'), + use: { + baseURL: 'http://127.0.0.1:15120/che/', + ...devices['Desktop Chrome'], + deviceScaleFactor: 1, + colorScheme: 'dark', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + webServer: { + command: + 'VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=/che/api/trpc pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port 15120', + cwd: repositoryRoot, + url: 'http://127.0.0.1:15120/che/', + reuseExistingServer: false, + timeout: 120_000, + }, +}); diff --git a/app/game-frontend/e2e/troop.spec.ts b/app/game-frontend/e2e/troop.spec.ts new file mode 100644 index 0000000..8d47bc8 --- /dev/null +++ b/app/game-frontend/e2e/troop.spec.ts @@ -0,0 +1,347 @@ +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 imageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../../image/game'); + +type Member = { id: number; name: string; cityId: number; cityName: string }; +type TroopFixture = { + id: number; + name: string; + nationId: number; + turnTime: string; + reservedCommands: string[]; + leader: { + id: number; + name: string; + cityId: number; + cityName: string; + picture: string | null; + imageServer: number; + }; + members: Member[]; +}; +type FixtureState = { + me: { id: number; troopId: number }; + permission: number; + troops: TroopFixture[]; + failCreate?: boolean; +}; + +const baseTroops = (): TroopFixture[] => [ + { + id: 1, + name: '백마대', + nationId: 1, + turnTime: '2026-07-25T08:20:30.000Z', + reservedCommands: ['che_집합', 'che_이동'], + leader: { + id: 1, + name: '공손찬', + cityId: 1, + cityName: '북평', + picture: 'default.jpg', + imageServer: 0, + }, + members: [ + { id: 1, name: '공손찬', cityId: 1, cityName: '북평' }, + { id: 3, name: '조운', cityId: 1, cityName: '북평' }, + { id: 4, name: '전예', cityId: 2, cityName: '계' }, + ], + }, + { + id: 2, + name: '청룡대', + nationId: 1, + turnTime: '2026-07-25T08:30:30.000Z', + reservedCommands: ['che_징병'], + leader: { + id: 2, + name: '관우', + cityId: 2, + cityName: '계', + picture: 'default.jpg', + imageServer: 0, + }, + members: [{ id: 2, name: '관우', cityId: 2, cityName: '계' }], + }, +]; + +const response = (data: unknown) => ({ result: { data } }); +const errorResponse = (path: string, message: string) => ({ + error: { + message, + code: -32000, + data: { code: 'BAD_REQUEST', httpStatus: 400, path }, + }, +}); +const operationName = (route: Route): string => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)); +}; + +const fulfillJson = async (route: Route, body: unknown) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(body), + }); +}; + +const gotoTroop = async (page: Page) => { + const lobbyResponse = page.waitForResponse((response) => response.url().includes('/trpc/lobby.info')); + await page.goto('troop'); + await lobbyResponse; +}; + +const installApiFixture = async (page: Page, state: FixtureState) => { + await page.addInitScript(() => { + window.localStorage.setItem('sammo-game-token', 'ga_playwright'); + window.localStorage.setItem('sammo-game-profile', 'che:default'); + }); + for (const filename of ['back_walnut.jpg', 'back_green.jpg']) { + await page.route(`**/image/game/${filename}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'image/jpeg', + body: await readFile(resolve(imageRoot, filename)), + }); + }); + } + await page.route('**/image/icons/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'image/png', + body: Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64' + ), + }); + }); + await page.route('**/che/api/trpc/**', async (route) => { + const operations = operationName(route).split(','); + const results = operations.map((operation) => { + if (operation === 'lobby.info') { + return response({ myGeneral: { id: state.me.id, name: '테스트 장수' } }); + } + if (operation === 'join.getConfig') { + return response({}); + } + if (operation === 'troop.getList') { + return response({ + nation: { id: 1, name: '테스트국' }, + me: state.me, + permission: state.permission, + troops: state.troops, + }); + } + if (operation === 'troop.create') { + if (state.failCreate) { + state.failCreate = false; + return errorResponse(operation, '부대 이름이 없습니다.'); + } + const createdId = state.me.id; + state.me.troopId = createdId; + state.troops.push({ + id: createdId, + name: '신규대', + nationId: 1, + turnTime: '2026-07-25T08:40:30.000Z', + reservedCommands: [], + leader: { + id: createdId, + name: '유비', + cityId: 1, + cityName: '북평', + picture: 'default.jpg', + imageServer: 0, + }, + members: [{ id: createdId, name: '유비', cityId: 1, cityName: '북평' }], + }); + return response({ ok: true, troopId: createdId, troopName: '신규대' }); + } + if (operation === 'troop.rename') { + state.troops[0]!.name = '백마의종'; + return response({ ok: true, troopName: '백마의종' }); + } + if (operation === 'troop.kick') { + state.troops[0]!.members = state.troops[0]!.members.filter((member) => member.id !== 3); + return response({ ok: true }); + } + return errorResponse(operation, `Unhandled fixture operation: ${operation}`); + }); + await fulfillJson(route, results); + }); +}; + +test('renders the legacy desktop grid with matching computed geometry and states', async ({ page }) => { + await installApiFixture(page, { + me: { id: 1, troopId: 1 }, + permission: 4, + troops: baseTroops(), + }); + await page.setViewportSize({ width: 1000, height: 800 }); + await gotoTroop(page); + await expect(page.locator('.troopInfo').filter({ hasText: '백마대' })).toBeVisible(); + + const geometry = await page + .locator('.troopItem') + .first() + .evaluate((item) => { + const origin = item.getBoundingClientRect(); + const box = (selector: string) => { + const rect = item.querySelector(selector)!.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }; + const members = item.querySelector('.troopMembers')!; + const style = getComputedStyle(members); + return { + item: { x: origin.x, y: origin.y, width: origin.width, height: origin.height }, + info: box('.troopInfo'), + icon: box('.troopLeaderIcon'), + reserved: box('.troopReservedCommand'), + members: box('.troopMembers'), + action: box('.troopAction'), + membersStyle: { + paddingTop: style.paddingTop, + paddingLeft: style.paddingLeft, + textAlign: style.textAlign, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + }, + }; + }); + expect(geometry.item).toEqual({ x: 0, y: 32, width: 1000, height: 127.5 }); + expect(geometry.info.width).toBeCloseTo(130, 0); + expect(geometry.info.height).toBeCloseTo(65, 0); + expect(geometry.icon.x - geometry.info.x).toBeCloseTo(130, 0); + expect(geometry.reserved.x - geometry.info.x).toBeCloseTo(260, 0); + expect(geometry.members.x - geometry.info.x).toBeCloseTo(360, 0); + expect(geometry.members.width).toBeCloseTo(639, 0); + expect(geometry.members.height).toBeCloseTo(93, 0); + expect(geometry.action.x - geometry.info.x).toBeCloseTo(65, 0); + expect(geometry.action.y - geometry.info.y).toBeCloseTo(93, 0); + expect(geometry.action.width).toBeCloseTo(934, 0); + expect(geometry.membersStyle).toEqual({ + paddingTop: '7px', + paddingLeft: '9.8px', + textAlign: 'left', + fontFamily: 'Pretendard, "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic"', + fontSize: '14px', + lineHeight: '21px', + }); + + const kickButton = page.getByRole('button', { name: '부대원 추방...' }).first(); + await kickButton.hover(); + const hoverStyle = await kickButton.evaluate((button) => ({ + cursor: getComputedStyle(button).cursor, + filter: getComputedStyle(button).filter, + })); + expect(hoverStyle.cursor).toBe('pointer'); + expect(hoverStyle.filter).not.toBe('none'); + + await page.locator('.troopMember').nth(1).hover(); + await expect(page.getByRole('tooltip')).toContainText('조운'); + expect(await page.getByRole('tooltip').evaluate((tooltip) => tooltip.getBoundingClientRect().width)).toBeCloseTo( + 500, + 0 + ); + await page.screenshot({ path: 'test-results/troop/desktop-leader.png', fullPage: true }); +}); + +test('matches the legacy 500px responsive placement', async ({ page }) => { + await installApiFixture(page, { + me: { id: 1, troopId: 1 }, + permission: 4, + troops: baseTroops(), + }); + await page.setViewportSize({ width: 500, height: 800 }); + await gotoTroop(page); + await expect(page.locator('.troopInfo').filter({ hasText: '백마대' })).toBeVisible(); + + const geometry = await page + .locator('.troopItem') + .first() + .evaluate((item) => { + const origin = item.getBoundingClientRect(); + const relative = (selector: string) => { + const rect = item.querySelector(selector)!.getBoundingClientRect(); + return { x: rect.x - origin.x, y: rect.y - origin.y, width: rect.width }; + }; + return { + item: { width: origin.width, height: origin.height }, + info: relative('.troopInfo'), + icon: relative('.troopLeaderIcon'), + reserved: relative('.troopReservedCommand'), + action: relative('.troopAction'), + members: relative('.troopMembers'), + }; + }); + expect(geometry.item).toEqual({ width: 500, height: 129 }); + expect(geometry.info).toMatchObject({ x: 0, y: 0, width: 130 }); + expect(geometry.icon).toMatchObject({ x: 130, y: 0, width: 130 }); + expect(geometry.reserved).toMatchObject({ x: 260, y: 0, width: 100 }); + expect(geometry.action).toMatchObject({ x: 360, y: 0, width: 140 }); + expect(geometry.members).toMatchObject({ x: 130, y: 93, width: 370 }); + await page.screenshot({ path: 'test-results/troop/mobile-leader.png', fullPage: true }); +}); + +test('shows API failure then creates a troop successfully', async ({ page }) => { + const state: FixtureState = { + me: { id: 7, troopId: 0 }, + permission: 0, + troops: baseTroops(), + failCreate: true, + }; + await installApiFixture(page, state); + await gotoTroop(page); + + const input = page.getByRole('textbox', { name: '부대명' }); + await input.fill('실패대'); + await page.getByRole('button', { name: '부대 창설', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('부대 이름이 없습니다.'); + expect(state.me.troopId).toBe(0); + + await input.fill('신규대'); + await page.getByRole('button', { name: '부대 창설', exact: true }).click(); + await expect(page.getByRole('status')).toContainText('신규대 부대가 생성되었습니다.'); + await expect(page.locator('.troopInfo').filter({ hasText: '신규대' })).toBeVisible(); + await expect(input).toBeHidden(); +}); + +test('renames and kicks through confirm dialogs, then refreshes state', async ({ page }) => { + const state: FixtureState = { + me: { id: 1, troopId: 1 }, + permission: 4, + troops: baseTroops(), + }; + await installApiFixture(page, state); + page.on('dialog', (dialog) => dialog.accept()); + await gotoTroop(page); + + await page.getByRole('button', { name: '부대명 변경...' }).first().click(); + await page.getByRole('textbox', { name: '새 부대명' }).fill('백마의종'); + await page.getByRole('button', { name: '변경', exact: true }).click(); + await expect(page.getByRole('status')).toContainText('부대명을 변경했습니다.'); + await expect(page.locator('.troopInfo').filter({ hasText: '백마의종' })).toBeVisible(); + + await page.getByRole('button', { name: '부대원 추방...' }).click(); + await page.getByRole('combobox', { name: '추방할 부대원' }).selectOption('3'); + await page.getByRole('button', { name: '추방', exact: true }).click(); + await expect(page.getByRole('status')).toContainText('조운을 추방했습니다.'); + await expect(page.locator('.troopMembers').first()).not.toContainText('조운'); +}); + +test('does not render management controls for an unauthorized member', async ({ page }) => { + await installApiFixture(page, { + me: { id: 3, troopId: 1 }, + permission: 1, + troops: baseTroops(), + }); + await gotoTroop(page); + await expect(page.getByRole('button', { name: '부대 탈퇴' })).toBeVisible(); + await expect(page.getByRole('button', { name: '부대원 추방...' })).toHaveCount(0); + await expect(page.getByRole('button', { name: '부대명 변경...' })).toHaveCount(0); +}); diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index df3671d..4d24888 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "vue-tsc && vite build", "preview": "vite preview", + "test:e2e:troop": "playwright test --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/main.ts b/app/game-frontend/src/main.ts index da2eff3..05866a0 100644 --- a/app/game-frontend/src/main.ts +++ b/app/game-frontend/src/main.ts @@ -3,7 +3,6 @@ import { createPinia } from 'pinia'; import App from './App.vue'; import router from './router'; import './assets/main.css'; -import { useSessionStore } from './stores/session'; const app = createApp(App); @@ -11,7 +10,4 @@ const pinia = createPinia(); app.use(pinia); app.use(router); -const session = useSessionStore(pinia); -void session.initialize(); - app.mount('#app'); diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index a81233d..2e14ecb 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -25,6 +25,7 @@ import HallOfFameView from '../views/HallOfFameView.vue'; import DynastyListView from '../views/DynastyListView.vue'; import DynastyDetailView from '../views/DynastyDetailView.vue'; import SurveyView from '../views/SurveyView.vue'; +import TroopView from '../views/TroopView.vue'; import { useSessionStore } from '../stores/session'; const routes = [ @@ -60,6 +61,15 @@ const routes = [ requiresGeneral: true, }, }, + { + path: '/troop', + name: 'troop', + component: TroopView, + meta: { + requiresAuth: true, + requiresGeneral: true, + }, + }, { path: '/nation/cities', name: 'nation-cities', diff --git a/app/game-frontend/src/stores/session.ts b/app/game-frontend/src/stores/session.ts index 6369a0f..ce50cb3 100644 --- a/app/game-frontend/src/stores/session.ts +++ b/app/game-frontend/src/stores/session.ts @@ -162,6 +162,11 @@ export const useSessionStore = defineStore('session', { this.setSessionToken(storedToken); } + const storedGameToken = this.gameToken ?? readStorage(GAME_TOKEN_KEY); + if (storedGameToken && storedGameToken !== this.gameToken) { + this.setGameToken(storedGameToken); + } + const storedProfile = this.profile ?? readStorage(PROFILE_KEY) ?? import.meta.env.VITE_GAME_PROFILE; if (storedProfile && storedProfile !== this.profile) { this.setProfile(storedProfile); diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index d2e5f27..00638de 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -94,6 +94,7 @@ watch( 세력 도시 세력 장수 인사부 + 부대 편성 내무부 외교부 사령부 diff --git a/app/game-frontend/src/views/TroopView.vue b/app/game-frontend/src/views/TroopView.vue new file mode 100644 index 0000000..bbc516f --- /dev/null +++ b/app/game-frontend/src/views/TroopView.vue @@ -0,0 +1,678 @@ + + + + + diff --git a/app/game-frontend/tsconfig.json b/app/game-frontend/tsconfig.json index bb3d6d6..bb471ef 100644 --- a/app/game-frontend/tsconfig.json +++ b/app/game-frontend/tsconfig.json @@ -38,7 +38,8 @@ "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", - "test/**/*.ts" + "test/**/*.ts", + "e2e/**/*.ts" ], "references": [ { diff --git a/package.json b/package.json index 39afe6a..92e0346 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "1.62.0", "@types/node": "^26.1.1", "@typescript-eslint/eslint-plugin": "^8.65.0", "@typescript-eslint/parser": "^8.65.0", diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index dd6a3a5..aa8f5aa 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -52,8 +52,17 @@ export type TurnDaemonCommand = | { type: 'resume'; requestId?: string; reason?: string } | { type: 'shutdown'; requestId?: string; reason?: string } | { type: 'getStatus'; requestId?: string } + | { type: 'troopCreate'; requestId?: string; generalId: number; troopName: string } | { type: 'troopJoin'; requestId?: string; generalId: number; troopId: number } | { type: 'troopExit'; requestId?: string; generalId: number } + | { + type: 'troopKick'; + requestId?: string; + generalId: number; + troopId: number; + targetGeneralId: number; + } + | { type: 'troopRename'; requestId?: string; generalId: number; troopId: number; troopName: string } | { type: 'dieOnPrestart'; requestId?: string; generalId: number } | { type: 'buildNationCandidate'; requestId?: string; generalId: number } | { type: 'instantRetreat'; requestId?: string; generalId: number } @@ -204,6 +213,19 @@ export type TurnDaemonCommandResult = auctionId: number; reason: string; } + | { + type: 'troopCreate'; + ok: true; + generalId: number; + troopId: number; + troopName: string; + } + | { + type: 'troopCreate'; + ok: false; + generalId: number; + reason: string; + } | { type: 'troopJoin'; ok: true; @@ -229,6 +251,35 @@ export type TurnDaemonCommandResult = generalId: number; reason: string; } + | { + type: 'troopKick'; + ok: true; + generalId: number; + troopId: number; + targetGeneralId: number; + } + | { + type: 'troopKick'; + ok: false; + generalId: number; + troopId: number; + targetGeneralId: number; + reason: string; + } + | { + type: 'troopRename'; + ok: true; + generalId: number; + troopId: number; + troopName: string; + } + | { + type: 'troopRename'; + ok: false; + generalId: number; + troopId: number; + reason: string; + } | { type: 'dieOnPrestart'; ok: boolean; generalId: number; reason?: string } | { type: 'buildNationCandidate'; ok: boolean; generalId: number; reason?: string } | { type: 'instantRetreat'; ok: boolean; generalId: number; reason?: string } diff --git a/packages/infra/src/turnEngineDb.ts b/packages/infra/src/turnEngineDb.ts index ffd2b44..9c14ece 100644 --- a/packages/infra/src/turnEngineDb.ts +++ b/packages/infra/src/turnEngineDb.ts @@ -43,6 +43,7 @@ export interface TurnEngineGeneralRow { age: number; npcState: number; meta: JsonValue; + penalty: JsonValue; turnTime: Date; recentWarTime: Date | null; } diff --git a/packages/logic/src/index.ts b/packages/logic/src/index.ts index 47d8f1d..6b164d0 100644 --- a/packages/logic/src/index.ts +++ b/packages/logic/src/index.ts @@ -18,5 +18,6 @@ export * from './scenario/index.js'; export * from './triggers/index.js'; export * from './turn/index.js'; export * from './tournament/index.js'; +export * from './troop/management.js'; export * from './world/index.js'; export * from './war/index.js'; diff --git a/packages/logic/src/troop/management.ts b/packages/logic/src/troop/management.ts new file mode 100644 index 0000000..9be19ee --- /dev/null +++ b/packages/logic/src/troop/management.ts @@ -0,0 +1,115 @@ +import { asRecord } from '@sammo-ts/common'; + +export interface TroopPermissionGeneral { + nationId: number; + officerLevel: number; + meta: unknown; + penalty?: unknown; +} + +const readNumber = (value: unknown, fallback = 0): number => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return fallback; +}; + +const isFullWidthCodePoint = (codePoint: number): boolean => + codePoint >= 0x1100 && + (codePoint <= 0x115f || + codePoint === 0x2329 || + codePoint === 0x232a || + (codePoint >= 0x2e80 && codePoint <= 0x3247 && codePoint !== 0x303f) || + (codePoint >= 0x3250 && codePoint <= 0x4dbf) || + (codePoint >= 0x4e00 && codePoint <= 0xa4c6) || + (codePoint >= 0xa960 && codePoint <= 0xa97c) || + (codePoint >= 0xac00 && codePoint <= 0xd7a3) || + (codePoint >= 0xf900 && codePoint <= 0xfaff) || + (codePoint >= 0xfe10 && codePoint <= 0xfe19) || + (codePoint >= 0xfe30 && codePoint <= 0xfe6b) || + (codePoint >= 0xff01 && codePoint <= 0xff60) || + (codePoint >= 0xffe0 && codePoint <= 0xffe6) || + (codePoint >= 0x1b000 && codePoint <= 0x1b001) || + (codePoint >= 0x1f200 && codePoint <= 0x1f251) || + (codePoint >= 0x20000 && codePoint <= 0x3fffd)); + +// PHP mb_strwidth와 같은 전각 2/반각 1 기준으로 부대명 길이를 센다. +export const getLegacyStringWidth = (value: string): number => { + let width = 0; + for (const character of value) { + const codePoint = character.codePointAt(0); + if (codePoint === undefined) { + continue; + } + if (codePoint === 0 || codePoint < 0x20 || (codePoint >= 0x7f && codePoint < 0xa0)) { + continue; + } + width += isFullWidthCodePoint(codePoint) ? 2 : 1; + } + return width; +}; + +// 레거시 StringUtil::neutralize처럼 HTML 특수문자를 저장용 문자열로 바꾼 뒤 +// 양 끝의 separator/control만 제거한다. +export const normalizeTroopName = (value: string): string => + value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll("'", ''') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replace(/^[\p{Z}\p{C}]+|[\p{Z}\p{C}]+$/gu, ''); + +export const isValidTroopNameWidth = (value: string): boolean => { + const width = getLegacyStringWidth(value); + return width >= 1 && width <= 18; +}; + +export const resolveTroopSecretPermission = ( + general: TroopPermissionGeneral, + nationMeta: unknown, + checkSecretLimit = false +): number => { + if (general.nationId <= 0 || general.officerLevel === 0) { + return -1; + } + + const penalty = asRecord(general.penalty); + if (penalty.noChief) { + return 0; + } + + const meta = asRecord(general.meta); + const permission = meta.permission; + const belong = readNumber(meta.belong, 0); + const nation = asRecord(nationMeta); + const secretLimit = readNumber(nation.secretlimit ?? nation.secretLimit, 3); + + let secretMax = 4; + if (penalty.noTopSecret || penalty.noChief) { + secretMax = 1; + } else if (penalty.noAmbassador) { + secretMax = 2; + } + + let secretMin = 0; + if (general.officerLevel === 12 || permission === 'ambassador') { + secretMin = 4; + } else if (permission === 'auditor') { + secretMin = 3; + } else if (general.officerLevel >= 5) { + secretMin = 2; + } else if (general.officerLevel > 1) { + secretMin = 1; + } else if (checkSecretLimit && belong >= secretLimit) { + secretMin = 1; + } + + return Math.min(secretMin, secretMax); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5159ef9..857a47b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.8.0(jiti@2.6.1)(supports-color@7.2.0)) + '@playwright/test': + specifier: 1.62.0 + version: 1.62.0 '@types/node': specifier: ^26.1.1 version: 26.1.1 @@ -1017,6 +1020,11 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} + '@playwright/test@1.62.0': + resolution: {integrity: sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==} + engines: {node: '>=20'} + hasBin: true + '@pm2/agent@2.0.4': resolution: {integrity: sha512-n7WYvvTJhHLS2oBb1PjOtgLpMhgImOq8sXkPBw6smeg9LJBWZjiEgPKOpR8mn9UJZsB5P3W4V/MyvNnp31LKeA==} @@ -2919,6 +2927,11 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3490,6 +3503,16 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + playwright-core@1.62.0: + resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.0: + resolution: {integrity: sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==} + engines: {node: '>=20'} + hasBin: true + pm2-axon-rpc@0.7.1: resolution: {integrity: sha512-FbLvW60w+vEyvMjP/xom2UPhUN/2bVpdtLfKJeYM3gwzYhoTEEChCOICfFzxkxuoEleOlnpjie+n1nue91bDQw==} engines: {node: '>=5'} @@ -4816,6 +4839,10 @@ snapshots: '@pkgr/core@0.3.6': {} + '@playwright/test@1.62.0': + dependencies: + playwright: 1.62.0 + '@pm2/agent@2.0.4(supports-color@7.2.0)': dependencies: async: 3.2.6 @@ -6532,6 +6559,9 @@ snapshots: fraction.js@5.3.4: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -7047,6 +7077,14 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + playwright-core@1.62.0: {} + + playwright@1.62.0: + dependencies: + playwright-core: 1.62.0 + optionalDependencies: + fsevents: 2.3.2 + pm2-axon-rpc@0.7.1(supports-color@7.2.0): dependencies: debug: 4.4.3(supports-color@7.2.0) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6db63a2..82420ef 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,6 +14,9 @@ allowBuilds: minimumReleaseAgeExclude: - eslint@10.8.0 + - '@playwright/test@1.62.0' + - playwright-core@1.62.0 + - playwright@1.62.0 onlyBuiltDependencies: - '@prisma/client'