diff --git a/app/game-api/src/router/nation/endpoints/changePermission.ts b/app/game-api/src/router/nation/endpoints/changePermission.ts index fbe0461..1ed0462 100644 --- a/app/game-api/src/router/nation/endpoints/changePermission.ts +++ b/app/game-api/src/router/nation/endpoints/changePermission.ts @@ -8,7 +8,10 @@ export const changePermission = authedProcedure .input( z.object({ isAmbassador: z.boolean(), - targetGeneralIds: z.array(z.number().int().positive()), + targetGeneralIds: z + .array(z.number().int().positive()) + .max(2) + .refine((ids) => new Set(ids).size === ids.length, '중복된 장수를 지정할 수 없습니다.'), }) ) .mutation(async ({ ctx, input }) => { diff --git a/app/game-api/src/router/nation/endpoints/getPersonnelInfo.ts b/app/game-api/src/router/nation/endpoints/getPersonnelInfo.ts index f0a2eea..90ea0e4 100644 --- a/app/game-api/src/router/nation/endpoints/getPersonnelInfo.ts +++ b/app/game-api/src/router/nation/endpoints/getPersonnelInfo.ts @@ -10,7 +10,7 @@ export const getPersonnelInfo = authedProcedure.query(async ({ ctx }) => { const me = await getMyGeneral(ctx); assertNationAccess(me); - const [nation, cityRows, troopRows, generalRows, worldState] = await Promise.all([ + const [nation, cityRows, troopRows, generalRows, worldState, rankRows] = await Promise.all([ ctx.db.nation.findUnique({ where: { id: me.nationId }, select: { @@ -25,7 +25,7 @@ export const getPersonnelInfo = authedProcedure.query(async ({ ctx }) => { }), ctx.db.city.findMany({ where: { nationId: me.nationId }, - select: { id: true, name: true, level: true, region: true }, + select: { id: true, name: true, level: true, region: true, meta: true }, orderBy: { id: 'asc' }, }), ctx.db.troop.findMany({ select: { troopLeaderId: true, name: true } }), @@ -38,6 +38,8 @@ export const getPersonnelInfo = authedProcedure.query(async ({ ctx }) => { nationId: true, cityId: true, troopId: true, + picture: true, + imageServer: true, officerLevel: true, leadership: true, strength: true, @@ -57,6 +59,15 @@ export const getPersonnelInfo = authedProcedure.query(async ({ ctx }) => { orderBy: { id: 'asc' }, }), ctx.db.worldState.findFirst(), + ctx.db.rankData.findMany({ + where: { + nationId: me.nationId, + type: { in: ['killnum', 'firenum'] }, + value: { gt: 0 }, + }, + select: { generalId: true, type: true, value: true }, + orderBy: [{ value: 'desc' }, { generalId: 'asc' }], + }), ]); if (!nation) { @@ -66,20 +77,42 @@ export const getPersonnelInfo = authedProcedure.query(async ({ ctx }) => { const cityNameMap = new Map(cityRows.map((city) => [city.id, city.name])); const troopNameMap = new Map(troopRows.map((troop) => [troop.troopLeaderId, troop.name])); const mappedGenerals = await mapGeneralList(generalRows, cityNameMap, troopNameMap); + const canManage = me.officerLevel >= 5; + const responseGenerals = canManage + ? mappedGenerals + : mappedGenerals.map((general) => ({ + ...general, + stats: { leadership: 0, strength: 0, intelligence: 0 }, + experience: 0, + dedication: 0, + injury: 0, + gold: 0, + rice: 0, + crew: 0, + troopId: 0, + troopName: null, + personality: null, + specialDomestic: null, + specialWar: null, + })); + const responseGeneralMap = new Map(responseGenerals.map((general) => [general.id, general])); + const visibleGenerals = responseGenerals.filter((general) => canManage || general.officerLevel >= 2); - const chiefAssignments = mappedGenerals + const chiefAssignments = responseGenerals .filter((general) => general.officerLevel >= 5) - .reduce>((acc, general) => { + .reduce>((acc, general) => { acc[general.officerLevel] = general; return acc; }, {}); const cityAssignments = cityRows.map((city) => { - const officers = mappedGenerals.filter( - (general) => general.officerLevel >= 2 && general.officerLevel <= 4 && general.officerCity === city.id - ); + const officers = mappedGenerals + .filter( + (general) => general.officerLevel >= 2 && general.officerLevel <= 4 && general.officerCity === city.id + ) + .map((general) => responseGeneralMap.get(general.id)!); - const officerMap: Record = { + const officerMap: Record = { 4: null, 3: null, 2: null, @@ -94,6 +127,7 @@ export const getPersonnelInfo = authedProcedure.query(async ({ ctx }) => { name: city.name, level: city.level, region: city.region, + officerSet: Number(asRecord(city.meta).officer_set ?? 0), officers: officerMap, }; }); @@ -116,17 +150,37 @@ export const getPersonnelInfo = authedProcedure.query(async ({ ctx }) => { }; }); - const ambassadors = permissionCandidates.filter( - (candidate) => candidate.permission === 'ambassador' || candidate.maxPermission === 4 - ); - const auditors = permissionCandidates.filter( - (candidate) => candidate.permission === 'auditor' || candidate.maxPermission >= 3 - ); + const canChangePermissions = me.officerLevel === 12; + const ambassadors = canChangePermissions + ? permissionCandidates.filter( + (candidate) => candidate.permission === 'ambassador' || candidate.maxPermission === 4 + ) + : []; + const auditors = canChangePermissions + ? permissionCandidates.filter((candidate) => candidate.permission === 'auditor' || candidate.maxPermission >= 3) + : []; + const generalNameMap = new Map(mappedGenerals.map((general) => [general.id, general.name])); + const awards = { + tigers: rankRows + .filter((row) => row.type === 'killnum') + .slice(0, 5) + .map((row) => ({ id: row.generalId, name: generalNameMap.get(row.generalId) ?? '-', value: row.value })), + eagles: rankRows + .filter((row) => row.type === 'firenum') + .slice(0, 7) + .map((row) => ({ id: row.generalId, name: generalNameMap.get(row.generalId) ?? '-', value: row.value })), + }; + const nationMeta = asRecord(nation.meta); + const mePenalty = penaltyMap.get(me.id) ?? {}; + const chiefSet = Number(nationMeta.chief_set ?? 0); return { me: { id: me.id, officerLevel: me.officerLevel, + canManage, + canChangePermissions, + canKick: canManage && mePenalty.noBanGeneral !== true && (chiefSet & (1 << me.officerLevel)) === 0, }, nation: { id: nation.id, @@ -135,11 +189,13 @@ export const getPersonnelInfo = authedProcedure.query(async ({ ctx }) => { level: nation.level, typeCode: nation.typeCode, capitalCityId: nation.capitalCityId ?? 0, + chiefSet, }, chiefStatMin: resolveChiefStatMin(worldState), - generals: mappedGenerals, + generals: visibleGenerals, chiefAssignments, cityAssignments, + awards, permissionCandidates: { ambassadors, auditors, diff --git a/app/game-api/src/router/nation/shared.ts b/app/game-api/src/router/nation/shared.ts index 6315b68..2a8c147 100644 --- a/app/game-api/src/router/nation/shared.ts +++ b/app/game-api/src/router/nation/shared.ts @@ -83,6 +83,8 @@ export type GeneralListRow = { nationId: number; cityId: number; troopId: number; + picture?: string | null; + imageServer?: number; officerLevel: number; leadership: number; strength: number; @@ -270,7 +272,8 @@ export const resolveNationScoutMessage = (meta: Record): string export const resolveWarSettingRemain = (meta: Record): number => { const legacy = readMetaNumber(meta, 'available_war_setting_cnt', -1); - const fallback = legacy >= 0 ? legacy : readMetaNumber(meta, 'availableWarSettingCnt', MAX_AVAILABLE_WAR_SETTING_CNT); + const fallback = + legacy >= 0 ? legacy : readMetaNumber(meta, 'availableWarSettingCnt', MAX_AVAILABLE_WAR_SETTING_CNT); return Math.max(0, Math.min(MAX_AVAILABLE_WAR_SETTING_CNT, fallback)); }; @@ -287,10 +290,7 @@ export const checkSecretMaxPermission = (penalty: Record): numb return 4; }; -export const loadTraitNames = async ( - keys: Array, - kind: keyof TraitCache -): Promise => { +export const loadTraitNames = async (keys: Array, kind: keyof TraitCache): Promise => { const cache = traitCache[kind]; const unique = Array.from(new Set(keys.filter((key): key is string => Boolean(key)))); const missing = unique.filter((key) => !cache.has(key)); @@ -481,8 +481,10 @@ export const mapGeneralList = async ( cityName: cityNameMap.get(general.cityId) ?? null, troopId: general.troopId, troopName: troopNameMap.get(general.troopId) ?? null, + picture: general.picture ?? null, + imageServer: general.imageServer ?? 0, officerCity, - officerCityName: officerCity > 0 ? cityNameMap.get(officerCity) ?? null : null, + officerCityName: officerCity > 0 ? (cityNameMap.get(officerCity) ?? null) : null, stats: { leadership: general.leadership, strength: general.strength, diff --git a/app/game-api/test/nationPersonnelRouter.test.ts b/app/game-api/test/nationPersonnelRouter.test.ts new file mode 100644 index 0000000..072ac3a --- /dev/null +++ b/app/game-api/test/nationPersonnelRouter.test.ts @@ -0,0 +1,255 @@ +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 type { TurnDaemonTransport } from '../src/daemon/transport.js'; +import { appRouter } from '../src/router.js'; + +const baseGeneral: GeneralRow = { + id: 22, + userId: 'user-22', + name: '인사담당', + nationId: 1, + cityId: 1, + troopId: 0, + npcState: 0, + affinity: null, + bornYear: 180, + deadYear: 300, + picture: 'default.jpg', + imageServer: 0, + leadership: 70, + strength: 70, + intel: 70, + injury: 0, + experience: 0, + dedication: 0, + officerLevel: 5, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: new Date('2026-01-01T00:00:00.000Z'), + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + lastTurn: {}, + meta: { killturn: 24, belong: 5, permission: 'normal' }, + penalty: {}, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +}; + +const auth: GameSessionTokenPayload = { + version: 1, + profile: 'che:default', + issuedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-02T00:00:00.000Z', + sessionId: 'session-22', + user: { id: 'user-22', username: 'tester', displayName: 'Tester', roles: [] }, + sanctions: {}, +}; + +const createContext = ( + options: { + me?: GeneralRow; + db?: Record; + requestCommand?: ReturnType; + } = {} +): GameApiContext => { + const requestCommand = options.requestCommand ?? vi.fn(); + const redisClient = { get: async () => null, set: async () => null }; + const db = { + general: { findFirst: vi.fn(async () => options.me ?? baseGeneral) }, + ...options.db, + }; + return { + 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: new RedisAccessTokenStore(redisClient, 'che:default'), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; +}; + +const listRow = (overrides: Record) => ({ + id: 1, + name: '장수1', + npcState: 0, + nationId: 1, + cityId: 1, + troopId: 0, + picture: 'default.jpg', + imageServer: 0, + officerLevel: 1, + leadership: 70, + strength: 70, + intel: 70, + experience: 0, + dedication: 0, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 0, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + meta: { belong: 5, permission: 'normal' }, + penalty: {}, + ...overrides, +}); + +describe('nation personnel router', () => { + it('always dispatches the authenticated general and never accepts a client actor id', async () => { + const requestCommand = vi + .fn() + .mockResolvedValueOnce({ type: 'appoint', ok: true, generalId: 22 }) + .mockResolvedValueOnce({ type: 'kick', ok: true, generalId: 22 }) + .mockResolvedValueOnce({ type: 'changePermission', ok: true, generalId: 22 }); + const caller = appRouter.createCaller(createContext({ requestCommand })); + + await caller.nation.appoint({ destGeneralId: 7, destCityId: 1, officerLevel: 4 }); + await caller.nation.kick({ destGeneralId: 8 }); + await caller.nation.changePermission({ isAmbassador: true, targetGeneralIds: [9] }); + + expect(requestCommand.mock.calls).toEqual([ + [{ type: 'appoint', generalId: 22, destGeneralId: 7, destCityId: 1, officerLevel: 4 }], + [{ type: 'kick', generalId: 22, destGeneralId: 8 }], + [{ type: 'changePermission', generalId: 22, isAmbassador: true, targetGeneralIds: [9] }], + ]); + }); + + it('rejects oversized and duplicate permission selections before daemon dispatch', async () => { + const requestCommand = vi.fn(); + const caller = appRouter.createCaller(createContext({ requestCommand })); + await expect( + caller.nation.changePermission({ isAmbassador: true, targetGeneralIds: [1, 2, 3] }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + await expect( + caller.nation.changePermission({ isAmbassador: false, targetGeneralIds: [1, 1] }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(requestCommand).not.toHaveBeenCalled(); + }); + + it('redacts management candidates for an ordinary member but keeps appointed officers and awards visible', async () => { + const me = { ...baseGeneral, officerLevel: 1 }; + const rows = [ + listRow({ id: 22, name: '일반인', officerLevel: 1 }), + listRow({ id: 30, name: '군사', officerLevel: 3, meta: { belong: 8, officerCity: 1 } }), + listRow({ id: 31, name: '후보', officerLevel: 1, gold: 99_999, rice: 99_999 }), + ]; + const context = createContext({ + me, + db: { + nation: { + findUnique: vi.fn(async () => ({ + id: 1, + name: '위', + color: '#777777', + level: 3, + typeCode: 'che_법가', + capitalCityId: 1, + meta: { chief_set: 0 }, + })), + }, + city: { + findMany: vi.fn(async () => [ + { id: 1, name: '허창', level: 7, region: 2, meta: { officer_set: 0 } }, + ]), + }, + troop: { findMany: vi.fn(async () => []) }, + general: { + findFirst: vi.fn(async () => me), + findMany: vi.fn(async () => rows), + }, + worldState: { findFirst: vi.fn(async () => ({ config: { stat: { chiefMin: 65 } } })) }, + rankData: { + findMany: vi.fn(async () => [{ generalId: 30, type: 'firenum', value: 7 }]), + }, + }, + }); + + const result = await appRouter.createCaller(context).nation.getPersonnelInfo(); + expect(result.me).toMatchObject({ id: 22, canManage: false, canChangePermissions: false, canKick: false }); + expect(result.generals.map((general) => general.id)).toEqual([30]); + expect(result.permissionCandidates).toEqual({ ambassadors: [], auditors: [] }); + expect(result.cityAssignments[0]?.officers[3]?.name).toBe('군사'); + expect(result.cityAssignments[0]?.officers[3]).toMatchObject({ + stats: { leadership: 0, strength: 0, intelligence: 0 }, + gold: 0, + rice: 0, + crew: 0, + troopId: 0, + }); + expect(result.awards.eagles).toEqual([{ id: 30, name: '군사', value: 7 }]); + }); + + it('allows finance mutations only for a head officer or an eligible ambassador', async () => { + const nationDb = { + nation: { + findUnique: vi.fn(async () => ({ meta: { _updatedAt: '2026-01-01T00:00:00.000Z' } })), + }, + }; + const makeCommand = () => + vi.fn(async () => ({ + type: 'setNationMeta', + ok: true, + nationId: 1, + updatedAt: '2026-01-01T00:01:00.000Z', + })); + + const headCommand = makeCommand(); + await expect( + appRouter.createCaller(createContext({ db: nationDb, requestCommand: headCommand })).nation.setRate({ + amount: 20, + }) + ).resolves.toEqual({ ok: true }); + expect(headCommand).toHaveBeenCalledWith({ + type: 'setNationMeta', + nationId: 1, + updates: { rate: 20 }, + expectedUpdatedAt: '2026-01-01T00:00:00.000Z', + }); + + const ambassadorCommand = makeCommand(); + const ambassador = { + ...baseGeneral, + officerLevel: 1, + meta: { killturn: 24, belong: 5, permission: 'ambassador' }, + }; + await expect( + appRouter + .createCaller(createContext({ me: ambassador, db: nationDb, requestCommand: ambassadorCommand })) + .nation.setRate({ amount: 25 }) + ).resolves.toEqual({ ok: true }); + + const memberCommand = makeCommand(); + const member = { ...baseGeneral, officerLevel: 1 }; + await expect( + appRouter + .createCaller(createContext({ me: member, db: nationDb, requestCommand: memberCommand })) + .nation.setRate({ amount: 25 }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + expect(memberCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 3be3c7c..bd3a5f5 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -23,6 +23,7 @@ import { type ItemModule, type TriggerValue, } from '@sammo-ts/logic'; +import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; import { cloneItemInventory, ensureItemInventory, @@ -56,6 +57,51 @@ const readMetaNumber = (meta: Record, key: string, fallback: nu return fallback; }; +const hasOfficerLock = (meta: Record, key: string, officerLevel: number): boolean => + (readMetaNumber(meta, key, 0) & (1 << officerLevel)) !== 0; + +const setOfficerLock = ( + meta: Record, + key: string, + officerLevel: number +): Record => ({ + ...meta, + [key]: readMetaNumber(meta, key, 0) | (1 << officerLevel), +}); + +const resolveNationChiefLevel = (nationLevel: number): number => { + if (nationLevel >= 6) return 5; + if (nationLevel >= 4) return 7; + if (nationLevel >= 2) return 9; + return 11; +}; + +const resolvePermissionKind = (general: TurnGeneral): 'normal' | 'ambassador' | 'auditor' => { + const permission = general.meta.permission; + return permission === 'ambassador' || permission === 'auditor' ? permission : 'normal'; +}; + +const resolveMaxSecretPermission = (general: TurnGeneral): number => { + const penalty = asRecord(general.penalty); + if (penalty.noTopSecret || penalty.noChief) return 1; + if (penalty.noAmbassador) return 2; + return 4; +}; + +const refreshActorKillturn = (world: InMemoryTurnWorld, actor: TurnGeneral): void => { + const worldKillturn = readMetaNumber(asRecord(world.getState().meta), 'killturn', 0); + const actorKillturn = readMetaNumber(asRecord(actor.meta), 'killturn', 0); + if (worldKillturn <= actorKillturn) { + return; + } + world.updateGeneral(actor.id, { + meta: { + ...actor.meta, + killturn: worldKillturn, + }, + }); +}; + interface CommandHandlerContext { world: InMemoryTurnWorld; commandDb?: GamePrisma.TransactionClient; @@ -868,20 +914,57 @@ async function handleChangePermission( }; } const nation = world.getNationById(general.nationId); - if (!nation || nation.chiefGeneralId !== general.id) { - return { type: 'changePermission', ok: false, generalId: command.generalId, reason: '권한이 없습니다.' }; + if (!nation || general.officerLevel !== 12 || nation.chiefGeneralId !== general.id) { + return { type: 'changePermission', ok: false, generalId: command.generalId, reason: '군주가 아닙니다.' }; } - for (const targetId of command.targetGeneralIds) { + const uniqueTargetIds = [...new Set(command.targetGeneralIds)]; + if (uniqueTargetIds.length > 2) { + return { + type: 'changePermission', + ok: false, + generalId: command.generalId, + reason: command.isAmbassador + ? '외교권자는 최대 둘까지만 설정 가능합니다.' + : '조언자는 최대 둘까지만 설정 가능합니다.', + }; + } + + const targetType = command.isAmbassador ? 'ambassador' : 'auditor'; + const requiredPermission = command.isAmbassador ? 4 : 3; + const targets: TurnGeneral[] = []; + for (const targetId of uniqueTargetIds) { const target = world.getGeneralById(targetId); - if (target && target.nationId === general.nationId) { - world.updateGeneral(targetId, { - meta: { - ...target.meta, - permission: command.isAmbassador ? 'ambassador' : 'auditor', - }, - }); + if ( + !target || + target.nationId !== general.nationId || + target.officerLevel === 12 || + !['normal', targetType].includes(resolvePermissionKind(target)) || + resolveMaxSecretPermission(target) < requiredPermission + ) { + continue; } + targets.push(target); + } + + for (const candidate of world.listGenerals()) { + if (candidate.nationId !== general.nationId || resolvePermissionKind(candidate) !== targetType) { + continue; + } + world.updateGeneral(candidate.id, { + meta: { + ...candidate.meta, + permission: 'normal', + }, + }); + } + for (const target of targets) { + world.updateGeneral(target.id, { + meta: { + ...target.meta, + permission: targetType, + }, + }); } return { type: 'changePermission', ok: true, generalId: command.generalId }; } @@ -896,12 +979,24 @@ async function handleKick( return { type: 'kick', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' }; } const nation = world.getNationById(general.nationId); - if (!nation || nation.chiefGeneralId !== general.id) { - return { type: 'kick', ok: false, generalId: command.generalId, reason: '권한이 없습니다.' }; + if (!nation || general.officerLevel < 5) { + return { type: 'kick', ok: false, generalId: command.generalId, reason: '수뇌가 아닙니다.' }; + } + const actorPenalty = asRecord(general.penalty); + if (actorPenalty.noBanGeneral) { + return { type: 'kick', ok: false, generalId: command.generalId, reason: '추방할 수 없는 상태입니다.' }; + } + if (hasOfficerLock(asRecord(nation.meta), 'chief_set', general.officerLevel)) { + return { + type: 'kick', + ok: false, + generalId: command.generalId, + reason: '이미 추방 권한을 사용했습니다.', + }; } const target = world.getGeneralById(command.destGeneralId); - if (!target || target.nationId !== general.nationId) { + if (!target || target.id === general.id || target.nationId !== general.nationId) { return { type: 'kick', ok: false, @@ -909,11 +1004,138 @@ async function handleKick( reason: '대상을 찾을 수 없거나 같은 국가가 아닙니다.', }; } + if (resolveMaxSecretPermission(target) === 4 && resolvePermissionKind(target) === 'ambassador') { + return { + type: 'kick', + ok: false, + generalId: command.generalId, + reason: '외교권자는 추방할 수 없습니다.', + }; + } + + const config = asRecord(world.getScenarioConfig().const); + const defaultGold = readMetaNumber(config, 'defaultGold', 1000); + const defaultRice = readMetaNumber(config, 'defaultRice', 1000); + const returnedGold = Math.max(0, target.gold - defaultGold); + const returnedRice = Math.max(0, target.rice - defaultRice); + const targetMeta = target.meta; + const nextMeta: TurnGeneral['meta'] = { + ...targetMeta, + officerCity: 0, + officer_city: 0, + belong: 0, + makelimit: 12, + permission: 'normal', + }; + + const worldState = world.getState(); + const scenarioMeta = asRecord(asRecord(worldState.meta).scenarioMeta); + const startYear = readMetaNumber(scenarioMeta, 'startYear', worldState.currentYear); + if (worldState.currentYear > startYear || target.npcState >= 2) { + const betray = Math.max(0, readMetaNumber(targetMeta, 'betray', 0)); + const maxBetrayCnt = readMetaNumber(config, 'maxBetrayCnt', 9); + nextMeta.betray = Math.min(maxBetrayCnt, betray + 1); + world.updateGeneral(target.id, { + experience: Math.max(0, Math.floor(target.experience - target.experience * 0.15 * betray)), + dedication: Math.max(0, Math.floor(target.dedication - target.dedication * 0.15 * betray)), + }); + } else { + nextMeta.makelimit = targetMeta.makelimit ?? 12; + } + if (worldState.currentYear < startYear + 3) { + const ruler = world.getGeneralById(nation.chiefGeneralId ?? 0); + if (ruler) { + world.updateGeneral(ruler.id, { injury: Math.min(80, ruler.injury + 1) }); + } + } + + if (target.troopId === target.id) { + for (const member of world.listGenerals()) { + if (member.troopId === target.id) { + world.updateGeneral(member.id, { troopId: 0 }); + } + } + world.removeTroop(target.id); + } world.updateGeneral(command.destGeneralId, { nationId: 0, officerLevel: 0, + troopId: 0, + gold: Math.min(target.gold, defaultGold), + rice: Math.min(target.rice, defaultRice), + meta: nextMeta, }); + const nationMeta = + worldState.currentYear >= startYear + 3 + ? setOfficerLock(nation.meta, 'chief_set', general.officerLevel) + : { ...nation.meta }; + nationMeta.gennum = Math.max(0, readMetaNumber(nation.meta, 'gennum', 0) - (target.npcState !== 5 ? 1 : 0)); + world.updateNation(nation.id, { + gold: nation.gold + returnedGold, + rice: nation.rice + returnedRice, + meta: nationMeta, + }); + refreshActorKillturn(world, general); + + const josaYi = JosaUtil.pick(target.name, '이'); + world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.SUMMARY, + format: LogFormat.MONTH, + text: `${target.name}${josaYi} ${nation.name}에서 추방당했습니다.`, + meta: {}, + }); + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + text: `${nation.name}에서 추방됨`, + generalId: target.id, + meta: {}, + }); + if (target.npcState >= 2) { + const worldMeta = asRecord(worldState.meta); + const hiddenSeed = worldMeta.hiddenSeed ?? worldMeta.seed ?? worldState.id; + const rng = new RandUtil( + new LiteHashDRBG( + simpleSerialize( + typeof hiddenSeed === 'string' || typeof hiddenSeed === 'number' ? hiddenSeed : String(hiddenSeed), + 'BanNPC', + worldState.currentYear, + worldState.currentMonth, + target.id + ) + ) + ); + const npcBanMessageProb = readMetaNumber(config, 'npcBanMessageProb', 0.01); + if (rng.nextBool(npcBanMessageProb)) { + const text = rng.choice([ + '날 버리다니... 곧 전장에서 복수해주겠다...', + '추방이라... 내가 무얼 잘못했단 말인가...', + '어디 추방해가면서 잘되나 보자... 꼭 복수하겠다.', + '인덕이 제일이거늘... 추방이 웬말인가... 저주한다!', + '날 추방했으니 그 복수로 적국에 정보를 팔아 넘겨야겠군요. 그럼 이만.', + ]); + const messageTarget = { + generalId: target.id, + generalName: target.name, + nationId: nation.id, + nationName: nation.name, + color: nation.color, + icon: target.picture === null ? '' : String(target.picture), + }; + world.queueMessage({ + msgType: 'public', + src: messageTarget, + dest: messageTarget, + text, + time: new Date(), + validUntil: new Date('9999-12-31T00:00:00.000Z'), + option: {}, + }); + } + } return { type: 'kick', ok: true, generalId: command.generalId }; } @@ -927,8 +1149,17 @@ async function handleAppoint( return { type: 'appoint', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' }; } const nation = world.getNationById(general.nationId); - if (!nation || nation.chiefGeneralId !== general.id) { - return { type: 'appoint', ok: false, generalId: command.generalId, reason: '권한이 없습니다.' }; + if (!nation || general.officerLevel < 5) { + return { type: 'appoint', ok: false, generalId: command.generalId, reason: '수뇌가 아닙니다.' }; + } + const actorPenalty = asRecord(general.penalty); + if (command.officerLevel === 12) { + return { + type: 'appoint', + ok: false, + generalId: command.generalId, + reason: '군주를 대상으로 할 수 없습니다.', + }; } const target = world.getGeneralById(command.destGeneralId); @@ -941,16 +1172,72 @@ async function handleAppoint( }; } - if (command.officerLevel >= 5) { + if (command.officerLevel >= 5 && command.officerLevel < 12) { + if (actorPenalty.noChiefChange) { + return { + type: 'appoint', + ok: false, + generalId: command.generalId, + reason: '수뇌를 임명할 수 없는 상태입니다.', + }; + } + const minLevel = resolveNationChiefLevel(nation.level); + if (command.officerLevel < minLevel) { + return { + type: 'appoint', + ok: false, + generalId: command.generalId, + reason: '임명불가능한 관직입니다.', + }; + } + if (hasOfficerLock(asRecord(nation.meta), 'chief_set', command.officerLevel)) { + return { + type: 'appoint', + ok: false, + generalId: command.generalId, + reason: '지금은 임명할 수 없습니다.', + }; + } + if (target) { + const targetPenalty = asRecord(target.penalty); + if (targetPenalty.noChief) { + return { + type: 'appoint', + ok: false, + generalId: command.generalId, + reason: '수뇌가 될 수 없는 상태입니다.', + }; + } + const chiefStatMin = world.getScenarioConfig().stat.chiefMin; + if (command.officerLevel !== 11 && command.officerLevel % 2 === 0 && target.stats.strength < chiefStatMin) { + return { type: 'appoint', ok: false, generalId: command.generalId, reason: '무력이 부족합니다.' }; + } + if ( + command.officerLevel !== 11 && + command.officerLevel % 2 === 1 && + target.stats.intelligence < chiefStatMin + ) { + return { type: 'appoint', ok: false, generalId: command.generalId, reason: '지력이 부족합니다.' }; + } + } for (const g of world.listGenerals()) { if (g.nationId === general.nationId && g.officerLevel === command.officerLevel) { - world.updateGeneral(g.id, { officerLevel: 0 }); + world.updateGeneral(g.id, { + officerLevel: 1, + meta: { ...g.meta, officerCity: 0, officer_city: 0 }, + }); } } if (command.destGeneralId !== 0) { - world.updateGeneral(command.destGeneralId, { officerLevel: command.officerLevel }); + world.updateGeneral(command.destGeneralId, { + officerLevel: command.officerLevel, + meta: { ...target!.meta, officerCity: 0, officer_city: 0 }, + }); } - } else { + world.updateNation(nation.id, { + meta: setOfficerLock(nation.meta, 'chief_set', command.officerLevel), + }); + } else if (command.officerLevel >= 2 && command.officerLevel <= 4) { const city = world.getCityById(command.destCityId); if (!city || city.nationId !== general.nationId) { return { @@ -960,22 +1247,54 @@ async function handleAppoint( reason: '도시를 찾을 수 없거나 아군 도시가 아닙니다.', }; } + if (hasOfficerLock(asRecord(city.meta), 'officer_set', command.officerLevel)) { + return { + type: 'appoint', + ok: false, + generalId: command.generalId, + reason: '이미 다른 장수가 임명되어있습니다.', + }; + } + if (target && target.officerLevel >= 4 && actorPenalty.noChiefChange) { + return { + type: 'appoint', + ok: false, + generalId: command.generalId, + reason: '수뇌인 장수를 변경할 수 없는 상태입니다.', + }; + } + const chiefStatMin = world.getScenarioConfig().stat.chiefMin; + if (target && command.officerLevel === 4 && target.stats.strength < chiefStatMin) { + return { type: 'appoint', ok: false, generalId: command.generalId, reason: '무력이 부족합니다.' }; + } + if (target && command.officerLevel === 3 && target.stats.intelligence < chiefStatMin) { + return { type: 'appoint', ok: false, generalId: command.generalId, reason: '지력이 부족합니다.' }; + } for (const g of world.listGenerals()) { if ( g.nationId === general.nationId && g.meta.officerCity === command.destCityId && g.officerLevel === command.officerLevel ) { - world.updateGeneral(g.id, { officerLevel: 0, meta: { ...g.meta, officerCity: 0 } }); + world.updateGeneral(g.id, { + officerLevel: 1, + meta: { ...g.meta, officerCity: 0, officer_city: 0 }, + }); } } if (command.destGeneralId !== 0) { world.updateGeneral(command.destGeneralId, { officerLevel: command.officerLevel, - meta: { ...target!.meta, officerCity: command.destCityId }, + meta: { ...target!.meta, officerCity: command.destCityId, officer_city: command.destCityId }, }); } + world.updateCity(city.id, { + meta: setOfficerLock(city.meta, 'officer_set', command.officerLevel), + }); + } else { + return { type: 'appoint', ok: false, generalId: command.generalId, reason: '올바르지 않은 지정입니다.' }; } + refreshActorKillturn(world, general); return { type: 'appoint', ok: true, generalId: command.generalId }; } diff --git a/app/game-engine/test/nationPersonnelManagement.test.ts b/app/game-engine/test/nationPersonnelManagement.test.ts new file mode 100644 index 0000000..aa42b49 --- /dev/null +++ b/app/game-engine/test/nationPersonnelManagement.test.ts @@ -0,0 +1,350 @@ +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, + userId: `user-${id}`, + name: `장수${id}`, + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 70, strength: 70, intelligence: 70 }, + turnTime: new Date('0185-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: 12, belong: 5, permission: 'normal' }, + penalty: {}, + officerLevel: 1, + experience: 1_000, + dedication: 2_000, + injury: 0, + gold: 1_500, + rice: 1_600, + crew: 100, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 30, + npcState: 0, + ...overrides, +}); + +const buildWorld = (options: { + generals?: TurnGeneral[]; + nationMeta?: Record; + cityMeta?: Record; + currentYear?: number; + scenarioConst?: Record; +}) => { + const state: TurnWorldState = { + id: 1, + currentYear: options.currentYear ?? 185, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0185-01-01T00:00:00Z'), + meta: { killturn: 24, scenarioMeta: { startYear: 180 } }, + }; + const snapshot: TurnWorldSnapshot = { + generals: options.generals ?? [ + buildGeneral(1, { officerLevel: 12 }), + buildGeneral(2, { officerLevel: 5 }), + buildGeneral(3), + ], + cities: [ + { + id: 1, + name: '허창', + nationId: 1, + level: 7, + state: 0, + population: 1_000, + populationMax: 2_000, + agriculture: 1_000, + agricultureMax: 2_000, + commerce: 1_000, + commerceMax: 2_000, + security: 1_000, + securityMax: 2_000, + supplyState: 1, + frontState: 0, + defence: 1_000, + defenceMax: 2_000, + wall: 1_000, + wallMax: 2_000, + meta: options.cityMeta ?? {}, + }, + ], + nations: [ + { + id: 1, + name: '위', + color: '#777777', + capitalCityId: 1, + chiefGeneralId: 1, + gold: 10_000, + rice: 20_000, + power: 0, + level: 3, + typeCode: 'che_법가', + meta: options.nationMeta ?? {}, + }, + ], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 }, + iconPath: '', + map: {}, + const: { defaultGold: 1000, defaultRice: 1000, ...options.scenarioConst }, + environment: { mapName: 'test', unitSet: 'test' }, + }, + scenarioMeta: { + title: 'test', + startYear: 180, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + }; + const world = new InMemoryTurnWorld(state, snapshot, { schedule }); + return { world, handler: createTurnDaemonCommandHandler({ world }) }; +}; + +describe('nation personnel world commands', () => { + it('allows any unlocked head officer to appoint and preserves legacy officer state', async () => { + const { world, handler } = buildWorld({}); + await expect( + handler.handle({ type: 'appoint', generalId: 2, destGeneralId: 3, destCityId: 0, officerLevel: 9 }) + ).resolves.toMatchObject({ ok: true }); + expect(world.getGeneralById(3)).toMatchObject({ + officerLevel: 9, + meta: expect.objectContaining({ officerCity: 0, officer_city: 0 }), + }); + expect(world.getGeneralById(2)?.meta.killturn).toBe(24); + expect(Number(world.getNationById(1)?.meta.chief_set) & (1 << 9)).toBe(1 << 9); + }); + + it('enforces actor, stat, penalty, and monthly lock boundaries without partial mutation', async () => { + const weak = buildGeneral(3, { stats: { leadership: 70, strength: 64, intelligence: 64 } }); + const fixture = buildWorld({ + generals: [buildGeneral(1, { officerLevel: 12 }), buildGeneral(2, { officerLevel: 5 }), weak], + }); + await expect( + fixture.handler.handle({ + type: 'appoint', + generalId: 3, + destGeneralId: 2, + destCityId: 0, + officerLevel: 9, + }) + ).resolves.toMatchObject({ ok: false, reason: '수뇌가 아닙니다.' }); + await expect( + fixture.handler.handle({ + type: 'appoint', + generalId: 2, + destGeneralId: 3, + destCityId: 0, + officerLevel: 9, + }) + ).resolves.toMatchObject({ ok: false, reason: '지력이 부족합니다.' }); + expect(fixture.world.getGeneralById(3)?.officerLevel).toBe(1); + + const locked = buildWorld({ nationMeta: { chief_set: 1 << 9 } }); + await expect( + locked.handler.handle({ + type: 'appoint', + generalId: 2, + destGeneralId: 3, + destCityId: 0, + officerLevel: 9, + }) + ).resolves.toMatchObject({ ok: false, reason: '지금은 임명할 수 없습니다.' }); + }); + + it('appoints city officers, releases prior holders to general, and respects city locks', async () => { + const previous = buildGeneral(4, { officerLevel: 4, meta: { killturn: 12, officerCity: 1 } }); + const fixture = buildWorld({ + generals: [ + buildGeneral(1, { officerLevel: 12 }), + buildGeneral(2, { officerLevel: 5 }), + buildGeneral(3), + previous, + ], + }); + await expect( + fixture.handler.handle({ + type: 'appoint', + generalId: 2, + destGeneralId: 3, + destCityId: 1, + officerLevel: 4, + }) + ).resolves.toMatchObject({ ok: true }); + expect(fixture.world.getGeneralById(4)).toMatchObject({ + officerLevel: 1, + meta: expect.objectContaining({ officerCity: 0 }), + }); + expect(fixture.world.getGeneralById(3)).toMatchObject({ + officerLevel: 4, + meta: expect.objectContaining({ officerCity: 1 }), + }); + + const locked = buildWorld({ cityMeta: { officer_set: 1 << 4 } }); + await expect( + locked.handler.handle({ + type: 'appoint', + generalId: 2, + destGeneralId: 3, + destCityId: 1, + officerLevel: 4, + }) + ).resolves.toMatchObject({ ok: false, reason: '이미 다른 장수가 임명되어있습니다.' }); + }); + + it('replaces only eligible permission holders and rejects non-ruler or oversized requests', async () => { + const fixture = buildWorld({ + generals: [ + buildGeneral(1, { officerLevel: 12 }), + buildGeneral(2, { officerLevel: 5, meta: { killturn: 12, permission: 'ambassador' } }), + buildGeneral(3), + buildGeneral(4, { penalty: { noAmbassador: true } }), + buildGeneral(5), + buildGeneral(6), + ], + }); + await expect( + fixture.handler.handle({ + type: 'changePermission', + generalId: 2, + isAmbassador: true, + targetGeneralIds: [3], + }) + ).resolves.toMatchObject({ ok: false, reason: '군주가 아닙니다.' }); + await expect( + fixture.handler.handle({ + type: 'changePermission', + generalId: 1, + isAmbassador: true, + targetGeneralIds: [3, 5, 6], + }) + ).resolves.toMatchObject({ ok: false, reason: expect.stringContaining('최대 둘') }); + + await expect( + fixture.handler.handle({ + type: 'changePermission', + generalId: 1, + isAmbassador: true, + targetGeneralIds: [2, 3, 4], + }) + ).resolves.toMatchObject({ ok: false, reason: expect.stringContaining('최대 둘') }); + + await expect( + fixture.handler.handle({ + type: 'changePermission', + generalId: 1, + isAmbassador: true, + targetGeneralIds: [2, 3], + }) + ).resolves.toMatchObject({ ok: true }); + expect(fixture.world.getGeneralById(2)?.meta.permission).toBe('ambassador'); + expect(fixture.world.getGeneralById(3)?.meta.permission).toBe('ambassador'); + expect(fixture.world.getGeneralById(4)?.meta.permission).toBe('normal'); + }); + + it('kicks for an unlocked head officer with resource, troop, permission, and log side effects', async () => { + const target = buildGeneral(3, { + troopId: 3, + gold: 2_500, + rice: 3_000, + experience: 1_000, + dedication: 2_000, + meta: { killturn: 12, permission: 'normal', belong: 8, betray: 1 }, + }); + const member = buildGeneral(4, { troopId: 3 }); + const fixture = buildWorld({ + generals: [buildGeneral(1, { officerLevel: 12 }), buildGeneral(2, { officerLevel: 5 }), target, member], + nationMeta: { gennum: 4 }, + }); + fixture.world.createTroop({ id: 3, nationId: 1, name: '추방대' }); + + await expect(fixture.handler.handle({ type: 'kick', generalId: 2, destGeneralId: 3 })).resolves.toMatchObject({ + ok: true, + }); + expect(fixture.world.getGeneralById(3)).toMatchObject({ + nationId: 0, + officerLevel: 0, + troopId: 0, + gold: 1_000, + rice: 1_000, + experience: 850, + dedication: 1_700, + meta: expect.objectContaining({ belong: 0, permission: 'normal', betray: 2 }), + }); + expect(fixture.world.getGeneralById(4)?.troopId).toBe(0); + expect(fixture.world.getTroopById(3)).toBeNull(); + expect(fixture.world.getNationById(1)).toMatchObject({ + gold: 11_500, + rice: 22_000, + meta: expect.objectContaining({ gennum: 3 }), + }); + expect(fixture.world.peekDirtyState().logs).toHaveLength(2); + }); + + it('preserves the legacy kick year boundaries and deterministic NPC public message', async () => { + const early = buildWorld({ + currentYear: 181, + generals: [ + buildGeneral(1, { officerLevel: 12 }), + buildGeneral(2, { officerLevel: 5 }), + buildGeneral(3, { meta: { killturn: 12, belong: 8, permission: 'normal', betray: 1 } }), + ], + }); + await early.handler.handle({ type: 'kick', generalId: 2, destGeneralId: 3 }); + expect(early.world.getGeneralById(3)).toMatchObject({ + experience: 850, + dedication: 1_700, + meta: expect.objectContaining({ betray: 2 }), + }); + expect(early.world.getGeneralById(1)?.injury).toBe(1); + expect(Number(early.world.getNationById(1)?.meta.chief_set ?? 0)).toBe(0); + + const npc = buildWorld({ + currentYear: 185, + scenarioConst: { npcBanMessageProb: 1 }, + generals: [ + buildGeneral(1, { officerLevel: 12 }), + buildGeneral(2, { officerLevel: 5 }), + buildGeneral(3, { npcState: 2 }), + ], + }); + await npc.handler.handle({ type: 'kick', generalId: 2, destGeneralId: 3 }); + expect(npc.world.peekDirtyState().messages).toHaveLength(1); + expect(npc.world.peekDirtyState().messages[0]).toMatchObject({ + msgType: 'public', + src: { generalId: 3, nationId: 1 }, + dest: { generalId: 3, nationId: 1 }, + }); + }); +}); diff --git a/app/game-frontend/e2e/nationOffices.spec.ts b/app/game-frontend/e2e/nationOffices.spec.ts new file mode 100644 index 0000000..f3f0083 --- /dev/null +++ b/app/game-frontend/e2e/nationOffices.spec.ts @@ -0,0 +1,420 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { mkdir, readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +type Role = 'leader' | 'head' | 'member'; +type FixtureState = { + role: Role; + failNextRate?: boolean; + failPersonnelLoad?: boolean; + rate: number; + appointedGeneralId?: number; +}; + +const artifactRoot = process.env.OFFICE_PARITY_ARTIFACT_DIR ? resolve(process.env.OFFICE_PARITY_ARTIFACT_DIR) : null; +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const imageRoots = [resolve(repositoryRoot, '../image/game'), resolve(repositoryRoot, '../../image/game')]; +const referenceImage = async (filename: string): Promise => { + for (const root of imageRoots) { + try { + return await readFile(resolve(root, filename)); + } catch { + // Nested worktrees and the primary checkout have different image parents. + } + } + throw new Error(`Reference image not found: ${filename}`); +}; +const response = (data: unknown) => ({ result: { data } }); +const errorResponse = (path: string, message: string, code = 'BAD_REQUEST') => ({ + error: { message, code: -32000, data: { code, httpStatus: code === 'FORBIDDEN' ? 403 : 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 = (route: Route, body: unknown) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); + +const general = (id: number, name: string, officerLevel: number, overrides: Record = {}) => ({ + id, + name, + npcState: 0, + officerLevel, + cityId: 1, + cityName: '허창', + troopId: 0, + troopName: null, + picture: 'default.jpg', + imageServer: 0, + officerCity: officerLevel >= 2 && officerLevel <= 4 ? 1 : 0, + officerCityName: officerLevel >= 2 && officerLevel <= 4 ? '허창' : null, + stats: { leadership: 70, strength: 70, intelligence: 70 }, + experience: 100, + dedication: 200, + injury: 0, + gold: 1000, + rice: 1000, + crew: 100, + personality: null, + specialDomestic: null, + specialWar: null, + belong: 10, + permission: 'normal', + ...overrides, +}); + +const personnelFixture = (state: FixtureState) => { + const officerLevel = state.role === 'leader' ? 12 : state.role === 'head' ? 5 : 1; + const fullGenerals = [ + general(1, '조조', 12), + general(2, '순욱', 11), + general(3, '하후돈', 4), + general(4, '곽가', 3), + general(5, '정욱', 2), + general(6, '장료', 1), + general(7, '허저', 1, { permission: 'ambassador' }), + ]; + const visibleGenerals = + state.role === 'member' ? fullGenerals.filter((entry) => entry.officerLevel >= 2) : fullGenerals; + return { + me: { + id: state.role === 'leader' ? 1 : state.role === 'head' ? 2 : 6, + officerLevel, + canManage: state.role !== 'member', + canChangePermissions: state.role === 'leader', + canKick: state.role !== 'member', + }, + nation: { + id: 1, + name: '작위검증국', + color: '#777777', + level: 3, + typeCode: 'che_법가', + capitalCityId: 1, + chiefSet: 0, + }, + chiefStatMin: 65, + generals: visibleGenerals, + chiefAssignments: { 12: fullGenerals[0], 11: fullGenerals[1] }, + cityAssignments: [ + { + id: 1, + name: '허창', + level: 7, + region: 2, + officerSet: 1 << 3, + officers: { 4: fullGenerals[2], 3: fullGenerals[3], 2: fullGenerals[4] }, + }, + { id: 2, name: '낙양', level: 6, region: 2, officerSet: 0, officers: { 4: null, 3: null, 2: null } }, + ], + awards: { + tigers: [{ id: 3, name: '하후돈', value: 12 }], + eagles: [{ id: 4, name: '곽가', value: 9 }], + }, + permissionCandidates: + state.role === 'leader' + ? { + ambassadors: [ + { id: 6, name: '장료', npcState: 0, permission: 'normal', maxPermission: 4 }, + { id: 7, name: '허저', npcState: 0, permission: 'ambassador', maxPermission: 4 }, + ], + auditors: [{ id: 6, name: '장료', npcState: 0, permission: 'normal', maxPermission: 4 }], + } + : { ambassadors: [], auditors: [] }, + }; +}; + +const financeFixture = (state: FixtureState) => ({ + editable: state.role === 'leader' || state.role === 'head', + nationMsg: '

백성을 편안하게 한다.

', + scoutMsg: '

천하의 인재를 구합니다.

', + nationId: 1, + officerLevel: state.role === 'leader' ? 12 : state.role === 'head' ? 5 : 1, + year: 185, + month: 1, + nationsList: [ + { + id: 1, + name: '작위검증국', + color: '#777777', + level: 3, + power: 1234, + generalCount: 7, + cityCount: 2, + diplomacy: { state: 7, term: null }, + }, + { + id: 2, + name: '촉', + color: '#008000', + level: 3, + power: 987, + generalCount: 5, + cityCount: 1, + diplomacy: { state: 2, term: 6 }, + }, + ], + gold: 10000, + rice: 20000, + income: { gold: { city: 2000, war: 300 }, rice: { city: 1800, wall: 200 } }, + outcome: 1000, + policy: { rate: state.rate, bill: 100, secretLimit: 3, blockScout: false, blockWar: false }, + warSettingCnt: { remain: 5, inc: 2, max: 10 }, +}); + +const installFixture = async (page: Page, state: FixtureState) => { + await page.addInitScript(() => { + localStorage.setItem('sammo-game-token', 'ga_office_playwright'); + localStorage.setItem('sammo-game-profile', 'che:default'); + }); + for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) { + await page.route(`**/image/game/${filename}`, async (route) => + route.fulfill({ status: 200, contentType: 'image/jpeg', body: await referenceImage(filename) }) + ); + } + await page.route('**/image/icons/**', async (route) => + route.fulfill({ + status: 200, + contentType: 'image/jpeg', + body: await readFile(resolve(repositoryRoot, '../../image/icons/default.jpg')), + }) + ); + 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: 1, name: '조조' } }); + if (operation === 'join.getConfig') return response({}); + if (operation === 'nation.getPersonnelInfo') { + return state.failPersonnelLoad + ? errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN') + : response(personnelFixture(state)); + } + if (operation === 'nation.getStratFinan') return response(financeFixture(state)); + if (operation === 'nation.appoint') { + state.appointedGeneralId = 6; + return response({ ok: true }); + } + if (operation === 'nation.kick' || operation === 'nation.changePermission') return response({ ok: true }); + if (operation === 'nation.setRate') { + if (state.failNextRate) { + state.failNextRate = false; + return errorResponse(operation, '세율을 변경할 수 없습니다.'); + } + state.rate = 25; + return response({ ok: true }); + } + if ( + [ + 'nation.setNotice', + 'nation.setScoutMsg', + 'nation.setBill', + 'nation.setSecretLimit', + 'nation.setBlockScout', + ].includes(operation) + ) + return response({ ok: true }); + if (operation === 'nation.setBlockWar') return response({ availableCnt: 4 }); + return errorResponse(operation, `Unhandled fixture operation: ${operation}`); + }); + await fulfillJson(route, results); + }); +}; + +const gotoOffice = async (page: Page, path: 'nation/personnel' | 'nation/finance') => { + const lobbyResponse = page.waitForResponse((response) => response.url().includes('/trpc/lobby.info')); + await page.goto(path); + await lobbyResponse; +}; +const screenshot = async (page: Page, name: string) => { + if (!artifactRoot) return; + await mkdir(artifactRoot, { recursive: true }); + await page.screenshot({ path: resolve(artifactRoot, name), fullPage: true }); +}; + +test('personnel matches the 1000px legacy table geometry, textures, image, and interaction states', async ({ + page, +}) => { + await installFixture(page, { role: 'leader', rate: 20 }); + await page.setViewportSize({ width: 1000, height: 900 }); + await gotoOffice(page, 'nation/personnel'); + await expect(page.getByText('작위검증국')).toBeVisible(); + + const computed = await page.locator('#personnel-container').evaluate((container) => { + const box = (selector?: string) => { + const element = selector ? container.querySelector(selector)! : container; + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + width: rect.width, + height: rect.height, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + backgroundImage: style.backgroundImage, + color: style.color, + }; + }; + return { + container: box(), + heading: box('.heading-table'), + status: box('.chief-status'), + icon: box('.general-icon'), + documentWidth: document.documentElement.scrollWidth, + }; + }); + expect(computed.container.width).toBe(1000); + expect(computed.heading.width).toBe(1000); + expect(computed.heading.height).toBeCloseTo(56, 0); + expect(computed.status.width).toBe(1000); + expect(computed.icon.width).toBeCloseTo(64.7, 0); + expect(computed.icon.height).toBeCloseTo(64, 0); + expect(computed.container.fontFamily).toContain('Pretendard'); + expect(computed.container.fontSize).toBe('14px'); + expect(computed.container.lineHeight).toBe('18.2px'); + expect(computed.status.backgroundImage).toContain('back_walnut.jpg'); + expect(computed.documentWidth).toBe(1000); + + const appointButton = page.getByRole('button', { name: '임명' }).first(); + expect(await appointButton.evaluate((button) => getComputedStyle(button).backgroundColor)).toBe( + 'rgb(108, 117, 125)' + ); + await appointButton.hover(); + expect(await appointButton.evaluate((button) => getComputedStyle(button).cursor)).toBe('pointer'); + await appointButton.focus(); + expect(await appointButton.evaluate((button) => getComputedStyle(button).outlineStyle)).not.toBe('none'); + await screenshot(page, 'core-personnel-desktop-leader.png'); +}); + +test('personnel preserves the legacy fixed 1000px document on a 500px viewport', async ({ page }) => { + await installFixture(page, { role: 'head', rate: 20 }); + await page.setViewportSize({ width: 500, height: 900 }); + await gotoOffice(page, 'nation/personnel'); + await expect(page.getByText('작위검증국')).toBeVisible(); + expect( + await page.locator('#personnel-container').evaluate((element) => element.getBoundingClientRect().width) + ).toBe(1000); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(1000); + await expect(page.getByRole('combobox', { name: '외교권자' })).toHaveCount(0); + await expect(page.getByRole('combobox', { name: '추방 대상 장수' })).toBeVisible(); + await screenshot(page, 'core-personnel-mobile-head.png'); +}); + +test('personnel hides every mutation control for an ordinary member and exposes load errors', async ({ page }) => { + await installFixture(page, { role: 'member', rate: 20 }); + await gotoOffice(page, 'nation/personnel'); + await expect(page.getByText('도 시 관 직 임 명')).toHaveCount(0); + await expect(page.getByText('외 교 권 자 임 명')).toHaveCount(0); + await expect(page.getByText('추 방', { exact: true })).toHaveCount(0); + await expect(page.getByText(/곽가\(10년\).*허창/)).toBeVisible(); + + const failed = await page.context().newPage(); + await installFixture(failed, { role: 'member', rate: 20, failPersonnelLoad: true }); + await gotoOffice(failed, 'nation/personnel'); + await expect(failed.getByRole('alert')).toContainText('권한이 부족합니다.'); +}); + +test('finance matches the reference 1000px and 500px computed DOM contracts', async ({ page }) => { + await installFixture(page, { role: 'head', rate: 20 }); + await page.setViewportSize({ width: 1000, height: 900 }); + await gotoOffice(page, 'nation/finance'); + await expect(page.getByText('외교관계')).toBeVisible(); + const desktop = await page.locator('#finance-container').evaluate((container) => { + const geometry = (selector?: string) => { + const element = selector ? container.querySelector(selector)! : container; + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + width: rect.width, + height: rect.height, + gridTemplateColumns: style.gridTemplateColumns, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + backgroundColor: style.backgroundColor, + backgroundImage: style.backgroundImage, + }; + }; + return { + container: geometry(), + top: geometry('.top-back-bar'), + title: geometry('.diplomacy-title'), + row: geometry('.diplomacy-row'), + notice: geometry('.notice-title'), + finance: geometry('.finance-title'), + input: geometry('input[type="number"]'), + }; + }); + expect(desktop.container.width).toBe(1000); + expect(desktop.container.fontFamily).toContain('Pretendard'); + expect(desktop.container.fontSize).toBe('14px'); + expect(desktop.container.backgroundImage).toContain('back_walnut.jpg'); + expect(desktop.top.height).toBe(32); + expect(desktop.top.gridTemplateColumns).toBe('90px 90px 640px 90px 90px'); + expect(desktop.title.height).toBeCloseTo(25.47, 1); + expect(desktop.title.backgroundColor).toBe('rgb(55, 90, 127)'); + expect(desktop.row.gridTemplateColumns).toBe( + '260.859px 130.438px 86.9531px 86.9531px 173.922px 86.9531px 173.922px' + ); + expect(desktop.notice.backgroundColor).toBe('rgb(255, 255, 255)'); + expect(desktop.finance.backgroundColor).toBe('rgb(0, 188, 140)'); + expect(desktop.input.width).toBeCloseTo(58.66, 1); + expect(desktop.input.height).toBe(30); + expect( + await page + .locator('.policy-submit') + .first() + .evaluate((element) => getComputedStyle(element).backgroundColor) + ).toBe('rgb(55, 90, 127)'); + expect( + await page + .locator('.policy-cancel') + .first() + .evaluate((element) => getComputedStyle(element).backgroundColor) + ).toBe('rgb(108, 117, 125)'); + expect( + await page + .getByRole('checkbox', { name: '전쟁 금지' }) + .evaluate((element) => getComputedStyle(element).borderRadius) + ).toBe('16px'); + await screenshot(page, 'core-finance-desktop-head.png'); + + await page.setViewportSize({ width: 500, height: 900 }); + await page.reload(); + await expect(page.getByText('외교관계')).toBeVisible(); + expect(await page.locator('#finance-container').evaluate((element) => element.getBoundingClientRect().width)).toBe( + 500 + ); + expect( + await page.locator('.top-back-bar').evaluate((element) => getComputedStyle(element).gridTemplateColumns) + ).toBe('90px 90px 140px 90px 90px'); + expect( + await page + .locator('.diplomacy-row') + .first() + .evaluate((element) => getComputedStyle(element).gridTemplateColumns) + ).toBe('130.422px 65.2188px 43.4844px 43.4688px 86.9688px 43.4688px 86.9688px'); + await screenshot(page, 'core-finance-mobile-head.png'); +}); + +test('finance enforces edit permissions and preserves the old value across an API failure', async ({ page }) => { + const state: FixtureState = { role: 'head', rate: 20, failNextRate: true }; + await installFixture(page, state); + await gotoOffice(page, 'nation/finance'); + const input = page.getByRole('spinbutton', { name: '세율' }); + await input.fill('25'); + await page.locator('.policy-cell').filter({ hasText: '세율' }).getByRole('button', { name: '변경' }).click(); + await expect(page.getByRole('alert')).toContainText('세율을 변경할 수 없습니다.'); + await expect(input).toHaveValue('20'); + await input.fill('25'); + await page.locator('.policy-cell').filter({ hasText: '세율' }).getByRole('button', { name: '변경' }).click(); + await expect(page.getByRole('status')).toContainText('세율을 변경했습니다.'); + expect(state.rate).toBe(25); + + const readOnly = await page.context().newPage(); + await installFixture(readOnly, { role: 'member', rate: 20 }); + await gotoOffice(readOnly, 'nation/finance'); + await expect(readOnly.getByRole('button', { name: '국가방침 수정' })).toHaveCount(0); + await expect(readOnly.locator('.policy-cell').getByRole('button', { name: '변경' })).toHaveCount(0); + await expect(readOnly.getByRole('checkbox', { name: '전쟁 금지' })).toBeDisabled(); +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index eddbfbd..f767877 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -3,10 +3,12 @@ import { fileURLToPath } from 'node:url'; import { defineConfig, devices } from '@playwright/test'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const port = Number(process.env.PLAYWRIGHT_FRONTEND_PORT ?? 15120); +const baseURL = `http://127.0.0.1:${port}/che/`; export default defineConfig({ testDir: '.', - testMatch: 'troop.spec.ts', + testMatch: ['troop.spec.ts', 'nationOffices.spec.ts'], fullyParallel: false, workers: 1, timeout: 30_000, @@ -14,9 +16,9 @@ export default defineConfig({ timeout: 5_000, }, reporter: [['list'], ['html', { open: 'never', outputFolder: resolve(repositoryRoot, 'playwright-report') }]], - outputDir: resolve(repositoryRoot, 'test-results/troop'), + outputDir: resolve(repositoryRoot, 'test-results/game-frontend'), use: { - baseURL: 'http://127.0.0.1:15120/che/', + baseURL, ...devices['Desktop Chrome'], deviceScaleFactor: 1, colorScheme: 'dark', @@ -24,10 +26,9 @@ export default defineConfig({ 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', + 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 ${port}`, cwd: repositoryRoot, - url: 'http://127.0.0.1:15120/che/', + url: baseURL, reuseExistingServer: false, timeout: 120_000, }, diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index 4d24888..9ac696d 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:nation-offices": "playwright test nationOffices.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 d82fe60..eaea35b 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -18,8 +18,6 @@ import TournamentView from '../views/TournamentView.vue'; import MyPageView from '../views/MyPageView.vue'; import MySettingsView from '../views/MySettingsView.vue'; import BoardView from '../views/BoardView.vue'; -import NationAffairsView from '../views/NationAffairsView.vue'; -import ScoutMessageView from '../views/ScoutMessageView.vue'; import DiplomacyView from '../views/DiplomacyView.vue'; import BestGeneralView from '../views/BestGeneralView.vue'; import HallOfFameView from '../views/HallOfFameView.vue'; @@ -95,7 +93,7 @@ const routes = [ { path: '/nation/affairs', name: 'nation-affairs', - component: NationAffairsView, + redirect: '/nation/finance', meta: { requiresAuth: true, requiresGeneral: true, @@ -104,7 +102,7 @@ const routes = [ { path: '/nation/recruit-message', name: 'nation-recruit-message', - component: ScoutMessageView, + redirect: '/nation/finance', meta: { requiresAuth: true, requiresGeneral: true, diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index a1284f0..687ae3a 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -95,7 +95,7 @@ watch( 세력 장수 인사부 부대 편성 - 내무부 + 내무부 외교부 사령부 감찰부 diff --git a/app/game-frontend/src/views/NationPersonnelView.vue b/app/game-frontend/src/views/NationPersonnelView.vue index abfa307..bf4cb7c 100644 --- a/app/game-frontend/src/views/NationPersonnelView.vue +++ b/app/game-frontend/src/views/NationPersonnelView.vue @@ -1,58 +1,39 @@ diff --git a/app/game-frontend/src/views/NationStratFinanView.vue b/app/game-frontend/src/views/NationStratFinanView.vue index bcfd85a..a1daef0 100644 --- a/app/game-frontend/src/views/NationStratFinanView.vue +++ b/app/game-frontend/src/views/NationStratFinanView.vue @@ -1,7 +1,6 @@