feat: complete map and trend frontend flows

This commit is contained in:
2026-07-26 08:39:35 +00:00
parent 8ac1ad4059
commit 477862464e
15 changed files with 645 additions and 36 deletions
+46
View File
@@ -15,6 +15,7 @@ const zGeneralSettings = z.object({
});
const zGeneralLogType = z.enum(['generalHistory', 'battleDetail', 'battleResult', 'generalAction']);
const FRONT_RECORD_LIMIT = 15;
const readNumber = (value: unknown, fallback: number): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
@@ -319,4 +320,49 @@ export const generalRouter = router({
})),
};
}),
getFrontRecords: authedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
const select = {
id: true,
text: true,
} as const;
const orderBy = { id: 'desc' } as const;
const [global, general, history] = await Promise.all([
ctx.db.logEntry.findMany({
where: {
scope: LogScope.SYSTEM,
category: LogCategory.ACTION,
},
select,
orderBy,
take: FRONT_RECORD_LIMIT,
}),
ctx.db.logEntry.findMany({
where: {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: me.id,
},
select,
orderBy,
take: FRONT_RECORD_LIMIT,
}),
ctx.db.logEntry.findMany({
where: {
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
},
select,
orderBy,
take: FRONT_RECORD_LIMIT,
}),
]);
return {
global,
general,
history,
};
}),
});
+34 -2
View File
@@ -1,5 +1,6 @@
import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import { LogCategory, LogScope } from '@sammo-ts/infra';
import { z } from 'zod';
import type { GameApiContext } from '../../context.js';
@@ -241,14 +242,45 @@ export const publicRouter = router({
return loadMapLayout(ctx.profile.scenario);
}),
getCachedMap: procedure.query(async ({ ctx }) => {
const map = await loadPublicMap(ctx, true);
const cacheKey = buildPublicCacheKey(ctx, 'cachedMapWithHistory');
const cached = await ctx.redis.get(cacheKey);
if (cached) {
try {
return JSON.parse(cached) as NonNullable<Awaited<ReturnType<typeof loadPublicMap>>> & {
history: { id: number; text: string }[];
};
} catch {
// Ignore cache parse errors.
}
}
const [map, history] = await Promise.all([
loadPublicMap(ctx, true),
ctx.db.logEntry.findMany({
where: {
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
},
select: {
id: true,
text: true,
},
orderBy: { id: 'desc' },
take: 10,
}),
]);
if (!map) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
return map;
const snapshot = {
...map,
history,
};
await ctx.redis.set(cacheKey, JSON.stringify(snapshot), { EX: PUBLIC_CACHE_TTL_SECONDS });
return snapshot;
}),
getWorldTrend: procedure.query(async ({ ctx }) => {
return loadCachedWorldTrend(ctx);
@@ -186,6 +186,42 @@ describe('in-game my information ownership', () => {
})
);
});
it('returns the three legacy front-page record streams for the session-owned general', async () => {
const fixture = createContext({});
const caller = appRouter.createCaller(fixture.context);
await expect(caller.general.getFrontRecords()).resolves.toEqual({
global: [{ id: 1, text: '기록' }],
general: [{ id: 1, text: '기록' }],
history: [{ id: 1, text: '기록' }],
});
expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
where: { scope: 'SYSTEM', category: 'ACTION' },
orderBy: { id: 'desc' },
take: 15,
})
);
expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
where: { scope: 'GENERAL', category: 'ACTION', generalId: 7 },
orderBy: { id: 'desc' },
take: 15,
})
);
expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
where: { scope: 'SYSTEM', category: 'HISTORY' },
orderBy: { id: 'desc' },
take: 15,
})
);
});
});
describe('battle-center general and user permissions', () => {
@@ -0,0 +1,90 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js';
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
import { appRouter } from '../src/router.js';
const profile: GameProfile = {
id: 'che',
scenario: 'default',
name: 'che:default',
};
const buildContext = () => {
const redis = {
get: vi.fn(async () => null),
set: vi.fn(async () => 'OK'),
};
const db = {
worldState: {
findFirst: vi.fn(async () => ({
currentYear: 190,
currentMonth: 3,
config: {},
meta: { scenarioMeta: { startYear: 184 } },
})),
},
logEntry: {
findMany: vi.fn(async () => [
{ id: 9, text: '<Y>최근 정세</>' },
{ id: 8, text: '이전 정세' },
]),
},
$queryRaw: vi
.fn()
.mockResolvedValueOnce([
{ id: 1, level: 5, nationId: 1, region: 1, supplyState: 1, meta: { state: 0 } },
])
.mockResolvedValueOnce([
{ id: 1, name: '촉', color: '#ff0000', capitalCityId: 1, meta: {} },
]),
};
const context: GameApiContext = {
db: db as unknown as DatabaseClient,
redis: redis as unknown as RedisConnector['client'],
turnDaemon: new InMemoryTurnDaemonTransport(),
battleSim: new InMemoryBattleSimTransport(),
profile,
auth: null as GameSessionTokenPayload | null,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore: new RedisAccessTokenStore(redis as unknown as RedisConnector['client'], profile.name),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
return { context, db, redis };
};
describe('public.getCachedMap', () => {
it('caches the neutral map and ten latest public history rows as one snapshot', async () => {
const fixture = buildContext();
const result = await appRouter.createCaller(fixture.context).public.getCachedMap();
expect(result).toMatchObject({
year: 190,
month: 3,
history: [
{ id: 9, text: '<Y>최근 정세</>' },
{ id: 8, text: '이전 정세' },
],
});
expect(fixture.db.logEntry.findMany).toHaveBeenCalledWith({
where: { scope: 'SYSTEM', category: 'HISTORY' },
select: { id: true, text: true },
orderBy: { id: 'desc' },
take: 10,
});
expect(fixture.redis.set).toHaveBeenCalledWith(
'sammo:public:cachedMapWithHistory:che:default',
expect.stringContaining('최근 정세'),
{ EX: 600 }
);
});
});
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useMediaQuery, useMouseInElement } from '@vueuse/core';
import { useElementSize, useMediaQuery, useMouseInElement } from '@vueuse/core';
import SkeletonLines from '../ui/SkeletonLines.vue';
import MapCityBasic from './MapCityBasic.vue';
import MapCityDetail from './MapCityDetail.vue';
@@ -72,6 +72,8 @@ const mapStore = useMapViewerStore();
const { showCityName, detailMode, hoveredCityId, selectedCityId } = storeToRefs(mapStore);
const mapArea = ref<HTMLElement | null>(null);
const mapBody = ref<HTMLElement | null>(null);
const { width: mapBodyWidth } = useElementSize(mapBody);
const { elementX, elementY } = useMouseInElement(mapArea);
const resolveSeason = (month: number): string => {
@@ -131,7 +133,15 @@ const dynamicCityById = computed(() => {
return map;
});
const mapScale = computed(() => (isWide.value ? 1 : SMALL_MAP_SCALE));
const mapScale = computed(() => {
if (isWide.value) {
return 1;
}
if (mapBodyWidth.value <= 0) {
return SMALL_MAP_SCALE;
}
return Math.min(SMALL_MAP_SCALE, mapBodyWidth.value / BASE_MAP_WIDTH);
});
const mapWidth = computed(() => `${BASE_MAP_WIDTH * mapScale.value}px`);
@@ -285,7 +295,7 @@ const selectCity = (cityId: number) => {
<div v-else-if="!props.mapData || !props.mapLayout" class="map-empty">
지도 데이터를 불러오지 못했습니다.
</div>
<div v-else class="map-body">
<div v-else ref="mapBody" class="map-body">
<div
ref="mapArea"
class="map-area"
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { computed } from 'vue';
import { formatLog } from '../../utils/formatLog';
type LogEntry = {
id: number;
text: string;
};
const props = withDefaults(
defineProps<{
logs?: LogEntry[] | null;
emptyText?: string;
}>(),
{
logs: null,
emptyText: '기록 없음',
}
);
const formattedLogs = computed(() =>
(props.logs ?? []).map((entry) => ({
id: entry.id,
html: formatLog(entry.text),
}))
);
</script>
<template>
<div class="recent-log-list">
<template v-if="formattedLogs.length">
<!-- 레거시 색상 tag만 formatLog가 span으로 변환한다. -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-for="entry in formattedLogs" :key="entry.id" class="recent-log-line" v-html="entry.html" />
</template>
<div v-else class="recent-log-empty">{{ emptyText }}</div>
</div>
</template>
<style scoped>
.recent-log-list {
min-width: 0;
color: #fff;
font-family: 'Times New Roman', serif;
font-size: 14px;
line-height: 1.35;
}
.recent-log-line {
overflow-wrap: anywhere;
}
.recent-log-empty {
color: #aaa;
text-align: center;
}
</style>
+13 -1
View File
@@ -24,11 +24,13 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
type CommandTable = Awaited<ReturnType<typeof trpc.turns.getCommandTable.query>>;
type MessageBundle = Awaited<ReturnType<typeof trpc.messages.getRecent.query>>;
type MessageContacts = Awaited<ReturnType<typeof trpc.messages.getContacts.query>>;
type FrontRecords = Awaited<ReturnType<typeof trpc.general.getFrontRecords.query>>;
type BoardAccess = Awaited<ReturnType<typeof trpc.board.getAccess.query>>;
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>[number];
const loading = ref(false);
const error = ref<string | null>(null);
const frontRecordsError = ref<string | null>(null);
const realtimeEnabled = ref(true);
const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle');
@@ -39,6 +41,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const commandTable = ref<CommandTable | null>(null);
const messages = ref<MessageBundle | null>(null);
const messageContacts = ref<MessageContacts | null>(null);
const frontRecords = ref<FrontRecords | null>(null);
const boardAccess = ref<BoardAccess | null>(null);
const reservedGeneralTurns = ref<ReservedTurnView[] | null>(null);
const reservedNationTurns = ref<ReservedTurnView[] | null>(null);
@@ -197,6 +200,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
}
loading.value = true;
error.value = null;
frontRecordsError.value = null;
try {
const context = await trpc.general.me.query();
@@ -217,7 +221,11 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
context.general.nationId > 0 && context.general.officerLevel >= 5
? trpc.turns.reserved.getNation.query({ generalId: id })
: Promise.resolve(null);
const [layout, lobby, map, commands, messageData, contacts, access, generalTurns, nationTurns] =
const frontRecordsPromise = trpc.general.getFrontRecords.query().catch((err: unknown) => {
frontRecordsError.value = resolveErrorMessage(err);
return null;
});
const [layout, lobby, map, commands, messageData, contacts, access, records, generalTurns, nationTurns] =
await Promise.all([
layoutPromise,
trpc.lobby.info.query(),
@@ -226,6 +234,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
trpc.messages.getRecent.query({ generalId: id }),
trpc.messages.getContacts.query({ generalId: id }),
trpc.board.getAccess.query(),
frontRecordsPromise,
generalTurnsPromise,
nationTurnsPromise,
]);
@@ -237,6 +246,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
messages.value = messageData;
messageContacts.value = contacts;
boardAccess.value = access;
frontRecords.value = records;
reservedGeneralTurns.value = generalTurns;
reservedNationTurns.value = nationTurns;
if (initializedMailboxGeneralId !== id) {
@@ -587,6 +597,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
return {
loading,
error,
frontRecordsError,
realtimeEnabled,
realtimeStatus,
generalContext,
@@ -600,6 +611,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
commandTable,
messages,
messageContacts,
frontRecords,
boardAccess,
reservedGeneralTurns,
reservedNationTurns,
+20 -16
View File
@@ -11,6 +11,7 @@ import CityBasicCard from '../components/main/CityBasicCard.vue';
import NationBasicCard from '../components/main/NationBasicCard.vue';
import MessagePanel from '../components/main/MessagePanel.vue';
import SelectedCityPanel from '../components/main/SelectedCityPanel.vue';
import RecentLogList from '../components/main/RecentLogList.vue';
import { useSessionStore } from '../stores/session';
import { useMainDashboardStore } from '../stores/mainDashboard';
import { trpc } from '../utils/trpc';
@@ -35,16 +36,17 @@ const tournamentStage = ref(0);
const {
loading,
error,
frontRecordsError,
realtimeEnabled,
general,
city,
nation,
lobbyInfo,
worldMap,
mapLayout,
selectedCity,
commandTable,
messages,
frontRecords,
boardAccess,
reservedGeneralTurns,
reservedNationTurns,
@@ -206,19 +208,18 @@ watch(
<div v-if="mobileTab === 'world'" class="mobile-panel">
<PanelCard title="장수 동향">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else class="placeholder">장수 동향은 실시간 스트림으로 연결 예정</div>
<div v-else-if="frontRecordsError" class="record-error" role="alert">{{ frontRecordsError }}</div>
<RecentLogList v-else :logs="frontRecords?.global" />
</PanelCard>
<PanelCard title="개인 기록">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else class="placeholder">개인 기록 영역</div>
<div v-else-if="frontRecordsError" class="record-error" role="alert">{{ frontRecordsError }}</div>
<RecentLogList v-else :logs="frontRecords?.general" />
</PanelCard>
<PanelCard title="중원 정세">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else class="placeholder">
<div>유저 {{ lobbyInfo?.userCnt ?? '-' }} / {{ lobbyInfo?.maxUserCnt ?? '-' }}</div>
<div>NPC {{ lobbyInfo?.npcCnt ?? '-' }}</div>
<div>세력 {{ lobbyInfo?.nationCnt ?? '-' }}</div>
</div>
<div v-else-if="frontRecordsError" class="record-error" role="alert">{{ frontRecordsError }}</div>
<RecentLogList v-else :logs="frontRecords?.history" />
</PanelCard>
</div>
@@ -255,12 +256,9 @@ watch(
<SelectedCityPanel :city="selectedCity" :loading="loading" />
</PanelCard>
<PanelCard title="중원 정세">
<SkeletonLines v-if="loading" :lines="3" />
<div v-else class="placeholder">
<div>유저 {{ lobbyInfo?.userCnt ?? '-' }} / {{ lobbyInfo?.maxUserCnt ?? '-' }}</div>
<div>NPC {{ lobbyInfo?.npcCnt ?? '-' }}</div>
<div>세력 {{ lobbyInfo?.nationCnt ?? '-' }}</div>
</div>
<SkeletonLines v-if="loading" :lines="4" />
<div v-else-if="frontRecordsError" class="record-error" role="alert">{{ frontRecordsError }}</div>
<RecentLogList v-else :logs="frontRecords?.history" />
</PanelCard>
</div>
@@ -284,7 +282,8 @@ watch(
</PanelCard>
<PanelCard title="장수 동향">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else class="placeholder">장수 동향은 실시간 스트림으로 연결 예정</div>
<div v-else-if="frontRecordsError" class="record-error" role="alert">{{ frontRecordsError }}</div>
<RecentLogList v-else :logs="frontRecords?.global" />
</PanelCard>
<PanelCard title="도시 정보">
<CityBasicCard :city="city" :loading="loading" />
@@ -294,7 +293,8 @@ watch(
</PanelCard>
<PanelCard title="개인 기록">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else class="placeholder">개인 기록 영역</div>
<div v-else-if="frontRecordsError" class="record-error" role="alert">{{ frontRecordsError }}</div>
<RecentLogList v-else :logs="frontRecords?.general" />
</PanelCard>
</div>
<MessagePanel
@@ -339,6 +339,10 @@ watch(
padding-bottom: 12px;
}
.record-error {
color: #ff8a80;
}
.page-title {
font-size: 1.6rem;
font-weight: 600;
+3 -13
View File
@@ -4,6 +4,7 @@ import { useMediaQuery } from '@vueuse/core';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import MapViewer from '../components/main/MapViewer.vue';
import RecentLogList from '../components/main/RecentLogList.vue';
import { trpc } from '../utils/trpc';
import { useSessionStore } from '../stores/session';
@@ -121,12 +122,7 @@ onMounted(() => {
</PanelCard>
<PanelCard title="중원 정세">
<SkeletonLines v-if="loading" :lines="3" />
<div v-else class="placeholder">
<div>유저 {{ worldTrend?.userCnt ?? '-' }} / {{ worldTrend?.maxUserCnt ?? '-' }}</div>
<div>NPC {{ worldTrend?.npcCnt ?? '-' }}</div>
<div>세력 {{ worldTrend?.nationCnt ?? '-' }}</div>
<div>상성 {{ worldTrend?.fictionMode ?? '-' }}</div>
</div>
<RecentLogList v-else :logs="mapData?.history" />
</PanelCard>
<PanelCard title="세력 일람">
<SkeletonLines v-if="loading" :lines="4" />
@@ -193,13 +189,7 @@ onMounted(() => {
</PanelCard>
<PanelCard title="중원 정세">
<SkeletonLines v-if="loading" :lines="3" />
<div v-else class="placeholder">
<div>유저 {{ worldTrend?.userCnt ?? '-' }} / {{ worldTrend?.maxUserCnt ?? '-' }}</div>
<div>NPC {{ worldTrend?.npcCnt ?? '-' }}</div>
<div>세력 {{ worldTrend?.nationCnt ?? '-' }}</div>
<div>상성 {{ worldTrend?.fictionMode ?? '-' }}</div>
<div>기타 {{ worldTrend?.otherTextInfo ?? '-' }}</div>
</div>
<RecentLogList v-else :logs="mapData?.history" />
</PanelCard>
</div>
+1 -1
View File
@@ -11,7 +11,7 @@ type MapLayout = Awaited<ReturnType<typeof trpc.public.getMapLayout.query>>;
type HistoryData = {
year: number;
month: number;
map: Awaited<ReturnType<typeof trpc.public.getCachedMap.query>>;
map: Omit<Awaited<ReturnType<typeof trpc.public.getCachedMap.query>>, 'history'>;
nations: Array<{
id: number;
name: string;
@@ -0,0 +1,26 @@
const logRegex = /<([RBGMCLSODYW]1?|1|\/)>/g;
const colorMap: Record<string, string> = {
R: 'red',
B: 'blue',
G: 'green',
M: 'magenta',
C: 'cyan',
L: 'limegreen',
S: 'skyblue',
O: 'orangered',
D: 'orangered',
Y: 'yellow',
W: 'white',
};
export const formatLog = (text: string): string =>
text.replace(logRegex, (_all, tag: string) => {
if (tag === '/') {
return '</span>';
}
const color = colorMap[tag[0] ?? ''];
const small = tag.includes('1');
const styles = [color ? `color: ${color}` : '', small ? 'font-size: 0.9em' : ''].filter(Boolean).join('; ');
return `<span style="${styles}">`;
});
@@ -8,6 +8,7 @@ import MapPreview from '../components/MapPreview.vue';
import DefaultLayout from '../layouts/DefaultLayout.vue';
import { createGameTrpc, type GameRouter } from '../utils/gameTrpc';
import { trpc } from '../utils/trpc';
import { formatLog } from '../utils/formatLog';
type GatewayOutput = inferRouterOutputs<AppRouter>;
type GameOutput = inferRouterOutputs<GameRouter>;
@@ -173,6 +174,11 @@ const handlePasswordReset = async (): Promise<void> => {
<li>유저 {{ info.userCnt }} · NPC {{ info.npcCnt }} · {{ info.nationCnt }} 경쟁중</li>
<li>{{ info.turnTerm }} 서버</li>
</ul>
<div v-if="mapData?.history?.length" class="status-history">
<!-- 레거시 색상 tag만 formatLog가 span으로 변환한다. -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-for="entry in mapData.history" :key="entry.id" v-html="formatLog(entry.text)" />
</div>
<button type="button" class="refresh-button" :disabled="statusLoading" @click="loadPublicStatus">
현황 새로고침
</button>
@@ -303,6 +309,15 @@ const handlePasswordReset = async (): Promise<void> => {
background: #000;
}
.status-history {
border-top: 1px solid #444;
padding: 8px 12px;
color: #ddd;
font-family: 'Times New Roman', serif;
font-size: 14px;
line-height: 1.35;
}
.status-card > header {
display: flex;
justify-content: space-between;