diff --git a/app/game-api/src/router/archive/index.ts b/app/game-api/src/router/archive/index.ts index 50bc33c3..275be3b7 100644 --- a/app/game-api/src/router/archive/index.ts +++ b/app/game-api/src/router/archive/index.ts @@ -3,7 +3,9 @@ import { z } from 'zod'; import { asRecord } from '@sammo-ts/common'; +import { resolveOfficerLevelName, sanitizeInternalDisplayCode } from '../../services/gameDisplayNames.js'; import { readOnlyAuthedProcedure, router } from '../../trpc.js'; +import { loadTraitNames } from '../nation/shared.js'; const numberOrNull = (value: unknown): number | null => typeof value === 'number' && Number.isFinite(value) ? value : null; @@ -87,6 +89,31 @@ export const archiveRouter = router({ nationByServerAndId.set(key, nation); } } + const archivedRoles = generals.map((general) => { + const data = asRecord(general.data); + const role = asRecord(data.role); + return { + personal: displayTextOrNull(data.personalCode ?? data.personal ?? role.personality), + special: displayTextOrNull(data.specialCode ?? data.special ?? role.specialDomestic), + special2: displayTextOrNull(data.special2Code ?? data.special2 ?? role.specialWar), + }; + }); + const [personalityNames, domesticNames, warNames] = await Promise.all([ + loadTraitNames( + archivedRoles.map((role) => role.personal), + 'personality' + ), + loadTraitNames( + archivedRoles.map((role) => role.special), + 'domestic' + ), + loadTraitNames( + archivedRoles.map((role) => role.special2), + 'war' + ), + ]); + const displayRole = (value: string | null, names: Awaited>): string | null => + value ? (names.get(value)?.name ?? sanitizeInternalDisplayCode(value)) : null; const seasons = new Map< string, @@ -110,6 +137,7 @@ export const archiveRouter = router({ experience: number | null; dedication: number | null; officerLevel: number | null; + officerLevelText: string | null; personal: string | null; special: string | null; special2: string | null; @@ -118,7 +146,7 @@ export const archiveRouter = router({ } >(); - for (const general of generals) { + for (const [generalIndex, general] of generals.entries()) { const game = gameByServer.get(general.serverId); let season = seasons.get(general.serverId); if (!season) { @@ -136,10 +164,12 @@ export const archiveRouter = router({ const data = asRecord(general.data); const stats = asRecord(data.stats); - const role = asRecord(data.role); const nationId = firstNumber(data, 'nationId', 'nation') ?? 0; const nation = nationByServerAndId.get(`${general.serverId}:${nationId}`); const nationData = asRecord(nation?.data); + const officerLevel = firstNumber(data, 'officerLevel', 'officer_level'); + const nationLevel = firstNumber(nationData, 'level', 'nationLevel'); + const archivedRole = archivedRoles[generalIndex]!; season.generals.push({ generalNo: general.generalNo, name: general.name, @@ -152,10 +182,14 @@ export const archiveRouter = router({ intel: firstNumber(data, 'intel', 'intelligence') ?? numberOrNull(stats.intelligence), experience: numberOrNull(data.experience), dedication: numberOrNull(data.dedication), - officerLevel: firstNumber(data, 'officerLevel', 'officer_level'), - personal: displayTextOrNull(data.personalCode ?? data.personal ?? role.personality), - special: displayTextOrNull(data.specialCode ?? data.special ?? role.specialDomestic), - special2: displayTextOrNull(data.special2Code ?? data.special2 ?? role.specialWar), + officerLevel, + officerLevelText: + officerLevel === null + ? null + : resolveOfficerLevelName(officerLevel, nationLevel === null ? undefined : nationLevel), + personal: displayRole(archivedRole.personal, personalityNames), + special: displayRole(archivedRole.special, domesticNames), + special2: displayRole(archivedRole.special2, warNames), historyCount: parseHistory(data.history).length, }); } diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 0ca59a00..da826725 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -17,6 +17,16 @@ import { import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTransport.js'; import { resolveAccessWindows } from '../../services/generalAccess.js'; import { adjustAccountIconForUser } from '../../services/accountIconSync.js'; +import { + loadCrewTypeDisplayNames, + loadItemDisplayNames, + resolveCityLevelName, + resolveDedicationLevelName, + resolveNationLevelName, + resolveOfficerLevelName, + resolveRegionName, + sanitizeInternalDisplayCode, +} from '../../services/gameDisplayNames.js'; import { getMyGeneral } from '../shared/general.js'; import { loadTraitNames, resolveNationNotice, type TraitNameMap } from '../nation/shared.js'; @@ -155,7 +165,7 @@ const resolveTraitDisplayName = (code: string, names: TraitNameMap): string => { return loadedName; } // Ref는 class getName()을 표시하므로 로더가 모르는 선택적 특기도 raw namespace는 노출하지 않는다. - return code.replace(/^che_(?:event_)?/u, ''); + return sanitizeInternalDisplayCode(code); }; const resolveUserSettings = (meta: Record) => { @@ -244,7 +254,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => { return null; } - const [city, nation, worldState] = await Promise.all([ + const [city, queriedNation, worldState] = await Promise.all([ general.cityId > 0 ? ctx.db.city.findUnique({ where: { id: general.cityId }, @@ -291,18 +301,36 @@ export const getGeneralContext = async (ctx: GameApiContext) => { : Promise.resolve(NEUTRAL_NATION_CONTEXT), ctx.db.worldState.findFirst({ select: { config: true } }), ]); + const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT; - const [personalityNames, domesticNames, warNames] = await Promise.all([ + const [capitalCity, cityNation] = await Promise.all([ + nation.capitalCityId + ? ctx.db.city.findUnique({ where: { id: nation.capitalCityId }, select: { name: true } }) + : Promise.resolve(null), + city && city.nationId > 0 + ? ctx.db.nation.findUnique({ where: { id: city.nationId }, select: { name: true } }) + : Promise.resolve(null), + ]); + const [personalityNames, domesticNames, warNames, nationTypeNames, crewTypeNames, itemNames] = await Promise.all([ loadTraitNames([general.personalCode], 'personality'), loadTraitNames([general.specialCode], 'domestic'), loadTraitNames([general.special2Code], 'war'), + loadTraitNames([nation.typeCode], 'nation'), + loadCrewTypeDisplayNames(worldState, ctx.profile.id), + loadItemDisplayNames([general.horseCode, general.weaponCode, general.bookCode, general.itemCode]), ]); const metaRecord = asRecord(general.meta); const worldConfig = asRecord(worldState?.config); const constValues = asRecord(worldConfig.const ?? worldConfig.consts); + const maxDedicationLevel = readNumber(constValues.maxDedLevel, 30); const settings = resolveUserSettings(metaRecord); const penalties = resolvePenalty(general.penalty); + const dedicationLevel = readNumber(metaRecord.dedlevel, 0); + const itemName = (code: string | null): string | null => { + const normalized = normalizeItemCode(code); + return normalized ? (itemNames.get(normalized) ?? sanitizeInternalDisplayCode(normalized)) : null; + }; return { general: { @@ -315,6 +343,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => { picture: general.picture, imageServer: general.imageServer, officerLevel: general.officerLevel, + officerLevelText: resolveOfficerLevelName(general.officerLevel, nation.level), stats: { leadership: general.leadership, strength: general.strength, @@ -331,6 +360,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => { age: general.age, turnTime: general.turnTime.toISOString(), crewTypeId: general.crewTypeId, + crewTypeName: crewTypeNames.get(general.crewTypeId) ?? '-', traits: { personal: resolveTraitDisplayName(general.personalCode, personalityNames), specialDomestic: resolveTraitDisplayName(general.specialCode, domesticNames), @@ -338,7 +368,8 @@ export const getGeneralContext = async (ctx: GameApiContext) => { }, progression: { experienceLevel: readNumber(metaRecord.explevel, 0), - dedicationLevel: readNumber(metaRecord.dedlevel, 0), + dedicationLevel, + dedicationText: resolveDedicationLevelName(dedicationLevel, maxDedicationLevel), statExperience: { leadership: readNumber(metaRecord.leadership_exp, 0), strength: readNumber(metaRecord.strength_exp, 0), @@ -353,6 +384,12 @@ export const getGeneralContext = async (ctx: GameApiContext) => { book: normalizeItemCode(general.bookCode), item: normalizeItemCode(general.itemCode), }, + itemNames: { + horse: itemName(general.horseCode), + weapon: itemName(general.weaponCode), + book: itemName(general.bookCode), + item: itemName(general.itemCode), + }, }, iconChoices: ctx.auth?.user.canUseGeneralPicture === false ? [] : (ctx.auth?.user.icons ?? []), canChangeIcon: general.npcState === 0 && ctx.auth?.user.canUseGeneralPicture !== false, @@ -360,8 +397,23 @@ export const getGeneralContext = async (ctx: GameApiContext) => { typeof metaRecord.generalIconChangedAt === 'string' ? new Date(new Date(metaRecord.generalIconChangedAt).getTime() + 24 * 60 * 60 * 1000).toISOString() : null, - city, - nation, + city: city + ? { + ...city, + levelName: resolveCityLevelName(city.level), + regionName: resolveRegionName(city.region), + nationName: city.nationId > 0 ? (cityNation?.name ?? '-') : '공백지', + } + : null, + nation: { + ...nation, + levelName: resolveNationLevelName(nation.level), + typeName: + nation.id === 0 + ? '해당 없음' + : (nationTypeNames.get(nation.typeCode)?.name ?? sanitizeInternalDisplayCode(nation.typeCode)), + capitalCityName: nation.id === 0 ? null : (capitalCity?.name ?? null), + }, settings, penalties, }; diff --git a/app/game-api/src/router/nation/endpoints/getBattleCenter.ts b/app/game-api/src/router/nation/endpoints/getBattleCenter.ts index c5e3e1f2..5ce0cb83 100644 --- a/app/game-api/src/router/nation/endpoints/getBattleCenter.ts +++ b/app/game-api/src/router/nation/endpoints/getBattleCenter.ts @@ -4,8 +4,15 @@ import { asRecord } from '@sammo-ts/common'; import { LogCategory } from '@sammo-ts/logic'; import { accessAuthedProcedure } from '../../../trpc.js'; +import { + loadCrewTypeDisplayNames, + loadItemDisplayNames, + resolveDedicationLevelName, + resolveOfficerLevelName, + sanitizeInternalDisplayCode, +} from '../../../services/gameDisplayNames.js'; import { getMyGeneral } from '../../shared/general.js'; -import { assertNationAccess, formatDateTime, resolveNationPermission } from '../shared.js'; +import { assertNationAccess, formatDateTime, loadTraitNames, resolveNationPermission } from '../shared.js'; export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => { const me = await getMyGeneral(ctx); @@ -98,6 +105,36 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => { typeof constValues.upgradeLimit === 'number' && Number.isFinite(constValues.upgradeLimit) ? constValues.upgradeLimit : 30; + const maxDedicationLevel = + typeof constValues.maxDedLevel === 'number' && Number.isFinite(constValues.maxDedLevel) + ? Math.max(0, Math.trunc(constValues.maxDedLevel)) + : 30; + const [personalityNames, domesticNames, warNames, crewTypeNames, itemNames] = await Promise.all([ + loadTraitNames( + generalRows.map((general) => general.personalCode), + 'personality' + ), + loadTraitNames( + generalRows.map((general) => general.specialCode), + 'domestic' + ), + loadTraitNames( + generalRows.map((general) => general.special2Code), + 'war' + ), + loadCrewTypeDisplayNames(worldState, ctx.profile.id), + loadItemDisplayNames( + generalRows.flatMap((general) => [ + general.weaponCode, + general.bookCode, + general.horseCode, + general.itemCode, + ]) + ), + ]); + const traitName = (code: string, names: Awaited>): string => + names.get(code)?.name ?? sanitizeInternalDisplayCode(code); + const itemName = (code: string): string => itemNames.get(code) ?? sanitizeInternalDisplayCode(code); const generals = generalRows.map((general) => { const meta = @@ -108,6 +145,11 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => { const value = meta[key]; return typeof value === 'number' && Number.isFinite(value) ? value : 0; }; + const storedDedicationLevel = metaNumber('dedlevel'); + const dedicationLevel = + storedDedicationLevel > 0 + ? storedDedicationLevel + : Math.max(0, Math.min(Math.ceil(Math.sqrt(general.dedication) / 10), maxDedicationLevel)); return { id: general.id, name: general.name, @@ -115,6 +157,7 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => { imageServer: general.imageServer, npcState: general.npcState, officerLevel: general.officerLevel, + officerLevelText: resolveOfficerLevelName(general.officerLevel, nation.level), cityId: general.cityId, turnTime: formatDateTime(general.turnTime), recentWar: formatDateTime(general.recentWarTime), @@ -134,19 +177,28 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => { atmos: general.atmos, age: general.age, crewTypeId: general.crewTypeId, + crewTypeName: crewTypeNames.get(general.crewTypeId) ?? '-', equipment: { weapon: general.weaponCode, book: general.bookCode, horse: general.horseCode, item: general.itemCode, }, + equipmentNames: { + weapon: itemName(general.weaponCode), + book: itemName(general.bookCode), + horse: itemName(general.horseCode), + item: itemName(general.itemCode), + }, traits: { - personal: general.personalCode, - specialDomestic: general.specialCode, - specialWar: general.special2Code, + personal: traitName(general.personalCode, personalityNames), + specialDomestic: traitName(general.specialCode, domesticNames), + specialWar: traitName(general.special2Code, warNames), }, progression: { experienceLevel: metaNumber('explevel'), + dedicationLevel, + dedicationText: resolveDedicationLevelName(dedicationLevel, maxDedicationLevel), statExperience: { leadership: metaNumber('leadership_exp'), strength: metaNumber('strength_exp'), diff --git a/app/game-api/src/router/nation/endpoints/getGeneralList.ts b/app/game-api/src/router/nation/endpoints/getGeneralList.ts index 715765ec..6909611c 100644 --- a/app/game-api/src/router/nation/endpoints/getGeneralList.ts +++ b/app/game-api/src/router/nation/endpoints/getGeneralList.ts @@ -1,6 +1,8 @@ import { TRPCError } from '@trpc/server'; +import { asNumber, asRecord } from '@sammo-ts/common'; import { accessAuthedProcedure } from '../../../trpc.js'; +import { resolveDedicationLevelName, sanitizeInternalDisplayCode } from '../../../services/gameDisplayNames.js'; import { getMyGeneral } from '../../shared/general.js'; import { assertNationAccess, @@ -15,8 +17,8 @@ const experienceLevel = (experience: number): number => 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(10, Math.ceil(Math.sqrt(dedication) / 10))); +const dedicationLevel = (dedication: number, maxLevel: number): number => + Math.max(0, Math.min(maxLevel, Math.ceil(Math.sqrt(dedication) / 10))); export const getGeneralList = accessAuthedProcedure.query(async ({ ctx }) => { const general = await getMyGeneral(ctx); @@ -85,14 +87,22 @@ export const getGeneralList = accessAuthedProcedure.query(async ({ ctx }) => { 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 config = asRecord(worldState?.config); + const maxDedicationLevel = Math.max(0, Math.trunc(asNumber(asRecord(config.const).maxDedLevel, 30))); const visibleList = list.map((entry) => { + const entryDedicationLevel = dedicationLevel(entry.dedication, maxDedicationLevel); + const dedicationDisplay = { + dedicationLevel: entryDedicationLevel, + dedicationText: resolveDedicationLevelName(entryDedicationLevel, maxDedicationLevel), + bill: entryDedicationLevel * 200 + 400, + }; const { permission: _targetPermission, ...safeEntry } = entry; if (permission >= 1) { return { ...safeEntry, refreshScoreTotal: accessByGeneral.get(entry.id) ?? 0, experienceLevel: experienceLevel(entry.experience), - dedicationLevel: dedicationLevel(entry.dedication), + ...dedicationDisplay, }; } const { crew: _crew, experience: _experience, dedication: _dedication, ...visible } = safeEntry; @@ -105,7 +115,7 @@ export const getGeneralList = accessAuthedProcedure.query(async ({ ctx }) => { officerCity: 0, officerCityName: null, experienceLevel: experienceLevel(entry.experience), - dedicationLevel: dedicationLevel(entry.dedication), + ...dedicationDisplay, }; }); @@ -118,7 +128,7 @@ export const getGeneralList = accessAuthedProcedure.query(async ({ ctx }) => { typeCode: nation.typeCode, type: { key: nation.typeCode, - name: nationTrait?.name ?? nation.typeCode, + name: nationTrait?.name ?? sanitizeInternalDisplayCode(nation.typeCode), info: nationTrait?.info ?? '', }, capitalCityId: nation.capitalCityId ?? 0, diff --git a/app/game-api/src/router/nation/shared.ts b/app/game-api/src/router/nation/shared.ts index 88eb0c4f..21cd21c5 100644 --- a/app/game-api/src/router/nation/shared.ts +++ b/app/game-api/src/router/nation/shared.ts @@ -28,6 +28,7 @@ import { import type { GameApiContext, InputJsonValue, WorldStateRow } from '../../context.js'; import { purifyNationHtml } from '../../security/nationHtml.js'; +import { sanitizeInternalDisplayCode } from '../../services/gameDisplayNames.js'; import { resolveSecretPermission } from '../shared/secretPermission.js'; export type PermissionKind = 'normal' | 'ambassador' | 'auditor'; @@ -313,10 +314,7 @@ export const loadTraitNames = async (keys: Array, kind: keyof Tra } } if (eventFiltered.length) { - const modules = await loadEventDomesticTraitModules( - eventFiltered, - new EventDomesticTraitLoader() - ); + const modules = await loadEventDomesticTraitModules(eventFiltered, new EventDomesticTraitLoader()); for (const module of modules) { cache.set(module.key, { name: module.name, info: module.info ?? '' }); } @@ -513,21 +511,21 @@ export const mapGeneralList = async ( personality: personalityKey ? { key: personalityKey, - name: personalityMap.get(personalityKey)?.name ?? personalityKey, + name: personalityMap.get(personalityKey)?.name ?? sanitizeInternalDisplayCode(personalityKey), info: personalityMap.get(personalityKey)?.info ?? '', } : null, specialDomestic: domesticKey ? { key: domesticKey, - name: domesticMap.get(domesticKey)?.name ?? domesticKey, + name: domesticMap.get(domesticKey)?.name ?? sanitizeInternalDisplayCode(domesticKey), info: domesticMap.get(domesticKey)?.info ?? '', } : null, specialWar: warKey ? { key: warKey, - name: warMap.get(warKey)?.name ?? warKey, + name: warMap.get(warKey)?.name ?? sanitizeInternalDisplayCode(warKey), info: warMap.get(warKey)?.info ?? '', } : null, diff --git a/app/game-api/src/router/public/index.ts b/app/game-api/src/router/public/index.ts index c503eccd..67c8bef5 100644 --- a/app/game-api/src/router/public/index.ts +++ b/app/game-api/src/router/public/index.ts @@ -8,6 +8,7 @@ import { zWorldStateConfig, zWorldStateMeta } from '../../context.js'; import { loadMapLayout } from '../../maps/mapLayout.js'; import { loadPublicMap } from '../../maps/worldMap.js'; import { accessPages, recordGeneralAccess } from '../../services/generalAccess.js'; +import { sanitizeInternalDisplayCode } from '../../services/gameDisplayNames.js'; import { accessInputProcedure, procedure, router, sessionActivityProcedure } from '../../trpc.js'; import { loadTraitNames } from '../nation/shared.js'; @@ -478,174 +479,170 @@ export const publicRouter = router({ })); }), getNpcList: accessInputProcedure( - z - .object({ - sort: z.number().int().min(1).max(8).catch(1).optional(), - includeAllWithToken: z.boolean().optional(), - }) - .optional() - ) - .query(async ({ ctx, input }) => { - const sort = (input?.sort ?? 1) as NpcListSort; - const includeAllWithToken = input?.includeAllWithToken === true; - if (includeAllWithToken && !ctx.auth) { - throw new TRPCError({ code: 'UNAUTHORIZED' }); - } - const now = new Date(Math.floor(Date.now() / 1000) * 1000); - const poolGeneralIds = includeAllWithToken - ? [] - : ( - await ctx.db.selectPoolEntry.findMany({ - where: { generalId: { not: null } }, - select: { generalId: true }, - }) - ).flatMap(({ generalId }) => (generalId === null ? [] : [generalId])); - const [generals, nations, activeTokens, worldState] = await Promise.all([ - ctx.db.general.findMany({ - ...(includeAllWithToken - ? {} - : { - where: { - OR: [ - { npcState: 1 }, - { npcState: 0, id: { in: poolGeneralIds } }, - ], - }, - }), - select: { - id: true, - name: true, - picture: true, - imageServer: true, - npcState: true, - age: true, - officerLevel: true, - nationId: true, - leadership: true, - strength: true, - intel: true, - experience: true, - dedication: true, - personalCode: true, - specialCode: true, - special2Code: true, - meta: true, - }, - orderBy: { id: 'asc' }, - }), - ctx.db.nation.findMany({ - select: { id: true, name: true, level: true }, - }), - includeAllWithToken - ? ctx.db.npcSelectionToken.findMany({ - where: { validUntil: { gte: now } }, - select: { pickResult: true }, - }) - : [], - includeAllWithToken - ? ctx.db.worldState.findFirst({ - select: { config: true }, - }) - : null, - ]); + z + .object({ + sort: z.number().int().min(1).max(8).catch(1).optional(), + includeAllWithToken: z.boolean().optional(), + }) + .optional() + ).query(async ({ ctx, input }) => { + const sort = (input?.sort ?? 1) as NpcListSort; + const includeAllWithToken = input?.includeAllWithToken === true; + if (includeAllWithToken && !ctx.auth) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + const now = new Date(Math.floor(Date.now() / 1000) * 1000); + const poolGeneralIds = includeAllWithToken + ? [] + : ( + await ctx.db.selectPoolEntry.findMany({ + where: { generalId: { not: null } }, + select: { generalId: true }, + }) + ).flatMap(({ generalId }) => (generalId === null ? [] : [generalId])); + const [generals, nations, activeTokens, worldState] = await Promise.all([ + ctx.db.general.findMany({ + ...(includeAllWithToken + ? {} + : { + where: { + OR: [{ npcState: 1 }, { npcState: 0, id: { in: poolGeneralIds } }], + }, + }), + select: { + id: true, + name: true, + picture: true, + imageServer: true, + npcState: true, + age: true, + officerLevel: true, + nationId: true, + leadership: true, + strength: true, + intel: true, + experience: true, + dedication: true, + personalCode: true, + specialCode: true, + special2Code: true, + meta: true, + }, + orderBy: { id: 'asc' }, + }), + ctx.db.nation.findMany({ + select: { id: true, name: true, level: true }, + }), + includeAllWithToken + ? ctx.db.npcSelectionToken.findMany({ + where: { validUntil: { gte: now } }, + select: { pickResult: true }, + }) + : [], + includeAllWithToken + ? ctx.db.worldState.findFirst({ + select: { config: true }, + }) + : null, + ]); - const personalityKeys = generals.map((general) => normalizeTraitKey(general.personalCode)); - const domesticKeys = generals.map((general) => normalizeTraitKey(general.specialCode)); - const warKeys = generals.map((general) => normalizeTraitKey(general.special2Code)); - const [personalityMap, domesticMap, warMap] = await Promise.all([ - loadTraitNames(personalityKeys, 'personality'), - loadTraitNames(domesticKeys, 'domestic'), - loadTraitNames(warKeys, 'war'), - ]); - const nationMap = new Map(nations.map((nation) => [nation.id, nation])); - const worldConfig = asRecord(worldState?.config); - const worldConstants = asRecord(worldConfig.const); - const maxLevel = Math.max(0, Math.floor(asNumber(worldConstants.maxLevel, 255))); - const maxDedLevel = Math.max(0, Math.floor(asNumber(worldConstants.maxDedLevel, 30))); + const personalityKeys = generals.map((general) => normalizeTraitKey(general.personalCode)); + const domesticKeys = generals.map((general) => normalizeTraitKey(general.specialCode)); + const warKeys = generals.map((general) => normalizeTraitKey(general.special2Code)); + const [personalityMap, domesticMap, warMap] = await Promise.all([ + loadTraitNames(personalityKeys, 'personality'), + loadTraitNames(domesticKeys, 'domestic'), + loadTraitNames(warKeys, 'war'), + ]); + const nationMap = new Map(nations.map((nation) => [nation.id, nation])); + const worldConfig = asRecord(worldState?.config); + const worldConstants = asRecord(worldConfig.const); + const maxLevel = Math.max(0, Math.floor(asNumber(worldConstants.maxLevel, 255))); + const maxDedLevel = Math.max(0, Math.floor(asNumber(worldConstants.maxDedLevel, 30))); - // Legacy a_npcList.php shows select_pool humans first and possessed npc=1 rows. - // Unpossessed npc=2 candidates belong only to the token-aware selection screen. - // selection list instead consumes the raw id-ordered full list before its own comparator. - const sourceRows = includeAllWithToken - ? generals - : [ - ...generals.filter((general) => general.npcState === 0), - ...generals.filter((general) => general.npcState === 1), - ]; - const rows = sourceRows.map((general) => { - const meta = asRecord(general.meta); - const personalityKey = normalizeTraitKey(general.personalCode); - const domesticKey = normalizeTraitKey(general.specialCode); - const warKey = normalizeTraitKey(general.special2Code); - const ownerName = - general.npcState === 1 - ? typeof meta.owner_name === 'string' - ? meta.owner_name - : typeof meta.ownerName === 'string' - ? meta.ownerName - : '' - : ''; - - return { - id: general.id, - name: general.name, - picture: general.picture, - imageServer: general.imageServer, - npcState: general.npcState, - ownerName, - age: general.age, - level: includeAllWithToken - ? resolveExperienceLevel(general.experience, maxLevel) - : readFiniteMetaNumber(meta, 'explevel'), - officerLevel: general.officerLevel, - killturn: readFiniteMetaNumber(meta, 'killturn'), - nationId: general.nationId, - nationName: nationMap.get(general.nationId)?.name ?? '-', - nationLevel: nationMap.get(general.nationId)?.level ?? 0, - personality: personalityKey - ? { - key: personalityKey, - name: personalityMap.get(personalityKey)?.name ?? personalityKey, - info: personalityMap.get(personalityKey)?.info ?? '', - } - : null, - specialDomestic: domesticKey - ? { - key: domesticKey, - name: domesticMap.get(domesticKey)?.name ?? domesticKey, - info: domesticMap.get(domesticKey)?.info ?? '', - } - : null, - specialWar: warKey - ? { - key: warKey, - name: warMap.get(warKey)?.name ?? warKey, - info: warMap.get(warKey)?.info ?? '', - } - : null, - statTotal: general.leadership + general.strength + general.intel, - leadership: general.leadership, - strength: general.strength, - intelligence: general.intel, - experience: general.experience, - experienceText: resolveHonorText(general.experience), - dedication: general.dedication, - dedicationText: resolveDedicationText(general.dedication, maxDedLevel), - }; - }); - const tokenKeepCounts = Object.fromEntries( - activeTokens.flatMap((token) => - Object.entries(asRecord(token.pickResult)).flatMap(([generalId, value]) => { - const keepCount = asNumber(asRecord(value).keepCount, Number.NaN); - return Number.isFinite(keepCount) ? [[generalId, Math.max(0, Math.floor(keepCount))]] : []; - }) - ) - ); + // Legacy a_npcList.php shows select_pool humans first and possessed npc=1 rows. + // Unpossessed npc=2 candidates belong only to the token-aware selection screen. + // selection list instead consumes the raw id-ordered full list before its own comparator. + const sourceRows = includeAllWithToken + ? generals + : [ + ...generals.filter((general) => general.npcState === 0), + ...generals.filter((general) => general.npcState === 1), + ]; + const rows = sourceRows.map((general) => { + const meta = asRecord(general.meta); + const personalityKey = normalizeTraitKey(general.personalCode); + const domesticKey = normalizeTraitKey(general.specialCode); + const warKey = normalizeTraitKey(general.special2Code); + const ownerName = + general.npcState === 1 + ? typeof meta.owner_name === 'string' + ? meta.owner_name + : typeof meta.ownerName === 'string' + ? meta.ownerName + : '' + : ''; return { - sort, - generals: includeAllWithToken ? rows : sortNpcList(rows, sort), - tokenKeepCounts, + id: general.id, + name: general.name, + picture: general.picture, + imageServer: general.imageServer, + npcState: general.npcState, + ownerName, + age: general.age, + level: includeAllWithToken + ? resolveExperienceLevel(general.experience, maxLevel) + : readFiniteMetaNumber(meta, 'explevel'), + officerLevel: general.officerLevel, + killturn: readFiniteMetaNumber(meta, 'killturn'), + nationId: general.nationId, + nationName: nationMap.get(general.nationId)?.name ?? '-', + nationLevel: nationMap.get(general.nationId)?.level ?? 0, + personality: personalityKey + ? { + key: personalityKey, + name: personalityMap.get(personalityKey)?.name ?? sanitizeInternalDisplayCode(personalityKey), + info: personalityMap.get(personalityKey)?.info ?? '', + } + : null, + specialDomestic: domesticKey + ? { + key: domesticKey, + name: domesticMap.get(domesticKey)?.name ?? sanitizeInternalDisplayCode(domesticKey), + info: domesticMap.get(domesticKey)?.info ?? '', + } + : null, + specialWar: warKey + ? { + key: warKey, + name: warMap.get(warKey)?.name ?? sanitizeInternalDisplayCode(warKey), + info: warMap.get(warKey)?.info ?? '', + } + : null, + statTotal: general.leadership + general.strength + general.intel, + leadership: general.leadership, + strength: general.strength, + intelligence: general.intel, + experience: general.experience, + experienceText: resolveHonorText(general.experience), + dedication: general.dedication, + dedicationText: resolveDedicationText(general.dedication, maxDedLevel), }; - }), + }); + const tokenKeepCounts = Object.fromEntries( + activeTokens.flatMap((token) => + Object.entries(asRecord(token.pickResult)).flatMap(([generalId, value]) => { + const keepCount = asNumber(asRecord(value).keepCount, Number.NaN); + return Number.isFinite(keepCount) ? [[generalId, Math.max(0, Math.floor(keepCount))]] : []; + }) + ) + ); + + return { + sort, + generals: includeAllWithToken ? rows : sortNpcList(rows, sort), + tokenKeepCounts, + }; + }), }); diff --git a/app/game-api/src/router/world/directory.ts b/app/game-api/src/router/world/directory.ts index 47b646e4..eb5b901b 100644 --- a/app/game-api/src/router/world/directory.ts +++ b/app/game-api/src/router/world/directory.ts @@ -2,6 +2,7 @@ import { asRecord } from '@sammo-ts/common'; import { z } from 'zod'; import { accessAuthedInputProcedure, authedProcedure } from '../../trpc.js'; +import { sanitizeInternalDisplayCode } from '../../services/gameDisplayNames.js'; import { loadTraitNames } from '../nation/shared.js'; import { getMyGeneral } from '../shared/general.js'; import { resolveSecretPermission } from '../shared/secretPermission.js'; @@ -172,7 +173,7 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => { level: nation.level, type: { key: nation.typeCode, - name: nationTypeNames.get(nation.typeCode)?.name ?? nation.typeCode, + name: nationTypeNames.get(nation.typeCode)?.name ?? sanitizeInternalDisplayCode(nation.typeCode), }, power: readMetaNumber(nation.meta, 'power'), capitalCityId: nation.capitalCityId ?? 0, diff --git a/app/game-api/src/services/gameDisplayNames.ts b/app/game-api/src/services/gameDisplayNames.ts new file mode 100644 index 00000000..29d8e900 --- /dev/null +++ b/app/game-api/src/services/gameDisplayNames.ts @@ -0,0 +1,138 @@ +import { asRecord } from '@sammo-ts/common'; +import { isItemKey, ItemLoader } from '@sammo-ts/logic'; +import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js'; + +import type { WorldStateRow } from '../context.js'; + +const NATION_LEVEL_NAMES: Record = { + 0: '방랑군', + 1: '호족', + 2: '군벌', + 3: '주자사', + 4: '주목', + 5: '공', + 6: '왕', + 7: '황제', +}; + +const CITY_LEVEL_NAMES: Record = { + 1: '수', + 2: '진', + 3: '관', + 4: '이', + 5: '소', + 6: '중', + 7: '대', + 8: '특', +}; + +const REGION_NAMES: Record = { + 1: '하북', + 2: '중원', + 3: '서북', + 4: '서촉', + 5: '남중', + 6: '초', + 7: '오월', + 8: '동이', +}; + +const OFFICER_LEVEL_NAMES: Record = { + 12: '군주', + 11: '참모', + 10: '제1장군', + 9: '제1모사', + 8: '제2장군', + 7: '제2모사', + 6: '제3장군', + 5: '제3모사', + 4: '태수', + 3: '군사', + 2: '종사', + 1: '일반', + 0: '재야', +}; + +const OFFICER_LEVEL_NAMES_BY_NATION_LEVEL: Record> = { + 7: { 12: '황제', 11: '승상', 10: '표기장군', 9: '사공', 8: '거기장군', 7: '태위', 6: '위장군', 5: '사도' }, + 6: { 12: '왕', 11: '광록훈', 10: '좌장군', 9: '상서령', 8: '우장군', 7: '중서령', 6: '전장군', 5: '비서령' }, + 5: { 12: '공', 11: '광록대부', 10: '안국장군', 9: '집금오', 8: '파로장군', 7: '소부' }, + 4: { 12: '주목', 11: '태사령', 10: '아문장군', 9: '낭중', 8: '호군', 7: '종사중랑' }, + 3: { 12: '주자사', 11: '주부', 10: '편장군', 9: '간의대부' }, + 2: { 12: '군벌', 11: '참모', 10: '비장군', 9: '부참모' }, + 1: { 12: '영주', 11: '참모' }, + 0: { 12: '두목', 11: '부두목' }, +}; + +export const sanitizeInternalDisplayCode = (value: string | null | undefined): string => { + if (!value || value === 'None') { + return '-'; + } + if (/^\d+$/u.test(value)) { + return '-'; + } + return value.replace(/^che_(?:event_)?/u, ''); +}; + +export const resolveNationLevelName = (level: number): string => NATION_LEVEL_NAMES[level] ?? '-'; + +export const resolveCityLevelName = (level: number): string => CITY_LEVEL_NAMES[level] ?? '-'; + +export const resolveRegionName = (region: number): string => REGION_NAMES[region] ?? '-'; + +export const resolveOfficerLevelName = (officerLevel: number, nationLevel?: number): string => { + if (officerLevel < 5) { + return OFFICER_LEVEL_NAMES[officerLevel] ?? '-'; + } + if (nationLevel === undefined) { + return OFFICER_LEVEL_NAMES[officerLevel] ?? '-'; + } + return OFFICER_LEVEL_NAMES_BY_NATION_LEVEL[nationLevel]?.[officerLevel] ?? '-'; +}; + +export const resolveDedicationLevelName = (dedicationLevel: number, maxDedicationLevel: number): string => { + if (dedicationLevel <= 0) { + return '무품관'; + } + return `${Math.max(1, maxDedicationLevel - dedicationLevel + 1)}품관`; +}; + +const resolveUnitSetName = (world: Pick | 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>>(); + +export const loadCrewTypeDisplayNames = ( + world: Pick | null, + fallback: string +): Promise> => { + const unitSetName = resolveUnitSetName(world, fallback); + const cached = crewTypeNameCache.get(unitSetName); + if (cached) { + return cached; + } + const pending = loadUnitSetDefinitionByName(unitSetName) + .then((definition) => new Map((definition.crewTypes ?? []).map((crewType) => [crewType.id, crewType.name]))) + .catch(() => new Map()); + crewTypeNameCache.set(unitSetName, pending); + return pending; +}; + +const itemLoader = new ItemLoader(); + +export const loadItemDisplayNames = async (values: Array): Promise> => { + const keys = Array.from(new Set(values.filter((value): value is string => Boolean(value) && value !== 'None'))); + const entries = await Promise.all( + keys.map(async (key) => { + if (!isItemKey(key)) { + return [key, sanitizeInternalDisplayCode(key)] as const; + } + const item = await itemLoader.load(key).catch(() => null); + return [key, item?.name ?? sanitizeInternalDisplayCode(key)] as const; + }) + ); + return new Map(entries); +}; diff --git a/app/game-api/test/archiveRouter.test.ts b/app/game-api/test/archiveRouter.test.ts index a6fad454..2451600a 100644 --- a/app/game-api/test/archiveRouter.test.ts +++ b/app/game-api/test/archiveRouter.test.ts @@ -156,7 +156,8 @@ describe('archive.myPastPlays', () => { leadership: 80, strength: 70, officerLevel: 8, - personal: '3', + officerLevelText: '제2장군', + personal: '-', historyCount: 2, }), expect.objectContaining({ @@ -166,9 +167,10 @@ describe('archive.myPastPlays', () => { strength: 71, intel: 61, officerLevel: 7, - personal: 'che_의리', - special: 'che_상재', - special2: 'che_신산', + officerLevelText: '제2모사', + personal: '의리', + special: '상재', + special2: '신산', historyCount: 1, }), ], diff --git a/app/game-api/test/gameDisplayNames.test.ts b/app/game-api/test/gameDisplayNames.test.ts new file mode 100644 index 00000000..62a576e6 --- /dev/null +++ b/app/game-api/test/gameDisplayNames.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { + resolveCityLevelName, + resolveDedicationLevelName, + resolveNationLevelName, + resolveOfficerLevelName, + resolveRegionName, + sanitizeInternalDisplayCode, +} from '../src/services/gameDisplayNames.js'; + +describe('Ref GUI display names', () => { + it('maps nation, city, region, office, and dedication levels to Ref labels', () => { + expect(resolveNationLevelName(3)).toBe('주자사'); + expect(resolveCityLevelName(8)).toBe('특'); + expect(resolveRegionName(2)).toBe('중원'); + expect(resolveOfficerLevelName(9, 3)).toBe('간의대부'); + expect(resolveOfficerLevelName(5, 3)).toBe('-'); + expect(resolveOfficerLevelName(5)).toBe('제3모사'); + expect(resolveDedicationLevelName(2, 30)).toBe('29품관'); + expect(resolveDedicationLevelName(0, 30)).toBe('무품관'); + }); + + it('never exposes internal prefixes or numeric fallback codes', () => { + expect(sanitizeInternalDisplayCode('che_event_의병')).toBe('의병'); + expect(sanitizeInternalDisplayCode('che_법가')).toBe('법가'); + expect(sanitizeInternalDisplayCode('3')).toBe('-'); + expect(resolveNationLevelName(99)).toBe('-'); + expect(resolveCityLevelName(99)).toBe('-'); + expect(resolveRegionName(99)).toBe('-'); + }); +}); diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts index 77c9dcd6..e634681b 100644 --- a/app/game-api/test/inGameMenuPermissions.test.ts +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -248,6 +248,55 @@ describe('in-game my information ownership', () => { ); }); + it('returns Ref display names instead of numeric levels and internal codes for the main GUI', async () => { + const fixture = createContext({ + me: buildGeneral({ + officerLevel: 9, + crewTypeId: 1100, + horseCode: 'che_명마_03_노새', + meta: { explevel: 4, dedlevel: 2 }, + }), + city: { + id: 1, + name: '업', + level: 8, + nationId: 1, + population: 1_000, + populationMax: 2_000, + agriculture: 100, + agricultureMax: 200, + commerce: 100, + commerceMax: 200, + security: 100, + securityMax: 200, + trust: 70, + trade: 100, + defence: 100, + defenceMax: 200, + wall: 100, + wallMax: 200, + region: 2, + supplyState: 1, + frontState: 0, + }, + }); + + await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({ + general: { + officerLevelText: '간의대부', + crewTypeName: '보병', + progression: { experienceLevel: 4, dedicationLevel: 2, dedicationText: '29품관' }, + itemNames: { horse: '노새(+3)' }, + }, + city: { levelName: '특', regionName: '중원', nationName: '위' }, + nation: { + levelName: '주자사', + typeName: '법가', + capitalCityName: '업', + }, + }); + }); + it('returns the Ref-style neutral nation frame and trait display names on the main read model', async () => { const fixture = createContext({ me: buildGeneral({ @@ -542,8 +591,14 @@ describe('battle-center general and user permissions', () => { id: 7, picture: 'default.jpg', imageServer: 0, + officerLevelText: '일반', + crewTypeName: '-', + equipmentNames: { weapon: '-', book: '-', horse: '-', item: '-' }, + traits: { personal: '-', specialDomestic: '-', specialWar: '-' }, progression: { experienceLevel: 0, + dedicationLevel: 1, + dedicationText: '30품관', statExperience: { leadership: 0, strength: 0, intelligence: 0 }, statUpgradeLimit: 20, dex: [0, 0, 0, 0, 0], diff --git a/app/game-api/test/nationGeneralSecretRouter.test.ts b/app/game-api/test/nationGeneralSecretRouter.test.ts index c1fce969..088733e1 100644 --- a/app/game-api/test/nationGeneralSecretRouter.test.ts +++ b/app/game-api/test/nationGeneralSecretRouter.test.ts @@ -117,6 +117,9 @@ describe('nation general and secret office permissions', () => { cityName: null, troopName: null, refreshScoreTotal: 10, + dedicationLevel: 1, + dedicationText: '30품관', + bill: 600, }); expect(result.generals[0]).not.toHaveProperty('crew'); await expect(caller.nation.getSecretGeneralList()).rejects.toMatchObject({ code: 'FORBIDDEN' }); diff --git a/app/game-engine/src/turn/selectPoolService.ts b/app/game-engine/src/turn/selectPoolService.ts index 55931dd9..cb18675b 100644 --- a/app/game-engine/src/turn/selectPoolService.ts +++ b/app/game-engine/src/turn/selectPoolService.ts @@ -6,10 +6,12 @@ import { EventDomesticTraitLoader, isEventDomesticTraitKey, isPersonalityTraitKey, + isWarTraitKey, LogCategory, LogScope, PERSONALITY_TRAIT_KEYS, simpleSerialize, + WarTraitLoader, } from '@sammo-ts/logic'; import type { DatabaseClient, GamePrisma as GamePrismaTypes } from '@sammo-ts/infra'; @@ -78,6 +80,8 @@ export interface SelectPoolCandidateDto { specialDomesticName: string; specialDomesticInfo: string; specialWar: string | null; + specialWarName: string | null; + specialWarInfo: string; ego: string | null; dex: [number, number, number, number, number]; imageServer: 0 | 1; @@ -158,11 +162,16 @@ const candidateWeight = (candidate: SelectPoolCandidateInfo): number => candidate.dex.reduce((sum, value) => sum + value, 0); const eventDomesticTraitLoader = new EventDomesticTraitLoader(); +const warTraitLoader = new WarTraitLoader(); const toCandidateDto = async (candidate: SelectPoolCandidateInfo): Promise => { const trait = isEventDomesticTraitKey(candidate.specialDomestic) ? await eventDomesticTraitLoader.load(candidate.specialDomestic) : null; + const warTrait = + candidate.specialWar && isWarTraitKey(candidate.specialWar) + ? await warTraitLoader.load(candidate.specialWar) + : null; return { uniqueName: candidate.uniqueName, generalName: candidate.generalName, @@ -173,6 +182,8 @@ const toCandidateDto = async (candidate: SelectPoolCandidateInfo): Promise getComputedStyle(el).borderCollapse) ).toBe(borderCollapse); + if (path === 'nation/info') { + await expect(page.locator(selector)).toContainText('작 위호족'); + await expect(page.locator(selector)).not.toContainText('작 위1'); + } } }); +test('국가 정보의 작위는 Ref 국가 등급 이름으로 표시된다', async ({ page }) => { + await install(page); + await page.goto('nation/info'); + + const root = page.locator('.legacy-info-page'); + await expect(root).toContainText('작 위호족'); + await expect(root).not.toContainText('작 위1'); +}); + test('global-info renders the ref nation summary columns beside the map', async ({ page }) => { await install(page); await page.setViewportSize({ width: 1200, height: 900 }); diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 3db03da2..202496d2 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -63,7 +63,9 @@ const myGeneral = (state: FixtureState) => ({ troopId: 0, picture: null, imageServer: 0, - officerLevel: state.permission === 'head' ? 5 : 1, + officerLevel: state.permission === 'head' ? 9 : 1, + officerLevelText: + state.permission === 'head' ? '간의대부' : state.buildNationCandidateEnabled ? '재야' : '일반', stats: { leadership: 70, strength: 60, intelligence: 50 }, gold: 1_000, rice: 2_000, @@ -76,30 +78,74 @@ const myGeneral = (state: FixtureState) => ({ age: 30, turnTime: '2026-01-01 00:10:00', crewTypeId: 1, + crewTypeName: '보병', traits: state.mainTraits ?? { personal: '-', specialDomestic: '-', specialWar: '-' }, progression: { experienceLevel: 1, dedicationLevel: 2, + dedicationText: '29품관', statExperience: { leadership: 7, strength: 8, intelligence: 9 }, statUpgradeLimit: 20, dex: [350, 1_375, 3_500, 7_125, 1_275_975], }, items: { horse: 'che_명마', weapon: null, book: null, item: null }, + itemNames: { horse: '명마', weapon: null, book: null, item: null }, + }, + city: { + id: 1, + name: '업', + level: 8, + levelName: '특', + region: 2, + regionName: '중원', + nationId: 1, + nationName: '위', + population: 1000, + populationMax: 2000, + agriculture: 100, + agricultureMax: 200, + commerce: 100, + commerceMax: 200, + security: 100, + securityMax: 200, + trust: 70, + trade: 100, + defence: 100, + defenceMax: 200, + wall: 100, + wallMax: 200, + supplyState: 1, + frontState: 0, }, - city: { id: 1, name: '업', level: 8, nationId: 1 }, nation: state.buildNationCandidateEnabled ? { id: 0, name: '재야', color: '#000000', level: 0, + levelName: '방랑군', gold: 0, rice: 0, tech: 0, typeCode: 'None', + typeName: '해당 없음', capitalCityId: null, + capitalCityName: null, } - : { id: 1, name: '위', color: '#777777', level: 3 }, + : { + id: 1, + name: '위', + color: '#777777', + level: 3, + levelName: '주자사', + gold: 10_000, + rice: 20_000, + tech: 100, + typeCode: 'che_법가', + typeName: '법가', + capitalCityId: 1, + capitalCityName: '업', + }, settings: { tnmt: 0, defence_train: 80, @@ -116,7 +162,7 @@ const myGeneral = (state: FixtureState) => ({ const battleCenter = (state: FixtureState) => ({ me: { id: 7, - officerLevel: state.permission === 'head' ? 5 : 1, + officerLevel: state.permission === 'head' ? 9 : 1, permissionLevel: state.permission === 'head' ? 2 : 0, }, nation: { id: 1, name: '위', color: '#777777', level: 3 }, @@ -128,7 +174,8 @@ const battleCenter = (state: FixtureState) => ({ id: 7, name: '검증장수', npcState: 0, - officerLevel: state.permission === 'head' ? 5 : 1, + officerLevel: state.permission === 'head' ? 9 : 1, + officerLevelText: state.permission === 'head' ? '간의대부' : '일반', cityId: 1, turnTime: '2026-01-01 00:10:00', recentWar: '2026-01-01 00:00:00', @@ -144,10 +191,14 @@ const battleCenter = (state: FixtureState) => ({ atmos: 90, age: 30, crewTypeId: 1, + crewTypeName: '보병', equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' }, - traits: { personal: 'None', specialDomestic: 'None', specialWar: 'None' }, + equipmentNames: { weapon: '-', book: '-', horse: '-', item: '-' }, + traits: { personal: '-', specialDomestic: '-', specialWar: '-' }, progression: { experienceLevel: 1, + dedicationLevel: 2, + dedicationText: '29품관', statExperience: { leadership: 7, strength: 8, intelligence: 9 }, statUpgradeLimit: 20, dex: [350, 1_375, 3_500, 7_125, 1_275_975], @@ -159,6 +210,7 @@ const battleCenter = (state: FixtureState) => ({ name: '다른장수', npcState: 2, officerLevel: 1, + officerLevelText: '일반', cityId: 1, turnTime: '2026-01-01 00:20:00', recentWar: null, @@ -174,10 +226,14 @@ const battleCenter = (state: FixtureState) => ({ atmos: 60, age: 20, crewTypeId: 1, + crewTypeName: '보병', equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' }, - traits: { personal: 'None', specialDomestic: 'None', specialWar: 'None' }, + equipmentNames: { weapon: '-', book: '-', horse: '-', item: '-' }, + traits: { personal: '-', specialDomestic: '-', specialWar: '-' }, progression: { experienceLevel: 0, + dedicationLevel: 0, + dedicationText: '무품관', statExperience: { leadership: 0, strength: 0, intelligence: 0 }, statUpgradeLimit: 20, dex: [0, 0, 0, 0, 0], @@ -512,6 +568,35 @@ test('재야 메인은 국가 틀과 성격·특기 표기명을 Chromium에 표 await persistParityArtifact(page, 'main-neutral-trait-display', geometry); }); +test('메인 카드의 국가·수도·관직·계급·병종은 Ref 출력명으로 표시된다', async ({ page }) => { + const state: FixtureState = { + permission: 'head', + myset: 3, + mainTraits: { personal: '안전', specialDomestic: '상재', specialWar: '신산' }, + settingMutations: [], + accessPages: [], + }; + await install(page, state); + await page.setViewportSize({ width: 1000, height: 900 }); + await page.goto(''); + + const nationCard = page.locator('.nation-card'); + await expect(nationCard.locator('.title')).toHaveText('위 (주자사)'); + await expect(nationCard).toContainText('체제법가'); + await expect(nationCard).toContainText('수도업'); + await expect(nationCard).toContainText('국가 등급주자사'); + + const generalCard = page.locator('.general-card'); + await expect(generalCard.locator('.general-title')).toContainText('검증장수 · 간의대부'); + await expect(generalCard).toContainText('병종보병'); + await expect(generalCard).toContainText('계급29품관'); + + const cityCard = page.locator('.city-card'); + await expect(cityCard.locator('.title')).toContainText('【중원 | 특】 업'); + await expect(cityCard.locator('.title')).toContainText('지배 국가 【 위 】'); + await expect(page.locator('.main-page')).not.toContainText('che_'); +}); + test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => { const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] }; await install(page, state); @@ -566,6 +651,10 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac await install(page, state); await page.setViewportSize({ width: 1000, height: 900 }); await page.goto('my-page'); + await expect(page.locator('.legacy-general-details')).toContainText('계급 29품관'); + await expect(page.locator('.legacy-general-details')).toContainText('병종 보병'); + await expect(page.locator('.item-group')).toContainText('명마'); + await expect(page.locator('#container')).not.toContainText('che_'); await expect(page.locator('.title-row')).toContainText('내 정 보'); await expect(page.locator('#set_my_setting')).toBeVisible(); await expect(page.locator('.general-column [role="progressbar"]')).toHaveCount(14); @@ -1020,6 +1109,10 @@ test('감찰부 keeps the selector interaction and shows the permission error pa await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8'); await page.getByRole('button', { name: '다음 ▶' }).click(); await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7'); + await expect(page.locator('.battle-general-name')).toContainText('검증장수 (간의대부)'); + await expect(page.locator('.battle-general-extra')).toContainText('계급29품관'); + await expect(page.locator('.battle-general-extra')).toContainText('병종보병'); + await expect(page.locator('.battle-general-card')).not.toContainText('che_'); await expect(page.locator('.battle-general-card [role="progressbar"]')).toHaveCount(14); await expect(page.locator('.battle-general-card [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5); expect( diff --git a/app/game-frontend/e2e/pastPlays.spec.ts b/app/game-frontend/e2e/pastPlays.spec.ts index bd6365c3..c696e4be 100644 --- a/app/game-frontend/e2e/pastPlays.spec.ts +++ b/app/game-frontend/e2e/pastPlays.spec.ts @@ -41,6 +41,7 @@ const installArchive = async (page: Page) => { experience: 23000, dedication: 1200, officerLevel: 12, + officerLevelText: '황제', personal: '대담', special: '상재', special2: '신산', @@ -66,6 +67,15 @@ const installArchive = async (page: Page) => { }); }; +test('지난 플레이 관직은 숫자 대신 저장된 Ref 표시명으로 나타난다', async ({ page }) => { + await installArchive(page); + await page.goto('past-plays'); + + const generalRow = page.locator('tbody tr').filter({ hasText: '관우' }); + await expect(generalRow).toContainText('황제'); + await expect(generalRow).not.toContainText('che_'); +}); + test('past plays is available without a current general and preserves desktop interaction geometry', async ({ page, }) => { @@ -78,6 +88,7 @@ test('past plays is available without a current general and preserves desktop in await expect(page.getByRole('heading', { name: '내 지난 플레이 보기' })).toBeVisible(); await expect(page.getByText('천하쟁패 · 51기')).toBeVisible(); await expect(page.locator('.general-name')).toHaveText('관우'); + await expect(page.locator('tbody tr').filter({ hasText: '관우' })).toContainText('황제'); await expect(page.getByRole('link', { name: '이 기수 국가 정보' })).toHaveAttribute('href', gamePath('/dynasty/7')); const historyToggle = page.locator('.history-toggle'); await expect(historyToggle).toHaveText('보기 (2)'); diff --git a/app/game-frontend/src/components/main/CityBasicCard.vue b/app/game-frontend/src/components/main/CityBasicCard.vue index 5f122258..8d81b588 100644 --- a/app/game-frontend/src/components/main/CityBasicCard.vue +++ b/app/game-frontend/src/components/main/CityBasicCard.vue @@ -8,7 +8,10 @@ interface CityInfo { id: number; name: string; level: number; + levelName: string; + regionName: string; nationId: number; + nationName: string; population: number; populationMax: number; agriculture: number; @@ -66,8 +69,8 @@ const metrics = computed(() => {
도시 정보를 불러오지 못했습니다.
- {{ props.city.name }} (Lv {{ props.city.level }}) · 국가 {{ props.city.nationId || '무주' }} · 보급 - {{ props.city.supplyState }} · 전방 {{ props.city.frontState }} + 【{{ props.city.regionName }} | {{ props.city.levelName }}】 {{ props.city.name }} · + {{ props.city.nationId > 0 ? `지배 국가 【 ${props.city.nationName} 】` : '공 백 지' }}
장수 정보를 불러오지 못했습니다.
- {{ props.general.name }} · 관직 {{ props.general.officerLevel }} · {{ props.general.age ?? '-' }}세 · + {{ props.general.name }} · {{ props.general.officerLevelText }} · {{ props.general.age ?? '-' }}세 · 다음 턴 {{ props.general.turnTime?.slice(11, 16) ?? '-' }}
@@ -102,11 +105,11 @@ const experiencePercent = computed(() => >{{ props.general.crew.toLocaleString() }} 훈련{{ props.general.train }} 사기{{ props.general.atmos }} 부상{{ props.general.injury }} 병종{{ props.general.crewTypeId || '-' }} 성격{{ props.general.crewTypeName ?? '-' }} 성격{{ props.general.traits?.personal ?? '-' }} 전투특기{{ props.general.traits?.specialWar ?? '-' }} 내정특기{{ props.general.traits?.specialDomestic ?? '-' }} 계급Lv {{ props.general.progression?.dedicationLevel ?? 0 }} 공헌{{ props.general.progression?.dedicationText ?? '무품관' }} 공헌{{ props.general.dedication.toLocaleString() }}
diff --git a/app/game-frontend/src/components/main/NationBasicCard.vue b/app/game-frontend/src/components/main/NationBasicCard.vue index 6470648f..d94cddcc 100644 --- a/app/game-frontend/src/components/main/NationBasicCard.vue +++ b/app/game-frontend/src/components/main/NationBasicCard.vue @@ -7,11 +7,14 @@ interface NationInfo { name: string; color: string; level: number; + levelName: string; gold: number; rice: number; tech: number; typeCode: string; + typeName: string; capitalCityId: number | null; + capitalCityName: string | null; } const props = defineProps<{ @@ -31,7 +34,7 @@ const props = defineProps<{ class="title" :style="{ backgroundColor: props.nation.color, color: legacyNationTextColor(props.nation.color) }" > - {{ props.nation.name }} + {{ props.nation.name }}
국고{{ props.nation.id === 0 ? '해당 없음' : props.nation.rice.toLocaleString() }} 기술{{ props.nation.id === 0 ? '해당 없음' : props.nation.tech.toLocaleString() }} - 체제{{ props.nation.id === 0 ? '해당 없음' : props.nation.typeCode }} + 체제{{ props.nation.id === 0 ? '해당 없음' : props.nation.typeName }} 수도{{ props.nation.id === 0 ? '해당 없음' : (props.nation.capitalCityId ?? '-') }} - 국가 등급{{ props.nation.id === 0 ? '해당 없음' : props.nation.level }} + >{{ props.nation.id === 0 ? '해당 없음' : (props.nation.capitalCityName ?? '-') }} + 국가 등급{{ props.nation.id === 0 ? '해당 없음' : props.nation.levelName }}
diff --git a/app/game-frontend/src/utils/nationFormat.ts b/app/game-frontend/src/utils/nationFormat.ts index 8fcea664..8d944c79 100644 --- a/app/game-frontend/src/utils/nationFormat.ts +++ b/app/game-frontend/src/utils/nationFormat.ts @@ -14,6 +14,19 @@ export const officerLevelMapDefault: Record = { 0: '재야', }; +export const nationLevelMap: Record = { + 7: '황제', + 6: '왕', + 5: '공', + 4: '주목', + 3: '주자사', + 2: '군벌', + 1: '호족', + 0: '방랑군', +}; + +export const formatNationLevelText = (nationLevel: number): string => nationLevelMap[nationLevel] ?? '-'; + export const officerLevelMapByNationLevel: Record> = { 7: { 12: '황제', @@ -75,15 +88,13 @@ export const officerLevelMapByNationLevel: Record export const formatOfficerLevelText = (officerLevel: number, nationLevel?: number): string => { if (officerLevel < 5) { - return officerLevelMapDefault[officerLevel] ?? '???'; + return officerLevelMapDefault[officerLevel] ?? '-'; } - const nationMap = - nationLevel === undefined - ? officerLevelMapDefault - : (officerLevelMapByNationLevel[nationLevel] ?? officerLevelMapDefault); - - return nationMap[officerLevel] ?? (officerLevelMapDefault[officerLevel] ?? '???'); + if (nationLevel === undefined) { + return officerLevelMapDefault[officerLevel] ?? '-'; + } + return officerLevelMapByNationLevel[nationLevel]?.[officerLevel] ?? '-'; }; export const regionMap: Record = { diff --git a/app/game-frontend/src/views/AuctionView.vue b/app/game-frontend/src/views/AuctionView.vue index bea7221f..dfb03396 100644 --- a/app/game-frontend/src/views/AuctionView.vue +++ b/app/game-frontend/src/views/AuctionView.vue @@ -38,6 +38,8 @@ const resolveErrorMessage = (value: unknown): string => { }; const formatNumber = (value: number | null | undefined): string => (value ?? 0).toLocaleString(); +const displayCode = (value: string | null | undefined): string => + !value || /^\d+$/u.test(value) ? '-' : value.replace(/^che_(?:event_)?/u, ''); const cutDateTime = (value: string | null | undefined, showSecond = false): string => { if (!value) { return '-'; @@ -395,7 +397,7 @@ onMounted(() => {

경매 {{ uniqueDetail.auction.id }}번 상세

경매명
-
{{ uniqueDetail.auction.detail.title ?? uniqueDetail.auction.targetCode }}
+
{{ uniqueDetail.auction.detail.title ?? displayCode(uniqueDetail.auction.targetCode) }}
주최자(익명)
{{ uniqueDetail.auction.hostName }}
종료일시
@@ -442,7 +444,7 @@ onMounted(() => { @keydown.space.prevent="selectUnique(auction)" > {{ auction.id }}{{ auction.detail.title ?? auction.targetCode }} + >{{ auction.detail.title ?? displayCode(auction.targetCode) }} {{ auction.hostName }} {{ cutDateTime(auction.closeAt) }} {{ (auction.detail.remainCloseDateExtensionCnt ?? 0) > 0 ? '남음' : '소진' }} @@ -474,7 +476,7 @@ onMounted(() => { @keydown.space.prevent="selectUnique(auction)" > {{ auction.id }}{{ auction.detail.title ?? auction.targetCode }} + >{{ auction.detail.title ?? displayCode(auction.targetCode) }} {{ auction.hostName }} {{ cutDateTime(auction.closeAt) }} {{ (auction.detail.remainCloseDateExtensionCnt ?? 0) > 0 ? '남음' : '소진' }} diff --git a/app/game-frontend/src/views/BattleCenterView.vue b/app/game-frontend/src/views/BattleCenterView.vue index 5a48d6e6..a71eb31d 100644 --- a/app/game-frontend/src/views/BattleCenterView.vue +++ b/app/game-frontend/src/views/BattleCenterView.vue @@ -269,7 +269,7 @@ onMounted(() => {
- {{ selectedGeneral.name }} (관직 {{ selectedGeneral.officerLevel }}) + {{ selectedGeneral.name }} ({{ selectedGeneral.officerLevelText }})
{
명성{{ selectedGeneral.experience.toLocaleString('ko-KR') }} - 계급{{ selectedGeneral.dedication.toLocaleString('ko-KR') }} + 계급{{ selectedGeneral.progression.dedicationText }} 나이{{ selectedGeneral.age }}세 병종{{ selectedGeneral.crewTypeId }} 승리{{ selectedGeneral.crewTypeName }} 승리{{ selectedGeneral.battleStats.kills }} 패배{{ selectedGeneral.battleStats.deaths }} 사살{{ selectedGeneral.battleStats.killCrew.toLocaleString('ko-KR') }} diff --git a/app/game-frontend/src/views/MyPageView.vue b/app/game-frontend/src/views/MyPageView.vue index 42cc1df4..2d3be533 100644 --- a/app/game-frontend/src/views/MyPageView.vue +++ b/app/game-frontend/src/views/MyPageView.vue @@ -132,12 +132,40 @@ const noDefencePenaltyWaived = computed(() => { ); }); const noDefenceLabel = computed(() => (noDefencePenaltyWaived.value ? '×' : '× [훈련 -3,사기 -6]')); -const items = computed>(() => [ - { key: 'horse', name: '말', code: data.value?.general.items.horse ?? null }, - { key: 'weapon', name: '무기', code: data.value?.general.items.weapon ?? null }, - { key: 'book', name: '서적', code: data.value?.general.items.book ?? null }, - { key: 'item', name: '도구', code: data.value?.general.items.item ?? null }, -]); +const fallbackDisplayCode = (value: string | null | undefined): string | null => + value && !/^\d+$/u.test(value) ? value.replace(/^che_(?:event_)?/u, '') : null; +const items = computed>( + () => [ + { + key: 'horse', + slotName: '말', + displayName: + data.value?.general.itemNames?.horse ?? fallbackDisplayCode(data.value?.general.items.horse) ?? null, + code: data.value?.general.items.horse ?? null, + }, + { + key: 'weapon', + slotName: '무기', + displayName: + data.value?.general.itemNames?.weapon ?? fallbackDisplayCode(data.value?.general.items.weapon) ?? null, + code: data.value?.general.items.weapon ?? null, + }, + { + key: 'book', + slotName: '서적', + displayName: + data.value?.general.itemNames?.book ?? fallbackDisplayCode(data.value?.general.items.book) ?? null, + code: data.value?.general.items.book ?? null, + }, + { + key: 'item', + slotName: '도구', + displayName: + data.value?.general.itemNames?.item ?? fallbackDisplayCode(data.value?.general.items.item) ?? null, + code: data.value?.general.items.item ?? null, + }, + ] +); const iconChoices = computed(() => data.value?.iconChoices ?? []); const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user)); @@ -302,8 +330,8 @@ const dieOnPrestart = async () => { } }; -const dropItem = (item: { key: ItemSlotKey; name: string; code: string | null }) => - confirmMutation(`${item.code ?? item.name}을(를) 버리시겠습니까?`, () => +const dropItem = (item: { key: ItemSlotKey; slotName: string; displayName: string | null; code: string | null }) => + confirmMutation(`${item.displayName ?? item.slotName}을(를) 버리시겠습니까?`, () => trpc.general.dropItem.mutate({ itemType: item.key }) ); @@ -416,7 +444,7 @@ onMounted(() => { > · 계급 Lv {{ data.general.progression?.dedicationLevel ?? 0 }} ({{ + >{{ data.general.progression?.dedicationText ?? '무품관' }} ({{ data.general.dedication }}) @@ -425,7 +453,7 @@ onMounted(() => {
승률 0% · 승리 0 · 패배 0
살상률 0% · 사살 0 · 피살 0
- 병종 {{ data.general.crewTypeId || '-' }} · 내정특기 + 병종 {{ data.general.crewTypeName ?? '-' }} · 내정특기 {{ data.general.traits?.specialDomestic ?? '-' }} · 부상 {{ data.general.injury }} · 부대 {{ data.general.troopId || '-' }} · 벌점 {{ penalties.length || '-' }}
@@ -595,7 +623,7 @@ onMounted(() => { :disabled="!item.code" @click="dropItem(item)" > - {{ item.code ?? '-' }} + {{ item.displayName ?? '-' }}
diff --git a/app/game-frontend/src/views/NationGeneralsView.vue b/app/game-frontend/src/views/NationGeneralsView.vue index e0e26b0b..733c8741 100644 --- a/app/game-frontend/src/views/NationGeneralsView.vue +++ b/app/game-frontend/src/views/NationGeneralsView.vue @@ -63,7 +63,6 @@ const generals = computed(() => }) ); const special = (general: General) => `${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`; -const rank = (general: General) => (general.dedicationLevel ? `${11 - general.dedicationLevel}품관` : '무품관'); const iconUrl = (general: General) => resolveGeneralIconUrl(general); onMounted(load); @@ -78,7 +77,13 @@ onMounted(load); 세력 장수 - +