diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 8294f99..32ab3ed 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -5,7 +5,9 @@ import { LogCategory, LogScope } from '@sammo-ts/infra'; import { asRecord } from '@sammo-ts/common'; import { authedProcedure, router } from '../../trpc.js'; +import { resolveAccessWindows } from '../../services/generalAccess.js'; import { getMyGeneral } from '../shared/general.js'; +import { resolveNationNotice } from '../nation/shared.js'; const zGeneralSettings = z.object({ tnmt: z.number().int().optional(), @@ -381,4 +383,119 @@ export const generalRouter = router({ history: trimRecentRecords(history, input.lastWorldHistoryId), }; }), + getFrontStatus: authedProcedure.query(async ({ ctx }) => { + const me = await getMyGeneral(ctx); + const worldState = await ctx.db.worldState.findFirst({ + orderBy: { id: 'asc' }, + select: { + tickSeconds: true, + meta: true, + }, + }); + if (!worldState) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' }); + } + + const now = new Date(); + const { scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, worldState.meta); + const [onlineAccess, ownNation, latestVote] = await Promise.all([ + ctx.db.generalAccessLog.findMany({ + where: { + lastRefresh: { + gte: scoreStartedAt, + }, + }, + select: { generalId: true }, + }), + me.nationId > 0 + ? ctx.db.nation.findUnique({ + where: { id: me.nationId }, + select: { meta: true }, + }) + : Promise.resolve(null), + ctx.db.votePoll.findFirst({ + where: { + startAt: { lte: now }, + closedAt: null, + OR: [{ endAt: null }, { endAt: { gte: now } }], + }, + orderBy: { id: 'desc' }, + select: { + id: true, + title: true, + }, + }), + ]); + + const onlineGeneralIds = onlineAccess.map((entry) => entry.generalId); + const onlineGenerals = + onlineGeneralIds.length > 0 + ? await ctx.db.general.findMany({ + where: { id: { in: onlineGeneralIds } }, + orderBy: { id: 'asc' }, + select: { + id: true, + name: true, + nationId: true, + }, + }) + : []; + const nationIds = [...new Set(onlineGenerals.map((general) => general.nationId).filter((id) => id > 0))]; + const nations = + nationIds.length > 0 + ? await ctx.db.nation.findMany({ + where: { id: { in: nationIds } }, + select: { + id: true, + name: true, + }, + }) + : []; + const nationNames = new Map(nations.map((nation) => [nation.id, nation.name])); + const onlineByNation = new Map(); + for (const general of onlineGenerals) { + const bucket = onlineByNation.get(general.nationId) ?? []; + bucket.push(general); + onlineByNation.set(general.nationId, bucket); + } + const onlineNations = [...onlineByNation.entries()] + .sort((left, right) => right[1].length - left[1].length || left[0] - right[0]) + .map(([nationId]) => `【${nationId === 0 ? '재야' : (nationNames.get(nationId) ?? `세력 ${nationId}`)}】`) + .join(', '); + const myOnlineGenerals = onlineGenerals + .filter((general) => general.nationId === me.nationId) + .map((general) => general.name) + .join(', '); + const myVote = latestVote + ? await ctx.db.vote.findFirst({ + where: { + voteId: latestVote.id, + generalId: me.id, + }, + select: { id: true }, + }) + : null; + const worldMeta = asRecord(worldState.meta); + const rawLastExecuted = worldMeta.lastTurnTime ?? worldMeta.turntime; + const parsedLastExecuted = + typeof rawLastExecuted === 'string' || rawLastExecuted instanceof Date ? new Date(rawLastExecuted) : null; + + return { + onlineUserCount: onlineGenerals.length, + onlineNations, + onlineGenerals: myOnlineGenerals, + nationNotice: ownNation ? resolveNationNotice(asRecord(ownNation.meta)) : '', + lastExecuted: + parsedLastExecuted && Number.isFinite(parsedLastExecuted.getTime()) + ? parsedLastExecuted.toISOString() + : null, + latestVote: latestVote + ? { + id: latestVote.id, + title: latestVote.title, + hasVoted: Boolean(myVote), + } + : null, + }; + }), }); diff --git a/app/game-api/test/mainFrontStatusRouter.test.ts b/app/game-api/test/mainFrontStatusRouter.test.ts new file mode 100644 index 0000000..ee10cff --- /dev/null +++ b/app/game-api/test/mainFrontStatusRouter.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; + +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: 'front-status-owner', + user: { + id: 'owner', + username: 'owner', + displayName: 'Owner', + roles: [], + }, + sanctions: {}, +}; + +const buildContext = (options: { auth?: GameSessionTokenPayload | null; hasVoted?: boolean } = {}) => + ({ + auth: options.auth === undefined ? auth : options.auth, + db: { + general: { + findFirst: vi.fn(async () => ({ + id: 7, + userId: 'owner', + nationId: 2, + })), + findMany: vi.fn(async () => [ + { id: 7, name: '유비', nationId: 2 }, + { id: 8, name: '관우', nationId: 2 }, + { id: 9, name: '조조', nationId: 3 }, + ]), + }, + worldState: { + findFirst: vi.fn(async () => ({ + tickSeconds: 3600, + meta: { + lastTurnTime: '2026-07-26T10:00:00.000Z', + }, + })), + }, + generalAccessLog: { + findMany: vi.fn(async () => [{ generalId: 7 }, { generalId: 8 }, { generalId: 9 }]), + }, + nation: { + findUnique: vi.fn(async () => ({ + meta: { + notice: '

북벌 준비

', + }, + })), + findMany: vi.fn(async () => [ + { id: 2, name: '촉' }, + { id: 3, name: '위' }, + ]), + }, + votePoll: { + findFirst: vi.fn(async () => ({ + id: 12, + title: '다음 시즌 턴 시간', + })), + }, + vote: { + findFirst: vi.fn(async () => (options.hasVoted ? { id: 21 } : null)), + }, + } as unknown as DatabaseClient, + }) as GameApiContext; + +describe('general.getFrontStatus', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-26T10:30:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns ref-compatible current-turn online, nation notice, and new vote data', async () => { + const context = buildContext(); + const caller = appRouter.createCaller(context); + + const result = await caller.general.getFrontStatus(); + + expect(result).toEqual({ + onlineUserCount: 3, + onlineNations: '【촉】, 【위】', + onlineGenerals: '유비, 관우', + nationNotice: '

북벌 준비

', + lastExecuted: '2026-07-26T10:00:00.000Z', + latestVote: { + id: 12, + title: '다음 시즌 턴 시간', + hasVoted: false, + }, + }); + expect(context.db.generalAccessLog.findMany).toHaveBeenCalledWith({ + where: { + lastRefresh: { + gte: new Date('2026-07-26T10:00:00.000Z'), + }, + }, + select: { generalId: true }, + }); + }); + + it('reports that the session-owned general already voted', async () => { + const caller = appRouter.createCaller(buildContext({ hasVoted: true })); + + await expect(caller.general.getFrontStatus()).resolves.toMatchObject({ + latestVote: { + id: 12, + hasVoted: true, + }, + }); + }); + + it('requires a game session and does not expose names or policy publicly', async () => { + const caller = appRouter.createCaller(buildContext({ auth: null })); + + await expect(caller.general.getFrontStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); +}); diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 72dda91..16f2ce2 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -482,12 +482,9 @@ export class InMemoryTurnWorld { } getDiplomacyEntry(srcNationId: number, destNationId: number): TurnDiplomacy | null { - if (srcNationId === destNationId) { - return null; - } const key = buildDiplomacyKey(srcNationId, destNationId); let entry = this.diplomacy.get(key); - if (!entry && this.nations.has(srcNationId) && this.nations.has(destNationId)) { + if (!entry && srcNationId !== destNationId && this.nations.has(srcNationId) && this.nations.has(destNationId)) { entry = buildDefaultDiplomacy(srcNationId, destNationId); this.diplomacy.set(key, entry); this.dirtyDiplomacyKeys.add(key); diff --git a/app/game-frontend/src/components/main/MainFrontStatus.vue b/app/game-frontend/src/components/main/MainFrontStatus.vue new file mode 100644 index 0000000..85b39b5 --- /dev/null +++ b/app/game-frontend/src/components/main/MainFrontStatus.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index a79a0d5..c02c3c5 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -27,10 +27,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { type BoardAccess = Awaited>; type ReservedTurnView = Awaited>[number]; type RecentRecord = Awaited>['global'][number]; + type FrontStatus = Awaited>; const loading = ref(false); const error = ref(null); const recordsError = ref(null); + const frontStatusError = ref(null); const realtimeEnabled = ref(true); const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle'); @@ -47,6 +49,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const globalRecords = ref([]); const generalRecords = ref([]); const worldHistory = ref([]); + const frontStatus = ref(null); + const surveyNotice = ref | null>(null); let lastGeneralRecordId = 0; let lastWorldHistoryId = 0; let recordGeneralId: number | null = null; @@ -199,6 +203,28 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { } }; + const updateFrontStatus = (nextStatus: FrontStatus) => { + frontStatus.value = nextStatus; + const latestVote = nextStatus.latestVote; + if (!latestVote || latestVote.hasVoted || typeof window === 'undefined') { + surveyNotice.value = null; + return; + } + const serverId = session.profile?.split(':', 1)[0] ?? 'game'; + const storageKey = `state.${serverId}.lastVote`; + const lastSeenVoteId = Number.parseInt(window.localStorage.getItem(storageKey) ?? '0', 10); + if (latestVote.id <= (Number.isFinite(lastSeenVoteId) ? lastSeenVoteId : 0)) { + surveyNotice.value = null; + return; + } + window.localStorage.setItem(storageKey, latestVote.id.toString()); + surveyNotice.value = latestVote; + }; + + const dismissSurveyNotice = () => { + surveyNotice.value = null; + }; + const mergeRecentRecords = (current: RecentRecord[], incoming: RecentRecord[]): RecentRecord[] => { const merged = new Map(current.map((entry) => [entry.id, entry])); for (const entry of incoming) { @@ -214,6 +240,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { lastGeneralRecordId = 0; lastWorldHistoryId = 0; recordGeneralId = id; + frontStatus.value = null; + surveyNotice.value = null; }; const loadMainData = async () => { @@ -223,6 +251,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { loading.value = true; error.value = null; recordsError.value = null; + frontStatusError.value = null; try { const context = await trpc.general.me.query(); @@ -256,19 +285,35 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { 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(), - trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }), - trpc.turns.getCommandTable.query({ generalId: id }), - trpc.messages.getRecent.query({ generalId: id }), - trpc.messages.getContacts.query({ generalId: id }), - trpc.board.getAccess.query(), - generalTurnsPromise, - nationTurnsPromise, - recordsPromise, - ]); + const frontStatusPromise = trpc.general.getFrontStatus.query().catch((err: unknown) => { + frontStatusError.value = resolveErrorMessage(err); + return null; + }); + const [ + layout, + lobby, + map, + commands, + messageData, + contacts, + access, + generalTurns, + nationTurns, + records, + nextFrontStatus, + ] = 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 }), + trpc.messages.getContacts.query({ generalId: id }), + trpc.board.getAccess.query(), + generalTurnsPromise, + nationTurnsPromise, + recordsPromise, + frontStatusPromise, + ]); mapLayout.value = layout; lobbyInfo.value = lobby; @@ -290,6 +335,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { ); lastWorldHistoryId = Math.max(lastWorldHistoryId, records.history[0]?.id ?? 0); } + if (nextFrontStatus) { + updateFrontStatus(nextFrontStatus); + } if (initializedMailboxGeneralId !== id) { targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId; initializedMailboxGeneralId = id; @@ -639,6 +687,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { loading, error, recordsError, + frontStatusError, realtimeEnabled, realtimeStatus, generalContext, @@ -658,12 +707,15 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { globalRecords, generalRecords, worldHistory, + frontStatus, + surveyNotice, messageDraftText, targetMailbox, mailboxGroups, statusLine, realtimeLabel, setRealtimeEnabled, + dismissSurveyNotice, loadMainData, refreshMessages, sendMessage, diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 023dde4..bdb24bd 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -1,5 +1,5 @@