Merge branch 'main' into feature/command-argument-ui
This commit is contained in:
@@ -15,6 +15,18 @@ const zGeneralSettings = z.object({
|
||||
});
|
||||
|
||||
const zGeneralLogType = z.enum(['generalHistory', 'battleDetail', 'battleResult', 'generalAction']);
|
||||
const MAIN_RECORD_LIMIT = 15;
|
||||
|
||||
const trimRecentRecords = <Entry extends { id: number }>(entries: Entry[], cursor: number): Entry[] => {
|
||||
if (entries.length === 0) {
|
||||
return entries;
|
||||
}
|
||||
const result = [...entries];
|
||||
if (result.at(-1)?.id === cursor || result.length > MAIN_RECORD_LIMIT) {
|
||||
result.pop();
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const readNumber = (value: unknown, fallback: number): number => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
@@ -319,4 +331,54 @@ export const generalRouter = router({
|
||||
})),
|
||||
};
|
||||
}),
|
||||
getRecentRecords: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
lastGeneralRecordId: z.number().int().nonnegative().default(0),
|
||||
lastWorldHistoryId: z.number().int().nonnegative().default(0),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
const take = MAIN_RECORD_LIMIT + 1;
|
||||
const [global, general, history] = await Promise.all([
|
||||
ctx.db.logEntry.findMany({
|
||||
where: {
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.SUMMARY,
|
||||
id: { gte: input.lastGeneralRecordId },
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
take,
|
||||
select: { id: true, text: true },
|
||||
}),
|
||||
ctx.db.logEntry.findMany({
|
||||
where: {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
generalId: me.id,
|
||||
id: { gte: input.lastGeneralRecordId },
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
take,
|
||||
select: { id: true, text: true },
|
||||
}),
|
||||
ctx.db.logEntry.findMany({
|
||||
where: {
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.HISTORY,
|
||||
id: { gte: input.lastWorldHistoryId },
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
take,
|
||||
select: { id: true, text: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
global: trimRecentRecords(global, input.lastGeneralRecordId),
|
||||
general: trimRecentRecords(general, input.lastGeneralRecordId),
|
||||
history: trimRecentRecords(history, input.lastWorldHistoryId),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,50 @@ 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.getRecentRecords({
|
||||
lastGeneralRecordId: 0,
|
||||
lastWorldHistoryId: 0,
|
||||
})
|
||||
).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: 'SUMMARY', id: { gte: 0 } },
|
||||
orderBy: { id: 'desc' },
|
||||
take: 16,
|
||||
select: { id: true, text: true },
|
||||
})
|
||||
);
|
||||
expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
where: { scope: 'GENERAL', category: 'ACTION', generalId: 7, id: { gte: 0 } },
|
||||
orderBy: { id: 'desc' },
|
||||
take: 16,
|
||||
select: { id: true, text: true },
|
||||
})
|
||||
);
|
||||
expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
where: { scope: 'SYSTEM', category: 'HISTORY', id: { gte: 0 } },
|
||||
orderBy: { id: 'desc' },
|
||||
take: 16,
|
||||
select: { id: true, text: true },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('battle-center general and user permissions', () => {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import { LogCategory, LogScope } from '@sammo-ts/infra';
|
||||
|
||||
import type { DatabaseClient, GameApiContext } from '../src/context.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
profile: 'che:default',
|
||||
issuedAt: '2026-07-26T00:00:00.000Z',
|
||||
expiresAt: '2026-07-27T00:00:00.000Z',
|
||||
sessionId: 'session-owner',
|
||||
user: {
|
||||
id: 'owner',
|
||||
username: 'owner',
|
||||
displayName: 'Owner',
|
||||
roles: [],
|
||||
},
|
||||
sanctions: {},
|
||||
};
|
||||
|
||||
type LogQuery = {
|
||||
where: {
|
||||
scope: LogScope;
|
||||
category: LogCategory;
|
||||
generalId?: number;
|
||||
id: { gte: number };
|
||||
};
|
||||
orderBy: { id: 'desc' };
|
||||
take: number;
|
||||
select: { id: true; text: true };
|
||||
};
|
||||
|
||||
const buildContext = (findMany: (query: LogQuery) => Promise<Array<{ id: number; text: string }>>) =>
|
||||
({
|
||||
auth,
|
||||
db: {
|
||||
general: {
|
||||
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => ({
|
||||
id: 7,
|
||||
userId: where.userId,
|
||||
})),
|
||||
},
|
||||
logEntry: { findMany },
|
||||
} as unknown as DatabaseClient,
|
||||
}) as GameApiContext;
|
||||
|
||||
describe('general.getRecentRecords', () => {
|
||||
it('derives the general and maps all three legacy dashboard buckets', async () => {
|
||||
const findMany = vi.fn(async (query: LogQuery) => {
|
||||
if (query.where.scope === LogScope.GENERAL) {
|
||||
return [
|
||||
{ id: 31, text: '개인 최신' },
|
||||
{ id: 20, text: '개인 cursor' },
|
||||
];
|
||||
}
|
||||
if (query.where.category === LogCategory.SUMMARY) {
|
||||
return [
|
||||
{ id: 32, text: '장수 최신' },
|
||||
{ id: 20, text: '장수 cursor' },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ id: 42, text: '중원 최신' },
|
||||
{ id: 40, text: '중원 cursor' },
|
||||
];
|
||||
});
|
||||
const caller = appRouter.createCaller(buildContext(findMany));
|
||||
|
||||
const result = await caller.general.getRecentRecords({
|
||||
lastGeneralRecordId: 20,
|
||||
lastWorldHistoryId: 40,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
global: [{ id: 32, text: '장수 최신' }],
|
||||
general: [{ id: 31, text: '개인 최신' }],
|
||||
history: [{ id: 42, text: '중원 최신' }],
|
||||
});
|
||||
expect(findMany).toHaveBeenCalledTimes(3);
|
||||
expect(findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
generalId: 7,
|
||||
id: { gte: 20 },
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
take: 16,
|
||||
select: { id: true, text: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('caps an initial bucket at the legacy 15-row limit', async () => {
|
||||
const rows = Array.from({ length: 16 }, (_, index) => ({
|
||||
id: 100 - index,
|
||||
text: `기록 ${index}`,
|
||||
}));
|
||||
const caller = appRouter.createCaller(buildContext(async () => rows));
|
||||
|
||||
const result = await caller.general.getRecentRecords({
|
||||
lastGeneralRecordId: 0,
|
||||
lastWorldHistoryId: 0,
|
||||
});
|
||||
|
||||
expect(result.global).toHaveLength(15);
|
||||
expect(result.general).toHaveLength(15);
|
||||
expect(result.history).toHaveLength(15);
|
||||
expect(result.global.at(-1)?.id).toBe(86);
|
||||
});
|
||||
|
||||
it('rejects an authenticated user without an in-game general', async () => {
|
||||
const context = buildContext(async () => []);
|
||||
context.db = {
|
||||
general: {
|
||||
findFirst: vi.fn(async () => null),
|
||||
},
|
||||
logEntry: {
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
} as unknown as DatabaseClient;
|
||||
const caller = appRouter.createCaller(context);
|
||||
|
||||
await expect(
|
||||
caller.general.getRecentRecords({
|
||||
lastGeneralRecordId: 0,
|
||||
lastWorldHistoryId: 0,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="record-panel">
|
||||
<h2 class="record-title">{{ title }}</h2>
|
||||
<div class="record-body">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.record-panel {
|
||||
min-width: 0;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
.record-title {
|
||||
box-sizing: border-box;
|
||||
height: 23px;
|
||||
margin: 0;
|
||||
border-top: 1px solid gray;
|
||||
border-bottom: 1px solid gray;
|
||||
background-color: #14241b;
|
||||
background-image: url('/image/game/back_green.jpg');
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.record-body {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -26,9 +26,11 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
type MessageContacts = Awaited<ReturnType<typeof trpc.messages.getContacts.query>>;
|
||||
type BoardAccess = Awaited<ReturnType<typeof trpc.board.getAccess.query>>;
|
||||
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>[number];
|
||||
type RecentRecord = Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>['global'][number];
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const recordsError = ref<string | null>(null);
|
||||
const realtimeEnabled = ref(true);
|
||||
const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle');
|
||||
|
||||
@@ -42,6 +44,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
const boardAccess = ref<BoardAccess | null>(null);
|
||||
const reservedGeneralTurns = ref<ReservedTurnView[] | null>(null);
|
||||
const reservedNationTurns = ref<ReservedTurnView[] | null>(null);
|
||||
const globalRecords = ref<RecentRecord[]>([]);
|
||||
const generalRecords = ref<RecentRecord[]>([]);
|
||||
const worldHistory = ref<RecentRecord[]>([]);
|
||||
let lastGeneralRecordId = 0;
|
||||
let lastWorldHistoryId = 0;
|
||||
let recordGeneralId: number | null = null;
|
||||
|
||||
const messageDraftText = ref('');
|
||||
const targetMailbox = ref<number>(MESSAGE_MAILBOX_PUBLIC);
|
||||
@@ -191,12 +199,30 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const mergeRecentRecords = (current: RecentRecord[], incoming: RecentRecord[]): RecentRecord[] => {
|
||||
const merged = new Map(current.map((entry) => [entry.id, entry]));
|
||||
for (const entry of incoming) {
|
||||
merged.set(entry.id, entry);
|
||||
}
|
||||
return [...merged.values()].sort((left, right) => right.id - left.id).slice(0, 15);
|
||||
};
|
||||
|
||||
const resetRecentRecords = (id: number | null) => {
|
||||
globalRecords.value = [];
|
||||
generalRecords.value = [];
|
||||
worldHistory.value = [];
|
||||
lastGeneralRecordId = 0;
|
||||
lastWorldHistoryId = 0;
|
||||
recordGeneralId = id;
|
||||
};
|
||||
|
||||
const loadMainData = async () => {
|
||||
if (loading.value) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
recordsError.value = null;
|
||||
|
||||
try {
|
||||
const context = await trpc.general.me.query();
|
||||
@@ -206,18 +232,31 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
reservedGeneralTurns.value = null;
|
||||
reservedNationTurns.value = null;
|
||||
boardAccess.value = null;
|
||||
resetRecentRecords(null);
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const id = context.general.id;
|
||||
if (recordGeneralId !== id) {
|
||||
resetRecentRecords(id);
|
||||
}
|
||||
const layoutPromise = mapLayout.value ? Promise.resolve(mapLayout.value) : trpc.world.getMapLayout.query();
|
||||
const generalTurnsPromise = trpc.turns.reserved.getGeneral.query({ generalId: id });
|
||||
const nationTurnsPromise =
|
||||
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 recordsPromise = trpc.general.getRecentRecords
|
||||
.query({
|
||||
lastGeneralRecordId,
|
||||
lastWorldHistoryId,
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
recordsError.value = resolveErrorMessage(err);
|
||||
return null;
|
||||
});
|
||||
const [layout, lobby, map, commands, messageData, contacts, access, generalTurns, nationTurns, records] =
|
||||
await Promise.all([
|
||||
layoutPromise,
|
||||
trpc.lobby.info.query(),
|
||||
@@ -228,6 +267,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
trpc.board.getAccess.query(),
|
||||
generalTurnsPromise,
|
||||
nationTurnsPromise,
|
||||
recordsPromise,
|
||||
]);
|
||||
|
||||
mapLayout.value = layout;
|
||||
@@ -239,6 +279,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
boardAccess.value = access;
|
||||
reservedGeneralTurns.value = generalTurns;
|
||||
reservedNationTurns.value = nationTurns;
|
||||
if (records) {
|
||||
globalRecords.value = mergeRecentRecords(globalRecords.value, records.global);
|
||||
generalRecords.value = mergeRecentRecords(generalRecords.value, records.general);
|
||||
worldHistory.value = mergeRecentRecords(worldHistory.value, records.history);
|
||||
lastGeneralRecordId = Math.max(
|
||||
lastGeneralRecordId,
|
||||
records.global[0]?.id ?? 0,
|
||||
records.general[0]?.id ?? 0
|
||||
);
|
||||
lastWorldHistoryId = Math.max(lastWorldHistoryId, records.history[0]?.id ?? 0);
|
||||
}
|
||||
if (initializedMailboxGeneralId !== id) {
|
||||
targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId;
|
||||
initializedMailboxGeneralId = id;
|
||||
@@ -587,6 +638,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
recordsError,
|
||||
realtimeEnabled,
|
||||
realtimeStatus,
|
||||
generalContext,
|
||||
@@ -603,6 +655,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
boardAccess,
|
||||
reservedGeneralTurns,
|
||||
reservedNationTurns,
|
||||
globalRecords,
|
||||
generalRecords,
|
||||
worldHistory,
|
||||
messageDraftText,
|
||||
targetMailbox,
|
||||
mailboxGroups,
|
||||
|
||||
@@ -11,13 +11,15 @@ 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 RecordPanel from '../components/main/RecordPanel.vue';
|
||||
import { formatLog } from '../utils/formatLog';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
import { useMainDashboardStore } from '../stores/mainDashboard';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
const session = useSessionStore();
|
||||
const dashboard = useMainDashboardStore();
|
||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
||||
const isMobile = useMediaQuery('(max-width: 991px)');
|
||||
|
||||
const mobileTabs = [
|
||||
{ key: 'map', label: '지도' },
|
||||
@@ -35,11 +37,11 @@ const tournamentStage = ref(0);
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
recordsError,
|
||||
realtimeEnabled,
|
||||
general,
|
||||
city,
|
||||
nation,
|
||||
lobbyInfo,
|
||||
worldMap,
|
||||
mapLayout,
|
||||
selectedCity,
|
||||
@@ -48,6 +50,9 @@ const {
|
||||
boardAccess,
|
||||
reservedGeneralTurns,
|
||||
reservedNationTurns,
|
||||
globalRecords,
|
||||
generalRecords,
|
||||
worldHistory,
|
||||
messageDraftText,
|
||||
targetMailbox,
|
||||
mailboxGroups,
|
||||
@@ -203,23 +208,49 @@ watch(
|
||||
</PanelCard>
|
||||
</div>
|
||||
|
||||
<div v-if="mobileTab === 'world'" class="mobile-panel">
|
||||
<PanelCard title="장수 동향">
|
||||
<div v-if="mobileTab === 'world'" class="mobile-panel record-zone-mobile">
|
||||
<RecordPanel title="장수 동향">
|
||||
<SkeletonLines v-if="loading" :lines="4" />
|
||||
<div v-else class="placeholder">장수 동향은 실시간 스트림으로 연결 예정</div>
|
||||
</PanelCard>
|
||||
<PanelCard title="개인 기록">
|
||||
<SkeletonLines v-if="loading" :lines="4" />
|
||||
<div v-else class="placeholder">개인 기록 영역</div>
|
||||
</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 v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
||||
<div v-else class="record-list" data-record-bucket="global">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div
|
||||
v-for="entry in globalRecords"
|
||||
:key="entry.id"
|
||||
class="record-line"
|
||||
v-html="formatLog(entry.text)"
|
||||
/>
|
||||
<div v-if="globalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
</RecordPanel>
|
||||
<RecordPanel title="개인 기록">
|
||||
<SkeletonLines v-if="loading" :lines="4" />
|
||||
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
||||
<div v-else class="record-list" data-record-bucket="general">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div
|
||||
v-for="entry in generalRecords"
|
||||
:key="entry.id"
|
||||
class="record-line"
|
||||
v-html="formatLog(entry.text)"
|
||||
/>
|
||||
<div v-if="generalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
|
||||
</div>
|
||||
</RecordPanel>
|
||||
<RecordPanel title="중원 정세">
|
||||
<SkeletonLines v-if="loading" :lines="4" />
|
||||
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
||||
<div v-else class="record-list" data-record-bucket="history">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div
|
||||
v-for="entry in worldHistory"
|
||||
:key="entry.id"
|
||||
class="record-line"
|
||||
v-html="formatLog(entry.text)"
|
||||
/>
|
||||
<div v-if="worldHistory.length === 0" class="record-empty">기록이 없습니다.</div>
|
||||
</div>
|
||||
</RecordPanel>
|
||||
</div>
|
||||
|
||||
<div v-if="mobileTab === 'messages'" class="mobile-panel">
|
||||
@@ -254,14 +285,6 @@ watch(
|
||||
<PanelCard title="선택 도시">
|
||||
<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>
|
||||
</PanelCard>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
@@ -282,21 +305,57 @@ watch(
|
||||
<PanelCard title="장수 스탯">
|
||||
<GeneralBasicCard :general="general" :loading="loading" />
|
||||
</PanelCard>
|
||||
<PanelCard title="장수 동향">
|
||||
<SkeletonLines v-if="loading" :lines="4" />
|
||||
<div v-else class="placeholder">장수 동향은 실시간 스트림으로 연결 예정</div>
|
||||
</PanelCard>
|
||||
<PanelCard title="도시 정보">
|
||||
<CityBasicCard :city="city" :loading="loading" />
|
||||
</PanelCard>
|
||||
<PanelCard title="국가 정보">
|
||||
<NationBasicCard :nation="nation" :loading="loading" />
|
||||
</PanelCard>
|
||||
<PanelCard title="개인 기록">
|
||||
<SkeletonLines v-if="loading" :lines="4" />
|
||||
<div v-else class="placeholder">개인 기록 영역</div>
|
||||
</PanelCard>
|
||||
</div>
|
||||
<section class="record-zone">
|
||||
<RecordPanel title="장수 동향">
|
||||
<SkeletonLines v-if="loading" :lines="4" />
|
||||
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
||||
<div v-else class="record-list" data-record-bucket="global">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div
|
||||
v-for="entry in globalRecords"
|
||||
:key="entry.id"
|
||||
class="record-line"
|
||||
v-html="formatLog(entry.text)"
|
||||
/>
|
||||
<div v-if="globalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
|
||||
</div>
|
||||
</RecordPanel>
|
||||
<RecordPanel title="개인 기록">
|
||||
<SkeletonLines v-if="loading" :lines="4" />
|
||||
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
||||
<div v-else class="record-list" data-record-bucket="general">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div
|
||||
v-for="entry in generalRecords"
|
||||
:key="entry.id"
|
||||
class="record-line"
|
||||
v-html="formatLog(entry.text)"
|
||||
/>
|
||||
<div v-if="generalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
|
||||
</div>
|
||||
</RecordPanel>
|
||||
<RecordPanel class="world-history-panel" title="중원 정세">
|
||||
<SkeletonLines v-if="loading" :lines="4" />
|
||||
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
||||
<div v-else class="record-list" data-record-bucket="history">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div
|
||||
v-for="entry in worldHistory"
|
||||
:key="entry.id"
|
||||
class="record-line"
|
||||
v-html="formatLog(entry.text)"
|
||||
/>
|
||||
<div v-if="worldHistory.length === 0" class="record-empty">기록이 없습니다.</div>
|
||||
</div>
|
||||
</RecordPanel>
|
||||
</section>
|
||||
<MessagePanel
|
||||
class="desktop-message-panel"
|
||||
:messages="messages"
|
||||
@@ -415,6 +474,42 @@ button {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.record-zone {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
width: calc(100% + 48px);
|
||||
margin-left: -24px;
|
||||
}
|
||||
|
||||
.world-history-panel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.record-list {
|
||||
min-height: 21px;
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
.record-line {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.record-empty {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.record-error {
|
||||
color: #ff8a80;
|
||||
}
|
||||
|
||||
.record-zone-mobile {
|
||||
width: 100vw;
|
||||
margin-left: -24px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.mobile-message-panel {
|
||||
width: 100vw;
|
||||
min-width: 0;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user