feat: implement public map features including caching, API integration, and UI components
This commit is contained in:
@@ -43,6 +43,7 @@ type GeneralCityRow = {
|
|||||||
|
|
||||||
const MAP_VERSION = 0 as const;
|
const MAP_VERSION = 0 as const;
|
||||||
const BASE_MAP_TTL_SECONDS = 30;
|
const BASE_MAP_TTL_SECONDS = 30;
|
||||||
|
const PUBLIC_MAP_TTL_SECONDS = 600;
|
||||||
|
|
||||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
@@ -94,11 +95,21 @@ const resolveSpyList = (meta: Record<string, unknown>): Record<number, number> =
|
|||||||
return {};
|
return {};
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildBaseMapCacheKey = (ctx: GameApiContext): string =>
|
const buildBaseMapCacheKey = (ctx: GameApiContext, scope: 'base' | 'public' = 'base'): string =>
|
||||||
`sammo:map:base:${ctx.profile.id}:${ctx.profile.scenario}`;
|
`sammo:map:${scope}:${ctx.profile.id}:${ctx.profile.scenario}`;
|
||||||
|
|
||||||
|
const loadBaseMap = async (
|
||||||
|
ctx: GameApiContext,
|
||||||
|
options?: {
|
||||||
|
useCache?: boolean;
|
||||||
|
cacheKey?: string;
|
||||||
|
ttlSeconds?: number;
|
||||||
|
}
|
||||||
|
): Promise<BaseMapResult | null> => {
|
||||||
|
const useCache = options?.useCache ?? true;
|
||||||
|
const cacheKey = options?.cacheKey ?? buildBaseMapCacheKey(ctx);
|
||||||
|
const ttlSeconds = options?.ttlSeconds ?? BASE_MAP_TTL_SECONDS;
|
||||||
|
|
||||||
const loadBaseMap = async (ctx: GameApiContext, useCache: boolean): Promise<BaseMapResult | null> => {
|
|
||||||
const cacheKey = buildBaseMapCacheKey(ctx);
|
|
||||||
if (useCache) {
|
if (useCache) {
|
||||||
const cached = await ctx.redis.get(cacheKey);
|
const cached = await ctx.redis.get(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -161,13 +172,21 @@ const loadBaseMap = async (ctx: GameApiContext, useCache: boolean): Promise<Base
|
|||||||
|
|
||||||
if (useCache) {
|
if (useCache) {
|
||||||
await ctx.redis.set(cacheKey, JSON.stringify(baseMap), {
|
await ctx.redis.set(cacheKey, JSON.stringify(baseMap), {
|
||||||
EX: BASE_MAP_TTL_SECONDS,
|
EX: ttlSeconds,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return baseMap;
|
return baseMap;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const loadPublicMap = async (ctx: GameApiContext, useCache = true): Promise<BaseMapResult | null> => {
|
||||||
|
return loadBaseMap(ctx, {
|
||||||
|
useCache,
|
||||||
|
cacheKey: buildBaseMapCacheKey(ctx, 'public'),
|
||||||
|
ttlSeconds: PUBLIC_MAP_TTL_SECONDS,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const loadWorldMap = async (
|
export const loadWorldMap = async (
|
||||||
ctx: GameApiContext,
|
ctx: GameApiContext,
|
||||||
options: {
|
options: {
|
||||||
@@ -177,7 +196,7 @@ export const loadWorldMap = async (
|
|||||||
useCache?: boolean;
|
useCache?: boolean;
|
||||||
}
|
}
|
||||||
): Promise<WorldMapResult | null> => {
|
): Promise<WorldMapResult | null> => {
|
||||||
const baseMap = await loadBaseMap(ctx, options.useCache ?? true);
|
const baseMap = await loadBaseMap(ctx, { useCache: options.useCache ?? true });
|
||||||
if (!baseMap) {
|
if (!baseMap) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { joinRouter } from './router/join/index.js';
|
|||||||
import { lobbyRouter } from './router/lobby/index.js';
|
import { lobbyRouter } from './router/lobby/index.js';
|
||||||
import { messagesRouter } from './router/messages/index.js';
|
import { messagesRouter } from './router/messages/index.js';
|
||||||
import { nationRouter } from './router/nation/index.js';
|
import { nationRouter } from './router/nation/index.js';
|
||||||
|
import { publicRouter } from './router/public/index.js';
|
||||||
import { troopRouter } from './router/troop/index.js';
|
import { troopRouter } from './router/troop/index.js';
|
||||||
import { turnDaemonRouter } from './router/turnDaemon/index.js';
|
import { turnDaemonRouter } from './router/turnDaemon/index.js';
|
||||||
import { turnsRouter } from './router/turns/index.js';
|
import { turnsRouter } from './router/turns/index.js';
|
||||||
@@ -15,6 +16,7 @@ import { worldRouter } from './router/world/index.js';
|
|||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
health: healthRouter,
|
health: healthRouter,
|
||||||
lobby: lobbyRouter,
|
lobby: lobbyRouter,
|
||||||
|
public: publicRouter,
|
||||||
join: joinRouter,
|
join: joinRouter,
|
||||||
battle: battleRouter,
|
battle: battleRouter,
|
||||||
world: worldRouter,
|
world: worldRouter,
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
|
import type { GameApiContext } from '../../context.js';
|
||||||
|
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
|
||||||
|
import { loadMapLayout } from '../../maps/mapLayout.js';
|
||||||
|
import { loadPublicMap } from '../../maps/worldMap.js';
|
||||||
|
import { procedure, router } from '../../trpc.js';
|
||||||
|
|
||||||
|
type WorldTrendSnapshot = {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
userCnt: number;
|
||||||
|
maxUserCnt: number;
|
||||||
|
npcCnt: number;
|
||||||
|
nationCnt: number;
|
||||||
|
turnTerm: number;
|
||||||
|
fictionMode: string;
|
||||||
|
starttime: string;
|
||||||
|
opentime: string;
|
||||||
|
turntime: string;
|
||||||
|
otherTextInfo: string;
|
||||||
|
isUnited: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NationSummary = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
level: number;
|
||||||
|
capitalCityId: number;
|
||||||
|
generalCount: number;
|
||||||
|
cityCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NationCountRow = {
|
||||||
|
nationId: number;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PUBLIC_CACHE_TTL_SECONDS = 600;
|
||||||
|
|
||||||
|
const buildPublicCacheKey = (ctx: GameApiContext, key: string): string =>
|
||||||
|
`sammo:public:${key}:${ctx.profile.id}:${ctx.profile.scenario}`;
|
||||||
|
|
||||||
|
const loadWorldTrendSnapshot = async (ctx: GameApiContext): Promise<WorldTrendSnapshot> => {
|
||||||
|
const rawWorldState = await ctx.db.worldState.findFirst();
|
||||||
|
if (!rawWorldState) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: 'World state not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = zWorldStateConfig.parse(rawWorldState.config);
|
||||||
|
const meta = zWorldStateMeta.parse(rawWorldState.meta);
|
||||||
|
|
||||||
|
const [userCnt, npcCnt, nationCnt] = await Promise.all([
|
||||||
|
ctx.db.general.count({ where: { npcState: 0 } }),
|
||||||
|
ctx.db.general.count({ where: { npcState: { gt: 0 } } }),
|
||||||
|
ctx.db.nation.count({ where: { level: { gt: 0 } } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
year: rawWorldState.currentYear,
|
||||||
|
month: rawWorldState.currentMonth,
|
||||||
|
userCnt,
|
||||||
|
maxUserCnt: config.maxUserCnt ?? 500,
|
||||||
|
npcCnt,
|
||||||
|
nationCnt,
|
||||||
|
turnTerm: rawWorldState.tickSeconds / 60,
|
||||||
|
fictionMode: config.fictionMode ?? '사실',
|
||||||
|
starttime: meta.starttime ?? '',
|
||||||
|
opentime: meta.opentime ?? '',
|
||||||
|
turntime: meta.turntime ?? '',
|
||||||
|
otherTextInfo: meta.otherTextInfo ?? '',
|
||||||
|
isUnited: meta.isUnited ?? 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCachedWorldTrend = async (ctx: GameApiContext): Promise<WorldTrendSnapshot> => {
|
||||||
|
const cacheKey = buildPublicCacheKey(ctx, 'worldTrend');
|
||||||
|
const cached = await ctx.redis.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(cached) as WorldTrendSnapshot;
|
||||||
|
} catch {
|
||||||
|
// Ignore cache parse errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = await loadWorldTrendSnapshot(ctx);
|
||||||
|
await ctx.redis.set(cacheKey, JSON.stringify(snapshot), { EX: PUBLIC_CACHE_TTL_SECONDS });
|
||||||
|
return snapshot;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCachedNationList = async (ctx: GameApiContext): Promise<NationSummary[]> => {
|
||||||
|
const cacheKey = buildPublicCacheKey(ctx, 'nationList');
|
||||||
|
const cached = await ctx.redis.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(cached) as NationSummary[];
|
||||||
|
} catch {
|
||||||
|
// Ignore cache parse errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [nations, generalCounts, cityCounts] = await Promise.all([
|
||||||
|
ctx.db.nation.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
color: true,
|
||||||
|
level: true,
|
||||||
|
capitalCityId: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ level: 'desc' }, { id: 'asc' }],
|
||||||
|
}),
|
||||||
|
ctx.db.$queryRaw<NationCountRow[]>`
|
||||||
|
SELECT nation_id as "nationId", COUNT(*)::int as "count"
|
||||||
|
FROM general
|
||||||
|
GROUP BY nation_id
|
||||||
|
`,
|
||||||
|
ctx.db.$queryRaw<NationCountRow[]>`
|
||||||
|
SELECT nation_id as "nationId", COUNT(*)::int as "count"
|
||||||
|
FROM city
|
||||||
|
GROUP BY nation_id
|
||||||
|
`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const generalCountMap = new Map<number, number>();
|
||||||
|
for (const row of generalCounts) {
|
||||||
|
generalCountMap.set(row.nationId, row.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cityCountMap = new Map<number, number>();
|
||||||
|
for (const row of cityCounts) {
|
||||||
|
cityCountMap.set(row.nationId, row.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = nations.map((nation) => ({
|
||||||
|
id: nation.id,
|
||||||
|
name: nation.name,
|
||||||
|
color: nation.color,
|
||||||
|
level: nation.level,
|
||||||
|
capitalCityId: nation.capitalCityId ?? 0,
|
||||||
|
generalCount: generalCountMap.get(nation.id) ?? 0,
|
||||||
|
cityCount: cityCountMap.get(nation.id) ?? 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await ctx.redis.set(cacheKey, JSON.stringify(summary), { EX: PUBLIC_CACHE_TTL_SECONDS });
|
||||||
|
return summary;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const publicRouter = router({
|
||||||
|
getMapLayout: procedure.query(async ({ ctx }) => {
|
||||||
|
return loadMapLayout(ctx.profile.scenario);
|
||||||
|
}),
|
||||||
|
getCachedMap: procedure.query(async ({ ctx }) => {
|
||||||
|
const map = await loadPublicMap(ctx, true);
|
||||||
|
if (!map) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: 'World state is not initialized.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}),
|
||||||
|
getWorldTrend: procedure.query(async ({ ctx }) => {
|
||||||
|
return loadCachedWorldTrend(ctx);
|
||||||
|
}),
|
||||||
|
getNationList: procedure.query(async ({ ctx }) => {
|
||||||
|
return loadCachedNationList(ctx);
|
||||||
|
}),
|
||||||
|
getGeneralList: procedure.query(async ({ ctx }) => {
|
||||||
|
const [generals, nations] = await Promise.all([
|
||||||
|
ctx.db.general.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
npcState: true,
|
||||||
|
nationId: true,
|
||||||
|
leadership: true,
|
||||||
|
strength: true,
|
||||||
|
intel: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ctx.db.nation.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const nationMap = new Map<number, string>();
|
||||||
|
for (const nation of nations) {
|
||||||
|
nationMap.set(nation.id, nation.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return generals.map((general) => ({
|
||||||
|
id: general.id,
|
||||||
|
name: general.name,
|
||||||
|
npcState: general.npcState,
|
||||||
|
nationId: general.nationId,
|
||||||
|
nationName: nationMap.get(general.nationId) ?? '무주',
|
||||||
|
leadership: general.leadership,
|
||||||
|
strength: general.strength,
|
||||||
|
intelligence: general.intel,
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,10 +1,415 @@
|
|||||||
<script setup lang="ts"></script>
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
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 { trpc } from '../utils/trpc';
|
||||||
|
import { useSessionStore } from '../stores/session';
|
||||||
|
|
||||||
|
type MapData = Awaited<ReturnType<typeof trpc.public.getCachedMap.query>>;
|
||||||
|
type MapLayout = Awaited<ReturnType<typeof trpc.public.getMapLayout.query>>;
|
||||||
|
type WorldTrend = Awaited<ReturnType<typeof trpc.public.getWorldTrend.query>>;
|
||||||
|
type NationSummary = Awaited<ReturnType<typeof trpc.public.getNationList.query>>[number];
|
||||||
|
type GeneralSummary = Awaited<ReturnType<typeof trpc.public.getGeneralList.query>>[number];
|
||||||
|
|
||||||
|
const session = useSessionStore();
|
||||||
|
const isMobile = useMediaQuery('(max-width: 1024px)');
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
const mapData = ref<MapData | null>(null);
|
||||||
|
const mapLayout = ref<MapLayout | null>(null);
|
||||||
|
const worldTrend = ref<WorldTrend | null>(null);
|
||||||
|
const nationList = ref<NationSummary[]>([]);
|
||||||
|
const generalList = ref<GeneralSummary[]>([]);
|
||||||
|
|
||||||
|
const generalFilter = ref('');
|
||||||
|
|
||||||
|
const filteredGenerals = computed(() => {
|
||||||
|
const keyword = generalFilter.value.trim().toLowerCase();
|
||||||
|
if (!keyword) {
|
||||||
|
return generalList.value;
|
||||||
|
}
|
||||||
|
return generalList.value.filter((general) => {
|
||||||
|
return (
|
||||||
|
general.name.toLowerCase().includes(keyword) ||
|
||||||
|
general.nationName.toLowerCase().includes(keyword)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolveErrorMessage = (value: unknown): string => {
|
||||||
|
if (value instanceof Error) {
|
||||||
|
return value.message;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return 'unknown_error';
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPublicData = async () => {
|
||||||
|
if (loading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const layoutPromise = mapLayout.value ? Promise.resolve(mapLayout.value) : trpc.public.getMapLayout.query();
|
||||||
|
const [layout, map, trend, nations, generals] = await Promise.all([
|
||||||
|
layoutPromise,
|
||||||
|
trpc.public.getCachedMap.query(),
|
||||||
|
trpc.public.getWorldTrend.query(),
|
||||||
|
trpc.public.getNationList.query(),
|
||||||
|
trpc.public.getGeneralList.query(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
mapLayout.value = layout;
|
||||||
|
mapData.value = map;
|
||||||
|
worldTrend.value = trend;
|
||||||
|
nationList.value = nations;
|
||||||
|
generalList.value = generals;
|
||||||
|
} catch (err) {
|
||||||
|
error.value = resolveErrorMessage(err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshPublicData = async () => {
|
||||||
|
await loadPublicData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const trendSummary = computed(() => {
|
||||||
|
if (!worldTrend.value) {
|
||||||
|
return '동향 정보를 불러오는 중';
|
||||||
|
}
|
||||||
|
return `${worldTrend.value.year}년 ${worldTrend.value.month}월 · ${worldTrend.value.turnTerm}분 턴`;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadPublicData();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="min-h-screen px-6 py-8">
|
<main class="public-page">
|
||||||
<h1 class="text-2xl font-semibold">Public Lobby</h1>
|
<header class="page-header">
|
||||||
<p class="mt-2 text-sm text-amber-200/80">
|
<div>
|
||||||
Public cached map and world trend entry point.
|
<h1 class="page-title">공개 동향</h1>
|
||||||
</p>
|
<p class="page-subtitle">{{ trendSummary }}</p>
|
||||||
|
<p class="page-hint">지도/정세는 10분 캐시된 정보로 제공됩니다.</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<RouterLink v-if="!session.isAuthed" class="ghost" to="/login">로그인</RouterLink>
|
||||||
|
<RouterLink v-else-if="session.needsGeneral" class="ghost" to="/join">장수 생성/빙의</RouterLink>
|
||||||
|
<RouterLink v-else class="ghost" to="/">메인으로</RouterLink>
|
||||||
|
<button class="ghost" @click="refreshPublicData">새로고침</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
|
|
||||||
|
<section v-if="isMobile" class="layout-mobile">
|
||||||
|
<PanelCard title="캐시 지도">
|
||||||
|
<MapViewer :map-data="mapData" :map-layout="mapLayout" :loading="loading" />
|
||||||
|
</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>
|
||||||
|
</PanelCard>
|
||||||
|
<PanelCard title="세력 일람">
|
||||||
|
<SkeletonLines v-if="loading" :lines="4" />
|
||||||
|
<div v-else class="table-scroll">
|
||||||
|
<table class="public-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>세력</th>
|
||||||
|
<th>장수</th>
|
||||||
|
<th>도시</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="nation in nationList" :key="nation.id">
|
||||||
|
<td>
|
||||||
|
<span class="nation-swatch" :style="{ backgroundColor: nation.color }" />
|
||||||
|
{{ nation.name }}
|
||||||
|
</td>
|
||||||
|
<td>{{ nation.generalCount }}</td>
|
||||||
|
<td>{{ nation.cityCount }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</PanelCard>
|
||||||
|
<PanelCard title="장수 일람">
|
||||||
|
<div class="list-head">
|
||||||
|
<input v-model="generalFilter" class="filter-input" placeholder="장수/국가 검색" />
|
||||||
|
<div class="list-count">총 {{ filteredGenerals.length }}명</div>
|
||||||
|
</div>
|
||||||
|
<SkeletonLines v-if="loading" :lines="5" />
|
||||||
|
<div v-else class="table-scroll">
|
||||||
|
<table class="public-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>이름</th>
|
||||||
|
<th>국가</th>
|
||||||
|
<th>통솔</th>
|
||||||
|
<th>무력</th>
|
||||||
|
<th>지력</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="general in filteredGenerals" :key="general.id">
|
||||||
|
<td>
|
||||||
|
<span v-if="general.npcState > 0" class="npc-tag">NPC</span>
|
||||||
|
{{ general.name }}
|
||||||
|
</td>
|
||||||
|
<td>{{ general.nationName }}</td>
|
||||||
|
<td>{{ general.leadership }}</td>
|
||||||
|
<td>{{ general.strength }}</td>
|
||||||
|
<td>{{ general.intelligence }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</PanelCard>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else class="layout-desktop">
|
||||||
|
<div class="stack">
|
||||||
|
<PanelCard title="캐시 지도" subtitle="10분 캐시된 공개 지도">
|
||||||
|
<MapViewer :map-data="mapData" :map-layout="mapLayout" :loading="loading" />
|
||||||
|
</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>
|
||||||
|
</PanelCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stack">
|
||||||
|
<PanelCard title="세력 일람">
|
||||||
|
<SkeletonLines v-if="loading" :lines="4" />
|
||||||
|
<div v-else class="table-scroll">
|
||||||
|
<table class="public-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>세력</th>
|
||||||
|
<th>장수</th>
|
||||||
|
<th>도시</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="nation in nationList" :key="nation.id">
|
||||||
|
<td>
|
||||||
|
<span class="nation-swatch" :style="{ backgroundColor: nation.color }" />
|
||||||
|
{{ nation.name }}
|
||||||
|
</td>
|
||||||
|
<td>{{ nation.generalCount }}</td>
|
||||||
|
<td>{{ nation.cityCount }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</PanelCard>
|
||||||
|
<PanelCard title="장수 일람">
|
||||||
|
<div class="list-head">
|
||||||
|
<input v-model="generalFilter" class="filter-input" placeholder="장수/국가 검색" />
|
||||||
|
<div class="list-count">총 {{ filteredGenerals.length }}명</div>
|
||||||
|
</div>
|
||||||
|
<SkeletonLines v-if="loading" :lines="6" />
|
||||||
|
<div v-else class="table-scroll">
|
||||||
|
<table class="public-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>이름</th>
|
||||||
|
<th>국가</th>
|
||||||
|
<th>통솔</th>
|
||||||
|
<th>무력</th>
|
||||||
|
<th>지력</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="general in filteredGenerals" :key="general.id">
|
||||||
|
<td>
|
||||||
|
<span v-if="general.npcState > 0" class="npc-tag">NPC</span>
|
||||||
|
{{ general.name }}
|
||||||
|
</td>
|
||||||
|
<td>{{ general.nationName }}</td>
|
||||||
|
<td>{{ general.leadership }}</td>
|
||||||
|
<td>{{ general.strength }}</td>
|
||||||
|
<td>{{ general.intelligence }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</PanelCard>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.public-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(232, 221, 196, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(232, 221, 196, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost {
|
||||||
|
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
background: rgba(16, 16, 16, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #f5b7b1;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-desktop {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(320px, 1.3fr) minmax(320px, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-mobile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(232, 221, 196, 0.7);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-table th,
|
||||||
|
.public-table td {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid rgba(201, 164, 90, 0.2);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-table thead th {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgba(232, 221, 196, 0.6);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nation-swatch {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
margin-right: 6px;
|
||||||
|
border-radius: 2px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.npc-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
padding: 2px 4px;
|
||||||
|
margin-right: 6px;
|
||||||
|
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||||
|
color: rgba(232, 221, 196, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||||
|
background: rgba(16, 16, 16, 0.8);
|
||||||
|
color: rgba(232, 221, 196, 0.9);
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-count {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgba(232, 221, 196, 0.6);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
interface MapSummary {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
cityList: [number, number, number, number, number, number][];
|
||||||
|
nationList: [number, string, string, number][];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapLayoutCity {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
region: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
path: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapLayout {
|
||||||
|
mapName: string;
|
||||||
|
cityList: MapLayoutCity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CityDot {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
color: string;
|
||||||
|
isCapital: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
mapData: MapSummary;
|
||||||
|
mapLayout: MapLayout;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const BASE_MAP_WIDTH = 700;
|
||||||
|
const BASE_MAP_HEIGHT = 500;
|
||||||
|
const MAP_SCALE = 0.45;
|
||||||
|
|
||||||
|
const mapWidth = computed(() => `${BASE_MAP_WIDTH * MAP_SCALE}px`);
|
||||||
|
const mapHeight = computed(() => `${BASE_MAP_HEIGHT * MAP_SCALE}px`);
|
||||||
|
|
||||||
|
const nationById = computed(() => {
|
||||||
|
const map = new Map<number, { name: string; color: string; capitalCityId: number }>();
|
||||||
|
for (const nation of props.mapData.nationList) {
|
||||||
|
const [id, name, color, capitalCityId] = nation;
|
||||||
|
map.set(id, {
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
capitalCityId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
const dynamicCityById = computed(() => {
|
||||||
|
const map = new Map<number, [number, number, number, number, number]>();
|
||||||
|
for (const entry of props.mapData.cityList) {
|
||||||
|
const [id, level, state, nationId, region, supplyFlag] = entry;
|
||||||
|
map.set(id, [level, state, nationId, region, supplyFlag]);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
const cityDots = computed<CityDot[]>(() => {
|
||||||
|
return props.mapLayout.cityList.map((layoutCity) => {
|
||||||
|
const dynamic = dynamicCityById.value.get(layoutCity.id);
|
||||||
|
const [, , nationId = 0] = dynamic ?? [];
|
||||||
|
const nation = nationById.value.get(nationId);
|
||||||
|
return {
|
||||||
|
id: layoutCity.id,
|
||||||
|
name: layoutCity.name,
|
||||||
|
x: layoutCity.x * MAP_SCALE,
|
||||||
|
y: layoutCity.y * MAP_SCALE,
|
||||||
|
color: nation?.color ?? '#666666',
|
||||||
|
isCapital: nation?.capitalCityId === layoutCity.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="map-preview">
|
||||||
|
<div class="map-preview-header">
|
||||||
|
<span class="map-preview-title">{{ props.mapLayout.mapName }}</span>
|
||||||
|
<span class="map-preview-date">{{ props.mapData.year }}년 {{ props.mapData.month }}월</span>
|
||||||
|
</div>
|
||||||
|
<div class="map-preview-body" :style="{ width: mapWidth, height: mapHeight }">
|
||||||
|
<div
|
||||||
|
v-for="city in cityDots"
|
||||||
|
:key="city.id"
|
||||||
|
class="city-dot"
|
||||||
|
:class="{ capital: city.isCapital }"
|
||||||
|
:title="city.name"
|
||||||
|
:style="{ left: `${city.x}px`, top: `${city.y}px`, backgroundColor: city.color }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.map-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-preview-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgba(232, 221, 196, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-preview-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-preview-body {
|
||||||
|
position: relative;
|
||||||
|
border: 1px dashed rgba(201, 164, 90, 0.4);
|
||||||
|
background: rgba(8, 8, 8, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-dot {
|
||||||
|
position: absolute;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-dot.capital {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
box-shadow: 0 0 6px rgba(255, 221, 164, 0.7);
|
||||||
|
border-color: rgba(255, 221, 164, 0.8);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router';
|
|||||||
import type { inferRouterOutputs } from '@trpc/server';
|
import type { inferRouterOutputs } from '@trpc/server';
|
||||||
import type { AppRouter } from '@sammo-ts/gateway-api';
|
import type { AppRouter } from '@sammo-ts/gateway-api';
|
||||||
import DefaultLayout from '../layouts/DefaultLayout.vue';
|
import DefaultLayout from '../layouts/DefaultLayout.vue';
|
||||||
|
import MapPreview from '../components/MapPreview.vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import { createGameTrpc } from '../utils/gameTrpc';
|
import { createGameTrpc } from '../utils/gameTrpc';
|
||||||
import type { GameRouter } from '../utils/gameTrpc';
|
import type { GameRouter } from '../utils/gameTrpc';
|
||||||
@@ -13,12 +14,19 @@ type GameRouterOutput = inferRouterOutputs<GameRouter>;
|
|||||||
type MeOutput = GatewayRouterOutput['me'];
|
type MeOutput = GatewayRouterOutput['me'];
|
||||||
type LobbyProfile = GatewayRouterOutput['lobby']['profiles'][number];
|
type LobbyProfile = GatewayRouterOutput['lobby']['profiles'][number];
|
||||||
type LobbyInfo = GameRouterOutput['lobby']['info'];
|
type LobbyInfo = GameRouterOutput['lobby']['info'];
|
||||||
|
type PublicMap = GameRouterOutput['public']['getCachedMap'];
|
||||||
|
type PublicMapLayout = GameRouterOutput['public']['getMapLayout'];
|
||||||
|
type MapPreviewBundle = {
|
||||||
|
mapData: PublicMap;
|
||||||
|
mapLayout: PublicMapLayout;
|
||||||
|
};
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const me = ref<MeOutput>(null);
|
const me = ref<MeOutput>(null);
|
||||||
const notice = ref('');
|
const notice = ref('');
|
||||||
const profiles = ref<LobbyProfile[]>([]);
|
const profiles = ref<LobbyProfile[]>([]);
|
||||||
const profileDetails = ref<Record<string, LobbyInfo | undefined>>({});
|
const profileDetails = ref<Record<string, LobbyInfo | undefined>>({});
|
||||||
|
const profileMapPreviews = ref<Record<string, MapPreviewBundle | undefined>>({});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -31,18 +39,32 @@ onMounted(async () => {
|
|||||||
notice.value = await trpc.lobby.notice.query();
|
notice.value = await trpc.lobby.notice.query();
|
||||||
profiles.value = await trpc.lobby.profiles.query();
|
profiles.value = await trpc.lobby.profiles.query();
|
||||||
|
|
||||||
// Fetch details for each profile
|
const detailTasks = profiles.value.map(async (profile) => {
|
||||||
for (const profile of profiles.value) {
|
if (profile.status !== 'RUNNING' && profile.status !== 'PREOPEN') {
|
||||||
if (profile.status === 'RUNNING' || profile.status === 'PREOPEN') {
|
return;
|
||||||
try {
|
|
||||||
const gameTrpc = createGameTrpc(profile.apiPort);
|
|
||||||
const info = await gameTrpc.lobby.info.query();
|
|
||||||
profileDetails.value[profile.profileName] = info;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Failed to fetch info for ${profile.profileName}`, e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
const gameTrpc = createGameTrpc(profile.apiPort);
|
||||||
|
const [infoResult, layoutResult, mapResult] = await Promise.allSettled([
|
||||||
|
gameTrpc.lobby.info.query(),
|
||||||
|
gameTrpc.public.getMapLayout.query(),
|
||||||
|
gameTrpc.public.getCachedMap.query(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (infoResult.status === 'fulfilled') {
|
||||||
|
profileDetails.value[profile.profileName] = infoResult.value;
|
||||||
|
} else {
|
||||||
|
console.error(`Failed to fetch info for ${profile.profileName}`, infoResult.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layoutResult.status === 'fulfilled' && mapResult.status === 'fulfilled') {
|
||||||
|
profileMapPreviews.value[profile.profileName] = {
|
||||||
|
mapLayout: layoutResult.value,
|
||||||
|
mapData: mapResult.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(detailTasks);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load lobby', e);
|
console.error('Failed to load lobby', e);
|
||||||
}
|
}
|
||||||
@@ -221,6 +243,46 @@ const handleLogout = async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-zinc-900 border border-zinc-800 rounded shadow-xl overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="bg-zinc-800 px-6 py-2 text-center font-bold text-white border-b border-zinc-700 tracking-widest"
|
||||||
|
>
|
||||||
|
공개 지도 미리보기
|
||||||
|
</div>
|
||||||
|
<div class="p-4 grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="profile in profiles"
|
||||||
|
:key="profile.profileName"
|
||||||
|
class="border border-zinc-800 rounded bg-zinc-950/50 p-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between text-xs text-zinc-400 mb-2">
|
||||||
|
<span class="font-semibold" :style="{ color: profile.color }">
|
||||||
|
{{ profile.korName }}섭
|
||||||
|
</span>
|
||||||
|
<span>{{ profile.status }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="profile.status === 'RUNNING' || profile.status === 'PREOPEN'">
|
||||||
|
<div v-if="profileMapPreviews[profile.profileName]">
|
||||||
|
<MapPreview
|
||||||
|
:map-data="profileMapPreviews[profile.profileName]!.mapData"
|
||||||
|
:map-layout="profileMapPreviews[profile.profileName]!.mapLayout"
|
||||||
|
/>
|
||||||
|
<div v-if="profileDetails[profile.profileName]" class="text-xs text-zinc-400 mt-2">
|
||||||
|
유저 {{ profileDetails[profile.profileName]?.userCnt ?? '-' }} /
|
||||||
|
{{ profileDetails[profile.profileName]?.maxUserCnt ?? '-' }} ·
|
||||||
|
{{ profileDetails[profile.profileName]?.nationCnt ?? '-' }}국 ·
|
||||||
|
{{ profileDetails[profile.profileName]?.turnTerm ?? '-' }}분 턴
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-xs text-zinc-500 py-8 text-center">
|
||||||
|
지도를 불러오는 중...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-xs text-zinc-600 py-8 text-center">- 폐 쇄 중 -</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Account Management -->
|
<!-- Account Management -->
|
||||||
<div class="bg-zinc-900 border border-zinc-800 rounded shadow-xl overflow-hidden">
|
<div class="bg-zinc-900 border border-zinc-800 rounded shadow-xl overflow-hidden">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -133,11 +133,12 @@
|
|||||||
- 지도 아이콘 베이스 경로: `VITE_GAME_ASSET_URL`로 레거시 이미지 경로 주입
|
- 지도 아이콘 베이스 경로: `VITE_GAME_ASSET_URL`로 레거시 이미지 경로 주입
|
||||||
- Join/빙의 UI 구현: 장수 생성/빙의 탭, NPC 목록 로딩, 생성/빙의 후 세션 상태 갱신 및 메인 이동
|
- Join/빙의 UI 구현: 장수 생성/빙의 탭, NPC 목록 로딩, 생성/빙의 후 세션 상태 갱신 및 메인 이동
|
||||||
- 게임 API: `join.getConfig`, `join.createGeneral`, `join.listPossessCandidates`, `join.possessGeneral` 추가
|
- 게임 API: `join.getConfig`, `join.createGeneral`, `join.listPossessCandidates`, `join.possessGeneral` 추가
|
||||||
|
- Public 화면 구현: 캐시 지도/중원 정세/세력 일람/제한 장수 일람 구성
|
||||||
|
- Public API: `public.getCachedMap`, `public.getWorldTrend`, `public.getNationList`, `public.getGeneralList` 추가
|
||||||
|
|
||||||
## Next Frontend Tasks
|
## Next Frontend Tasks
|
||||||
|
|
||||||
- 게이트웨이 로그인/프로필 선택 플로우 정리 (토큰 전달 방식, 자동 로그인, 쿠키 기반 전환 고려)
|
- 게이트웨이 로그인/프로필 선택 플로우 정리 (토큰 전달 방식, 자동 로그인, 쿠키 기반 전환 고려)
|
||||||
- 공개(Public) 화면 구현: 캐싱된 지도/중원정세/세력일람 + 제한된 장수일람
|
|
||||||
- 실시간 업데이트(SSE) 연결 설계 및 메인 화면 토글과 연동
|
- 실시간 업데이트(SSE) 연결 설계 및 메인 화면 토글과 연동
|
||||||
- MapViewer 비주얼 보강: 레거시 테마/아이콘/맵 배경 스타일 상세 이식
|
- MapViewer 비주얼 보강: 레거시 테마/아이콘/맵 배경 스타일 상세 이식
|
||||||
- 레거시 이미지 서빙 위치 확정 및 SPA 배포 시 정적 경로 매핑
|
- 레거시 이미지 서빙 위치 확정 및 SPA 배포 시 정적 경로 매핑
|
||||||
|
|||||||
Reference in New Issue
Block a user