Merge branch 'main' into feature/main-signup-kakao-gate
# Conflicts: # app/gateway-frontend/src/views/LobbyView.vue
This commit is contained in:
@@ -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<number, typeof onlineGenerals>();
|
||||
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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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: '<p>북벌 준비</p>',
|
||||
},
|
||||
})),
|
||||
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: '<p>북벌 준비</p>',
|
||||
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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user