diff --git a/AGENTS.md b/AGENTS.md index 339fbd58..f2eb4582 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -316,8 +316,10 @@ docs에서 확인해 주세요. 존재하지 않는 명령을 오래된 report ## 코드 스타일 -- TypeScript는 workspace 전체에서 정확히 `6.0.2`를 사용해 주세요. package-local - 다른 버전을 추가하지 말아 주세요. +- Vite, Vue/Volar와 compiler API 소비자는 workspace 전체에서 정확히 + TypeScript `6.0.3`을 사용해 주세요. CLI project build/typecheck는 root의 + `@typescript/native` alias로 고정한 TypeScript 7 `tsc`를 사용하며 package-local + 다른 compiler version이나 직접 `tsc` 호출을 추가하지 말아 주세요. - TypeScript/JSON/Vue SFC는 기존 4-space 스타일을 유지해 주세요. - public API는 명시적 타입을 사용하고 `any`, 불필요하게 넓은 `unknown`, `as unknown as` 우회를 피해 주세요. diff --git a/README.md b/README.md index 3bfa7389..72274ee6 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,8 @@ input-event 결과를 transaction으로 반영합니다. Redis pub/sub과 SSE는 ## 도구 체인 -- pnpm `11.17.0`, Turbo -- TypeScript `6.0.2` +- pnpm `11.21.0`, Turbo +- TypeScript `6.0.3` compiler API와 TypeScript `7.0.2` native `tsc` - Fastify, tRPC, zod - Vue 3, Pinia, Vue Router, Vite - PostgreSQL, Prisma, Redis diff --git a/app/game-api/package.json b/app/game-api/package.json index 7a7194c9..70c17b2c 100644 --- a/app/game-api/package.json +++ b/app/game-api/package.json @@ -23,7 +23,7 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "vitest run --config vitest.config.ts", - "typecheck": "tsc -b" + "typecheck": "pnpm -w tsc7 -b app/game-api/tsconfig.json" }, "devDependencies": { "@types/sanitize-html": "2.16.1", diff --git a/app/game-api/src/realtime/publicEvent.ts b/app/game-api/src/realtime/publicEvent.ts new file mode 100644 index 00000000..cf70bd69 --- /dev/null +++ b/app/game-api/src/realtime/publicEvent.ts @@ -0,0 +1,85 @@ +import { + createFullRealtimeReadModelInvalidation, + hasRealtimeReadModelInvalidation, + mergeRealtimeReadModelInvalidations, + resolveRealtimeReadModelInvalidation, + type PublicRealtimeEvent, + type RealtimeEvent, + type RealtimeReadModelChanges, + type RealtimeViewerIdentity, +} from '@sammo-ts/common'; +import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC } from '@sammo-ts/logic'; + +const uniqueIdentities = (identities: readonly RealtimeViewerIdentity[]): RealtimeViewerIdentity[] => { + const seen = new Set(); + return identities.filter((identity) => { + const key = `${identity.generalId ?? ''}:${identity.cityId ?? ''}:${identity.nationId ?? ''}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +}; + +const isMailboxRelevant = (mailbox: number, identity: RealtimeViewerIdentity): boolean => + mailbox === MESSAGE_MAILBOX_PUBLIC || + (identity.generalId !== null && mailbox === identity.generalId) || + (identity.nationId !== null && mailbox === MESSAGE_MAILBOX_NATIONAL_BASE + identity.nationId); + +const eventChanges = (event: RealtimeEvent): RealtimeReadModelChanges | null => { + if (event.type === 'readModelChanged') return event.changes; + if (event.type === 'turnCompleted') return event.changes ?? null; + return null; +}; + +export const shouldReloadRealtimeViewerIdentity = ( + event: RealtimeEvent, + identity: RealtimeViewerIdentity +): boolean => { + if (identity.generalId === null) return false; + const changes = eventChanges(event); + if (!changes) return false; + const generalId = identity.generalId; + return [ + changes.generalIds, + changes.mapGeneralIds ?? changes.generalIds, + changes.frontStatusGeneralIds ?? [], + changes.frontStatusActorIds ?? [], + changes.lobbyGeneralIds ?? changes.generalIds, + changes.reservedGeneralIds, + changes.recordGeneralIds, + ].some((ids) => ids.includes(generalId)); +}; + +/** + * Converts an internal Redis event to the minimal browser contract. Empty + * clock-only turn events are suppressed; the remaining payload never includes + * entity IDs, wall-clock timestamps, logical turn times, or revisions. + */ +export const toPublicRealtimeEvent = ( + event: RealtimeEvent, + identities: readonly RealtimeViewerIdentity[] +): PublicRealtimeEvent | null => { + const viewers = uniqueIdentities( + identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }] + ); + if (event.type === 'messageCreated') { + return viewers.some((identity) => isMailboxRelevant(event.mailbox, identity)) + ? { type: 'messagesInvalidated' } + : null; + } + + if (event.type === 'turnCompleted' && !event.changes) { + return { + type: 'readModelInvalidated', + invalidation: createFullRealtimeReadModelInvalidation(), + }; + } + + const changes = eventChanges(event); + if (!changes) return null; + const invalidation = viewers + .map((identity) => resolveRealtimeReadModelInvalidation(changes, identity)) + .reduce(mergeRealtimeReadModelInvalidations); + if (!hasRealtimeReadModelInvalidation(invalidation)) return null; + return { type: 'readModelInvalidated', invalidation }; +}; 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/server.ts b/app/game-api/src/server.ts index acd274b2..02d257d6 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -4,7 +4,7 @@ import fastifyStatic from '@fastify/static'; import path from 'path'; import fs from 'node:fs/promises'; import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; -import { buildGameEventChannel } from '@sammo-ts/common'; +import { buildGameEventChannel, type RealtimeViewerIdentity } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import { createGamePostgresConnector, @@ -23,6 +23,7 @@ import { buildBattleSimQueueKeys } from './battleSim/keys.js'; import { RedisBattleSimTransport } from './battleSim/redisTransport.js'; import { RedisRealtimeEventHub } from './realtime/eventHub.js'; import { formatSseFrame } from './realtime/sse.js'; +import { shouldReloadRealtimeViewerIdentity, toPublicRealtimeEvent } from './realtime/publicEvent.js'; import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js'; import { createAdminProfileIconResetFlushHandler } from './services/accountIconSync.js'; import { AccountIconResetReconciler } from './services/accountIconResetReconciler.js'; @@ -229,6 +230,17 @@ export const createGameApiServer = async () => { return; } + const loadViewerIdentity = async (): Promise => { + const general = await postgres.prisma.general.findFirst({ + where: { userId: auth.user.id, npcState: 0 }, + select: { id: true, cityId: true, nationId: true }, + }); + return general + ? { generalId: general.id, cityId: general.cityId, nationId: general.nationId } + : { generalId: null, cityId: null, nationId: null }; + }; + let viewerIdentity = await loadViewerIdentity(); + reply.hijack(); const requestOrigin = request.headers.origin; if (typeof requestOrigin === 'string' && requestOrigin.length > 0) { @@ -256,30 +268,47 @@ export const createGameApiServer = async () => { sendFrame( formatSseFrame({ event: 'ready', - data: JSON.stringify({ at: new Date().toISOString() }), + data: '{}', }) ); + let closed = false; + let eventQueue = Promise.resolve(); const unsubscribe = realtimeHub.subscribe((event) => { - sendFrame( - formatSseFrame({ - event: event.type, - data: JSON.stringify(event), - id: event.at, + eventQueue = eventQueue + .then(async () => { + if (closed) return; + const identities = [viewerIdentity]; + if (shouldReloadRealtimeViewerIdentity(event, viewerIdentity)) { + const nextIdentity = await loadViewerIdentity(); + identities.push(nextIdentity); + viewerIdentity = nextIdentity; + } + const publicEvent = toPublicRealtimeEvent(event, identities); + if (!publicEvent || closed) return; + sendFrame( + formatSseFrame({ + event: publicEvent.type, + data: JSON.stringify(publicEvent), + }) + ); }) - ); + .catch(() => { + // A best-effort notification must not affect committed game state. + }); }); const heartbeat = setInterval(() => { sendFrame( formatSseFrame({ event: 'ping', - data: JSON.stringify({ at: new Date().toISOString() }), + data: '{}', }) ); }, 15000); const close = () => { + closed = true; clearInterval(heartbeat); unsubscribe(); }; 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-api/test/publicRealtimeEvent.test.ts b/app/game-api/test/publicRealtimeEvent.test.ts new file mode 100644 index 00000000..19474989 --- /dev/null +++ b/app/game-api/test/publicRealtimeEvent.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; + +import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-ts/common'; +import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic'; + +import { shouldReloadRealtimeViewerIdentity, toPublicRealtimeEvent } from '../src/realtime/publicEvent.js'; + +const viewer = { generalId: 7, cityId: 3, nationId: 2 } as const; + +const turnEvent = (changes = createEmptyRealtimeReadModelChanges()): RealtimeEvent => ({ + type: 'turnCompleted', + at: '2026-08-12T12:34:56.789Z', + lastTurnTime: '0185-02-01T00:00:00.000Z', + changes, + revision: 42, +}); + +describe('public realtime event privacy boundary', () => { + it('suppresses clock-only and unrelated private general turns', () => { + expect(toPublicRealtimeEvent(turnEvent(), [viewer])).toBeNull(); + expect( + toPublicRealtimeEvent( + turnEvent({ + ...createEmptyRealtimeReadModelChanges(), + generalIds: [99], + }), + [viewer] + ) + ).toBeNull(); + }); + + it('publishes only viewer-specific boolean invalidations', () => { + const publicEvent = toPublicRealtimeEvent( + turnEvent({ + ...createEmptyRealtimeReadModelChanges(), + generalIds: [7, 99], + reservedGeneralIds: [7], + recordGeneralIds: [7], + }), + [viewer] + ); + + expect(publicEvent).toEqual({ + type: 'readModelInvalidated', + invalidation: { + context: true, + lobby: false, + map: false, + commands: true, + contacts: false, + boardAccess: true, + reservedTurns: true, + records: true, + frontStatus: false, + }, + }); + const serialized = JSON.stringify(publicEvent); + expect(publicEvent).not.toHaveProperty('at'); + expect(publicEvent).not.toHaveProperty('lastTurnTime'); + expect(publicEvent).not.toHaveProperty('revision'); + for (const forbidden of ['generalIds', 'cityIds', 'nationIds', '99']) { + expect(serialized).not.toContain(forbidden); + } + }); + + it('keeps global refresh meaning without exposing its source identity or time', () => { + const publicEvent = toPublicRealtimeEvent( + { + type: 'readModelChanged', + at: '2026-08-12T12:34:56.789Z', + revision: 43, + changes: { + ...createEmptyRealtimeReadModelChanges(), + worldChanged: true, + globalRecordsChanged: true, + worldHistoryChanged: true, + }, + }, + [viewer] + ); + + expect(publicEvent).toMatchObject({ + type: 'readModelInvalidated', + invalidation: { lobby: true, map: true, commands: true, records: true }, + }); + expect(JSON.stringify(publicEvent)).not.toMatch(/2026|0185|revision|Ids/u); + }); + + it('uses a conservative identifier-free fallback for an older daemon', () => { + expect( + toPublicRealtimeEvent( + { + type: 'turnCompleted', + at: '2026-08-12T12:34:56.789Z', + lastTurnTime: '0185-02-01T00:00:00.000Z', + }, + [viewer] + ) + ).toEqual({ + type: 'readModelInvalidated', + invalidation: { + context: true, + lobby: true, + map: true, + commands: true, + contacts: true, + boardAccess: true, + reservedTurns: true, + records: true, + frontStatus: true, + }, + }); + }); + + it('filters message events per viewer and removes mailbox, sender, message, and time fields', () => { + const event: RealtimeEvent = { + type: 'messageCreated', + at: '2026-08-12T12:34:56.789Z', + mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + viewer.nationId, + msgType: 'national', + messageId: 123, + senderId: 99, + }; + + expect(toPublicRealtimeEvent(event, [viewer])).toEqual({ type: 'messagesInvalidated' }); + expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull(); + }); + + it('requests an identity refresh only when the viewer general may have changed', () => { + expect( + shouldReloadRealtimeViewerIdentity( + turnEvent({ ...createEmptyRealtimeReadModelChanges(), generalIds: [7] }), + viewer + ) + ).toBe(true); + expect( + shouldReloadRealtimeViewerIdentity( + turnEvent({ ...createEmptyRealtimeReadModelChanges(), generalIds: [99] }), + viewer + ) + ).toBe(false); + }); + + it('merges previous and committed identities across an ownership transition', () => { + const event: RealtimeEvent = { + type: 'readModelChanged', + at: '2026-08-12T12:34:56.789Z', + revision: 44, + changes: { + ...createEmptyRealtimeReadModelChanges(), + generalIds: [7], + nationIds: [3], + frontStatusNationIds: [3], + }, + }; + + expect( + toPublicRealtimeEvent(event, [viewer, { generalId: 7, cityId: 4, nationId: 3 }]) + ).toMatchObject({ + type: 'readModelInvalidated', + invalidation: { + context: true, + commands: true, + boardAccess: true, + frontStatus: true, + }, + }); + }); +}); diff --git a/app/game-api/tsconfig.json b/app/game-api/tsconfig.json index fd0712c2..f18f113d 100644 --- a/app/game-api/tsconfig.json +++ b/app/game-api/tsconfig.json @@ -3,7 +3,6 @@ "compilerOptions": { "outDir": "dist", "composite": true, - "baseUrl": ".", "paths": { "@sammo-ts/common": [ "../../packages/common/src/index.ts" diff --git a/app/game-engine/package.json b/app/game-engine/package.json index 8c1d6c3e..0531cd16 100644 --- a/app/game-engine/package.json +++ b/app/game-engine/package.json @@ -99,7 +99,7 @@ "lint:fix": "eslint . --fix", "profile:npc-unification-memory": "node scripts/profile-npc-unification-memory.mjs", "test": "vitest run --config vitest.config.ts", - "typecheck": "tsc -b" + "typecheck": "pnpm -w tsc7 -b app/game-engine/tsconfig.json" }, "dependencies": { "@prisma/client": "^7.9.1", diff --git a/app/game-engine/src/turn/joinCreateGeneralService.ts b/app/game-engine/src/turn/joinCreateGeneralService.ts index 494aaa23..5ce18abb 100644 --- a/app/game-engine/src/turn/joinCreateGeneralService.ts +++ b/app/game-engine/src/turn/joinCreateGeneralService.ts @@ -79,6 +79,7 @@ const DEFAULT_GENERAL_RICE = 1000; const DEFAULT_CREW_TYPE_ID = 1100; const MAX_GENERAL_TURNS = 30; const DEFAULT_TURN_ACTION = '휴식'; +export const JOIN_WELCOME_MESSAGE = '삼국지 모의전투 HiDCHe의 세계에 오신 것을 환영합니다 ^o^'; const LEGACY_TIMEZONE_OFFSET_MS = 9 * 60 * 60 * 1000; const LEGACY_JOIN_REMOVED_CHARACTERS = /[ⓝⓜⓖⓞⓧ㉥\\/`#|-]/gu; @@ -460,7 +461,7 @@ const pushCreationLogs = ( logger.pushGeneralHistoryLog(`${options.name}, ${options.cityName}에서 큰 뜻을 품다.`); logger.pushGeneralActionLog( [ - '삼국지 모의전투 PHP의 세계에 오신 것을 환영합니다 ^o^', + JOIN_WELCOME_MESSAGE, '처음 하시는 경우에는 도움말을 참고하시고,', '문의사항이 있으시면 게시판에 글을 남겨주시면 되겠네요~', '부디 즐거운 삼모전 되시길 바랍니다 ^^', 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 { it('builds the Ref MakeGeneral seed from the logical game tick', () => { @@ -14,4 +18,9 @@ describe('generic join legacy time contracts', () => { '2026-07-30T02:00:00.000Z' ); }); + + it('uses the HiDCHe product name without the legacy PHP runtime label', () => { + expect(JOIN_WELCOME_MESSAGE).toBe('삼국지 모의전투 HiDCHe의 세계에 오신 것을 환영합니다 ^o^'); + expect(JOIN_WELCOME_MESSAGE).not.toContain('PHP'); + }); }); diff --git a/app/game-frontend/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts index a4d3dec3..d7992b4a 100644 --- a/app/game-frontend/e2e/inGameInfo.spec.ts +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -383,9 +383,22 @@ test('four legacy menu pages keep the 1000px desktop table contract', async ({ p .first() .evaluate((el) => 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/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 7654e9ec..0a2e49e3 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -20,6 +20,7 @@ type NavigationFixture = { generalMeCalls: number; operations: string[]; generalName?: string; + generalTurnTime?: string; cityDefence?: number; cityState?: number; nationRate?: number; @@ -68,60 +69,40 @@ const operationInput = (route: Route, index: number): DashboardBundleInput => { return entry.json ?? (entry as DashboardBundleInput); }; -const readModelChanges = ( +const readModelInvalidation = ( overrides: Partial<{ - generalIds: number[]; - cityIds: number[]; - nationIds: number[]; - mapGeneralIds: number[]; - mapCityIds: number[]; - mapNationIds: number[]; - frontStatusGeneralIds: number[]; - frontStatusNationIds: number[]; - frontStatusActorIds: number[]; - frontStatusChanged: boolean; - lobbyGeneralIds: number[]; - lobbyChanged: boolean; - reservedGeneralIds: number[]; - recordGeneralIds: number[]; - worldChanged: boolean; - globalRecordsChanged: boolean; - worldHistoryChanged: boolean; - contactsChanged: boolean; + context: boolean; + lobby: boolean; + map: boolean; + commands: boolean; + contacts: boolean; + boardAccess: boolean; + reservedTurns: boolean; + records: boolean; + frontStatus: boolean; }> ) => ({ - generalIds: [], - cityIds: [], - nationIds: [], - mapGeneralIds: [], - mapCityIds: [], - mapNationIds: [], - frontStatusGeneralIds: [], - frontStatusNationIds: [], - frontStatusActorIds: [], - frontStatusChanged: false, - lobbyGeneralIds: [], - lobbyChanged: false, - reservedGeneralIds: [], - recordGeneralIds: [], - worldChanged: false, - globalRecordsChanged: false, - worldHistoryChanged: false, - contactsChanged: false, + context: false, + lobby: false, + map: false, + commands: false, + contacts: false, + boardAccess: false, + reservedTurns: false, + records: false, + frontStatus: false, ...overrides, }); -const emitReadModelChanges = (page: Page, changes: ReturnType) => +const emitReadModelInvalidation = (page: Page, invalidation: ReturnType) => page.evaluate((payload) => { (window as unknown as { __emitMainRealtime: (type: string, value: unknown) => void }).__emitMainRealtime( - 'readModelChanged', + 'readModelInvalidated', { - at: new Date().toISOString(), - revision: Date.now(), - changes: payload, + invalidation: payload, } ); - }, changes); + }, invalidation); const commandTableFixture = (large: boolean, blockedCount = 0) => ({ general: large @@ -239,7 +220,7 @@ const generalContext = (state: NavigationFixture) => ({ dex: [350, 100_000, 500_000, 1_000_000, 1_275_975], }, items: { horse: null, weapon: null, book: null, item: null }, - turnTime: '0185-01-01T00:00:00.000Z', + turnTime: state.generalTurnTime ?? '0185-01-01T00:00:00.000Z', }, city: { id: 1, @@ -666,6 +647,66 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); }); +test('main general card renders the next turn in the Seoul server timezone', async ({ page }) => { + const state: NavigationFixture = { + officerLevel: 0, + permission: 0, + nationLevel: 0, + stage: 0, + npcMode: 1, + generalMeCalls: 0, + operations: [], + generalName: 'Administrator', + generalTurnTime: '2026-08-13T00:07:06.713Z', + currentYear: 179, + currentMonth: 8, + }; + await installFixture(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await waitForMain(page); + + const title = page.locator('[data-main-target="general"] .general-title').first(); + await expect(title).toContainText('Administrator'); + await expect(title).toContainText('다음 턴 09:07'); + await expect(title).not.toContainText('00:07'); + + const desktopGeometry = await title.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + width: rect.width, + height: rect.height, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + overflow: style.overflow, + }; + }); + expect(desktopGeometry.width).toBeGreaterThan(0); + expect(desktopGeometry.height).toBeGreaterThan(0); + if (artifactRoot) { + const target = resolve(artifactRoot); + await mkdir(target, { recursive: true }); + await Promise.all([ + page.screenshot({ path: resolve(target, 'main-turn-time-seoul-desktop-1200.png'), fullPage: true }), + writeFile( + resolve(target, 'main-turn-time-seoul-desktop-1200.json'), + `${JSON.stringify(desktopGeometry, null, 2)}\n` + ), + ]); + } + + await page.setViewportSize({ width: 500, height: 900 }); + const mobileTitle = page.locator('[data-main-target="general"] .general-title').first(); + await expect(mobileTitle).toContainText('다음 턴 09:07'); + expect(await mobileTitle.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(0); + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'main-turn-time-seoul-mobile-500.png'), + fullPage: true, + }); + } +}); + test('pure NPC message senders are not rendered as reply targets', async ({ page }) => { const target = (generalId: number, generalName: string) => ({ generalId, @@ -1279,26 +1320,6 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl const callsBeforeRefresh = state.generalMeCalls; const operationsBeforeClockOnly = state.operations.length; - await page.evaluate(() => { - (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( - 'turnCompleted', - { - at: new Date().toISOString(), - lastTurnTime: '0185-02-01T00:00:00.000Z', - changes: { - generalIds: [], - cityIds: [], - nationIds: [], - reservedGeneralIds: [], - recordGeneralIds: [], - worldChanged: false, - globalRecordsChanged: false, - worldHistoryChanged: false, - contactsChanged: false, - }, - } - ); - }); await new Promise((resolve) => setTimeout(resolve, 300)); expect(state.operations.slice(operationsBeforeClockOnly)).toEqual([]); @@ -1308,28 +1329,17 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl const emit = (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }) .__emitMainRealtime; for (let index = 0; index < 100; index += 1) { - emit('turnCompleted', { - at: new Date().toISOString(), - lastTurnTime: '0185-02-01T00:00:00.000Z', - changes: { - generalIds: [7], - cityIds: [], - nationIds: [], - mapGeneralIds: [], - mapCityIds: [], - mapNationIds: [], - frontStatusGeneralIds: [], - frontStatusNationIds: [], - frontStatusActorIds: [], - frontStatusChanged: false, - lobbyGeneralIds: [], - lobbyChanged: false, - reservedGeneralIds: [], - recordGeneralIds: [], - worldChanged: false, - globalRecordsChanged: false, - worldHistoryChanged: false, - contactsChanged: false, + emit('readModelInvalidated', { + invalidation: { + context: true, + lobby: false, + map: false, + commands: true, + contacts: false, + boardAccess: true, + reservedTurns: false, + records: false, + frontStatus: false, }, }); } @@ -1374,29 +1384,18 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl const operationsBeforeSurvey = state.operations.length; await page.evaluate(() => { (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( - 'readModelChanged', + 'readModelInvalidated', { - at: new Date().toISOString(), - revision: 42, - changes: { - generalIds: [], - cityIds: [], - nationIds: [], - mapGeneralIds: [], - mapCityIds: [], - mapNationIds: [], - frontStatusGeneralIds: [], - frontStatusNationIds: [], - frontStatusActorIds: [], - frontStatusChanged: true, - lobbyGeneralIds: [], - lobbyChanged: false, - reservedGeneralIds: [], - recordGeneralIds: [], - worldChanged: false, - globalRecordsChanged: false, - worldHistoryChanged: false, - contactsChanged: false, + invalidation: { + context: false, + lobby: false, + map: false, + commands: false, + contacts: false, + boardAccess: false, + reservedTurns: false, + records: false, + frontStatus: true, }, } ); @@ -1471,7 +1470,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl { op: 'replace', path: '/general/0/values/0/possible', value: false }, { op: 'replace', path: '/general/0/values/0/status', value: 'blocked' }, ]; - await emitReadModelChanges(page, readModelChanges({ cityIds: [1], mapCityIds: [] })); + await emitReadModelInvalidation(page, readModelInvalidation({ context: true, commands: true })); await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeDefence + 1); await expect(page.locator('[data-city-progress="수비"] .city-progress__text')).toHaveText('900 / 2,000'); @@ -1485,7 +1484,10 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl { op: 'replace', path: '/general/0/values/1/possible', value: false }, { op: 'replace', path: '/general/0/values/1/status', value: 'blocked' }, ]; - await emitReadModelChanges(page, readModelChanges({ nationIds: [1], mapNationIds: [], frontStatusNationIds: [] })); + await emitReadModelInvalidation( + page, + readModelInvalidation({ context: true, commands: true, boardAccess: true }) + ); await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeTax + 1); const callsBeforeCityState = state.generalMeCalls; @@ -1499,7 +1501,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl { op: 'replace', path: '/general/0/values/2/possible', value: false }, { op: 'replace', path: '/general/0/values/2/status', value: 'blocked' }, ]; - await emitReadModelChanges(page, readModelChanges({ cityIds: [1], mapCityIds: [1] })); + await emitReadModelInvalidation(page, readModelInvalidation({ context: true, map: true, commands: true })); await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeCityState + 1); await expect(page.locator('.city-base .city-state img')).toHaveAttribute('src', /event5\.gif$/u); expect(state.operations.slice(operationsBeforeCityState).sort()).toEqual( @@ -1512,7 +1514,10 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl state.contextRevision = 'O'.repeat(22); state.contextOperations = [{ op: 'replace', path: '/missing/value', value: 'invalid-delta' }]; state.commandTableOperations = []; - await emitReadModelChanges(page, readModelChanges({ generalIds: [7] })); + await emitReadModelInvalidation( + page, + readModelInvalidation({ context: true, commands: true, boardAccess: true }) + ); await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeFallback + 2); expect(state.forceSnapshotCalls).toBe(forcedBeforeFallback + 1); await expect(page.locator('.general-title')).toContainText('snapshot복구장수'); @@ -1541,29 +1546,18 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl const callsAfterLeavingMain = state.generalMeCalls; await page.evaluate(() => { (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( - 'turnCompleted', + 'readModelInvalidated', { - at: new Date().toISOString(), - lastTurnTime: '0185-02-01T00:00:00.000Z', - changes: { - generalIds: [7], - cityIds: [], - nationIds: [], - mapGeneralIds: [], - mapCityIds: [], - mapNationIds: [], - frontStatusGeneralIds: [], - frontStatusNationIds: [], - frontStatusActorIds: [], - frontStatusChanged: false, - lobbyGeneralIds: [], - lobbyChanged: false, - reservedGeneralIds: [], - recordGeneralIds: [], - worldChanged: false, - globalRecordsChanged: false, - worldHistoryChanged: false, - contactsChanged: false, + invalidation: { + context: true, + lobby: false, + map: false, + commands: true, + contacts: false, + boardAccess: true, + reservedTurns: false, + records: false, + frontStatus: false, }, } ); @@ -1597,7 +1591,7 @@ test('global activity, world history, and a month boundary refresh their visible { id: 3, text: '장수 동향 기록' }, ]; const operationsBeforeGlobal = state.operations.length; - await emitReadModelChanges(page, readModelChanges({ globalRecordsChanged: true })); + await emitReadModelInvalidation(page, readModelInvalidation({ records: true })); await expect(page.locator('[data-main-target="global-records"]')).toContainText('자동 갱신된 장수 동향'); expect(state.operations.slice(operationsBeforeGlobal)).toEqual(['general.getRecentRecords']); @@ -1606,24 +1600,22 @@ test('global activity, world history, and a month boundary refresh their visible { id: 1, text: '중원 정세 기록' }, ]; const operationsBeforeHistory = state.operations.length; - await emitReadModelChanges(page, readModelChanges({ worldHistoryChanged: true })); + await emitReadModelInvalidation(page, readModelInvalidation({ records: true })); await expect(page.locator('[data-main-target="world-history"]')).toContainText('자동 갱신된 중원 정세'); expect(state.operations.slice(operationsBeforeHistory)).toEqual(['general.getRecentRecords']); state.currentMonth = 2; const operationsBeforeMonth = state.operations.length; await page.evaluate( - (changes) => { + (invalidation) => { (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( - 'turnCompleted', + 'readModelInvalidated', { - at: new Date().toISOString(), - lastTurnTime: '0185-02-01T00:00:00.000Z', - changes, + invalidation, } ); }, - readModelChanges({ worldChanged: true }) + readModelInvalidation({ lobby: true, map: true, commands: true }) ); await expect(page.getByText('현재: 185년 2월')).toBeVisible(); await expect(page.locator('.map-viewer')).toContainText('185年 2月'); @@ -1689,29 +1681,18 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn state.generalName = '탭공유갱신장수'; await leaderPage.evaluate(() => { (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( - 'readModelChanged', + 'readModelInvalidated', { - at: new Date().toISOString(), - revision: 100, - changes: { - generalIds: [7], - cityIds: [], - nationIds: [], - mapGeneralIds: [], - mapCityIds: [], - mapNationIds: [], - frontStatusGeneralIds: [], - frontStatusNationIds: [], - frontStatusActorIds: [], - frontStatusChanged: false, - lobbyGeneralIds: [], - lobbyChanged: false, - reservedGeneralIds: [], - recordGeneralIds: [], - worldChanged: false, - globalRecordsChanged: false, - worldHistoryChanged: false, - contactsChanged: false, + invalidation: { + context: true, + lobby: false, + map: false, + commands: true, + contacts: false, + boardAccess: true, + reservedTurns: false, + records: false, + frontStatus: false, }, } ); @@ -1727,29 +1708,18 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn state.generalName = '리더만갱신장수'; await leaderPage.evaluate(() => { (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( - 'readModelChanged', + 'readModelInvalidated', { - at: new Date().toISOString(), - revision: 101, - changes: { - generalIds: [7], - cityIds: [], - nationIds: [], - mapGeneralIds: [], - mapCityIds: [], - mapNationIds: [], - frontStatusGeneralIds: [], - frontStatusNationIds: [], - frontStatusActorIds: [], - frontStatusChanged: false, - lobbyGeneralIds: [], - lobbyChanged: false, - reservedGeneralIds: [], - recordGeneralIds: [], - worldChanged: false, - globalRecordsChanged: false, - worldHistoryChanged: false, - contactsChanged: false, + invalidation: { + context: true, + lobby: false, + map: false, + commands: true, + contacts: false, + boardAccess: true, + reservedTurns: false, + records: false, + frontStatus: false, }, } ); 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/e2e/selectGeneralLive.spec.ts b/app/game-frontend/e2e/selectGeneralLive.spec.ts index b2daf9d8..3e6dd1fc 100644 --- a/app/game-frontend/e2e/selectGeneralLive.spec.ts +++ b/app/game-frontend/e2e/selectGeneralLive.spec.ts @@ -230,7 +230,7 @@ test.describe('scenario 903 live selection pool', () => { expect(geometry.footerBanner.height).toBeCloseTo(20.1875, 3); await expect(page.locator('.invitation-table tbody tr')).toHaveCount(0); await expect(page.locator('.footer-banner')).toContainText( - '삼국지 모의전투 HiDCHe core2026' + '삼국지 모의전투 HiDCHe' ); await expect(page.locator('.footer-banner a')).toHaveText('Credit'); diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index 0a9ad20a..228a482c 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -56,7 +56,7 @@ "autoprefixer": "^10.5.4", "postcss": "8.5.26", "tailwindcss": "^4.3.3", - "typescript": "6.0.2", + "typescript": "6.0.3", "vite": "^8.2.1", "vue-tsc": "^3.3.9" } 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) ?? '-' }} + {{ props.general.turnTime ? formatSeoulHourMinute(props.general.turnTime) : '-' }}
@@ -102,11 +106,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/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index 4fd05e72..b392d3d5 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -4,8 +4,8 @@ import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, type MessageType import { applyReadModelDelta, cloneReadModelJson, - type RealtimeEvent, - type RealtimeReadModelChanges, + type PublicRealtimeEvent, + type RealtimeReadModelInvalidation, } from '@sammo-ts/common'; import { trpc } from '../utils/trpc'; import { useMapViewerStore } from './mapViewer'; @@ -13,7 +13,7 @@ import { useSessionStore } from './session'; import { createLatestRefreshQueue } from '../utils/latestRefreshQueue'; import { createRateLimitedRefreshQueue } from '../utils/rateLimitedRefreshQueue'; import { structurallyShare } from '../utils/structuralShare'; -import { createMergedReadModelRefreshQueue, resolveDashboardRefreshPlan } from '../utils/dashboardReadModel'; +import { createMergedReadModelRefreshQueue } from '../utils/dashboardReadModel'; import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator'; import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery'; @@ -612,16 +612,11 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { } ); - const refreshChangedReadModels = async (changes: RealtimeReadModelChanges) => { + const refreshChangedReadModels = async (plan: RealtimeReadModelInvalidation) => { const id = generalId.value; if (!id) { return; } - const plan = resolveDashboardRefreshPlan(changes, { - generalId: id, - cityId: city.value?.id ?? null, - nationId: nation.value?.id ?? null, - }); if (!Object.values(plan).some(Boolean)) { return; } @@ -944,12 +939,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { return url.toString(); }; - const parseRealtimePayload = (raw: MessageEvent): RealtimeEvent | null => { + const parseRealtimePayload = (raw: MessageEvent): PublicRealtimeEvent | null => { if (!raw.data || typeof raw.data !== 'string') { return null; } try { - const parsed = JSON.parse(raw.data) as RealtimeEvent; + const parsed = JSON.parse(raw.data) as PublicRealtimeEvent; if (!parsed || typeof parsed !== 'object') { return null; } @@ -962,21 +957,6 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { } }; - const isMailboxRelevant = (mailbox: number): boolean => { - if (mailbox === MESSAGE_MAILBOX_PUBLIC) { - return true; - } - const currentGeneralId = generalId.value; - if (currentGeneralId && mailbox === currentGeneralId) { - return true; - } - const currentNationId = nationId.value; - if (currentNationId && mailbox === MESSAGE_MAILBOX_NATIONAL_BASE + currentNationId) { - return true; - } - return false; - }; - const closeRealtimeSource = () => { if (!realtimeSource) { return; @@ -1095,36 +1075,34 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused'; realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'idle' }); }); - source.addEventListener('turnCompleted', (event) => { + source.addEventListener('readModelInvalidated', (event) => { if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return; const payload = parseRealtimePayload(event); - if (!payload || payload.type !== 'turnCompleted') { + if (!payload || payload.type !== 'readModelInvalidated') { return; } - if (!payload.changes) { - // Rolling deployment fallback for an older daemon. + readModelRefreshQueue.request(payload.invalidation); + }); + source.addEventListener('messagesInvalidated', (event) => { + if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return; + const payload = parseRealtimePayload(event); + if (!payload || payload.type !== 'messagesInvalidated') { + return; + } + void refreshMessages(); + }); + + // Rolling deployment fallback: an older API may still expose internal + // events. Do not inspect their payload; use the bounded full refresh. + for (const legacyEventType of ['turnCompleted', 'readModelChanged'] as const) { + source.addEventListener(legacyEventType, () => { + if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return; realtimeRefreshQueue.request(); - return; - } - readModelRefreshQueue.request(payload.changes); - }); - source.addEventListener('readModelChanged', (event) => { + }); + } + source.addEventListener('messageCreated', () => { if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return; - const payload = parseRealtimePayload(event); - if (!payload || payload.type !== 'readModelChanged') { - return; - } - readModelRefreshQueue.request(payload.changes); - }); - source.addEventListener('messageCreated', (event) => { - if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return; - const payload = parseRealtimePayload(event); - if (!payload || payload.type !== 'messageCreated') { - return; - } - if (isMailboxRelevant(payload.mailbox)) { - void refreshMessages(); - } + void refreshMessages(); }); source.addEventListener('ping', () => { if (realtimeEnabled.value) { diff --git a/app/game-frontend/src/utils/dashboardReadModel.ts b/app/game-frontend/src/utils/dashboardReadModel.ts index af7f09f2..b7d93e8e 100644 --- a/app/game-frontend/src/utils/dashboardReadModel.ts +++ b/app/game-frontend/src/utils/dashboardReadModel.ts @@ -1,85 +1,29 @@ import { - createEmptyRealtimeReadModelChanges, - mergeRealtimeReadModelChanges, + createEmptyRealtimeReadModelInvalidation, + mergeRealtimeReadModelInvalidations, + resolveRealtimeReadModelInvalidation, type RealtimeReadModelChanges, + type RealtimeReadModelInvalidation, + type RealtimeViewerIdentity, } from '@sammo-ts/common'; -export interface DashboardReadModelIdentity { - generalId: number | null; - cityId: number | null; - nationId: number | null; -} - -export interface DashboardRefreshPlan { - context: boolean; - lobby: boolean; - map: boolean; - commands: boolean; - contacts: boolean; - boardAccess: boolean; - reservedTurns: boolean; - records: boolean; - frontStatus: boolean; -} - -const contains = (ids: readonly number[], id: number | null): boolean => id !== null && ids.includes(id); +export type DashboardReadModelIdentity = RealtimeViewerIdentity; +export type DashboardRefreshPlan = RealtimeReadModelInvalidation; export const resolveDashboardRefreshPlan = ( changes: RealtimeReadModelChanges, identity: DashboardReadModelIdentity -): DashboardRefreshPlan => { - const ownGeneralChanged = contains(changes.generalIds, identity.generalId); - const ownCityChanged = contains(changes.cityIds, identity.cityId); - const ownNationChanged = contains(changes.nationIds, identity.nationId); - const ownFrontStatusNationChanged = contains( - changes.frontStatusNationIds ?? changes.nationIds, - identity.nationId - ); - const ownGeneralMapChanged = contains(changes.mapGeneralIds ?? changes.generalIds, identity.generalId); - const frontStatusGeneralChanged = - changes.frontStatusGeneralIds !== undefined - ? changes.frontStatusGeneralIds.length > 0 - : changes.contactsChanged; - const ownFrontStatusActorChanged = contains(changes.frontStatusActorIds ?? [], identity.generalId); - const ownLobbyGeneralChanged = contains(changes.lobbyGeneralIds ?? changes.generalIds, identity.generalId); - const lobbyChanged = changes.lobbyChanged ?? changes.contactsChanged; - const entityContextChanged = ownGeneralChanged || ownCityChanged || ownNationChanged; - const mapEntitiesChanged = - (changes.mapCityIds ?? changes.cityIds).length > 0 || - (changes.mapNationIds ?? changes.nationIds).length > 0; - const commandEntitiesChanged = changes.cityIds.length > 0 || changes.nationIds.length > 0; - - return { - context: entityContextChanged, - lobby: changes.worldChanged || lobbyChanged || ownLobbyGeneralChanged, - map: changes.worldChanged || mapEntitiesChanged || ownGeneralMapChanged, - commands: changes.worldChanged || commandEntitiesChanged || ownGeneralChanged, - contacts: changes.contactsChanged, - boardAccess: ownGeneralChanged || ownNationChanged, - reservedTurns: contains(changes.reservedGeneralIds, identity.generalId), - records: - changes.globalRecordsChanged || - changes.worldHistoryChanged || - contains(changes.recordGeneralIds, identity.generalId), - // lastTurnTime is intentionally excluded. This slice contains the - // nation notice/vote/presence model and only follows related changes. - frontStatus: - Boolean(changes.frontStatusChanged) || - frontStatusGeneralChanged || - ownFrontStatusNationChanged || - ownFrontStatusActorChanged, - }; -}; +): DashboardRefreshPlan => resolveRealtimeReadModelInvalidation(changes, identity); type TimerHandle = ReturnType; export interface MergedReadModelRefreshQueue { - request(changes: RealtimeReadModelChanges): void; + request(invalidation: RealtimeReadModelInvalidation): void; cancelPending(): void; } export const createMergedReadModelRefreshQueue = ( - refresh: (changes: RealtimeReadModelChanges) => Promise, + refresh: (invalidation: RealtimeReadModelInvalidation) => Promise, options: { minIntervalMs?: number; now?: () => number; @@ -91,7 +35,7 @@ export const createMergedReadModelRefreshQueue = ( const now = options.now ?? Date.now; const setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs)); const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle)); - let pending = createEmptyRealtimeReadModelChanges(); + let pending = createEmptyRealtimeReadModelInvalidation(); let hasPending = false; let running = false; let timer: TimerHandle | null = null; @@ -108,7 +52,7 @@ export const createMergedReadModelRefreshQueue = ( return; } const next = pending; - pending = createEmptyRealtimeReadModelChanges(); + pending = createEmptyRealtimeReadModelInvalidation(); hasPending = false; running = true; lastStartedAt = now(); @@ -120,14 +64,14 @@ export const createMergedReadModelRefreshQueue = ( }; return { - request: (changes) => { - pending = hasPending ? mergeRealtimeReadModelChanges(pending, changes) : changes; + request: (invalidation) => { + pending = hasPending ? mergeRealtimeReadModelInvalidations(pending, invalidation) : invalidation; hasPending = true; schedule(); }, cancelPending: () => { hasPending = false; - pending = createEmptyRealtimeReadModelChanges(); + pending = createEmptyRealtimeReadModelInvalidation(); if (timer !== null) { clearTimer(timer); timer = null; diff --git a/app/game-frontend/src/utils/legacyDateTime.ts b/app/game-frontend/src/utils/legacyDateTime.ts index 2435c0c0..3b89fd7e 100644 --- a/app/game-frontend/src/utils/legacyDateTime.ts +++ b/app/game-frontend/src/utils/legacyDateTime.ts @@ -20,3 +20,5 @@ export const formatSeoulDateTime = (value: string | Date): string => { koreaTime.getUTCSeconds() )}`; }; + +export const formatSeoulHourMinute = (value: string | Date): string => formatSeoulDateTime(value).slice(11, 16); 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/BestGeneralView.vue b/app/game-frontend/src/views/BestGeneralView.vue index 22db07ad..434f2d65 100644 --- a/app/game-frontend/src/views/BestGeneralView.vue +++ b/app/game-frontend/src/views/BestGeneralView.vue @@ -158,7 +158,7 @@ watch(viewMode, () => {
- 삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD / + 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD / Credit
diff --git a/app/game-frontend/src/views/BettingView.vue b/app/game-frontend/src/views/BettingView.vue index a6213443..90242ce5 100644 --- a/app/game-frontend/src/views/BettingView.vue +++ b/app/game-frontend/src/views/BettingView.vue @@ -248,7 +248,7 @@ const placeBet = async (targetId: number) => { - 삼국지 모의전투 PHP HiDCHe -unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : + 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit diff --git a/app/game-frontend/src/views/CurrentCityView.vue b/app/game-frontend/src/views/CurrentCityView.vue index c98875c6..f87f0f67 100644 --- a/app/game-frontend/src/views/CurrentCityView.vue +++ b/app/game-frontend/src/views/CurrentCityView.vue @@ -321,7 +321,7 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general - 삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : + 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit diff --git a/app/game-frontend/src/views/DiplomacyView.vue b/app/game-frontend/src/views/DiplomacyView.vue index 95b1c288..e6f590d4 100644 --- a/app/game-frontend/src/views/DiplomacyView.vue +++ b/app/game-frontend/src/views/DiplomacyView.vue @@ -621,7 +621,7 @@ onBeforeUnmount(() => {

- 삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : + 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit diff --git a/app/game-frontend/src/views/DynastyDetailView.vue b/app/game-frontend/src/views/DynastyDetailView.vue index e5567532..c9c28664 100644 --- a/app/game-frontend/src/views/DynastyDetailView.vue +++ b/app/game-frontend/src/views/DynastyDetailView.vue @@ -289,7 +289,7 @@ onMounted(loadDetail); - 삼국지 모의전투 TypeScript core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD + 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD diff --git a/app/game-frontend/src/views/DynastyListView.vue b/app/game-frontend/src/views/DynastyListView.vue index 724d9a85..767e7cf9 100644 --- a/app/game-frontend/src/views/DynastyListView.vue +++ b/app/game-frontend/src/views/DynastyListView.vue @@ -144,7 +144,7 @@ onMounted(loadDynasty); - 삼국지 모의전투 TypeScript core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD + 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD diff --git a/app/game-frontend/src/views/HallOfFameView.vue b/app/game-frontend/src/views/HallOfFameView.vue index 7bbc84c3..9fc768d6 100644 --- a/app/game-frontend/src/views/HallOfFameView.vue +++ b/app/game-frontend/src/views/HallOfFameView.vue @@ -174,7 +174,7 @@ onMounted(loadOptions);
- 삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD / + 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD / Credit
diff --git a/app/game-frontend/src/views/MyPageView.vue b/app/game-frontend/src/views/MyPageView.vue index 42cc1df4..7b9859e3 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 ?? '-' }} @@ -637,7 +665,7 @@ onMounted(() => {
- 삼국지 모의전투 PHP HiDCHe - core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작: HideD / Credit + 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작: HideD / Credit
diff --git a/app/game-frontend/src/views/NationCitiesView.vue b/app/game-frontend/src/views/NationCitiesView.vue index f36e3921..10669e95 100644 --- a/app/game-frontend/src/views/NationCitiesView.vue +++ b/app/game-frontend/src/views/NationCitiesView.vue @@ -252,7 +252,7 @@ onMounted(async () => { - 삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : + 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit 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); 세력 장수 - + - 삼국지 모의전투 PHP HiDCHe / KOEI의 이미지를 사용했습니다 / 제작: Hide.D + 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용했습니다 / 제작: Hide.D diff --git a/app/game-frontend/src/views/NationListView.vue b/app/game-frontend/src/views/NationListView.vue index bdc94b82..4b4ee10e 100644 --- a/app/game-frontend/src/views/NationListView.vue +++ b/app/game-frontend/src/views/NationListView.vue @@ -1,7 +1,7 @@