From f8159728ca98c0cdc211b1de4f524b11a89bda90 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Fri, 16 Jan 2026 15:57:56 +0000 Subject: [PATCH] feat: Implement session management and routing for general creation - Added session initialization in main.ts to manage user sessions. - Updated router to include a new route for joining or creating a general. - Enhanced session store with methods for managing session tokens and user profiles. - Introduced new components for displaying city, general, nation, and command information. - Implemented loading states and error handling in the main view. - Created a skeleton loader component for better user experience during data fetching. - Updated TypeScript definitions for route metadata to include new authentication requirements. --- app/game-api/src/router.ts | 103 +++++ app/game-frontend/package.json | 1 + .../src/components/main/CityBasicCard.vue | 68 ++++ .../src/components/main/CommandListPanel.vue | 102 +++++ .../src/components/main/GeneralBasicCard.vue | 99 +++++ .../src/components/main/MapViewer.vue | 64 +++ .../src/components/main/MessagePanel.vue | 116 ++++++ .../src/components/main/NationBasicCard.vue | 74 ++++ .../src/components/ui/PanelCard.vue | 67 ++++ .../src/components/ui/SkeletonLines.vue | 41 ++ app/game-frontend/src/env.d.ts | 10 + app/game-frontend/src/main.ts | 7 +- app/game-frontend/src/router/index.ts | 24 +- app/game-frontend/src/router/meta.d.ts | 1 + app/game-frontend/src/stores/session.ts | 167 +++++++- app/game-frontend/src/utils/gatewayTrpc.ts | 22 ++ app/game-frontend/src/utils/trpc.ts | 10 +- app/game-frontend/src/views/JoinView.vue | 10 + app/game-frontend/src/views/MainView.vue | 370 +++++++++++++++++- app/game-frontend/tsconfig.json | 4 +- 20 files changed, 1342 insertions(+), 18 deletions(-) create mode 100644 app/game-frontend/src/components/main/CityBasicCard.vue create mode 100644 app/game-frontend/src/components/main/CommandListPanel.vue create mode 100644 app/game-frontend/src/components/main/GeneralBasicCard.vue create mode 100644 app/game-frontend/src/components/main/MapViewer.vue create mode 100644 app/game-frontend/src/components/main/MessagePanel.vue create mode 100644 app/game-frontend/src/components/main/NationBasicCard.vue create mode 100644 app/game-frontend/src/components/ui/PanelCard.vue create mode 100644 app/game-frontend/src/components/ui/SkeletonLines.vue create mode 100644 app/game-frontend/src/utils/gatewayTrpc.ts create mode 100644 app/game-frontend/src/views/JoinView.vue diff --git a/app/game-api/src/router.ts b/app/game-api/src/router.ts index 7b43599..fb53455 100644 --- a/app/game-api/src/router.ts +++ b/app/game-api/src/router.ts @@ -684,6 +684,109 @@ export const appRouter = router({ }), }), general: router({ + me: authedProcedure.query(async ({ ctx }) => { + const userId = ctx.auth?.user.id; + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + + const general = await ctx.db.general.findFirst({ + where: { userId }, + select: { + id: true, + name: true, + npcState: true, + nationId: true, + cityId: true, + troopId: true, + picture: true, + imageServer: true, + leadership: true, + strength: true, + intel: true, + officerLevel: true, + gold: true, + rice: true, + crew: true, + train: true, + atmos: true, + injury: true, + experience: true, + dedication: true, + }, + }); + + if (!general) { + return null; + } + + const [city, nation] = await Promise.all([ + general.cityId > 0 + ? ctx.db.city.findUnique({ + where: { id: general.cityId }, + select: { + id: true, + name: true, + level: true, + nationId: true, + population: true, + agriculture: true, + commerce: true, + security: true, + defence: true, + wall: true, + supplyState: true, + frontState: true, + }, + }) + : null, + general.nationId > 0 + ? ctx.db.nation.findUnique({ + where: { id: general.nationId }, + select: { + id: true, + name: true, + color: true, + level: true, + gold: true, + rice: true, + tech: true, + typeCode: true, + capitalCityId: true, + }, + }) + : null, + ]); + + return { + general: { + id: general.id, + name: general.name, + npcState: general.npcState, + nationId: general.nationId, + cityId: general.cityId, + troopId: general.troopId, + picture: general.picture, + imageServer: general.imageServer, + officerLevel: general.officerLevel, + stats: { + leadership: general.leadership, + strength: general.strength, + intelligence: general.intel, + }, + gold: general.gold, + rice: general.rice, + crew: general.crew, + train: general.train, + atmos: general.atmos, + injury: general.injury, + experience: general.experience, + dedication: general.dedication, + }, + city, + nation, + }; + }), dieOnPrestart: authedProcedure.mutation(async ({ ctx }) => { const general = await getMyGeneral(ctx); const result = await ctx.turnDaemon.requestCommand({ diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index e67a4fa..90f257d 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -15,6 +15,7 @@ "dependencies": { "@sammo-ts/common": "workspace:*", "@sammo-ts/game-api": "workspace:*", + "@sammo-ts/gateway-api": "workspace:*", "@sammo-ts/logic": "workspace:*", "@trpc/client": "^11.8.1", "@trpc/server": "^11.8.1", diff --git a/app/game-frontend/src/components/main/CityBasicCard.vue b/app/game-frontend/src/components/main/CityBasicCard.vue new file mode 100644 index 0000000..e20a90c --- /dev/null +++ b/app/game-frontend/src/components/main/CityBasicCard.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/app/game-frontend/src/components/main/CommandListPanel.vue b/app/game-frontend/src/components/main/CommandListPanel.vue new file mode 100644 index 0000000..89ae347 --- /dev/null +++ b/app/game-frontend/src/components/main/CommandListPanel.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/app/game-frontend/src/components/main/GeneralBasicCard.vue b/app/game-frontend/src/components/main/GeneralBasicCard.vue new file mode 100644 index 0000000..51f4df5 --- /dev/null +++ b/app/game-frontend/src/components/main/GeneralBasicCard.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/app/game-frontend/src/components/main/MapViewer.vue b/app/game-frontend/src/components/main/MapViewer.vue new file mode 100644 index 0000000..a996dc9 --- /dev/null +++ b/app/game-frontend/src/components/main/MapViewer.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/app/game-frontend/src/components/main/MessagePanel.vue b/app/game-frontend/src/components/main/MessagePanel.vue new file mode 100644 index 0000000..4743452 --- /dev/null +++ b/app/game-frontend/src/components/main/MessagePanel.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/app/game-frontend/src/components/main/NationBasicCard.vue b/app/game-frontend/src/components/main/NationBasicCard.vue new file mode 100644 index 0000000..7659283 --- /dev/null +++ b/app/game-frontend/src/components/main/NationBasicCard.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/app/game-frontend/src/components/ui/PanelCard.vue b/app/game-frontend/src/components/ui/PanelCard.vue new file mode 100644 index 0000000..4fc4f76 --- /dev/null +++ b/app/game-frontend/src/components/ui/PanelCard.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/app/game-frontend/src/components/ui/SkeletonLines.vue b/app/game-frontend/src/components/ui/SkeletonLines.vue new file mode 100644 index 0000000..a9529a3 --- /dev/null +++ b/app/game-frontend/src/components/ui/SkeletonLines.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/app/game-frontend/src/env.d.ts b/app/game-frontend/src/env.d.ts index f089962..3a37566 100644 --- a/app/game-frontend/src/env.d.ts +++ b/app/game-frontend/src/env.d.ts @@ -5,3 +5,13 @@ declare module '*.vue' { const component: DefineComponent, Record, Record>; export default component; } + +interface ImportMetaEnv { + readonly VITE_GATEWAY_API_URL?: string; + readonly VITE_GAME_API_URL?: string; + readonly VITE_GAME_PROFILE?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/app/game-frontend/src/main.ts b/app/game-frontend/src/main.ts index 2e552ae..da2eff3 100644 --- a/app/game-frontend/src/main.ts +++ b/app/game-frontend/src/main.ts @@ -3,10 +3,15 @@ import { createPinia } from 'pinia'; import App from './App.vue'; import router from './router'; import './assets/main.css'; +import { useSessionStore } from './stores/session'; const app = createApp(App); -app.use(createPinia()); +const pinia = createPinia(); +app.use(pinia); app.use(router); +const session = useSessionStore(pinia); +void session.initialize(); + app.mount('#app'); diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index 1fd3c8e..d898a47 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -2,6 +2,7 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' import MainView from '../views/MainView.vue'; import PublicView from '../views/PublicView.vue'; import LoginView from '../views/LoginView.vue'; +import JoinView from '../views/JoinView.vue'; import NotFoundView from '../views/NotFoundView.vue'; import { useSessionStore } from '../stores/session'; @@ -20,6 +21,15 @@ const routes = [ name: 'public', component: PublicView, }, + { + path: '/join', + name: 'join', + component: JoinView, + meta: { + requiresAuth: true, + requiresNoGeneral: true, + }, + }, { path: '/login', name: 'login', @@ -40,23 +50,31 @@ const router = createRouter({ routes, }); -router.beforeEach((to) => { +router.beforeEach(async (to) => { const session = useSessionStore(); + if (!session.isReady) { + await session.initialize(); + } + if (!session.isReady) { return true; } if (to.meta.publicOnly && session.isAuthed) { - return { name: 'home' }; + return { name: session.hasGeneral ? 'home' : 'join' }; } if (to.meta.requiresAuth && !session.isAuthed) { return { name: 'public' }; } + if (to.meta.requiresNoGeneral && session.hasGeneral) { + return { name: 'home' }; + } + if (to.meta.requiresGeneral && !session.hasGeneral) { - return { name: 'public' }; + return { name: session.isAuthed ? 'join' : 'public' }; } return true; diff --git a/app/game-frontend/src/router/meta.d.ts b/app/game-frontend/src/router/meta.d.ts index 8802019..4bbf45f 100644 --- a/app/game-frontend/src/router/meta.d.ts +++ b/app/game-frontend/src/router/meta.d.ts @@ -4,6 +4,7 @@ declare module 'vue-router' { interface RouteMeta { requiresAuth?: boolean; requiresGeneral?: boolean; + requiresNoGeneral?: boolean; publicOnly?: boolean; } } diff --git a/app/game-frontend/src/stores/session.ts b/app/game-frontend/src/stores/session.ts index 3c34b30..497ff18 100644 --- a/app/game-frontend/src/stores/session.ts +++ b/app/game-frontend/src/stores/session.ts @@ -1,23 +1,186 @@ import { defineStore } from 'pinia'; +import { gatewayTrpc } from '../utils/gatewayTrpc'; +import { trpc as gameTrpc } from '../utils/trpc'; export type SessionStatus = 'unknown' | 'public' | 'authed' | 'general'; +export interface SessionUser { + id: string; + username: string; + displayName: string; +} + interface SessionState { status: SessionStatus; + user: SessionUser | null; + sessionToken: string | null; + gameToken: string | null; + profile: string | null; + initializing: boolean; + error: string | null; } +const SESSION_TOKEN_KEY = 'sammo-session-token'; +const PROFILE_KEY = 'sammo-game-profile'; +const GAME_TOKEN_KEY = 'sammo-game-token'; + +const readStorage = (key: string): string | null => { + if (typeof window === 'undefined') { + return null; + } + return window.localStorage.getItem(key); +}; + +const writeStorage = (key: string, value: string | null): void => { + if (typeof window === 'undefined') { + return; + } + if (value) { + window.localStorage.setItem(key, value); + } else { + window.localStorage.removeItem(key); + } +}; + +const readQueryParam = (key: string): string | null => { + if (typeof window === 'undefined') { + return null; + } + const url = new URL(window.location.href); + const value = url.searchParams.get(key); + if (!value) { + return null; + } + url.searchParams.delete(key); + window.history.replaceState({}, '', url.toString()); + return value; +}; + export const useSessionStore = defineStore('session', { state: (): SessionState => ({ status: 'unknown', + user: null, + sessionToken: null, + gameToken: null, + profile: null, + initializing: false, + error: null, }), getters: { isReady: (state) => state.status !== 'unknown', isAuthed: (state) => state.status === 'authed' || state.status === 'general', hasGeneral: (state) => state.status === 'general', + needsGeneral: (state) => state.status === 'authed', }, actions: { - setStatus(status: SessionStatus) { - this.status = status; + setSessionToken(sessionToken: string | null) { + this.sessionToken = sessionToken; + writeStorage(SESSION_TOKEN_KEY, sessionToken); + }, + setProfile(profile: string | null) { + this.profile = profile; + writeStorage(PROFILE_KEY, profile); + }, + setGameToken(gameToken: string | null) { + this.gameToken = gameToken; + writeStorage(GAME_TOKEN_KEY, gameToken); + }, + clearSession() { + this.user = null; + this.setSessionToken(null); + this.setGameToken(null); + this.status = 'public'; + }, + async refreshGeneralStatus() { + if (!this.gameToken) { + this.status = 'authed'; + return; + } + try { + const lobby = await gameTrpc.lobby.info.query(); + this.status = lobby.myGeneral ? 'general' : 'authed'; + } catch { + this.error = 'game_status_unavailable'; + } + }, + async initialize() { + if (this.initializing || this.status !== 'unknown') { + return; + } + + this.initializing = true; + this.error = null; + + const tokenFromQuery = readQueryParam('sessionToken'); + if (tokenFromQuery) { + this.setSessionToken(tokenFromQuery); + } + + const profileFromQuery = readQueryParam('profile'); + if (profileFromQuery) { + this.setProfile(profileFromQuery); + } + + const storedToken = this.sessionToken ?? readStorage(SESSION_TOKEN_KEY); + if (storedToken && storedToken !== this.sessionToken) { + this.setSessionToken(storedToken); + } + + const storedProfile = this.profile ?? readStorage(PROFILE_KEY) ?? import.meta.env.VITE_GAME_PROFILE; + if (storedProfile && storedProfile !== this.profile) { + this.setProfile(storedProfile); + } + + if (!this.sessionToken) { + this.status = 'public'; + this.initializing = false; + return; + } + + try { + const me = await gatewayTrpc.me.query(); + if (!me) { + this.clearSession(); + this.initializing = false; + return; + } + this.user = { + id: me.id, + username: me.username, + displayName: me.displayName, + }; + this.status = 'authed'; + } catch { + this.error = 'gateway_unavailable'; + this.status = 'public'; + this.initializing = false; + return; + } + + if (!this.profile) { + this.initializing = false; + return; + } + + try { + const sessionToken = this.sessionToken; + if (!sessionToken) { + this.status = 'public'; + this.initializing = false; + return; + } + const issued = await gatewayTrpc.auth.issueGameSession.mutate({ + sessionToken, + profile: this.profile, + }); + this.setGameToken(issued.gameToken); + await this.refreshGeneralStatus(); + } catch { + this.error = 'game_session_unavailable'; + this.status = 'authed'; + } finally { + this.initializing = false; + } }, }, }); diff --git a/app/game-frontend/src/utils/gatewayTrpc.ts b/app/game-frontend/src/utils/gatewayTrpc.ts new file mode 100644 index 0000000..c77fb97 --- /dev/null +++ b/app/game-frontend/src/utils/gatewayTrpc.ts @@ -0,0 +1,22 @@ +import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; +import type { AppRouter } from '@sammo-ts/gateway-api'; + +const getSessionToken = (): string | null => { + if (typeof window === 'undefined') { + return null; + } + + return window.localStorage.getItem('sammo-session-token'); +}; + +export const gatewayTrpc = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc', + headers() { + const token = getSessionToken(); + return token ? { 'x-session-token': token } : {}; + }, + }), + ], +}); diff --git a/app/game-frontend/src/utils/trpc.ts b/app/game-frontend/src/utils/trpc.ts index 494a6ce..7486b0e 100644 --- a/app/game-frontend/src/utils/trpc.ts +++ b/app/game-frontend/src/utils/trpc.ts @@ -1,21 +1,21 @@ import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import type { AppRouter } from '@sammo-ts/game-api'; -const getSessionToken = (): string | null => { +const getGameToken = (): string | null => { if (typeof window === 'undefined') { return null; } - return window.localStorage.getItem('sammo-session-token'); + return window.localStorage.getItem('sammo-game-token'); }; export const trpc = createTRPCProxyClient({ links: [ httpBatchLink({ - url: '/api/trpc', + url: import.meta.env.VITE_GAME_API_URL ?? '/api/trpc', headers() { - const token = getSessionToken(); - return token ? { 'x-session-token': token } : {}; + const token = getGameToken(); + return token ? { authorization: `Bearer ${token}` } : {}; }, }), ], diff --git a/app/game-frontend/src/views/JoinView.vue b/app/game-frontend/src/views/JoinView.vue new file mode 100644 index 0000000..26f2986 --- /dev/null +++ b/app/game-frontend/src/views/JoinView.vue @@ -0,0 +1,10 @@ + + + diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index f3c9105..29ed188 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -1,10 +1,368 @@ - + + + diff --git a/app/game-frontend/tsconfig.json b/app/game-frontend/tsconfig.json index d06457d..bb3d6d6 100644 --- a/app/game-frontend/tsconfig.json +++ b/app/game-frontend/tsconfig.json @@ -28,7 +28,9 @@ "@sammo-ts/game-engine": ["../../app/game-engine/src/index.ts"], "@sammo-ts/game-engine/*": ["../../app/game-engine/src/*"], "@sammo-ts/game-api": ["../../app/game-api/src/index.ts"], - "@sammo-ts/game-api/*": ["../../app/game-api/src/*"] + "@sammo-ts/game-api/*": ["../../app/game-api/src/*"], + "@sammo-ts/gateway-api": ["../../app/gateway-api/src/index.ts"], + "@sammo-ts/gateway-api/*": ["../../app/gateway-api/src/*"] } }, "include": [