diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index 22246d7..b4167c6 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -6,6 +6,8 @@ import { buildTurnCommandTable } from '../../turns/commandTable.js'; import { MAX_GENERAL_TURNS, MAX_NATION_TURNS, + listGeneralTurns, + listNationTurns, setGeneralTurn, setNationTurn, shiftGeneralTurns, @@ -76,6 +78,56 @@ export const turnsRouter = router({ }); }), reserved: router({ + getGeneral: authedProcedure + .input( + z.object({ + generalId: z.number().int().positive(), + }) + ) + .query(async ({ ctx, input }) => { + const general = await ctx.db.general.findUnique({ + where: { id: input.generalId }, + }); + if (!general) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'General not found.', + }); + } + + return listGeneralTurns(ctx.db, input.generalId); + }), + getNation: authedProcedure + .input( + z.object({ + generalId: z.number().int().positive(), + }) + ) + .query(async ({ ctx, input }) => { + const general = await ctx.db.general.findUnique({ + where: { id: input.generalId }, + }); + if (!general) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'General not found.', + }); + } + if (general.nationId <= 0) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'General is not part of a nation.', + }); + } + if (general.officerLevel < 5) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'General is not an officer.', + }); + } + + return listNationTurns(ctx.db, general.nationId, general.officerLevel); + }), setGeneral: authedProcedure .input( z.object({ diff --git a/app/game-api/src/turns/reservedTurns.ts b/app/game-api/src/turns/reservedTurns.ts index 2c30797..e973c53 100644 --- a/app/game-api/src/turns/reservedTurns.ts +++ b/app/game-api/src/turns/reservedTurns.ts @@ -108,6 +108,11 @@ export const loadGeneralTurns = async (db: DatabaseClient, generalId: number): P return buildTurnListFromRows(rows, MAX_GENERAL_TURNS); }; +export const listGeneralTurns = async (db: DatabaseClient, generalId: number): Promise => { + const turns = await loadGeneralTurns(db, generalId); + return serializeTurnList(turns); +}; + export const loadNationTurns = async ( db: DatabaseClient, nationId: number, @@ -120,6 +125,15 @@ export const loadNationTurns = async ( return buildTurnListFromRows(rows, MAX_NATION_TURNS); }; +export const listNationTurns = async ( + db: DatabaseClient, + nationId: number, + officerLevel: number +): Promise => { + const turns = await loadNationTurns(db, nationId, officerLevel); + return serializeTurnList(turns); +}; + export const setGeneralTurn = async ( db: DatabaseClient, generalId: number, diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index 570573d..458dc47 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -1,12 +1,21 @@ import { describe, expect, it } from 'vitest'; -import type { DatabaseClient, GameApiContext, GameProfile, WorldStateRow } from '../src/context.js'; +import type { + DatabaseClient, + GameApiContext, + GameProfile, + WorldStateRow, + GeneralRow, + GeneralTurnRow, + NationTurnRow, +} from '../src/context.js'; import type { RedisConnector } from '@sammo-ts/infra'; import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; import { InMemoryFlushStore } from '../src/auth/flushStore.js'; import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; import { appRouter } from '../src/router.js'; +import type { GameSessionTokenPayload } from '@sammo-ts/common'; const profile: GameProfile = { id: 'che', @@ -14,19 +23,77 @@ const profile: GameProfile = { name: 'che:default', }; +const buildGeneralRow = (overrides?: Partial): GeneralRow => { + const base: GeneralRow = { + id: 1, + userId: null, + name: '테스트', + nationId: 0, + cityId: 1, + troopId: 0, + npcState: 0, + affinity: null, + bornYear: 180, + deadYear: 300, + picture: null, + imageServer: 0, + leadership: 50, + strength: 50, + intel: 50, + injury: 0, + experience: 0, + dedication: 0, + officerLevel: 0, + gold: 1000, + rice: 1000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: new Date('2026-01-01T00:00:00Z'), + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + lastTurn: {}, + meta: {}, + penalty: {}, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + }; + return { ...base, ...(overrides ?? {}) }; +}; + const buildContext = (options?: { state?: WorldStateRow | null; transport?: InMemoryTurnDaemonTransport; battleSim?: InMemoryBattleSimTransport; + general?: GeneralRow | null; + generalTurns?: GeneralTurnRow[]; + nationTurns?: NationTurnRow[]; + auth?: GameSessionTokenPayload | null; }): GameApiContext => { const transport = options?.transport ?? new InMemoryTurnDaemonTransport(); const battleSim = options?.battleSim ?? new InMemoryBattleSimTransport(); + const generalTurns = options?.generalTurns ?? []; + const nationTurns = options?.nationTurns ?? []; const db = { worldState: { findFirst: async () => options?.state ?? null, }, general: { - findUnique: async () => null, + findUnique: async ({ where }: { where: { id: number } }) => { + if (!options?.general) { + return null; + } + return options.general.id === where.id ? options.general : null; + }, }, city: { findUnique: async () => null, @@ -35,12 +102,16 @@ const buildContext = (options?: { findUnique: async () => null, }, generalTurn: { - findMany: async () => [], + findMany: async ({ where }: { where: { generalId: number } }) => + generalTurns.filter((row) => row.generalId === where.generalId), deleteMany: async () => ({}), createMany: async () => ({}), }, nationTurn: { - findMany: async () => [], + findMany: async ({ where }: { where: { nationId: number; officerLevel: number } }) => + nationTurns.filter( + (row) => row.nationId === where.nationId && row.officerLevel === where.officerLevel + ), deleteMany: async () => ({}), createMany: async () => ({}), }, @@ -52,12 +123,26 @@ const buildContext = (options?: { }, profile.name ); + const auth: GameSessionTokenPayload = options?.auth ?? { + version: 1, + profile: profile.name, + issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(), + expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(), + sessionId: 'session-1', + user: { + id: 'user-1', + username: 'tester', + displayName: 'Tester', + roles: [], + }, + sanctions: {}, + }; return { db: db as unknown as DatabaseClient, turnDaemon: transport, battleSim, profile, - auth: null, + auth, redis: {} as unknown as RedisConnector['client'], accessTokenStore, flushStore: new InMemoryFlushStore(), @@ -111,4 +196,43 @@ describe('appRouter', () => { expect(response?.state).toBe('paused'); expect(response?.queueDepth).toBe(2); }); + + it('returns reserved general turns', async () => { + const general = buildGeneralRow({ id: 11 }); + const generalTurns: GeneralTurnRow[] = [ + { + id: 1, + generalId: 11, + turnIdx: 0, + actionCode: 'che_화계', + arg: {}, + createdAt: new Date('2026-01-01T00:00:00Z'), + }, + ]; + const caller = appRouter.createCaller(buildContext({ general, generalTurns })); + const response = await caller.turns.reserved.getGeneral({ generalId: 11 }); + + expect(response[0]?.action).toBe('che_화계'); + expect(response[0]?.index).toBe(0); + }); + + it('returns reserved nation turns', async () => { + const general = buildGeneralRow({ id: 12, nationId: 3, officerLevel: 5 }); + const nationTurns: NationTurnRow[] = [ + { + id: 1, + nationId: 3, + officerLevel: 5, + turnIdx: 0, + actionCode: 'che_포상', + arg: {}, + createdAt: new Date('2026-01-01T00:00:00Z'), + }, + ]; + const caller = appRouter.createCaller(buildContext({ general, nationTurns })); + const response = await caller.turns.reserved.getNation({ generalId: 12 }); + + expect(response[0]?.action).toBe('che_포상'); + expect(response[0]?.index).toBe(0); + }); }); diff --git a/app/game-frontend/src/components/main/CommandListPanel.vue b/app/game-frontend/src/components/main/CommandListPanel.vue index b46bdc6..b2e4744 100644 --- a/app/game-frontend/src/components/main/CommandListPanel.vue +++ b/app/game-frontend/src/components/main/CommandListPanel.vue @@ -5,6 +5,7 @@ import CommandSelectForm from './CommandSelectForm.vue'; interface TurnCommandAvailability { key: string; name: string; + reqArg: boolean; status: 'available' | 'blocked' | 'needsInput' | 'unknown'; possible: boolean; reason?: string; @@ -27,17 +28,93 @@ interface SelectedCityInfo { regionName: string; } +interface ReservedTurnEntry { + index: number; + action: string; + args: unknown; +} + +interface GeneralInfo { + id: number; + nationId: number; + officerLevel: number; +} + const props = defineProps<{ commandTable: TurnCommandTable | null; loading: boolean; selectedCity: SelectedCityInfo | null; + reservedGeneralTurns: ReservedTurnEntry[] | null; + reservedNationTurns: ReservedTurnEntry[] | null; + general: GeneralInfo | null; +}>(); + +const emit = defineEmits<{ + (event: 'set-general-turn', payload: { index: number; action: string }): void; + (event: 'shift-general-turns', amount: number): void; + (event: 'set-nation-turn', payload: { index: number; action: string }): void; + (event: 'shift-nation-turns', amount: number): void; }>(); const activeCategory = ref(''); +const selectedCommand = ref(null); const handleSelect = (commandKey: string) => { - void commandKey; + if (!props.commandTable) { + selectedCommand.value = null; + return; + } + const allGroups = [...props.commandTable.general, ...props.commandTable.nation]; + for (const group of allGroups) { + const match = group.values.find((entry) => entry.key === commandKey); + if (match) { + selectedCommand.value = match; + return; + } + } + selectedCommand.value = null; }; + +const canReserveSelected = () => { + if (!selectedCommand.value) { + return false; + } + if (!selectedCommand.value.possible) { + return false; + } + if (selectedCommand.value.status !== 'available') { + return false; + } + if (selectedCommand.value.reqArg) { + return false; + } + return true; +}; + +const reserveGeneralTurn = (index: number) => { + if (!selectedCommand.value) { + return; + } + emit('set-general-turn', { index, action: selectedCommand.value.key }); +}; + +const reserveNationTurn = (index: number) => { + if (!selectedCommand.value) { + return; + } + emit('set-nation-turn', { index, action: selectedCommand.value.key }); +}; + +const clearGeneralTurn = (index: number) => { + emit('set-general-turn', { index, action: '휴식' }); +}; + +const clearNationTurn = (index: number) => { + emit('set-nation-turn', { index, action: '휴식' }); +}; + +const canNationReserve = () => + Boolean(props.general && props.general.nationId > 0 && props.general.officerLevel >= 5); @@ -84,10 +214,89 @@ const handleSelect = (commandKey: string) => { color: rgba(232, 221, 196, 0.6); } -.command-placeholder { - border: 1px dashed rgba(201, 164, 90, 0.3); +.command-selected { + border: 1px solid rgba(201, 164, 90, 0.3); padding: 8px; + display: grid; + gap: 6px; font-size: 0.75rem; +} + +.command-selected .label { color: rgba(232, 221, 196, 0.6); } + +.command-selected .meta { + display: flex; + gap: 8px; + font-size: 0.7rem; + color: rgba(232, 221, 196, 0.6); +} + +.reserved-section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.reserved-header { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.75rem; + font-weight: 600; +} + +.reserved-actions { + display: flex; + gap: 6px; +} + +.reserved-actions button { + border: 1px solid rgba(201, 164, 90, 0.3); + padding: 4px 6px; + font-size: 0.7rem; +} + +.reserved-list { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 240px; + overflow-y: auto; +} + +.reserved-item { + border: 1px solid rgba(201, 164, 90, 0.2); + padding: 6px; + display: grid; + grid-template-columns: 50px 1fr auto; + gap: 6px; + align-items: center; + font-size: 0.75rem; +} + +.turn-label { + color: rgba(232, 221, 196, 0.6); +} + +.turn-buttons { + display: flex; + gap: 4px; +} + +.turn-buttons button { + border: 1px solid rgba(201, 164, 90, 0.3); + padding: 4px 6px; + font-size: 0.7rem; +} + +.ghost { + background: transparent; +} + +.muted { + color: rgba(232, 221, 196, 0.6); + font-size: 0.75rem; +} diff --git a/app/game-frontend/src/components/main/CommandSelectForm.vue b/app/game-frontend/src/components/main/CommandSelectForm.vue index 885dcfa..9ab1726 100644 --- a/app/game-frontend/src/components/main/CommandSelectForm.vue +++ b/app/game-frontend/src/components/main/CommandSelectForm.vue @@ -5,6 +5,7 @@ import SkeletonLines from '../ui/SkeletonLines.vue'; type CommandAvailability = { key: string; name: string; + reqArg: boolean; status: 'available' | 'blocked' | 'needsInput' | 'unknown'; possible: boolean; reason?: string; diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index b3e196b..bd57937 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -23,6 +23,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { type MapLayout = Awaited>; type CommandTable = Awaited>; type MessageBundle = Awaited>; + type ReservedTurnView = Awaited>[number]; const loading = ref(false); const error = ref(null); @@ -35,6 +36,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const mapLayout = ref(null); const commandTable = ref(null); const messages = ref(null); + const reservedGeneralTurns = ref(null); + const reservedNationTurns = ref(null); const messageDraftText = ref(''); const targetMailbox = ref(MESSAGE_MAILBOX_PUBLIC); @@ -128,18 +131,27 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { generalContext.value = context; if (!context) { + reservedGeneralTurns.value = null; + reservedNationTurns.value = null; loading.value = false; return; } const id = context.general.id; const layoutPromise = mapLayout.value ? Promise.resolve(mapLayout.value) : trpc.world.getMapLayout.query(); - const [layout, lobby, map, commands, messageData] = await Promise.all([ + 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, generalTurns, nationTurns] = await Promise.all([ layoutPromise, trpc.lobby.info.query(), trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }), trpc.turns.getCommandTable.query({ generalId: id }), trpc.messages.getRecent.query({ generalId: id }), + generalTurnsPromise, + nationTurnsPromise, ]); mapLayout.value = layout; @@ -147,6 +159,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { worldMap.value = map; commandTable.value = commands; messages.value = messageData; + reservedGeneralTurns.value = generalTurns; + reservedNationTurns.value = nationTurns; } catch (err) { error.value = resolveErrorMessage(err); } finally { @@ -221,6 +235,83 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { } }; + const setGeneralTurn = async (turnIndex: number, action: string) => { + const id = generalId.value; + if (!id) { + return; + } + try { + const result = await trpc.turns.reserved.setGeneral.mutate({ + generalId: id, + turnIndex, + action, + args: {}, + }); + reservedGeneralTurns.value = result.turns; + } catch (err) { + error.value = resolveErrorMessage(err); + } + }; + + const shiftGeneralTurns = async (amount: number) => { + const id = generalId.value; + if (!id) { + return; + } + try { + const result = await trpc.turns.reserved.shiftGeneral.mutate({ + generalId: id, + amount, + }); + reservedGeneralTurns.value = result.turns; + } catch (err) { + error.value = resolveErrorMessage(err); + } + }; + + const setNationTurn = async (turnIndex: number, action: string) => { + const id = generalId.value; + const currentGeneral = general.value; + if (!id || !currentGeneral) { + return; + } + if (currentGeneral.nationId <= 0 || currentGeneral.officerLevel < 5) { + return; + } + try { + const result = await trpc.turns.reserved.setNation.mutate({ + generalId: id, + turnIndex, + action, + args: {}, + }); + reservedNationTurns.value = result.turns; + } catch (err) { + error.value = resolveErrorMessage(err); + } + }; + + const shiftNationTurns = async (amount: number) => { + const id = generalId.value; + const currentGeneral = general.value; + if (!id || !currentGeneral) { + return; + } + if (currentGeneral.nationId <= 0 || currentGeneral.officerLevel < 5) { + return; + } + try { + const result = await trpc.turns.reserved.shiftNation.mutate({ + generalId: id, + amount, + }); + reservedNationTurns.value = result.turns; + } catch (err) { + error.value = resolveErrorMessage(err); + } + }; + + let realtimeSource: EventSource | null = null; let realtimeToken: string | null = null; @@ -369,6 +460,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { selectedCity, commandTable, messages, + reservedGeneralTurns, + reservedNationTurns, messageDraftText, targetMailbox, mailboxOptions, @@ -379,5 +472,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { refreshMessages, sendMessage, loadOlderMessages, + setGeneralTurn, + shiftGeneralTurns, + setNationTurn, + shiftNationTurns, }; }); diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 8b292c7..fd33e8e 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -43,6 +43,8 @@ const { selectedCity, commandTable, messages, + reservedGeneralTurns, + reservedNationTurns, messageDraftText, targetMailbox, mailboxOptions, @@ -50,6 +52,22 @@ const { realtimeLabel, } = storeToRefs(dashboard); +const reserveGeneralTurn = (payload: { index: number; action: string }) => { + void dashboard.setGeneralTurn(payload.index, payload.action); +}; + +const shiftGeneralTurns = (amount: number) => { + void dashboard.shiftGeneralTurns(amount); +}; + +const reserveNationTurn = (payload: { index: number; action: string }) => { + void dashboard.setNationTurn(payload.index, payload.action); +}; + +const shiftNationTurns = (amount: number) => { + void dashboard.shiftNationTurns(amount); +}; + const loadMainData = async () => { await dashboard.loadMainData(); }; @@ -113,7 +131,18 @@ watch(
- +
@@ -200,7 +229,18 @@ watch(
- + diff --git a/docs/architecture/game-frontend-spa-plan.md b/docs/architecture/game-frontend-spa-plan.md index e2c26d8..d3b9dc8 100644 --- a/docs/architecture/game-frontend-spa-plan.md +++ b/docs/architecture/game-frontend-spa-plan.md @@ -136,15 +136,15 @@ - Public 화면 구현: 캐시 지도/중원 정세/세력 일람/제한 장수 일람 구성 - Public API: `public.getCachedMap`, `public.getWorldTrend`, `public.getNationList`, `public.getGeneralList` 추가 - Gateway → Game handoff: 게이트웨이에서 `gameToken` 발급 후 게임 프론트에서 1회 교환(access token)하는 흐름 추가 +- 실시간 업데이트(SSE): 메인 화면 토글과 EventSource 연결 + Redis pub/sub 연동 +- CommandSelectForm/MessagePanel 예약/전송 플로우 연결(예턴 배치/메시지 전송) ## Next Frontend Tasks - 게이트웨이 로그인/프로필 선택 플로우 정리 (토큰 전달 방식, 자동 로그인, 쿠키 기반 전환 고려) - 게이트웨이/게임 프론트 도메인 경로(`VITE_GAME_WEB_URL`) 확정 및 운영 배포 경로 문서화 -- 실시간 업데이트(SSE) 연결 설계 및 메인 화면 토글과 연동 - MapViewer 비주얼 보강: 레거시 테마/아이콘/맵 배경 스타일 상세 이식 - 레거시 이미지 서빙 위치 확정 및 SPA 배포 시 정적 경로 매핑 -- CommandSelectForm/MessagePanel 이식 마무리(예약/전송 플로우 연결) - 유산 포인트/추가 옵션(도시·특기·턴타임) 이식 및 서버 검증 규칙 합의 - 화면 라우트 매핑 표 및 데이터 계약 문서화