diff --git a/app/game-api/src/index.ts b/app/game-api/src/index.ts index c69c4e6..4de2b2d 100644 --- a/app/game-api/src/index.ts +++ b/app/game-api/src/index.ts @@ -30,6 +30,9 @@ export * from './auction/types.js'; export * from './auction/keys.js'; export * from './auction/scheduler.js'; export * from './auction/worker.js'; +export * from './tournament/keys.js'; +export * from './tournament/store.js'; +export * from './tournament/types.js'; export * from './tournament/worker.js'; // Types for TRPC consumer diff --git a/app/game-api/src/router/inherit/index.ts b/app/game-api/src/router/inherit/index.ts index 9397806..e072927 100644 --- a/app/game-api/src/router/inherit/index.ts +++ b/app/game-api/src/router/inherit/index.ts @@ -3,7 +3,14 @@ import { z } from 'zod'; import { authedProcedure, router } from '../../trpc.js'; import { asNumber, asRecord, parseJson, LiteHashDRBG } from '@sammo-ts/common'; -import { loadWarTraitModules, WarTraitLoader, WAR_TRAIT_KEYS, isWarTraitKey } from '@sammo-ts/logic'; +import { + ItemLoader, + isItemKey, + loadWarTraitModules, + WarTraitLoader, + WAR_TRAIT_KEYS, + isWarTraitKey, +} from '@sammo-ts/logic'; import type { InheritBuffType } from '@sammo-ts/logic'; import { appendInheritanceLog, @@ -23,8 +30,8 @@ const BUFF_KEYS: InheritBuffType[] = [ 'warAvoidRatio', 'warCriticalRatio', 'warMagicTrialProb', - 'success', - 'fail', + 'domesticSuccessProb', + 'domesticFailProb', 'warAvoidRatioOppose', 'warCriticalRatioOppose', 'warMagicTrialProbOppose', @@ -34,8 +41,8 @@ const BUFF_LABELS: Record = { warAvoidRatio: '회피 확률 증가', warCriticalRatio: '필살 확률 증가', warMagicTrialProb: '전투계략 시도 확률 증가', - success: '내정 성공률 증가', - fail: '내정 실패율 감소', + domesticSuccessProb: '내정 성공률 증가', + domesticFailProb: '내정 실패율 감소', warAvoidRatioOppose: '상대 회피 확률 감소', warCriticalRatioOppose: '상대 필살 확률 감소', warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소', @@ -58,6 +65,37 @@ const parseBuffRecord = (raw: unknown): Record => { const serializeBuffRecord = (buff: Record): string => JSON.stringify(buff); +const readBuffLevel = (buff: Record, key: InheritBuffType): number => { + const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null; + return Math.max(0, Math.min(5, Math.floor(buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : 0) ?? 0))); +}; + +const loadAvailableUniqueItems = async (worldState: WorldStateRow) => { + const configuredItems = asRecord(asRecord(worldState.config).const).allItems; + const enabledKeys: Array[0]> = []; + for (const entries of Object.values(asRecord(configuredItems))) { + for (const [key, amount] of Object.entries(asRecord(entries))) { + if (asNumber(amount, 0) !== 0 && isItemKey(key)) { + enabledKeys.push(key); + } + } + } + + const loader = new ItemLoader(); + const items = await Promise.all( + [...new Set(enabledKeys)].map(async (key) => { + const item = await loader.load(key); + return { + key, + name: item.name, + rawName: item.rawName, + info: item.info ?? '', + }; + }) + ); + return items.sort((left, right) => left.name.localeCompare(right.name, 'ko')); +}; + const resolveWorld = async (ctx: { db: { worldState: { findFirst: () => Promise } } }) => { const worldState = await ctx.db.worldState.findFirst(); if (!worldState || typeof worldState !== 'object') { @@ -199,6 +237,9 @@ export const inheritRouter = router({ special2Code: true, meta: true, turnTime: true, + leadership: true, + strength: true, + intel: true, }, }); @@ -219,7 +260,7 @@ export const inheritRouter = router({ const inheritConst = resolveInheritConstants(worldState); const buffState = parseBuffRecord(asRecord(general.meta).inheritBuff); const buffLevels = BUFF_KEYS.reduce>((acc, key) => { - acc[key] = Math.max(0, Math.min(5, Math.floor(buffState[key] ?? 0))); + acc[key] = readBuffLevel(buffState, key); return acc; }, {}); @@ -240,11 +281,14 @@ export const inheritRouter = router({ info: trait.info ?? '', })); - const others = await ctx.db.general.findMany({ - where: { id: { not: general.id }, userId: { not: null } }, - select: { id: true, name: true }, - orderBy: { id: 'asc' }, - }); + const [others, availableUnique] = await Promise.all([ + ctx.db.general.findMany({ + where: { id: { not: general.id }, npcState: { lt: 2 }, userId: { not: null } }, + select: { id: true, name: true }, + orderBy: { id: 'asc' }, + }), + loadAvailableUniqueItems(worldState), + ]); return { items, @@ -260,10 +304,16 @@ export const inheritRouter = router({ resetTurnTime: resetTurnLevel, }, availableSpecialWar: warSpecials, + availableUnique, availableTargetGenerals: others, turnTimeZones: buildTurnTimeZoneList(Math.max(1, Math.round(worldState.tickSeconds / 60))), isUnited, currentSpecialWar: general.special2Code ?? 'None', + currentStat: { + leadership: general.leadership, + strength: general.strength, + intel: general.intel, + }, }; }), getLogs: authedProcedure @@ -285,7 +335,7 @@ export const inheritRouter = router({ }, orderBy: { id: 'desc' }, take: 30, - select: { id: true, year: true, month: true, text: true }, + select: { id: true, year: true, month: true, text: true, createdAt: true }, }); return logs; }), @@ -318,7 +368,7 @@ export const inheritRouter = router({ } const buff = parseBuffRecord(asRecord(general.meta).inheritBuff); - const prevLevel = Math.max(0, Math.min(5, Math.floor(buff[input.type] ?? 0))); + const prevLevel = readBuffLevel(buff, input.type); if (input.level === prevLevel) { throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 구입했습니다.' }); } @@ -417,7 +467,12 @@ export const inheritRouter = router({ }, }); - await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritSpecificSpecialPoint); + await setInheritancePoint( + ctx.db, + userId, + 'previous', + currentPoint - inheritConst.inheritSpecificSpecialPoint + ); await appendInheritanceLog( ctx.db, userId, @@ -460,7 +515,8 @@ export const inheritRouter = router({ } const meta = asRecord(general.meta); - const prevList = parseJson(typeof meta.prev_types_special2 === 'string' ? meta.prev_types_special2 : null) ?? []; + const prevList = + parseJson(typeof meta.prev_types_special2 === 'string' ? meta.prev_types_special2 : null) ?? []; prevList.push(general.special2Code); await patchGeneral(ctx, general.id, { @@ -473,7 +529,13 @@ export const inheritRouter = router({ }); await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost); - await appendInheritanceLog(ctx.db, userId, worldState.currentYear, worldState.currentMonth, `${cost} 포인트로 전투 특기 초기화`); + await appendInheritanceLog( + ctx.db, + userId, + worldState.currentYear, + worldState.currentMonth, + `${cost} 포인트로 전투 특기 초기화` + ); return { ok: true }; }), resetTurnTime: authedProcedure.mutation(async ({ ctx }) => { @@ -624,9 +686,7 @@ export const inheritRouter = router({ const finalBonus = bonusSum === 0 ? buildRandomBonus( - new LiteHashDRBG( - `${asRecord(worldState.meta).hiddenSeed ?? 'inherit'}:ResetStat:${userId}` - ), + new LiteHashDRBG(`${asRecord(worldState.meta).hiddenSeed ?? 'inherit'}:ResetStat:${userId}`), [input.leadership, input.strength, input.intel] ) : (bonus as [number, number, number]); @@ -674,9 +734,7 @@ export const inheritRouter = router({ if (seasonValue !== null) { const userState = await readUserStateMeta(ctx.db, userId); const resetSeasons = readResetSeasons(userState); - const nextSeasons = resetSeasons.includes(seasonValue) - ? resetSeasons - : [...resetSeasons, seasonValue]; + const nextSeasons = resetSeasons.includes(seasonValue) ? resetSeasons : [...resetSeasons, seasonValue]; await writeUserStateMeta(ctx.db, userId, { ...userState, last_stat_reset: nextSeasons, @@ -709,7 +767,10 @@ export const inheritRouter = router({ } const meta = asRecord(general.meta); if (meta.inheritRandomUnique !== undefined && meta.inheritRandomUnique !== null) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.' }); + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.', + }); } await patchGeneral(ctx, general.id, { @@ -803,7 +864,9 @@ export const inheritRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: '자신의 정보는 확인할 수 없습니다.' }); } - const ownerName = typeof asRecord(target.meta).ownerName === 'string' ? (asRecord(target.meta).ownerName as string) : target.userId; + const rawOwnerName = asRecord(target.meta).ownerName; + const ownerName = + typeof rawOwnerName === 'string' && rawOwnerName.trim().length > 0 ? rawOwnerName : '알수없음'; await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritCheckOwnerPoint); await appendInheritanceLog( diff --git a/app/game-api/src/router/nation/endpoints/getGeneralList.ts b/app/game-api/src/router/nation/endpoints/getGeneralList.ts index 171e452..21ed1f6 100644 --- a/app/game-api/src/router/nation/endpoints/getGeneralList.ts +++ b/app/game-api/src/router/nation/endpoints/getGeneralList.ts @@ -1,279 +1,130 @@ import { TRPCError } from '@trpc/server'; -import { asRecord } from '@sammo-ts/common'; - -import type { GameApiContext } from '../../../context.js'; import { authedProcedure } from '../../../trpc.js'; import { getMyGeneral } from '../../shared/general.js'; import { assertNationAccess, loadTraitNames, + mapGeneralList, + resolveChiefStatMin, resolveNationPermission, - resolveOfficerCity, } from '../shared.js'; -const MAX_DEDICATION_LEVEL = 10; - -const readNumber = (record: Record, keys: string[], fallback = 0): number => { - for (const key of keys) { - const value = record[key]; - if (typeof value === 'number' && Number.isFinite(value)) { - return value; - } - } - return fallback; -}; - const experienceLevel = (experience: number): number => - Math.max(0, Math.min(100, experience < 1_000 ? Math.floor(experience / 100) : Math.floor(Math.sqrt(experience / 10)))); - + Math.max( + 0, + Math.min(100, experience < 1000 ? Math.floor(experience / 100) : Math.floor(Math.sqrt(experience / 10))) + ); const dedicationLevel = (dedication: number): number => - Math.max(0, Math.min(MAX_DEDICATION_LEVEL, Math.ceil(Math.sqrt(Math.max(0, dedication)) / 10))); + Math.max(0, Math.min(10, Math.ceil(Math.sqrt(dedication) / 10))); -const dedicationLevelText = (level: number): string => - level === 0 ? '무품관' : `${MAX_DEDICATION_LEVEL - level + 1}품관`; +export const getGeneralList = authedProcedure.query(async ({ ctx }) => { + const general = await getMyGeneral(ctx); + assertNationAccess(general); -const honorText = (experience: number): string => { - const levels: Array<[number, string]> = [ - [640, '전무'], - [2_560, '무명'], - [5_760, '신동'], - [10_240, '약간'], - [16_000, '평범'], - [23_040, '지역적'], - [31_360, '전국적'], - [40_960, '세계적'], - [45_000, '유명'], - [51_840, '명사'], - [55_000, '호걸'], - [64_000, '효웅'], - [77_440, '영웅'], - ]; - return levels.find(([limit]) => experience < limit)?.[1] ?? '구세주'; -}; + const [nation, cityRows, troopRows, generalRows, worldState] = await Promise.all([ + ctx.db.nation.findUnique({ + where: { id: general.nationId }, + select: { + id: true, + name: true, + color: true, + level: true, + typeCode: true, + capitalCityId: true, + meta: true, + }, + }), + ctx.db.city.findMany({ select: { id: true, name: true } }), + ctx.db.troop.findMany({ select: { troopLeaderId: true, name: true } }), + ctx.db.general.findMany({ + where: { nationId: general.nationId }, + select: { + id: true, + name: true, + npcState: true, + nationId: true, + cityId: true, + troopId: true, + picture: true, + imageServer: true, + officerLevel: true, + leadership: true, + strength: true, + intel: true, + experience: true, + dedication: true, + injury: true, + gold: true, + rice: true, + crew: true, + personalCode: true, + specialCode: true, + special2Code: true, + meta: true, + penalty: true, + }, + orderBy: { id: 'asc' }, + }), + ctx.db.worldState.findFirst(), + ]); -const leadershipBonus = (officerLevel: number, nationLevel: number): number => { - if (officerLevel === 12) return nationLevel * 2; - if (officerLevel >= 5) return nationLevel; - return 0; -}; - -const woundedStat = (value: number, injury: number): number => - injury > 0 ? Math.floor((value * (100 - injury)) / 100) : value; - -const defenceTrainText = (value: number): string => { - if (value === 999) return '×'; - if (value >= 90) return '☆'; - if (value >= 80) return '◎'; - if (value >= 60) return '○'; - return '△'; -}; - -const loadNationGeneralData = async (ctx: GameApiContext) => { - const me = await getMyGeneral(ctx); - assertNationAccess(me); - - const nation = await ctx.db.nation.findUnique({ - where: { id: me.nationId }, - select: { - id: true, - name: true, - color: true, - level: true, - typeCode: true, - capitalCityId: true, - meta: true, - }, - }); if (!nation) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); } - const viewerPermission = resolveNationPermission(me, nation.meta, true); - - const [cityRows, troopRows, generalRows] = await Promise.all([ - ctx.db.city.findMany({ select: { id: true, name: true } }), - ctx.db.troop.findMany({ - where: { nationId: me.nationId }, - select: { troopLeaderId: true, name: true }, - }), - ctx.db.general.findMany({ - where: { nationId: me.nationId }, - orderBy: [{ turnTime: 'asc' }, { id: 'asc' }], - }), - ]); - const generalIds = generalRows.map((general) => general.id); - const [accessRows, turnRows] = await Promise.all([ - ctx.db.generalAccessLog.findMany({ - where: { generalId: { in: generalIds } }, - select: { generalId: true, refreshScore: true, refreshScoreTotal: true }, - }), - viewerPermission >= 1 - ? ctx.db.generalTurn.findMany({ - where: { generalId: { in: generalIds }, turnIdx: { lt: 5 } }, - select: { generalId: true, turnIdx: true, actionCode: true }, - orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }], - }) - : Promise.resolve([]), - ]); - - const cityNames = new Map(cityRows.map((city) => [city.id, city.name])); - const troopNames = new Map(troopRows.map((troop) => [troop.troopLeaderId, troop.name])); - const accessByGeneral = new Map(accessRows.map((row) => [row.generalId, row])); - const turnsByGeneral = new Map(); - for (const turn of turnRows) { - const turns = turnsByGeneral.get(turn.generalId) ?? []; - turns[turn.turnIdx] = turn.actionCode; - turnsByGeneral.set(turn.generalId, turns); - } - - const [personalityMap, domesticMap, warMap] = await Promise.all([ - loadTraitNames(generalRows.map((general) => general.personalCode), 'personality'), - loadTraitNames(generalRows.map((general) => general.specialCode), 'domestic'), - loadTraitNames(generalRows.map((general) => general.special2Code), 'war'), - ]); - - const generals = generalRows.map((general) => { - const meta = asRecord(general.meta); - const officerCity = resolveOfficerCity(meta); - const access = accessByGeneral.get(general.id); - const dedLevel = dedicationLevel(general.dedication); - const actualOfficerLevel = general.officerLevel; - const visibleOfficerLevel = - viewerPermission >= 1 || actualOfficerLevel >= 5 ? actualOfficerLevel : Math.min(1, actualOfficerLevel); - const bonus = leadershipBonus(actualOfficerLevel, nation.level); - const detail = - viewerPermission >= 1 - ? { - officerLevel: actualOfficerLevel, - officerCity, - officerCityName: officerCity > 0 ? (cityNames.get(officerCity) ?? null) : null, - cityId: general.cityId, - cityName: cityNames.get(general.cityId) ?? null, - troopId: general.troopId, - troopName: troopNames.get(general.troopId) ?? null, - defenceTrain: readNumber(meta, ['defenceTrain', 'defence_train'], 80), - crewTypeId: general.crewTypeId, - crew: general.crew, - train: general.train, - atmos: general.atmos, - experience: general.experience, - dedication: general.dedication, - turnTime: general.turnTime.toISOString(), - recentWarTime: general.recentWarTime?.toISOString() ?? null, - killTurn: readNumber(meta, ['killturn', 'killTurn']), - refreshScore: access?.refreshScore ?? 0, - reservedCommands: general.npcState < 2 ? (turnsByGeneral.get(general.id) ?? []) : [], - } - : null; + const cityNameMap = new Map(cityRows.map((city) => [city.id, city.name])); + const troopNameMap = new Map(troopRows.map((troop) => [troop.troopLeaderId, troop.name])); + const list = await mapGeneralList(generalRows, cityNameMap, troopNameMap); + const accessRows = generalRows.length + ? await ctx.db.generalAccessLog.findMany({ + where: { generalId: { in: generalRows.map((entry) => entry.id) } }, + select: { generalId: true, refreshScoreTotal: true }, + }) + : []; + const accessByGeneral = new Map(accessRows.map((entry) => [entry.generalId, entry.refreshScoreTotal])); + const nationTrait = (await loadTraitNames([nation.typeCode], 'nation')).get(nation.typeCode); + const permission = resolveNationPermission(general, nation.meta, true); + const visibleList = list.map((entry) => { + const { permission: _targetPermission, ...safeEntry } = entry; + if (permission >= 1) { + return { + ...safeEntry, + refreshScoreTotal: accessByGeneral.get(entry.id) ?? 0, + experienceLevel: experienceLevel(entry.experience), + dedicationLevel: dedicationLevel(entry.dedication), + }; + } + const { crew: _crew, experience: _experience, dedication: _dedication, ...visible } = safeEntry; return { - id: general.id, - name: general.name, - npcState: general.npcState, - picture: general.picture, - imageServer: general.imageServer, - injury: general.injury, - stats: { - leadership: woundedStat(general.leadership, general.injury), - strength: woundedStat(general.strength, general.injury), - intelligence: woundedStat(general.intel, general.injury), - }, - leadershipBonus: bonus, - officerLevel: visibleOfficerLevel, - experienceLevel: experienceLevel(general.experience), - honorText: honorText(general.experience), - dedicationLevel: dedLevel, - dedicationLevelText: dedicationLevelText(dedLevel), - bill: dedLevel * 200 + 400, - gold: general.gold, - rice: general.rice, - age: general.age, - belong: readNumber(meta, ['belong']), - refreshScoreTotal: access?.refreshScoreTotal ?? 0, - personality: general.personalCode === 'None' ? null : (personalityMap.get(general.personalCode) ?? null), - specialDomestic: - general.specialCode === 'None' ? null : (domesticMap.get(general.specialCode) ?? null), - specialWar: general.special2Code === 'None' ? null : (warMap.get(general.special2Code) ?? null), - detail, + ...visible, + refreshScoreTotal: accessByGeneral.get(entry.id) ?? 0, + officerLevel: entry.officerLevel >= 5 ? entry.officerLevel : Math.min(1, entry.officerLevel), + cityName: null, + troopName: null, + officerCity: 0, + officerCityName: null, + experienceLevel: experienceLevel(entry.experience), + dedicationLevel: dedicationLevel(entry.dedication), }; }); return { - me, nation: { id: nation.id, name: nation.name, color: nation.color, level: nation.level, typeCode: nation.typeCode, + type: { + key: nation.typeCode, + name: nationTrait?.name ?? nation.typeCode, + info: nationTrait?.info ?? '', + }, capitalCityId: nation.capitalCityId ?? 0, }, - viewerPermission, - generals, - }; -}; - -export const getGeneralList = authedProcedure.query(async ({ ctx }) => { - const data = await loadNationGeneralData(ctx); - return { - nation: data.nation, - viewer: { generalId: data.me.id, permission: data.viewerPermission }, - generals: data.generals, - }; -}); - -export const getSecretGeneralList = authedProcedure.query(async ({ ctx }) => { - const data = await loadNationGeneralData(ctx); - if (data.viewerPermission < 1) { - throw new TRPCError({ - code: 'FORBIDDEN', - message: '권한이 부족합니다. 수뇌부가 아니거나 사관년도가 부족합니다.', - }); - } - - const visibleGenerals = data.generals.filter((general) => general.npcState !== 5); - const summaryBase = visibleGenerals.reduce( - (summary, general) => { - const detail = general.detail; - if (!detail) return summary; - summary.gold += general.gold; - summary.rice += general.rice; - summary.crew += detail.crew; - if (detail.crew > 0) { - for (const threshold of [90, 80, 60] as const) { - if (detail.train >= threshold && detail.atmos >= threshold) { - summary.readiness[threshold].crew += detail.crew; - summary.readiness[threshold].generals += 1; - } - } - } - return summary; - }, - { - gold: 0, - rice: 0, - crew: 0, - readiness: { - 90: { crew: 0, generals: 0 }, - 80: { crew: 0, generals: 0 }, - 60: { crew: 0, generals: 0 }, - }, - } - ); - const generalCount = visibleGenerals.length; - - return { - nation: data.nation, - viewer: { generalId: data.me.id, permission: data.viewerPermission }, - summary: { - ...summaryBase, - generalCount, - averageGold: generalCount ? summaryBase.gold / generalCount : 0, - averageRice: generalCount ? summaryBase.rice / generalCount : 0, - }, - generals: data.generals.map((general) => ({ - ...general, - defenceTrainText: defenceTrainText(general.detail?.defenceTrain ?? 0), - })), + chiefStatMin: resolveChiefStatMin(worldState), + viewer: { generalId: general.id, permission }, + generals: visibleList, }; }); diff --git a/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts b/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts new file mode 100644 index 0000000..b72977f --- /dev/null +++ b/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts @@ -0,0 +1,141 @@ +import { TRPCError } from '@trpc/server'; + +import { asRecord } from '@sammo-ts/common'; + +import { authedProcedure } from '../../../trpc.js'; +import { getMyGeneral } from '../../shared/general.js'; +import { assertNationAccess, resolveNationPermission } from '../shared.js'; + +const readNumber = (record: Record, keys: string[], fallback = 0): number => { + for (const key of keys) { + const value = record[key]; + if (typeof value === 'number' && Number.isFinite(value)) return value; + } + return fallback; +}; +const woundedStat = (value: number, injury: number): number => + injury > 0 ? Math.floor((value * (100 - injury)) / 100) : value; +const experienceLevel = (experience: number): number => + Math.max( + 0, + Math.min(100, experience < 1000 ? Math.floor(experience / 100) : Math.floor(Math.sqrt(experience / 10))) + ); +const leadershipBonus = (officerLevel: number, nationLevel: number): number => + officerLevel === 12 ? nationLevel * 2 : officerLevel >= 5 ? nationLevel : 0; +const defenceTrainText = (value: number): string => + value === 999 ? '×' : value >= 90 ? '☆' : value >= 80 ? '◎' : value >= 60 ? '○' : '△'; + +export const getSecretGeneralList = authedProcedure.query(async ({ ctx }) => { + const me = await getMyGeneral(ctx); + assertNationAccess(me); + const nation = await ctx.db.nation.findUnique({ + where: { id: me.nationId }, + select: { id: true, name: true, color: true, level: true, meta: true }, + }); + if (!nation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); + const permission = resolveNationPermission(me, nation.meta, true); + if (permission < 1) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: '권한이 부족합니다. 수뇌부가 아니거나 사관년도가 부족합니다.', + }); + } + + const [cities, troops, generalRows] = await Promise.all([ + ctx.db.city.findMany({ select: { id: true, name: true } }), + ctx.db.troop.findMany({ + where: { nationId: me.nationId }, + select: { troopLeaderId: true, name: true }, + }), + ctx.db.general.findMany({ + where: { nationId: me.nationId }, + orderBy: [{ turnTime: 'asc' }, { id: 'asc' }], + }), + ]); + const generalIds = generalRows.map((general) => general.id); + const turns = generalIds.length + ? await ctx.db.generalTurn.findMany({ + where: { generalId: { in: generalIds }, turnIdx: { lt: 5 } }, + select: { generalId: true, turnIdx: true, actionCode: true }, + orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }], + }) + : []; + const cityNames = new Map(cities.map((city) => [city.id, city.name])); + const troopNames = new Map(troops.map((troop) => [troop.troopLeaderId, troop.name])); + const turnMap = new Map(); + for (const turn of turns) { + const list = turnMap.get(turn.generalId) ?? []; + list[turn.turnIdx] = turn.actionCode; + turnMap.set(turn.generalId, list); + } + const generals = generalRows.map((general) => { + const meta = asRecord(general.meta); + const defenceTrain = readNumber(meta, ['defenceTrain', 'defence_train'], 80); + return { + id: general.id, + name: general.name, + npcState: general.npcState, + injury: general.injury, + stats: { + leadership: woundedStat(general.leadership, general.injury), + strength: woundedStat(general.strength, general.injury), + intelligence: woundedStat(general.intel, general.injury), + }, + leadershipBonus: leadershipBonus(general.officerLevel, nation.level), + experienceLevel: experienceLevel(general.experience), + troopId: general.troopId, + troopName: troopNames.get(general.troopId) ?? null, + gold: general.gold, + rice: general.rice, + cityId: general.cityId, + cityName: cityNames.get(general.cityId) ?? null, + defenceTrain, + defenceTrainText: defenceTrainText(defenceTrain), + crewTypeId: general.crewTypeId, + crew: general.crew, + train: general.train, + atmos: general.atmos, + killTurn: readNumber(meta, ['killturn', 'killTurn']), + turnTime: general.turnTime.toISOString(), + reservedCommands: general.npcState < 2 ? (turnMap.get(general.id) ?? []) : [], + }; + }); + const counted = generals.filter((general) => general.npcState !== 5); + const summary = counted.reduce( + (result, general) => { + result.gold += general.gold; + result.rice += general.rice; + result.crew += general.crew; + if (general.crew > 0) { + for (const threshold of [90, 80, 60] as const) { + if (general.train >= threshold && general.atmos >= threshold) { + result.readiness[threshold].crew += general.crew; + result.readiness[threshold].generals += 1; + } + } + } + return result; + }, + { + gold: 0, + rice: 0, + crew: 0, + readiness: { + 90: { crew: 0, generals: 0 }, + 80: { crew: 0, generals: 0 }, + 60: { crew: 0, generals: 0 }, + }, + } + ); + return { + nation: { id: nation.id, name: nation.name, color: nation.color, level: nation.level }, + viewer: { generalId: me.id, permission }, + summary: { + ...summary, + generalCount: counted.length, + averageGold: counted.length ? summary.gold / counted.length : 0, + averageRice: counted.length ? summary.rice / counted.length : 0, + }, + generals, + }; +}); diff --git a/app/game-api/src/router/nation/index.ts b/app/game-api/src/router/nation/index.ts index 4fece4a..04ff481 100644 --- a/app/game-api/src/router/nation/index.ts +++ b/app/game-api/src/router/nation/index.ts @@ -4,7 +4,8 @@ import { changePermission } from './endpoints/changePermission.js'; import { getBattleCenter } from './endpoints/getBattleCenter.js'; import { getChiefCenter } from './endpoints/getChiefCenter.js'; import { getCityOverview } from './endpoints/getCityOverview.js'; -import { getGeneralList, getSecretGeneralList } from './endpoints/getGeneralList.js'; +import { getGeneralList } from './endpoints/getGeneralList.js'; +import { getSecretGeneralList } from './endpoints/getSecretGeneralList.js'; import { getGeneralLog } from './endpoints/getGeneralLog.js'; import { getNationInfo } from './endpoints/getNationInfo.js'; import { getPersonnelInfo } from './endpoints/getPersonnelInfo.js'; diff --git a/app/game-api/src/router/npc/index.ts b/app/game-api/src/router/npc/index.ts index e4d7781..87cfe3b 100644 --- a/app/game-api/src/router/npc/index.ts +++ b/app/game-api/src/router/npc/index.ts @@ -1,5 +1,3 @@ -import path from 'node:path'; - import { TRPCError } from '@trpc/server'; import { z } from 'zod'; @@ -165,8 +163,6 @@ const FLOAT_POLICY_KEYS = ['safeRecruitCityPopulationRatio'] as const; type NumericPolicyKey = (typeof INTEGER_POLICY_KEYS)[number]; type FloatPolicyKey = (typeof FLOAT_POLICY_KEYS)[number]; -const UNIT_SET_ROOT = path.resolve(process.cwd(), 'resources', 'unitset'); - const readNumber = (value: unknown, fallback = 0): number => { if (typeof value === 'number' && Number.isFinite(value)) { return value; @@ -353,8 +349,8 @@ const buildZeroPolicy = async ( } ): Promise => { const { statMax, statNpcMax, nationTech, develCost, defaultCrewTypeId, unitSetName } = options; - const unitSet = await loadUnitSetDefinitionByName(unitSetName, { unitSetRoot: UNIT_SET_ROOT }); - const crewType = findCrewTypeById(unitSet, defaultCrewTypeId); + const unitSet = await loadUnitSetDefinitionByName(unitSetName); + const crewType = findCrewTypeById(unitSet, defaultCrewTypeId || unitSet.defaultCrewTypeId || 0); const techCost = getTechCost(nationTech); const next = clonePolicy(policy); @@ -364,7 +360,7 @@ const buildZeroPolicy = async ( if (next.reqNPCWarGold === 0 || next.reqNPCWarRice === 0) { const baseGold = crewType ? crewType.cost * techCost * statNpcMax : 0; - const baseRice = statNpcMax; + const baseRice = crewType ? crewType.rice * techCost * statNpcMax : 0; if (next.reqNPCWarGold === 0) { next.reqNPCWarGold = roundTo(baseGold * 4, -2); } @@ -375,7 +371,7 @@ const buildZeroPolicy = async ( if (next.reqHumanWarUrgentGold === 0 || next.reqHumanWarUrgentRice === 0) { const baseGold = crewType ? crewType.cost * techCost * statMax : 0; - const baseRice = statMax; + const baseRice = crewType ? crewType.rice * techCost * statMax : 0; if (next.reqHumanWarUrgentGold === 0) { next.reqHumanWarUrgentGold = roundTo(baseGold * 6, -2); } @@ -415,8 +411,6 @@ const resolveSetterInfo = (policy: Record, kind: 'value' | 'pri }; }; -const ensureUniquePriority = (priority: string[]): string[] => Array.from(new Set(priority)); - const validateGeneralPriority = (priority: string[]): string | null => { const orderRequired: Array<[string, string]> = [['출병', '일반내정']]; const mustHave = new Set(['출병', '일반내정']); @@ -461,6 +455,7 @@ export const npcRouter = router({ id: true, name: true, level: true, + tech: true, meta: true, }, }), @@ -508,9 +503,9 @@ export const npcRouter = router({ const stat = resolveScenarioStat(config); const env = resolveCommandEnv(config); const unitSetName = resolveUnitSetName(config, 'che'); - const nationTech = readNumber(asRecord(nationMeta).tech, 0); + const nationTech = readNumber(nation.tech, 0); - const zeroPolicy = await buildZeroPolicy(defaultNationPolicy, { + const zeroPolicy = await buildZeroPolicy(DEFAULT_NATION_POLICY, { statMax: stat.max, statNpcMax: stat.npcMax, nationTech, @@ -542,277 +537,272 @@ export const npcRouter = router({ permissionLevel, }; }), - setNationPolicy: authedProcedure - .input(z.record(z.string(), z.unknown())) - .mutation(async ({ ctx, input }) => { - const general = await getMyGeneral(ctx); - if (general.nationId <= 0) { - throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' }); - } + setNationPolicy: authedProcedure.input(z.record(z.string(), z.unknown())).mutation(async ({ ctx, input }) => { + const general = await getMyGeneral(ctx); + if (general.nationId <= 0) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' }); + } - const nation = await ctx.db.nation.findUnique({ - where: { id: general.nationId }, - select: { id: true, meta: true }, - }); - if (!nation) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); - } + const nation = await ctx.db.nation.findUnique({ + where: { id: general.nationId }, + select: { id: true, meta: true }, + }); + if (!nation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); + } - const permissionLevel = resolveSecretPermission( - { - nationId: general.nationId, - officerLevel: general.officerLevel, - meta: general.meta, - penalty: general.penalty, - }, - nation.meta - ); - if (permissionLevel < 3) { - throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); - } + const permissionLevel = resolveSecretPermission( + { + nationId: general.nationId, + officerLevel: general.officerLevel, + meta: general.meta, + penalty: general.penalty, + }, + nation.meta + ); + if (permissionLevel < 3) { + throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); + } - const keys = Object.keys(input); - for (const key of keys) { - if (!NATION_POLICY_KEYS.has(key as keyof NationPolicy)) { + const keys = Object.keys(input); + for (const key of keys) { + if (!NATION_POLICY_KEYS.has(key as keyof NationPolicy)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` }); + } + } + + const troopRows = await ctx.db.troop.findMany({ + where: { nationId: general.nationId }, + select: { troopLeaderId: true }, + }); + const cityRows = await ctx.db.city.findMany({ select: { id: true } }); + + const troopSet = new Set(troopRows.map((row) => row.troopLeaderId)); + const citySet = new Set(cityRows.map((row) => row.id)); + const assigned = new Set(); + + const nationMeta = asRecord(nation.meta); + const policyRoot = asRecord(nationMeta.npc_nation_policy); + const nextValues = applyPolicyValues(DEFAULT_NATION_POLICY, asRecord(policyRoot.values)); + + for (const key of INTEGER_POLICY_KEYS) { + if (!(key in input)) { + continue; + } + const value = input[key]; + if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` }); + } + nextValues[key] = Math.max(0, value); + } + + for (const key of FLOAT_POLICY_KEYS) { + if (!(key in input)) { + continue; + } + const value = input[key]; + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` }); + } + nextValues[key] = value; + } + + if ('CombatForce' in input) { + const rawCombat = input.CombatForce; + if (!isRecord(rawCombat)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'CombatForce는 올바른 정책값이 아닙니다.' }); + } + const combatForce: Record = {}; + for (const [rawKey, rawValue] of Object.entries(rawCombat)) { + const leaderId = Number(rawKey); + if (!Number.isFinite(leaderId)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `${rawKey}는 올바른 부대가 아닙니다.` }); + } + if (!troopSet.has(leaderId)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}는 국가의 부대가 아닙니다.` }); + } + if (assigned.has(leaderId)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `부대(${leaderId})는 하나의 역할만 지정할 수 있습니다.`, + }); + } + if (!Array.isArray(rawValue) || rawValue.length < 2) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `${leaderId}의 입력양식이 올바르지 않습니다.`, + }); + } + const fromCity = Number(rawValue[0]); + const toCity = Number(rawValue[1]); + if (!citySet.has(fromCity) || !citySet.has(toCity)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `${leaderId}의 도시 ${fromCity}, ${toCity}가 올바른 도시 번호가 아닙니다.`, + }); + } + combatForce[leaderId] = [fromCity, toCity]; + assigned.add(leaderId); + } + nextValues.CombatForce = combatForce; + } + + for (const key of ['SupportForce', 'DevelopForce'] as const) { + if (!(key in input)) { + continue; + } + const rawList = input[key]; + if (!Array.isArray(rawList)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` }); + } + const list: number[] = []; + for (const rawValue of rawList) { + if (typeof rawValue !== 'number' || !Number.isFinite(rawValue)) { throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` }); } - } - - const troopRows = await ctx.db.troop.findMany({ - where: { nationId: general.nationId }, - select: { troopLeaderId: true }, - }); - const cityRows = await ctx.db.city.findMany({ select: { id: true } }); - - const troopSet = new Set(troopRows.map((row) => row.troopLeaderId)); - const citySet = new Set(cityRows.map((row) => row.id)); - const assigned = new Set(); - - const nationMeta = asRecord(nation.meta); - const policyRoot = asRecord(nationMeta.npc_nation_policy); - const nextValues = applyPolicyValues(DEFAULT_NATION_POLICY, asRecord(policyRoot.values)); - - for (const key of INTEGER_POLICY_KEYS) { - if (!(key in input)) { - continue; + if (!troopSet.has(rawValue)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `${rawValue}는 국가의 부대가 아닙니다.`, + }); } - const value = input[key]; - if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` }); + if (assigned.has(rawValue)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `부대(${rawValue})는 하나의 역할만 지정할 수 있습니다.`, + }); } - nextValues[key] = Math.max(0, value); + assigned.add(rawValue); + list.push(rawValue); } - - for (const key of FLOAT_POLICY_KEYS) { - if (!(key in input)) { - continue; - } - const value = input[key]; - if (typeof value !== 'number' || !Number.isFinite(value)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` }); - } - nextValues[key] = Math.max(0, value); + if (key === 'SupportForce') { + nextValues.SupportForce = list; + } else { + nextValues.DevelopForce = list; } + } - if ('CombatForce' in input) { - const rawCombat = input.CombatForce; - if (!isRecord(rawCombat)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'CombatForce는 올바른 정책값이 아닙니다.' }); - } - const combatForce: Record = {}; - for (const [rawKey, rawValue] of Object.entries(rawCombat)) { - const leaderId = Number(rawKey); - if (!Number.isFinite(leaderId)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${rawKey}는 올바른 부대가 아닙니다.` }); - } - if (!troopSet.has(leaderId)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}는 국가의 부대가 아닙니다.` }); - } - if (assigned.has(leaderId)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `부대(${leaderId})는 하나의 역할만 지정할 수 있습니다.`, - }); - } - if (!Array.isArray(rawValue) || rawValue.length < 2) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}의 입력양식이 올바르지 않습니다.` }); - } - const fromCity = Number(rawValue[0]); - const toCity = Number(rawValue[1]); - if (!citySet.has(fromCity) || !citySet.has(toCity)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `${leaderId}의 도시 ${fromCity}, ${toCity}가 올바른 도시 번호가 아닙니다.`, - }); - } - combatForce[leaderId] = [fromCity, toCity]; - assigned.add(leaderId); - } - nextValues.CombatForce = combatForce; + const nextPolicyRoot = { + ...policyRoot, + values: nextValues, + valueSetter: general.name, + valueSetTime: new Date().toISOString(), + }; + + await updateNationMeta( + ctx, + nation.id, + { + npc_nation_policy: nextPolicyRoot, + }, + nationMeta + ); + + return { ok: true }; + }), + setNationPriority: authedProcedure.input(z.array(z.string())).mutation(async ({ ctx, input }) => { + const general = await getMyGeneral(ctx); + if (general.nationId <= 0) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' }); + } + + const nation = await ctx.db.nation.findUnique({ + where: { id: general.nationId }, + select: { id: true, meta: true }, + }); + if (!nation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); + } + + const permissionLevel = resolveSecretPermission( + { + nationId: general.nationId, + officerLevel: general.officerLevel, + meta: general.meta, + penalty: general.penalty, + }, + nation.meta + ); + if (permissionLevel < 3) { + throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); + } + + for (const item of input) { + if (!DEFAULT_NATION_PRIORITY.includes(item as (typeof DEFAULT_NATION_PRIORITY)[number])) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `${item}은 올바른 명령이 아닙니다.` }); } + } - for (const key of ['SupportForce', 'DevelopForce'] as const) { - if (!(key in input)) { - continue; - } - const rawList = input[key]; - if (!Array.isArray(rawList)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` }); - } - const list: number[] = []; - for (const rawValue of rawList) { - if (typeof rawValue !== 'number' || !Number.isFinite(rawValue)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` }); - } - if (!troopSet.has(rawValue)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `${rawValue}는 국가의 부대가 아닙니다.`, - }); - } - if (assigned.has(rawValue)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `부대(${rawValue})는 하나의 역할만 지정할 수 있습니다.`, - }); - } - assigned.add(rawValue); - list.push(rawValue); - } - if (key === 'SupportForce') { - nextValues.SupportForce = list; - } else { - nextValues.DevelopForce = list; - } - } + const nationMeta = asRecord(nation.meta); + const policyRoot = asRecord(nationMeta.npc_nation_policy); + const nextPolicyRoot = { + ...policyRoot, + priority: input, + prioritySetter: general.name, + prioritySetTime: new Date().toISOString(), + }; - const nextPolicyRoot = { - ...policyRoot, - values: nextValues, - valueSetter: general.name, - valueSetTime: new Date().toISOString(), - }; + await updateNationMeta( + ctx, + nation.id, + { + npc_nation_policy: nextPolicyRoot, + }, + nationMeta + ); - await updateNationMeta( - ctx, - nation.id, - { - npc_nation_policy: nextPolicyRoot, - }, - nationMeta - ); + return { ok: true }; + }), + setGeneralPriority: authedProcedure.input(z.array(z.string())).mutation(async ({ ctx, input }) => { + const general = await getMyGeneral(ctx); + if (general.nationId <= 0) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' }); + } - return { ok: true }; - }), - setNationPriority: authedProcedure - .input(z.array(z.string())) - .mutation(async ({ ctx, input }) => { - const general = await getMyGeneral(ctx); - if (general.nationId <= 0) { - throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' }); - } + const nation = await ctx.db.nation.findUnique({ + where: { id: general.nationId }, + select: { id: true, meta: true }, + }); + if (!nation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); + } - const nation = await ctx.db.nation.findUnique({ - where: { id: general.nationId }, - select: { id: true, meta: true }, - }); - if (!nation) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); - } + const permissionLevel = resolveSecretPermission( + { + nationId: general.nationId, + officerLevel: general.officerLevel, + meta: general.meta, + penalty: general.penalty, + }, + nation.meta + ); + if (permissionLevel < 3) { + throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); + } - const permissionLevel = resolveSecretPermission( - { - nationId: general.nationId, - officerLevel: general.officerLevel, - meta: general.meta, - penalty: general.penalty, - }, - nation.meta - ); - if (permissionLevel < 3) { - throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); - } + const validationError = validateGeneralPriority(input); + if (validationError) { + throw new TRPCError({ code: 'BAD_REQUEST', message: validationError }); + } - const unique = ensureUniquePriority(input); - for (const item of unique) { - if (!DEFAULT_NATION_PRIORITY.includes(item as (typeof DEFAULT_NATION_PRIORITY)[number])) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${item}은 올바른 명령이 아닙니다.` }); - } - } + const nationMeta = asRecord(nation.meta); + const policyRoot = asRecord(nationMeta.npc_general_policy); + const nextPolicyRoot = { + ...policyRoot, + priority: input, + prioritySetter: general.name, + prioritySetTime: new Date().toISOString(), + }; - const nationMeta = asRecord(nation.meta); - const policyRoot = asRecord(nationMeta.npc_nation_policy); - const nextPolicyRoot = { - ...policyRoot, - priority: unique, - prioritySetter: general.name, - prioritySetTime: new Date().toISOString(), - }; + await updateNationMeta( + ctx, + nation.id, + { + npc_general_policy: nextPolicyRoot, + }, + nationMeta + ); - await updateNationMeta( - ctx, - nation.id, - { - npc_nation_policy: nextPolicyRoot, - }, - nationMeta - ); - - return { ok: true }; - }), - setGeneralPriority: authedProcedure - .input(z.array(z.string())) - .mutation(async ({ ctx, input }) => { - const general = await getMyGeneral(ctx); - if (general.nationId <= 0) { - throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' }); - } - - const nation = await ctx.db.nation.findUnique({ - where: { id: general.nationId }, - select: { id: true, meta: true }, - }); - if (!nation) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); - } - - const permissionLevel = resolveSecretPermission( - { - nationId: general.nationId, - officerLevel: general.officerLevel, - meta: general.meta, - penalty: general.penalty, - }, - nation.meta - ); - if (permissionLevel < 3) { - throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); - } - - const unique = ensureUniquePriority(input); - const validationError = validateGeneralPriority(unique); - if (validationError) { - throw new TRPCError({ code: 'BAD_REQUEST', message: validationError }); - } - - const nationMeta = asRecord(nation.meta); - const policyRoot = asRecord(nationMeta.npc_general_policy); - const nextPolicyRoot = { - ...policyRoot, - priority: unique, - prioritySetter: general.name, - prioritySetTime: new Date().toISOString(), - }; - - await updateNationMeta( - ctx, - nation.id, - { - npc_general_policy: nextPolicyRoot, - }, - nationMeta - ); - - return { ok: true }; - }), + return { ok: true }; + }), }); diff --git a/app/game-api/src/router/world/index.ts b/app/game-api/src/router/world/index.ts index 5719247..80d1ca7 100644 --- a/app/game-api/src/router/world/index.ts +++ b/app/game-api/src/router/world/index.ts @@ -7,6 +7,7 @@ import { authedProcedure } from '../../trpc.js'; import { asRecord, isRecord } from '@sammo-ts/common'; import { loadWorldMap } from '../../maps/worldMap.js'; import { loadMapLayout } from '../../maps/mapLayout.js'; +import { loadUnitSetDefinitionByName } from '../../battleSim/unitSetLoader.js'; import { getMyGeneral, getOwnedGeneral } from '../shared/general.js'; const isWorldAdmin = (roles: readonly string[]): boolean => @@ -33,6 +34,29 @@ const defenceTrain = (meta: unknown): number => { return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0; }; +const unitSetName = (world: WorldStateRow | null, fallback: string): string => { + const config = asRecord(world?.config); + const environment = asRecord(config.environment ?? config.map); + return typeof environment.unitSet === 'string' && environment.unitSet.trim() ? environment.unitSet : fallback; +}; + +const crewTypeNameCache = new Map>>(); +const loadCrewTypeNames = (name: string): Promise> => { + const cached = crewTypeNameCache.get(name); + if (cached) return cached; + const pending = loadUnitSetDefinitionByName(name) + .then((definition) => new Map((definition.crewTypes ?? []).map((crewType) => [crewType.id, crewType.name]))) + .catch(() => new Map()); + crewTypeNameCache.set(name, pending); + return pending; +}; + +const leadershipBonus = (officerLevel: number, nationLevel: number): number => { + if (officerLevel === 12) return nationLevel * 2; + if (officerLevel >= 5) return nationLevel; + return 0; +}; + const toWorldStateSnapshot = (row: WorldStateRow) => ({ scenarioCode: row.scenarioCode, currentYear: row.currentYear, @@ -122,6 +146,8 @@ export const worldRouter = router({ if (me.officerLevel > 0 && me.nationId > 0) { cities.filter((city) => city.nationId === me.nationId).forEach((city) => selectable.add(city.id)); nationGenerals.forEach((general) => selectable.add(general.cityId)); + } + if ((nation?.level ?? 0) > 0) { Object.keys(spy).forEach((id) => selectable.add(Number(id))); } if (admin) cities.forEach((city) => selectable.add(city.id)); @@ -150,6 +176,8 @@ export const worldRouter = router({ turnMap.set(turn.generalId, list); } const nationMap = new Map(nations.map((item) => [item.id, item])); + const selectedNation = nationMap.get(selected.nationId); + const crewTypeNames = await loadCrewTypeNames(unitSetName(world, ctx.profile.id)); const officers = await ctx.db.general.findMany({ where: { officerLevel: { in: [2, 3, 4] } }, select: { name: true, officerLevel: true, meta: true }, @@ -175,14 +203,59 @@ export const worldRouter = router({ intelligence: general.intel, injury: general.injury, officerLevel: general.officerLevel, + leadershipBonus: leadershipBonus(general.officerLevel, nationMap.get(general.nationId)?.level ?? 0), defenceTrain: ours ? defenceTrain(general.meta) : null, crewTypeId: ours ? general.crewTypeId : null, + crewTypeName: ours ? (crewTypeNames.get(general.crewTypeId) ?? null) : null, crew: ours || full ? general.crew : null, train: ours ? general.train : null, atmos: ours ? general.atmos : null, turns: ours && general.npcState <= 1 ? (turnMap.get(general.id) ?? []) : [], }; }); + const forceSummary = mappedGenerals.reduce( + (summary, general) => { + if (general.nationId > 0 && me.nationId > 0 && general.nationId !== me.nationId) { + summary.enemyGenerals += 1; + if (general.crew !== null && general.crew >= 0) summary.enemyCrew += general.crew; + if (general.crew !== null && general.crew > 0) summary.enemyArmedGenerals += 1; + return summary; + } + if (me.nationId <= 0 || general.nationId !== me.nationId) return summary; + summary.ownGenerals += 1; + summary.ownCrew += general.crew ?? 0; + if ((general.crew ?? 0) <= 0) return summary; + summary.ownArmedGenerals += 1; + const readiness = Math.min(general.train ?? -1, general.atmos ?? -1); + if (readiness >= 90) { + summary.ready90Crew += general.crew ?? 0; + summary.ready90Generals += 1; + } + if (readiness >= 60) { + summary.ready60Crew += general.crew ?? 0; + summary.ready60Generals += 1; + } + if (general.defenceTrain !== null && readiness >= general.defenceTrain) { + summary.defenceReadyCrew += general.crew ?? 0; + summary.defenceReadyGenerals += 1; + } + return summary; + }, + { + enemyCrew: 0, + enemyArmedGenerals: 0, + enemyGenerals: 0, + ownCrew: 0, + ownArmedGenerals: 0, + ownGenerals: 0, + ready90Crew: 0, + ready90Generals: 0, + ready60Crew: 0, + ready60Generals: 0, + defenceReadyCrew: 0, + defenceReadyGenerals: 0, + } + ); return { me: { id: me.id, nationId: me.nationId, officerLevel: me.officerLevel, admin }, options: [...selectable] @@ -194,6 +267,7 @@ export const worldRouter = router({ id: selected.id, name: selected.name, nationId: selected.nationId, + nationColor: selectedNation?.color ?? '#000000', level: selected.level, region: selected.region, population: redact(selected.population), @@ -217,8 +291,11 @@ export const worldRouter = router({ }, }, generals: mappedGenerals, + forceSummary, lastExecute: - typeof asRecord(world?.meta).turntime === 'string' ? String(asRecord(world?.meta).turntime) : '', + typeof asRecord(world?.meta).turntime === 'string' + ? String(asRecord(world?.meta).turntime).slice(5, 19) + : '', }; }), getState: procedure.query(async ({ ctx }) => { diff --git a/app/game-api/src/tournament/worker.ts b/app/game-api/src/tournament/worker.ts index af79bfb..9d65372 100644 --- a/app/game-api/src/tournament/worker.ts +++ b/app/game-api/src/tournament/worker.ts @@ -1,15 +1,15 @@ -import { createTournamentRng } from '@sammo-ts/common'; +import { createTournamentRng, type TurnDaemonCommandResult } from '@sammo-ts/common'; import { resolveTournamentBattle } from '@sammo-ts/logic'; import { createGamePostgresConnector, createRedisConnector, resolvePostgresConfigFromEnv, resolveRedisConfigFromEnv, + type GamePrismaClient, } from '@sammo-ts/infra'; import { resolveGameApiConfigFromEnv } from '../config.js'; -import { RedisTurnDaemonTransport } from '../daemon/redisTransport.js'; -import { buildTurnDaemonStreamKeys } from '../daemon/streamKeys.js'; +import { DatabaseTurnDaemonTransport } from '../daemon/databaseTransport.js'; import type { TurnDaemonTransport } from '../daemon/transport.js'; import { buildTournamentKeys } from './keys.js'; import { TournamentStore } from './store.js'; @@ -462,13 +462,30 @@ export const settleTournamentOutcome = async (options: { return null; } - let settledState: TournamentState | null = null; + let settledState = state; + let changed = false; - if (!state.rewardSettled) { + const requireSuccessfulResult = ( + result: TurnDaemonCommandResult | null, + expectedType: TurnDaemonCommandResult['type'] + ): void => { + if (!result) { + throw new Error(`${expectedType} 명령 응답 시간이 초과되었습니다.`); + } + if (result.type !== expectedType) { + throw new Error(`${expectedType} 명령에 잘못된 응답(${result.type})을 받았습니다.`); + } + if (!result.ok) { + throw new Error(`${expectedType} 명령이 실패했습니다: ${result.reason}`); + } + }; + + if (!settledState.rewardSettled) { const matches = await store.getMatches(); const rewardPayload = buildTournamentRewardPayload(matches); - await daemonTransport.sendCommand({ + const result = await daemonTransport.requestCommand({ type: 'tournamentReward', + requestId: `tournament:${state.bettingId ?? `${state.openYear}:${state.openMonth}:${state.type}`}:reward`, tournamentType: state.type, winnerId: rewardPayload.winnerId, runnerUpId: rewardPayload.runnerUpId, @@ -476,46 +493,100 @@ export const settleTournamentOutcome = async (options: { top8: rewardPayload.top8, top4: rewardPayload.top4, }); + requireSuccessfulResult(result, 'tournamentReward'); settledState = { - ...(settledState ?? state), + ...settledState, rewardSettled: true, }; + changed = true; + await store.setState(settledState); } - if (state.bettingId && !state.bettingSettled) { + if (settledState.bettingId && !settledState.bettingSettled) { const bettingEntries = await store.getBettingEntries(); if (bettingEntries.length > 0) { - const payoutInfo = buildBettingPayouts(state.winnerId, bettingEntries); + const payoutInfo = buildBettingPayouts(settledState.winnerId!, bettingEntries); if (payoutInfo.payouts.length > 0) { if (payoutInfo.refundAll) { - await daemonTransport.sendCommand({ + const result = await daemonTransport.requestCommand({ type: 'tournamentRefund', - bettingId: state.bettingId, + requestId: `tournament:${settledState.bettingId}:betting-refund`, + bettingId: settledState.bettingId, refunds: payoutInfo.payouts, reason: 'no_winner', }); + requireSuccessfulResult(result, 'tournamentRefund'); } else { - await daemonTransport.sendCommand({ + const result = await daemonTransport.requestCommand({ type: 'tournamentBettingPayout', - bettingId: state.bettingId, + requestId: `tournament:${settledState.bettingId}:betting-payout`, + bettingId: settledState.bettingId, payouts: payoutInfo.payouts, reason: 'winner_payout', }); + requireSuccessfulResult(result, 'tournamentBettingPayout'); } } } settledState = { - ...(settledState ?? state), + ...settledState, bettingSettled: true, }; - } - - if (settledState) { + changed = true; await store.setState(settledState); } - return settledState; + return changed ? settledState : null; +}; + +const needsSettlement = (state: TournamentState): boolean => + state.stage === 0 && + Boolean(state.winnerId) && + (!state.rewardSettled || (Boolean(state.bettingId) && !state.bettingSettled)); + +export const processTournamentTick = async (options: { + store: TournamentStore; + prisma: GamePrismaClient; + daemonTransport: TurnDaemonTransport; + now?: () => number; +}): Promise => { + const { store, prisma, daemonTransport } = options; + const now = options.now ?? Date.now; + let processedState: TournamentState | null = null; + + await store.withMutationLock(async () => { + const state = await store.getState(); + if (!state || (!state.auto && !needsSettlement(state))) { + return; + } + const nextAt = new Date(state.nextAt).getTime(); + if (state.auto && Number.isFinite(nextAt) && nextAt > now()) { + return; + } + + if (needsSettlement(state)) { + processedState = (await settleTournamentOutcome({ store, daemonTransport, state })) ?? state; + return; + } + + const worldState = await prisma.worldState.findFirst(); + const baseSeed = (worldState?.meta as Record | null)?.hiddenSeed ?? 'tournament'; + let nextState = state; + if (isBattleStage(state.stage)) { + nextState = await applyBattle(store, state, String(baseSeed), daemonTransport); + } else if (isPreBattleStage(state.stage)) { + nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport); + } + processedState = + (await settleTournamentOutcome({ + store, + daemonTransport, + state: nextState, + })) ?? nextState; + }); + + return processedState; }; export const runTournamentWorker = async (): Promise => { @@ -527,10 +598,7 @@ export const runTournamentWorker = async (): Promise => { await redis.connect(); const store = new TournamentStore(redis.client, buildTournamentKeys(config.profileName)); - const daemonTransport = new RedisTurnDaemonTransport(redis.client, { - keys: buildTurnDaemonStreamKeys(config.profileName), - requestTimeoutMs: config.daemonRequestTimeoutMs, - }); + const daemonTransport = new DatabaseTurnDaemonTransport(postgres.prisma, config.daemonRequestTimeoutMs); const handleExit = async () => { await redis.disconnect(); @@ -541,49 +609,23 @@ export const runTournamentWorker = async (): Promise => { while (true) { const state = await store.getState(); - if (!state || !state.auto) { + if (!state || (!state.auto && !needsSettlement(state))) { await sleepMs(config.tournamentPollMs); continue; } const nextAt = new Date(state.nextAt).getTime(); const now = Date.now(); - if (Number.isFinite(nextAt) && nextAt > now) { + if (state.auto && Number.isFinite(nextAt) && nextAt > now) { await sleepMs(Math.min(config.tournamentPollMs, nextAt - now)); continue; } try { - await store.withMutationLock(async () => { - const lockedState = await store.getState(); - if (!lockedState || !lockedState.auto) { - return; - } - const lockedNextAt = new Date(lockedState.nextAt).getTime(); - if (Number.isFinite(lockedNextAt) && lockedNextAt > Date.now()) { - return; - } - - const worldState = await postgres.prisma.worldState.findFirst(); - const baseSeed = (worldState?.meta as Record | null)?.hiddenSeed ?? 'tournament'; - let nextState = lockedState; - if (isBattleStage(lockedState.stage)) { - nextState = await applyBattle(store, lockedState, String(baseSeed), daemonTransport); - } else if (isPreBattleStage(lockedState.stage)) { - nextState = await applyPreBattleStage( - store, - postgres.prisma, - lockedState, - String(baseSeed), - daemonTransport - ); - } - - await settleTournamentOutcome({ - store, - daemonTransport, - state: nextState, - }); + await processTournamentTick({ + store, + prisma: postgres.prisma, + daemonTransport, }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; @@ -603,9 +645,9 @@ export const runTournamentWorker = async (): Promise => { }, }, }); + const currentState = (await store.getState()) ?? state; const nextState: TournamentState = { - ...state, - auto: false, + ...currentState, lastError: message, lastErrorAt: now, }; diff --git a/app/game-api/src/tournament/workerHelpers.ts b/app/game-api/src/tournament/workerHelpers.ts index 2519d24..eda261b 100644 --- a/app/game-api/src/tournament/workerHelpers.ts +++ b/app/game-api/src/tournament/workerHelpers.ts @@ -520,8 +520,9 @@ export const buildBettingPayouts = ( const winners = entries.filter((entry) => entry.targetId === winnerId); const winnersTotal = winners.reduce((sum, entry) => sum + entry.amount, 0); if (winnersTotal <= 0) { - const refunds = entries.map((entry) => ({ generalId: entry.generalId, amount: entry.amount })); - return { payouts: refunds, total, refundAll: true }; + // Legacy Betting::_calcRewardExclusive() builds a refund candidate list + // but returns no rewards when nobody selected the winner. + return { payouts: [], total, refundAll: false }; } const ratio = total / winnersTotal; const payouts = winners.map((entry) => ({ diff --git a/app/game-api/test/inGameInfoRouter.test.ts b/app/game-api/test/inGameInfoRouter.test.ts index 8a788cd..9cb7c5d 100644 --- a/app/game-api/test/inGameInfoRouter.test.ts +++ b/app/game-api/test/inGameInfoRouter.test.ts @@ -53,13 +53,13 @@ const general = (overrides: Partial = {}): GeneralRow => ({ updatedAt: now, ...overrides, }); -const auth = (roles: string[] = []): GameSessionTokenPayload => ({ +const auth = (roles: string[] = [], userId = 'user-1'): GameSessionTokenPayload => ({ version: 1, profile: 'che:default', issuedAt: now.toISOString(), expiresAt: new Date(now.getTime() + 86400000).toISOString(), sessionId: 'session', - user: { id: 'user-1', username: 'tester', displayName: 'Tester', roles }, + user: { id: userId, username: 'tester', displayName: 'Tester', roles }, sanctions: {}, }); const city = (id: number, nationId: number) => ({ @@ -88,15 +88,30 @@ const city = (id: number, nationId: number) => ({ meta: {}, }); -const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Record } = {}) => { +const context = ( + options: { + me?: GeneralRow; + roles?: string[]; + userId?: string; + nationMeta?: Record; + nationLevel?: number; + stationCityId?: number; + } = {} +) => { const me = options.me ?? general(); const cities = [city(1, 1), city(2, 2), city(3, 2), city(80, 1)]; const foreign = general({ id: 2, userId: 'user-2', name: '적군', nationId: 2, cityId: 2, crew: 777 }); const db = { general: { - findFirst: vi.fn(async () => me), + findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => + where.userId === me.userId ? me : null + ), findMany: vi.fn(async (args: { where?: Record; select?: Record }) => { - if (args.where?.nationId === 1 && args.select?.cityId) return [{ cityId: me.cityId }]; + if (args.where?.nationId === 1 && args.select?.cityId) + return [ + { cityId: me.cityId }, + ...(options.stationCityId ? [{ cityId: options.stationCityId }] : []), + ]; if (args.where?.cityId === 2) return [foreign]; if (args.where?.cityId === 3) return [foreign]; if (args.where?.officerLevel) return []; @@ -108,7 +123,7 @@ const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Reco id: 1, name: '아국', color: '#008000', - level: 1, + level: options.nationLevel ?? 1, capitalCityId: 1, meta: options.nationMeta ?? {}, })), @@ -130,7 +145,7 @@ const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Reco turnDaemon: {} as GameApiContext['turnDaemon'], battleSim: {} as GameApiContext['battleSim'], profile: { id: 'che', scenario: 'default', name: 'che:default' }, - auth: auth(options.roles), + auth: auth(options.roles, options.userId), uploadDir: 'uploads', uploadPath: '/uploads', uploadPublicUrl: null, @@ -157,6 +172,25 @@ describe('in-game information permissions', () => { expect(result.generals).toEqual([]); }); + it('derives the actor from the session user instead of accepting another user general', async () => { + const caller = appRouter.createCaller(context({ userId: 'user-2' })); + await expect(caller.world.getCurrentCity({ cityId: 1 })).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('allows a nation member to select a city occupied by another general of the same nation', async () => { + const result = await appRouter.createCaller(context({ stationCityId: 2 })).world.getCurrentCity({ cityId: 2 }); + expect(result.options.map((entry) => entry.id)).toContain(2); + expect(result.visibility.full).toBe(true); + }); + + it('does not grant a spy city while the nation has no active level', async () => { + const result = await appRouter + .createCaller(context({ nationMeta: { spy: { 2: 2 } }, nationLevel: 0 })) + .world.getCurrentCity({ cityId: 2 }); + expect(result.options.map((entry) => entry.id)).not.toContain(2); + expect(result.visibility.full).toBe(false); + }); + it('keeps adjacent foreign detail redacted and never reveals military fields', async () => { const result = await appRouter .createCaller(context({ me: general({ cityId: 80 }) })) @@ -174,12 +208,20 @@ describe('in-game information permissions', () => { expect(result.visibility.full).toBe(true); expect(result.city.population).toBe(1000); expect(result.generals[0]).toMatchObject({ crew: 777, train: null, atmos: null, crewTypeId: null }); + expect(result.forceSummary).toMatchObject({ + enemyCrew: 777, + enemyArmedGenerals: 1, + enemyGenerals: 1, + }); }); - it('allows administrative roles to inspect all city and general fields', async () => { - const result = await appRouter.createCaller(context({ roles: ['admin'] })).world.getCurrentCity({ cityId: 3 }); - expect(result.options).toHaveLength(4); - expect(result.visibility.full).toBe(true); - expect(result.generals[0]).toMatchObject({ crew: 777, train: 90, atmos: 90, crewTypeId: 1 }); - }); + it.each(['admin', 'superuser', 'admin.superuser'])( + 'allows the %s role to inspect all city and general fields', + async (role) => { + const result = await appRouter.createCaller(context({ roles: [role] })).world.getCurrentCity({ cityId: 3 }); + expect(result.options).toHaveLength(4); + expect(result.visibility.full).toBe(true); + expect(result.generals[0]).toMatchObject({ crew: 777, train: 90, atmos: 90, crewTypeId: 1 }); + } + ); }); diff --git a/app/game-api/test/inheritRouter.test.ts b/app/game-api/test/inheritRouter.test.ts new file mode 100644 index 0000000..da9f6e5 --- /dev/null +++ b/app/game-api/test/inheritRouter.test.ts @@ -0,0 +1,266 @@ +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 buildGeneral = (overrides: Partial = {}): GeneralRow => ({ + id: 7, + userId: 'user-1', + name: '유비', + nationId: 1, + cityId: 1, + troopId: 0, + npcState: 0, + affinity: null, + bornYear: 180, + deadYear: 300, + picture: null, + imageServer: 0, + leadership: 70, + strength: 45, + intel: 85, + injury: 0, + experience: 0, + dedication: 0, + officerLevel: 1, + gold: 1000, + rice: 1000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: new Date('2026-07-26T00:00:00Z'), + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'che_선봉', + lastTurn: {}, + meta: {}, + penalty: {}, + createdAt: new Date('2026-07-26T00:00:00Z'), + updatedAt: new Date('2026-07-26T00:00:00Z'), + ...overrides, +}); + +const buildAuth = (userId = 'user-1'): GameSessionTokenPayload => ({ + version: 1, + profile: 'che:default', + issuedAt: '2026-07-26T00:00:00.000Z', + expiresAt: '2026-07-27T00:00:00.000Z', + sessionId: `session-${userId}`, + user: { + id: userId, + username: userId, + displayName: userId, + roles: [], + }, + sanctions: {}, +}); + +const worldState = { + id: 1, + scenarioCode: 'default', + currentYear: 200, + currentMonth: 4, + tickSeconds: 3600, + config: { + const: { + availableSpecialWar: ['che_선봉'], + allItems: { + weapon: { + che_무기_12_칠성검: 1, + che_무기_01_단도: 0, + }, + }, + }, + }, + meta: { hiddenSeed: 'test-seed', isUnited: 0, season: 1 }, + updatedAt: new Date('2026-07-26T00:00:00Z'), +}; + +const buildContext = (options: { + auth?: GameSessionTokenPayload | null; + general?: GeneralRow | null; + target?: GeneralRow | null; + inheritancePoint?: number; +}) => { + const auth = options.auth === undefined ? buildAuth() : options.auth; + const general = options.general === undefined ? buildGeneral() : options.general; + const target = + options.target === undefined + ? buildGeneral({ id: 8, userId: 'user-2', name: '조조', meta: { ownerName: '위유저' } }) + : options.target; + const requestCommand = vi.fn(async (command: { type: string; generalId: number }) => ({ + type: command.type, + ok: true, + generalId: command.generalId, + })); + const pointUpsert = vi.fn(async () => ({})); + const logCreate = vi.fn(async () => ({})); + const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : [])); + const db = { + $queryRaw: vi.fn(async () => [{ value: options.inheritancePoint ?? 10_000 }]), + worldState: { + findFirst: vi.fn(async () => worldState), + }, + general: { + findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => + general?.userId === where.userId ? general : null + ), + findMany, + findUnique: vi.fn(async ({ where }: { where: { id: number } }) => + target?.id === where.id ? target : null + ), + }, + inheritancePoint: { + upsert: pointUpsert, + }, + inheritanceLog: { + create: logCreate, + findMany: vi.fn(async () => []), + }, + inheritanceUserState: { + findUnique: vi.fn(async () => null), + upsert: vi.fn(async () => ({})), + }, + }; + const accessTokenStore = new RedisAccessTokenStore( + { + get: async () => null, + set: async () => null, + }, + 'che:default' + ); + const context: GameApiContext = { + db: db as unknown as DatabaseClient, + redis: {} as RedisConnector['client'], + turnDaemon: { requestCommand } as unknown as TurnDaemonTransport, + battleSim: {} as GameApiContext['battleSim'], + profile: { id: 'che', scenario: 'default', name: 'che:default' }, + auth, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore, + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; + return { context, requestCommand, pointUpsert, logCreate, findMany }; +}; + +describe('inherit router actor and permission boundaries', () => { + it('rejects unauthenticated status and mutations', async () => { + const fixture = buildContext({ auth: null }); + const caller = appRouter.createCaller(fixture.context); + + await expect(caller.inherit.getStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + await expect(caller.inherit.buyHiddenBuff({ type: 'warAvoidRatio', level: 1 })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); + + it('builds status only from the authenticated user general and filters target generals like ref', async () => { + const fixture = buildContext({}); + const status = await appRouter.createCaller(fixture.context).inherit.getStatus(); + + expect(status.currentStat).toEqual({ leadership: 70, strength: 45, intel: 85 }); + expect(status.availableTargetGenerals).toEqual([{ id: 8, name: '조조' }]); + expect(status.availableUnique).toEqual([ + expect.objectContaining({ key: 'che_무기_12_칠성검', rawName: '칠성검' }), + ]); + expect(status.buffLevels).toHaveProperty('domesticSuccessProb', 0); + expect(fixture.findMany).toHaveBeenCalledWith({ + where: { id: { not: 7 }, npcState: { lt: 2 }, userId: { not: null } }, + select: { id: true, name: true }, + orderBy: { id: 'asc' }, + }); + }); + + it('does not dispatch or charge when the authenticated user owns no general', async () => { + const fixture = buildContext({ + auth: buildAuth('user-2'), + general: buildGeneral({ userId: 'user-1' }), + }); + + await expect( + appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({ + type: 'domesticSuccessProb', + level: 1, + }) + ).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + message: '장수가 존재하지 않습니다.', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + expect(fixture.pointUpsert).not.toHaveBeenCalled(); + }); + + it('mutates only the authenticated user general and inheritance balance', async () => { + const fixture = buildContext({ inheritancePoint: 1000 }); + + await expect( + appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({ + type: 'domesticSuccessProb', + level: 1, + }) + ).resolves.toEqual({ ok: true, remainPoint: 800 }); + + expect(fixture.requestCommand).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'patchGeneral', + generalId: 7, + patch: expect.objectContaining({ + meta: expect.objectContaining({ + inheritBuff: JSON.stringify({ domesticSuccessProb: 1 }), + }), + }), + }) + ); + expect(fixture.pointUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId_key: { userId: 'user-1', key: 'previous' } }, + update: { value: 800 }, + }) + ); + }); + + it('reveals a target owner to the caller without using the caller general id from input', async () => { + const fixture = buildContext({ inheritancePoint: 1500 }); + + await expect( + appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 }) + ).resolves.toEqual({ + ok: true, + ownerName: '위유저', + targetName: '조조', + }); + expect(fixture.pointUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId_key: { userId: 'user-1', key: 'previous' } }, + update: { value: 500 }, + }) + ); + expect(fixture.logCreate).toHaveBeenCalledWith({ + data: { + userId: 'user-1', + year: 200, + month: 4, + text: '1000 포인트로 장수 소유자 확인', + }, + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/app/game-api/test/nationGeneralSecretRouter.test.ts b/app/game-api/test/nationGeneralSecretRouter.test.ts index 329ad5e..c1fce96 100644 --- a/app/game-api/test/nationGeneralSecretRouter.test.ts +++ b/app/game-api/test/nationGeneralSecretRouter.test.ts @@ -1,18 +1,16 @@ import { describe, expect, it, vi } from 'vitest'; - import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { RedisConnector } from '@sammo-ts/infra'; - import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; import { InMemoryFlushStore } from '../src/auth/flushStore.js'; import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js'; import { appRouter } from '../src/router.js'; -const now = new Date('2026-01-01T01:02:00.000Z'); -const buildGeneral = (overrides: Partial = {}): GeneralRow => ({ +const now = new Date('2026-01-01T01:02:00Z'); +const general = (overrides: Partial = {}): GeneralRow => ({ id: 1, - userId: 'user-1', - name: '일반장수', + userId: 'u1', + name: '장수', nationId: 1, cityId: 1, troopId: 0, @@ -20,7 +18,7 @@ const buildGeneral = (overrides: Partial = {}): GeneralRow => ({ affinity: null, bornYear: 180, deadYear: 300, - picture: 'default.jpg', + picture: null, imageServer: 0, leadership: 70, strength: 60, @@ -29,8 +27,8 @@ const buildGeneral = (overrides: Partial = {}): GeneralRow => ({ experience: 900, dedication: 100, officerLevel: 1, - gold: 1_000, - rice: 2_000, + gold: 1000, + rice: 2000, crew: 300, crewTypeId: 1, train: 90, @@ -53,157 +51,92 @@ const buildGeneral = (overrides: Partial = {}): GeneralRow => ({ updatedAt: now, ...overrides, }); - -const auth = (userId: string): GameSessionTokenPayload => ({ +const token = (userId: string): GameSessionTokenPayload => ({ version: 1, profile: 'che:default', issuedAt: now.toISOString(), - expiresAt: new Date(now.getTime() + 86_400_000).toISOString(), - sessionId: `session-${userId}`, + expiresAt: new Date(now.getTime() + 86400000).toISOString(), + sessionId: userId, user: { id: userId, username: userId, displayName: userId, roles: [] }, sanctions: {}, }); - -const createContext = (options: { - sessionUserId?: string; - generals?: GeneralRow[]; - nationMeta?: Record; -}) => { - const sessionUserId = options.sessionUserId ?? 'user-1'; - const generals = options.generals ?? [buildGeneral()]; +const fixture = (generals: GeneralRow[], userId = 'u1') => { const db = { general: { findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => - generals.find((general) => general.userId === where.userId) + generals.find((g) => g.userId === where.userId) ), findMany: vi.fn(async ({ where }: { where: { nationId: number } }) => - generals.filter((general) => general.nationId === where.nationId) + generals.filter((g) => g.nationId === where.nationId) ), }, nation: { findUnique: vi.fn(async () => ({ id: 1, name: '위', - color: '#008000', + color: '#080', level: 3, typeCode: 'che_중립', capitalCityId: 1, - meta: options.nationMeta ?? { secretlimit: 3 }, + meta: { secretlimit: 3 }, })), }, city: { findMany: vi.fn(async () => [{ id: 1, name: '업' }]) }, troop: { findMany: vi.fn(async () => [{ troopLeaderId: 2, name: '선봉대' }]) }, + worldState: { findFirst: vi.fn(async () => null) }, + generalTurn: { findMany: vi.fn(async () => [{ generalId: 1, turnIdx: 0, actionCode: '징병' }]) }, generalAccessLog: { - findMany: vi.fn(async () => - generals.map((general) => ({ - generalId: general.id, - refreshScore: general.id, - refreshScoreTotal: general.id * 10, - })) - ), - }, - generalTurn: { - findMany: vi.fn(async () => [ - { generalId: 1, turnIdx: 0, actionCode: '징병' }, - { generalId: 1, turnIdx: 1, actionCode: '훈련' }, - ]), + findMany: vi.fn(async () => generals.map((g) => ({ generalId: g.id, refreshScoreTotal: g.id * 10 }))), }, }; const redis = { get: vi.fn(async () => null), set: vi.fn(async () => null) } as unknown as RedisConnector['client']; - return { - context: { - db: db as unknown as DatabaseClient, - redis, - turnDaemon: {} as GameApiContext['turnDaemon'], - battleSim: {} as GameApiContext['battleSim'], - profile: { id: 'che', scenario: 'default', name: 'che:default' }, - auth: auth(sessionUserId), - uploadDir: 'uploads', - uploadPath: '/uploads', - uploadPublicUrl: null, - accessTokenStore: new RedisAccessTokenStore(redis, 'che:default'), - flushStore: new InMemoryFlushStore(), - gameTokenSecret: 'test-secret', - } satisfies GameApiContext, - db, + const context: GameApiContext = { + db: db as unknown as DatabaseClient, + redis, + turnDaemon: {} as GameApiContext['turnDaemon'], + battleSim: {} as GameApiContext['battleSim'], + profile: { id: 'che', scenario: 'default', name: 'che:default' }, + auth: token(userId), + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore: new RedisAccessTokenStore(redis, 'che:default'), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'secret', }; + return { caller: appRouter.createCaller(context), db }; }; -describe('nation general and secret-office permissions', () => { - it('redacts confidential columns for an ordinary member and denies the secret office', async () => { - const fixture = createContext({}); - const caller = appRouter.createCaller(fixture.context); - - const list = await caller.nation.getGeneralList(); - expect(list.viewer).toEqual({ generalId: 1, permission: 0 }); - expect(list.generals[0]).toMatchObject({ +describe('nation general and secret office permissions', () => { + it('redacts ordinary-member details and denies the secret office', async () => { + const { caller } = fixture([general()]); + const result = await caller.nation.getGeneralList(); + expect(result.viewer).toEqual({ generalId: 1, permission: 0 }); + expect(result.generals[0]).toMatchObject({ officerLevel: 1, - gold: 1_000, - rice: 2_000, - detail: null, + cityName: null, + troopName: null, + refreshScoreTotal: 10, }); - expect(fixture.db.generalTurn.findMany).not.toHaveBeenCalled(); + expect(result.generals[0]).not.toHaveProperty('crew'); await expect(caller.nation.getSecretGeneralList()).rejects.toMatchObject({ code: 'FORBIDDEN' }); }); - - it('allows a tenured member, scopes rows to the actor nation, and returns legacy summary and turns', async () => { - const me = buildGeneral({ meta: { belong: 3, defence_train: 90, killturn: 7 } }); - const ally = buildGeneral({ - id: 2, - userId: 'user-2', - name: '아군', - troopId: 2, - gold: 3_000, - rice: 4_000, - crew: 200, - train: 80, - atmos: 80, - }); - const hiddenNpc = buildGeneral({ id: 3, userId: null, name: '가상', npcState: 5, gold: 99_999 }); - const foreign = buildGeneral({ id: 4, userId: 'foreign', nationId: 2, gold: 88_888 }); - const fixture = createContext({ generals: [me, ally, hiddenNpc, foreign] }); - - const result = await appRouter.createCaller(fixture.context).nation.getSecretGeneralList(); - expect(result.viewer.permission).toBe(1); - expect(result.generals.map((general) => general.id)).toEqual([1, 2, 3]); - expect(result.generals[0]?.detail).toMatchObject({ - cityName: '업', - defenceTrain: 90, - killTurn: 7, - reservedCommands: ['징병', '훈련'], - }); - expect(result.generals[1]?.detail?.troopName).toBe('선봉대'); - expect(result.summary).toMatchObject({ - gold: 4_000, - rice: 6_000, - crew: 500, - generalCount: 2, - readiness: { - 90: { crew: 300, generals: 1 }, - 80: { crew: 500, generals: 2 }, - 60: { crew: 500, generals: 2 }, - }, - }); - expect(fixture.db.general.findMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { nationId: 1 } }) - ); - }); - - it('derives the acting general from the authenticated user and applies that general permission', async () => { - const first = buildGeneral({ userId: 'user-1', meta: { belong: 1 } }); - const second = buildGeneral({ id: 2, userId: 'user-2', officerLevel: 5, meta: { belong: 1 } }); - const fixture = createContext({ sessionUserId: 'user-2', generals: [first, second] }); - - const result = await appRouter.createCaller(fixture.context).nation.getSecretGeneralList(); + it('uses the session-owned general and scopes secret rows to that nation', async () => { + const first = general(); + const actor = general({ id: 2, userId: 'u2', officerLevel: 5, meta: { belong: 1 } }); + const ally = general({ id: 3, userId: 'u3', gold: 3000, crew: 200, train: 80, atmos: 80 }); + const foreign = general({ id: 4, userId: 'u4', nationId: 2, gold: 99999 }); + const { caller, db } = fixture([first, actor, ally, foreign], 'u2'); + const result = await caller.nation.getSecretGeneralList(); expect(result.viewer).toEqual({ generalId: 2, permission: 2 }); - expect(fixture.db.general.findFirst).toHaveBeenCalledWith({ where: { userId: 'user-2' } }); + expect(result.generals.map((g) => g.id)).toEqual([1, 2, 3]); + expect(result.summary).toMatchObject({ gold: 5000, crew: 800, generalCount: 3 }); + expect(db.general.findFirst).toHaveBeenCalledWith({ where: { userId: 'u2' } }); + expect(db.general.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { nationId: 1 } })); }); - - it('keeps penalty-based secret denial even for an otherwise qualified general', async () => { - const me = buildGeneral({ officerLevel: 5, penalty: { noChief: true } }); - const fixture = createContext({ generals: [me] }); - await expect(appRouter.createCaller(fixture.context).nation.getSecretGeneralList()).rejects.toMatchObject({ - code: 'FORBIDDEN', - }); + it('honors general penalties after a user switch', async () => { + const penalized = general({ officerLevel: 5, penalty: { noChief: true } }); + const { caller } = fixture([penalized]); + await expect(caller.nation.getSecretGeneralList()).rejects.toMatchObject({ code: 'FORBIDDEN' }); }); }); diff --git a/app/game-api/test/npcPolicyRouter.test.ts b/app/game-api/test/npcPolicyRouter.test.ts new file mode 100644 index 0000000..052f35d --- /dev/null +++ b/app/game-api/test/npcPolicyRouter.test.ts @@ -0,0 +1,275 @@ +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: 12, + 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: { 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 baseNation = { + id: 1, + name: '위', + level: 3, + tech: 3_000, + meta: { + _updatedAt: '2026-01-01T00:00:00.000Z', + npc_nation_policy: { + values: { reqNationRice: 456 }, + priority: ['천도', '천도'], + }, + npc_general_policy: { + priority: ['출병', '일반내정', '출병'], + }, + }, +}; + +const baseWorld = { + config: { + stat: { max: 80, npcMax: 75 }, + environment: { unitSet: 'basic' }, + const: { develCost: 100 }, + }, + meta: { + npc_nation_policy: { values: { reqNationGold: 123 } }, + npc_general_policy: {}, + }, +}; + +const createContext = ( + options: { + me?: GeneralRow; + nation?: typeof baseNation; + world?: typeof baseWorld; + requestCommand?: ReturnType; + troopRows?: Array<{ troopLeaderId: number }>; + cityRows?: Array<{ id: number }>; + } = {} +): { context: GameApiContext; findFirst: ReturnType; requestCommand: ReturnType } => { + const requestCommand = + options.requestCommand ?? + vi.fn(async () => ({ + type: 'setNationMeta', + ok: true, + nationId: 1, + updatedAt: '2026-01-01T00:01:00.000Z', + })); + const findFirst = vi.fn(async () => options.me ?? baseGeneral); + const db = { + general: { findFirst }, + nation: { findUnique: vi.fn(async () => options.nation ?? baseNation) }, + worldState: { findFirst: vi.fn(async () => options.world ?? baseWorld) }, + troop: { findMany: vi.fn(async () => options.troopRows ?? [{ troopLeaderId: 101 }]) }, + city: { findMany: vi.fn(async () => options.cityRows ?? [{ id: 1 }, { id: 2 }]) }, + }; + const redisClient = { get: async () => null, set: async () => null }; + return { + context: { + 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', + }, + findFirst, + requestCommand, + }; +}; + +describe('NPC policy router', () => { + it('loads server and nation overrides while calculating legacy zero-value hints from nation tech', async () => { + const fixture = createContext(); + const result = await appRouter.createCaller(fixture.context).npc.getPolicy(); + + expect(fixture.findFirst).toHaveBeenCalledWith({ where: { userId: 'user-22' } }); + expect(result.currentNationPolicy).toMatchObject({ reqNationGold: 123, reqNationRice: 456 }); + expect(result.currentNationPriority).toEqual(['천도', '천도']); + expect(result.currentGeneralActionPriority).toEqual(['출병', '일반내정', '출병']); + expect(result.zeroPolicy).toMatchObject({ + reqNationGold: 10_000, + reqNationRice: 12_000, + reqNPCDevelGold: 3_000, + reqNPCWarGold: 3_900, + reqNPCWarRice: 3_900, + reqHumanWarUrgentGold: 6_300, + reqHumanWarUrgentRice: 6_300, + reqHumanWarRecommandGold: 12_600, + reqHumanWarRecommandRice: 12_600, + }); + }); + + it('lets a secret-level reader load the page but rejects every mutation before daemon dispatch', async () => { + const reader = { ...baseGeneral, officerLevel: 2 }; + const fixture = createContext({ me: reader }); + const caller = appRouter.createCaller(fixture.context); + + await expect(caller.npc.getPolicy()).resolves.toMatchObject({ permissionLevel: 1 }); + await expect(caller.npc.setNationPriority(['천도'])).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await expect(caller.npc.setGeneralPriority(['출병', '일반내정'])).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect(caller.npc.setNationPolicy({ reqNationGold: 100 })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); + + it.each([ + ['군주', { ...baseGeneral, officerLevel: 12 }], + ['감찰권자', { ...baseGeneral, officerLevel: 1, meta: { belong: 0, permission: 'auditor' } }], + ['외교권자', { ...baseGeneral, officerLevel: 1, meta: { belong: 0, permission: 'ambassador' } }], + ])('%s can persist policy through the daemon-owned metadata command', async (_label, me) => { + const fixture = createContext({ me }); + await expect(appRouter.createCaller(fixture.context).npc.setNationPriority(['천도', '천도'])).resolves.toEqual({ + ok: true, + }); + expect(fixture.requestCommand).toHaveBeenCalledWith({ + type: 'setNationMeta', + nationId: 1, + updates: { + npc_nation_policy: expect.objectContaining({ + priority: ['천도', '천도'], + prioritySetter: '정책담당', + prioritySetTime: expect.any(String), + }), + }, + expectedUpdatedAt: '2026-01-01T00:00:00.000Z', + }); + }); + + it('clamps legacy integer values, preserves float values, and validates troop ownership before dispatch', async () => { + const fixture = createContext(); + const caller = appRouter.createCaller(fixture.context); + + await caller.npc.setNationPolicy({ + reqNationGold: -100, + safeRecruitCityPopulationRatio: -0.5, + CombatForce: { 101: [1, 2] }, + }); + expect(fixture.requestCommand).toHaveBeenCalledWith( + expect.objectContaining({ + updates: { + npc_nation_policy: expect.objectContaining({ + values: expect.objectContaining({ + reqNationGold: 0, + safeRecruitCityPopulationRatio: -0.5, + CombatForce: { 101: [1, 2] }, + }), + }), + }, + }) + ); + + fixture.requestCommand.mockClear(); + await expect(caller.npc.setNationPolicy({ SupportForce: [999] })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); + + it('preserves duplicate legacy priority entries and enforces required general actions and ordering', async () => { + const fixture = createContext(); + const caller = appRouter.createCaller(fixture.context); + + await caller.npc.setGeneralPriority(['출병', '출병', '일반내정']); + expect(fixture.requestCommand).toHaveBeenCalledWith( + expect.objectContaining({ + updates: { + npc_general_policy: expect.objectContaining({ + priority: ['출병', '출병', '일반내정'], + }), + }, + }) + ); + await expect(caller.npc.setGeneralPriority(['일반내정', '출병'])).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + await expect(caller.npc.setGeneralPriority(['출병'])).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + + it('blocks nationless, penalized, and stale writers without changing lifecycle state directly', async () => { + const nationless = createContext({ me: { ...baseGeneral, nationId: 0, officerLevel: 0 } }); + await expect(appRouter.createCaller(nationless.context).npc.getPolicy()).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + }); + + const penalized = createContext({ me: { ...baseGeneral, penalty: { noChief: true } } }); + await expect(appRouter.createCaller(penalized.context).npc.getPolicy()).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + + const staleCommand = vi.fn(async () => ({ + type: 'setNationMeta', + ok: false, + nationId: 1, + reason: 'CONFLICT', + })); + const stale = createContext({ requestCommand: staleCommand }); + await expect(appRouter.createCaller(stale.context).npc.setNationPriority(['천도'])).rejects.toMatchObject({ + code: 'CONFLICT', + }); + }); +}); diff --git a/app/game-api/test/tournamentWorker.test.ts b/app/game-api/test/tournamentWorker.test.ts index 4a0ca2a..4c89803 100644 --- a/app/game-api/test/tournamentWorker.test.ts +++ b/app/game-api/test/tournamentWorker.test.ts @@ -12,6 +12,7 @@ import type { TournamentState, } from '../src/tournament/types.js'; import { applyBattle, applyPreBattleStage, settleTournamentOutcome } from '../src/tournament/worker.js'; +import { buildBettingPayouts } from '../src/tournament/workerHelpers.js'; import type { TurnDaemonTransport } from '../src/daemon/transport.js'; class MemoryRedis { @@ -209,6 +210,15 @@ const runTournamentToCompletion = async (options: { const delayTick = async (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); describe('tournament worker (in-memory)', () => { + it('당첨자가 없으면 레거시와 같이 베팅금을 지급하거나 환불하지 않는다', () => { + expect( + buildBettingPayouts(10, [ + { generalId: 1, targetId: 11, amount: 100 }, + { generalId: 2, targetId: 12, amount: 200 }, + ]) + ).toEqual({ payouts: [], total: 300, refundAll: false }); + }); + it('locks 64 applicants into eight groups of eight', async () => { const redis = new MemoryRedis(); const store = new TournamentStore(redis, buildTournamentKeys('test-groups')); @@ -284,11 +294,33 @@ describe('tournament worker (in-memory)', () => { const sent: TurnDaemonCommand[] = []; const transport: TurnDaemonTransport = { - sendCommand: async (command) => { + sendCommand: async () => 'unused', + requestCommand: async (command) => { sent.push(command); - return 'ok'; + if (command.type === 'tournamentReward') { + return { + type: 'tournamentReward', + ok: true, + winnerId: command.winnerId, + runnerUpId: command.runnerUpId, + rewarded: 2, + missing: 0, + totalGold: 100, + totalExp: 10, + }; + } + if (command.type === 'tournamentBettingPayout') { + return { + type: 'tournamentBettingPayout', + ok: true, + bettingId: command.bettingId, + processed: command.payouts.length, + missing: 0, + totalPayout: command.payouts.reduce((sum, payout) => sum + payout.amount, 0), + }; + } + return null; }, - requestCommand: async () => null, requestStatus: async () => null, }; @@ -302,6 +334,82 @@ describe('tournament worker (in-memory)', () => { if (bettingCommand && bettingCommand.type === 'tournamentBettingPayout') { expect(bettingCommand.payouts).toEqual([{ generalId: 1, amount: 300 }]); } + expect(await store.getState()).toMatchObject({ + rewardSettled: true, + bettingSettled: true, + }); + }); + + it('정산 응답 실패 시 완료 표시를 남기지 않고 성공한 보상만 재시도에서 제외한다', async () => { + const redis = new MemoryRedis(); + const store = new TournamentStore(redis, buildTournamentKeys('test-bet-retry')); + const state = createTournamentState({ + stage: 0, + auto: false, + winnerId: 10, + bettingId: 124, + rewardSettled: false, + bettingSettled: false, + }); + await store.setMatches([{ id: 1, stage: 10, roundIndex: 0, attackerId: 10, defenderId: 11, winnerId: 10 }]); + await store.setBettingEntries([{ generalId: 1, targetId: 10, amount: 100 }]); + await store.setState(state); + + let payoutAttempts = 0; + const commands: TurnDaemonCommand[] = []; + const transport: TurnDaemonTransport = { + sendCommand: async () => 'unused', + requestCommand: async (command) => { + commands.push(command); + if (command.type === 'tournamentReward') { + return { + type: 'tournamentReward', + ok: true, + winnerId: command.winnerId, + runnerUpId: command.runnerUpId, + rewarded: 2, + missing: 0, + totalGold: 100, + totalExp: 10, + }; + } + if (command.type === 'tournamentBettingPayout') { + payoutAttempts += 1; + if (payoutAttempts === 1) { + return { + type: 'tournamentBettingPayout', + ok: false, + bettingId: command.bettingId, + reason: '일시적 실패', + }; + } + return { + type: 'tournamentBettingPayout', + ok: true, + bettingId: command.bettingId, + processed: 1, + missing: 0, + totalPayout: 100, + }; + } + return null; + }, + requestStatus: async () => null, + }; + + await expect(settleTournamentOutcome({ store, daemonTransport: transport, state })).rejects.toThrow( + '일시적 실패' + ); + const afterFailure = await store.getState(); + expect(afterFailure).toMatchObject({ rewardSettled: true, bettingSettled: false }); + + await settleTournamentOutcome({ store, daemonTransport: transport, state: afterFailure! }); + expect(await store.getState()).toMatchObject({ rewardSettled: true, bettingSettled: true }); + expect(commands.filter((command) => command.type === 'tournamentReward')).toHaveLength(1); + expect(commands.filter((command) => command.type === 'tournamentBettingPayout')).toHaveLength(2); + expect( + commands.filter((command) => command.type === 'tournamentBettingPayout').map((command) => command.requestId) + ).toEqual(['tournament:124:betting-payout', 'tournament:124:betting-payout']); }); it('자동 오픈 후 참가자 보충(NPC/더미 포함)하고 결승까지 진행된다', async () => { diff --git a/app/game-engine/test/npcPolicyLifecycle.test.ts b/app/game-engine/test/npcPolicyLifecycle.test.ts new file mode 100644 index 0000000..18ca5af --- /dev/null +++ b/app/game-engine/test/npcPolicyLifecycle.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'vitest'; + +import type { TurnCommandEnv, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic'; +import { asRecord } from '@sammo-ts/common'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { AutorunNationPolicy } from '../src/turn/ai/policies.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 general: TurnGeneral = { + id: 1, + userId: 'owner-1', + name: 'NPC군주', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 75, strength: 40, 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: 24 }, + penalty: {}, + officerLevel: 12, + experience: 0, + dedication: 0, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 1100, + train: 0, + atmos: 0, + age: 30, + npcState: 2, +}; + +const snapshot: TurnWorldSnapshot = { + generals: [general], + cities: [ + { + id: 1, + name: '허창', + nationId: 1, + level: 7, + state: 0, + population: 100_000, + populationMax: 200_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: {}, + }, + ], + nations: [ + { + id: 1, + name: '위', + color: '#777777', + capitalCityId: 1, + chiefGeneralId: 1, + gold: 10_000, + rice: 20_000, + power: 0, + level: 3, + typeCode: 'che_법가', + meta: { tech: 3_000, preserved: 'yes', _updatedAt: '2026-01-01T00:00:00.000Z' }, + }, + ], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + scenarioConfig: { + stat: { total: 300, min: 10, max: 80, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 65 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'basic' }, + }, + 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 state: TurnWorldState = { + id: 1, + currentYear: 185, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0185-01-01T00:00:00Z'), + meta: { killturn: 24 }, +}; + +const commandEnv: TurnCommandEnv = { + baseGold: 1_000, + baseRice: 1_000, + develCost: 18, + maxResourceActionAmount: 10_000, + minAvailableRecruitPop: 30_000, + trainDelta: 5, + atmosDelta: 5, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + sabotageDefaultProb: 0.5, + sabotageProbCoefByStat: 0.01, + sabotageDefenceCoefByGeneralCount: 0.01, + sabotageDamageMin: 1, + sabotageDamageMax: 10, + defaultCrewTypeId: 1100, + maxGeneral: 100, + defaultNpcGold: 1_000, + defaultNpcRice: 1_000, + defaultSpecialDomestic: null, + defaultSpecialWar: null, + openingPartYear: 3, + initialNationGenLimit: 10, + maxTechLevel: 10, + techLevelIncYear: 5, + initialAllowedTechLevel: 1, +}; + +const unitSet: UnitSetDefinition = { + id: 'basic', + name: 'basic', + defaultCrewTypeId: 1100, + armTypes: { 1: '보병' }, + crewTypes: [ + { + id: 1100, + armType: 1, + name: '보병', + attack: 100, + defence: 150, + speed: 7, + avoid: 10, + magicCoef: 0, + cost: 9, + rice: 9, + requirements: [], + attackCoef: {}, + defenceCoef: {}, + info: [], + initSkillTrigger: null, + phaseSkillTrigger: null, + iActionList: null, + }, + ], +}; + +describe('NPC policy lifecycle', () => { + it('applies one CAS-protected metadata command and the next AI instance consumes it without scheduler changes', async () => { + const world = new InMemoryTurnWorld(state, snapshot, { schedule }); + const handler = createTurnDaemonCommandHandler({ world }); + const updates = { + npc_nation_policy: { + values: { reqNationGold: 4_321 }, + priority: ['천도'], + valueSetter: '정책담당', + }, + }; + + await expect( + handler.handle({ + type: 'setNationMeta', + nationId: 1, + updates, + expectedUpdatedAt: '2026-01-01T00:00:00.000Z', + }) + ).resolves.toMatchObject({ type: 'setNationMeta', ok: true, nationId: 1 }); + + const nation = world.getNationById(1)!; + expect(nation.meta).toMatchObject({ preserved: 'yes', npc_nation_policy: updates.npc_nation_policy }); + const policy = new AutorunNationPolicy({ + general: world.getGeneralById(1)!, + aiOptions: null, + nationPolicy: asRecord(nation.meta).npc_nation_policy as Record, + serverPolicy: null, + nation, + env: commandEnv, + scenarioConfig: snapshot.scenarioConfig, + unitSet, + }); + expect(policy.reqNationGold).toBe(4_321); + expect(policy.priority).toEqual(['천도']); + expect(policy.reqNpcDevelGold).toBe(540); + expect(policy.reqNpcWarGold).toBe(3_900); + expect(policy.reqNpcWarRice).toBe(3_900); + + await expect( + handler.handle({ + type: 'setNationMeta', + nationId: 1, + updates: { npc_nation_policy: { values: { reqNationGold: 9_999 } } }, + expectedUpdatedAt: '2026-01-01T00:00:00.000Z', + }) + ).resolves.toMatchObject({ type: 'setNationMeta', ok: false, reason: 'CONFLICT' }); + expect(asRecord(asRecord(world.getNationById(1)?.meta).npc_nation_policy).values).toEqual({ + reqNationGold: 4_321, + }); + expect(world.getState()).toMatchObject({ currentYear: 185, currentMonth: 1, tickSeconds: 600 }); + }); +}); diff --git a/app/game-frontend/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts index 98e9421..b7da581 100644 --- a/app/game-frontend/e2e/inGameInfo.spec.ts +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -1,6 +1,28 @@ import { expect, test, type Page, type Route } from '@playwright/test'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; const response = (data: unknown) => ({ result: { data } }); +const artifactRoot = process.env.CITY_PARITY_ARTIFACT_DIR; +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')]; +const readImage = async (relativePath: string): Promise => { + if (relativePath.includes('..')) throw new Error(`Unsafe fixture image path: ${relativePath}`); + for (const root of imageRoots) { + try { + return await readFile(resolve(root, relativePath)); + } catch { + // Product checkout and feature worktrees have different image-root parents. + } + } + throw new Error(`Fixture image not found: ${relativePath}`); +}; +const imageContentType = (relativePath: string) => { + if (relativePath.endsWith('.png')) return 'image/png'; + if (relativePath.endsWith('.gif')) return 'image/gif'; + return 'image/jpeg'; +}; const operationNames = (route: Route) => decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); const city = { @@ -46,19 +68,68 @@ const layout = { regionMap: { 1: '하북' }, levelMap: { 8: '특' }, }; +const generalContext = { + general: { + id: 1, + name: '장수', + nationId: 1, + cityId: 1, + officerLevel: 1, + npcState: 0, + troopId: 0, + picture: null, + imageServer: 0, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + gold: 1000, + rice: 1000, + crew: 500, + train: 90, + atmos: 90, + injury: 0, + experience: 0, + dedication: 0, + items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' }, + }, + city, + nation: { id: 1, name: '아국', color: '#008000', level: 1 }, + settings: {}, + penalties: {}, +}; +const emptyMessages = { + private: [], + national: [], + public: [], + diplomacy: [], + sequence: -1, + hasMore: { private: false, national: false, public: false, diplomacy: false }, + latestRead: { private: 0, national: 0, public: 0, diplomacy: 0 }, + canRespondDiplomacy: false, +}; const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'member') => { await page.addInitScript(() => { localStorage.setItem('sammo-game-token', 'ga_info'); localStorage.setItem('sammo-game-profile', 'che:default'); }); - await page.route('**/image/game/**', (route) => - route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') }) - ); + await page.route('**/image/**', async (route) => { + const relativePath = decodeURIComponent(new URL(route.request().url()).pathname.split('/image/')[1] ?? ''); + await route.fulfill({ + status: 200, + contentType: imageContentType(relativePath), + body: await readImage(relativePath), + }); + }); await page.route('**/che/api/trpc/**', async (route) => { const results = operationNames(route).map((operation) => { if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '장수' } }); if (operation === 'join.getConfig') return response({}); + if (operation === 'general.me') return response(generalContext); + if (operation === 'world.getMap') return response(map); + if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] }); + if (operation === 'turns.reserved.getGeneral') return response([]); + if (operation === 'messages.getRecent') return response(emptyMessages); + if (operation === 'board.getAccess') return response({ canMeeting: false, canSecret: false }); + if (operation === 'tournament.getState') return response({ stage: 0 }); if (operation === 'nation.getNationInfo') return response({ nation: { @@ -158,6 +229,7 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb id: 1, name: '업', nationId: 1, + nationColor: '#008000', level: 8, region: 1, population: mode === 'wanderer' ? null : 150000, @@ -193,14 +265,30 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb intelligence: 50, injury: 0, officerLevel: 1, + leadershipBonus: 0, defenceTrain: 80, crewTypeId: 1, + crewTypeName: '보병', crew: 500, train: 90, atmos: 90, turns: ['징병'], }, ], + forceSummary: { + enemyCrew: 0, + enemyArmedGenerals: 0, + enemyGenerals: 0, + ownCrew: mode === 'wanderer' ? 0 : 500, + ownArmedGenerals: mode === 'wanderer' ? 0 : 1, + ownGenerals: mode === 'wanderer' ? 0 : 1, + ready90Crew: mode === 'wanderer' ? 0 : 500, + ready90Generals: mode === 'wanderer' ? 0 : 1, + ready60Crew: mode === 'wanderer' ? 0 : 500, + ready60Generals: mode === 'wanderer' ? 0 : 1, + defenceReadyCrew: mode === 'wanderer' ? 0 : 500, + defenceReadyGenerals: mode === 'wanderer' ? 0 : 1, + }, lastExecute: '2026-07-26', }); return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } }; @@ -215,11 +303,11 @@ const go = async (page: Page, path: string) => { test('four legacy menu pages keep the 1000px desktop table contract', async ({ page }) => { await install(page); await page.setViewportSize({ width: 1200, height: 900 }); - for (const [path, selector] of [ - ['nation/info', '.legacy-info-page'], - ['nation/cities', '.nation-cities-page'], - ['global-info', '.global-page'], - ['current-city', '.city-page'], + for (const [path, selector, fontSize, fontFamily, borderCollapse] of [ + ['nation/info', '.legacy-info-page', '14px', 'Pretendard', 'collapse'], + ['nation/cities', '.nation-cities-page', '14px', 'Pretendard', 'collapse'], + ['global-info', '.global-page', '14px', 'Pretendard', 'collapse'], + ['current-city', '.city-page', '16px', 'Times New Roman', 'separate'], ] as const) { await go(page, path); await expect(page.locator(selector)).toBeVisible(); @@ -230,14 +318,14 @@ test('four legacy menu pages keep the 1000px desktop table contract', async ({ p }); expect(box.width).toBe(1000); expect(box.x).toBe(100); - expect(box.fontSize).toBe('14px'); - expect(box.fontFamily).toContain('Pretendard'); + expect(box.fontSize).toBe(fontSize); + expect(box.fontFamily).toContain(fontFamily); expect( await page .locator('table') .first() .evaluate((el) => getComputedStyle(el).borderCollapse) - ).toBe('collapse'); + ).toBe(borderCollapse); } }); @@ -250,7 +338,100 @@ test('current-city hides values and general rows for a wandering user', async ({ test('current-city exposes own general details to a member and admin fixture', async ({ page }) => { await install(page, 'admin'); + await page.setViewportSize({ width: 1200, height: 900 }); await go(page, 'current-city'); await expect(page.locator('.generals')).toContainText('장수'); await expect(page.locator('.generals')).toContainText('90'); + const legacyGeometry = await page.evaluate(() => { + const rect = (selector: string) => { + const box = document.querySelector(selector)?.getBoundingClientRect(); + return box ? { x: box.x, y: box.y, width: box.width, height: box.height } : null; + }; + const icon = document.querySelector('.general-icon'); + return { + selector: rect('#citySelector'), + stats: rect('.stats'), + generals: rect('.generals'), + titleAlign: getComputedStyle(document.querySelector('.city-page > table:first-child td')!).textAlign, + icon: icon + ? { + ...rect('.general-icon'), + naturalWidth: icon.naturalWidth, + naturalHeight: icon.naturalHeight, + } + : null, + }; + }); + expect(legacyGeometry.selector).toMatchObject({ width: 400, height: 19 }); + expect(legacyGeometry.stats).toEqual({ x: 100, y: 178, width: 1000, height: 136 }); + expect(legacyGeometry.generals).toMatchObject({ x: 88, y: 332, width: 1024 }); + expect(legacyGeometry.titleAlign).toBe('start'); + expect(legacyGeometry.icon).toMatchObject({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64 }); + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + const computedDom = await page.evaluate(() => { + const measure = (selector: string) => { + const element = document.querySelector(selector); + if (!element) return null; + const box = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: { x: box.x, y: box.y, width: box.width, height: box.height }, + style: { + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + color: style.color, + backgroundColor: style.backgroundColor, + backgroundImage: style.backgroundImage, + borderCollapse: style.borderCollapse, + padding: style.padding, + textAlign: style.textAlign, + }, + }; + }; + const icon = document.querySelector('.general-icon'); + return { + body: measure('body'), + page: measure('.city-page'), + selector: measure('#citySelector'), + stats: measure('.stats'), + generals: measure('.generals'), + title: measure('.city-title'), + firstIcon: icon + ? { + ...measure('.general-icon'), + naturalWidth: icon.naturalWidth, + naturalHeight: icon.naturalHeight, + } + : null, + document: { + width: document.documentElement.scrollWidth, + height: document.documentElement.scrollHeight, + }, + }; + }); + await writeFile( + resolve(artifactRoot, 'core-current-city-computed-dom.json'), + `${JSON.stringify(computedDom, null, 2)}\n` + ); + await page.screenshot({ + path: resolve(artifactRoot, 'core-current-city-desktop.png'), + fullPage: true, + animations: 'disabled', + }); + } +}); + +test('a Chromium map click opens the clicked city route and keeps the legacy pointer interaction', async ({ page }) => { + await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); + await go(page, ''); + const cityLink = page.locator('.map-city').first(); + await expect(cityLink).toBeVisible(); + await cityLink.hover(); + await expect(cityLink).toHaveCSS('cursor', 'pointer'); + await cityLink.click(); + await expect(page).toHaveURL(/\/che\/current-city\?cityId=1$/); + await expect(page.locator('.stats')).toContainText('업'); }); diff --git a/app/game-frontend/e2e/nationGeneralSecret.spec.ts b/app/game-frontend/e2e/nationGeneralSecret.spec.ts new file mode 100644 index 0000000..39a8f5a --- /dev/null +++ b/app/game-frontend/e2e/nationGeneralSecret.spec.ts @@ -0,0 +1,150 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +const response = (data: unknown) => ({ result: { data } }); +const operations = (route: Route) => + decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); +const general = { + id: 1, + name: '테스트장수', + npcState: 0, + officerLevel: 1, + cityId: 1, + cityName: null, + troopId: 0, + troopName: null, + officerCity: 0, + officerCityName: null, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + experienceLevel: 9, + dedicationLevel: 1, + injury: 0, + gold: 1000, + rice: 2000, + personality: null, + specialDomestic: null, + specialWar: null, + belong: 1, + refreshScoreTotal: 10, + permission: 'normal', +}; +const install = async (page: Page, secretAllowed = true) => { + await page.addInitScript(() => { + localStorage.setItem('sammo-game-token', 'ga_general'); + localStorage.setItem('sammo-game-profile', 'che:default'); + }); + await page.route('**/che/api/trpc/**', async (route) => { + const results = operations(route).map((operation) => { + if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '테스트장수' } }); + if (operation === 'join.getConfig') return response({}); + if (operation === 'nation.getGeneralList') + return response({ + nation: { id: 1, name: '위', color: '#008000', level: 3 }, + viewer: { generalId: 1, permission: 0 }, + generals: [general], + }); + if (operation === 'nation.getSecretGeneralList') { + if (!secretAllowed) + return { + error: { + message: '권한이 부족합니다.', + code: -32000, + data: { code: 'FORBIDDEN', httpStatus: 403, path: operation }, + }, + }; + return response({ + nation: { id: 1, name: '위', color: '#008000', level: 3 }, + viewer: { generalId: 1, permission: 1 }, + summary: { + gold: 1000, + rice: 2000, + crew: 300, + generalCount: 1, + averageGold: 1000, + averageRice: 2000, + readiness: { + 90: { crew: 300, generals: 1 }, + 80: { crew: 300, generals: 1 }, + 60: { crew: 300, generals: 1 }, + }, + }, + generals: [ + { + id: 1, + name: '테스트장수', + npcState: 0, + injury: 0, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + leadershipBonus: 0, + experienceLevel: 9, + troopId: 0, + troopName: null, + gold: 1000, + rice: 2000, + cityId: 1, + cityName: '업', + defenceTrain: 90, + defenceTrainText: '☆', + crewTypeId: 1, + crew: 300, + train: 90, + atmos: 90, + killTurn: 7, + turnTime: '2026-01-01T01:02:00.000Z', + reservedCommands: ['징병', '훈련'], + }, + ], + }); + } + return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } }; + }); + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); + }); +}; + +test('nation generals keeps the 1000px legacy grid and redacted member columns', async ({ page }) => { + await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('nation/generals'); + await expect(page.locator('#nation-general-list')).toContainText('테스트장수'); + await expect(page.locator('#nation-general-list')).toContainText('?'); + const computed = await page.locator('.general-page').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { x: rect.x, width: rect.width, fontSize: style.fontSize, fontFamily: style.fontFamily }; + }); + expect(computed).toMatchObject({ x: 100, width: 1000, fontSize: '16px' }); + expect(computed.fontFamily).toContain('Times New Roman'); + expect(await page.locator('#nation-general-list').evaluate((el) => getComputedStyle(el).borderCollapse)).toBe( + 'separate' + ); + expect((await page.locator('#nation-general-list').boundingBox())?.width).toBe(1030); + expect((await page.locator('#nation-general-list tbody tr').boundingBox())?.height).toBe(66); +}); + +test('both pages preserve the legacy 1000px overflow contract at 500px', async ({ page }) => { + await install(page); + await page.setViewportSize({ width: 500, height: 900 }); + for (const path of ['nation/generals', 'nation/secret']) { + await page.goto(path); + await expect( + page.locator(path.endsWith('secret') ? '#secret-general-list' : '#nation-general-list') + ).toBeVisible(); + expect(await page.locator('main').evaluate((el) => el.getBoundingClientRect().width)).toBe(1000); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(1000); + } +}); + +test('secret office renders summary, turns, and the forbidden error flow', async ({ page }) => { + await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('nation/secret'); + await expect(page.locator('.summary')).toContainText('전체 금'); + await expect(page.locator('#secret-general-list')).toContainText('1 : 징병'); + expect(await page.locator('.secret-page').evaluate((el) => el.getBoundingClientRect().width)).toBe(1000); + + await page.unroute('**/che/api/trpc/**'); + await install(page, false); + await page.goto('nation/secret'); + await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.'); + await expect(page.locator('#secret-general-list')).toHaveCount(0); +}); diff --git a/app/game-frontend/e2e/npcPolicy.spec.ts b/app/game-frontend/e2e/npcPolicy.spec.ts new file mode 100644 index 0000000..e627d63 --- /dev/null +++ b/app/game-frontend/e2e/npcPolicy.spec.ts @@ -0,0 +1,338 @@ +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 FixtureState = { + permissionLevel: number; + failNextMutation?: boolean; + failLoad?: boolean; + mutations: string[]; +}; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const artifactRoot = process.env.NPC_POLICY_PARITY_ARTIFACT_DIR + ? resolve(process.env.NPC_POLICY_PARITY_ARTIFACT_DIR) + : null; +const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')]; +const referenceAsset = async (relativePath: string): Promise => { + for (const root of imageRoots) { + try { + return await readFile(resolve(root, relativePath)); + } catch { + // Nested worktrees and the primary checkout have different image parents. + } + } + throw new Error(`Reference image not found: ${relativePath}`); +}; + +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 nationPriority = [ + '불가침제의', + '선전포고', + '천도', + '유저장긴급포상', + '부대전방발령', + '유저장구출발령', + '유저장후방발령', + '부대유저장후방발령', + '유저장전방발령', + '유저장포상', + '부대구출발령', + '부대후방발령', + 'NPC긴급포상', + 'NPC구출발령', + 'NPC후방발령', + 'NPC포상', + 'NPC전방발령', + '유저장내정발령', + 'NPC내정발령', + 'NPC몰수', +]; +const generalPriority = [ + 'NPC사망대비', + '귀환', + '금쌀구매', + '출병', + '긴급내정', + '전투준비', + '전방워프', + 'NPC헌납', + '징병', + '후방워프', + '전쟁내정', + '소집해제', + '일반내정', + '내정워프', +]; +const policy = { + reqNationGold: 10_000, + reqNationRice: 12_000, + CombatForce: {}, + SupportForce: [], + DevelopForce: [], + reqHumanWarUrgentGold: 0, + reqHumanWarUrgentRice: 0, + reqHumanWarRecommandGold: 0, + reqHumanWarRecommandRice: 0, + reqHumanDevelGold: 10_000, + reqHumanDevelRice: 10_000, + reqNPCWarGold: 0, + reqNPCWarRice: 0, + reqNPCDevelGold: 0, + reqNPCDevelRice: 500, + minimumResourceActionAmount: 1_000, + maximumResourceActionAmount: 10_000, + minNPCWarLeadership: 40, + minWarCrew: 1_500, + minNPCRecruitCityPopulation: 50_000, + safeRecruitCityPopulationRatio: 0.5, + properWarTrainAtmos: 90, + cureThreshold: 10, +}; + +const policyFixture = (state: FixtureState) => ({ + nationId: 1, + nationName: '위', + nationLevel: 3, + defaultNationPolicy: policy, + currentNationPolicy: policy, + zeroPolicy: { + ...policy, + reqHumanWarUrgentGold: 7_600, + reqHumanWarUrgentRice: 7_600, + reqHumanWarRecommandGold: 15_200, + reqHumanWarRecommandRice: 15_200, + reqNPCWarGold: 2_700, + reqNPCWarRice: 2_700, + reqNPCDevelGold: 540, + }, + defaultNationPriority: nationPriority, + currentNationPriority: nationPriority, + availableNationPriorityItems: nationPriority, + defaultGeneralActionPriority: generalPriority, + currentGeneralActionPriority: generalPriority, + availableGeneralActionPriorityItems: generalPriority, + lastSetters: { + policy: { setter: null, date: null }, + nation: { setter: null, date: null }, + general: { setter: null, date: null }, + }, + defaultStatMax: 70, + defaultStatNpcMax: 75, + permissionLevel: state.permissionLevel, +}); + +const installFixture = async (page: Page, state: FixtureState) => { + await page.addInitScript(() => { + localStorage.setItem('sammo-game-token', 'ga_npc_policy_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 referenceAsset(`game/${filename}`), + }) + ); + } + 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: 22, name: '정책담당' } }); + if (operation === 'join.getConfig') return response({}); + if (operation === 'npc.getPolicy') { + return state.failLoad + ? errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN') + : response(policyFixture(state)); + } + if ( + operation === 'npc.setNationPolicy' || + operation === 'npc.setNationPriority' || + operation === 'npc.setGeneralPriority' + ) { + state.mutations.push(operation); + if (state.failNextMutation) { + state.failNextMutation = false; + return errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN'); + } + return response({ ok: true }); + } + return errorResponse(operation, `Unhandled fixture operation: ${operation}`); + }); + await fulfillJson(route, results); + }); +}; + +const gotoPolicy = async (page: Page) => { + await page.goto('npc-control'); +}; + +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('desktop geometry, typography, textures, drag, focus, tooltip, and successful save match the reference', async ({ + page, +}) => { + const state: FixtureState = { permissionLevel: 4, mutations: [] }; + await installFixture(page, state); + await page.setViewportSize({ width: 1000, height: 900 }); + await gotoPolicy(page); + await expect(page.locator('#container')).toBeVisible(); + + const computed = await page.evaluate(() => { + const measure = (selector: string) => { + const element = document.querySelector(selector)!; + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + display: style.display, + gridTemplateColumns: style.gridTemplateColumns, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + color: style.color, + backgroundImage: style.backgroundImage, + backgroundColor: style.backgroundColor, + }; + }; + return { + body: measure('body'), + container: measure('#container'), + topBar: measure('.top-back-bar'), + section: measure('.section_bar'), + form: measure('.form_list'), + field: measure('.policy-field'), + input: measure('.field-row input'), + control: measure('.control_bar'), + reset: measure('.reset_btn'), + submit: measure('.submit_btn'), + priorityPanel: measure('.priority-panel'), + priorityList: measure('.priority-list'), + inactiveHeader: measure('.inactive-header'), + activeItem: measure('.priority-column:nth-child(2) .priority-item'), + help: measure('.help-button'), + documentWidth: document.documentElement.scrollWidth, + }; + }); + + expect(computed.body).toMatchObject({ width: 1000, fontSize: '14px', lineHeight: '21px' }); + expect(computed.body.fontFamily).toContain('Pretendard'); + expect(computed.container).toMatchObject({ x: 0, y: 32, width: 1000 }); + expect(computed.container.backgroundImage).toContain('back_walnut.jpg'); + expect(computed.topBar).toMatchObject({ width: 1000, height: 32 }); + expect(computed.section).toMatchObject({ x: 1, y: 33, width: 998, height: 23 }); + expect(computed.section.backgroundImage).toContain('back_green.jpg'); + expect(computed.form).toMatchObject({ x: 9, width: 982 }); + expect(computed.form.gridTemplateColumns).toBe('491px 491px'); + expect(computed.field.width).toBeCloseTo(491, 0); + expect(computed.input).toMatchObject({ width: 224, height: 34 }); + expect(computed.reset).toMatchObject({ width: 150, height: 35.5, backgroundColor: 'rgb(48, 48, 48)' }); + expect(computed.submit).toMatchObject({ width: 150, height: 35.5, backgroundColor: 'rgb(55, 90, 127)' }); + expect(computed.priorityPanel.width).toBeCloseTo(499, 0); + expect(computed.priorityList.width).toBeCloseTo(229, 0); + expect(computed.inactiveHeader).toMatchObject({ height: 37, backgroundColor: 'rgb(214, 214, 214)' }); + expect(computed.activeItem.height).toBe(37); + expect(computed.help).toMatchObject({ width: 24, height: 22.375 }); + expect(computed.documentWidth).toBe(1000); + await screenshot(page, 'core-npc-policy-desktop-baseline.png'); + + const goldInput = page.getByLabel('국가 권장 금'); + await goldInput.focus(); + await expect(goldInput).toBeFocused(); + expect(await goldInput.evaluate((element) => getComputedStyle(element).outlineStyle)).not.toBe('none'); + + const help = page.getByRole('button', { name: '불가침제의 설명' }); + await help.hover(); + await expect.poll(() => help.evaluate((element) => getComputedStyle(element, '::after').opacity)).toBe('1'); + + const active = page.locator('.priority-panel').first().locator('.priority-column').nth(1).getByText('불가침제의'); + await active.dragTo( + page.locator('.priority-panel').first().locator('.priority-column').first().locator('.priority-list') + ); + await expect( + page.locator('.priority-panel').first().locator('.priority-column').first().getByText('불가침제의') + ).toBeVisible(); + + await goldInput.fill('12345'); + page.once('dialog', (dialog) => dialog.accept()); + await page.locator('#container > .control_bar').getByRole('button', { name: '설정' }).click(); + await expect(page.getByRole('status')).toContainText('NPC 정책이 반영되었습니다.'); + expect(state.mutations).toContain('npc.setNationPolicy'); + await screenshot(page, 'core-npc-policy-desktop.png'); +}); + +test('500px layout stacks policy fields and priority panels like the reference', async ({ page }) => { + await installFixture(page, { permissionLevel: 4, mutations: [] }); + await page.setViewportSize({ width: 500, height: 900 }); + await gotoPolicy(page); + await expect(page.locator('#container')).toBeVisible(); + + const geometry = await page.evaluate(() => { + const rect = (selector: string) => { + const value = document.querySelector(selector)!.getBoundingClientRect(); + return { x: value.x, y: value.y, width: value.width, height: value.height }; + }; + return { + container: rect('#container'), + form: rect('.form_list'), + firstField: rect('.policy-field'), + panels: [...document.querySelectorAll('.priority-panel')].map((element) => { + const value = element.getBoundingClientRect(); + return { x: value.x, y: value.y, width: value.width }; + }), + documentWidth: document.documentElement.scrollWidth, + }; + }); + + expect(geometry.container).toMatchObject({ x: 0, y: 32, width: 500 }); + expect(geometry.form).toMatchObject({ x: 9, width: 482 }); + expect(geometry.firstField.width).toBeCloseTo(482, 0); + expect(geometry.panels).toHaveLength(2); + expect(geometry.panels[0]).toMatchObject({ x: 1, width: 498 }); + expect(geometry.panels[1]?.x).toBe(1); + expect(geometry.panels[1]?.y).toBeGreaterThan(geometry.panels[0]?.y ?? 0); + expect(geometry.documentWidth).toBe(500); + await screenshot(page, 'core-npc-policy-mobile.png'); +}); + +test('a read-level user sees enabled legacy controls but a forbidden save retains the draft', async ({ page }) => { + const state: FixtureState = { permissionLevel: 1, failNextMutation: true, mutations: [] }; + await installFixture(page, state); + await gotoPolicy(page); + + const input = page.getByLabel('국가 권장 금'); + await expect(input).toBeEnabled(); + await input.fill('23456'); + page.once('dialog', (dialog) => dialog.accept()); + await page.locator('#container > .control_bar').getByRole('button', { name: '설정' }).click(); + await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.'); + await expect(input).toHaveValue('23456'); + expect(state.mutations).toEqual(['npc.setNationPolicy']); +}); + +test('a user below secret read permission receives a recoverable page error', async ({ page }) => { + await installFixture(page, { permissionLevel: 0, failLoad: true, mutations: [] }); + await gotoPolicy(page); + await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.'); + await expect(page.locator('#container')).toHaveCount(0); + await expect(page.getByRole('button', { name: '다시 시도' })).toBeVisible(); +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index b6090fe..94135ac 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -8,7 +8,15 @@ const baseURL = `http://127.0.0.1:${port}/che/`; export default defineConfig({ testDir: '.', - testMatch: ['troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', 'inGameMenus.spec.ts', 'nationOffices.spec.ts'], + testMatch: [ + 'troop.spec.ts', + 'board.spec.ts', + 'inGameInfo.spec.ts', + 'inGameMenus.spec.ts', + 'nationOffices.spec.ts', + 'nationGeneralSecret.spec.ts', + 'npcPolicy.spec.ts', + ], fullyParallel: false, workers: 1, timeout: 30_000, diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index 48884db..a60d1d3 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -9,6 +9,7 @@ "preview": "vite preview", "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", + "test:e2e:npc-policy": "playwright test npcPolicy.spec.ts --config e2e/playwright.config.mjs", "test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/app/game-frontend/src/components/main/MapCityBasic.vue b/app/game-frontend/src/components/main/MapCityBasic.vue index ff072f4..d678446 100644 --- a/app/game-frontend/src/components/main/MapCityBasic.vue +++ b/app/game-frontend/src/components/main/MapCityBasic.vue @@ -34,8 +34,9 @@ const stateOffset = computed(() => -6 * props.mapScale); diff --git a/app/game-frontend/src/views/InheritView.vue b/app/game-frontend/src/views/InheritView.vue index 8e8261d..47d478e 100644 --- a/app/game-frontend/src/views/InheritView.vue +++ b/app/game-frontend/src/views/InheritView.vue @@ -1,7 +1,5 @@ diff --git a/app/game-frontend/src/views/NationGeneralsView.vue b/app/game-frontend/src/views/NationGeneralsView.vue index c79a501..347d0b7 100644 --- a/app/game-frontend/src/views/NationGeneralsView.vue +++ b/app/game-frontend/src/views/NationGeneralsView.vue @@ -6,12 +6,11 @@ import { trpc } from '../utils/trpc'; type Result = Awaited>; type General = Result['generals'][number]; type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15; - const data = ref(null); const error = ref(''); const loading = ref(false); const sort = ref(1); -const sortOptions = [ +const options = [ '관직', '계급', '명성', @@ -28,9 +27,8 @@ const sortOptions = [ '사관', 'NPC', ]; - +const visibleCrew = (general: General): number | null => ('crew' in general ? general.crew : null); const load = async () => { - if (loading.value) return; loading.value = true; error.value = ''; try { @@ -41,67 +39,59 @@ const load = async () => { loading.value = false; } }; - -const sortedGenerals = computed(() => - [...(data.value?.generals ?? [])].sort((left, right) => { - const key = sort.value; - if (key === 1) return right.officerLevel - left.officerLevel || left.id - right.id; - if (key === 2) return right.dedicationLevel - left.dedicationLevel || left.id - right.id; - if (key === 3) return right.experienceLevel - left.experienceLevel || left.id - right.id; - if (key === 4) return right.stats.leadership - left.stats.leadership || left.id - right.id; - if (key === 5) return right.stats.strength - left.stats.strength || left.id - right.id; - if (key === 6) return right.stats.intelligence - left.stats.intelligence || left.id - right.id; - if (key === 7) return right.gold - left.gold || left.id - right.id; - if (key === 8) return right.rice - left.rice || left.id - right.id; - if (key === 9) return (right.detail?.crew ?? -1) - (left.detail?.crew ?? -1) || left.id - right.id; - if (key === 10) return right.refreshScoreTotal - left.refreshScoreTotal || left.id - right.id; - if (key === 11) return (left.personality?.name ?? '').localeCompare(right.personality?.name ?? ''); - if (key === 12) return (left.specialDomestic?.name ?? '').localeCompare(right.specialDomestic?.name ?? ''); - if (key === 13) return (left.specialWar?.name ?? '').localeCompare(right.specialWar?.name ?? ''); - if (key === 14) return right.belong - left.belong || left.id - right.id; - return right.npcState - left.npcState || left.id - right.id; +const generals = computed(() => + [...(data.value?.generals ?? [])].sort((a, b) => { + if (sort.value === 1) return b.officerLevel - a.officerLevel || a.id - b.id; + if (sort.value === 2) return b.dedicationLevel - a.dedicationLevel || a.id - b.id; + if (sort.value === 3) return b.experienceLevel - a.experienceLevel || a.id - b.id; + if (sort.value === 4) return b.stats.leadership - a.stats.leadership || a.id - b.id; + if (sort.value === 5) return b.stats.strength - a.stats.strength || a.id - b.id; + if (sort.value === 6) return b.stats.intelligence - a.stats.intelligence || a.id - b.id; + if (sort.value === 7) return b.gold - a.gold || a.id - b.id; + if (sort.value === 8) return b.rice - a.rice || a.id - b.id; + if (sort.value === 9) return (visibleCrew(b) ?? -1) - (visibleCrew(a) ?? -1) || a.id - b.id; + if (sort.value === 10) return b.refreshScoreTotal - a.refreshScoreTotal || a.id - b.id; + if (sort.value === 11) return (a.personality?.name ?? '').localeCompare(b.personality?.name ?? ''); + if (sort.value === 12) return (a.specialDomestic?.name ?? '').localeCompare(b.specialDomestic?.name ?? ''); + if (sort.value === 13) return (a.specialWar?.name ?? '').localeCompare(b.specialWar?.name ?? ''); + if (sort.value === 14) return b.belong - a.belong || a.id - b.id; + if (sort.value === 15) return b.npcState - a.npcState || a.id - b.id; + return a.id - b.id; }) ); - -const imageUrl = (general: General): string => { - const picture = general.picture || 'default.jpg'; - return general.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/icons/${picture}`; -}; -const specialText = (general: General): string => - `${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`; - +const special = (general: General) => `${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`; onMounted(load);