merge: 최신 main 변경을 계략 명령 브랜치에 통합
# Conflicts: # app/game-api/test/commandTable.test.ts # app/game-frontend/e2e/commandArguments.spec.ts
This commit is contained in:
@@ -119,6 +119,7 @@ describe('buildTurnCommandTable', () => {
|
||||
'che_단련',
|
||||
'che_숙련전환',
|
||||
'che_견문',
|
||||
'che_은퇴',
|
||||
'che_장비매매',
|
||||
'che_군량매매',
|
||||
'che_내정특기초기화',
|
||||
@@ -135,9 +136,58 @@ describe('buildTurnCommandTable', () => {
|
||||
'che_주민선정',
|
||||
],
|
||||
군사: ['che_징병', 'che_모병', 'che_훈련', 'che_사기진작', 'che_출병', 'che_집합', 'che_소집해제'],
|
||||
인사: ['che_이동', 'che_인재탐색', 'che_귀환', 'che_임관', 'che_랜덤임관'],
|
||||
인사: ['che_이동', 'che_강행', 'che_인재탐색', 'che_귀환', 'che_임관', 'che_랜덤임관'],
|
||||
계략: ['che_선동', 'che_탈취', 'che_파괴', 'che_화계'],
|
||||
국가: ['che_증여', 'che_헌납', 'che_물자조달', 'che_거병', 'che_건국', 'che_선양', 'che_해산'],
|
||||
국가: ['che_증여', 'che_헌납', 'che_물자조달', 'che_하야', 'che_거병', 'che_건국', 'che_선양', 'che_해산'],
|
||||
});
|
||||
});
|
||||
|
||||
it('projects the Ref availability boundaries for force move, retirement, and resignation', async () => {
|
||||
const buildTable = (general: GeneralRow, nation: NationRow | null = buildNation()) =>
|
||||
buildTurnCommandTable({
|
||||
worldState: buildWorldState(),
|
||||
general,
|
||||
city: buildCity(),
|
||||
nation,
|
||||
nationGenerals: null,
|
||||
});
|
||||
const findCommand = (table: Awaited<ReturnType<typeof buildTable>>, key: string) =>
|
||||
table.general.flatMap((group) => group.values).find((command) => command.key === key);
|
||||
|
||||
const ordinary = await buildTable(buildGeneral());
|
||||
expect(findCommand(ordinary, 'che_강행')).toMatchObject({
|
||||
name: '강행',
|
||||
reqArg: true,
|
||||
possible: true,
|
||||
inputFields: [{ key: 'destCityId', optionSource: 'cities' }],
|
||||
});
|
||||
expect(findCommand(ordinary, 'che_은퇴')).toMatchObject({
|
||||
name: '은퇴',
|
||||
possible: false,
|
||||
status: 'blocked',
|
||||
reason: '나이가 60세 이상이어야 합니다.',
|
||||
});
|
||||
expect(findCommand(ordinary, 'che_하야')).toMatchObject({
|
||||
name: '하야',
|
||||
possible: true,
|
||||
status: 'available',
|
||||
});
|
||||
|
||||
const oldEnough = await buildTable({ ...buildGeneral(), age: 60 } as GeneralRow);
|
||||
expect(findCommand(oldEnough, 'che_은퇴')).toMatchObject({ possible: true, status: 'available' });
|
||||
|
||||
const ruler = await buildTable({ ...buildGeneral(), officerLevel: 12 } as GeneralRow);
|
||||
expect(findCommand(ruler, 'che_하야')).toMatchObject({
|
||||
possible: false,
|
||||
status: 'blocked',
|
||||
reason: expect.stringContaining('군주'),
|
||||
});
|
||||
|
||||
const neutral = await buildTable({ ...buildGeneral(), nationId: 0, officerLevel: 0 } as GeneralRow, null);
|
||||
expect(findCommand(neutral, 'che_하야')).toMatchObject({
|
||||
possible: false,
|
||||
status: 'blocked',
|
||||
reason: '재야입니다.',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ const buildContext = (authenticated: boolean) => {
|
||||
},
|
||||
city: { findUnique: async () => null },
|
||||
nation: { findUnique: async () => null },
|
||||
generalAccessLog: { findUnique: async () => null },
|
||||
worldState: { findFirst: async () => ({ config: { const: {} } }) },
|
||||
},
|
||||
} as unknown as GameApiContext;
|
||||
|
||||
@@ -379,6 +379,7 @@ integration('general access tracking persistence', () => {
|
||||
name: yearbookProfile,
|
||||
scenario: 'default',
|
||||
},
|
||||
profileStatusSource: { get: async () => 'RUNNING' as const },
|
||||
} as unknown as GameApiContext;
|
||||
const boundaryCaller = endpointBoundaryRouter.createCaller(context);
|
||||
|
||||
|
||||
@@ -5,16 +5,28 @@ import { z } from 'zod';
|
||||
import type { GameApiContext } from '../src/context.js';
|
||||
import type { DatabaseClient } from '../src/context.js';
|
||||
|
||||
import { accessAuthedInputProcedure, router } from '../src/trpc.js';
|
||||
import { accessAuthedInputProcedure, accessLimitAuthedProcedure, router } from '../src/trpc.js';
|
||||
import {
|
||||
accessPageWeights,
|
||||
generalAccessEndpointWeights,
|
||||
getGeneralAccessState,
|
||||
recordGeneralAccess,
|
||||
recordGeneralAccessWeight,
|
||||
resolveGeneralAccessEndpointWeight,
|
||||
resolveAccessWindows,
|
||||
resolveGeneralScoreStartedAt,
|
||||
} from '../src/services/generalAccess.js';
|
||||
|
||||
const profile = { id: 'che', name: 'che:default', scenario: 'default' };
|
||||
const profileStatusSource = { get: vi.fn(async () => 'RUNNING' as const) };
|
||||
const accessContext = (db: DatabaseClient, token: GameSessionTokenPayload | null = auth()) => ({
|
||||
auth: token,
|
||||
db,
|
||||
profile,
|
||||
profileStatusSource,
|
||||
generalAccessTracking: true as const,
|
||||
});
|
||||
|
||||
const auth = (roles = ['user']): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile: 'che:default',
|
||||
@@ -30,7 +42,10 @@ const auth = (roles = ['user']): GameSessionTokenPayload => ({
|
||||
sanctions: {},
|
||||
});
|
||||
|
||||
const buildDb = (meta: Record<string, unknown> = {}) => {
|
||||
const buildDb = (
|
||||
meta: Record<string, unknown> = {},
|
||||
access: { lastRefresh: Date | null; refreshScore: number } | null = null
|
||||
) => {
|
||||
const executeRaw = vi.fn(async (_query: unknown) => 1);
|
||||
const queryRaw = vi.fn(async (_query: unknown) => [{ id: 41 }]);
|
||||
const transaction = vi.fn(
|
||||
@@ -38,7 +53,11 @@ const buildDb = (meta: Record<string, unknown> = {}) => {
|
||||
callback: (client: { $executeRaw: typeof executeRaw; $queryRaw: typeof queryRaw }) => Promise<unknown>
|
||||
) => callback({ $executeRaw: executeRaw, $queryRaw: queryRaw })
|
||||
);
|
||||
const findGeneral = vi.fn(async () => ({ id: 7, userId: 'user-7' }));
|
||||
const findGeneral = vi.fn(async () => ({
|
||||
id: 7,
|
||||
userId: 'user-7',
|
||||
turnTime: new Date('2026-07-26T03:10:00.000Z'),
|
||||
}));
|
||||
const findWorld = vi.fn(async () => ({
|
||||
id: 3,
|
||||
currentYear: 185,
|
||||
@@ -53,6 +72,7 @@ const buildDb = (meta: Record<string, unknown> = {}) => {
|
||||
const db = {
|
||||
$transaction: transaction,
|
||||
general: { findFirst: findGeneral },
|
||||
generalAccessLog: { findUnique: vi.fn(async () => access) },
|
||||
worldState: { findFirst: findWorld },
|
||||
} as unknown as DatabaseClient;
|
||||
return { db, executeRaw, queryRaw, transaction, findGeneral, findWorld };
|
||||
@@ -91,6 +111,7 @@ describe('general access tracking', () => {
|
||||
'general.dieOnPrestart': 1,
|
||||
'general.instantRetreat': 1,
|
||||
'messages.send': 1,
|
||||
'turns.getCommandTable': 1,
|
||||
'general.setMySetting': 0,
|
||||
'npc.setNationPolicy': 0,
|
||||
'npc.setNationPriority': 0,
|
||||
@@ -120,17 +141,20 @@ describe('general access tracking', () => {
|
||||
periodStartedAt: new Date('2026-07-26T03:10:00.000Z'),
|
||||
scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'),
|
||||
});
|
||||
expect(resolveGeneralScoreStartedAt(600, new Date('2026-07-26T03:20:00.000Z'))).toEqual(
|
||||
new Date('2026-07-26T03:10:00.000Z')
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the session user actor and the legacy page weight in one atomic upsert', async () => {
|
||||
const { db, executeRaw, queryRaw, transaction, findGeneral } = buildDb();
|
||||
const now = new Date('2026-07-26T03:05:00.000Z');
|
||||
|
||||
await expect(recordGeneralAccess({ auth: auth(), db }, 'nation-list', now)).resolves.toBe(true);
|
||||
await expect(recordGeneralAccess(accessContext(db), 'nation-list', now)).resolves.toBe(true);
|
||||
expect(findGeneral).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-7' },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, userId: true },
|
||||
select: { id: true, userId: true, turnTime: true },
|
||||
});
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(queryRaw).toHaveBeenCalledTimes(1);
|
||||
@@ -167,7 +191,7 @@ describe('general access tracking', () => {
|
||||
const { db, executeRaw, queryRaw, transaction } = buildDb();
|
||||
const now = new Date('2026-07-26T03:06:00.000Z');
|
||||
|
||||
await expect(recordGeneralAccessWeight({ auth: auth(), db }, 0, now)).resolves.toBe(true);
|
||||
await expect(recordGeneralAccessWeight(accessContext(db), 0, now)).resolves.toBe(true);
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect((queryRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0);
|
||||
expect((executeRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0);
|
||||
@@ -175,14 +199,42 @@ describe('general access tracking', () => {
|
||||
expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(now);
|
||||
});
|
||||
|
||||
it('blocks above the strict limit and lazily clears a score from before the own turn', async () => {
|
||||
const blocked = buildDb(
|
||||
{ refreshLimit: 120 },
|
||||
{ lastRefresh: new Date('2026-07-26T03:05:00.000Z'), refreshScore: 121 }
|
||||
);
|
||||
await expect(getGeneralAccessState(accessContext(blocked.db))).resolves.toMatchObject({
|
||||
refreshScore: 121,
|
||||
refreshLimit: 120,
|
||||
level: 2,
|
||||
nextAccessAt: new Date('2026-07-26T03:10:00.000Z'),
|
||||
});
|
||||
|
||||
const stale = buildDb(
|
||||
{ refreshLimit: 120 },
|
||||
{ lastRefresh: new Date('2026-07-26T02:59:59.999Z'), refreshScore: 999 }
|
||||
);
|
||||
await expect(getGeneralAccessState(accessContext(stale.db))).resolves.toMatchObject({
|
||||
refreshScore: 0,
|
||||
level: 0,
|
||||
});
|
||||
|
||||
const resolver = vi.fn(() => ({ ok: true }));
|
||||
const limitedRouter = router({ read: accessLimitAuthedProcedure.query(resolver) });
|
||||
await expect(
|
||||
limitedRouter.createCaller(accessContext(blocked.db) as unknown as GameApiContext).read()
|
||||
).rejects.toMatchObject({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: expect.stringContaining('자신의 턴이 되면 다시 접속 가능합니다.'),
|
||||
});
|
||||
expect(resolver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects weights that cannot come from a server-owned Ref call boundary', async () => {
|
||||
const fixture = buildDb();
|
||||
await expect(recordGeneralAccessWeight({ auth: auth(), db: fixture.db }, -1)).rejects.toBeInstanceOf(
|
||||
RangeError
|
||||
);
|
||||
await expect(recordGeneralAccessWeight({ auth: auth(), db: fixture.db }, 0.5)).rejects.toBeInstanceOf(
|
||||
RangeError
|
||||
);
|
||||
await expect(recordGeneralAccessWeight(accessContext(fixture.db), -1)).rejects.toBeInstanceOf(RangeError);
|
||||
await expect(recordGeneralAccessWeight(accessContext(fixture.db), 0.5)).rejects.toBeInstanceOf(RangeError);
|
||||
expect(fixture.findGeneral).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -201,6 +253,13 @@ describe('general access tracking', () => {
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: 7,
|
||||
userId: 'user-7',
|
||||
turnTime: new Date('2026-07-26T03:10:00.000Z'),
|
||||
})),
|
||||
},
|
||||
generalAccessLog: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
lastRefresh: new Date('2026-07-26T03:05:00.000Z'),
|
||||
refreshScore: 1,
|
||||
})),
|
||||
},
|
||||
worldState: {
|
||||
@@ -253,6 +312,7 @@ describe('general access tracking', () => {
|
||||
generalAccessTracking: true,
|
||||
requestId: 'access-boundary-test',
|
||||
profile: { id: 'che:default', name: 'che' },
|
||||
profileStatusSource,
|
||||
} as unknown as GameApiContext;
|
||||
|
||||
await expect(trackedRouter.createCaller(context).board.writeArticle({ value: 'ok' })).rejects.toMatchObject({
|
||||
@@ -279,21 +339,37 @@ describe('general access tracking', () => {
|
||||
|
||||
it('does not write for anonymous/admin users, a future opening, or a finished world', async () => {
|
||||
const anonymous = buildDb();
|
||||
await expect(recordGeneralAccess({ auth: null, db: anonymous.db }, 'traffic')).resolves.toBe(false);
|
||||
await expect(recordGeneralAccess(accessContext(anonymous.db, null), 'traffic')).resolves.toBe(false);
|
||||
expect(anonymous.findGeneral).not.toHaveBeenCalled();
|
||||
|
||||
const admin = buildDb();
|
||||
await expect(recordGeneralAccess({ auth: auth(['admin']), db: admin.db }, 'traffic')).resolves.toBe(false);
|
||||
await expect(recordGeneralAccess(accessContext(admin.db, auth(['admin'])), 'traffic')).resolves.toBe(false);
|
||||
expect(admin.findGeneral).not.toHaveBeenCalled();
|
||||
|
||||
const future = buildDb({ opentime: '2026-07-27T00:00:00.000Z' });
|
||||
await expect(
|
||||
recordGeneralAccess({ auth: auth(), db: future.db }, 'traffic', new Date('2026-07-26T03:05:00.000Z'))
|
||||
recordGeneralAccess(accessContext(future.db), 'traffic', new Date('2026-07-26T03:05:00.000Z'))
|
||||
).resolves.toBe(false);
|
||||
expect(future.transaction).not.toHaveBeenCalled();
|
||||
|
||||
const united = buildDb({ isUnited: 2 });
|
||||
await expect(recordGeneralAccess({ auth: auth(), db: united.db }, 'traffic')).resolves.toBe(false);
|
||||
await expect(recordGeneralAccess(accessContext(united.db), 'traffic')).resolves.toBe(false);
|
||||
expect(united.transaction).not.toHaveBeenCalled();
|
||||
|
||||
const paused = buildDb();
|
||||
const pausedContext = {
|
||||
...accessContext(paused.db),
|
||||
profileStatusSource: { get: vi.fn(async () => 'PAUSED' as const) },
|
||||
};
|
||||
await expect(recordGeneralAccess(pausedContext, 'traffic')).resolves.toBe(false);
|
||||
expect(paused.findGeneral).not.toHaveBeenCalled();
|
||||
|
||||
const unavailable = buildDb();
|
||||
const unavailableContext = {
|
||||
...accessContext(unavailable.db),
|
||||
profileStatusSource: { get: vi.fn(async () => Promise.reject(new Error('gateway unavailable'))) },
|
||||
};
|
||||
await expect(recordGeneralAccess(unavailableContext, 'traffic')).resolves.toBe(false);
|
||||
expect(unavailable.findGeneral).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,15 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import type { DatabaseClient, GameApiContext } from '../src/context.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
|
||||
const buildContext = (meta: Record<string, unknown>): GameApiContext =>
|
||||
const buildContext = (
|
||||
meta: Record<string, unknown>,
|
||||
clock: {
|
||||
baseTime?: Date;
|
||||
tick?: bigint;
|
||||
mode?: string;
|
||||
wallAnchor?: Date;
|
||||
} = {}
|
||||
): GameApiContext =>
|
||||
({
|
||||
auth: null,
|
||||
db: {
|
||||
@@ -16,6 +24,10 @@ const buildContext = (meta: Record<string, unknown>): GameApiContext =>
|
||||
tickSeconds: 3_600,
|
||||
config: {},
|
||||
meta,
|
||||
clockBaseTime: clock.baseTime ?? null,
|
||||
clockTick: clock.tick ?? null,
|
||||
clockMode: clock.mode ?? 'realtime',
|
||||
clockWallAnchor: clock.wallAnchor ?? null,
|
||||
updatedAt: new Date('2026-07-31T00:00:00.000Z'),
|
||||
})),
|
||||
},
|
||||
@@ -36,4 +48,23 @@ describe('lobby season state', () => {
|
||||
|
||||
expect(result.isUnited).toBe(isunited);
|
||||
});
|
||||
|
||||
it('returns the projected server game time and whether the clock is running', async () => {
|
||||
const result = await appRouter
|
||||
.createCaller(
|
||||
buildContext(
|
||||
{},
|
||||
{
|
||||
baseTime: new Date('2026-08-15T00:00:00.000Z'),
|
||||
tick: 72_000_000n,
|
||||
mode: 'manual',
|
||||
wallAnchor: new Date('2026-08-15T17:00:00.000Z'),
|
||||
}
|
||||
)
|
||||
)
|
||||
.lobby.info();
|
||||
|
||||
expect(result.serverTime).toBe('2026-08-15T02:00:00.000Z');
|
||||
expect(result.clockMode).toBe('manual');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { GatewayHttpProfileStatusSource } from '../src/auth/profileStatusSource.js';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('GatewayHttpProfileStatusSource', () => {
|
||||
it('uses an encoded path and a purpose-derived credential', async () => {
|
||||
const fetchMock = vi.fn<(input: string | URL, init?: RequestInit) => Promise<Response>>(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ profileName: 'che:default/한글', status: 'RUNNING' }), { status: 200 })
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const source = new GatewayHttpProfileStatusSource('http://gateway.internal/', 'root-secret');
|
||||
await expect(source.get('che:default/한글')).resolves.toBe('RUNNING');
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe('http://gateway.internal/internal/profile-status/che%3Adefault%2F%ED%95%9C%EA%B8%80');
|
||||
expect(init?.headers).toMatchObject({
|
||||
'x-sammo-internal-token': expect.not.stringContaining('root-secret'),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null only for a missing profile and rejects malformed status values', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(null, { status: 404 }))
|
||||
);
|
||||
await expect(new GatewayHttpProfileStatusSource('http://gateway', 'secret').get('missing')).resolves.toBeNull();
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(JSON.stringify({ profileName: 'che', status: 'UNKNOWN' }), { status: 200 }))
|
||||
);
|
||||
await expect(new GatewayHttpProfileStatusSource('http://gateway', 'secret').get('che')).rejects.toThrow(
|
||||
'invalid'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user