Merge branch 'main' into feature/dynasty-list-parity

# Conflicts:
#	tools/frontend-legacy-parity/playwright.config.mjs
This commit is contained in:
2026-07-26 05:41:13 +00:00
105 changed files with 13336 additions and 3227 deletions
+230 -21
View File
@@ -19,19 +19,30 @@ const profile: GameProfile = {
class QueuedBattleSimTransport implements BattleSimTransport {
public simulateCalls = 0;
public lastPayload: BattleSimJobPayload | null = null;
public lastRequesterUserId: string | null = null;
private readonly owners = new Map<string, string>();
private readonly results = new Map<string, BattleSimResultPayload>();
async simulate(payload: BattleSimJobPayload) {
async simulate(payload: BattleSimJobPayload, requesterUserId: string) {
this.simulateCalls += 1;
this.lastPayload = payload;
return { status: 'queued', jobId: 'job-1' } as const;
this.lastRequesterUserId = requesterUserId;
const jobId = `job-${this.simulateCalls}`;
this.owners.set(jobId, requesterUserId);
return { status: 'queued', jobId } as const;
}
async getSimulationResult(jobId: string) {
async getSimulationResult(jobId: string, requesterUserId: string) {
if (this.owners.get(jobId) !== requesterUserId) {
return null;
}
return this.results.get(jobId) ?? null;
}
pushResult(jobId: string, payload: BattleSimResultPayload) {
pushResult(jobId: string, requesterUserId: string, payload: BattleSimResultPayload) {
if (this.owners.get(jobId) !== requesterUserId) {
throw new Error('requester mismatch');
}
this.results.set(jobId, payload);
}
}
@@ -194,8 +205,13 @@ const buildBattleRequest = () => ({
},
});
const buildContext = (options: { state: WorldStateRow; battleSim: BattleSimTransport }): GameApiContext => {
const db = {
const buildContext = (options: {
state: WorldStateRow;
battleSim: BattleSimTransport;
userId?: string | null;
db?: Partial<DatabaseClient>;
}): GameApiContext => {
const db = options.db ?? {
worldState: {
findFirst: async () => options.state,
},
@@ -207,20 +223,23 @@ const buildContext = (options: { state: WorldStateRow; battleSim: BattleSimTrans
},
profile.name
);
const auth: GameSessionTokenPayload = {
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: {},
};
const auth: GameSessionTokenPayload | null =
options.userId === null
? null
: {
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: options.userId ?? 'user-1',
username: 'tester',
displayName: 'Tester',
roles: [],
},
sanctions: {},
};
return {
db: db as unknown as DatabaseClient,
turnDaemon: new InMemoryTurnDaemonTransport(),
@@ -255,14 +274,204 @@ describe('battle router orchestration', () => {
const response = await caller.battle.simulate(buildBattleRequest());
expect(response.status).toBe('queued');
expect(battleSim.simulateCalls).toBe(1);
expect(battleSim.lastRequesterUserId).toBe('user-1');
const queued = await caller.battle.getSimulation({ jobId: response.jobId });
expect(queued.status).toBe('queued');
battleSim.pushResult(response.jobId, { result: true, reason: 'success', avgWar: 1 });
battleSim.pushResult(response.jobId, 'user-1', { result: true, reason: 'success', avgWar: 1 });
const completed = await caller.battle.getSimulation({ jobId: response.jobId });
expect(completed.status).toBe('completed');
expect(completed.payload?.result).toBe(true);
});
it('requires login, allows a user without a general, and does not open an input-event transaction', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: {},
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
let transactionCalls = 0;
const db = {
worldState: { findFirst: async () => state },
$transaction: async () => {
transactionCalls += 1;
throw new Error('simulation must not create an input event transaction');
},
} as unknown as DatabaseClient;
const anonymous = appRouter.createCaller(buildContext({ state, battleSim, userId: null, db }));
await expect(anonymous.battle.simulate(buildBattleRequest())).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
const noGeneralUser = appRouter.createCaller(
buildContext({ state, battleSim, userId: 'user-without-general', db })
);
await expect(noGeneralUser.battle.simulate(buildBattleRequest())).resolves.toMatchObject({
status: 'queued',
});
expect(transactionCalls).toBe(0);
expect(battleSim.lastRequesterUserId).toBe('user-without-general');
});
it('does not expose queued results across authenticated users', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: {},
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const owner = appRouter.createCaller(buildContext({ state, battleSim, userId: 'owner-user' }));
const other = appRouter.createCaller(buildContext({ state, battleSim, userId: 'other-user' }));
const response = await owner.battle.simulate(buildBattleRequest());
battleSim.pushResult(response.jobId, 'owner-user', { result: true, reason: 'success', avgWar: 7 });
await expect(owner.battle.getSimulation({ jobId: response.jobId })).resolves.toMatchObject({
status: 'completed',
payload: { avgWar: 7 },
});
await expect(other.battle.getSimulation({ jobId: response.jobId })).resolves.toEqual({
status: 'queued',
jobId: response.jobId,
});
});
});
describe('battle simulator general import permissions', () => {
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: {},
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const buildGeneral = (overrides: Record<string, unknown>) => ({
id: 1,
userId: 'same-nation-user',
name: '관전자',
npcState: 0,
nationId: 1,
leadership: 70,
strength: 71,
intel: 72,
officerLevel: 1,
injury: 0,
rice: 9000,
crew: 5000,
crewTypeId: 100,
atmos: 100,
train: 100,
experience: 400,
horseCode: null,
weaponCode: null,
bookCode: null,
itemCode: null,
personalCode: null,
special2Code: null,
meta: {},
...overrides,
});
const actor = buildGeneral({ id: 1, userId: 'same-nation-user', nationId: 1 });
const ally = buildGeneral({
id: 2,
userId: 'ally-user',
name: '아군 장수',
nationId: 1,
officerLevel: 4,
rice: 4321,
crew: 3210,
train: 97,
atmos: 96,
horseCode: 'che_적토마',
weaponCode: 'che_의천검',
bookCode: 'che_손자병법',
itemCode: 'che_옥새',
meta: {
dex1: 10000,
rank_warnum: 33,
rank_killnum: 22,
rank_killcrew: 1111,
},
});
const foreignActor = buildGeneral({ id: 3, userId: 'foreign-user', nationId: 2 });
const generals = [actor, ally, foreignActor];
const db = {
worldState: { findFirst: async () => state },
general: {
findFirst: async ({ where }: { where: { userId: string } }) =>
generals.find((general) => general.userId === where.userId) ?? null,
findUnique: async ({ where }: { where: { id: number } }) =>
generals.find((general) => general.id === where.id) ?? null,
},
} as unknown as DatabaseClient;
it('returns full ally details to the same nation but redacts them for another nation', async () => {
const battleSim = new QueuedBattleSimTransport();
const sameNation = appRouter.createCaller(buildContext({ state, battleSim, userId: 'same-nation-user', db }));
const foreign = appRouter.createCaller(buildContext({ state, battleSim, userId: 'foreign-user', db }));
const visible = await sameNation.battle.getGeneralDetail({ generalId: ally.id });
expect(visible.general).toMatchObject({
name: '아군 장수',
officer_level: 4,
horse: 'che_적토마',
crew: 3210,
rice: 4321,
train: 97,
atmos: 96,
warnum: 33,
killnum: 22,
killcrew: 1111,
});
const redacted = await foreign.battle.getGeneralDetail({ generalId: ally.id });
expect(redacted.general).toMatchObject({
name: '아군 장수',
officer_level: 1,
horse: null,
weapon: null,
book: null,
item: null,
crew: 0,
rice: 10000,
dex1: 0,
warnum: 0,
killnum: 0,
killcrew: 0,
});
});
it('requires a game general only for server-side general import', async () => {
const caller = appRouter.createCaller(
buildContext({
state,
battleSim: new QueuedBattleSimTransport(),
userId: 'user-without-general',
db,
})
);
await expect(caller.battle.getGeneralDetail({ generalId: ally.id })).rejects.toMatchObject({
code: 'NOT_FOUND',
message: 'General not found',
});
});
});
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { buildBattleSimQueueKeys } from '../src/battleSim/keys.js';
import { RedisBattleSimTransport } from '../src/battleSim/redisTransport.js';
import type { BattleSimJob, BattleSimJobPayload } from '../src/battleSim/types.js';
class FakeRedisClient {
readonly values = new Map<string, string>();
readonly lists = new Map<string, string[]>();
async rPush(key: string, value: string): Promise<number> {
const list = this.lists.get(key) ?? [];
list.push(value);
this.lists.set(key, list);
return list.length;
}
async blPop(): Promise<null> {
return null;
}
async set(key: string, value: string): Promise<'OK'> {
this.values.set(key, value);
return 'OK';
}
async get(key: string): Promise<string | null> {
return this.values.get(key) ?? null;
}
async expire(): Promise<number> {
return 1;
}
}
describe('RedisBattleSimTransport requester isolation', () => {
it('records the requester on queued jobs and scopes completed results to that user', async () => {
const client = new FakeRedisClient();
const keys = buildBattleSimQueueKeys('che:test');
const transport = new RedisBattleSimTransport(client, {
keys,
requestTimeoutMs: 1,
resultTtlSeconds: 60,
});
const response = await transport.simulate({} as BattleSimJobPayload, 'user/one');
expect(response.status).toBe('queued');
const queuedRaw = client.lists.get(keys.queueKey)?.[0];
expect(queuedRaw).toBeTruthy();
expect(JSON.parse(queuedRaw ?? '{}') as BattleSimJob).toMatchObject({
jobId: response.jobId,
requesterUserId: 'user/one',
});
await transport.pushResult(response.jobId, 'user/one', {
result: true,
reason: 'success',
avgWar: 3,
});
await expect(transport.getSimulationResult(response.jobId, 'user/one')).resolves.toMatchObject({
result: true,
avgWar: 3,
});
await expect(transport.getSimulationResult(response.jobId, 'user/two')).resolves.toBeNull();
expect(Array.from(client.values.keys()).some((key) => key.includes('user%2Fone'))).toBe(true);
});
});
@@ -0,0 +1,94 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildBattleSimEnvironment } from '../src/battleSim/environment.js';
import { buildBattleSimQueueKeys } from '../src/battleSim/keys.js';
import { RedisBattleSimTransport } from '../src/battleSim/redisTransport.js';
import type { BattleSimRequestPayload } from '../src/battleSim/types.js';
import { runBattleSimWorker } from '../src/battleSim/worker.js';
import type { WorldStateRow } from '../src/context.js';
const liveDescribe = process.env.REDIS_URL ? describe : describe.skip;
afterEach(() => {
vi.unstubAllEnvs();
});
liveDescribe('battle simulator worker with live Redis', () => {
it('consumes an isolated queue, produces a result, and stops cleanly', { timeout: 30_000 }, async () => {
const scenario = `battle-sim-e2e-${randomUUID()}`;
const profileName = `che:${scenario}`;
const requesterUserId = 'worker-e2e-user';
vi.stubEnv('PROFILE', 'che');
vi.stubEnv('SCENARIO', scenario);
vi.stubEnv('GAME_TOKEN_SECRET', 'battle-sim-test-only');
const fixturePath = path.resolve(
process.cwd(),
'../../tools/integration-tests/fixtures/battle/basic-infantry.json'
);
const fixture = JSON.parse(await fs.readFile(fixturePath, 'utf8')) as BattleSimRequestPayload & {
startYear: number;
};
const { startYear, ...request } = fixture;
const worldState: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: request.year,
currentMonth: request.month,
tickSeconds: 600,
config: {},
meta: { scenarioMeta: { startYear } },
updatedAt: new Date(),
};
const environment = await buildBattleSimEnvironment(worldState, 'che');
const payload = {
...request,
unitSet: environment.unitSet,
config: environment.config,
time: { year: request.year, month: request.month, startYear },
};
const clientConnector = createRedisConnector(resolveRedisConfigFromEnv());
await clientConnector.connect();
const keys = buildBattleSimQueueKeys(profileName);
const transport = new RedisBattleSimTransport(clientConnector.client, {
keys,
requestTimeoutMs: 15_000,
resultTtlSeconds: 60,
});
const abortController = new AbortController();
const worker = runBattleSimWorker({ signal: abortController.signal });
let jobId: string | null = null;
try {
const result = await transport.simulate(payload, requesterUserId);
jobId = result.jobId;
expect(result.status).toBe('completed');
if (result.status === 'completed') {
expect(result.payload).toMatchObject({
result: true,
reason: 'success',
avgWar: 1,
});
expect(result.payload.phase).toBeGreaterThan(0);
}
} finally {
abortController.abort();
await worker;
if (jobId) {
const encodedRequester = encodeURIComponent(requesterUserId);
await clientConnector.client.del([
keys.queueKey,
`${keys.resultKeyPrefix}${encodedRequester}:${jobId}`,
`${keys.notifyKeyPrefix}${encodedRequester}:${jobId}`,
]);
}
await clientConnector.disconnect();
}
});
});
+55 -13
View File
@@ -53,13 +53,13 @@ const general = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
updatedAt: now,
...overrides,
});
const auth = (roles: string[] = []): GameSessionTokenPayload => ({
const auth = (roles: string[] = [], userId = 'user-1'): GameSessionTokenPayload => ({
version: 1,
profile: 'che:default',
issuedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + 86400000).toISOString(),
sessionId: 'session',
user: { id: 'user-1', username: 'tester', displayName: 'Tester', roles },
user: { id: userId, username: 'tester', displayName: 'Tester', roles },
sanctions: {},
});
const city = (id: number, nationId: number) => ({
@@ -88,15 +88,30 @@ const city = (id: number, nationId: number) => ({
meta: {},
});
const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Record<string, unknown> } = {}) => {
const context = (
options: {
me?: GeneralRow;
roles?: string[];
userId?: string;
nationMeta?: Record<string, unknown>;
nationLevel?: number;
stationCityId?: number;
} = {}
) => {
const me = options.me ?? general();
const cities = [city(1, 1), city(2, 2), city(3, 2), city(80, 1)];
const foreign = general({ id: 2, userId: 'user-2', name: '적군', nationId: 2, cityId: 2, crew: 777 });
const db = {
general: {
findFirst: vi.fn(async () => me),
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
where.userId === me.userId ? me : null
),
findMany: vi.fn(async (args: { where?: Record<string, unknown>; select?: Record<string, boolean> }) => {
if (args.where?.nationId === 1 && args.select?.cityId) return [{ cityId: me.cityId }];
if (args.where?.nationId === 1 && args.select?.cityId)
return [
{ cityId: me.cityId },
...(options.stationCityId ? [{ cityId: options.stationCityId }] : []),
];
if (args.where?.cityId === 2) return [foreign];
if (args.where?.cityId === 3) return [foreign];
if (args.where?.officerLevel) return [];
@@ -108,7 +123,7 @@ const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Reco
id: 1,
name: '아국',
color: '#008000',
level: 1,
level: options.nationLevel ?? 1,
capitalCityId: 1,
meta: options.nationMeta ?? {},
})),
@@ -130,7 +145,7 @@ const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Reco
turnDaemon: {} as GameApiContext['turnDaemon'],
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth: auth(options.roles),
auth: auth(options.roles, options.userId),
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
@@ -157,6 +172,25 @@ describe('in-game information permissions', () => {
expect(result.generals).toEqual([]);
});
it('derives the actor from the session user instead of accepting another user general', async () => {
const caller = appRouter.createCaller(context({ userId: 'user-2' }));
await expect(caller.world.getCurrentCity({ cityId: 1 })).rejects.toMatchObject({ code: 'NOT_FOUND' });
});
it('allows a nation member to select a city occupied by another general of the same nation', async () => {
const result = await appRouter.createCaller(context({ stationCityId: 2 })).world.getCurrentCity({ cityId: 2 });
expect(result.options.map((entry) => entry.id)).toContain(2);
expect(result.visibility.full).toBe(true);
});
it('does not grant a spy city while the nation has no active level', async () => {
const result = await appRouter
.createCaller(context({ nationMeta: { spy: { 2: 2 } }, nationLevel: 0 }))
.world.getCurrentCity({ cityId: 2 });
expect(result.options.map((entry) => entry.id)).not.toContain(2);
expect(result.visibility.full).toBe(false);
});
it('keeps adjacent foreign detail redacted and never reveals military fields', async () => {
const result = await appRouter
.createCaller(context({ me: general({ cityId: 80 }) }))
@@ -174,12 +208,20 @@ describe('in-game information permissions', () => {
expect(result.visibility.full).toBe(true);
expect(result.city.population).toBe(1000);
expect(result.generals[0]).toMatchObject({ crew: 777, train: null, atmos: null, crewTypeId: null });
expect(result.forceSummary).toMatchObject({
enemyCrew: 777,
enemyArmedGenerals: 1,
enemyGenerals: 1,
});
});
it('allows administrative roles to inspect all city and general fields', async () => {
const result = await appRouter.createCaller(context({ roles: ['admin'] })).world.getCurrentCity({ cityId: 3 });
expect(result.options).toHaveLength(4);
expect(result.visibility.full).toBe(true);
expect(result.generals[0]).toMatchObject({ crew: 777, train: 90, atmos: 90, crewTypeId: 1 });
});
it.each(['admin', 'superuser', 'admin.superuser'])(
'allows the %s role to inspect all city and general fields',
async (role) => {
const result = await appRouter.createCaller(context({ roles: [role] })).world.getCurrentCity({ cityId: 3 });
expect(result.options).toHaveLength(4);
expect(result.visibility.full).toBe(true);
expect(result.generals[0]).toMatchObject({ crew: 777, train: 90, atmos: 90, crewTypeId: 1 });
}
);
});
@@ -0,0 +1,257 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
import { appRouter } from '../src/router.js';
const now = new Date('2026-01-01T00:00:00.000Z');
const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
id: 7,
userId: 'user-7',
name: '검증장수',
nationId: 1,
cityId: 1,
troopId: 0,
npcState: 0,
affinity: null,
bornYear: 180,
deadYear: 300,
picture: 'default.jpg',
imageServer: 0,
leadership: 70,
strength: 60,
intel: 50,
injury: 0,
experience: 10,
dedication: 20,
officerLevel: 1,
gold: 1_000,
rice: 1_000,
crew: 100,
crewTypeId: 0,
train: 80,
atmos: 80,
weaponCode: 'None',
bookCode: 'None',
horseCode: 'None',
itemCode: 'None',
turnTime: now,
recentWarTime: null,
age: 20,
startAge: 20,
personalCode: 'None',
specialCode: 'None',
special2Code: 'None',
lastTurn: {},
meta: {
belong: 1,
permission: 'normal',
myset: 3,
tnmt: 0,
defence_train: 80,
use_treatment: 21,
use_auto_nation_turn: 1,
},
penalty: {},
createdAt: now,
updatedAt: now,
...overrides,
});
const auth: GameSessionTokenPayload = {
version: 1,
profile: 'che:default',
issuedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + 86_400_000).toISOString(),
sessionId: 'session-7',
user: { id: 'user-7', username: 'tester', displayName: 'Tester', roles: [] },
sanctions: {},
};
const createContext = (options: {
me?: GeneralRow;
targets?: GeneralRow[];
nationMeta?: Record<string, unknown>;
requestCommand?: ReturnType<typeof vi.fn>;
}) => {
const me = options.me ?? buildGeneral();
const targets = options.targets ?? [me];
const requestCommand =
options.requestCommand ?? vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: me.id }));
const generalFindUnique = vi.fn(
async ({ where }: { where: { id: number } }) => targets.find((general) => general.id === where.id) ?? null
);
const db = {
general: {
findFirst: vi.fn(async () => me),
findUnique: generalFindUnique,
findMany: vi.fn(async () => targets.filter((general) => general.nationId === me.nationId)),
update: vi.fn(),
},
city: { findUnique: vi.fn(async () => null) },
nation: {
findUnique: vi.fn(async () => ({
id: 1,
name: '위',
color: '#777777',
level: 3,
gold: 10_000,
rice: 20_000,
tech: 100,
typeCode: 'che_법가',
capitalCityId: 1,
meta: options.nationMeta ?? { secretlimit: 3 },
})),
},
worldState: {
findFirst: vi.fn(async () => ({
currentYear: 185,
currentMonth: 1,
tickSeconds: 600,
})),
},
logEntry: {
groupBy: vi.fn(async () => []),
findMany: vi.fn(async () => [{ id: 1, text: '기록' }]),
},
};
const redisClient = { get: async () => null, set: async () => null };
const context: GameApiContext = {
db: db as unknown as DatabaseClient,
redis: {} as RedisConnector['client'],
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:default'),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
return { context, db, requestCommand };
};
describe('in-game my information ownership', () => {
it('reads legacy top-level settings and dispatches only the session-owned general', async () => {
const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 }));
const fixture = createContext({ requestCommand });
const caller = appRouter.createCaller(fixture.context);
const me = await caller.general.me();
expect(me?.settings).toEqual({
tnmt: 0,
defence_train: 80,
use_treatment: 21,
use_auto_nation_turn: 1,
myset: 3,
});
await caller.general.setMySetting({ tnmt: 1, defence_train: 999 });
expect(requestCommand).toHaveBeenCalledWith({
type: 'setMySetting',
generalId: 7,
settings: { tnmt: 1, defence_train: 999 },
});
expect(fixture.db.general.update).not.toHaveBeenCalled();
});
it('uses the authenticated user for both the page and its logs without accepting a target general id', async () => {
const otherUser = buildGeneral({ id: 8, userId: 'user-8', name: '타유저' });
const fixture = createContext({ targets: [buildGeneral(), otherUser] });
const caller = appRouter.createCaller(fixture.context);
await expect(caller.general.me()).resolves.toMatchObject({
general: { id: 7, name: '검증장수' },
});
await expect(caller.general.getMyLog({ type: 'generalAction' })).resolves.toMatchObject({
type: 'generalAction',
logs: [{ id: 1 }],
});
expect(fixture.db.general.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: 'user-7' },
})
);
expect(fixture.db.logEntry.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ generalId: 7 }),
})
);
});
});
describe('battle-center general and user permissions', () => {
it('distinguishes an ordinary member, a tenured member, and an auditor', async () => {
const ordinary = createContext({
me: buildGeneral({ officerLevel: 1, meta: { belong: 1, permission: 'normal' } }),
nationMeta: { secretlimit: 3 },
});
await expect(appRouter.createCaller(ordinary.context).nation.getBattleCenter()).rejects.toMatchObject({
code: 'FORBIDDEN',
});
const tenured = createContext({
me: buildGeneral({ officerLevel: 1, meta: { belong: 3, permission: 'normal' } }),
nationMeta: { secretlimit: 3 },
});
await expect(appRouter.createCaller(tenured.context).nation.getBattleCenter()).resolves.toMatchObject({
me: { id: 7, permissionLevel: 1 },
});
const auditor = createContext({
me: buildGeneral({ officerLevel: 1, meta: { belong: 0, permission: 'auditor' } }),
nationMeta: { secretlimit: 3 },
});
await expect(appRouter.createCaller(auditor.context).nation.getBattleCenter()).resolves.toMatchObject({
me: { id: 7, permissionLevel: 3 },
});
});
it('redacts another user action log while allowing own, NPC, chief, and non-private logs', async () => {
const me = buildGeneral({ meta: { belong: 3, permission: 'normal' } });
const otherUser = buildGeneral({ id: 8, userId: 'user-8', name: '타유저', npcState: 0 });
const npc = buildGeneral({ id: 9, userId: null, name: 'NPC', npcState: 2 });
const foreign = buildGeneral({ id: 10, userId: 'user-10', name: '타국', nationId: 2 });
const memberFixture = createContext({
me,
targets: [me, otherUser, npc, foreign],
nationMeta: { secretlimit: 3 },
});
const member = appRouter.createCaller(memberFixture.context);
await expect(member.nation.getGeneralLog({ generalId: me.id, type: 'generalAction' })).resolves.toMatchObject({
generalId: me.id,
});
await expect(
member.nation.getGeneralLog({ generalId: otherUser.id, type: 'generalAction' })
).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(
member.nation.getGeneralLog({ generalId: otherUser.id, type: 'battleDetail' })
).resolves.toMatchObject({ generalId: otherUser.id });
await expect(member.nation.getGeneralLog({ generalId: npc.id, type: 'generalAction' })).resolves.toMatchObject({
generalId: npc.id,
});
await expect(
member.nation.getGeneralLog({ generalId: foreign.id, type: 'battleDetail' })
).rejects.toMatchObject({ code: 'FORBIDDEN' });
const chiefFixture = createContext({
me: buildGeneral({ officerLevel: 5 }),
targets: [buildGeneral({ officerLevel: 5 }), otherUser],
nationMeta: { secretlimit: 3 },
});
await expect(
appRouter
.createCaller(chiefFixture.context)
.nation.getGeneralLog({ generalId: otherUser.id, type: 'generalAction' })
).resolves.toMatchObject({ generalId: otherUser.id });
});
});
+266
View File
@@ -0,0 +1,266 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
import { appRouter } from '../src/router.js';
const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
id: 7,
userId: 'user-1',
name: '유비',
nationId: 1,
cityId: 1,
troopId: 0,
npcState: 0,
affinity: null,
bornYear: 180,
deadYear: 300,
picture: null,
imageServer: 0,
leadership: 70,
strength: 45,
intel: 85,
injury: 0,
experience: 0,
dedication: 0,
officerLevel: 1,
gold: 1000,
rice: 1000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
weaponCode: 'None',
bookCode: 'None',
horseCode: 'None',
itemCode: 'None',
turnTime: new Date('2026-07-26T00:00:00Z'),
recentWarTime: null,
age: 20,
startAge: 20,
personalCode: 'None',
specialCode: 'None',
special2Code: 'che_선봉',
lastTurn: {},
meta: {},
penalty: {},
createdAt: new Date('2026-07-26T00:00:00Z'),
updatedAt: new Date('2026-07-26T00:00:00Z'),
...overrides,
});
const buildAuth = (userId = 'user-1'): GameSessionTokenPayload => ({
version: 1,
profile: 'che:default',
issuedAt: '2026-07-26T00:00:00.000Z',
expiresAt: '2026-07-27T00:00:00.000Z',
sessionId: `session-${userId}`,
user: {
id: userId,
username: userId,
displayName: userId,
roles: [],
},
sanctions: {},
});
const worldState = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 4,
tickSeconds: 3600,
config: {
const: {
availableSpecialWar: ['che_선봉'],
allItems: {
weapon: {
che_무기_12_칠성검: 1,
che_무기_01_단도: 0,
},
},
},
},
meta: { hiddenSeed: 'test-seed', isUnited: 0, season: 1 },
updatedAt: new Date('2026-07-26T00:00:00Z'),
};
const buildContext = (options: {
auth?: GameSessionTokenPayload | null;
general?: GeneralRow | null;
target?: GeneralRow | null;
inheritancePoint?: number;
}) => {
const auth = options.auth === undefined ? buildAuth() : options.auth;
const general = options.general === undefined ? buildGeneral() : options.general;
const target =
options.target === undefined
? buildGeneral({ id: 8, userId: 'user-2', name: '조조', meta: { ownerName: '위유저' } })
: options.target;
const requestCommand = vi.fn(async (command: { type: string; generalId: number }) => ({
type: command.type,
ok: true,
generalId: command.generalId,
}));
const pointUpsert = vi.fn(async () => ({}));
const logCreate = vi.fn(async () => ({}));
const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : []));
const db = {
$queryRaw: vi.fn(async () => [{ value: options.inheritancePoint ?? 10_000 }]),
worldState: {
findFirst: vi.fn(async () => worldState),
},
general: {
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
general?.userId === where.userId ? general : null
),
findMany,
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
target?.id === where.id ? target : null
),
},
inheritancePoint: {
upsert: pointUpsert,
},
inheritanceLog: {
create: logCreate,
findMany: vi.fn(async () => []),
},
inheritanceUserState: {
findUnique: vi.fn(async () => null),
upsert: vi.fn(async () => ({})),
},
};
const accessTokenStore = new RedisAccessTokenStore(
{
get: async () => null,
set: async () => null,
},
'che:default'
);
const context: GameApiContext = {
db: db as unknown as DatabaseClient,
redis: {} as RedisConnector['client'],
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore,
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
return { context, requestCommand, pointUpsert, logCreate, findMany };
};
describe('inherit router actor and permission boundaries', () => {
it('rejects unauthenticated status and mutations', async () => {
const fixture = buildContext({ auth: null });
const caller = appRouter.createCaller(fixture.context);
await expect(caller.inherit.getStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
await expect(caller.inherit.buyHiddenBuff({ type: 'warAvoidRatio', level: 1 })).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
});
it('builds status only from the authenticated user general and filters target generals like ref', async () => {
const fixture = buildContext({});
const status = await appRouter.createCaller(fixture.context).inherit.getStatus();
expect(status.currentStat).toEqual({ leadership: 70, strength: 45, intel: 85 });
expect(status.availableTargetGenerals).toEqual([{ id: 8, name: '조조' }]);
expect(status.availableUnique).toEqual([
expect.objectContaining({ key: 'che_무기_12_칠성검', rawName: '칠성검' }),
]);
expect(status.buffLevels).toHaveProperty('domesticSuccessProb', 0);
expect(fixture.findMany).toHaveBeenCalledWith({
where: { id: { not: 7 }, npcState: { lt: 2 }, userId: { not: null } },
select: { id: true, name: true },
orderBy: { id: 'asc' },
});
});
it('does not dispatch or charge when the authenticated user owns no general', async () => {
const fixture = buildContext({
auth: buildAuth('user-2'),
general: buildGeneral({ userId: 'user-1' }),
});
await expect(
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
type: 'domesticSuccessProb',
level: 1,
})
).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
message: '장수가 존재하지 않습니다.',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
expect(fixture.pointUpsert).not.toHaveBeenCalled();
});
it('mutates only the authenticated user general and inheritance balance', async () => {
const fixture = buildContext({ inheritancePoint: 1000 });
await expect(
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
type: 'domesticSuccessProb',
level: 1,
})
).resolves.toEqual({ ok: true, remainPoint: 800 });
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
type: 'patchGeneral',
generalId: 7,
patch: expect.objectContaining({
meta: expect.objectContaining({
inheritBuff: JSON.stringify({ domesticSuccessProb: 1 }),
}),
}),
})
);
expect(fixture.pointUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId_key: { userId: 'user-1', key: 'previous' } },
update: { value: 800 },
})
);
});
it('reveals a target owner to the caller without using the caller general id from input', async () => {
const fixture = buildContext({ inheritancePoint: 1500 });
await expect(
appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 })
).resolves.toEqual({
ok: true,
ownerName: '위유저',
targetName: '조조',
});
expect(fixture.pointUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId_key: { userId: 'user-1', key: 'previous' } },
update: { value: 500 },
})
);
expect(fixture.logCreate).toHaveBeenCalledWith({
data: {
userId: 'user-1',
year: 200,
month: 4,
text: '1000 포인트로 장수 소유자 확인',
},
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
});
});
+336 -3
View File
@@ -30,7 +30,7 @@ const auth: GameSessionTokenPayload = {
sanctions: {},
};
const buildContext = (overrides: Record<string, unknown> = {}) => {
const buildContext = (overrides: Record<string, unknown> = {}, contextOverrides: Record<string, unknown> = {}) => {
const executeRaw = vi.fn(async () => 1);
const updateMany = vi.fn(async () => ({ count: 1 }));
const db = {
@@ -55,11 +55,15 @@ const buildContext = (overrides: Record<string, unknown> = {}) => {
$executeRaw: executeRaw,
...overrides,
};
const redis = {
set: vi.fn(async () => 'OK'),
publish: vi.fn(async () => 1),
};
const context = {
db,
auth,
profile: { id: 'che', scenario: 'default', name: 'che:default' },
redis: {},
redis,
turnDaemon: {},
battleSim: {},
uploadDir: 'uploads',
@@ -68,8 +72,9 @@ const buildContext = (overrides: Record<string, unknown> = {}) => {
accessTokenStore: {},
flushStore: {},
gameTokenSecret: 'test-secret',
...contextOverrides,
} as unknown as GameApiContext;
return { caller: appRouter.createCaller(context), db, executeRaw, updateMany };
return { caller: appRouter.createCaller(context), db, executeRaw, updateMany, redis };
};
describe('messages router missing-flow compatibility', () => {
@@ -99,6 +104,291 @@ describe('messages router missing-flow compatibility', () => {
expect(result.canRespondDiplomacy).toBe(true);
});
it('lists an appointed ambassador as permission 4 but keeps responses limited to officers', async () => {
const ambassador = {
...general,
officerLevel: 1,
meta: { permission: 'ambassador' },
} as GeneralRow;
const { caller } = buildContext({
general: {
findUnique: vi.fn(async () => ambassador),
findMany: vi.fn(async () => []),
},
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async () => ({ meta: {} })),
},
});
const result = await caller.messages.getRecent({ generalId: ambassador.id });
expect(result.permission).toBe(4);
expect(result.canRespondDiplomacy).toBe(false);
});
it('redacts recent and old diplomacy content below secret permission 3', async () => {
const diplomacyRow = {
id: 19,
mailbox: 9001,
type: 'diplomacy',
src: 9002,
dest: 9001,
time: new Date(),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: {
src: {
generalId: 8,
generalName: '외교관',
nationId: 2,
nationName: '촉',
color: '#000000',
icon: '',
},
dest: {
generalId: 0,
generalName: '',
nationId: 1,
nationName: '위',
color: '#ffffff',
icon: '',
},
text: '보이면 안 되는 외교 본문',
option: { action: 'noAggression' },
},
};
const queryRaw = vi.fn(async () => [diplomacyRow]);
const { caller } = buildContext({
$queryRaw: queryRaw,
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async () => ({ meta: {} })),
},
});
const recent = await caller.messages.getRecent({ generalId: general.id });
const old = await caller.messages.getOld({
generalId: general.id,
type: 'diplomacy',
to: 20,
});
expect(recent.permission).toBe(2);
expect(recent.diplomacy[0]).toMatchObject({
text: '(외교 메시지입니다)',
option: { action: 'noAggression', invalid: true },
});
expect(old.diplomacy[0]).toMatchObject({
text: '(외교 메시지입니다)',
option: { action: 'noAggression', invalid: true },
});
});
it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => {
const queryRaw = vi.fn(async () => [{ id: 51 }]);
const findNation = vi.fn(async ({ where }: { where: { id: number } }) => ({
id: where.id,
name: where.id === 1 ? '위' : '촉',
color: '#112233',
meta: {},
}));
const { caller } = buildContext({
$queryRaw: queryRaw,
nation: {
findMany: vi.fn(async () => []),
findUnique: findNation,
},
});
const result = await caller.messages.send({
generalId: general.id,
mailbox: 9002,
text: '국가 메시지',
});
expect(result.msgType).toBe('national');
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national']));
});
it('allows an ambassador to target a foreign nation mailbox as diplomacy', async () => {
const ambassador = {
...general,
officerLevel: 1,
meta: { permission: 'ambassador' },
} as GeneralRow;
const queryRaw = vi.fn(async () => [{ id: 52 }]);
const { caller } = buildContext({
$queryRaw: queryRaw,
general: {
findUnique: vi.fn(async () => ambassador),
findMany: vi.fn(async () => []),
},
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async ({ where }: { where: { id: number } }) => ({
id: where.id,
name: where.id === 1 ? '위' : '촉',
color: '#112233',
meta: {},
})),
},
});
const result = await caller.messages.send({
generalId: ambassador.id,
mailbox: 9002,
text: '외교 메시지',
});
expect(result.msgType).toBe('diplomacy');
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9002, 'diplomacy']));
});
it('blocks private messages between foreign ambassadors', async () => {
const ambassador = {
...general,
officerLevel: 1,
meta: { permission: 'ambassador' },
} as GeneralRow;
const foreignAmbassador = {
...ambassador,
id: 8,
userId: 'user-8',
name: '상대 외교관',
nationId: 2,
} as GeneralRow;
const { caller } = buildContext({
general: {
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
where.id === ambassador.id ? ambassador : foreignAmbassador
),
findMany: vi.fn(async () => []),
},
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async ({ where }: { where: { id: number } }) => ({
id: where.id,
name: where.id === 1 ? '위' : '촉',
color: '#112233',
meta: {},
})),
},
});
await expect(
caller.messages.send({
generalId: ambassador.id,
mailbox: foreignAmbassador.id,
text: '개인 메시지',
})
).rejects.toMatchObject({
code: 'FORBIDDEN',
message: '외교권자끼리는 메시지를 보낼 수 없습니다.',
});
});
it.each([
['public', { noSendPublicMsg: 1 }, 9999, '공개 메세지를 보낼 수 없습니다.'],
['private', { noSendPrivateMsg: 1 }, 8, '개인 메세지를 보낼 수 없습니다.'],
])('enforces the general %s-message penalty', async (_type, penalty, mailbox, message) => {
const penalized = { ...general, penalty } as GeneralRow;
const { caller } = buildContext({
general: {
findUnique: vi.fn(async () => penalized),
findMany: vi.fn(async () => []),
},
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#fff', meta: {} })),
},
});
await expect(
caller.messages.send({
generalId: penalized.id,
mailbox,
text: '차단 메시지',
})
).rejects.toMatchObject({ code: 'FORBIDDEN', message });
});
it('enforces the legacy private-message interval through Redis without touching lifecycle', async () => {
const redis = {
set: vi.fn(async () => null),
publish: vi.fn(async () => 1),
};
const { caller } = buildContext(
{
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#fff', meta: {} })),
},
},
{ redis }
);
await expect(
caller.messages.send({
generalId: general.id,
mailbox: 8,
text: '너무 빠른 메시지',
})
).rejects.toMatchObject({
code: 'TOO_MANY_REQUESTS',
message: '개인메세지는 2초당 1건만 보낼 수 있습니다!',
});
});
it('blocks sends for a muted authenticated user independently of general permission', async () => {
const mutedAuth = {
...auth,
sanctions: { mutedUntil: '2099-01-01T00:00:00.000Z' },
};
const { caller } = buildContext({}, { auth: mutedAuth });
await expect(
caller.messages.send({
generalId: general.id,
mailbox: 9999,
text: '사용자 mute',
})
).rejects.toMatchObject({
code: 'FORBIDDEN',
message: '메시지 전송이 제한된 계정입니다.',
});
});
it('rejects every remaining general-scoped message mutation for another user general', async () => {
const foreignGeneral = { ...general, userId: 'user-8' } as GeneralRow;
const { caller } = buildContext({
general: {
findUnique: vi.fn(async () => foreignGeneral),
findMany: vi.fn(async () => []),
},
});
await expect(caller.messages.getContacts({ generalId: foreignGeneral.id })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(
caller.messages.readLatest({
generalId: foreignGeneral.id,
type: 'private',
messageId: 1,
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.messages.delete({ generalId: foreignGeneral.id, messageId: 1 })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(
caller.messages.respond({
generalId: foreignGeneral.id,
messageId: 1,
response: true,
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('persists latest-read updates through the monotonic upsert', async () => {
const { caller, executeRaw } = buildContext();
@@ -154,6 +444,49 @@ describe('messages router missing-flow compatibility', () => {
});
});
it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => {
const queryRaw = vi.fn(async () => [
{
id: 25,
mailbox: 9001,
type: 'diplomacy',
src: 9001,
dest: 9002,
time: new Date(),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: {
src: {
generalId: general.id,
generalName: general.name,
nationId: 1,
nationName: '위',
color: '#fff',
icon: '',
},
dest: {
generalId: 0,
generalName: '',
nationId: 2,
nationName: '촉',
color: '#000',
icon: '',
},
text: '일반 외교 메시지',
option: { receiverMessageID: 26 },
},
},
]);
const { caller, updateMany } = buildContext({ $queryRaw: queryRaw });
const result = await caller.messages.delete({ generalId: general.id, messageId: 25 });
expect(result.deletedIds).toEqual([25]);
expect(updateMany).toHaveBeenCalledWith({
where: { id: { in: [25] } },
data: { validUntil: expect.any(Date) },
});
});
it('rejects deleting another general message', async () => {
const queryRaw = vi.fn(async () => [
{
@@ -14,8 +14,12 @@ const integration = describe.skipIf(!databaseUrl);
const bettingId = 990_071;
const concurrentBettingId = 990_072;
const generalId = 9_971;
const otherGeneralId = 9_972;
const nationId = 990_071;
const otherNationId = 990_072;
const userId = 'nation-betting-router-user';
const otherUserId = 'nation-betting-router-other-user';
const noGeneralUserId = 'nation-betting-router-no-general-user';
const auth: GameSessionTokenPayload = {
version: 1,
@@ -32,12 +36,34 @@ const auth: GameSessionTokenPayload = {
sanctions: {},
};
const otherAuth: GameSessionTokenPayload = {
...auth,
sessionId: 'nation-betting-router-other-session',
user: {
...auth.user,
id: otherUserId,
username: 'other-bettor',
displayName: 'Other Bettor',
},
};
const noGeneralAuth: GameSessionTokenPayload = {
...auth,
sessionId: 'nation-betting-router-no-general-session',
user: {
...auth.user,
id: noGeneralUserId,
username: 'no-general',
displayName: 'No General',
},
};
integration('nation betting router', () => {
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
let worldStateId: number;
const buildContext = (requestId: string): GameApiContext => {
const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload | null = auth): GameApiContext => {
const redisClient = {
get: async () => null,
set: async () => null,
@@ -52,7 +78,7 @@ integration('nation betting router', () => {
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
auth,
auth: actorAuth,
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:2'),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
@@ -64,33 +90,55 @@ integration('nation betting router', () => {
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
await db.rankData.deleteMany({ where: { generalId } });
await db.inheritanceLog.deleteMany({ where: { userId } });
await db.inheritancePoint.deleteMany({ where: { userId } });
await db.general.deleteMany({ where: { id: generalId } });
await db.nation.deleteMany({ where: { id: nationId } });
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
await db.nation.create({
data: {
id: nationId,
name: '베팅국',
color: '#123456',
level: 2,
},
await db.nation.createMany({
data: [
{
id: nationId,
name: '베팅국',
color: '#123456',
level: 2,
},
{
id: otherNationId,
name: '다른베팅국',
color: '#654321',
level: 6,
},
],
});
await db.general.create({
data: {
id: generalId,
userId,
name: '베팅장수',
nationId,
cityId: 1,
npcState: 0,
turnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: {},
},
await db.general.createMany({
data: [
{
id: generalId,
userId,
name: '베팅장수',
nationId,
cityId: 1,
npcState: 0,
officerLevel: 0,
turnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: {},
},
{
id: otherGeneralId,
userId: otherUserId,
name: '다른국가수뇌',
nationId: otherNationId,
cityId: 1,
npcState: 0,
officerLevel: 12,
turnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: {},
},
],
});
const world = await db.worldState.create({
data: {
@@ -132,19 +180,22 @@ integration('nation betting router', () => {
candidates: [{ title: '베팅국', info: '', isHtml: true, aux: { nation: nationId } }],
},
});
await db.inheritancePoint.create({
data: { userId, key: 'previous', value: 1_000 },
await db.inheritancePoint.createMany({
data: [
{ userId, key: 'previous', value: 1_000 },
{ userId: otherUserId, key: 'previous', value: 500 },
],
});
});
afterAll(async () => {
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
await db.rankData.deleteMany({ where: { generalId } });
await db.inheritanceLog.deleteMany({ where: { userId } });
await db.inheritancePoint.deleteMany({ where: { userId } });
await db.general.deleteMany({ where: { id: generalId } });
await db.nation.deleteMany({ where: { id: nationId } });
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
await db.worldState.delete({ where: { id: worldStateId } });
await closeDb?.();
});
@@ -229,12 +280,107 @@ integration('nation betting router', () => {
}),
]);
expect(results.map((result) => result.status).sort()).toEqual(['fulfilled', 'rejected']);
expect(await db.nationBet.aggregate({ where: { bettingId: concurrentBettingId }, _sum: { amount: true } }))
.toMatchObject({ _sum: { amount: 600 } });
expect(
await db.nationBet.aggregate({ where: { bettingId: concurrentBettingId }, _sum: { amount: true } })
).toMatchObject({ _sum: { amount: 600 } });
expect(
await db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId, key: 'previous' } },
})
).toMatchObject({ value: 250 });
});
it('requires authentication and an owned player general for every betting operation', async () => {
await expect(
appRouter.createCaller(buildContext('nation-betting-anonymous-list', null)).betting.getList({
req: 'bettingNation',
})
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
await expect(
appRouter
.createCaller(buildContext('nation-betting-anonymous-detail', null))
.betting.getDetail({ bettingId })
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
await expect(
appRouter.createCaller(buildContext('nation-betting-anonymous-bet', null)).betting.bet({
bettingId,
bettingType: [0],
amount: 10,
})
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
await expect(
appRouter.createCaller(buildContext('nation-betting-no-general-list', noGeneralAuth)).betting.getList({
req: 'bettingNation',
})
).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' });
await expect(
appRouter
.createCaller(buildContext('nation-betting-no-general-detail', noGeneralAuth))
.betting.getDetail({ bettingId })
).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' });
await expect(
appRouter.createCaller(buildContext('nation-betting-no-general-bet', noGeneralAuth)).betting.bet({
bettingId,
bettingType: [0],
amount: 10,
})
).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' });
});
it('allows generals across nation and office levels while isolating each session user bet', async () => {
await expect(
appRouter.createCaller(buildContext('nation-betting-other-list', otherAuth)).betting.getList({
req: 'bettingNation',
})
).resolves.toMatchObject({
result: true,
bettingList: {
[bettingId]: { name: '천통국 예상' },
},
});
await expect(
appRouter.createCaller(buildContext('nation-betting-other-bet', otherAuth)).betting.bet({
bettingId,
bettingType: [0],
amount: 100,
})
).resolves.toEqual({ result: true });
const [firstUserDetail, otherUserDetail] = await Promise.all([
appRouter.createCaller(buildContext('nation-betting-first-user-detail')).betting.getDetail({ bettingId }),
appRouter
.createCaller(buildContext('nation-betting-other-user-detail', otherAuth))
.betting.getDetail({ bettingId }),
]);
expect(firstUserDetail.myBetting).toEqual([['[0]', 150]]);
expect(otherUserDetail.myBetting).toEqual([['[0]', 100]]);
expect(firstUserDetail.bettingDetail).toEqual([['[0]', 250]]);
expect(otherUserDetail.bettingDetail).toEqual([['[0]', 250]]);
expect(
await db.nationBet.findUniqueOrThrow({
where: {
bettingId_userId_selectionKey: {
bettingId,
userId: otherUserId,
selectionKey: '[0]',
},
},
})
).toMatchObject({
generalId: otherGeneralId,
userId: otherUserId,
amount: 100,
});
expect(
await db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId: otherUserId, key: 'previous' } },
})
).toMatchObject({ value: 400 });
expect(
await db.rankData.findUniqueOrThrow({
where: { generalId_type: { generalId: otherGeneralId, type: 'inherit_spent_dyn' } },
})
).toMatchObject({ nationId: otherNationId, value: 100 });
});
});
@@ -0,0 +1,142 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
import { appRouter } from '../src/router.js';
const now = new Date('2026-01-01T01:02:00Z');
const general = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
id: 1,
userId: 'u1',
name: '장수',
nationId: 1,
cityId: 1,
troopId: 0,
npcState: 0,
affinity: null,
bornYear: 180,
deadYear: 300,
picture: null,
imageServer: 0,
leadership: 70,
strength: 60,
intel: 50,
injury: 0,
experience: 900,
dedication: 100,
officerLevel: 1,
gold: 1000,
rice: 2000,
crew: 300,
crewTypeId: 1,
train: 90,
atmos: 90,
weaponCode: 'None',
bookCode: 'None',
horseCode: 'None',
itemCode: 'None',
turnTime: now,
recentWarTime: null,
age: 20,
startAge: 20,
personalCode: 'None',
specialCode: 'None',
special2Code: 'None',
lastTurn: {},
meta: { belong: 1, defence_train: 80, killturn: 7 },
penalty: {},
createdAt: now,
updatedAt: now,
...overrides,
});
const token = (userId: string): GameSessionTokenPayload => ({
version: 1,
profile: 'che:default',
issuedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + 86400000).toISOString(),
sessionId: userId,
user: { id: userId, username: userId, displayName: userId, roles: [] },
sanctions: {},
});
const fixture = (generals: GeneralRow[], userId = 'u1') => {
const db = {
general: {
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
generals.find((g) => g.userId === where.userId)
),
findMany: vi.fn(async ({ where }: { where: { nationId: number } }) =>
generals.filter((g) => g.nationId === where.nationId)
),
},
nation: {
findUnique: vi.fn(async () => ({
id: 1,
name: '위',
color: '#080',
level: 3,
typeCode: 'che_중립',
capitalCityId: 1,
meta: { secretlimit: 3 },
})),
},
city: { findMany: vi.fn(async () => [{ id: 1, name: '업' }]) },
troop: { findMany: vi.fn(async () => [{ troopLeaderId: 2, name: '선봉대' }]) },
worldState: { findFirst: vi.fn(async () => null) },
generalTurn: { findMany: vi.fn(async () => [{ generalId: 1, turnIdx: 0, actionCode: '징병' }]) },
generalAccessLog: {
findMany: vi.fn(async () => generals.map((g) => ({ generalId: g.id, refreshScoreTotal: g.id * 10 }))),
},
};
const redis = { get: vi.fn(async () => null), set: vi.fn(async () => null) } as unknown as RedisConnector['client'];
const context: GameApiContext = {
db: db as unknown as DatabaseClient,
redis,
turnDaemon: {} as GameApiContext['turnDaemon'],
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth: token(userId),
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore: new RedisAccessTokenStore(redis, 'che:default'),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'secret',
};
return { caller: appRouter.createCaller(context), db };
};
describe('nation general and secret office permissions', () => {
it('redacts ordinary-member details and denies the secret office', async () => {
const { caller } = fixture([general()]);
const result = await caller.nation.getGeneralList();
expect(result.viewer).toEqual({ generalId: 1, permission: 0 });
expect(result.generals[0]).toMatchObject({
officerLevel: 1,
cityName: null,
troopName: null,
refreshScoreTotal: 10,
});
expect(result.generals[0]).not.toHaveProperty('crew');
await expect(caller.nation.getSecretGeneralList()).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('uses the session-owned general and scopes secret rows to that nation', async () => {
const first = general();
const actor = general({ id: 2, userId: 'u2', officerLevel: 5, meta: { belong: 1 } });
const ally = general({ id: 3, userId: 'u3', gold: 3000, crew: 200, train: 80, atmos: 80 });
const foreign = general({ id: 4, userId: 'u4', nationId: 2, gold: 99999 });
const { caller, db } = fixture([first, actor, ally, foreign], 'u2');
const result = await caller.nation.getSecretGeneralList();
expect(result.viewer).toEqual({ generalId: 2, permission: 2 });
expect(result.generals.map((g) => g.id)).toEqual([1, 2, 3]);
expect(result.summary).toMatchObject({ gold: 5000, crew: 800, generalCount: 3 });
expect(db.general.findFirst).toHaveBeenCalledWith({ where: { userId: 'u2' } });
expect(db.general.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { nationId: 1 } }));
});
it('honors general penalties after a user switch', async () => {
const penalized = general({ officerLevel: 5, penalty: { noChief: true } });
const { caller } = fixture([penalized]);
await expect(caller.nation.getSecretGeneralList()).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
});
+275
View File
@@ -0,0 +1,275 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
import { appRouter } from '../src/router.js';
const baseGeneral: GeneralRow = {
id: 22,
userId: 'user-22',
name: '정책담당',
nationId: 1,
cityId: 1,
troopId: 0,
npcState: 0,
affinity: null,
bornYear: 180,
deadYear: 300,
picture: 'default.jpg',
imageServer: 0,
leadership: 70,
strength: 70,
intel: 70,
injury: 0,
experience: 0,
dedication: 0,
officerLevel: 12,
gold: 1_000,
rice: 1_000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
weaponCode: 'None',
bookCode: 'None',
horseCode: 'None',
itemCode: 'None',
turnTime: new Date('2026-01-01T00:00:00.000Z'),
recentWarTime: null,
age: 20,
startAge: 20,
personalCode: 'None',
specialCode: 'None',
special2Code: 'None',
lastTurn: {},
meta: { belong: 5, permission: 'normal' },
penalty: {},
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
};
const auth: GameSessionTokenPayload = {
version: 1,
profile: 'che:default',
issuedAt: '2026-01-01T00:00:00.000Z',
expiresAt: '2026-01-02T00:00:00.000Z',
sessionId: 'session-22',
user: { id: 'user-22', username: 'tester', displayName: 'Tester', roles: [] },
sanctions: {},
};
const baseNation = {
id: 1,
name: '위',
level: 3,
tech: 3_000,
meta: {
_updatedAt: '2026-01-01T00:00:00.000Z',
npc_nation_policy: {
values: { reqNationRice: 456 },
priority: ['천도', '천도'],
},
npc_general_policy: {
priority: ['출병', '일반내정', '출병'],
},
},
};
const baseWorld = {
config: {
stat: { max: 80, npcMax: 75 },
environment: { unitSet: 'basic' },
const: { develCost: 100 },
},
meta: {
npc_nation_policy: { values: { reqNationGold: 123 } },
npc_general_policy: {},
},
};
const createContext = (
options: {
me?: GeneralRow;
nation?: typeof baseNation;
world?: typeof baseWorld;
requestCommand?: ReturnType<typeof vi.fn>;
troopRows?: Array<{ troopLeaderId: number }>;
cityRows?: Array<{ id: number }>;
} = {}
): { context: GameApiContext; findFirst: ReturnType<typeof vi.fn>; requestCommand: ReturnType<typeof vi.fn> } => {
const requestCommand =
options.requestCommand ??
vi.fn(async () => ({
type: 'setNationMeta',
ok: true,
nationId: 1,
updatedAt: '2026-01-01T00:01:00.000Z',
}));
const findFirst = vi.fn(async () => options.me ?? baseGeneral);
const db = {
general: { findFirst },
nation: { findUnique: vi.fn(async () => options.nation ?? baseNation) },
worldState: { findFirst: vi.fn(async () => options.world ?? baseWorld) },
troop: { findMany: vi.fn(async () => options.troopRows ?? [{ troopLeaderId: 101 }]) },
city: { findMany: vi.fn(async () => options.cityRows ?? [{ id: 1 }, { id: 2 }]) },
};
const redisClient = { get: async () => null, set: async () => null };
return {
context: {
db: db as unknown as DatabaseClient,
redis: {} as RedisConnector['client'],
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:default'),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
},
findFirst,
requestCommand,
};
};
describe('NPC policy router', () => {
it('loads server and nation overrides while calculating legacy zero-value hints from nation tech', async () => {
const fixture = createContext();
const result = await appRouter.createCaller(fixture.context).npc.getPolicy();
expect(fixture.findFirst).toHaveBeenCalledWith({ where: { userId: 'user-22' } });
expect(result.currentNationPolicy).toMatchObject({ reqNationGold: 123, reqNationRice: 456 });
expect(result.currentNationPriority).toEqual(['천도', '천도']);
expect(result.currentGeneralActionPriority).toEqual(['출병', '일반내정', '출병']);
expect(result.zeroPolicy).toMatchObject({
reqNationGold: 10_000,
reqNationRice: 12_000,
reqNPCDevelGold: 3_000,
reqNPCWarGold: 3_900,
reqNPCWarRice: 3_900,
reqHumanWarUrgentGold: 6_300,
reqHumanWarUrgentRice: 6_300,
reqHumanWarRecommandGold: 12_600,
reqHumanWarRecommandRice: 12_600,
});
});
it('lets a secret-level reader load the page but rejects every mutation before daemon dispatch', async () => {
const reader = { ...baseGeneral, officerLevel: 2 };
const fixture = createContext({ me: reader });
const caller = appRouter.createCaller(fixture.context);
await expect(caller.npc.getPolicy()).resolves.toMatchObject({ permissionLevel: 1 });
await expect(caller.npc.setNationPriority(['천도'])).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.npc.setGeneralPriority(['출병', '일반내정'])).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(caller.npc.setNationPolicy({ reqNationGold: 100 })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
});
it.each([
['군주', { ...baseGeneral, officerLevel: 12 }],
['감찰권자', { ...baseGeneral, officerLevel: 1, meta: { belong: 0, permission: 'auditor' } }],
['외교권자', { ...baseGeneral, officerLevel: 1, meta: { belong: 0, permission: 'ambassador' } }],
])('%s can persist policy through the daemon-owned metadata command', async (_label, me) => {
const fixture = createContext({ me });
await expect(appRouter.createCaller(fixture.context).npc.setNationPriority(['천도', '천도'])).resolves.toEqual({
ok: true,
});
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'setNationMeta',
nationId: 1,
updates: {
npc_nation_policy: expect.objectContaining({
priority: ['천도', '천도'],
prioritySetter: '정책담당',
prioritySetTime: expect.any(String),
}),
},
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
});
});
it('clamps legacy integer values, preserves float values, and validates troop ownership before dispatch', async () => {
const fixture = createContext();
const caller = appRouter.createCaller(fixture.context);
await caller.npc.setNationPolicy({
reqNationGold: -100,
safeRecruitCityPopulationRatio: -0.5,
CombatForce: { 101: [1, 2] },
});
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
updates: {
npc_nation_policy: expect.objectContaining({
values: expect.objectContaining({
reqNationGold: 0,
safeRecruitCityPopulationRatio: -0.5,
CombatForce: { 101: [1, 2] },
}),
}),
},
})
);
fixture.requestCommand.mockClear();
await expect(caller.npc.setNationPolicy({ SupportForce: [999] })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
});
it('preserves duplicate legacy priority entries and enforces required general actions and ordering', async () => {
const fixture = createContext();
const caller = appRouter.createCaller(fixture.context);
await caller.npc.setGeneralPriority(['출병', '출병', '일반내정']);
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
updates: {
npc_general_policy: expect.objectContaining({
priority: ['출병', '출병', '일반내정'],
}),
},
})
);
await expect(caller.npc.setGeneralPriority(['일반내정', '출병'])).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
await expect(caller.npc.setGeneralPriority(['출병'])).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
it('blocks nationless, penalized, and stale writers without changing lifecycle state directly', async () => {
const nationless = createContext({ me: { ...baseGeneral, nationId: 0, officerLevel: 0 } });
await expect(appRouter.createCaller(nationless.context).npc.getPolicy()).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
});
const penalized = createContext({ me: { ...baseGeneral, penalty: { noChief: true } } });
await expect(appRouter.createCaller(penalized.context).npc.getPolicy()).rejects.toMatchObject({
code: 'FORBIDDEN',
});
const staleCommand = vi.fn(async () => ({
type: 'setNationMeta',
ok: false,
nationId: 1,
reason: 'CONFLICT',
}));
const stale = createContext({ requestCommand: staleCommand });
await expect(appRouter.createCaller(stale.context).npc.setNationPriority(['천도'])).rejects.toMatchObject({
code: 'CONFLICT',
});
});
});
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest';
import type { RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js';
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
import { appRouter } from '../src/router.js';
const profile: GameProfile = {
id: 'che',
scenario: 'default',
name: 'che:default',
};
const buildContext = (): GameApiContext => {
const db = {
worldState: {
findFirst: async () => ({
id: 1,
currentYear: 185,
currentMonth: 3,
tickSeconds: 600,
config: {},
meta: {
lastTurnTime: '2026-07-26T03:00:00.000Z',
refresh: 12,
maxrefresh: 30,
maxonline: 5,
recentTraffic: [
{
year: 185,
month: 2,
refresh: 30,
online: 5,
date: '2026-07-26 02:50:00',
},
],
},
}),
},
generalAccessLog: {
aggregate: async () => ({
_sum: {
refresh: 12,
refreshScoreTotal: 21,
},
}),
count: async (args: { where: { lastRefresh: { gte: Date } } }) => {
expect(args.where.lastRefresh.gte).toEqual(new Date('2026-07-26T03:00:00.000Z'));
return 2;
},
findMany: async () => [
{ generalId: 7, refresh: 9, refreshScoreTotal: 15 },
{ generalId: 8, refresh: 3, refreshScoreTotal: 6 },
],
},
general: {
findMany: async () => [
{ id: 7, name: '갑' },
{ id: 8, name: '을' },
],
},
};
const redis = {
get: async () => null,
set: async () => null,
} as unknown as RedisConnector['client'];
return {
db: db as unknown as DatabaseClient,
turnDaemon: new InMemoryTurnDaemonTransport(),
battleSim: new InMemoryBattleSimTransport(),
profile,
auth: null,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
redis,
accessTokenStore: new RedisAccessTokenStore(redis, profile.name),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
};
describe('public.getTraffic', () => {
it('is public and returns only aggregate traffic plus allowlisted general names', async () => {
const result = await appRouter.createCaller(buildContext()).public.getTraffic();
expect(result.history).toHaveLength(2);
expect(result.history[0]).toEqual({
year: 185,
month: 2,
refresh: 30,
online: 5,
date: '2026-07-26 02:50:00',
});
expect(result.history[1]).toMatchObject({
year: 185,
month: 3,
refresh: 12,
online: 2,
});
expect(result.maxRefresh).toBe(30);
expect(result.maxOnline).toBe(5);
expect(result.suspects).toEqual([
{ generalId: null, name: '접속자 총합', refresh: 12, refreshScoreTotal: 21 },
{ generalId: 7, name: '갑', refresh: 9, refreshScoreTotal: 15 },
{ generalId: 8, name: '을', refresh: 3, refreshScoreTotal: 6 },
]);
expect(JSON.stringify(result)).not.toContain('userId');
});
});
+248
View File
@@ -0,0 +1,248 @@
import { describe, expect, it } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js';
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
import { appRouter } from '../src/router.js';
const profile: GameProfile = {
id: 'che',
scenario: 'default',
name: 'che:default',
};
const auth: GameSessionTokenPayload = {
version: 1,
profile: 'che',
issuedAt: '2026-07-26T00:00:00.000Z',
expiresAt: '2026-07-27T00:00:00.000Z',
sessionId: 'ranking-session',
user: {
id: 'request-user-id',
username: 'ranking-user',
displayName: '조회자',
roles: [],
},
sanctions: {},
};
const generalRows = [
{
id: 1,
name: '유비',
nationId: 1,
userId: 'private-user-id-1',
npcState: 0,
picture: '1.jpg',
imageServer: 0,
meta: { ownerName: '공개소유자' },
experience: 1200,
dedication: 900,
horseCode: 'che_명마_15_적토마',
weaponCode: 'None',
bookCode: 'None',
itemCode: 'None',
},
{
id: 2,
name: '빙의관우',
nationId: 1,
userId: 'private-user-id-2',
npcState: 1,
picture: null,
imageServer: 0,
meta: { owner_name: '빙의소유자' },
experience: 1100,
dedication: 800,
horseCode: 'None',
weaponCode: 'None',
bookCode: 'None',
itemCode: 'None',
},
{
id: 3,
name: 'NPC조조',
nationId: 2,
userId: null,
npcState: 2,
picture: null,
imageServer: 0,
meta: {},
experience: 1300,
dedication: 1000,
horseCode: 'None',
weaponCode: 'None',
bookCode: 'None',
itemCode: 'None',
},
] as const;
const buildContext = (options?: {
authenticated?: boolean;
isUnited?: boolean;
includeOwnerDisplayName?: boolean;
}): GameApiContext => {
const db = {
worldState: {
findFirst: async () => ({
meta: { isUnited: options?.isUnited ? 1 : 0 },
config: {
const: {
allItems: {
horse: { che_명마_15_적토마: 2 },
weapon: {},
book: {},
item: {},
},
},
},
}),
},
nation: {
findMany: async () => [
{ id: 1, name: '촉', color: '#006400' },
{ id: 2, name: '위', color: '#8b0000' },
],
},
general: {
findMany: async (args: { where: { npcState: { lt?: number; gte?: number } } }) =>
generalRows.filter((general) =>
args.where.npcState.gte !== undefined
? general.npcState >= args.where.npcState.gte
: general.npcState < (args.where.npcState.lt ?? Number.POSITIVE_INFINITY)
),
},
rankData: {
findMany: async () => [
{ generalId: 1, type: 'firenum', value: 10 },
{ generalId: 2, type: 'firenum', value: 20 },
{ generalId: 3, type: 'firenum', value: 30 },
],
},
auction: {
findMany: async () => [{ targetCode: 'che_명마_15_적토마' }],
},
gameHistory: {
findMany: async () => [
{ season: 3, scenario: 22, scenarioName: '가상모드22' },
{ season: 3, scenario: 22, scenarioName: '가상모드22' },
],
},
hallOfFame: {
findMany: async (args: { where: { type: string } }) =>
args.where.type === 'experience'
? [
{
generalNo: 1,
value: 1200,
aux: {
name: '유비',
ownerName: 'private-hall-user-id',
...(options?.includeOwnerDisplayName ? { ownerDisplayName: '공개소유자' } : {}),
nationName: '촉',
bgColor: '#006400',
fgColor: '#ffffff',
},
},
]
: [],
},
};
const redis = {
get: async () => null,
set: async () => null,
} as unknown as RedisConnector['client'];
return {
db: db as unknown as DatabaseClient,
turnDaemon: new InMemoryTurnDaemonTransport(),
battleSim: new InMemoryBattleSimTransport(),
profile,
auth: options?.authenticated === false ? null : auth,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
redis,
accessTokenStore: new RedisAccessTokenStore(redis, profile.name),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
};
describe('ranking.getBestGeneral', () => {
it('requires a game login even though the ranking is the same for every authenticated user', async () => {
await expect(
appRouter.createCaller(buildContext({ authenticated: false })).ranking.getBestGeneral({ view: 'user' })
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
});
it('keeps possessed generals in the user view and redacts account identifiers before unification', async () => {
const result = await appRouter.createCaller(buildContext({ isUnited: false })).ranking.getBestGeneral({
view: 'user',
});
expect(result.sections[0]?.entries.map((entry) => entry.id)).toEqual([1, 2]);
expect(result.sections[0]?.entries.map((entry) => entry.ownerName)).toEqual([null, null]);
expect(result.sections.find((section) => section.title === '계 략 성 공')?.entries).toEqual([
expect.objectContaining({ id: 2, name: '???', nationName: '???', ownerName: null }),
expect.objectContaining({ id: 1, name: '???', nationName: '???', ownerName: null }),
]);
expect(JSON.stringify(result)).not.toContain('private-user-id');
});
it('uses display names only after unification and preserves configured item copies plus auctions', async () => {
const result = await appRouter.createCaller(buildContext({ isUnited: true })).ranking.getBestGeneral({
view: 'user',
});
expect(result.sections[0]?.entries.map((entry) => entry.ownerName)).toEqual(['공개소유자', '빙의소유자']);
expect(result.uniqueItems.find((section) => section.slot === 'horse')?.entries).toEqual([
expect.objectContaining({
itemKey: 'che_명마_15_적토마',
owner: expect.objectContaining({ id: 1, name: '유비' }),
}),
expect.objectContaining({
itemKey: 'che_명마_15_적토마',
owner: expect.objectContaining({ id: 0, name: '경매중' }),
}),
]);
expect(JSON.stringify(result)).not.toContain('private-user-id');
});
it('separates autonomous NPCs from users and possessed generals', async () => {
const result = await appRouter.createCaller(buildContext()).ranking.getBestGeneral({ view: 'npc' });
expect(result.sections[0]?.entries.map((entry) => entry.id)).toEqual([3]);
});
});
describe('ranking hall of fame', () => {
it('remains public and groups scenario counts', async () => {
const options = await appRouter
.createCaller(buildContext({ authenticated: false }))
.ranking.getHallOfFameOptions();
expect(options).toEqual([
{
season: 3,
scenarios: [{ id: 22, name: '가상모드22', count: 2 }],
},
]);
});
it('returns an explicit display name but never exposes the stored account identifier', async () => {
const result = await appRouter
.createCaller(buildContext({ authenticated: false, includeOwnerDisplayName: true }))
.ranking.getHallOfFame({ season: 3 });
expect(result.sections[0]?.entries[0]?.ownerName).toBe('공개소유자');
expect(JSON.stringify(result)).not.toContain('private-hall-user-id');
const redacted = await appRouter
.createCaller(buildContext({ authenticated: false }))
.ranking.getHallOfFame({ season: 3 });
expect(redacted.sections[0]?.entries[0]?.ownerName).toBeNull();
});
});
+111 -3
View File
@@ -12,6 +12,7 @@ import type {
TournamentState,
} from '../src/tournament/types.js';
import { applyBattle, applyPreBattleStage, settleTournamentOutcome } from '../src/tournament/worker.js';
import { buildBettingPayouts } from '../src/tournament/workerHelpers.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
class MemoryRedis {
@@ -209,6 +210,15 @@ const runTournamentToCompletion = async (options: {
const delayTick = async (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
describe('tournament worker (in-memory)', () => {
it('당첨자가 없으면 레거시와 같이 베팅금을 지급하거나 환불하지 않는다', () => {
expect(
buildBettingPayouts(10, [
{ generalId: 1, targetId: 11, amount: 100 },
{ generalId: 2, targetId: 12, amount: 200 },
])
).toEqual({ payouts: [], total: 300, refundAll: false });
});
it('locks 64 applicants into eight groups of eight', async () => {
const redis = new MemoryRedis();
const store = new TournamentStore(redis, buildTournamentKeys('test-groups'));
@@ -284,11 +294,33 @@ describe('tournament worker (in-memory)', () => {
const sent: TurnDaemonCommand[] = [];
const transport: TurnDaemonTransport = {
sendCommand: async (command) => {
sendCommand: async () => 'unused',
requestCommand: async (command) => {
sent.push(command);
return 'ok';
if (command.type === 'tournamentReward') {
return {
type: 'tournamentReward',
ok: true,
winnerId: command.winnerId,
runnerUpId: command.runnerUpId,
rewarded: 2,
missing: 0,
totalGold: 100,
totalExp: 10,
};
}
if (command.type === 'tournamentBettingPayout') {
return {
type: 'tournamentBettingPayout',
ok: true,
bettingId: command.bettingId,
processed: command.payouts.length,
missing: 0,
totalPayout: command.payouts.reduce((sum, payout) => sum + payout.amount, 0),
};
}
return null;
},
requestCommand: async () => null,
requestStatus: async () => null,
};
@@ -302,6 +334,82 @@ describe('tournament worker (in-memory)', () => {
if (bettingCommand && bettingCommand.type === 'tournamentBettingPayout') {
expect(bettingCommand.payouts).toEqual([{ generalId: 1, amount: 300 }]);
}
expect(await store.getState()).toMatchObject({
rewardSettled: true,
bettingSettled: true,
});
});
it('정산 응답 실패 시 완료 표시를 남기지 않고 성공한 보상만 재시도에서 제외한다', async () => {
const redis = new MemoryRedis();
const store = new TournamentStore(redis, buildTournamentKeys('test-bet-retry'));
const state = createTournamentState({
stage: 0,
auto: false,
winnerId: 10,
bettingId: 124,
rewardSettled: false,
bettingSettled: false,
});
await store.setMatches([{ id: 1, stage: 10, roundIndex: 0, attackerId: 10, defenderId: 11, winnerId: 10 }]);
await store.setBettingEntries([{ generalId: 1, targetId: 10, amount: 100 }]);
await store.setState(state);
let payoutAttempts = 0;
const commands: TurnDaemonCommand[] = [];
const transport: TurnDaemonTransport = {
sendCommand: async () => 'unused',
requestCommand: async (command) => {
commands.push(command);
if (command.type === 'tournamentReward') {
return {
type: 'tournamentReward',
ok: true,
winnerId: command.winnerId,
runnerUpId: command.runnerUpId,
rewarded: 2,
missing: 0,
totalGold: 100,
totalExp: 10,
};
}
if (command.type === 'tournamentBettingPayout') {
payoutAttempts += 1;
if (payoutAttempts === 1) {
return {
type: 'tournamentBettingPayout',
ok: false,
bettingId: command.bettingId,
reason: '일시적 실패',
};
}
return {
type: 'tournamentBettingPayout',
ok: true,
bettingId: command.bettingId,
processed: 1,
missing: 0,
totalPayout: 100,
};
}
return null;
},
requestStatus: async () => null,
};
await expect(settleTournamentOutcome({ store, daemonTransport: transport, state })).rejects.toThrow(
'일시적 실패'
);
const afterFailure = await store.getState();
expect(afterFailure).toMatchObject({ rewardSettled: true, bettingSettled: false });
await settleTournamentOutcome({ store, daemonTransport: transport, state: afterFailure! });
expect(await store.getState()).toMatchObject({ rewardSettled: true, bettingSettled: true });
expect(commands.filter((command) => command.type === 'tournamentReward')).toHaveLength(1);
expect(commands.filter((command) => command.type === 'tournamentBettingPayout')).toHaveLength(2);
expect(
commands.filter((command) => command.type === 'tournamentBettingPayout').map((command) => command.requestId)
).toEqual(['tournament:124:betting-payout', 'tournament:124:betting-payout']);
});
it('자동 오픈 후 참가자 보충(NPC/더미 포함)하고 결승까지 진행된다', async () => {